ModReleaser/src/components/PublishModal.tsx

839 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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 } from "@ant-design/icons";
import { useMutation } from "@tanstack/react-query";
import { useSubscription } from "@trpc/tanstack-react-query";
import type { BaseVersion, Config, CurseForgeFile, UploadReleaseType, Version } from "@/types";
import type { ParsedProject } from "@/services/project";
import { platforms } from "@/services/publish.schemas";
import type { PlatformResult, ProgressEvent } from "@/services/publish.schemas";
import { Template } from "@/lib/utils/template";
import type { McVersionEntry } from "@/lib/utils/mcVersion";
import { computeVersionRanges } from "@/lib/utils/mcVersion";
import { SemVer } from "@/lib/utils/templates/semver";
import { getModrinthMcVersions, getCurseForgeMcVersions } from "@/services/meta";
import { compareVersions } from "@/services/versions";
import { errorMessage } from "@/lib/utils/error";
import { compareParsed, parseExisting } from "@/lib/utils/existingVersion";
import { trpc } from "@/lib/trpc";
import { ChangelogEditor } from "./ChangelogEditor";
const { Text } = Typography;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface PublishVersion {
key: string;
mc_version: string;
artifact: string | null;
sources: string | null;
mrRange: string;
cfRange: string;
modrinthVersion: string;
modrinthVersionName: string;
existsModrinth: boolean;
existsCurseforge: boolean;
}
/** Modrinth 表格行待发布kind="pending"+ 平台已有版本kind="existing" */
interface ModrinthTableRow {
key: string;
kind: "pending" | "existing";
pending: PublishVersion | null;
modrinthVersion: string;
mrRange: string;
modrinthVersionName: string;
}
/** CurseForge 表格行 */
interface CurseforgeTableRow {
key: string;
kind: "pending" | "existing";
pending: PublishVersion | null;
mc_version: string;
cfRange: string;
artifact: string | null;
}
interface PublishModalProps {
open: boolean;
/** 配置文件名configs/ 下的 .json 文件名) */
configFile: string;
config: Config;
project: ParsedProject;
lastVersionEnd: string | null;
onClose: () => void;
}
type ReleaseType = Extract<BaseVersion["version_type"], UploadReleaseType>;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function entriesToMap(
entries: McVersionEntry[] | null,
field: "explicit" | "wildcard",
): Map<string, string> {
const map = new Map<string, string>();
if (entries) {
for (const entry of entries) {
map.set(entry.version, entry[field]);
}
}
return map;
}
const RELEASE_TYPE_LABEL_MAP: Record<ReleaseType, string> = {
release: "Release",
beta: "Beta",
alpha: "Alpha"
};
const RELEASE_TYPES = Object.entries(RELEASE_TYPE_LABEL_MAP).map(v => ({ value: v[0], label: v[1] }));
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
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
const [selectedModrinth, setSelectedModrinth] = useState<string[]>([]);
const [selectedCurseforge, setSelectedCurseforge] = useState<string[]>([]);
// Publish settings
const [changelog, setChangelog] = useState("");
const [versionType, setVersionType] = useState<ReleaseType>(
"release",
);
// Progress & result
const [publishing, setPublishing] = useState(false);
const [publishDone, setPublishDone] = 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 [publishResult, setPublishResult] = useState<{
success: number;
failed: number;
errors: Record<typeof platforms[number], string[]>;
} | null>(null);
// Platform MC versions — loaded when modal opens
const [loadingExisting, setLoadingExisting] = useState(false);
const [modrinthMcVersions, setModrinthMcVersions] = useState<string[]>([]);
const [curseforgeMcVersions, setCurseforgeMcVersions] = useState<string[]>([]);
const [existingModrinth, setExistingModrinth] = useState<string[]>([]);
const [existingCurseforge, setExistingCurseforge] = useState<string[]>([]);
// 两平台全量已有版本(用于第一页下方灰色行展示)
const [modrinthExistingVersions, setModrinthExistingVersions] = useState<
Version[]
>([]);
const [curseforgeExistingFiles, setCurseforgeExistingFiles] = useState<
CurseForgeFile[]
>([]);
const loadExisting = useCallback(async () => {
setLoadingExisting(true);
try {
const mcVersions = project.artifacts.map((a) => a.mc_version);
// 元数据列表和已有版本比对同属第一页数据,任一失败都不允许进入下一步,
// 否则无法区分"未发布"和"没查到",有重复发布的风险
const [mrVersions, cfVersions, comparison] = await Promise.all([
getModrinthMcVersions(),
getCurseForgeMcVersions(),
compareVersions(config, project.version, mcVersions),
]);
setModrinthMcVersions(mrVersions);
setCurseforgeMcVersions(cfVersions);
setExistingModrinth(
mcVersions.filter((v) => comparison.modrinth[v]?.exists),
);
setExistingCurseforge(
mcVersions.filter((v) => comparison.curseforge[v]?.exists),
);
setModrinthExistingVersions(comparison.modrinthVersions);
setCurseforgeExistingFiles(comparison.curseforgeFiles);
} catch (e) {
message.error(
`加载已有版本失败,请检查网络后点击“刷新已有版本”重试:${errorMessage(e)}`,
);
// 清空比对数据,已有版本状态未知时禁用"下一步"
setExistingModrinth([]);
setExistingCurseforge([]);
setModrinthExistingVersions([]);
setCurseforgeExistingFiles([]);
} finally {
setLoadingExisting(false);
}
}, [config, project, message]);
// Auto-load MC versions when modal opens
useEffect(() => {
if (open) {
loadExisting();
}
}, [open, loadExisting]);
// Pre-compute version display values
const versionTmpl = useMemo(() => new Template(config.modrinth.version), [config.modrinth.version]);
const versionNameTmpl = useMemo(() => new Template(config.modrinth.version_name), [config.modrinth.version_name]);
// Compute per-platform version ranges
const projectVersions = useMemo(
() => project.artifacts.map((a) => a.mc_version),
[project],
);
const mrRanges = useMemo(() => {
if (modrinthMcVersions.length === 0) return null;
return computeVersionRanges(projectVersions, modrinthMcVersions, lastVersionEnd ?? undefined);
}, [projectVersions, modrinthMcVersions, lastVersionEnd]);
const cfRanges = useMemo(() => {
if (curseforgeMcVersions.length === 0) return null;
return computeVersionRanges(projectVersions, curseforgeMcVersions, lastVersionEnd ?? undefined);
}, [projectVersions, curseforgeMcVersions, lastVersionEnd]);
// Build per-platform range maps (mc_version → explicit)
const mrExplicitMap = useMemo(() => entriesToMap(mrRanges, "explicit"), [mrRanges]);
const cfExplicitMap = useMemo(() => entriesToMap(cfRanges, "explicit"), [cfRanges]);
// Build publish version rows from project artifacts
const publishVersions: PublishVersion[] = useMemo(() =>
project.artifacts.map((a) => {
// Note: version_name template uses Modrinth explicit range for ${mc_version_range}
const mrRange = mrExplicitMap.get(a.mc_version) ?? a.mc_version;
const tmplValues = {
version: project.version,
mc_version: a.mc_version,
mc_version_range: mrRange,
};
return {
key: a.mc_version,
mc_version: a.mc_version,
artifact: a.artifact,
sources: a.sources,
mrRange: mrExplicitMap.get(a.mc_version) ?? a.mc_version,
cfRange: cfExplicitMap.get(a.mc_version) ?? a.mc_version,
modrinthVersion: versionTmpl.format(tmplValues),
modrinthVersionName: versionNameTmpl.format(tmplValues),
existsModrinth: existingModrinth.includes(a.mc_version),
existsCurseforge: existingCurseforge.includes(a.mc_version),
};
}),
[project, versionTmpl, versionNameTmpl, mrExplicitMap, cfExplicitMap, existingModrinth, existingCurseforge],
);
// ── 第一页表格数据源:待发布行(上)+ 平台已有版本行(下,灰色禁用)──
// 与待发布版本匹配的已有对象不再重复出现在已有版本区
const matchedMrNames = useMemo(
() =>
new Set(
publishVersions
.filter((v) => v.existsModrinth)
.map((v) => v.modrinthVersion),
),
[publishVersions],
);
const modrinthRows: ModrinthTableRow[] = useMemo(() => {
// 行 key 发布时会作为 mc_version 直接传入后端 API严禁加任何前缀
// 此处曾加 "pending-" 前缀,导致后端准备阶段报 Invalid SemVer: "pending-1.19"。
// 教训:所有要传入 API 的 key 都不能加前缀(行类型区分请用 kind 字段)。
const pending: ModrinthTableRow[] = publishVersions.map((v) => ({
key: v.key,
kind: "pending",
pending: v,
modrinthVersion: v.modrinthVersion,
mrRange: v.mrRange,
modrinthVersionName: v.modrinthVersionName,
}));
const existingTmpl = new Template(config.modrinth.version);
const existing = modrinthExistingVersions
.filter((v) => !matchedMrNames.has(v.version_number))
.map((v) => ({
row: {
key: `existing-mr-${v.version_number}`,
kind: "existing" as const,
pending: null,
modrinthVersion: v.version_number,
mrRange: "",
modrinthVersionName: v.name,
},
parsed: parseExisting(existingTmpl, v.version_number),
}))
.sort((a, b) => compareParsed(a.parsed, b.parsed))
.map((e) => e.row);
return [...pending, ...existing];
}, [publishVersions, modrinthExistingVersions, matchedMrNames, config.modrinth.version]);
const matchedCfFileNames = useMemo(
() =>
new Set(
publishVersions
.filter((v) => v.existsCurseforge && v.artifact)
.map((v) => v.artifact as string),
),
[publishVersions],
);
const curseforgeRows: CurseforgeTableRow[] = useMemo(() => {
// 行 key 发布时会作为 mc_version 直接传入后端 API严禁加任何前缀
// 此处曾加 "pending-" 前缀,导致后端准备阶段报 Invalid SemVer: "pending-1.19"。
// 教训:所有要传入 API 的 key 都不能加前缀(行类型区分请用 kind 字段)。
const pending: CurseforgeTableRow[] = publishVersions.map((v) => ({
key: v.key,
kind: "pending",
pending: v,
mc_version: v.mc_version,
cfRange: v.cfRange,
artifact: v.artifact,
}));
const filenameTmpl = new Template(config.filename_format);
const existing = curseforgeExistingFiles
.filter((f) => !matchedCfFileNames.has(f.fileName))
.map((f) => {
const parsed = parseExisting(filenameTmpl, f.fileName);
return {
row: {
key: `existing-cf-${f.fileName}`,
kind: "existing" as const,
pending: null,
mc_version: parsed.mc_version ?? "—",
cfRange: "",
artifact: f.displayName || f.fileName,
},
parsed,
};
})
.sort((a, b) => compareParsed(a.parsed, b.parsed))
.map((e) => e.row);
return [...pending, ...existing];
}, [publishVersions, curseforgeExistingFiles, matchedCfFileNames, config.filename_format]);
/** 新增版本行 className已有版本行用默认样式 */
const rowClassName = useCallback(
(kind: "pending" | "existing", exists: boolean) =>
kind === "pending" && !exists ? "row-new" : "",
[],
);
// 注入 antd 绿色系 token 作为 CSS 变量(供 globals.css 的 .row-new 规则使用)。
// 三个过渡色用 color-mix 在 green-1 / green-2 之间混出半步色阶:
// hover = 25%,选中 = 50%,选中+hover = 75%
const newRowCssVars = {
"--row-new-bg": token.colorSuccessBg,
"--row-new-text": token.colorSuccessText,
"--row-new-bg-hover": `color-mix(in srgb, ${token.colorSuccessBg} 75%, ${token.colorSuccessBgHover} 25%)`,
"--row-new-bg-active": `color-mix(in srgb, ${token.colorSuccessBg} 50%, ${token.colorSuccessBgHover} 50%)`,
"--row-new-bg-active-hover": `color-mix(in srgb, ${token.colorSuccessBg} 25%, ${token.colorSuccessBgHover} 75%)`,
"--row-new-text-active": token.colorSuccessTextActive,
} as React.CSSProperties;
// Auto-select all non-existing versions when data first loads
const initRef = useRef(false);
useEffect(() => {
if (open) {
initRef.current = false;
}
}, [open]);
useEffect(() => {
if (!loadingExisting && publishVersions.length > 0 && !initRef.current) {
initRef.current = true;
setSelectedModrinth(
publishVersions.filter((v) => !v.existsModrinth).map((v) => v.key),
);
setSelectedCurseforge(
publishVersions.filter((v) => !v.existsCurseforge).map((v) => v.key),
);
}
}, [loadingExisting, publishVersions]);
// --------------- Steps handling ---------------
// 进入第二页的前置条件:
// 1. 数据加载完成且成功(元数据为空说明加载失败或未加载,已有版本状态未知);
// 2. 至少勾选一个待发布版本。
const canProceed =
!loadingExisting &&
modrinthMcVersions.length > 0 &&
curseforgeMcVersions.length > 0 &&
(selectedModrinth.length > 0 || selectedCurseforge.length > 0);
const handleNext = useCallback(() => {
setCurrentStep((prev) => Math.min(prev + 1, 2));
}, []);
const handlePrev = useCallback(() => {
setCurrentStep((prev) => Math.max(prev - 1, 0));
}, []);
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);
}, [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 })),
},
allMcVersions: projectVersions,
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;
setCurrentStep(0);
setSelectedModrinth([]);
setSelectedCurseforge([]);
setChangelog("");
setVersionType("release");
setPublishResult(null);
setPublishDone(false);
onClose();
}, [publishing, onClose]);
// --------------- Render: Step 1 — Version Selection ---------------
const renderStep1 = () => {
const modrinthColumns = [
{
title: "版本号",
dataIndex: "modrinthVersion",
key: "modrinthVersion",
width: 150,
},
{
title: "兼容范围",
dataIndex: "mrRange",
key: "mr_range",
width: 100,
render: (val: string) => <Tag>{val}</Tag>,
},
{
title: "版本名称",
dataIndex: "modrinthVersionName",
key: "modrinthVersionName",
},
];
const curseforgeColumns = [
{
title: "MC 版本",
dataIndex: "mc_version",
key: "mc_version",
width: 80,
},
{
title: "兼容范围",
dataIndex: "cfRange",
key: "cf_range",
width: 100,
render: (val: string) => <Tag>{val}</Tag>,
},
{
title: "显示名称",
dataIndex: "artifact",
key: "artifact",
ellipsis: true,
},
];
return (
<div>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: token.margin,
}}
>
<Text strong></Text>
<Button
icon={<ReloadOutlined />}
loading={loadingExisting}
onClick={loadExisting}
size="small"
>
</Button>
</div>
{loadingExisting ? (
<div style={{ textAlign: "center", padding: 40 }}></div>
) : (
<Row gutter={token.margin} className="publish-version-tables" style={newRowCssVars}>
<Col span={14}>
<Text strong style={{ display: "block", marginBottom: token.marginXS }}>
Modrinth
</Text>
{modrinthRows.length > 0 ? (
<Table
dataSource={modrinthRows}
columns={modrinthColumns}
rowKey="key"
rowClassName={(r) =>
rowClassName(
r.kind,
r.kind === "pending"
? (r.pending?.existsModrinth ?? false)
: true,
)
}
rowSelection={{
selectedRowKeys: selectedModrinth,
onChange: (keys) => setSelectedModrinth(keys as string[]),
getCheckboxProps: (r: ModrinthTableRow) => ({
disabled:
r.kind === "existing" ||
(r.pending?.existsModrinth ?? false),
}),
}}
pagination={false}
scroll={{ y: 480 }}
size="small"
bordered
/>
) : (
<Empty description="无版本" />
)}
</Col>
<Col span={10}>
<Text strong style={{ display: "block", marginBottom: token.marginXS }}>
CurseForge
</Text>
{curseforgeRows.length > 0 ? (
<Table
dataSource={curseforgeRows}
columns={curseforgeColumns}
rowKey="key"
rowClassName={(r) =>
rowClassName(
r.kind,
r.kind === "pending"
? (r.pending?.existsCurseforge ?? false)
: true,
)
}
rowSelection={{
selectedRowKeys: selectedCurseforge,
onChange: (keys) => setSelectedCurseforge(keys as string[]),
getCheckboxProps: (r: CurseforgeTableRow) => ({
disabled:
r.kind === "existing" ||
(r.pending?.existsCurseforge ?? false),
}),
}}
pagination={false}
scroll={{ y: 480 }}
size="small"
bordered
/>
) : (
<Empty description="无版本" />
)}
</Col>
</Row>
)}
</div>
);
};
// --------------- Render: Step 2 — Publish Settings ---------------
const renderStep2 = () => {
return (
<div>
{/* Changelog */}
<div style={{ marginBottom: token.marginLG }}>
<Text strong style={{ display: "block", marginBottom: token.marginSM }}>
Changelog (Markdown)
</Text>
<ChangelogEditor value={changelog} onChange={setChangelog} />
</div>
{/* Version type */}
<div>
<Text strong style={{ display: "block", marginBottom: token.marginSM }}>
</Text>
<Select
value={versionType}
onChange={(v) => setVersionType(v)}
style={{ width: 160 }}
options={RELEASE_TYPES}
/>
</div>
</div>
);
};
// --------------- Render: Step 3 — Progress & Result ---------------
const renderStep3 = () => {
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 */}
{publishDone && publishResult && (
<Alert
type={publishResult.failed > 0 ? "warning" : "success"}
title="发布结果"
description={
<div>
<div>
{publishResult.success} {" "}
{publishResult.failed}
</div>
{platforms.map((platform) => {
const errors = publishResult.errors[platform];
if (errors.length === 0) return null;
const label =
platform === "modrinth" ? "Modrinth" : "CurseForge";
return (
<div key={platform} style={{ marginTop: token.marginSM }}>
<Text strong>{label}:</Text>
<ul style={{ margin: "4px 0 0", paddingLeft: 20 }}>
{errors.map((e, i) => (
<li key={i}>
<Text type="danger">{e}</Text>
</li>
))}
</ul>
</div>
);
})}
</div>
}
showIcon
/>
)}
</div>
);
};
// --------------- Render ---------------
return (
<Modal
title="一键发布"
open={open}
onCancel={handleClose}
width={1200}
destroyOnHidden
footer={
publishing ? null : (
<Space>
{currentStep > 0 && currentStep < 2 && (
<Button onClick={handlePrev}></Button>
)}
{currentStep === 0 ? (
<Button
type="primary"
onClick={handleNext}
disabled={!canProceed}
>
</Button>
) : currentStep === 1 ? (
<Button type="primary" onClick={handleConfirmPublish}>
</Button>
) : (
<Button type="primary" onClick={handleClose}>
</Button>
)}
</Space>
)
}
>
<Steps
current={currentStep}
size="small"
style={{ marginBottom: 24 }}
items={[
{ title: "版本选择" },
{ title: "发布设置" },
{ title: "发布进度" },
]}
/>
{currentStep === 0
? renderStep1()
: currentStep === 1
? renderStep2()
: renderStep3()}
</Modal>
);
}