diff --git a/package-lock.json b/package-lock.json
index ac64ab5..9d0e0c9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -6,6 +6,7 @@
"": {
"dependencies": {
"@ant-design/icons": "^6.3.2",
+ "@ant-design/nextjs-registry": "^1.3.0",
"@tanstack/react-query": "^5.101.2",
"@trpc/client": "^11.18.0",
"@trpc/server": "^11.18.0",
@@ -99,6 +100,18 @@
"resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.5.0.tgz",
"integrity": "sha512-1BTUFyKPTBZ53MuTP8s0k5SFEXL7o3VHEOwLgzaoWKwnBeqIcqUtVshc4SKzhI6uACfqhJqBwBUE9FsWR3uULA=="
},
+ "node_modules/@ant-design/nextjs-registry": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@ant-design/nextjs-registry/-/nextjs-registry-1.3.0.tgz",
+ "integrity": "sha512-lHlg5bnj3LPYHUD+iZsO1k11NhIrx1mLWh2DXQ0m3Ly3OSaYfgeHX0WoVXxdVjEGjjYnPTe+OUY7YvU/p1aPZw==",
+ "peerDependencies": {
+ "@ant-design/cssinjs": ">=1.0.0",
+ "antd": ">=5.0.0",
+ "next": ">=14.0.0",
+ "react": ">=16.0.0",
+ "react-dom": ">=16.0.0"
+ }
+ },
"node_modules/@ant-design/react-slick": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-2.0.0.tgz",
@@ -5859,6 +5872,12 @@
"resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.5.0.tgz",
"integrity": "sha512-1BTUFyKPTBZ53MuTP8s0k5SFEXL7o3VHEOwLgzaoWKwnBeqIcqUtVshc4SKzhI6uACfqhJqBwBUE9FsWR3uULA=="
},
+ "@ant-design/nextjs-registry": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@ant-design/nextjs-registry/-/nextjs-registry-1.3.0.tgz",
+ "integrity": "sha512-lHlg5bnj3LPYHUD+iZsO1k11NhIrx1mLWh2DXQ0m3Ly3OSaYfgeHX0WoVXxdVjEGjjYnPTe+OUY7YvU/p1aPZw==",
+ "requires": {}
+ },
"@ant-design/react-slick": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-2.0.0.tgz",
diff --git a/package.json b/package.json
index 60874fe..a35c258 100644
--- a/package.json
+++ b/package.json
@@ -9,6 +9,7 @@
},
"dependencies": {
"@ant-design/icons": "^6.3.2",
+ "@ant-design/nextjs-registry": "^1.3.0",
"@tanstack/react-query": "^5.101.2",
"@trpc/client": "^11.18.0",
"@trpc/server": "^11.18.0",
diff --git a/src/app/globals.css b/src/app/globals.css
new file mode 100644
index 0000000..abfbbb0
--- /dev/null
+++ b/src/app/globals.css
@@ -0,0 +1,26 @@
+/* Global reset & base styles — follow Ant Design 3-layer surface model */
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+html,
+body {
+ height: 100%;
+ font-family:
+ -apple-system,
+ BlinkMacSystemFont,
+ "Segoe UI",
+ Roboto,
+ "Helvetica Neue",
+ Arial,
+ "Noto Sans",
+ sans-serif;
+ font-size: 14px;
+ line-height: 1.5715;
+ color: #1f1f1f; /* Ant Design on-surface / colorText */
+ background-color: #f5f5f5; /* Ant Design bg-layout */
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index dbce4ea..535d7fb 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,11 +1,22 @@
+import type { Metadata } from "next";
+import { Providers } from "./providers";
+import "./globals.css";
+
+export const metadata: Metadata = {
+ title: "Mod Releaser",
+ description: "一键向 Modrinth 和 CurseForge 发布 mod 新版本",
+};
+
export default function RootLayout({
children,
}: {
- children: React.ReactNode
+ children: React.ReactNode;
}) {
return (
-
-
{children}
+
+
+ {children}
+
- )
+ );
}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 6baa6ad..af6b686 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,3 +1,8 @@
-export default function Page() {
- return Hello, Next.js!
+import { listConfigs } from "@/services/config";
+import { MainPage } from "@/components/MainPage";
+
+export default async function Page() {
+ const configList = await listConfigs();
+
+ return ;
}
diff --git a/src/app/providers.tsx b/src/app/providers.tsx
new file mode 100644
index 0000000..e0cdefd
--- /dev/null
+++ b/src/app/providers.tsx
@@ -0,0 +1,21 @@
+"use client";
+
+import { AntdRegistry } from "@ant-design/nextjs-registry";
+import { App, ConfigProvider } from "antd";
+
+export function Providers({ children }: { children: React.ReactNode }) {
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/src/components/ConfigModal.tsx b/src/components/ConfigModal.tsx
new file mode 100644
index 0000000..339225b
--- /dev/null
+++ b/src/components/ConfigModal.tsx
@@ -0,0 +1,609 @@
+"use client";
+
+import { useEffect, useState, useCallback } from "react";
+import {
+ Modal,
+ Form,
+ Input,
+ InputNumber,
+ Select,
+ Tabs,
+ Button,
+ Space,
+ App,
+ Spin,
+} from "antd";
+import { PlusOutlined, DeleteOutlined } from "@ant-design/icons";
+import { useDebouncedCallback } from "use-debounce";
+import { createConfig, updateConfig } from "@/services/config";
+import {
+ getModrinthLoaders,
+ getModrinthEnvironments,
+ getCurseForgeMeta,
+} from "@/services/meta";
+import type { Config } from "@/types";
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+const DEPENDENCY_TYPES = [
+ { label: "Required", value: "required" },
+ { label: "Optional", value: "optional" },
+ { label: "Incompatible", value: "incompatible" },
+ { label: "Embedded", value: "embedded" },
+];
+
+const RELATION_TYPES = [
+ { label: "Embedded Library", value: "embeddedLibrary" },
+ { label: "Incompatible", value: "incompatible" },
+ { label: "Optional Dependency", value: "optionalDependency" },
+ { label: "Required Dependency", value: "requiredDependency" },
+ { label: "Tool", value: "tool" },
+];
+
+type ConfigValues = Config;
+
+// ---------------------------------------------------------------------------
+// Outer component — Modal shell, meta loading, state
+// ---------------------------------------------------------------------------
+
+interface ConfigModalProps {
+ open: boolean;
+ mode: "add" | "edit";
+ initialName: string | null;
+ initialConfig: Config | null;
+ onCancel: () => void;
+ onSaved: (file: string, name: string) => void;
+}
+
+export function ConfigModal({
+ open,
+ mode,
+ initialName,
+ initialConfig,
+ onCancel,
+ onSaved,
+}: ConfigModalProps) {
+ const { message } = App.useApp();
+ const [saving, setSaving] = useState(false);
+
+ // --------------- Metadata ---------------
+ const [metaLoading, setMetaLoading] = useState(false);
+ const [meta, setMeta] = useState<{
+ modrinthLoaders: string[];
+ modrinthEnvironments: { value: string; label: string }[];
+ curseforgeEnvs: string[];
+ curseforgeLoaders: string[];
+ } | null>(null);
+
+ const loadMeta = useCallback(async () => {
+ setMetaLoading(true);
+ try {
+ const [loaders, envs, cfMeta] = await Promise.all([
+ getModrinthLoaders(),
+ getModrinthEnvironments(),
+ getCurseForgeMeta(),
+ ]);
+ setMeta({
+ modrinthLoaders: loaders,
+ modrinthEnvironments: envs,
+ curseforgeEnvs: cfMeta.environments,
+ curseforgeLoaders: cfMeta.loaders,
+ });
+ } catch {
+ message.warning("部分元数据加载失败,手动输入仍然可用");
+ } finally {
+ setMetaLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (open) loadMeta();
+ }, [open, loadMeta]);
+
+ // --------------- Save ---------------
+
+ const handleSave = useCallback(
+ async (values: ConfigValues) => {
+ const data: Config = {
+ ...values,
+ modrinth: {
+ ...values.modrinth,
+ dependencies: values.modrinth.dependencies ?? [],
+ },
+ curseforge: {
+ ...values.curseforge,
+ relations: values.curseforge.relations ?? { projects: [] },
+ },
+ };
+
+ setSaving(true);
+ try {
+ if (mode === "add") {
+ await createConfig(data);
+ onSaved(`${data.name}.json`, data.name);
+ message.success("配置已创建");
+ } else {
+ const oldName = (initialName ?? "").replace(/\.json$/, "");
+ await updateConfig(oldName, data);
+ onSaved(`${data.name}.json`, data.name);
+ message.success("配置已更新");
+ }
+ } catch (err) {
+ message.error(
+ err instanceof Error ? err.message : "保存配置失败",
+ );
+ } finally {
+ setSaving(false);
+ }
+ },
+ [mode, initialName, onSaved],
+ );
+
+ // --------------- Debounced project name lookup ---------------
+
+ const [modrinthProjectName, setModrinthProjectName] = useState(null);
+ const [curseforgeProjectName, setCurseforgeProjectName] = useState(null);
+
+ const lookupModrinthProject = useDebouncedCallback(async (projectId: string) => {
+ if (!projectId) {
+ setModrinthProjectName(null);
+ return;
+ }
+ // TODO: call Modrinth API to get project name
+ setModrinthProjectName(null);
+ }, 800);
+
+ const lookupCurseforgeProject = useDebouncedCallback(async (projectId: number | null) => {
+ if (projectId == null) {
+ setCurseforgeProjectName(null);
+ return;
+ }
+ // TODO: call CurseForge API to get project name
+ setCurseforgeProjectName(null);
+ }, 800);
+
+ // --------------- Render ---------------
+
+ return (
+
+
+
+
+ }
+ >
+ {metaLoading ? (
+
+
+
+ ) : meta ? (
+
+ ) : null}
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Inner component — Form (only rendered when meta is loaded)
+// ---------------------------------------------------------------------------
+
+interface ConfigModalFormProps {
+ mode: "add" | "edit";
+ initialName: string | null;
+ initialConfig: Config | null;
+ meta: {
+ modrinthLoaders: string[];
+ modrinthEnvironments: { value: string; label: string }[];
+ curseforgeEnvs: string[];
+ curseforgeLoaders: string[];
+ };
+ modrinthProjectName: string | null;
+ curseforgeProjectName: string | null;
+ lookupModrinthProject: (projectId: string) => void;
+ lookupCurseforgeProject: (projectId: number | null) => void;
+ onSave: (values: ConfigValues) => void;
+}
+
+function ConfigModalForm({
+ mode,
+ initialConfig,
+ meta,
+ modrinthProjectName,
+ curseforgeProjectName,
+ lookupModrinthProject,
+ lookupCurseforgeProject,
+ onSave,
+}: ConfigModalFormProps) {
+ const [form] = Form.useForm();
+
+ // Reset form when modal opens or mode/config changes
+ useEffect(() => {
+ if (mode === "edit" && initialConfig) {
+ form.setFieldsValue(initialConfig);
+ } else {
+ form.resetFields();
+ form.setFieldsValue({
+ minecraft_properties_dir: "./properties",
+ project_properties_path: "./gradle.properties",
+ mod_version_field: "mod_version",
+ filename_format: "",
+ source_filename_format: "",
+ modrinth: {
+ project_id: "",
+ version_name: "ModName v${version} for Minecraft ${mc_version_range}",
+ version: "${version}-mc${mc_version}",
+ loaders: [],
+ environment: "client_and_server",
+ dependencies: [],
+ } as Config["modrinth"],
+ curseforge: {
+ project_id: 0,
+ version_name: "#{filename_format}",
+ environment: [],
+ loaders: [],
+ relations: { projects: [] },
+ } as Config["curseforge"],
+ } as Partial);
+ }
+ }, [mode, initialConfig, form]);
+
+ const handleFinish = useCallback(
+ async (values: ConfigValues) => {
+ onSave(values);
+ },
+ [onSave],
+ );
+
+ const handleFinishFailed = useCallback(() => {
+ // Ant Design shows inline validation errors
+ }, []);
+
+ return (
+
+ );
+}
diff --git a/src/components/ConfigSelector.tsx b/src/components/ConfigSelector.tsx
new file mode 100644
index 0000000..1f6d39e
--- /dev/null
+++ b/src/components/ConfigSelector.tsx
@@ -0,0 +1,135 @@
+"use client";
+
+import { useState, useCallback } from "react";
+import { Select, Button, Space, App } from "antd";
+import { EditOutlined, PlusOutlined } from "@ant-design/icons";
+import { ConfigModal } from "./ConfigModal";
+import { getConfig } from "@/services/config";
+import { parseProject } from "@/services/project";
+import type { Config } from "@/types";
+import type { ParsedProject } from "@/services/project";
+
+interface ConfigEntry {
+ name: string;
+ file: string;
+}
+
+interface ConfigSelectorProps {
+ configs: ConfigEntry[];
+ selectedFile: string | null;
+ onSelect: (file: string, config: Config, project: ParsedProject) => void;
+ onConfigAdded: (entry: ConfigEntry) => void;
+ onConfigUpdated: (entry: ConfigEntry) => void;
+}
+
+/** Strip .json suffix to get the config name */
+function toConfigName(file: string): string {
+ return file.replace(/\.json$/, "");
+}
+
+export function ConfigSelector({
+ configs,
+ selectedFile,
+ onSelect,
+ onConfigAdded,
+ onConfigUpdated,
+}: ConfigSelectorProps) {
+ const { message } = App.useApp();
+ const [selecting, setSelecting] = useState(false);
+
+ // Config modal state
+ const [modalOpen, setModalOpen] = useState(false);
+ const [modalMode, setModalMode] = useState<"add" | "edit">("add");
+ const [modalInitial, setModalInitial] = useState<{
+ name: string;
+ config: Config;
+ } | null>(null);
+
+ // Handle config selection
+ const handleChange = useCallback(
+ async (file: string) => {
+ setSelecting(true);
+ try {
+ const config = await getConfig(toConfigName(file));
+ const project = await parseProject(config);
+ onSelect(file, config, project);
+ } catch (err) {
+ message.error(
+ err instanceof Error ? err.message : "解析项目配置失败",
+ );
+ } finally {
+ setSelecting(false);
+ }
+ },
+ [onSelect],
+ );
+
+ // Open add modal
+ const openAdd = useCallback(() => {
+ setModalMode("add");
+ setModalInitial(null);
+ setModalOpen(true);
+ }, []);
+
+ // Open edit modal
+ const openEdit = useCallback(async () => {
+ if (!selectedFile) {
+ message.warning("请先选择一个配置");
+ return;
+ }
+ try {
+ const config = await getConfig(toConfigName(selectedFile));
+ setModalMode("edit");
+ setModalInitial({ name: selectedFile, config });
+ setModalOpen(true);
+ } catch (err) {
+ message.error("读取配置失败");
+ }
+ }, [selectedFile]);
+
+ // Modal save callback
+ const handleModalSaved = useCallback(
+ (file: string, name: string) => {
+ if (modalMode === "add") {
+ onConfigAdded({ name, file });
+ } else {
+ onConfigUpdated({ name, file });
+ }
+ setModalOpen(false);
+ },
+ [modalMode, onConfigAdded, onConfigUpdated],
+ );
+
+ return (
+ <>
+
+
+
+ setModalOpen(false)}
+ onSaved={handleModalSaved}
+ />
+ >
+ );
+}
diff --git a/src/components/MainPage.tsx b/src/components/MainPage.tsx
new file mode 100644
index 0000000..92ddd1a
--- /dev/null
+++ b/src/components/MainPage.tsx
@@ -0,0 +1,130 @@
+"use client";
+
+import { useState, useCallback } from "react";
+import { Typography, Space, Card, Button } from "antd";
+import { CloudUploadOutlined } from "@ant-design/icons";
+import { ConfigSelector } from "./ConfigSelector";
+import { VersionTable } from "./VersionTable";
+import { PublishModal } from "./PublishModal";
+import type { Config } from "@/types";
+import type { ParsedProject } from "@/services/project";
+
+const { Title } = Typography;
+
+interface MainPageProps {
+ initialConfigs: { name: string; file: string }[];
+}
+
+export function MainPage({ initialConfigs }: MainPageProps) {
+ const [configs, setConfigs] =
+ useState<{ name: string; file: string }[]>(initialConfigs);
+ const [selectedConfigFile, setSelectedConfigFile] = useState(
+ null,
+ );
+ const [selectedConfig, setSelectedConfig] = useState(null);
+ const [parsedProject, setParsedProject] = useState(
+ null,
+ );
+ const [projectLoading, setProjectLoading] = useState(false);
+
+ // ------- ConfigSelector callbacks -------
+
+ const handleConfigSelect = useCallback(
+ async (file: string, config: Config, project: ParsedProject) => {
+ setSelectedConfigFile(file);
+ setSelectedConfig(config);
+ setParsedProject(project);
+ },
+ [],
+ );
+
+ const handleConfigAdded = useCallback(
+ (entry: { name: string; file: string }) => {
+ setConfigs((prev) => [...prev, entry]);
+ },
+ [],
+ );
+
+ const handleConfigUpdated = useCallback(
+ (entry: { name: string; file: string }) => {
+ setConfigs((prev) =>
+ prev.map((c) => (c.file === entry.file ? entry : c)),
+ );
+ },
+ [],
+ );
+
+ // ------- Publish -------
+
+ const [publishOpen, setPublishOpen] = useState(false);
+
+ const handlePublishClose = useCallback(() => {
+ setPublishOpen(false);
+ }, []);
+
+ return (
+
+ {/* Page header */}
+
+ Mod Releaser
+
+
+
+ {/* 1. Config selector */}
+
+
+ {/* 2. Version table */}
+
+
+
+ {/* Publish button */}
+ {parsedProject && parsedProject.artifacts.length > 0 && (
+
+ }
+ size="large"
+ onClick={() => setPublishOpen(true)}
+ disabled={!selectedConfig}
+ >
+ 一键发布
+
+
+ )}
+
+
+
+ {/* 3. Publish modal */}
+ {publishOpen && selectedConfig && parsedProject && (
+
+ )}
+
+ );
+}
diff --git a/src/components/PublishModal.tsx b/src/components/PublishModal.tsx
new file mode 100644
index 0000000..83e8572
--- /dev/null
+++ b/src/components/PublishModal.tsx
@@ -0,0 +1,412 @@
+"use client";
+
+import { useState, useCallback } from "react";
+import {
+ Modal,
+ Steps,
+ Button,
+ Space,
+ Checkbox,
+ Table,
+ Select,
+ Row,
+ Col,
+ Progress,
+ Typography,
+ Empty,
+} from "antd";
+import { ReloadOutlined } from "@ant-design/icons";
+import type { Config } from "@/types";
+import type { ParsedProject } from "@/services/project";
+
+const { Text } = Typography;
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+interface PublishVersion {
+ key: string;
+ mc_version: string;
+ artifact: string | null;
+ sources: string | null;
+ // version label that matches the platform's versioning
+ label: string;
+ // already exists on this platform?
+ existsModrinth: boolean;
+ existsCurseforge: boolean;
+}
+
+interface PublishModalProps {
+ open: boolean;
+ config: Config;
+ project: ParsedProject;
+ onClose: () => void;
+}
+
+// ---------------------------------------------------------------------------
+// Component
+// ---------------------------------------------------------------------------
+
+export function PublishModal({
+ open,
+ config,
+ project,
+ onClose,
+}: PublishModalProps) {
+ const [currentStep, setCurrentStep] = useState(0);
+
+ // Version selection
+ const [selectedVersions, setSelectedVersions] = useState>(
+ new Set(),
+ );
+
+ // Publish settings
+ const [changelog, setChangelog] = useState("");
+ const [versionType, setVersionType] = useState<"release" | "beta" | "alpha">(
+ "release",
+ );
+
+ // Progress
+ const [publishing, setPublishing] = useState(false);
+ const [progress, setProgress] = useState<{
+ modrinth: { done: number; total: number };
+ curseforge: { done: number; total: number };
+ }>({ modrinth: { done: 0, total: 0 }, curseforge: { done: 0, total: 0 } });
+
+ // Platform existing versions — loaded when modal opens
+ const [loadingExisting, setLoadingExisting] = useState(false);
+ const [existingModrinth, setExistingModrinth] = useState([]);
+ const [existingCurseforge, setExistingCurseforge] = useState([]);
+
+ const loadExisting = useCallback(async () => {
+ setLoadingExisting(true);
+ try {
+ // TODO: Fetch existing versions from both platforms via tRPC/Server Action
+ // For now, placeholder
+ setExistingModrinth([]);
+ setExistingCurseforge([]);
+ } catch {
+ // ignore
+ } finally {
+ setLoadingExisting(false);
+ }
+ }, []);
+
+ // Build publish version rows from project artifacts
+ const publishVersions: PublishVersion[] = project.artifacts.map((a) => ({
+ key: a.mc_version,
+ mc_version: a.mc_version,
+ artifact: a.artifact,
+ sources: a.sources,
+ label: "", // TODO: compute from version template
+ existsModrinth: existingModrinth.includes(a.mc_version),
+ existsCurseforge: existingCurseforge.includes(a.mc_version),
+ }));
+
+ // --------------- Steps handling ---------------
+
+ const handleNext = useCallback(() => {
+ if (currentStep === 0) {
+ // Init selection with all non-existing entries
+ const preselected = new Set();
+ for (const v of publishVersions) {
+ if (!v.existsModrinth || !v.existsCurseforge) {
+ preselected.add(v.key);
+ }
+ }
+ setSelectedVersions(preselected);
+ }
+ setCurrentStep((prev) => Math.min(prev + 1, 1));
+ }, [currentStep, publishVersions]);
+
+ const handlePrev = useCallback(() => {
+ setCurrentStep((prev) => Math.max(prev - 1, 0));
+ }, []);
+
+ const handleConfirmPublish = useCallback(async () => {
+ setPublishing(true);
+ // TODO: Call tRPC mutation to publish
+ setPublishing(false);
+ }, []);
+
+ const handleClose = useCallback(() => {
+ if (publishing) return;
+ setCurrentStep(0);
+ setSelectedVersions(new Set());
+ setChangelog("");
+ setVersionType("release");
+ onClose();
+ }, [publishing, onClose]);
+
+ // --------------- Render: Step 1 — Version Selection ---------------
+
+ const renderStep1 = () => {
+ const modrinthColumns = [
+ {
+ title: "MC 版本",
+ dataIndex: "mc_version",
+ key: "mc_version",
+ width: 100,
+ },
+ {
+ title: "构建产物",
+ dataIndex: "artifact",
+ key: "artifact",
+ ellipsis: true,
+ },
+ {
+ title: "",
+ key: "select",
+ width: 50,
+ render: (_: unknown, record: PublishVersion) => (
+ {
+ const next = new Set(selectedVersions);
+ if (e.target.checked) next.add(record.key);
+ else next.delete(record.key);
+ setSelectedVersions(next);
+ }}
+ />
+ ),
+ },
+ ];
+
+ const curseforgeColumns = [
+ {
+ title: "MC 版本",
+ dataIndex: "mc_version",
+ key: "mc_version",
+ width: 100,
+ },
+ {
+ title: "构建产物",
+ dataIndex: "artifact",
+ key: "artifact",
+ ellipsis: true,
+ },
+ {
+ title: "",
+ key: "select",
+ width: 50,
+ render: (_: unknown, record: PublishVersion) => (
+ {
+ const next = new Set(selectedVersions);
+ if (e.target.checked) next.add(record.key);
+ else next.delete(record.key);
+ setSelectedVersions(next);
+ }}
+ />
+ ),
+ },
+ ];
+
+ return (
+
+
+ 选择要发布的版本
+ }
+ loading={loadingExisting}
+ onClick={loadExisting}
+ size="small"
+ >
+ 刷新已有版本
+
+
+
+ {loadingExisting ? (
+
加载中…
+ ) : (
+
+
+
+ Modrinth
+
+ {publishVersions.length > 0 ? (
+
+ ) : (
+
+ )}
+
+
+
+ CurseForge
+
+ {publishVersions.length > 0 ? (
+
+ ) : (
+
+ )}
+
+
+ )}
+
+ );
+ };
+
+ // --------------- Render: Step 2 — Publish Settings ---------------
+
+ const renderStep2 = () => {
+ const hasModrinth = publishVersions.some(
+ (v) =>
+ !v.existsModrinth &&
+ (selectedVersions.has(v.key) || v.existsModrinth),
+ );
+ const hasCurseforge = publishVersions.some(
+ (v) =>
+ !v.existsCurseforge &&
+ (selectedVersions.has(v.key) || v.existsCurseforge),
+ );
+
+ return (
+
+ {/* Changelog */}
+
+
+ Changelog (Markdown)
+
+ {/* TODO: Replace with @uiw/react-md-editor */}
+
+
+ {/* Version type */}
+
+
+ 版本类型
+
+
+
+ {/* Publishing progress */}
+ {publishing && (
+
+ {hasModrinth && (
+
+
Modrinth:
+
+ )}
+ {hasCurseforge && (
+
+
CurseForge:
+
+ )}
+
+ )}
+
+ );
+ };
+
+ // --------------- Render ---------------
+
+ return (
+
+ {currentStep > 0 && (
+
+ )}
+ {currentStep < 1 ? (
+
+ ) : (
+
+ )}
+
+ )
+ }
+ >
+
+
+ {currentStep === 0 ? renderStep1() : renderStep2()}
+
+ );
+}
diff --git a/src/components/VersionTable.tsx b/src/components/VersionTable.tsx
new file mode 100644
index 0000000..526c679
--- /dev/null
+++ b/src/components/VersionTable.tsx
@@ -0,0 +1,108 @@
+"use client";
+
+import { Table, Empty, Tag } from "antd";
+import { useMemo } from "react";
+import type { ParsedProject } from "@/services/project";
+
+interface ArtifactRow {
+ key: string;
+ mc_version: string;
+ artifact: string | null;
+ sources: string | null;
+}
+
+interface VersionTableProps {
+ project: ParsedProject | null;
+ loading: boolean;
+ onLoadingChange: (loading: boolean) => void;
+}
+
+export function VersionTable({
+ project,
+ loading,
+ onLoadingChange,
+}: VersionTableProps) {
+ const dataSource: ArtifactRow[] = useMemo(() => {
+ if (!project) return [];
+ return project.artifacts.map((a) => ({
+ key: a.mc_version,
+ mc_version: a.mc_version,
+ artifact: a.artifact,
+ sources: a.sources,
+ }));
+ }, [project]);
+
+ const columns = [
+ {
+ title: "MC 版本",
+ dataIndex: "mc_version",
+ key: "mc_version",
+ width: 120,
+ render: (val: string) => {val},
+ },
+ {
+ title: "兼容范围",
+ dataIndex: "mc_version",
+ key: "range",
+ width: 200,
+ render: (_: string, __: ArtifactRow, index: number) => {
+ const isLast =
+ project && index === project.artifacts.length - 1;
+ // 兼容范围在发布前由 PublishModal 加载元数据后完整计算
+ // 此处仅作占位显示
+ return isLast ? (
+ 加载元数据后计算
+ ) : (
+ —
+ );
+ },
+ },
+ {
+ title: "构建产物",
+ dataIndex: "artifact",
+ key: "artifact",
+ ellipsis: true,
+ render: (val: string | null) =>
+ val ? (
+ {val}
+ ) : (
+ 文件不存在
+ ),
+ },
+ {
+ title: "源码",
+ dataIndex: "sources",
+ key: "sources",
+ ellipsis: true,
+ render: (val: string | null) =>
+ val ? (
+ {val}
+ ) : (
+ 文件不存在
+ ),
+ },
+ ];
+
+ if (!project) {
+ return (
+
+ );
+ }
+
+ return (
+
+ dataSource={dataSource}
+ columns={columns}
+ loading={loading}
+ pagination={false}
+ size="middle"
+ bordered
+ locale={{
+ emptyText: "未找到构建产物",
+ }}
+ />
+ );
+}