diff --git a/docs/frontend.md b/docs/frontend.md index a62fcc3..80a41bf 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -72,7 +72,7 @@ - 任一请求失败则取消后续所有任务 - 点击确认后显示两个进度条(仅显示有发布任务的平台): - `平台名: 已发布数 / 总发布数` -- 全部完成后或失败后,弹出结果 Modal 显示最终状态 +- 全部完成后或失败后,在第 3 步进度页内嵌显示最终状态(Alert) ### ConfigModal diff --git a/docs/workflow.md b/docs/workflow.md index 8349ca4..746852f 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -137,14 +137,14 @@ - CurseForge:构造 multipart/form-data 请求,含 `metadata` JSON + 文件 - 通过 tRPC subscription 推送进度更新 6. **任一请求失败则取消当前平台的后续所有任务**,不影响另一个平台 -7. 全部完成或失败后,弹出结果 Modal 显示最终状态(成功数 / 失败数 + 各平台失败原因列表) +7. 全部完成或失败后,在第 3 步进度页内嵌显示最终状态(成功数 / 失败数 + 各平台失败原因列表) ### 错误处理与可见性 - 单个版本上传失败:记入该平台 `errors`,发出 `failed` 进度事件(进度条变红),该平台后续版本取消; - 平台准备阶段失败(元数据拉取、版本范围计算):记为该平台**全部失败**,同样发出 `failed` 事件,并在服务端终端输出 `console.error`; - mutation 层兜底:任何未被捕获的平台异常记入 `errors` 并输出服务端日志; -- 前端结果 Modal 按平台分行列出所有失败原因。 +- 前端进度页的结果区按平台分行列出所有失败原因。 ## Dry-run(模拟发布) diff --git a/src/components/PublishModal.tsx b/src/components/PublishModal.tsx index d044c27..873a096 100644 --- a/src/components/PublishModal.tsx +++ b/src/components/PublishModal.tsx @@ -22,14 +22,18 @@ import { 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 { 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"; @@ -52,6 +56,26 @@ interface PublishVersion { 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 文件名) */ @@ -138,25 +162,48 @@ export function PublishModal({ 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 [mrVersions, cfVersions] = await Promise.all([ + 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); - // TODO: Fetch existing versions from both platforms via tRPC/Server Action + 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([]); - } catch (e) { - console.error(e); + setModrinthExistingVersions([]); + setCurseforgeExistingFiles([]); } finally { setLoadingExisting(false); } - }, []); + }, [config, project, message]); // Auto-load MC versions when modal opens useEffect(() => { @@ -215,6 +262,103 @@ export function PublishModal({ [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]); + + /** 行底色:待发布且未存在 = 绿色;其余(已存在/已有版本)= 灰色 */ + const rowStyle = useCallback( + (kind: "pending" | "existing", exists: boolean): React.CSSProperties => ({ + background: + kind === "pending" && !exists + ? token.colorSuccessBg + : token.colorFillQuaternary, + }), + [token], + ); + // Auto-select all non-existing versions when data first loads const initRef = useRef(false); useEffect(() => { @@ -236,6 +380,15 @@ export function PublishModal({ // --------------- 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)); }, []); @@ -341,6 +494,7 @@ export function PublishModal({ title: "版本号", dataIndex: "modrinthVersion", key: "modrinthVersion", + width: 150, }, { title: "兼容范围", @@ -361,7 +515,7 @@ export function PublishModal({ title: "MC 版本", dataIndex: "mc_version", key: "mc_version", - width: 100, + width: 80, }, { title: "兼容范围", @@ -407,18 +561,30 @@ export function PublishModal({ Modrinth - {publishVersions.length > 0 ? ( + {modrinthRows.length > 0 ? ( ({ + style: rowStyle( + r.kind, + r.kind === "pending" + ? (r.pending?.existsModrinth ?? false) + : true, + ), + })} rowSelection={{ selectedRowKeys: selectedModrinth, onChange: (keys) => setSelectedModrinth(keys as string[]), - getCheckboxProps: (r: PublishVersion) => ({ - disabled: r.existsModrinth, + getCheckboxProps: (r: ModrinthTableRow) => ({ + disabled: + r.kind === "existing" || + (r.pending?.existsModrinth ?? false), }), }} pagination={false} + scroll={{ y: 480 }} size="small" bordered /> @@ -430,18 +596,30 @@ export function PublishModal({ CurseForge - {publishVersions.length > 0 ? ( + {curseforgeRows.length > 0 ? (
({ + style: rowStyle( + r.kind, + r.kind === "pending" + ? (r.pending?.existsCurseforge ?? false) + : true, + ), + })} rowSelection={{ selectedRowKeys: selectedCurseforge, onChange: (keys) => setSelectedCurseforge(keys as string[]), - getCheckboxProps: (r: PublishVersion) => ({ - disabled: r.existsCurseforge, + getCheckboxProps: (r: CurseforgeTableRow) => ({ + disabled: + r.kind === "existing" || + (r.pending?.existsCurseforge ?? false), }), }} pagination={false} + scroll={{ y: 480 }} size="small" bordered /> @@ -604,7 +782,11 @@ export function PublishModal({ )} {currentStep === 0 ? ( - ) : currentStep === 1 ? ( diff --git a/src/lib/utils/existingVersion.test.ts b/src/lib/utils/existingVersion.test.ts new file mode 100644 index 0000000..fd1ceef --- /dev/null +++ b/src/lib/utils/existingVersion.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { Template } from "./template"; +import { compareParsed, parseExisting } from "./existingVersion"; + +describe("parseExisting", () => { + it("从 version_number 模板反向解析 version 和 mc_version", () => { + const tmpl = new Template("${version}-mc${mc_version}"); + expect(parseExisting(tmpl, "1.2.3-mc1.21")).toEqual({ + version: "1.2.3", + mc_version: "1.21", + }); + }); + + it("从 filename_format 模板反向解析(含字面量前缀)", () => { + const tmpl = new Template("modname-${version}-mc${mc_version}.jar"); + expect(parseExisting(tmpl, "modname-0.9.0-mc1.20.1.jar")).toEqual({ + version: "0.9.0", + mc_version: "1.20.1", + }); + }); + + it("不匹配模板时返回 null", () => { + const tmpl = new Template("${version}-mc${mc_version}"); + expect(parseExisting(tmpl, "random-string")).toEqual({ + version: null, + mc_version: null, + }); + }); + + it("捕获段不是合法 SemVer 时返回 null", () => { + const tmpl = new Template("${version}-mc${mc_version}"); + expect(parseExisting(tmpl, "abc-mc1.21")).toEqual({ + version: null, + mc_version: null, + }); + }); +}); + +describe("compareParsed", () => { + const p = (version: string | null, mc_version: string | null = null) => ({ + version, + mc_version, + }); + + it("mod_version 降序优先", () => { + const list = [p("1.0.0"), p("1.2.0"), p("0.9.0")]; + list.sort(compareParsed); + expect(list.map((x) => x.version)).toEqual(["1.2.0", "1.0.0", "0.9.0"]); + }); + + it("同 mod_version 时 mc_version 降序", () => { + const list = [ + p("1.0.0", "1.20"), + p("1.0.0", "1.21"), + p("1.0.0", "1.19.4"), + ]; + list.sort(compareParsed); + expect(list.map((x) => x.mc_version)).toEqual(["1.21", "1.20", "1.19.4"]); + }); + + it("无法解析的排最后", () => { + const list = [p(null), p("1.0.0", "1.21"), p("0.9.0", "1.20")]; + list.sort(compareParsed); + expect(list[0]!.version).toBe("1.0.0"); + expect(list[1]!.version).toBe("0.9.0"); + expect(list[2]!.version).toBeNull(); + }); +}); diff --git a/src/lib/utils/existingVersion.ts b/src/lib/utils/existingVersion.ts new file mode 100644 index 0000000..61539a3 --- /dev/null +++ b/src/lib/utils/existingVersion.ts @@ -0,0 +1,42 @@ +import { Template } from "./template"; +import { SemVer } from "./templates/semver"; + +/** 用于已有版本排序的反向解析结果 */ +export interface ParsedExisting { + version: string | null; + mc_version: string | null; +} + +/** 反向解析已有版本字符串(Modrinth version_number / CF fileName),失败返回 null */ +export function parseExisting( + tmpl: Template, + concrete: string, +): ParsedExisting { + try { + const parsed = tmpl.parse(concrete, { + version: SemVer.parse, + mc_version: SemVer.parse, + }); + return { + version: parsed.version.format(), + mc_version: parsed.mc_version.format(), + }; + } catch { + return { version: null, mc_version: null }; + } +} + +/** 已有版本排序:mod_version 降序 → mc_version 降序;无法解析的排最后 */ +export function compareParsed(a: ParsedExisting, b: ParsedExisting): number { + if (a.version === null && b.version === null) return 0; + if (a.version === null) return 1; + if (b.version === null) return -1; + + const byVersion = SemVer.compare(b.version, a.version); + if (byVersion !== 0) return byVersion; + + if (a.mc_version === null && b.mc_version === null) return 0; + if (a.mc_version === null) return 1; + if (b.mc_version === null) return -1; + return SemVer.compare(b.mc_version, a.mc_version); +} diff --git a/src/services/versions.ts b/src/services/versions.ts index 6e8e007..4089980 100644 --- a/src/services/versions.ts +++ b/src/services/versions.ts @@ -60,6 +60,10 @@ export interface FileMatch { export interface VersionComparison { modrinth: Record; curseforge: Record; + /** Modrinth 全量已有版本(含未匹配项),用于第一页展示 */ + modrinthVersions: Version[]; + /** CurseForge 全量已有文件(含未匹配项),用于第一页展示 */ + curseforgeFiles: CurseForgeFile[]; } /** @@ -68,7 +72,7 @@ export interface VersionComparison { * @param config 项目配置 * @param version 项目版本号(如 "1.0.0") * @param mc_versions 待发布的 MC 版本列表 - * @returns 按 MC 版本分组的比对结果 + * @returns 按 MC 版本分组的比对结果 + 两平台全量已有版本列表 */ export async function compareVersions( config: Config, @@ -109,5 +113,5 @@ export async function compareVersions( : { exists: false }; } - return { modrinth, curseforge }; + return { modrinth, curseforge, modrinthVersions, curseforgeFiles }; }