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

View File

@ -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模拟发布

View File

@ -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<string[]>([]);
const [existingModrinth, setExistingModrinth] = useState<string[]>([]);
const [existingCurseforge, setExistingCurseforge] = useState<string[]>([]);
// 两平台全量已有版本(用于第一页下方灰色行展示)
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({
<Text strong style={{ display: "block", marginBottom: token.marginXS }}>
Modrinth
</Text>
{publishVersions.length > 0 ? (
{modrinthRows.length > 0 ? (
<Table
dataSource={publishVersions}
dataSource={modrinthRows}
columns={modrinthColumns}
rowKey="key"
onRow={(r) => ({
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({
<Text strong style={{ display: "block", marginBottom: token.marginXS }}>
CurseForge
</Text>
{publishVersions.length > 0 ? (
{curseforgeRows.length > 0 ? (
<Table
dataSource={publishVersions}
dataSource={curseforgeRows}
columns={curseforgeColumns}
rowKey="key"
onRow={(r) => ({
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({
<Button onClick={handlePrev}></Button>
)}
{currentStep === 0 ? (
<Button type="primary" onClick={handleNext}>
<Button
type="primary"
onClick={handleNext}
disabled={!canProceed}
>
</Button>
) : 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 {
modrinth: Record<string, VersionMatch>;
curseforge: Record<string, FileMatch>;
/** 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 };
}