Implement project frontend page
This commit is contained in:
parent
b5555d5635
commit
8d23ae1823
19
package-lock.json
generated
19
package-lock.json
generated
@ -6,6 +6,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^6.3.2",
|
"@ant-design/icons": "^6.3.2",
|
||||||
|
"@ant-design/nextjs-registry": "^1.3.0",
|
||||||
"@tanstack/react-query": "^5.101.2",
|
"@tanstack/react-query": "^5.101.2",
|
||||||
"@trpc/client": "^11.18.0",
|
"@trpc/client": "^11.18.0",
|
||||||
"@trpc/server": "^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",
|
"resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.5.0.tgz",
|
||||||
"integrity": "sha512-1BTUFyKPTBZ53MuTP8s0k5SFEXL7o3VHEOwLgzaoWKwnBeqIcqUtVshc4SKzhI6uACfqhJqBwBUE9FsWR3uULA=="
|
"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": {
|
"node_modules/@ant-design/react-slick": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-2.0.0.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.5.0.tgz",
|
||||||
"integrity": "sha512-1BTUFyKPTBZ53MuTP8s0k5SFEXL7o3VHEOwLgzaoWKwnBeqIcqUtVshc4SKzhI6uACfqhJqBwBUE9FsWR3uULA=="
|
"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": {
|
"@ant-design/react-slick": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-2.0.0.tgz",
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^6.3.2",
|
"@ant-design/icons": "^6.3.2",
|
||||||
|
"@ant-design/nextjs-registry": "^1.3.0",
|
||||||
"@tanstack/react-query": "^5.101.2",
|
"@tanstack/react-query": "^5.101.2",
|
||||||
"@trpc/client": "^11.18.0",
|
"@trpc/client": "^11.18.0",
|
||||||
"@trpc/server": "^11.18.0",
|
"@trpc/server": "^11.18.0",
|
||||||
|
|||||||
26
src/app/globals.css
Normal file
26
src/app/globals.css
Normal file
@ -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 */
|
||||||
|
}
|
||||||
@ -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({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="zh-CN">
|
||||||
<body>{children}</body>
|
<body>
|
||||||
|
<Providers>{children}</Providers>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,8 @@
|
|||||||
export default function Page() {
|
import { listConfigs } from "@/services/config";
|
||||||
return <h1>Hello, Next.js!</h1>
|
import { MainPage } from "@/components/MainPage";
|
||||||
|
|
||||||
|
export default async function Page() {
|
||||||
|
const configList = await listConfigs();
|
||||||
|
|
||||||
|
return <MainPage initialConfigs={configList} />;
|
||||||
}
|
}
|
||||||
|
|||||||
21
src/app/providers.tsx
Normal file
21
src/app/providers.tsx
Normal file
@ -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 (
|
||||||
|
<AntdRegistry>
|
||||||
|
<ConfigProvider
|
||||||
|
theme={{
|
||||||
|
token: {
|
||||||
|
colorPrimary: "#1677FF",
|
||||||
|
borderRadius: 6,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<App>{children}</App>
|
||||||
|
</ConfigProvider>
|
||||||
|
</AntdRegistry>
|
||||||
|
);
|
||||||
|
}
|
||||||
609
src/components/ConfigModal.tsx
Normal file
609
src/components/ConfigModal.tsx
Normal file
@ -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<string | null>(null);
|
||||||
|
const [curseforgeProjectName, setCurseforgeProjectName] = useState<string | null>(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 (
|
||||||
|
<Modal
|
||||||
|
title={mode === "add" ? "添加配置" : "修改配置"}
|
||||||
|
open={open}
|
||||||
|
onCancel={onCancel}
|
||||||
|
width={720}
|
||||||
|
destroyOnHidden
|
||||||
|
footer={
|
||||||
|
<Space>
|
||||||
|
<Button onClick={onCancel}>取消</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
loading={saving}
|
||||||
|
onClick={() => {
|
||||||
|
// Trigger form submit via a hidden button inside the form
|
||||||
|
document
|
||||||
|
.getElementById("config-modal-submit")
|
||||||
|
?.click();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{metaLoading ? (
|
||||||
|
<div style={{ textAlign: "center", padding: 40 }}>
|
||||||
|
<Spin description="正在加载元数据…" />
|
||||||
|
</div>
|
||||||
|
) : meta ? (
|
||||||
|
<ConfigModalForm
|
||||||
|
mode={mode}
|
||||||
|
initialName={initialName}
|
||||||
|
initialConfig={initialConfig}
|
||||||
|
meta={meta}
|
||||||
|
modrinthProjectName={modrinthProjectName}
|
||||||
|
curseforgeProjectName={curseforgeProjectName}
|
||||||
|
lookupModrinthProject={lookupModrinthProject}
|
||||||
|
lookupCurseforgeProject={lookupCurseforgeProject}
|
||||||
|
onSave={handleSave}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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<ConfigValues>();
|
||||||
|
|
||||||
|
// 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<ConfigValues>);
|
||||||
|
}
|
||||||
|
}, [mode, initialConfig, form]);
|
||||||
|
|
||||||
|
const handleFinish = useCallback(
|
||||||
|
async (values: ConfigValues) => {
|
||||||
|
onSave(values);
|
||||||
|
},
|
||||||
|
[onSave],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFinishFailed = useCallback(() => {
|
||||||
|
// Ant Design shows inline validation errors
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={handleFinish}
|
||||||
|
onFinishFailed={handleFinishFailed}
|
||||||
|
style={{ maxHeight: "60vh", overflow: "auto", paddingRight: 8 }}
|
||||||
|
>
|
||||||
|
{/* Hidden submit button triggered by modal footer */}
|
||||||
|
<button id="config-modal-submit" type="submit" style={{ display: "none" }} />
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
items={[
|
||||||
|
// ──── 通用 ────
|
||||||
|
{
|
||||||
|
key: "general",
|
||||||
|
label: "通用",
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<Form.Item
|
||||||
|
name="name"
|
||||||
|
label="配置名称"
|
||||||
|
rules={[{ required: true, message: "请输入配置名称" }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="模组名称" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="project_dir"
|
||||||
|
label="项目目录"
|
||||||
|
rules={[{ required: true, message: "请输入项目目录绝对路径" }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="/path/to/project" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="minecraft_properties_dir"
|
||||||
|
label="MC 属性目录"
|
||||||
|
rules={[{ required: true }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="./properties" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="project_properties_path"
|
||||||
|
label="项目属性文件路径"
|
||||||
|
rules={[{ required: true }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="./gradle.properties" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="mod_version_field"
|
||||||
|
label="版本号字段名"
|
||||||
|
rules={[{ required: true }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="mod_version" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="filename_format"
|
||||||
|
label="构建产物文件名模板"
|
||||||
|
rules={[{ required: true, message: "请输入文件名模板" }]}
|
||||||
|
extra="支持 ${version} 和 ${mc_version} 占位符"
|
||||||
|
>
|
||||||
|
<Input placeholder='modname-${version}-mc${mc_version}.jar' />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="source_filename_format"
|
||||||
|
label="源码文件名模板"
|
||||||
|
rules={[{ required: true }]}
|
||||||
|
extra="支持 ${version} 和 ${mc_version} 占位符"
|
||||||
|
>
|
||||||
|
<Input placeholder='modname-${version}-mc${mc_version}-sources.jar' />
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
// ──── Modrinth ────
|
||||||
|
{
|
||||||
|
key: "modrinth",
|
||||||
|
label: "Modrinth",
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<Form.Item
|
||||||
|
name={["modrinth", "project_id"]}
|
||||||
|
label="项目 ID"
|
||||||
|
rules={[{ required: true, message: "请输入 Modrinth 项目 ID" }]}
|
||||||
|
extra={
|
||||||
|
modrinthProjectName
|
||||||
|
? `项目名称: ${modrinthProjectName}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder="8 位 base62"
|
||||||
|
onChange={(e) => lookupModrinthProject(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name={["modrinth", "version_name"]}
|
||||||
|
label="版本名称模板"
|
||||||
|
rules={[{ required: true }]}
|
||||||
|
extra="支持 ${version}、${mc_version_range} 等占位符"
|
||||||
|
>
|
||||||
|
<Input placeholder="ModName v${version} for Minecraft ${mc_version_range}" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name={["modrinth", "version"]}
|
||||||
|
label="版本号模板"
|
||||||
|
rules={[{ required: true }]}
|
||||||
|
extra="支持 ${version}、${mc_version} 等占位符"
|
||||||
|
>
|
||||||
|
<Input placeholder="${version}-mc${mc_version}" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name={["modrinth", "loaders"]}
|
||||||
|
label="加载器"
|
||||||
|
rules={[{ required: true, message: "请选择加载器" }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
placeholder="选择加载器…"
|
||||||
|
options={meta.modrinthLoaders.map((l) => ({
|
||||||
|
label: l,
|
||||||
|
value: l,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name={["modrinth", "environment"]}
|
||||||
|
label="运行环境"
|
||||||
|
rules={[{ required: true, message: "请选择运行环境" }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
placeholder="选择运行环境"
|
||||||
|
options={meta.modrinthEnvironments}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{/* Dependencies */}
|
||||||
|
<Form.Item label="依赖">
|
||||||
|
<Form.List name={["modrinth", "dependencies"]}>
|
||||||
|
{(fields, { add, remove }) => (
|
||||||
|
<>
|
||||||
|
{fields.map(({ key, name, ...rest }) => (
|
||||||
|
<Space
|
||||||
|
key={key}
|
||||||
|
style={{ display: "flex", marginBottom: 8 }}
|
||||||
|
align="baseline"
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
{...rest}
|
||||||
|
name={[name, "dependency_type"]}
|
||||||
|
rules={[{ required: true, message: "必填" }]}
|
||||||
|
style={{ marginBottom: 0 }}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
placeholder="类型"
|
||||||
|
options={DEPENDENCY_TYPES}
|
||||||
|
style={{ width: 140 }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
{...rest}
|
||||||
|
name={[name, "project_id"]}
|
||||||
|
rules={[{ required: true, message: "必填" }]}
|
||||||
|
style={{ marginBottom: 0 }}
|
||||||
|
>
|
||||||
|
<Input placeholder="项目 ID" style={{ width: 200 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<DeleteOutlined onClick={() => remove(name)} />
|
||||||
|
</Space>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
type="dashed"
|
||||||
|
onClick={() =>
|
||||||
|
add({ dependency_type: "required", project_id: "" })
|
||||||
|
}
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
block
|
||||||
|
>
|
||||||
|
添加依赖
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Form.List>
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
// ──── CurseForge ────
|
||||||
|
{
|
||||||
|
key: "curseforge",
|
||||||
|
label: "CurseForge",
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<Form.Item
|
||||||
|
name={["curseforge", "project_id"]}
|
||||||
|
label="项目 ID"
|
||||||
|
rules={[{ required: true, message: "请输入 CurseForge 项目 ID" }]}
|
||||||
|
extra={
|
||||||
|
curseforgeProjectName
|
||||||
|
? `项目名称: ${curseforgeProjectName}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
placeholder="数字 ID"
|
||||||
|
onChange={(val) =>
|
||||||
|
lookupCurseforgeProject(
|
||||||
|
typeof val === "number" ? val : null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name={["curseforge", "version_name"]}
|
||||||
|
label="版本名称模板"
|
||||||
|
rules={[{ required: true }]}
|
||||||
|
extra="支持 #{filename_format} 等占位符"
|
||||||
|
>
|
||||||
|
<Input placeholder="#{filename_format}" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name={["curseforge", "environment"]}
|
||||||
|
label="运行环境"
|
||||||
|
rules={[{ required: true, message: "请选择运行环境" }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
placeholder="选择运行环境…"
|
||||||
|
options={meta.curseforgeEnvs.map((e) => ({
|
||||||
|
label: e,
|
||||||
|
value: e,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name={["curseforge", "loaders"]}
|
||||||
|
label="加载器"
|
||||||
|
rules={[{ required: true, message: "请选择加载器" }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
placeholder="选择加载器…"
|
||||||
|
options={meta.curseforgeLoaders.map((l) => ({
|
||||||
|
label: l,
|
||||||
|
value: l,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{/* Relations */}
|
||||||
|
<Form.Item label="关联项目">
|
||||||
|
<Form.List name={["curseforge", "relations", "projects"]}>
|
||||||
|
{(fields, { add, remove }) => (
|
||||||
|
<>
|
||||||
|
{fields.map(({ key, name, ...rest }) => (
|
||||||
|
<Space
|
||||||
|
key={key}
|
||||||
|
style={{ display: "flex", marginBottom: 8 }}
|
||||||
|
align="baseline"
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
{...rest}
|
||||||
|
name={[name, "slug"]}
|
||||||
|
rules={[{ required: true, message: "必填" }]}
|
||||||
|
style={{ marginBottom: 0 }}
|
||||||
|
>
|
||||||
|
<Input placeholder="Slug" style={{ width: 160 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
{...rest}
|
||||||
|
name={[name, "type"]}
|
||||||
|
rules={[{ required: true, message: "必填" }]}
|
||||||
|
style={{ marginBottom: 0 }}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
placeholder="类型"
|
||||||
|
options={RELATION_TYPES}
|
||||||
|
style={{ width: 170 }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<DeleteOutlined onClick={() => remove(name)} />
|
||||||
|
</Space>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
type="dashed"
|
||||||
|
onClick={() =>
|
||||||
|
add({ slug: "", type: "requiredDependency" })
|
||||||
|
}
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
block
|
||||||
|
>
|
||||||
|
添加关联项目
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Form.List>
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
135
src/components/ConfigSelector.tsx
Normal file
135
src/components/ConfigSelector.tsx
Normal file
@ -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 (
|
||||||
|
<>
|
||||||
|
<Space.Compact style={{ width: "100%" }}>
|
||||||
|
<Select
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
placeholder="选择配置…"
|
||||||
|
loading={selecting}
|
||||||
|
value={selectedFile}
|
||||||
|
onChange={handleChange}
|
||||||
|
options={configs.map((c) => ({
|
||||||
|
value: c.file,
|
||||||
|
label: c.name,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<Button icon={<EditOutlined />} onClick={openEdit}>
|
||||||
|
修改配置
|
||||||
|
</Button>
|
||||||
|
<Button icon={<PlusOutlined />} onClick={openAdd}>
|
||||||
|
添加配置
|
||||||
|
</Button>
|
||||||
|
</Space.Compact>
|
||||||
|
|
||||||
|
<ConfigModal
|
||||||
|
open={modalOpen}
|
||||||
|
mode={modalMode}
|
||||||
|
initialName={modalInitial?.name ?? null}
|
||||||
|
initialConfig={modalInitial?.config ?? null}
|
||||||
|
onCancel={() => setModalOpen(false)}
|
||||||
|
onSaved={handleModalSaved}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
130
src/components/MainPage.tsx
Normal file
130
src/components/MainPage.tsx
Normal file
@ -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<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [selectedConfig, setSelectedConfig] = useState<Config | null>(null);
|
||||||
|
const [parsedProject, setParsedProject] = useState<ParsedProject | null>(
|
||||||
|
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 (
|
||||||
|
<main
|
||||||
|
style={{
|
||||||
|
maxWidth: 900,
|
||||||
|
margin: "40px auto",
|
||||||
|
padding: "0 24px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Page header */}
|
||||||
|
<Title level={2} style={{ marginBottom: 24 }}>
|
||||||
|
Mod Releaser
|
||||||
|
</Title>
|
||||||
|
|
||||||
|
<Space orientation="vertical" size="large" style={{ width: "100%" }}>
|
||||||
|
{/* 1. Config selector */}
|
||||||
|
<ConfigSelector
|
||||||
|
configs={configs}
|
||||||
|
selectedFile={selectedConfigFile}
|
||||||
|
onSelect={handleConfigSelect}
|
||||||
|
onConfigAdded={handleConfigAdded}
|
||||||
|
onConfigUpdated={handleConfigUpdated}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 2. Version table */}
|
||||||
|
<Card>
|
||||||
|
<VersionTable
|
||||||
|
project={parsedProject}
|
||||||
|
loading={projectLoading}
|
||||||
|
onLoadingChange={setProjectLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Publish button */}
|
||||||
|
{parsedProject && parsedProject.artifacts.length > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
marginTop: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<CloudUploadOutlined />}
|
||||||
|
size="large"
|
||||||
|
onClick={() => setPublishOpen(true)}
|
||||||
|
disabled={!selectedConfig}
|
||||||
|
>
|
||||||
|
一键发布
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
{/* 3. Publish modal */}
|
||||||
|
{publishOpen && selectedConfig && parsedProject && (
|
||||||
|
<PublishModal
|
||||||
|
open={publishOpen}
|
||||||
|
config={selectedConfig}
|
||||||
|
project={parsedProject}
|
||||||
|
onClose={handlePublishClose}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
412
src/components/PublishModal.tsx
Normal file
412
src/components/PublishModal.tsx
Normal file
@ -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<Set<string>>(
|
||||||
|
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<string[]>([]);
|
||||||
|
const [existingCurseforge, setExistingCurseforge] = useState<string[]>([]);
|
||||||
|
|
||||||
|
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<string>();
|
||||||
|
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) => (
|
||||||
|
<Checkbox
|
||||||
|
disabled={record.existsModrinth}
|
||||||
|
checked={
|
||||||
|
record.existsModrinth || selectedVersions.has(record.key)
|
||||||
|
}
|
||||||
|
onChange={(e) => {
|
||||||
|
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) => (
|
||||||
|
<Checkbox
|
||||||
|
disabled={record.existsCurseforge}
|
||||||
|
checked={
|
||||||
|
record.existsCurseforge || selectedVersions.has(record.key)
|
||||||
|
}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = new Set(selectedVersions);
|
||||||
|
if (e.target.checked) next.add(record.key);
|
||||||
|
else next.delete(record.key);
|
||||||
|
setSelectedVersions(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text strong>选择要发布的版本</Text>
|
||||||
|
<Button
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
loading={loadingExisting}
|
||||||
|
onClick={loadExisting}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
刷新已有版本
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loadingExisting ? (
|
||||||
|
<div style={{ textAlign: "center", padding: 40 }}>加载中…</div>
|
||||||
|
) : (
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Text strong style={{ display: "block", marginBottom: 8 }}>
|
||||||
|
Modrinth
|
||||||
|
</Text>
|
||||||
|
{publishVersions.length > 0 ? (
|
||||||
|
<Table
|
||||||
|
dataSource={publishVersions}
|
||||||
|
columns={modrinthColumns}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
bordered
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Empty description="无版本" />
|
||||||
|
)}
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Text strong style={{ display: "block", marginBottom: 8 }}>
|
||||||
|
CurseForge
|
||||||
|
</Text>
|
||||||
|
{publishVersions.length > 0 ? (
|
||||||
|
<Table
|
||||||
|
dataSource={publishVersions}
|
||||||
|
columns={curseforgeColumns}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
bordered
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Empty description="无版本" />
|
||||||
|
)}
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --------------- 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 (
|
||||||
|
<div>
|
||||||
|
{/* Changelog */}
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<Text strong style={{ display: "block", marginBottom: 8 }}>
|
||||||
|
Changelog (Markdown)
|
||||||
|
</Text>
|
||||||
|
{/* TODO: Replace with @uiw/react-md-editor */}
|
||||||
|
<textarea
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
minHeight: 160,
|
||||||
|
border: "1px solid #d9d9d9",
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: 8,
|
||||||
|
fontFamily: "monospace",
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
placeholder="输入 Markdown 格式的更新日志…"
|
||||||
|
value={changelog}
|
||||||
|
onChange={(e) => setChangelog(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Version type */}
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<Text strong style={{ display: "block", marginBottom: 8 }}>
|
||||||
|
版本类型
|
||||||
|
</Text>
|
||||||
|
<Select
|
||||||
|
value={versionType}
|
||||||
|
onChange={(v) => setVersionType(v)}
|
||||||
|
style={{ width: 160 }}
|
||||||
|
options={[
|
||||||
|
{ label: "Release", value: "release" },
|
||||||
|
{ label: "Beta", value: "beta" },
|
||||||
|
{ label: "Alpha", value: "alpha" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Publishing progress */}
|
||||||
|
{publishing && (
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
{hasModrinth && (
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<Text>Modrinth: </Text>
|
||||||
|
<Progress
|
||||||
|
percent={Math.round(
|
||||||
|
(progress.modrinth.done / Math.max(progress.modrinth.total, 1)) *
|
||||||
|
100,
|
||||||
|
)}
|
||||||
|
format={() =>
|
||||||
|
`${progress.modrinth.done} / ${progress.modrinth.total}`
|
||||||
|
}
|
||||||
|
style={{ width: "80%" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasCurseforge && (
|
||||||
|
<div>
|
||||||
|
<Text>CurseForge: </Text>
|
||||||
|
<Progress
|
||||||
|
percent={Math.round(
|
||||||
|
(progress.curseforge.done /
|
||||||
|
Math.max(progress.curseforge.total, 1)) *
|
||||||
|
100,
|
||||||
|
)}
|
||||||
|
format={() =>
|
||||||
|
`${progress.curseforge.done} / ${progress.curseforge.total}`
|
||||||
|
}
|
||||||
|
style={{ width: "80%" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --------------- Render ---------------
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="一键发布"
|
||||||
|
open={open}
|
||||||
|
onCancel={handleClose}
|
||||||
|
width={760}
|
||||||
|
destroyOnHidden
|
||||||
|
footer={
|
||||||
|
publishing ? null : (
|
||||||
|
<Space>
|
||||||
|
{currentStep > 0 && (
|
||||||
|
<Button onClick={handlePrev}>上一步</Button>
|
||||||
|
)}
|
||||||
|
{currentStep < 1 ? (
|
||||||
|
<Button type="primary" onClick={handleNext}>
|
||||||
|
下一步
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button type="primary" onClick={handleConfirmPublish}>
|
||||||
|
确认发布
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Steps
|
||||||
|
current={currentStep}
|
||||||
|
size="small"
|
||||||
|
style={{ marginBottom: 24 }}
|
||||||
|
items={[
|
||||||
|
{ title: "版本选择" },
|
||||||
|
{ title: "发布设置" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{currentStep === 0 ? renderStep1() : renderStep2()}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
src/components/VersionTable.tsx
Normal file
108
src/components/VersionTable.tsx
Normal file
@ -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) => <Tag>{val}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "兼容范围",
|
||||||
|
dataIndex: "mc_version",
|
||||||
|
key: "range",
|
||||||
|
width: 200,
|
||||||
|
render: (_: string, __: ArtifactRow, index: number) => {
|
||||||
|
const isLast =
|
||||||
|
project && index === project.artifacts.length - 1;
|
||||||
|
// 兼容范围在发布前由 PublishModal 加载元数据后完整计算
|
||||||
|
// 此处仅作占位显示
|
||||||
|
return isLast ? (
|
||||||
|
<Tag color="blue">加载元数据后计算</Tag>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: "#8c8c8c" }}>—</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "构建产物",
|
||||||
|
dataIndex: "artifact",
|
||||||
|
key: "artifact",
|
||||||
|
ellipsis: true,
|
||||||
|
render: (val: string | null) =>
|
||||||
|
val ? (
|
||||||
|
<span style={{ color: "#52C41A" }}>{val}</span>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: "#FF4D4F" }}>文件不存在</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "源码",
|
||||||
|
dataIndex: "sources",
|
||||||
|
key: "sources",
|
||||||
|
ellipsis: true,
|
||||||
|
render: (val: string | null) =>
|
||||||
|
val ? (
|
||||||
|
<span style={{ color: "#52C41A" }}>{val}</span>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: "#FF4D4F" }}>文件不存在</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return (
|
||||||
|
<Empty
|
||||||
|
description="请选择一个配置以查看版本信息"
|
||||||
|
style={{ padding: 40 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table<ArtifactRow>
|
||||||
|
dataSource={dataSource}
|
||||||
|
columns={columns}
|
||||||
|
loading={loading}
|
||||||
|
pagination={false}
|
||||||
|
size="middle"
|
||||||
|
bordered
|
||||||
|
locale={{
|
||||||
|
emptyText: "未找到构建产物",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user