diff --git a/docs/workflow.md b/docs/workflow.md index a9f3d42..8349ca4 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -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(模拟发布) diff --git a/src/app/providers.tsx b/src/app/providers.tsx index e0cdefd..d57d549 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -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 ( - - {children} - + + + {children} + + ); } diff --git a/src/components/MainPage.tsx b/src/components/MainPage.tsx index 907bffe..1499b0e 100644 --- a/src/components/MainPage.tsx +++ b/src/components/MainPage.tsx @@ -127,9 +127,10 @@ export function MainPage({ initialConfigs }: MainPageProps) { {/* 3. Publish modal */} - {publishOpen && selectedConfig && parsedProject && ( + {publishOpen && selectedConfig && parsedProject && selectedConfigFile && ( ({ 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 + 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; } | 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 && ( -
- 发布结果 -
- - 成功 {publishResult.success} 个,失败 {publishResult.failed} 个 - -
-
+ 0 ? "warning" : "success"} + title="发布结果" + description={ +
+
+ 成功 {publishResult.success} 个,失败{" "} + {publishResult.failed} 个 +
+ {platforms.map((platform) => { + const errors = publishResult.errors[platform]; + if (errors.length === 0) return null; + const label = + platform === "modrinth" ? "Modrinth" : "CurseForge"; + return ( +
+ {label}: +
    + {errors.map((e, i) => ( +
  • + {e} +
  • + ))} +
+
+ ); + })} +
+ } + showIcon + /> )} ); diff --git a/src/lib/curseforge/client.ts b/src/lib/curseforge/client.ts index 8c4a7dc..917f750 100644 --- a/src/lib/curseforge/client.ts +++ b/src/lib/curseforge/client.ts @@ -79,18 +79,23 @@ export class CurseForgeClient { if (isPublishDryRun()) { return interceptUpload("CurseForge", "POST", url, body) as Promise; } - 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; + // ⚠️ 真实上传已暂时禁用,防止调试期间误传。 + // 恢复方式:删除下面的 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; } } diff --git a/src/lib/modrinth/client.ts b/src/lib/modrinth/client.ts index 8d5bd02..a6f4191 100644 --- a/src/lib/modrinth/client.ts +++ b/src/lib/modrinth/client.ts @@ -46,18 +46,23 @@ export class ModrinthClient { if (isPublishDryRun()) { return interceptUpload("Modrinth", "POST", url, body) as Promise; } - 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; + // ⚠️ 真实上传已暂时禁用,防止调试期间误传。 + // 恢复方式:删除下面的 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; } } diff --git a/src/lib/trpc.ts b/src/lib/trpc.ts new file mode 100644 index 0000000..894de25 --- /dev/null +++ b/src/lib/trpc.ts @@ -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({ + 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({ + client: trpcClient, + queryClient, +}); diff --git a/src/services/meta.ts b/src/services/meta.ts index 276c75d..f02e39a 100644 --- a/src/services/meta.ts +++ b/src/services/meta.ts @@ -191,8 +191,16 @@ export async function getCurseForgeMcVersions(): Promise { 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); } diff --git a/src/services/publish.ts b/src/services/publish.ts index 8248cd3..ae06f72 100644 --- a/src/services/publish.ts +++ b/src/services/publish.ts @@ -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; } }), });