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": {
|
||||
"@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",
|
||||
|
||||
@ -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",
|
||||
|
||||
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({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
<html lang="zh-CN">
|
||||
<body>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,3 +1,8 @@
|
||||
export default function Page() {
|
||||
return <h1>Hello, Next.js!</h1>
|
||||
import { listConfigs } from "@/services/config";
|
||||
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