"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, CurseForgeFile, UploadReleaseType, Version } 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 { SemVer } from "@/lib/utils/templates/semver"; import { getModrinthMcVersions, getCurseForgeMcVersions } from "@/services/meta"; import { compareVersions } from "@/services/versions"; import { errorMessage } from "@/lib/utils/error"; import { compareParsed, parseExisting } from "@/lib/utils/existingVersion"; 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; } /** Modrinth 表格行:待发布(kind="pending")+ 平台已有版本(kind="existing") */ interface ModrinthTableRow { key: string; kind: "pending" | "existing"; pending: PublishVersion | null; modrinthVersion: string; mrRange: string; modrinthVersionName: string; } /** CurseForge 表格行 */ interface CurseforgeTableRow { key: string; kind: "pending" | "existing"; pending: PublishVersion | null; mc_version: string; cfRange: string; artifact: string | null; } 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 [modrinthExistingVersions, setModrinthExistingVersions] = useState< Version[] >([]); const [curseforgeExistingFiles, setCurseforgeExistingFiles] = useState< CurseForgeFile[] >([]); const loadExisting = useCallback(async () => { setLoadingExisting(true); try { const mcVersions = project.artifacts.map((a) => a.mc_version); // 元数据列表和已有版本比对同属第一页数据,任一失败都不允许进入下一步, // 否则无法区分"未发布"和"没查到",有重复发布的风险 const [mrVersions, cfVersions, comparison] = await Promise.all([ getModrinthMcVersions(), getCurseForgeMcVersions(), compareVersions(config, project.version, mcVersions), ]); setModrinthMcVersions(mrVersions); setCurseforgeMcVersions(cfVersions); setExistingModrinth( mcVersions.filter((v) => comparison.modrinth[v]?.exists), ); setExistingCurseforge( mcVersions.filter((v) => comparison.curseforge[v]?.exists), ); setModrinthExistingVersions(comparison.modrinthVersions); setCurseforgeExistingFiles(comparison.curseforgeFiles); } catch (e) { message.error( `加载已有版本失败,请检查网络后点击“刷新已有版本”重试:${errorMessage(e)}`, ); // 清空比对数据,已有版本状态未知时禁用"下一步" setExistingModrinth([]); setExistingCurseforge([]); setModrinthExistingVersions([]); setCurseforgeExistingFiles([]); } finally { setLoadingExisting(false); } }, [config, project, message]); // 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], ); // ── 第一页表格数据源:待发布行(上)+ 平台已有版本行(下,灰色禁用)── // 与待发布版本匹配的已有对象不再重复出现在已有版本区 const matchedMrNames = useMemo( () => new Set( publishVersions .filter((v) => v.existsModrinth) .map((v) => v.modrinthVersion), ), [publishVersions], ); const modrinthRows: ModrinthTableRow[] = useMemo(() => { const pending: ModrinthTableRow[] = publishVersions.map((v) => ({ key: `pending-${v.key}`, kind: "pending", pending: v, modrinthVersion: v.modrinthVersion, mrRange: v.mrRange, modrinthVersionName: v.modrinthVersionName, })); const existingTmpl = new Template(config.modrinth.version); const existing = modrinthExistingVersions .filter((v) => !matchedMrNames.has(v.version_number)) .map((v) => ({ row: { key: `existing-mr-${v.version_number}`, kind: "existing" as const, pending: null, modrinthVersion: v.version_number, mrRange: "", modrinthVersionName: v.name, }, parsed: parseExisting(existingTmpl, v.version_number), })) .sort((a, b) => compareParsed(a.parsed, b.parsed)) .map((e) => e.row); return [...pending, ...existing]; }, [publishVersions, modrinthExistingVersions, matchedMrNames, config.modrinth.version]); const matchedCfFileNames = useMemo( () => new Set( publishVersions .filter((v) => v.existsCurseforge && v.artifact) .map((v) => v.artifact as string), ), [publishVersions], ); const curseforgeRows: CurseforgeTableRow[] = useMemo(() => { const pending: CurseforgeTableRow[] = publishVersions.map((v) => ({ key: `pending-${v.key}`, kind: "pending", pending: v, mc_version: v.mc_version, cfRange: v.cfRange, artifact: v.artifact, })); const filenameTmpl = new Template(config.filename_format); const existing = curseforgeExistingFiles .filter((f) => !matchedCfFileNames.has(f.fileName)) .map((f) => { const parsed = parseExisting(filenameTmpl, f.fileName); return { row: { key: `existing-cf-${f.fileName}`, kind: "existing" as const, pending: null, mc_version: parsed.mc_version ?? "—", cfRange: "", artifact: f.displayName || f.fileName, }, parsed, }; }) .sort((a, b) => compareParsed(a.parsed, b.parsed)) .map((e) => e.row); return [...pending, ...existing]; }, [publishVersions, curseforgeExistingFiles, matchedCfFileNames, config.filename_format]); /** 新增版本行 className;已有版本行用默认样式 */ const rowClassName = useCallback( (kind: "pending" | "existing", exists: boolean) => kind === "pending" && !exists ? "row-new" : "", [], ); // 注入 antd 绿色系 token 作为 CSS 变量(供 globals.css 的 .row-new 规则使用)。 // 三个过渡色用 color-mix 在 green-1 / green-2 之间混出半步色阶: // hover = 25%,选中 = 50%,选中+hover = 75% const newRowCssVars = { "--row-new-bg": token.colorSuccessBg, "--row-new-text": token.colorSuccessText, "--row-new-bg-hover": `color-mix(in srgb, ${token.colorSuccessBg} 75%, ${token.colorSuccessBgHover} 25%)`, "--row-new-bg-active": `color-mix(in srgb, ${token.colorSuccessBg} 50%, ${token.colorSuccessBgHover} 50%)`, "--row-new-bg-active-hover": `color-mix(in srgb, ${token.colorSuccessBg} 25%, ${token.colorSuccessBgHover} 75%)`, "--row-new-text-active": token.colorSuccessTextActive, } as React.CSSProperties; // 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 --------------- // 进入第二页的前置条件: // 1. 数据加载完成且成功(元数据为空说明加载失败或未加载,已有版本状态未知); // 2. 至少勾选一个待发布版本。 const canProceed = !loadingExisting && modrinthMcVersions.length > 0 && curseforgeMcVersions.length > 0 && (selectedModrinth.length > 0 || selectedCurseforge.length > 0); 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", width: 150, }, { 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: 80, }, { title: "兼容范围", dataIndex: "cfRange", key: "cf_range", width: 100, render: (val: string) => {val}, }, { title: "显示名称", dataIndex: "artifact", key: "artifact", ellipsis: true, }, ]; return (
选择要发布的版本
{loadingExisting ? (
加载中…
) : ( Modrinth {modrinthRows.length > 0 ? ( rowClassName( r.kind, r.kind === "pending" ? (r.pending?.existsModrinth ?? false) : true, ) } rowSelection={{ selectedRowKeys: selectedModrinth, onChange: (keys) => setSelectedModrinth(keys as string[]), getCheckboxProps: (r: ModrinthTableRow) => ({ disabled: r.kind === "existing" || (r.pending?.existsModrinth ?? false), }), }} pagination={false} scroll={{ y: 480 }} size="small" bordered /> ) : ( )} CurseForge {curseforgeRows.length > 0 ? (
rowClassName( r.kind, r.kind === "pending" ? (r.pending?.existsCurseforge ?? false) : true, ) } rowSelection={{ selectedRowKeys: selectedCurseforge, onChange: (keys) => setSelectedCurseforge(keys as string[]), getCheckboxProps: (r: CurseforgeTableRow) => ({ disabled: r.kind === "existing" || (r.pending?.existsCurseforge ?? false), }), }} pagination={false} scroll={{ y: 480 }} size="small" bordered /> ) : ( )} )} ); }; // --------------- Render: Step 2 — Publish Settings --------------- const renderStep2 = () => { return (
{/* Changelog */}
Changelog (Markdown)
{/* Version type */}
版本类型