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

View File

@ -1,11 +1,14 @@
"use client"; "use client";
import { AntdRegistry } from "@ant-design/nextjs-registry"; import { AntdRegistry } from "@ant-design/nextjs-registry";
import { QueryClientProvider } from "@tanstack/react-query";
import { App, ConfigProvider } from "antd"; import { App, ConfigProvider } from "antd";
import { queryClient } from "@/lib/trpc";
export function Providers({ children }: { children: React.ReactNode }) { export function Providers({ children }: { children: React.ReactNode }) {
return ( return (
<AntdRegistry> <AntdRegistry>
<QueryClientProvider client={queryClient}>
<ConfigProvider <ConfigProvider
theme={{ theme={{
token: { token: {
@ -16,6 +19,7 @@ export function Providers({ children }: { children: React.ReactNode }) {
> >
<App>{children}</App> <App>{children}</App>
</ConfigProvider> </ConfigProvider>
</QueryClientProvider>
</AntdRegistry> </AntdRegistry>
); );
} }

View File

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

View File

@ -15,16 +15,22 @@ import {
Typography, Typography,
Empty, Empty,
Tag, Tag,
Alert,
App,
theme, theme,
} from "antd"; } from "antd";
import { ReloadOutlined } from "@ant-design/icons"; 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, UploadReleaseType } 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 { 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 { getModrinthMcVersions, getCurseForgeMcVersions } from "@/services/meta"; import { getModrinthMcVersions, getCurseForgeMcVersions } from "@/services/meta";
import { trpc } from "@/lib/trpc";
import { ChangelogEditor } from "./ChangelogEditor"; import { ChangelogEditor } from "./ChangelogEditor";
const { Text } = Typography; const { Text } = Typography;
@ -48,6 +54,8 @@ interface PublishVersion {
interface PublishModalProps { interface PublishModalProps {
open: boolean; open: boolean;
/** 配置文件名configs/ 下的 .json 文件名) */
configFile: string;
config: Config; config: Config;
project: ParsedProject; project: ParsedProject;
lastVersionEnd: string | null; lastVersionEnd: string | null;
@ -86,12 +94,14 @@ const RELEASE_TYPES = Object.entries(RELEASE_TYPE_LABEL_MAP).map(v => ({ value:
export function PublishModal({ export function PublishModal({
open, open,
configFile,
config, config,
project, project,
lastVersionEnd, lastVersionEnd,
onClose, onClose,
}: PublishModalProps) { }: PublishModalProps) {
const { token } = theme.useToken(); const { token } = theme.useToken();
const { message } = App.useApp();
const [currentStep, setCurrentStep] = useState(0); const [currentStep, setCurrentStep] = useState(0);
// Version selection // Version selection
@ -108,14 +118,18 @@ export function PublishModal({
const [publishing, setPublishing] = useState(false); const [publishing, setPublishing] = useState(false);
const [publishDone, setPublishDone] = useState(false); const [publishDone, setPublishDone] = useState(false);
const [progress, setProgress] = useState< 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 }, modrinth: { done: 0, total: 0, status: "running" },
curseforge: { done: 0, total: 0 }, curseforge: { done: 0, total: 0, status: "running" },
}); });
const [publishResult, setPublishResult] = useState<{ const [publishResult, setPublishResult] = useState<{
success: number; success: number;
failed: number; failed: number;
errors: Record<typeof platforms[number], string[]>;
} | null>(null); } | null>(null);
// Platform MC versions — loaded when modal opens // Platform MC versions — loaded when modal opens
@ -230,16 +244,82 @@ export function PublishModal({
setCurrentStep((prev) => Math.max(prev - 1, 0)); setCurrentStep((prev) => Math.max(prev - 1, 0));
}, []); }, []);
const handleConfirmPublish = useCallback(async () => { const handleConfirmPublish = useCallback(() => {
// 重置状态并进入进度页;真正发起 mutation 在下方 useSubscription 的
// onStarted 里(保证先连上 SSE 再发布,不漏早期进度事件)
setPublishing(true); setPublishing(true);
setPublishDone(false); setPublishDone(false);
setPublishResult(null); setPublishResult(null);
setProgress({
modrinth: { done: 0, total: selectedModrinth.length, status: "running" },
curseforge: {
done: 0,
total: selectedCurseforge.length,
status: "running",
},
});
setCurrentStep(2); setCurrentStep(2);
// TODO: Call tRPC mutation to publish, update progress & result }, [selectedModrinth.length, selectedCurseforge.length]);
setPublishResult({ success: 0, failed: 0 });
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); setPublishDone(true);
setPublishing(false); 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(() => { const handleClose = useCallback(() => {
if (publishing) return; if (publishing) return;
@ -429,6 +509,11 @@ export function PublishModal({
Math.max(progress.modrinth.total, 1)) * Math.max(progress.modrinth.total, 1)) *
100, 100,
)} )}
status={
progress.modrinth.status === "failed"
? "exception"
: undefined
}
format={() => format={() =>
`${progress.modrinth.done} / ${progress.modrinth.total}` `${progress.modrinth.done} / ${progress.modrinth.total}`
} }
@ -450,6 +535,11 @@ export function PublishModal({
Math.max(progress.curseforge.total, 1)) * Math.max(progress.curseforge.total, 1)) *
100, 100,
)} )}
status={
progress.curseforge.status === "failed"
? "exception"
: undefined
}
format={() => format={() =>
`${progress.curseforge.done} / ${progress.curseforge.total}` `${progress.curseforge.done} / ${progress.curseforge.total}`
} }
@ -462,21 +552,37 @@ export function PublishModal({
{/* Result */} {/* Result */}
{publishDone && publishResult && ( {publishDone && publishResult && (
<div <Alert
style={{ type={publishResult.failed > 0 ? "warning" : "success"}
background: token.colorSuccessBg, title="发布结果"
border: `1px solid ${token.colorSuccessBorder}`, description={
borderRadius: token.borderRadius, <div>
padding: token.padding, <div>
}} {publishResult.success} {" "}
> {publishResult.failed}
<Text strong></Text>
<div style={{ marginTop: token.marginSM }}>
<Text>
{publishResult.success} {publishResult.failed}
</Text>
</div> </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>
);
})}
</div>
}
showIcon
/>
)} )}
</div> </div>
); );

View File

@ -79,18 +79,23 @@ export class CurseForgeClient {
if (isPublishDryRun()) { if (isPublishDryRun()) {
return interceptUpload("CurseForge", "POST", url, body) as Promise<T>; return interceptUpload("CurseForge", "POST", url, body) as Promise<T>;
} }
const res = await fetch(url, { // ⚠️ 真实上传已暂时禁用,防止调试期间误传。
method: "POST", // 恢复方式:删除下面的 throw并取消注释 fetch 代码块。
headers: this.uploadHeaders(),
body,
});
if (!res.ok) {
const reqBody = summarizeFormData(body);
const resBody = await readErrorBody(res);
throw new Error( throw new Error(
`CurseForge Upload POST ${path} failed: ${res.status} ${res.statusText}\n\n--Request--:\n${reqBody}\n\n--Response--:\n${resBody}`, "真实上传已暂时禁用,请设置 PUBLISH_DRY_RUN=1 使用模拟发布",
); );
} // const res = await fetch(url, {
return res.json() as Promise<T>; // 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()) { if (isPublishDryRun()) {
return interceptUpload("Modrinth", "POST", url, body) as Promise<T>; return interceptUpload("Modrinth", "POST", url, body) as Promise<T>;
} }
const res = await fetch(url, { // ⚠️ 真实上传已暂时禁用,防止调试期间误传。
method: "POST", // 恢复方式:删除下面的 throw并取消注释 fetch 代码块。
headers: this.headers(),
body,
});
if (!res.ok) {
const reqBody = summarizeFormData(body);
const resBody = await readErrorBody(res);
throw new Error( throw new Error(
`Modrinth API POST ${path} failed: ${res.status} ${res.statusText}\n\n--Request--:\n${reqBody}\n\n--Response--:\n${resBody}`, "真实上传已暂时禁用,请设置 PUBLISH_DRY_RUN=1 使用模拟发布",
); );
} // const res = await fetch(url, {
return res.json() as Promise<T>; // 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); const versions = await getMinecraftVersions(curseforgeClient);
return versions return versions
.filter((v) => { .filter((v) => {
try {
const sv = SemVer.parse(v.versionString); const sv = SemVer.parse(v.versionString);
return sv.preRelease === null; return sv.preRelease === null;
} catch {
// 正常不该出现;出现了说明上游返回了非标准版本号,跳过并记录
console.warn(
`[meta] CurseForge MC 版本号无法解析,已跳过: "${v.versionString}"`,
);
return false;
}
}) })
.map((v) => v.versionString); .map((v) => v.versionString);
} }

View File

@ -16,7 +16,7 @@ import {
} from "./publish.schemas"; } from "./publish.schemas";
import { publishModrinthVersion } from "./platforms/modrinth"; import { publishModrinthVersion } from "./platforms/modrinth";
import { publishCurseForgeVersion } from "./platforms/curseforge"; 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 { getCurseForgeMcVersions, getModrinthMcVersions } from "./meta";
import { readAsFile } from "@/lib/utils/file"; import { readAsFile } from "@/lib/utils/file";
import { Template } from "@/lib/utils/template"; import { Template } from "@/lib/utils/template";
@ -76,11 +76,30 @@ async function runPlatform(
const publishFn = platformAdaptors[platform].publish; const publishFn = platformAdaptors[platform].publish;
const getGameVersionsFn = platformAdaptors[platform].getGameVersions; const getGameVersionsFn = platformAdaptors[platform].getGameVersions;
const mcVersionEntries = computeVersionRanges( // 平台准备阶段(元数据拉取 + 版本范围计算)。此处异常不在逐版本的
// try/catch 内,必须单独兜底:记为该平台全部失败并发出 failed 事件,
// 否则前端进度条会停在 running 假象里,错误也被吞掉。
let mcVersionEntries: McVersionEntry[];
try {
mcVersionEntries = computeVersionRanges(
mcVersions.map(v => v.mc_version), mcVersions.map(v => v.mc_version),
await getGameVersionsFn(), await getGameVersionsFn(),
ctx.input.cutoffMcVersion 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 primaryFileTemplate = new Template(ctx.config.filename_format);
const sourceFileTemplate = new Template(ctx.config.source_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); const result = await runPlatform(platform, input.mcVersions[platform], ctx);
return {platform, result}; return {platform, result};
} catch (err) { } catch (err) {
console.error(`[publish] ${platform} 执行异常:`, err);
return { return {
platform, platform,
result: { success: 0, fail: 0, errors: [errorMessage(err)] } 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", { for await (const [data] of on(ee, "progress", {
signal: opts.signal, 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;
} }
}), }),
}); });