610 lines
20 KiB
TypeScript
610 lines
20 KiB
TypeScript
"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>
|
|
);
|
|
}
|