"use client"; import { useState, useCallback, useMemo, useEffect, useRef } from "react"; import { Modal, Steps, Button, Space, Table, Select, Row, Col, Flex, Progress, Typography, Empty, Tag, Alert, App, theme, } from "antd"; import { ReloadOutlined } from "@ant-design/icons"; import { useMutation } from "@tanstack/react-query"; import { useSubscription } from "@trpc/tanstack-react-query"; import type { BaseVersion, Config, UploadReleaseType } from "@/types"; import type { ParsedProject } from "@/services/project"; import { platforms } from "@/services/publish.schemas"; import type { PlatformResult, ProgressEvent } from "@/services/publish.schemas"; import { Template } from "@/lib/utils/template"; import type { McVersionEntry } from "@/lib/utils/mcVersion"; import { computeVersionRanges } from "@/lib/utils/mcVersion"; import { getModrinthMcVersions, getCurseForgeMcVersions } from "@/services/meta"; import { trpc } from "@/lib/trpc"; import { ChangelogEditor } from "./ChangelogEditor"; const { Text } = Typography; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface PublishVersion { key: string; mc_version: string; artifact: string | null; sources: string | null; mrRange: string; cfRange: string; modrinthVersion: string; modrinthVersionName: string; existsModrinth: boolean; existsCurseforge: boolean; } interface PublishModalProps { open: boolean; /** 配置文件名(configs/ 下的 .json 文件名) */ configFile: string; config: Config; project: ParsedProject; lastVersionEnd: string | null; onClose: () => void; } type ReleaseType = Extract; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function entriesToMap( entries: McVersionEntry[] | null, field: "explicit" | "wildcard", ): Map { const map = new Map(); if (entries) { for (const entry of entries) { map.set(entry.version, entry[field]); } } return map; } const RELEASE_TYPE_LABEL_MAP: Record = { release: "Release", beta: "Beta", alpha: "Alpha" }; const RELEASE_TYPES = Object.entries(RELEASE_TYPE_LABEL_MAP).map(v => ({ value: v[0], label: v[1] })); // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- export function PublishModal({ open, configFile, config, project, lastVersionEnd, onClose, }: PublishModalProps) { const { token } = theme.useToken(); const { message } = App.useApp(); const [currentStep, setCurrentStep] = useState(0); // Version selection const [selectedModrinth, setSelectedModrinth] = useState([]); const [selectedCurseforge, setSelectedCurseforge] = useState([]); // Publish settings const [changelog, setChangelog] = useState(""); const [versionType, setVersionType] = useState( "release", ); // Progress & result const [publishing, setPublishing] = useState(false); const [publishDone, setPublishDone] = useState(false); const [progress, setProgress] = useState< Record< typeof platforms[number], { done: number; total: number; status: ProgressEvent["status"] } > >({ modrinth: { done: 0, total: 0, status: "running" }, curseforge: { done: 0, total: 0, status: "running" }, }); const [publishResult, setPublishResult] = useState<{ success: number; failed: number; errors: Record; } | null>(null); // Platform MC versions — loaded when modal opens const [loadingExisting, setLoadingExisting] = useState(false); const [modrinthMcVersions, setModrinthMcVersions] = useState([]); const [curseforgeMcVersions, setCurseforgeMcVersions] = useState([]); const [existingModrinth, setExistingModrinth] = useState([]); const [existingCurseforge, setExistingCurseforge] = useState([]); const loadExisting = useCallback(async () => { setLoadingExisting(true); try { const [mrVersions, cfVersions] = await Promise.all([ getModrinthMcVersions(), getCurseForgeMcVersions(), ]); setModrinthMcVersions(mrVersions); setCurseforgeMcVersions(cfVersions); // TODO: Fetch existing versions from both platforms via tRPC/Server Action setExistingModrinth([]); setExistingCurseforge([]); } catch (e) { console.error(e); } finally { setLoadingExisting(false); } }, []); // Auto-load MC versions when modal opens useEffect(() => { if (open) { loadExisting(); } }, [open, loadExisting]); // Pre-compute version display values const versionTmpl = useMemo(() => new Template(config.modrinth.version), [config.modrinth.version]); const versionNameTmpl = useMemo(() => new Template(config.modrinth.version_name), [config.modrinth.version_name]); // Compute per-platform version ranges const projectVersions = useMemo( () => project.artifacts.map((a) => a.mc_version), [project], ); const mrRanges = useMemo(() => { if (modrinthMcVersions.length === 0) return null; return computeVersionRanges(projectVersions, modrinthMcVersions, lastVersionEnd ?? undefined); }, [projectVersions, modrinthMcVersions, lastVersionEnd]); const cfRanges = useMemo(() => { if (curseforgeMcVersions.length === 0) return null; return computeVersionRanges(projectVersions, curseforgeMcVersions, lastVersionEnd ?? undefined); }, [projectVersions, curseforgeMcVersions, lastVersionEnd]); // Build per-platform range maps (mc_version → explicit) const mrExplicitMap = useMemo(() => entriesToMap(mrRanges, "explicit"), [mrRanges]); const cfExplicitMap = useMemo(() => entriesToMap(cfRanges, "explicit"), [cfRanges]); // Build publish version rows from project artifacts const publishVersions: PublishVersion[] = useMemo(() => project.artifacts.map((a) => { // Note: version_name template uses Modrinth explicit range for ${mc_version_range} const mrRange = mrExplicitMap.get(a.mc_version) ?? a.mc_version; const tmplValues = { version: project.version, mc_version: a.mc_version, mc_version_range: mrRange, }; return { key: a.mc_version, mc_version: a.mc_version, artifact: a.artifact, sources: a.sources, mrRange: mrExplicitMap.get(a.mc_version) ?? a.mc_version, cfRange: cfExplicitMap.get(a.mc_version) ?? a.mc_version, modrinthVersion: versionTmpl.format(tmplValues), modrinthVersionName: versionNameTmpl.format(tmplValues), existsModrinth: existingModrinth.includes(a.mc_version), existsCurseforge: existingCurseforge.includes(a.mc_version), }; }), [project, versionTmpl, versionNameTmpl, mrExplicitMap, cfExplicitMap, existingModrinth, existingCurseforge], ); // Auto-select all non-existing versions when data first loads const initRef = useRef(false); useEffect(() => { if (open) { initRef.current = false; } }, [open]); useEffect(() => { if (!loadingExisting && publishVersions.length > 0 && !initRef.current) { initRef.current = true; setSelectedModrinth( publishVersions.filter((v) => !v.existsModrinth).map((v) => v.key), ); setSelectedCurseforge( publishVersions.filter((v) => !v.existsCurseforge).map((v) => v.key), ); } }, [loadingExisting, publishVersions]); // --------------- Steps handling --------------- const handleNext = useCallback(() => { setCurrentStep((prev) => Math.min(prev + 1, 2)); }, []); const handlePrev = useCallback(() => { setCurrentStep((prev) => Math.max(prev - 1, 0)); }, []); const handleConfirmPublish = useCallback(() => { // 重置状态并进入进度页;真正发起 mutation 在下方 useSubscription 的 // onStarted 里(保证先连上 SSE 再发布,不漏早期进度事件) setPublishing(true); setPublishDone(false); setPublishResult(null); setProgress({ modrinth: { done: 0, total: selectedModrinth.length, status: "running" }, curseforge: { done: 0, total: selectedCurseforge.length, status: "running", }, }); setCurrentStep(2); }, [selectedModrinth.length, selectedCurseforge.length]); const publishMutation = useMutation( trpc.publish.mutationOptions({ onSuccess: (result) => { let success = 0; let failed = 0; const errors = { modrinth: [], curseforge: [] } as Record< typeof platforms[number], string[] >; for (const platform of platforms) { const r: PlatformResult = result[platform]; success += r.success; failed += r.fail; errors[platform] = r.errors; } setPublishResult({ success, failed, errors }); setPublishDone(true); setPublishing(false); }, onError: (error) => { message.error(`发布请求失败:${error.message}`); setPublishDone(true); setPublishing(false); }, }), ); useSubscription( trpc.progress.subscriptionOptions(undefined, { enabled: publishing, onStarted: () => { // SSE 已连接,现在发起发布不会漏事件 publishMutation.mutate({ configFile, version: project.version, mcVersions: { modrinth: selectedModrinth.map((mc_version) => ({ mc_version })), curseforge: selectedCurseforge.map((mc_version) => ({ mc_version })), }, cutoffMcVersion: lastVersionEnd ?? "", changelog, versionType, }); }, onData: (event) => { setProgress((prev) => ({ ...prev, [event.platform]: { done: event.current, total: event.total, status: event.status, }, })); }, onError: (error) => { console.error("Progress subscription error:", error); }, }), ); const handleClose = useCallback(() => { if (publishing) return; setCurrentStep(0); setSelectedModrinth([]); setSelectedCurseforge([]); setChangelog(""); setVersionType("release"); setPublishResult(null); setPublishDone(false); onClose(); }, [publishing, onClose]); // --------------- Render: Step 1 — Version Selection --------------- const renderStep1 = () => { const modrinthColumns = [ { title: "版本号", dataIndex: "modrinthVersion", key: "modrinthVersion", }, { title: "兼容范围", dataIndex: "mrRange", key: "mr_range", width: 100, render: (val: string) => {val}, }, { title: "版本名称", dataIndex: "modrinthVersionName", key: "modrinthVersionName", }, ]; const curseforgeColumns = [ { title: "MC 版本", dataIndex: "mc_version", key: "mc_version", width: 100, }, { title: "兼容范围", dataIndex: "cfRange", key: "cf_range", width: 100, render: (val: string) => {val}, }, { title: "显示名称", dataIndex: "artifact", key: "artifact", ellipsis: true, }, ]; return (
选择要发布的版本
{loadingExisting ? (
加载中…
) : ( Modrinth {publishVersions.length > 0 ? ( setSelectedModrinth(keys as string[]), getCheckboxProps: (r: PublishVersion) => ({ disabled: r.existsModrinth, }), }} pagination={false} size="small" bordered /> ) : ( )} CurseForge {publishVersions.length > 0 ? (
setSelectedCurseforge(keys as string[]), getCheckboxProps: (r: PublishVersion) => ({ disabled: r.existsCurseforge, }), }} pagination={false} size="small" bordered /> ) : ( )} )} ); }; // --------------- Render: Step 2 — Publish Settings --------------- const renderStep2 = () => { return (
{/* Changelog */}
Changelog (Markdown)
{/* Version type */}
版本类型