Show existing platform versions in publish modal

This commit is contained in:
CPTProgrammer 2026-07-18 10:04:16 +08:00
parent e5de2f5dbd
commit 619abfb0c5
No known key found for this signature in database
6 changed files with 317 additions and 21 deletions

View File

@ -72,7 +72,7 @@
- 任一请求失败则取消后续所有任务 - 任一请求失败则取消后续所有任务
- 点击确认后显示两个进度条(仅显示有发布任务的平台): - 点击确认后显示两个进度条(仅显示有发布任务的平台):
- `平台名: 已发布数 / 总发布数` - `平台名: 已发布数 / 总发布数`
- 全部完成后或失败后,弹出结果 Modal 显示最终状态 - 全部完成后或失败后,在第 3 步进度页内嵌显示最终状态Alert
### ConfigModal ### ConfigModal

View File

@ -137,14 +137,14 @@
- CurseForge构造 multipart/form-data 请求,含 `metadata` JSON + 文件 - CurseForge构造 multipart/form-data 请求,含 `metadata` JSON + 文件
- 通过 tRPC subscription 推送进度更新 - 通过 tRPC subscription 推送进度更新
6. **任一请求失败则取消当前平台的后续所有任务**,不影响另一个平台 6. **任一请求失败则取消当前平台的后续所有任务**,不影响另一个平台
7. 全部完成或失败后,弹出结果 Modal 显示最终状态(成功数 / 失败数 + 各平台失败原因列表) 7. 全部完成或失败后,在第 3 步进度页内嵌显示最终状态(成功数 / 失败数 + 各平台失败原因列表)
### 错误处理与可见性 ### 错误处理与可见性
- 单个版本上传失败:记入该平台 `errors`,发出 `failed` 进度事件(进度条变红),该平台后续版本取消; - 单个版本上传失败:记入该平台 `errors`,发出 `failed` 进度事件(进度条变红),该平台后续版本取消;
- 平台准备阶段失败(元数据拉取、版本范围计算):记为该平台**全部失败**,同样发出 `failed` 事件,并在服务端终端输出 `console.error` - 平台准备阶段失败(元数据拉取、版本范围计算):记为该平台**全部失败**,同样发出 `failed` 事件,并在服务端终端输出 `console.error`
- mutation 层兜底:任何未被捕获的平台异常记入 `errors` 并输出服务端日志; - mutation 层兜底:任何未被捕获的平台异常记入 `errors` 并输出服务端日志;
- 前端结果 Modal 按平台分行列出所有失败原因。 - 前端进度页的结果区按平台分行列出所有失败原因。
## Dry-run模拟发布 ## Dry-run模拟发布

View File

