diff --git a/src/components/MainPage.tsx b/src/components/MainPage.tsx
index 1499b0e..0eb7cb0 100644
--- a/src/components/MainPage.tsx
+++ b/src/components/MainPage.tsx
@@ -2,10 +2,11 @@
import { useState, useCallback } from "react";
import { Typography, Space, Card, Button, theme } from "antd";
-import { CloudUploadOutlined } from "@ant-design/icons";
+import { CloudUploadOutlined, SyncOutlined } from "@ant-design/icons";
import { ConfigSelector } from "./ConfigSelector";
import { VersionTable } from "./VersionTable";
import { PublishModal } from "./PublishModal";
+import { UpdateModal } from "./UpdateModal";
import type { Config } from "@/types";
import type { ParsedProject } from "@/services/project";
import type { McVersionEntry } from "@/lib/utils/mcVersion";
@@ -60,11 +61,16 @@ export function MainPage({ initialConfigs }: MainPageProps) {
// ------- Publish -------
const [publishOpen, setPublishOpen] = useState(false);
+ const [updateOpen, setUpdateOpen] = useState(false);
const handlePublishClose = useCallback(() => {
setPublishOpen(false);
}, []);
+ const handleUpdateClose = useCallback(() => {
+ setUpdateOpen(false);
+ }, []);
+
const handleRangesChange = useCallback(
(_ranges: McVersionEntry[] | null, end: string | null) => {
setLastVersionEnd(end);
@@ -104,15 +110,23 @@ export function MainPage({ initialConfigs }: MainPageProps) {
onRangesChange={handleRangesChange}
/>
- {/* Publish button */}
+ {/* Publish & Update buttons */}
{parsedProject && parsedProject.artifacts.length > 0 && (
+
}
+ onClick={() => setUpdateOpen(true)}
+ disabled={!selectedConfig}
+ >
+ 更新版本
+
}
@@ -137,6 +151,17 @@ export function MainPage({ initialConfigs }: MainPageProps) {
onClose={handlePublishClose}
/>
)}
+
+ {/* 4. Update modal */}
+ {updateOpen && selectedConfig && parsedProject && selectedConfigFile && (
+
+ )}
);
}
diff --git a/src/components/UpdateModal.tsx b/src/components/UpdateModal.tsx
new file mode 100644
index 0000000..9ad7c5d
--- /dev/null
+++ b/src/components/UpdateModal.tsx
@@ -0,0 +1,684 @@
+"use client";
+
+import { useState, useCallback, useMemo, useEffect, useRef } from "react";
+import {
+ Modal,
+ Steps,
+ Button,
+ Space,
+ Table,
+ Select,
+ Row,
+ Col,
+ Flex,
+ Progress,
+ Typography,
+ Empty,
+ Tag,
+ Alert,
+ App,
+ theme,
+} from "antd";
+import { ReloadOutlined, ArrowRightOutlined } from "@ant-design/icons";
+import { useMutation } from "@tanstack/react-query";
+import { useSubscription } from "@trpc/tanstack-react-query";
+import type { Config } from "@/types";
+import type { ParsedProject } from "@/services/project";
+import { platforms } from "@/services/publish.schemas";
+import type {
+ ExistingRelease,
+ PlatformResult,
+ ProgressEvent,
+} from "@/services/publish.schemas";
+import { Template } from "@/lib/utils/template";
+import type { McVersionEntry } from "@/lib/utils/mcVersion";
+import {
+ computeVersionRanges,
+ collectAutoRange,
+ sameVersionSet,
+} from "@/lib/utils/mcVersion";
+import { SemVer } from "@/lib/utils/templates/semver";
+import { getModrinthMcVersions, getCurseForgeMcVersions } from "@/services/meta";
+import { fetchExistingReleases, type ExistingReleases } from "@/services/versions";
+import { errorMessage } from "@/lib/utils/error";
+import { trpc } from "@/lib/trpc";
+
+const { Text } = Typography;
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+interface UpdateRow {
+ /** 行 key,提交时作为 mc_version 直接传入后端 API,严禁加任何前缀 */
+ key: string;
+ mc_version: string;
+ /** 平台当前范围(升序,仅 MC 版本) */
+ currentRange: string[];
+ /** 新范围(升序) */
+ newRange: string[];
+ /** 新范围显示(通配符,无法通配时回退显式范围) */
+ newRangeDisplay: string;
+ currentTitle: string;
+ newTitle: string;
+ /** 范围或标题有变化 */
+ changed: boolean;
+}
+
+interface UpdateModalProps {
+ open: boolean;
+ /** 配置文件名(configs/ 下的 .json 文件名) */
+ configFile: string;
+ config: Config;
+ project: ParsedProject;
+ onClose: () => void;
+}
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+/** 将项目产物与平台已有版本按模板匹配,构建更新行(仅含平台上已存在的版本) */
+function buildUpdateRows(
+ project: ParsedProject,
+ entries: McVersionEntry[] | null,
+ existingList: ExistingRelease[] | undefined,
+ matchTmpl: Template,
+ nameTmpl: Template,
+): UpdateRow[] {
+ if (!entries || !existingList) return [];
+ const entryMap = new Map(entries.map((e) => [e.version, e]));
+ const rows: UpdateRow[] = [];
+
+ for (const artifact of project.artifacts) {
+ const entry = entryMap.get(artifact.mc_version);
+ if (!entry) continue;
+
+ const matchKey = matchTmpl.format({
+ version: project.version,
+ mc_version: artifact.mc_version,
+ });
+ const found = existingList.find((e) => e.matchKey === matchKey);
+ // 平台上不存在的版本不属于更新范围(应走发布流程)
+ if (!found) continue;
+
+ const newTitle = nameTmpl.format({
+ version: project.version,
+ mc_version: artifact.mc_version,
+ mc_version_range: entry.explicit,
+ });
+
+ rows.push({
+ key: artifact.mc_version,
+ mc_version: artifact.mc_version,
+ currentRange: [...found.gameVersions].sort((a, b) => SemVer.compare(a, b)),
+ newRange: entry.range,
+ newRangeDisplay: entry.wildcard,
+ currentTitle: found.title,
+ newTitle,
+ changed:
+ found.title !== newTitle ||
+ !sameVersionSet(found.gameVersions, entry.range),
+ });
+ }
+
+ return rows;
+}
+
+// ---------------------------------------------------------------------------
+// Component
+// ---------------------------------------------------------------------------
+
+export function UpdateModal({
+ open,
+ configFile,
+ config,
+ project,
+ onClose,
+}: UpdateModalProps) {
+ const { token } = theme.useToken();
+ const { message } = App.useApp();
+ const [currentStep, setCurrentStep] = useState(0);
+
+ // Version selection
+ const [selectedModrinth, setSelectedModrinth] = useState
([]);
+ const [selectedCurseforge, setSelectedCurseforge] = useState([]);
+
+ // Platform data — loaded when modal opens
+ const [loading, setLoading] = useState(false);
+ const [modrinthMcVersions, setModrinthMcVersions] = useState([]);
+ const [curseforgeMcVersions, setCurseforgeMcVersions] = useState([]);
+ const [existing, setExisting] = useState(null);
+
+ // 最后一个版本的截止 MC 版本(更新流程的核心输入)
+ const [cutoff, setCutoff] = useState(null);
+
+ // Progress & result
+ const [updating, setUpdating] = useState(false);
+ const [updateDone, setUpdateDone] = useState(false);
+ const [progress, setProgress] = useState<
+ Record<
+ typeof platforms[number],
+ { done: number; total: number; status: ProgressEvent["status"] }
+ >
+ >({
+ modrinth: { done: 0, total: 0, status: "running" },
+ curseforge: { done: 0, total: 0, status: "running" },
+ });
+ const [updateResult, setUpdateResult] = useState<{
+ success: number;
+ failed: number;
+ errors: Record;
+ } | null>(null);
+
+ const loadData = useCallback(async () => {
+ setLoading(true);
+ try {
+ // 元数据与已有版本同属第一页数据,任一失败都不允许进入下一步,
+ // 否则无法区分"无变化"和"没查到"
+ const [mrVersions, cfVersions, existingReleases] = await Promise.all([
+ getModrinthMcVersions(),
+ getCurseForgeMcVersions(),
+ fetchExistingReleases(config),
+ ]);
+ setModrinthMcVersions(mrVersions);
+ setCurseforgeMcVersions(cfVersions);
+ setExisting(existingReleases);
+ } catch (e) {
+ message.error(
+ `加载平台数据失败,请检查网络后点击“刷新平台数据”重试:${errorMessage(e)}`,
+ );
+ setModrinthMcVersions([]);
+ setCurseforgeMcVersions([]);
+ setExisting(null);
+ } finally {
+ setLoading(false);
+ }
+ }, [config, message]);
+
+ // Auto-load when modal opens
+ useEffect(() => {
+ if (open) {
+ loadData();
+ }
+ }, [open, loadData]);
+
+ const projectVersions = useMemo(
+ () => project.artifacts.map((a) => a.mc_version),
+ [project],
+ );
+
+ // 截止版本下拉框选项:两平台目录并集中 >= 最后一个项目版本的同系列版本
+ const allMcVersionsUnion = useMemo(() => {
+ const union = new Set([...modrinthMcVersions, ...curseforgeMcVersions]);
+ return [...union].sort((a, b) => SemVer.compare(a, b));
+ }, [modrinthMcVersions, curseforgeMcVersions]);
+
+ const cutoffOptions = useMemo(() => {
+ const lastVer = project.artifacts[0]?.mc_version;
+ if (!lastVer || allMcVersionsUnion.length === 0) return [];
+ return collectAutoRange(lastVer, allMcVersionsUnion)
+ .filter(
+ (v) => v.localeCompare(lastVer, undefined, { numeric: true }) >= 0,
+ )
+ .map((v) => ({ value: v, label: v }));
+ }, [project, allMcVersionsUnion]);
+
+ // 目录加载后默认截止版本为同系列最新
+ useEffect(() => {
+ if (allMcVersionsUnion.length === 0) return;
+ const lastVer = project.artifacts[0]?.mc_version;
+ if (!lastVer) return;
+ const series = collectAutoRange(lastVer, allMcVersionsUnion);
+ setCutoff(series[series.length - 1] ?? null);
+ }, [allMcVersionsUnion, project]);
+
+ // Per-platform new ranges
+ const mrRanges = useMemo(() => {
+ if (modrinthMcVersions.length === 0) return null;
+ return computeVersionRanges(projectVersions, modrinthMcVersions, cutoff ?? undefined);
+ }, [projectVersions, modrinthMcVersions, cutoff]);
+
+ const cfRanges = useMemo(() => {
+ if (curseforgeMcVersions.length === 0) return null;
+ return computeVersionRanges(projectVersions, curseforgeMcVersions, cutoff ?? undefined);
+ }, [projectVersions, curseforgeMcVersions, cutoff]);
+
+ // Templates(与服务端 matchKey / resolveVersionName 保持同一渲染逻辑)
+ const tmplRefs = useMemo(
+ () => ({ filename_format: config.filename_format }),
+ [config.filename_format],
+ );
+ const mrVersionTmpl = useMemo(
+ () => new Template(config.modrinth.version, tmplRefs),
+ [config.modrinth.version, tmplRefs],
+ );
+ const mrNameTmpl = useMemo(
+ () => new Template(config.modrinth.version_name, tmplRefs),
+ [config.modrinth.version_name, tmplRefs],
+ );
+ // CurseForge 匹配键与显示名称共用同一 version_name 模板(通常为 #{filename_format})
+ const cfNameTmpl = useMemo(
+ () => new Template(config.curseforge.version_name, tmplRefs),
+ [config.curseforge.version_name, tmplRefs],
+ );
+
+ const modrinthRows = useMemo(
+ () => buildUpdateRows(project, mrRanges, existing?.modrinth, mrVersionTmpl, mrNameTmpl),
+ [project, mrRanges, existing, mrVersionTmpl, mrNameTmpl],
+ );
+ const curseforgeRows = useMemo(
+ () => buildUpdateRows(project, cfRanges, existing?.curseforge, cfNameTmpl, cfNameTmpl),
+ [project, cfRanges, existing, cfNameTmpl],
+ );
+
+ // Auto-select changed rows when data first loads
+ const initRef = useRef(false);
+ useEffect(() => {
+ if (open) {
+ initRef.current = false;
+ }
+ }, [open]);
+ const dataReady =
+ !loading &&
+ existing !== null &&
+ modrinthMcVersions.length > 0 &&
+ curseforgeMcVersions.length > 0;
+ useEffect(() => {
+ if (dataReady && !initRef.current) {
+ initRef.current = true;
+ setSelectedModrinth(modrinthRows.filter((r) => r.changed).map((r) => r.key));
+ setSelectedCurseforge(curseforgeRows.filter((r) => r.changed).map((r) => r.key));
+ }
+ }, [dataReady, modrinthRows, curseforgeRows]);
+
+ // --------------- Steps handling ---------------
+
+ const canProceed = dataReady && (selectedModrinth.length > 0 || selectedCurseforge.length > 0);
+
+ const handleConfirmUpdate = useCallback(() => {
+ // 重置状态并进入进度页;真正发起 mutation 在下方 useSubscription 的
+ // onStarted 里(保证先连上 SSE 再更新,不漏早期进度事件)
+ setUpdating(true);
+ setUpdateDone(false);
+ setUpdateResult(null);
+ setProgress({
+ modrinth: { done: 0, total: selectedModrinth.length, status: "running" },
+ curseforge: { done: 0, total: selectedCurseforge.length, status: "running" },
+ });
+ setCurrentStep(1);
+ }, [selectedModrinth.length, selectedCurseforge.length]);
+
+ const updateMutation = useMutation(
+ trpc.update.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;
+ }
+ setUpdateResult({ success, failed, errors });
+ setUpdateDone(true);
+ setUpdating(false);
+ },
+ onError: (error) => {
+ message.error(`更新请求失败:${error.message}`);
+ setUpdateDone(true);
+ setUpdating(false);
+ },
+ }),
+ );
+
+ useSubscription(
+ trpc.progress.subscriptionOptions(undefined, {
+ enabled: updating,
+ onStarted: () => {
+ // SSE 已连接,现在发起更新不会漏事件
+ updateMutation.mutate({
+ configFile,
+ version: project.version,
+ mcVersions: {
+ modrinth: selectedModrinth.map((mc_version) => ({ mc_version })),
+ curseforge: selectedCurseforge.map((mc_version) => ({ mc_version })),
+ },
+ allMcVersions: projectVersions,
+ cutoffMcVersion: cutoff ?? "",
+ });
+ },
+ 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 (updating) return;
+ setCurrentStep(0);
+ setSelectedModrinth([]);
+ setSelectedCurseforge([]);
+ setUpdateResult(null);
+ setUpdateDone(false);
+ onClose();
+ }, [updating, onClose]);
+
+ // --------------- Render: diff cells ---------------
+
+ const renderRangeCell = useCallback(
+ (row: UpdateRow) => {
+ const current = row.currentRange.join(", ");
+ const rangeChanged = !sameVersionSet(row.currentRange, row.newRange);
+ if (!rangeChanged) {
+ return {current};
+ }
+ return (
+
+ {current}
+
+ {row.newRangeDisplay}
+
+ );
+ },
+ [token],
+ );
+
+ const renderTitleCell = useCallback(
+ (row: UpdateRow) => {
+ if (row.currentTitle === row.newTitle) {
+ return {row.currentTitle};
+ }
+ return (
+
+ {row.currentTitle}
+
+ {row.newTitle}
+
+ );
+ },
+ [token],
+ );
+
+ // --------------- Render: Step 1 — Version Selection ---------------
+
+ const renderStep1 = () => {
+ const makeColumns = (titleHeader: string) => [
+ {
+ title: "MC 版本",
+ dataIndex: "mc_version",
+ key: "mc_version",
+ width: 80,
+ render: (val: string) => {val},
+ },
+ {
+ title: "兼容范围",
+ key: "range",
+ width: "42%",
+ render: (_: unknown, row: UpdateRow) => renderRangeCell(row),
+ },
+ {
+ title: titleHeader,
+ key: "title",
+ render: (_: unknown, row: UpdateRow) => renderTitleCell(row),
+ },
+ ];
+
+ return (
+
+
+
+ 选择要更新的版本
+
+ 截止
+
+
+
+ }
+ loading={loading}
+ onClick={loadData}
+ size="small"
+ >
+ 刷新平台数据
+
+
+
+
+
+ 仅列出平台上已存在的版本;范围或标题有变化的版本已自动勾选。
+
+
+
+ {loading ? (
+
加载中…
+ ) : (
+
+
+
+ Modrinth
+
+ {modrinthRows.length > 0 ? (
+ setSelectedModrinth(keys as string[]),
+ getCheckboxProps: (r: UpdateRow) => ({ disabled: !r.changed }),
+ }}
+ pagination={false}
+ scroll={{ y: 420 }}
+ size="small"
+ bordered
+ />
+ ) : (
+
+ )}
+
+
+
+ CurseForge
+
+ {curseforgeRows.length > 0 ? (
+ setSelectedCurseforge(keys as string[]),
+ getCheckboxProps: (r: UpdateRow) => ({ disabled: !r.changed }),
+ }}
+ pagination={false}
+ scroll={{ y: 420 }}
+ size="small"
+ bordered
+ />
+ ) : (
+
+ )}
+
+
+ )}
+
+ );
+ };
+
+ // --------------- Render: Step 2 — Progress & Result ---------------
+
+ const renderStep2 = () => {
+ const hasModrinth = selectedModrinth.length > 0;
+ const hasCurseforge = selectedCurseforge.length > 0;
+
+ return (
+
+ {/* Progress bars */}
+
+
+ {hasModrinth && (
+
+
+ Modrinth
+
+ )}
+ {hasCurseforge && (
+
+
+ CurseForge
+
+ )}
+
+
+
+ {/* Result */}
+ {updateDone && updateResult && (
+
0 ? "warning" : "success"}
+ title="更新结果"
+ description={
+
+
+ 成功 {updateResult.success} 个,失败{" "}
+ {updateResult.failed} 个
+
+ {platforms.map((platform) => {
+ const errors = updateResult.errors[platform];
+ if (errors.length === 0) return null;
+ const label =
+ platform === "modrinth" ? "Modrinth" : "CurseForge";
+ return (
+
+
{label}:
+
+ {errors.map((e, i) => (
+ -
+ {e}
+
+ ))}
+
+
+ );
+ })}
+
+ }
+ showIcon
+ />
+ )}
+
+ );
+ };
+
+ // --------------- Render ---------------
+
+ return (
+
+ {currentStep === 0 ? (
+
+ ) : (
+
+ )}
+
+ )
+ }
+ >
+
+
+ {currentStep === 0 ? renderStep1() : renderStep2()}
+
+ );
+}
diff --git a/src/lib/modrinth/client.ts b/src/lib/modrinth/client.ts
index 41f0199..74a5eff 100644
--- a/src/lib/modrinth/client.ts
+++ b/src/lib/modrinth/client.ts
@@ -66,7 +66,12 @@ export class ModrinthClient {
* 成功响应为 204 No Content,无响应体,故返回 void。
*/
async patch(path: string, body: unknown): Promise {
- const res = await fetch(`${this.base}${path}`, {
+ const url = `${this.base}${path}`;
+ if (isPublishDryRun()) {
+ await interceptUpload("Modrinth", "PATCH", url, JSON.stringify(body, null, 2));
+ return;
+ }
+ const res = await fetch(url, {
method: "PATCH",
headers: {
...this.headers(),
diff --git a/src/lib/utils/dryRun.ts b/src/lib/utils/dryRun.ts
index 2717687..8997fcd 100644
--- a/src/lib/utils/dryRun.ts
+++ b/src/lib/utils/dryRun.ts
@@ -25,9 +25,9 @@ export async function interceptUpload(
platform: string,
method: string,
url: string,
- body: FormData,
+ body: FormData | string,
): Promise {
- const summary = summarizeFormData(body);
+ const summary = typeof body === "string" ? body : summarizeFormData(body);
const logDir = process.env.PUBLISH_DRY_RUN_LOG_DIR ?? tmpdir();
const logFile = join(logDir, `dry-run-${Date.now()}-${platform}.log`);
@@ -50,6 +50,6 @@ export async function interceptUpload(
await new Promise((resolve) => setTimeout(resolve, DRY_RUN_DELAY_MS));
return platform === "Modrinth"
- ? { name: "(dry-run)", version_number: "(dry-run)" }
+ ? { id: "(dry-run)", name: "(dry-run)", version_number: "(dry-run)", game_versions: [] }
: { id: 0 };
}
diff --git a/src/lib/utils/mcVersion.ts b/src/lib/utils/mcVersion.ts
index dac011a..35855e4 100644
--- a/src/lib/utils/mcVersion.ts
+++ b/src/lib/utils/mcVersion.ts
@@ -74,6 +74,13 @@ function collectRange(
return result;
}
+/** 判断两个版本列表作为集合是否相等(顺序无关) */
+export function sameVersionSet(a: string[], b: string[]): boolean {
+ if (a.length !== b.length) return false;
+ const set = new Set(a);
+ return b.every((v) => set.has(v));
+}
+
/** 自动匹配:收集与 start 同系列的所有版本 */
export function collectAutoRange(
start: string,
diff --git a/src/services/platforms/curseforge.ts b/src/services/platforms/curseforge.ts
index d883353..a6f10eb 100644
--- a/src/services/platforms/curseforge.ts
+++ b/src/services/platforms/curseforge.ts
@@ -1,14 +1,99 @@
import { curseforgeClient } from "@/services/clients";
-import { uploadFile, getGameVersions, getMinecraftVersions } from "@/lib/curseforge";
+import { uploadFile, updateFile, getFiles, getGameVersions, getMinecraftVersions } from "@/lib/curseforge";
import { resolveVersionName } from "@/lib/utils/format";
-import type { UploadMetadata } from "@/types";
-import { PlatformPublishFn } from "../publish.schemas";
+import { sameVersionSet } from "@/lib/utils/mcVersion";
+import { Template } from "@/lib/utils/template";
+import type { Config, CurseForgeFile, UploadMetadata } from "@/types";
+import {
+ PlatformListExistingFn,
+ PlatformMatchKeyFn,
+ PlatformPublishFn,
+ PlatformUpdateFn,
+} from "../publish.schemas";
export const publishCurseForgeVersion: PlatformPublishFn = async (ctx, mcVersion, files) => {
const { input, config } = ctx;
const displayName = resolveVersionName(config.curseforge.version_name, config, input.version, mcVersion);
+ const metadata: UploadMetadata = {
+ changelog: input.changelog,
+ changelogType: "markdown",
+ displayName,
+ releaseType: input.versionType,
+ gameVersions: await resolveGameVersionIds(config, mcVersion.range),
+ relations: config.curseforge.relations,
+ };
+
+ await uploadFile(
+ curseforgeClient,
+ config.curseforge.project_id,
+ metadata,
+ files.primary,
+ );
+}
+
+/** 匹配键:按 version_name 模板渲染文件名(与 compareVersions 一致) */
+export const curseForgeMatchKey: PlatformMatchKeyFn = (config, version, mcVersion) =>
+ new Template(config.curseforge.version_name, {
+ filename_format: config.filename_format,
+ }).format({ version, mc_version: mcVersion });
+
+export const listExistingCurseForge: PlatformListExistingFn = async (config) => {
+ const pageSize = 50;
+ let index = 0;
+ const allFiles: CurseForgeFile[] = [];
+
+ while (true) {
+ const response = await getFiles(curseforgeClient, {
+ modId: config.curseforge.project_id,
+ index,
+ pageSize,
+ });
+ allFiles.push(...response.data);
+
+ if (allFiles.length >= response.pagination.totalCount) {
+ break;
+ }
+ index += pageSize;
+ }
+
+ // file.gameVersions 混合了 MC 版本 / loader / 环境等标签,只保留 MC 版本用于 diff
+ const mcCatalog = new Set(
+ (await getMinecraftVersions(curseforgeClient)).map((v) => v.versionString),
+ );
+
+ return allFiles.map((f) => ({
+ id: f.id,
+ matchKey: f.fileName,
+ title: f.displayName,
+ gameVersions: f.gameVersions.filter((v) => mcCatalog.has(v)),
+ }));
+}
+
+export const updateCurseForgeVersion: PlatformUpdateFn = async (ctx, mcVersion, existing) => {
+ const { input, config } = ctx;
+
+ const displayName = resolveVersionName(config.curseforge.version_name, config, input.version, mcVersion);
+
+ // 标题与范围都无变化时不调 API
+ if (existing.title === displayName && sameVersionSet(existing.gameVersions, mcVersion.range)) {
+ return;
+ }
+
+ // update-file 的 gameVersions 是整体替换:必须连同 loader/环境 ID 一起提交,
+ // 只传 MC 版本会把原有 loader/环境标签清掉
+ const gameVersions = await resolveGameVersionIds(config, mcVersion.range);
+
+ await updateFile(curseforgeClient, config.curseforge.project_id, {
+ fileID: Number(existing.id),
+ displayName,
+ gameVersions,
+ });
+}
+
+/** 将 loaders + environment + MC 版本范围解析为 CurseForge Game Version ID 列表 */
+async function resolveGameVersionIds(config: Config, mcRange: string[]): Promise {
const gameVersions = await getGameVersions(curseforgeClient);
const minecraftVersions = await getMinecraftVersions(curseforgeClient);
@@ -23,23 +108,9 @@ export const publishCurseForgeVersion: PlatformPublishFn = async (ctx, mcVersion
return found.gameVersionId;
};
- const metadata: UploadMetadata = {
- changelog: input.changelog,
- changelogType: "markdown",
- displayName,
- releaseType: input.versionType,
- gameVersions: [
- ...config.curseforge.loaders.map(resolveId),
- ...config.curseforge.environment.map(resolveId),
- ...mcVersion.range.map(resolveMinecraftVersionId),
- ],
- relations: config.curseforge.relations,
- };
-
- await uploadFile(
- curseforgeClient,
- config.curseforge.project_id,
- metadata,
- files.primary,
- );
+ return [
+ ...config.curseforge.loaders.map(resolveId),
+ ...config.curseforge.environment.map(resolveId),
+ ...mcRange.map(resolveMinecraftVersionId),
+ ];
}
diff --git a/src/services/platforms/modrinth.ts b/src/services/platforms/modrinth.ts
index 8b2e086..8324b68 100644
--- a/src/services/platforms/modrinth.ts
+++ b/src/services/platforms/modrinth.ts
@@ -1,8 +1,15 @@
import { modrinthClient } from "@/services/clients";
-import { createVersion } from "@/lib/modrinth";
+import { createVersion, listVersions, updateVersion } from "@/lib/modrinth";
import { resolveVersionName } from "@/lib/utils/format";
+import { sameVersionSet } from "@/lib/utils/mcVersion";
+import { Template } from "@/lib/utils/template";
import type { CreatableVersion } from "@/types";
-import { PlatformPublishFn } from "../publish.schemas";
+import {
+ PlatformListExistingFn,
+ PlatformMatchKeyFn,
+ PlatformPublishFn,
+ PlatformUpdateFn,
+} from "../publish.schemas";
export const publishModrinthVersion: PlatformPublishFn = async (ctx, mcVersion, files) => {
const { input, config } = ctx;
@@ -36,3 +43,37 @@ export const publishModrinthVersion: PlatformPublishFn = async (ctx, mcVersion,
sources: files.source,
});
}
+
+/** 匹配键:按 version 模板渲染 version_number(与 compareVersions 一致) */
+export const modrinthMatchKey: PlatformMatchKeyFn = (config, version, mcVersion) =>
+ new Template(config.modrinth.version, {
+ filename_format: config.filename_format,
+ }).format({ version, mc_version: mcVersion });
+
+export const listExistingModrinth: PlatformListExistingFn = async (config) => {
+ const versions = await listVersions(modrinthClient, config.modrinth.project_id, {
+ include_changelog: false,
+ });
+ return versions.map((v) => ({
+ id: v.id,
+ matchKey: v.version_number,
+ title: v.name,
+ gameVersions: v.game_versions,
+ }));
+}
+
+export const updateModrinthVersion: PlatformUpdateFn = async (ctx, mcVersion, existing) => {
+ const { input, config } = ctx;
+
+ const name = resolveVersionName(config.modrinth.version_name, config, input.version, mcVersion);
+
+ // 标题与范围都无变化时不调 API,避免产生无效的 PATCH 请求
+ if (existing.title === name && sameVersionSet(existing.gameVersions, mcVersion.range)) {
+ return;
+ }
+
+ await updateVersion(modrinthClient, String(existing.id), {
+ name,
+ game_versions: mcVersion.range,
+ });
+}
diff --git a/src/services/publish.schemas.ts b/src/services/publish.schemas.ts
index 16b2cc9..6b55f6c 100644
--- a/src/services/publish.schemas.ts
+++ b/src/services/publish.schemas.ts
@@ -5,8 +5,8 @@ import { z } from "zod";
export const platforms = ["modrinth", "curseforge"] as const;
-export interface PlatformContext {
- input: PublishInput;
+export interface PlatformContext {
+ input: I;
config: Config;
}
export type PlatformPublishFn = (
@@ -19,8 +19,36 @@ export type PlatformPublishFn = (
) => Promise;
export type PlatformGetGameVersionsFn = () => Promise;
+/** 平台上已有版本/文件的规范化视图,供更新流程匹配与 diff */
+export interface ExistingRelease {
+ /** 平台内部 ID:Modrinth 版本 ID(base62 字符串)/ CurseForge 文件 ID(数字) */
+ id: string | number;
+ /** 模板匹配键:Modrinth version_number / CurseForge fileName */
+ matchKey: string;
+ /** 当前标题:Modrinth name / CurseForge displayName */
+ title: string;
+ /** 当前 MC 版本范围(CurseForge 已剔除 loader/环境等非 MC 版本项) */
+ gameVersions: string[];
+}
+
+export type PlatformUpdateFn = (
+ ctx: PlatformContext,
+ mcVersion: McVersionEntry,
+ existing: ExistingRelease,
+) => Promise;
+export type PlatformListExistingFn = (config: Config) => Promise;
+/** 生成已有版本的模板匹配键(前端预览与服务端正式匹配共用同一逻辑) */
+export type PlatformMatchKeyFn = (
+ config: Config,
+ version: string,
+ mcVersion: string,
+) => string;
+
export type PlatformAdaptor = {
publish: PlatformPublishFn,
+ update: PlatformUpdateFn,
+ listExisting: PlatformListExistingFn,
+ matchKey: PlatformMatchKeyFn,
getGameVersions: PlatformGetGameVersionsFn,
}
@@ -40,6 +68,12 @@ export const PublishInputSchema = z.object({
versionType: BaseVersionSchema.shape.version_type,
});
+// 更新已有版本:不上传文件、不改 changelog,只改标题与兼容范围
+export const UpdateInputSchema = PublishInputSchema.omit({
+ changelog: true,
+ versionType: true,
+});
+
export const ProgressEventSchema = z.object({
platform: z.enum(platforms),
current: z.number().int().min(0),
@@ -61,6 +95,7 @@ export const PublishResultSchema = z.object(createRecord(
// ── Inferred types ─────────────────────────────────────
export type PublishInput = z.infer;
+export type UpdateInput = z.infer;
export type ProgressEvent = z.infer;
export type PlatformResult = z.infer;
export type PublishResult = z.infer;
diff --git a/src/services/publish.ts b/src/services/publish.ts
index 58bc6bd..cefbd94 100644
--- a/src/services/publish.ts
+++ b/src/services/publish.ts
@@ -4,6 +4,7 @@ import { getConfig } from "./config";
import { errorMessage } from "@/lib/utils/error";
import {
PublishInputSchema,
+ UpdateInputSchema,
type ProgressEvent,
type PlatformResult,
type PublishResult,
@@ -11,11 +12,23 @@ import {
type PlatformContext,
type PlatformAdaptor,
type PublishInput,
+ type UpdateInput,
+ type ExistingRelease,
PublishResultSchema,
ProgressEventSchema,
} from "./publish.schemas";
-import { publishModrinthVersion } from "./platforms/modrinth";
-import { publishCurseForgeVersion } from "./platforms/curseforge";
+import {
+ publishModrinthVersion,
+ updateModrinthVersion,
+ listExistingModrinth,
+ modrinthMatchKey,
+} from "./platforms/modrinth";
+import {
+ publishCurseForgeVersion,
+ updateCurseForgeVersion,
+ listExistingCurseForge,
+ curseForgeMatchKey,
+} from "./platforms/curseforge";
import { computeVersionRanges, type McVersionEntry } from "@/lib/utils/mcVersion";
import { getCurseForgeMcVersions, getModrinthMcVersions } from "./meta";
import { readAsFile } from "@/lib/utils/file";
@@ -27,10 +40,16 @@ export const platformAdaptors: {
} = {
modrinth: {
publish: publishModrinthVersion,
+ update: updateModrinthVersion,
+ listExisting: listExistingModrinth,
+ matchKey: modrinthMatchKey,
getGameVersions: getModrinthMcVersions,
},
curseforge: {
publish: publishCurseForgeVersion,
+ update: updateCurseForgeVersion,
+ listExisting: listExistingCurseForge,
+ matchKey: curseForgeMatchKey,
getGameVersions: getCurseForgeMcVersions,
},
};
@@ -45,6 +64,49 @@ const ee = new EventEmitter();
// ── Platform orchestrator ──────────────────────────────
+/**
+ * 计算勾选版本的兼容范围条目(发布与更新流程共用)。
+ * 范围必须基于完整项目版本列表计算,勾选只决定处理哪些条目。
+ */
+async function computeSelectedEntries(
+ platform: typeof platforms[number],
+ selectedMcVersions: string[],
+ ctx: PlatformContext,
+): Promise {
+ const selected = new Set(selectedMcVersions);
+ const entries = computeVersionRanges(
+ ctx.input.allMcVersions,
+ await platformAdaptors[platform].getGameVersions(),
+ ctx.input.cutoffMcVersion
+ ).filter((e) => selected.has(e.version));
+ if (entries.length !== selected.size) {
+ throw new Error("勾选版本与项目版本列表不一致,请刷新后重试");
+ }
+ return entries;
+}
+
+/** 平台准备阶段失败的兜底:记为该平台全部失败并发出 failed 事件,
+ * 否则前端进度条会停在 running 假象里,错误也被吞掉。 */
+function failPlatformPreparation(
+ platform: typeof platforms[number],
+ total: number,
+ result: PlatformResult,
+ err: unknown,
+ logPrefix: string,
+): void {
+ const msg = errorMessage(err);
+ console.error(`[${logPrefix}] ${platform} 准备阶段失败:`, err);
+ result.fail = total;
+ result.errors.push(msg);
+ ee.emit("progress", {
+ platform,
+ current: 0,
+ total,
+ status: "failed",
+ errors: result.errors,
+ } satisfies ProgressEvent);
+}
+
async function runPlatform(
platform: typeof platforms[number],
mcVersions: PublishInput["mcVersions"][keyof PublishInput["mcVersions"]],
@@ -74,35 +136,18 @@ async function runPlatform(
} satisfies ProgressEvent);
const publishFn = platformAdaptors[platform].publish;
- const getGameVersionsFn = platformAdaptors[platform].getGameVersions;
// 平台准备阶段(元数据拉取 + 版本范围计算)。此处异常不在逐版本的
- // try/catch 内,必须单独兜底:记为该平台全部失败并发出 failed 事件,
- // 否则前端进度条会停在 running 假象里,错误也被吞掉。
+ // try/catch 内,必须单独兜底。
let mcVersionEntries: McVersionEntry[];
try {
- // 范围必须基于完整项目版本列表计算,勾选只决定发布哪些条目
- const selected = new Set(mcVersions.map((v) => v.mc_version));
- mcVersionEntries = computeVersionRanges(
- ctx.input.allMcVersions,
- await getGameVersionsFn(),
- ctx.input.cutoffMcVersion
- ).filter((e) => selected.has(e.version));
- if (mcVersionEntries.length !== selected.size) {
- throw new Error("勾选版本与项目版本列表不一致,请刷新后重试");
- }
- } catch (err) {
- const msg = errorMessage(err);
- console.error(`[publish] ${platform} 准备阶段失败:`, err);
- result.fail = total;
- result.errors.push(msg);
- ee.emit("progress", {
+ mcVersionEntries = await computeSelectedEntries(
platform,
- current: 0,
- total,
- status: "failed",
- errors: result.errors,
- } satisfies ProgressEvent);
+ mcVersions.map((v) => v.mc_version),
+ ctx,
+ );
+ } catch (err) {
+ failPlatformPreparation(platform, total, result, err, "publish");
return result;
}
@@ -151,6 +196,95 @@ async function runPlatform(
return result;
}
+// ── Update orchestrator ────────────────────────────────
+
+async function runPlatformUpdate(
+ platform: typeof platforms[number],
+ mcVersions: UpdateInput["mcVersions"][keyof UpdateInput["mcVersions"]],
+ ctx: PlatformContext,
+): Promise {
+ const result: PlatformResult = { success: 0, fail: 0, errors: [] };
+ const total = mcVersions.length;
+
+ if (total === 0) {
+ ee.emit("progress", {
+ platform,
+ current: 0,
+ total: 0,
+ status: "completed",
+ errors: [],
+ } satisfies ProgressEvent);
+ return result;
+ }
+
+ ee.emit("progress", {
+ platform,
+ current: 0,
+ total,
+ status: "running",
+ errors: [],
+ } satisfies ProgressEvent);
+
+ const adaptor = platformAdaptors[platform];
+
+ // 准备阶段(范围计算 + 已有版本列表拉取),异常处理同 runPlatform
+ let mcVersionEntries: McVersionEntry[];
+ let existingReleases: ExistingRelease[];
+ try {
+ mcVersionEntries = await computeSelectedEntries(
+ platform,
+ mcVersions.map((v) => v.mc_version),
+ ctx,
+ );
+ existingReleases = await adaptor.listExisting(ctx.config);
+ } catch (err) {
+ failPlatformPreparation(platform, total, result, err, "update");
+ return result;
+ }
+
+ for (let i = 0; i < mcVersionEntries.length; i++) {
+ const mcVersionEntry = mcVersionEntries[i];
+
+ try {
+ // 服务端按模板重新匹配已有版本,不信任前端传入的任何 ID
+ const key = adaptor.matchKey(ctx.config, ctx.input.version, mcVersionEntry.version);
+ const existing = existingReleases.find((e) => e.matchKey === key);
+ if (!existing) {
+ throw new Error(`平台上未找到版本 "${key}",请改用发布流程`);
+ }
+
+ await adaptor.update(ctx, mcVersionEntry, existing);
+ result.success++;
+
+ const isLast = i + 1 === total;
+ ee.emit("progress", {
+ platform,
+ current: i + 1,
+ total,
+ status: isLast ? "completed" : "running",
+ errors: result.errors,
+ } satisfies ProgressEvent);
+ } catch (err) {
+ result.fail++;
+ const msg = `${mcVersionEntry.version}: ${errorMessage(err)}`;
+ result.errors.push(msg);
+
+ ee.emit("progress", {
+ platform,
+ current: i,
+ total,
+ status: "failed",
+ errors: result.errors,
+ } satisfies ProgressEvent);
+
+ // Cancel remaining for this platform only
+ break;
+ }
+ }
+
+ return result;
+}
+
// ── Router ─────────────────────────────────────────────
export const appRouter = t.router({
@@ -177,6 +311,29 @@ export const appRouter = t.router({
return PublishResultSchema.parse(Object.fromEntries(results.map(result => [result.platform, result.result])));
}),
+ update: t.procedure
+ .input(UpdateInputSchema)
+ .mutation(async ({ input }): Promise => {
+ const config = await getConfig(input.configFile);
+
+ const ctx: PlatformContext = { input, config };
+
+ const results = await Promise.all(platforms.map(async (platform) => {
+ try {
+ const result = await runPlatformUpdate(platform, input.mcVersions[platform], ctx);
+ return {platform, result};
+ } catch (err) {
+ console.error(`[update] ${platform} 执行异常:`, err);
+ return {
+ platform,
+ result: { success: 0, fail: 0, errors: [errorMessage(err)] }
+ }
+ }
+ }));
+
+ return PublishResultSchema.parse(Object.fromEntries(results.map(result => [result.platform, result.result])));
+ }),
+
progress: t.procedure.subscription(async function* (opts) {
for await (const [data] of on(ee, "progress", {
signal: opts.signal,
diff --git a/src/services/versions.ts b/src/services/versions.ts
index f4a56ee..ef77f96 100644
--- a/src/services/versions.ts
+++ b/src/services/versions.ts
@@ -5,6 +5,9 @@ import { modrinthClient, curseforgeClient } from "./clients";
import { listVersions } from "@/lib/modrinth";
import { getFiles } from "@/lib/curseforge";
import { Template } from "@/lib/utils/template";
+import { listExistingModrinth } from "./platforms/modrinth";
+import { listExistingCurseForge } from "./platforms/curseforge";
+import type { ExistingRelease } from "./publish.schemas";
/**
* 获取 Modrinth 项目的全部已有版本(不含 changelog 以减小响应体)。
@@ -119,3 +122,21 @@ export async function compareVersions(
return { modrinth, curseforge, modrinthVersions, curseforgeFiles };
}
+
+/** 两平台已有版本/文件的规范化列表(更新版本流程用) */
+export interface ExistingReleases {
+ modrinth: ExistingRelease[];
+ curseforge: ExistingRelease[];
+}
+
+/**
+ * 获取两平台已有版本/文件的规范化列表,含平台内部 ID 与当前 MC 版本范围。
+ * 供“更新版本”弹窗展示 diff;服务端执行更新时会重新拉取,不信任此结果。
+ */
+export async function fetchExistingReleases(config: Config): Promise {
+ const [modrinth, curseforge] = await Promise.all([
+ listExistingModrinth(config),
+ listExistingCurseForge(config),
+ ]);
+ return { modrinth, curseforge };
+}
diff --git a/src/types/curseforge/files.ts b/src/types/curseforge/files.ts
index d0e89e9..b76feb6 100644
--- a/src/types/curseforge/files.ts
+++ b/src/types/curseforge/files.ts
@@ -172,8 +172,10 @@ export const CurseForgeFileSchema = z.object({
/** 文件模块列表 */
modules: z.array(FileModuleSchema),
}).pick({
+ id: true,
displayName: true,
fileName: true,
+ gameVersions: true,
});
export type CurseForgeFile = z.infer;
// endregion
diff --git a/src/types/modrinth/version.ts b/src/types/modrinth/version.ts
index 839ed6a..3c1b0b9 100644
--- a/src/types/modrinth/version.ts
+++ b/src/types/modrinth/version.ts
@@ -114,8 +114,10 @@ export const VersionSchema = BaseVersionSchema.extend({
/** 该版本的所有文件列表 */
files: z.array(VersionFileSchema),
}).pick({
+ id: true,
name: true,
version_number: true,
+ game_versions: true,
});
export type Version = z.infer;
// endregion