ModReleaser/src/components/ConfigModal.tsx
2026-07-11 12:13:20 +08:00

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>
);
}