ModReleaser/src/components/PublishModal.tsx
2026-07-17 19:15:41 +08:00

642 lines
20 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, UploadReleaseType } from "@/types";
import type { ParsedProject } from "@/services/project";
import { platforms } from "@/services/publish.schemas";
import type { PlatformResult, ProgressEvent } from "@/services/publish.schemas";
import { Template } from "@/lib/utils/template";
import type { McVersionEntry } from "@/lib/utils/mcVersion";
import { computeVersionRanges } from "@/lib/utils/mcVersion";
import { getModrinthMcVersions, getCurseForgeMcVersions } from "@/services/meta";
import { trpc } from "@/lib/trpc";
import { ChangelogEditor } from "./ChangelogEditor";
const { Text } = Typography;
// ---------------------------------------------------------------------------
// 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;
}
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 loadExisting = useCallback(async () => {
setLoadingExisting(true);
try {
const [mrVersions, cfVersions] = await Promise.all([
getModrinthMcVersions(),
getCurseForgeMcVersions(),
]);
setModrinthMcVersions(mrVersions);
setCurseforgeMcVersions(cfVersions);
// TODO: Fetch existing versions from both platforms via tRPC/Server Action
setExistingModrinth([]);
setExistingCurseforge([]);
} catch (e) {
console.error(e);
} finally {
setLoadingExisting(false);
}
}, []);
// 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],
);
// 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 ---------------
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 })),
},
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",
},
{
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: 100,
},
{
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}>
<Col span={14}>
<Text strong style={{ display: "block", marginBottom: token.marginXS }}>
Modrinth
</Text>
{publishVersions.length > 0 ? (
<Table
dataSource={publishVersions}
columns={modrinthColumns}
rowSelection={{
selectedRowKeys: selectedModrinth,
onChange: (keys) => setSelectedModrinth(keys as string[]),
getCheckboxProps: (r: PublishVersion) => ({
disabled: r.existsModrinth,
}),
}}
pagination={false}
size="small"
bordered
/>
) : (
<Empty description="无版本" />
)}
</Col>
<Col span={10}>
<Text strong style={{ display: "block", marginBottom: token.marginXS }}>
CurseForge
</Text>
{publishVersions.length > 0 ? (
<Table
dataSource={publishVersions}
columns={curseforgeColumns}
rowSelection={{
selectedRowKeys: selectedCurseforge,
onChange: (keys) => setSelectedCurseforge(keys as string[]),
getCheckboxProps: (r: PublishVersion) => ({
disabled: r.existsCurseforge,
}),
}}
pagination={false}
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}>
</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>
);
}