Add update feature for existing platform versions
Extend existing releases' title and compatible MC versions without
re-uploading files, via Modrinth PATCH /version/{id} and CurseForge
update-file. Adds an UpdateModal with range/title diff preview, an
update tRPC mutation sharing the progress subscription with publish, and
dry-run interception for Modrinth PATCH requests.
This commit is contained in:
@@ -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 && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: token.marginSM,
|
||||
marginTop: token.marginLG,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
icon={<SyncOutlined />}
|
||||
onClick={() => setUpdateOpen(true)}
|
||||
disabled={!selectedConfig}
|
||||
>
|
||||
更新版本
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CloudUploadOutlined />}
|
||||
@@ -137,6 +151,17 @@ export function MainPage({ initialConfigs }: MainPageProps) {
|
||||
onClose={handlePublishClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 4. Update modal */}
|
||||
{updateOpen && selectedConfig && parsedProject && selectedConfigFile && (
|
||||
<UpdateModal
|
||||
open={updateOpen}
|
||||
configFile={selectedConfigFile}
|
||||
config={selectedConfig}
|
||||
project={parsedProject}
|
||||
onClose={handleUpdateClose}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string[]>([]);
|
||||
const [selectedCurseforge, setSelectedCurseforge] = useState<string[]>([]);
|
||||
|
||||
// Platform data — loaded when modal opens
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modrinthMcVersions, setModrinthMcVersions] = useState<string[]>([]);
|
||||
const [curseforgeMcVersions, setCurseforgeMcVersions] = useState<string[]>([]);
|
||||
const [existing, setExisting] = useState<ExistingReleases | null>(null);
|
||||
|
||||
// 最后一个版本的截止 MC 版本(更新流程的核心输入)
|
||||
const [cutoff, setCutoff] = useState<string | null>(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<typeof platforms[number], string[]>;
|
||||
} | 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 <Text type={row.changed ? undefined : "secondary"}>{current}</Text>;
|
||||
}
|
||||
return (
|
||||
<Flex align="center" gap={4} wrap>
|
||||
<Text delete type="secondary">{current}</Text>
|
||||
<ArrowRightOutlined style={{ fontSize: 10, color: token.colorTextTertiary }} />
|
||||
<Text strong>{row.newRangeDisplay}</Text>
|
||||
</Flex>
|
||||
);
|
||||
},
|
||||
[token],
|
||||
);
|
||||
|
||||
const renderTitleCell = useCallback(
|
||||
(row: UpdateRow) => {
|
||||
if (row.currentTitle === row.newTitle) {
|
||||
return <Text type={row.changed ? undefined : "secondary"}>{row.currentTitle}</Text>;
|
||||
}
|
||||
return (
|
||||
<Flex align="center" gap={4} wrap>
|
||||
<Text delete type="secondary">{row.currentTitle}</Text>
|
||||
<ArrowRightOutlined style={{ fontSize: 10, color: token.colorTextTertiary }} />
|
||||
<Text strong>{row.newTitle}</Text>
|
||||
</Flex>
|
||||
);
|
||||
},
|
||||
[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) => <Tag>{val}</Tag>,
|
||||
},
|
||||
{
|
||||
title: "兼容范围",
|
||||
key: "range",
|
||||
width: "42%",
|
||||
render: (_: unknown, row: UpdateRow) => renderRangeCell(row),
|
||||
},
|
||||
{
|
||||
title: titleHeader,
|
||||
key: "title",
|
||||
render: (_: unknown, row: UpdateRow) => renderTitleCell(row),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: token.margin,
|
||||
}}
|
||||
>
|
||||
<Space size={token.marginXS}>
|
||||
<Text strong>选择要更新的版本</Text>
|
||||
<Text type="secondary" style={{ fontSize: token.fontSizeSM }}>
|
||||
截止
|
||||
</Text>
|
||||
<Select
|
||||
size="small"
|
||||
style={{ width: 110 }}
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
value={cutoff ?? undefined}
|
||||
onChange={setCutoff}
|
||||
options={cutoffOptions}
|
||||
/>
|
||||
</Space>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
loading={loading}
|
||||
onClick={loadData}
|
||||
size="small"
|
||||
>
|
||||
刷新平台数据
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: token.marginSM }}>
|
||||
<Text type="secondary" style={{ fontSize: token.fontSizeSM }}>
|
||||
仅列出平台上已存在的版本;范围或标题有变化的版本已自动勾选。
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: "center", padding: 40 }}>加载中…</div>
|
||||
) : (
|
||||
<Row gutter={token.margin}>
|
||||
<Col span={12}>
|
||||
<Text strong style={{ display: "block", marginBottom: token.marginXS }}>
|
||||
Modrinth
|
||||
</Text>
|
||||
{modrinthRows.length > 0 ? (
|
||||
<Table
|
||||
dataSource={modrinthRows}
|
||||
columns={makeColumns("版本名称")}
|
||||
rowKey="key"
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedModrinth,
|
||||
onChange: (keys) => setSelectedModrinth(keys as string[]),
|
||||
getCheckboxProps: (r: UpdateRow) => ({ disabled: !r.changed }),
|
||||
}}
|
||||
pagination={false}
|
||||
scroll={{ y: 420 }}
|
||||
size="small"
|
||||
bordered
|
||||
/>
|
||||
) : (
|
||||
<Empty description="平台上没有可更新的版本" />
|
||||
)}
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Text strong style={{ display: "block", marginBottom: token.marginXS }}>
|
||||
CurseForge
|
||||
</Text>
|
||||
{curseforgeRows.length > 0 ? (
|
||||
<Table
|
||||
dataSource={curseforgeRows}
|
||||
columns={makeColumns("显示名称")}
|
||||
rowKey="key"
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedCurseforge,
|
||||
onChange: (keys) => setSelectedCurseforge(keys as string[]),
|
||||
getCheckboxProps: (r: UpdateRow) => ({ disabled: !r.changed }),
|
||||
}}
|
||||
pagination={false}
|
||||
scroll={{ y: 420 }}
|
||||
size="small"
|
||||
bordered
|
||||
/>
|
||||
) : (
|
||||
<Empty description="平台上没有可更新的版本" />
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --------------- Render: Step 2 — Progress & Result ---------------
|
||||
|
||||
const renderStep2 = () => {
|
||||
const hasModrinth = selectedModrinth.length > 0;
|
||||
const hasCurseforge = selectedCurseforge.length > 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Progress bars */}
|
||||
<div style={{ marginBottom: token.marginLG }}>
|
||||
<Flex vertical gap={token.marginSM}>
|
||||
{hasModrinth && (
|
||||
<Flex align="center" gap={token.marginXS}>
|
||||
<img
|
||||
src="/platform/modrinth.svg"
|
||||
alt="Modrinth"
|
||||
style={{ width: 30, height: 30, flexShrink: 0 }}
|
||||
/>
|
||||
<Text style={{ width: 80, flexShrink: 0, fontFamily: "Inter", fontWeight: "bold" }}>Modrinth</Text>
|
||||
<Progress
|
||||
percent={Math.round(
|
||||
(progress.modrinth.done /
|
||||
Math.max(progress.modrinth.total, 1)) *
|
||||
100,
|
||||
)}
|
||||
status={
|
||||
progress.modrinth.status === "failed"
|
||||
? "exception"
|
||||
: undefined
|
||||
}
|
||||
format={() =>
|
||||
`${progress.modrinth.done} / ${progress.modrinth.total}`
|
||||
}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</Flex>
|
||||
)}
|
||||
{hasCurseforge && (
|
||||
<Flex align="center" gap={token.marginXS}>
|
||||
<img
|
||||
src="/platform/curseforge.svg"
|
||||
alt="CurseForge"
|
||||
style={{ width: 30, height: 30, flexShrink: 0 }}
|
||||
/>
|
||||
<Text style={{ width: 80, flexShrink: 0, fontFamily: "Inter", fontWeight: "bold" }}>CurseForge</Text>
|
||||
<Progress
|
||||
percent={Math.round(
|
||||
(progress.curseforge.done /
|
||||
Math.max(progress.curseforge.total, 1)) *
|
||||
100,
|
||||
)}
|
||||
status={
|
||||
progress.curseforge.status === "failed"
|
||||
? "exception"
|
||||
: undefined
|
||||
}
|
||||
format={() =>
|
||||
`${progress.curseforge.done} / ${progress.curseforge.total}`
|
||||
}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
</div>
|
||||
|
||||
{/* Result */}
|
||||
{updateDone && updateResult && (
|
||||
<Alert
|
||||
type={updateResult.failed > 0 ? "warning" : "success"}
|
||||
title="更新结果"
|
||||
description={
|
||||
<div>
|
||||
<div>
|
||||
成功 {updateResult.success} 个,失败{" "}
|
||||
{updateResult.failed} 个
|
||||
</div>
|
||||
{platforms.map((platform) => {
|
||||
const errors = updateResult.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>
|
||||
);
|
||||
};
|
||||
|
||||
// --------------- Render ---------------
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="更新版本"
|
||||
open={open}
|
||||
onCancel={handleClose}
|
||||
width={1100}
|
||||
destroyOnHidden
|
||||
footer={
|
||||
updating ? null : (
|
||||
<Space>
|
||||
{currentStep === 0 ? (
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleConfirmUpdate}
|
||||
disabled={!canProceed}
|
||||
>
|
||||
确认更新
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="primary" onClick={handleClose}>
|
||||
关闭
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Steps
|
||||
current={currentStep}
|
||||
size="small"
|
||||
style={{ marginBottom: 24 }}
|
||||
items={[
|
||||
{ title: "版本选择" },
|
||||
{ title: "更新进度" },
|
||||
]}
|
||||
/>
|
||||
|
||||
{currentStep === 0 ? renderStep1() : renderStep2()}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -66,7 +66,12 @@ export class ModrinthClient {
|
||||
* 成功响应为 204 No Content,无响应体,故返回 void。
|
||||
*/
|
||||
async patch(path: string, body: unknown): Promise<void> {
|
||||
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(),
|
||||
|
||||
@@ -25,9 +25,9 @@ export async function interceptUpload(
|
||||
platform: string,
|
||||
method: string,
|
||||
url: string,
|
||||
body: FormData,
|
||||
body: FormData | string,
|
||||
): Promise<unknown> {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<number[]> {
|
||||
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: [
|
||||
return [
|
||||
...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,
|
||||
);
|
||||
...mcRange.map(resolveMinecraftVersionId),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ import { z } from "zod";
|
||||
|
||||
export const platforms = ["modrinth", "curseforge"] as const;
|
||||
|
||||
export interface PlatformContext {
|
||||
input: PublishInput;
|
||||
export interface PlatformContext<I = PublishInput> {
|
||||
input: I;
|
||||
config: Config;
|
||||
}
|
||||
export type PlatformPublishFn = (
|
||||
@@ -19,8 +19,36 @@ export type PlatformPublishFn = (
|
||||
) => Promise<void>;
|
||||
export type PlatformGetGameVersionsFn = () => Promise<string[]>;
|
||||
|
||||
/** 平台上已有版本/文件的规范化视图,供更新流程匹配与 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<UpdateInput>,
|
||||
mcVersion: McVersionEntry,
|
||||
existing: ExistingRelease,
|
||||
) => Promise<void>;
|
||||
export type PlatformListExistingFn = (config: Config) => Promise<ExistingRelease[]>;
|
||||
/** 生成已有版本的模板匹配键(前端预览与服务端正式匹配共用同一逻辑) */
|
||||
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<typeof PublishInputSchema>;
|
||||
export type UpdateInput = z.infer<typeof UpdateInputSchema>;
|
||||
export type ProgressEvent = z.infer<typeof ProgressEventSchema>;
|
||||
export type PlatformResult = z.infer<typeof PlatformResultSchema>;
|
||||
export type PublishResult = z.infer<typeof PublishResultSchema>;
|
||||
|
||||
+183
-26
@@ -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<UpdateInput>,
|
||||
): Promise<McVersionEntry[]> {
|
||||
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<UpdateInput>,
|
||||
): Promise<PlatformResult> {
|
||||
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<PublishResult> => {
|
||||
const config = await getConfig(input.configFile);
|
||||
|
||||
const ctx: PlatformContext<UpdateInput> = { 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,
|
||||
|
||||
@@ -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<ExistingReleases> {
|
||||
const [modrinth, curseforge] = await Promise.all([
|
||||
listExistingModrinth(config),
|
||||
listExistingCurseForge(config),
|
||||
]);
|
||||
return { modrinth, curseforge };
|
||||
}
|
||||
|
||||
@@ -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<typeof CurseForgeFileSchema>;
|
||||
// endregion
|
||||
|
||||
@@ -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<typeof VersionSchema>;
|
||||
// endregion
|
||||
|
||||
Reference in New Issue
Block a user