ModReleaser/src/components/PublishModal.tsx

642 lines
20 KiB
TypeScript
Raw Normal View History

2026-07-11 12:13:20 +08:00
"use client";
2026-07-16 20:43:00 +08:00
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
2026-07-11 12:13:20 +08:00
import {
Modal,
Steps,
Button,
Space,
Table,
Select,
Row,
Col,
Flex,
2026-07-11 12:13:20 +08:00
Progress,
Typography,
Empty,
Tag,
Alert,
App,
theme,
2026-07-11 12:13:20 +08:00
} 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";
2026-07-11 12:13:20 +08:00
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";
2026-07-11 12:13:20 +08:00
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;
2026-07-11 12:13:20 +08:00
existsModrinth: boolean;
existsCurseforge: boolean;
}
interface PublishModalProps {
open: boolean;
/** 配置文件名configs/ 下的 .json 文件名) */
configFile: string;
2026-07-11 12:13:20 +08:00
config: Config;
project: ParsedProject;
lastVersionEnd: string | null;
2026-07-11 12:13:20 +08:00
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] }));
2026-07-11 12:13:20 +08:00
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function PublishModal({
open,
configFile,
2026-07-11 12:13:20 +08:00
config,
project,
lastVersionEnd,
2026-07-11 12:13:20 +08:00
onClose,
}: PublishModalProps) {
const { token } = theme.useToken();
const { message } = App.useApp();
2026-07-11 12:13:20 +08:00
const [currentStep, setCurrentStep] = useState(0);
// Version selection
2026-07-16 20:43:00 +08:00
const [selectedModrinth, setSelectedModrinth] = useState<string[]>([]);
const [selectedCurseforge, setSelectedCurseforge] = useState<string[]>([]);
2026-07-11 12:13:20 +08:00
// Publish settings
const [changelog, setChangelog] = useState("");
const [versionType, setVersionType] = useState<ReleaseType>(
2026-07-11 12:13:20 +08:00
"release",
);
// Progress & result
2026-07-11 12:13:20 +08:00
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);
2026-07-11 12:13:20 +08:00
// Platform MC versions — loaded when modal opens
2026-07-11 12:13:20 +08:00
const [loadingExisting, setLoadingExisting] = useState(false);
const [modrinthMcVersions, setModrinthMcVersions] = useState<string[]>([]);
const [curseforgeMcVersions, setCurseforgeMcVersions] = useState<string[]>([]);
2026-07-11 12:13:20 +08:00
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);
2026-07-11 12:13:20 +08:00
// TODO: Fetch existing versions from both platforms via tRPC/Server Action
setExistingModrinth([]);
setExistingCurseforge([]);
} catch (e) {
console.error(e);
2026-07-11 12:13:20 +08:00
} 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]);
2026-07-11 12:13:20 +08:00
// 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],
);
2026-07-11 12:13:20 +08:00
2026-07-16 20:43:00 +08:00
// 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]);
2026-07-11 12:13:20 +08:00
// --------------- Steps handling ---------------
const handleNext = useCallback(() => {
setCurrentStep((prev) => Math.min(prev + 1, 2));
2026-07-16 20:43:00 +08:00
}, []);
2026-07-11 12:13:20 +08:00
const handlePrev = useCallback(() => {
setCurrentStep((prev) => Math.max(prev - 1, 0));
}, []);
const handleConfirmPublish = useCallback(() => {
// 重置状态并进入进度页;真正发起 mutation 在下方 useSubscription 的
// onStarted 里(保证先连上 SSE 再发布,不漏早期进度事件)
2026-07-11 12:13:20 +08:00
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);
},
}),
);
2026-07-11 12:13:20 +08:00
const handleClose = useCallback(() => {
if (publishing) return;
setCurrentStep(0);
2026-07-16 20:43:00 +08:00
setSelectedModrinth([]);
setSelectedCurseforge([]);
2026-07-11 12:13:20 +08:00
setChangelog("");
setVersionType("release");
setPublishResult(null);
setPublishDone(false);
2026-07-11 12:13:20 +08:00
onClose();
}, [publishing, onClose]);
// --------------- Render: Step 1 — Version Selection ---------------
const renderStep1 = () => {
const modrinthColumns = [
{
title: "版本号",
dataIndex: "modrinthVersion",
key: "modrinthVersion",
2026-07-11 12:13:20 +08:00
},
{
title: "兼容范围",
dataIndex: "mrRange",
key: "mr_range",
width: 100,
render: (val: string) => <Tag>{val}</Tag>,
},
2026-07-11 12:13:20 +08:00
{
title: "版本名称",
dataIndex: "modrinthVersionName",
key: "modrinthVersionName",
2026-07-11 12:13:20 +08:00
},
];
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>,
},
2026-07-11 12:13:20 +08:00
{
title: "显示名称",
2026-07-11 12:13:20 +08:00
dataIndex: "artifact",
key: "artifact",
ellipsis: true,
},
];
return (
<div>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: token.margin,
2026-07-11 12:13:20 +08:00
}}
>
<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 }}>
2026-07-11 12:13:20 +08:00
Modrinth
</Text>
{publishVersions.length > 0 ? (
<Table
dataSource={publishVersions}
columns={modrinthColumns}
2026-07-16 20:43:00 +08:00
rowSelection={{
selectedRowKeys: selectedModrinth,
onChange: (keys) => setSelectedModrinth(keys as string[]),
getCheckboxProps: (r: PublishVersion) => ({
disabled: r.existsModrinth,
}),
}}
2026-07-11 12:13:20 +08:00
pagination={false}
size="small"
bordered
/>
) : (
<Empty description="无版本" />
)}
</Col>
<Col span={10}>
<Text strong style={{ display: "block", marginBottom: token.marginXS }}>
2026-07-11 12:13:20 +08:00
CurseForge
</Text>
{publishVersions.length > 0 ? (
<Table
dataSource={publishVersions}
columns={curseforgeColumns}
2026-07-16 20:43:00 +08:00
rowSelection={{
selectedRowKeys: selectedCurseforge,
onChange: (keys) => setSelectedCurseforge(keys as string[]),
getCheckboxProps: (r: PublishVersion) => ({
disabled: r.existsCurseforge,
}),
}}
2026-07-11 12:13:20 +08:00
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 }}>
2026-07-11 12:13:20 +08:00
Changelog (Markdown)
</Text>
<ChangelogEditor value={changelog} onChange={setChangelog} />
2026-07-11 12:13:20 +08:00
</div>
{/* Version type */}
<div>
<Text strong style={{ display: "block", marginBottom: token.marginSM }}>
2026-07-11 12:13:20 +08:00
</Text>
<Select
value={versionType}
onChange={(v) => setVersionType(v)}
style={{ width: 160 }}
options={RELEASE_TYPES}
2026-07-11 12:13:20 +08:00
/>
</div>
</div>
);
};
2026-07-11 12:13:20 +08:00
// --------------- Render: Step 3 — Progress & Result ---------------
const renderStep3 = () => {
2026-07-16 20:43:00 +08:00
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
/>
2026-07-11 12:13:20 +08:00
)}
</div>
);
};
// --------------- Render ---------------
return (
<Modal
title="一键发布"
open={open}
onCancel={handleClose}
width={1200}
2026-07-11 12:13:20 +08:00
destroyOnHidden
footer={
publishing ? null : (
<Space>
{currentStep > 0 && currentStep < 2 && (
2026-07-11 12:13:20 +08:00
<Button onClick={handlePrev}></Button>
)}
{currentStep === 0 ? (
2026-07-11 12:13:20 +08:00
<Button type="primary" onClick={handleNext}>
</Button>
) : currentStep === 1 ? (
2026-07-11 12:13:20 +08:00
<Button type="primary" onClick={handleConfirmPublish}>
</Button>
) : (
<Button type="primary" onClick={handleClose}>
</Button>
2026-07-11 12:13:20 +08:00
)}
</Space>
)
}
>
<Steps
current={currentStep}
size="small"
style={{ marginBottom: 24 }}
items={[
{ title: "版本选择" },
{ title: "发布设置" },
{ title: "发布进度" },
2026-07-11 12:13:20 +08:00
]}
/>
{currentStep === 0
? renderStep1()
: currentStep === 1
? renderStep2()
: renderStep3()}
2026-07-11 12:13:20 +08:00
</Modal>
);
}