@ -22,14 +22,18 @@ import {
import { ReloadOutlined } from "@ant-design/icons"; import { ReloadOutlined } from "@ant-design/icons";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { useSubscription } from "@trpc/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 type { ParsedProject } from "@/services/project";
import { platforms } from "@/services/publish.schemas"; import { platforms } from "@/services/publish.schemas";
import type { PlatformResult, ProgressEvent } from "@/services/publish.schemas"; import type { PlatformResult, ProgressEvent } from "@/services/publish.schemas";
import { Template } from "@/lib/utils/template"; import { Template } from "@/lib/utils/template";
import type { McVersionEntry } from "@/lib/utils/mcVersion"; import type { McVersionEntry } from "@/lib/utils/mcVersion";
import { computeVersionRanges } from "@/lib/utils/mcVersion"; import { computeVersionRanges } from "@/lib/utils/mcVersion";
import { SemVer } from "@/lib/utils/templates/semver";
import { getModrinthMcVersions, getCurseForgeMcVersions } from "@/services/meta"; 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 { trpc } from "@/lib/trpc";
import { ChangelogEditor } from "./ChangelogEditor"; import { ChangelogEditor } from "./ChangelogEditor";
@ -52,6 +56,26 @@ interface PublishVersion {
existsCurseforge: 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 { interface PublishModalProps {
open: boolean; open: boolean;
/** 配置文件名configs/ 下的 .json 文件名) */ /** 配置文件名configs/ 下的 .json 文件名) */
@ -138,25 +162,48 @@ export function PublishModal({
const [curseforgeMcVersions, setCurseforgeMcVersions] = useState<string[]>([]); const [curseforgeMcVersions, setCurseforgeMcVersions] = useState<string[]>([]);
const [existingModrinth, setExistingModrinth] = useState<string[]>([]); const [existingModrinth, setExistingModrinth] = useState<string[]>([]);
const [existingCurseforge, setExistingCurseforge] = useState<string[]>([]); const [existingCurseforge, setExistingCurseforge] = useState<string[]>([]);
// 两平台全量已有版本(用于第一页下方灰色行展示)
const [modrinthExistingVersions, setModrinthExistingVersions] = useState<
Version[]
>([]);
const [curseforgeExistingFiles, setCurseforgeExistingFiles] = useState<
CurseForgeFile[]
>([]);
const loadExisting = useCallback(async () => { const loadExisting = useCallback(async () => {
setLoadingExisting(true); setLoadingExisting(true);
try { try {
const [mrVersions, cfVersions] = await Promise.all([ const mcVersions = project.artifacts.map((a) => a.mc_version);
// 元数据列表和已有版本比对同属第一页数据,任一失败都不允许进入下一步,
// 否则无法区分"未发布"和"没查到",有重复发布的风险
const [mrVersions, cfVersions, comparison] = await Promise.all([
getModrinthMcVersions(), getModrinthMcVersions(),
getCurseForgeMcVersions(), getCurseForgeMcVersions(),
compareVersions(config, project.version, mcVersions),
]); ]);
setModrinthMcVersions(mrVersions); setModrinthMcVersions(mrVersions);
setCurseforgeMcVersions(cfVersions); 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([]); setExistingModrinth([]);
setExistingCurseforge([]); setExistingCurseforge([]);
} catch (e) { setModrinthExistingVersions([]);
console.error(e); setCurseforgeExistingFiles([]);
} finally { } finally {
setLoadingExisting(false); setLoadingExisting(false);
} }
}, []); }, [config, project, message]);
// Auto-load MC versions when modal opens // Auto-load MC versions when modal opens
useEffect(() => { useEffect(() => {
@ -215,6 +262,103 @@ export function PublishModal({
[project, versionTmpl, versionNameTmpl, mrExplicitMap, cfExplicitMap, existingModrinth, existingCurseforge], [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 // Auto-select all non-existing versions when data first loads
const initRef = useRef(false); const initRef = useRef(false);
useEffect(() => { useEffect(() => {
@ -236,6 +380,15 @@ export function PublishModal({
// --------------- Steps handling --------------- // --------------- Steps handling ---------------
// 进入第二页的前置条件:
// 1. 数据加载完成且成功(元数据为空说明加载失败或未加载,已有版本状态未知);
// 2. 至少勾选一个待发布版本。
const canProceed =
!loadingExisting &&
modrinthMcVersions.length > 0 &&
curseforgeMcVersions.length > 0 &&
(selectedModrinth.length > 0 || selectedCurseforge.length > 0);
const handleNext = useCallback(() => { const handleNext = useCallback(() => {
setCurrentStep((prev) => Math.min(prev + 1, 2)); setCurrentStep((prev) => Math.min(prev + 1, 2));
}, []); }, []);
@ -341,6 +494,7 @@ export function PublishModal({
title: "版本号", title: "版本号",
dataIndex: "modrinthVersion", dataIndex: "modrinthVersion",
key: "modrinthVersion", key: "modrinthVersion",
width: 150,
}, },
{ {
title: "兼容范围", title: "兼容范围",
@ -361,7 +515,7 @@ export function PublishModal({
title: "MC 版本", title: "MC 版本",
dataIndex: "mc_version", dataIndex: "mc_version",
key: "mc_version", key: "mc_version",
width: 100, width: 80,
}, },
{ {
title: "兼容范围", title: "兼容范围",
@ -407,18 +561,30 @@ export function PublishModal({
<Text strong style={{ display: "block", marginBottom: token.marginXS }}> <Text strong style={{ display: "block", marginBottom: token.marginXS }}>
Modrinth Modrinth
</Text> </Text>
{publishVersions.length > 0 ? ( {modrinthRows.length > 0 ? (
<Table <Table
dataSource={publishVersions} dataSource={modrinthRows}
columns={modrinthColumns} columns={modrinthColumns}
rowKey="key"
onRow={(r) => ({
style: rowStyle(
r.kind,
r.kind === "pending"
? (r.pending?.existsModrinth ?? false)
: true,
),
})}
rowSelection={{ rowSelection={{
selectedRowKeys: selectedModrinth, selectedRowKeys: selectedModrinth,
onChange: (keys) => setSelectedModrinth(keys as string[]), onChange: (keys) => setSelectedModrinth(keys as string[]),
getCheckboxProps: (r: PublishVersion) => ({ getCheckboxProps: (r: ModrinthTableRow) => ({
disabled: r.existsModrinth, disabled:
r.kind === "existing" ||
(r.pending?.existsModrinth ?? false),
}), }),
}} }}
pagination={false} pagination={false}
scroll={{ y: 480 }}
size="small" size="small"
bordered bordered
/> />
@ -430,18 +596,30 @@ export function PublishModal({
<Text strong style={{ display: "block", marginBottom: token.marginXS }}> <Text strong style={{ display: "block", marginBottom: token.marginXS }}>
CurseForge CurseForge
</Text> </Text>
{publishVersions.length > 0 ? ( {curseforgeRows.length > 0 ? (
<Table <Table
dataSource={publishVersions} dataSource={curseforgeRows}
columns={curseforgeColumns} columns={curseforgeColumns}
rowKey="key"
onRow={(r) => ({
style: rowStyle(
r.kind,
r.kind === "pending"
? (r.pending?.existsCurseforge ?? false)
: true,
),
})}
rowSelection={{ rowSelection={{
selectedRowKeys: selectedCurseforge, selectedRowKeys: selectedCurseforge,
onChange: (keys) => setSelectedCurseforge(keys as string[]), onChange: (keys) => setSelectedCurseforge(keys as string[]),
getCheckboxProps: (r: PublishVersion) => ({ getCheckboxProps: (r: CurseforgeTableRow) => ({
disabled: r.existsCurseforge, disabled:
r.kind === "existing" ||
(r.pending?.existsCurseforge ?? false),
}), }),
}} }}
pagination={false} pagination={false}
scroll={{ y: 480 }}
size="small" size="small"
bordered bordered
/> />
@ -604,7 +782,11 @@ export function PublishModal({
<Button onClick={handlePrev}></Button> <Button onClick={handlePrev}></Button>
)} )}
{currentStep === 0 ? ( {currentStep === 0 ? (
<Button type="primary" onClick={handleNext}> <Button
type="primary"
onClick={handleNext}
disabled={!canProceed}
>
</Button> </Button>
) : currentStep === 1 ? ( ) : currentStep === 1 ? (

View File

@ -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();
});
});

View File

@ -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);
}

View File

@ -60,6 +60,10 @@ export interface FileMatch {
export interface VersionComparison { export interface VersionComparison {
modrinth: Record<string, VersionMatch>; modrinth: Record<string, VersionMatch>;
curseforge: Record<string, FileMatch>; curseforge: Record<string, FileMatch>;
/** Modrinth 全量已有版本(含未匹配项),用于第一页展示 */
modrinthVersions: Version[];
/** CurseForge 全量已有文件(含未匹配项),用于第一页展示 */
curseforgeFiles: CurseForgeFile[];
} }
/** /**
@ -68,7 +72,7 @@ export interface VersionComparison {
* @param config * @param config
* @param version "1.0.0" * @param version "1.0.0"
* @param mc_versions MC * @param mc_versions MC
* @returns MC * @returns MC +
*/ */
export async function compareVersions( export async function compareVersions(
config: Config, config: Config,
@ -109,5 +113,5 @@ export async function compareVersions(
: { exists: false }; : { exists: false };
} }
return { modrinth, curseforge }; return { modrinth, curseforge, modrinthVersions, curseforgeFiles };
} }