Wire publish flow to tRPC with live progress

This commit is contained in:
CPTProgrammer 2026-07-17 19:15:41 +08:00
parent 9c7fd31a69
commit 5dd25a3267
No known key found for this signature in database
9 changed files with 264 additions and 71 deletions

View File

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

View File

@ -1,21 +1,25 @@
"use client";
import { AntdRegistry } from "@ant-design/nextjs-registry";
import { QueryClientProvider } from "@tanstack/react-query";
import { App, ConfigProvider } from "antd";
import { queryClient } from "@/lib/trpc";
export function Providers({ children }: { children: React.ReactNode }) {
return (
<AntdRegistry>
<ConfigProvider
theme={{
token: {
colorPrimary: "#1677FF",
borderRadius: 6,
},
}}
>
<App>{children}</App>
</ConfigProvider>
<QueryClientProvider client={queryClient}>
<ConfigProvider
theme={{
token: {
colorPrimary: "#1677FF",
borderRadius: 6,
},
}}
>
<App>{children}</App>
</ConfigProvider>
</QueryClientProvider>
</AntdRegistry>
);
}

View File

@ -127,9 +127,10 @@ export function MainPage({ initialConfigs }: MainPageProps) {
</Space>
{/* 3. Publish modal */}
{publishOpen && selectedConfig && parsedProject && (
{publishOpen && selectedConfig && parsedProject && selectedConfigFile && (
<PublishModal
open={publishOpen}
configFile={selectedConfigFile}
config={selectedConfig}
project={parsedProject}
lastVersionEnd={lastVersionEnd}

View File

@ -15,16 +15,22 @@ import {
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, UploadReleaseType } 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 { getModrinthMcVersions, getCurseForgeMcVersions } from "@/services/meta";
import { trpc } from "@/lib/trpc";
import { ChangelogEditor } from "./ChangelogEditor";
const { Text } = Typography;
@ -48,6 +54,8 @@ interface PublishVersion {
interface PublishModalProps {
open: boolean;
/** 配置文件名configs/ 下的 .json 文件名) */
configFile: string;
config: Config;
project: ParsedProject;
lastVersionEnd: string | null;
@ -86,12 +94,14 @@ const RELEASE_TYPES = Object.entries(RELEASE_TYPE_LABEL_MAP).map(v => ({ value:
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
@ -108,14 +118,18 @@ export function PublishModal({
const [publishing, setPublishing] = useState(false);
const [publishDone, setPublishDone] = useState(false);
const [progress, setProgress] = useState<
Record<typeof platforms[number], { done: number; total: number }>
Record<
typeof platforms[number],
{ done: number; total: number; status: ProgressEvent["status"] }
>
>({
modrinth: { done: 0, total: 0 },
curseforge: { done: 0, total: 0 },
modrinth: { done: 0, total: 0, status: "running" },
curseforge: { done: 0, total: 0, status: "running" },
});
const [publishResult, setPublishResult] = useState<{
success: number;
failed: number;
errors: Record<typeof platforms[number], string[]>;
} | null>(null);
// Platform MC versions — loaded when modal opens
@ -230,16 +244,82 @@ export function PublishModal({
setCurrentStep((prev) => Math.max(prev - 1, 0));
}, []);
const handleConfirmPublish = useCallback(async () => {
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);
// TODO: Call tRPC mutation to publish, update progress & result
setPublishResult({ success: 0, failed: 0 });
setPublishDone(true);
setPublishing(false);
}, []);
}, [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;
@ -429,6 +509,11 @@ export function PublishModal({
Math.max(progress.modrinth.total, 1)) *
100,
)}
status={
progress.modrinth.status === "failed"
? "exception"
: undefined
}
format={() =>
`${progress.modrinth.done} / ${progress.modrinth.total}`
}
@ -450,6 +535,11 @@ export function PublishModal({
Math.max(progress.curseforge.total, 1)) *
100,
)}
status={
progress.curseforge.status === "failed"
? "exception"
: undefined
}
format={() =>
`${progress.curseforge.done} / ${progress.curseforge.total}`
}
@ -462,21 +552,37 @@ export function PublishModal({
{/* Result */}
{publishDone && publishResult && (
<div
style={{
background: token.colorSuccessBg,
border: `1px solid ${token.colorSuccessBorder}`,
borderRadius: token.borderRadius,
padding: token.padding,
}}
>
<Text strong></Text>
<div style={{ marginTop: token.marginSM }}>
<Text>
{publishResult.success} {publishResult.failed}
</Text>
</div>
</div>
<Alert
type={publishResult.failed > 0 ? "warning" : "success"}
title="发布结果"
description={
<div>
<div>
{publishResult.success} {" "}
{publishResult.failed}
</div>
{platforms.map((platform) => {
const errors = publishResult.errors[platform];
if (errors.length === 0) return null;
const label =
platform === "modrinth" ? "Modrinth" : "CurseForge";
return (
<div key={platform} style={{ marginTop: token.marginSM }}>
<Text strong>{label}:</Text>
<ul style={{ margin: "4px 0 0", paddingLeft: 20 }}>
{errors.map((e, i) => (
<li key={i}>
<Text type="danger">{e}</Text>
</li>
))}
</ul>
</div>
);
})}
</div>
}
showIcon
/>
)}
</div>
);

View File

@ -79,18 +79,23 @@ export class CurseForgeClient {
if (isPublishDryRun()) {
return interceptUpload("CurseForge", "POST", url, body) as Promise<T>;
}
const res = await fetch(url, {
method: "POST",
headers: this.uploadHeaders(),
body,
});
if (!res.ok) {
const reqBody = summarizeFormData(body);
const resBody = await readErrorBody(res);
throw new Error(
`CurseForge Upload POST ${path} failed: ${res.status} ${res.statusText}\n\n--Request--:\n${reqBody}\n\n--Response--:\n${resBody}`,
);
}
return res.json() as Promise<T>;
// ⚠️ 真实上传已暂时禁用,防止调试期间误传。
// 恢复方式:删除下面的 throw并取消注释 fetch 代码块。
throw new Error(
"真实上传已暂时禁用,请设置 PUBLISH_DRY_RUN=1 使用模拟发布",
);
// const res = await fetch(url, {
// method: "POST",
// headers: this.uploadHeaders(),
// body,
// });
// if (!res.ok) {
// const reqBody = summarizeFormData(body);
// const resBody = await readErrorBody(res);
// throw new Error(
// `CurseForge Upload POST ${path} failed: ${res.status} ${res.statusText}\n\n--Request--:\n${reqBody}\n\n--Response--:\n${resBody}`,
// );
// }
// return res.json() as Promise<T>;
}
}

View File

@ -46,18 +46,23 @@ export class ModrinthClient {
if (isPublishDryRun()) {
return interceptUpload("Modrinth", "POST", url, body) as Promise<T>;
}
const res = await fetch(url, {
method: "POST",
headers: this.headers(),
body,
});
if (!res.ok) {
const reqBody = summarizeFormData(body);
const resBody = await readErrorBody(res);
throw new Error(
`Modrinth API POST ${path} failed: ${res.status} ${res.statusText}\n\n--Request--:\n${reqBody}\n\n--Response--:\n${resBody}`,
);
}
return res.json() as Promise<T>;
// ⚠️ 真实上传已暂时禁用,防止调试期间误传。
// 恢复方式:删除下面的 throw并取消注释 fetch 代码块。
throw new Error(
"真实上传已暂时禁用,请设置 PUBLISH_DRY_RUN=1 使用模拟发布",
);
// const res = await fetch(url, {
// method: "POST",
// headers: this.headers(),
// body,
// });
// if (!res.ok) {
// const reqBody = summarizeFormData(body);
// const resBody = await readErrorBody(res);
// throw new Error(
// `Modrinth API POST ${path} failed: ${res.status} ${res.statusText}\n\n--Request--:\n${reqBody}\n\n--Response--:\n${resBody}`,
// );
// }
// return res.json() as Promise<T>;
}
}

31
src/lib/trpc.ts Normal file
View File

@ -0,0 +1,31 @@
import "client-only";
import { QueryClient } from "@tanstack/react-query";
import {
createTRPCClient,
httpBatchLink,
httpSubscriptionLink,
splitLink,
} from "@trpc/client";
import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query";
import type { AppRouter } from "@/services/publish";
const url = "/api/publish";
const trpcClient = createTRPCClient<AppRouter>({
links: [
splitLink({
// subscription 走 SSE其余走普通 HTTP batch
condition: (op) => op.type === "subscription",
true: httpSubscriptionLink({ url }),
false: httpBatchLink({ url }),
}),
],
});
export const queryClient = new QueryClient();
export const trpc = createTRPCOptionsProxy<AppRouter>({
client: trpcClient,
queryClient,
});

View File

@ -191,8 +191,16 @@ export async function getCurseForgeMcVersions(): Promise<string[]> {
const versions = await getMinecraftVersions(curseforgeClient);
return versions
.filter((v) => {
const sv = SemVer.parse(v.versionString);
return sv.preRelease === null;
try {
const sv = SemVer.parse(v.versionString);
return sv.preRelease === null;
} catch {
// 正常不该出现;出现了说明上游返回了非标准版本号,跳过并记录
console.warn(
`[meta] CurseForge MC 版本号无法解析,已跳过: "${v.versionString}"`,
);
return false;
}
})
.map((v) => v.versionString);
}

View File

@ -16,7 +16,7 @@ import {
} from "./publish.schemas";
import { publishModrinthVersion } from "./platforms/modrinth";
import { publishCurseForgeVersion } from "./platforms/curseforge";
import { computeVersionRanges } from "@/lib/utils/mcVersion";
import { computeVersionRanges, type McVersionEntry } from "@/lib/utils/mcVersion";
import { getCurseForgeMcVersions, getModrinthMcVersions } from "./meta";
import { readAsFile } from "@/lib/utils/file";
import { Template } from "@/lib/utils/template";
@ -76,11 +76,30 @@ async function runPlatform(
const publishFn = platformAdaptors[platform].publish;
const getGameVersionsFn = platformAdaptors[platform].getGameVersions;
const mcVersionEntries = computeVersionRanges(
mcVersions.map(v => v.mc_version),
await getGameVersionsFn(),
ctx.input.cutoffMcVersion
);
// 平台准备阶段(元数据拉取 + 版本范围计算)。此处异常不在逐版本的
// try/catch 内,必须单独兜底:记为该平台全部失败并发出 failed 事件,
// 否则前端进度条会停在 running 假象里,错误也被吞掉。
let mcVersionEntries: McVersionEntry[];
try {
mcVersionEntries = computeVersionRanges(
mcVersions.map(v => v.mc_version),
await getGameVersionsFn(),
ctx.input.cutoffMcVersion
);
} catch (err) {
const msg = errorMessage(err);
console.error(`[publish] ${platform} 准备阶段失败:`, err);
result.fail = total;
result.errors.push(msg);
ee.emit("progress", {
platform,
current: 0,
total,
status: "failed",
errors: result.errors,
} satisfies ProgressEvent);
return result;
}
const primaryFileTemplate = new Template(ctx.config.filename_format);
const sourceFileTemplate = new Template(ctx.config.source_filename_format);
@ -142,6 +161,7 @@ export const appRouter = t.router({
const result = await runPlatform(platform, input.mcVersions[platform], ctx);
return {platform, result};
} catch (err) {
console.error(`[publish] ${platform} 执行异常:`, err);
return {
platform,
result: { success: 0, fail: 0, errors: [errorMessage(err)] }
@ -156,7 +176,13 @@ export const appRouter = t.router({
for await (const [data] of on(ee, "progress", {
signal: opts.signal,
})) {
yield ProgressEventSchema.parse(data);
const parsed = ProgressEventSchema.safeParse(data);
if (!parsed.success) {
// 坏事件不应中断订阅(客户端没有重连机制),跳过并记录
console.error("[publish] invalid progress event:", parsed.error);
continue;
}
yield parsed.data;
}
}),
});