Wire publish flow to tRPC with live progress
This commit is contained in:
parent
9c7fd31a69
commit
5dd25a3267
@ -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(模拟发布)
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
31
src/lib/trpc.ts
Normal 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,
|
||||
});
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user