Show existing platform versions in publish modal
This commit is contained in:
parent
e5de2f5dbd
commit
619abfb0c5
@ -72,7 +72,7 @@
|
||||
- 任一请求失败则取消后续所有任务
|
||||
- 点击确认后显示两个进度条(仅显示有发布任务的平台):
|
||||
- `平台名: 已发布数 / 总发布数`
|
||||
- 全部完成后或失败后,弹出结果 Modal 显示最终状态
|
||||
- 全部完成后或失败后,在第 3 步进度页内嵌显示最终状态(Alert)
|
||||
|
||||
### ConfigModal
|
||||
|
||||
|
||||
@ -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(模拟发布)
|
||||
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
68
src/lib/utils/existingVersion.test.ts
Normal file
68
src/lib/utils/existingVersion.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
42
src/lib/utils/existingVersion.ts
Normal file
42
src/lib/utils/existingVersion.ts
Normal 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);
|
||||
}
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user