695 lines
22 KiB
TypeScript
695 lines
22 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState, useCallback } from "react";
|
|
import {
|
|
Modal,
|
|
Form,
|
|
Input,
|
|
InputNumber,
|
|
Select,
|
|
Tabs,
|
|
Button,
|
|
Space,
|
|
App,
|
|
} 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, DependencyType, UploadRelationType } from "@/types";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const DEPENDENCY_TYPE_LABEL_MAP: Record<DependencyType, string> = {
|
|
required: "Required",
|
|
optional: "Optional",
|
|
incompatible: "Incompatible",
|
|
embedded: "Embedded"
|
|
};
|
|
const DEPENDENCY_TYPES = Object.entries(DEPENDENCY_TYPE_LABEL_MAP).map(v => ({ value: v[0], label: v[1] }));
|
|
|
|
const RELATION_TYPE_LABEL_MAP: Record<UploadRelationType, string> = {
|
|
incompatible: "Incompatible",
|
|
embeddedLibrary: "Embedded Library",
|
|
optionalDependency: "Optional Dependency",
|
|
requiredDependency: "Required Dependency",
|
|
tool: "Tool"
|
|
};
|
|
const RELATION_TYPES = Object.entries(RELATION_TYPE_LABEL_MAP).map(v => ({ value: v[0], label: v[1] }));
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Data-fetching hooks — each Select loads independently, no blocking
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function useModrinthLoaders(): {
|
|
loading: boolean;
|
|
options: { label: string; value: string }[];
|
|
} {
|
|
const [loading, setLoading] = useState(true);
|
|
const [options, setOptions] = useState<{ label: string; value: string }[]>(
|
|
[],
|
|
);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setLoading(true);
|
|
getModrinthLoaders()
|
|
.then((list) => {
|
|
if (!cancelled) {
|
|
setOptions(list.map((l) => ({ label: l, value: l })));
|
|
}
|
|
})
|
|
.catch(() => {})
|
|
.finally(() => {
|
|
if (!cancelled) setLoading(false);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
return { loading, options };
|
|
}
|
|
|
|
function useModrinthEnvironments(): {
|
|
loading: boolean;
|
|
options: { value: string; label: string }[];
|
|
} {
|
|
const [loading, setLoading] = useState(true);
|
|
const [options, setOptions] = useState<
|
|
{ value: string; label: string }[]
|
|
>([]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setLoading(true);
|
|
getModrinthEnvironments()
|
|
.then((list) => {
|
|
if (!cancelled) setOptions(list);
|
|
})
|
|
.catch(() => {})
|
|
.finally(() => {
|
|
if (!cancelled) setLoading(false);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
return { loading, options };
|
|
}
|
|
|
|
function useCurseforgeMeta(): {
|
|
loading: boolean;
|
|
environments: string[];
|
|
loaders: string[];
|
|
} {
|
|
const [loading, setLoading] = useState(true);
|
|
const [environments, setEnvironments] = useState<string[]>([]);
|
|
const [loaders, setLoaders] = useState<string[]>([]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setLoading(true);
|
|
getCurseForgeMeta()
|
|
.then((meta) => {
|
|
if (!cancelled) {
|
|
setEnvironments(meta.environments);
|
|
setLoaders(meta.loaders);
|
|
}
|
|
})
|
|
.catch(() => {})
|
|
.finally(() => {
|
|
if (!cancelled) setLoading(false);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
return { loading, environments, loaders };
|
|
}
|
|
|
|
type ConfigValues = Config;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Component
|
|
// ---------------------------------------------------------------------------
|
|
|
|
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);
|
|
|
|
// --------------- 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, message],
|
|
);
|
|
|
|
// --------------- 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={() => {
|
|
document
|
|
.getElementById("config-modal-submit")
|
|
?.click();
|
|
}}
|
|
>
|
|
保存
|
|
</Button>
|
|
</Space>
|
|
}
|
|
>
|
|
<ConfigModalForm
|
|
mode={mode}
|
|
initialConfig={initialConfig}
|
|
modrinthProjectName={modrinthProjectName}
|
|
curseforgeProjectName={curseforgeProjectName}
|
|
lookupModrinthProject={lookupModrinthProject}
|
|
lookupCurseforgeProject={lookupCurseforgeProject}
|
|
onSave={handleSave}
|
|
/>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Inner component — Form with independently-loading Selects
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface ConfigModalFormProps {
|
|
mode: "add" | "edit";
|
|
initialConfig: Config | null;
|
|
modrinthProjectName: string | null;
|
|
curseforgeProjectName: string | null;
|
|
lookupModrinthProject: (projectId: string) => void;
|
|
lookupCurseforgeProject: (projectId: number | null) => void;
|
|
onSave: (values: ConfigValues) => void;
|
|
}
|
|
|
|
function ConfigModalForm({
|
|
mode,
|
|
initialConfig,
|
|
modrinthProjectName,
|
|
curseforgeProjectName,
|
|
lookupModrinthProject,
|
|
lookupCurseforgeProject,
|
|
onSave,
|
|
}: ConfigModalFormProps) {
|
|
const [form] = Form.useForm<ConfigValues>();
|
|
|
|
// Each Select loads its own data independently
|
|
const mrLoaders = useModrinthLoaders();
|
|
const mrEnvs = useModrinthEnvironments();
|
|
const cfMeta = useCurseforgeMeta();
|
|
|
|
// 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(
|
|
(values: ConfigValues) => {
|
|
onSave(values);
|
|
},
|
|
[onSave],
|
|
);
|
|
|
|
const handleFinishFailed = useCallback(() => {}, []);
|
|
|
|
return (
|
|
<Form
|
|
form={form}
|
|
layout="vertical"
|
|
onFinish={handleFinish}
|
|
onFinishFailed={handleFinishFailed}
|
|
style={{ maxHeight: "60vh", overflow: "auto", paddingRight: 8 }}
|
|
>
|
|
<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="选择加载器…"
|
|
loading={mrLoaders.loading}
|
|
options={mrLoaders.options}
|
|
notFoundContent={
|
|
mrLoaders.loading ? "加载中…" : "无可用选项"
|
|
}
|
|
/>
|
|
</Form.Item>
|
|
|
|
<Form.Item
|
|
name={["modrinth", "environment"]}
|
|
label="运行环境"
|
|
rules={[{ required: true, message: "请选择运行环境" }]}
|
|
>
|
|
<Select
|
|
placeholder="选择运行环境"
|
|
loading={mrEnvs.loading}
|
|
options={mrEnvs.options}
|
|
notFoundContent={
|
|
mrEnvs.loading ? "加载中…" : "无可用选项"
|
|
}
|
|
/>
|
|
</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="选择运行环境…"
|
|
loading={cfMeta.loading}
|
|
options={cfMeta.environments.map((e) => ({
|
|
label: e,
|
|
value: e,
|
|
}))}
|
|
notFoundContent={
|
|
cfMeta.loading ? "加载中…" : "无可用选项"
|
|
}
|
|
/>
|
|
</Form.Item>
|
|
|
|
<Form.Item
|
|
name={["curseforge", "loaders"]}
|
|
label="加载器"
|
|
rules={[{ required: true, message: "请选择加载器" }]}
|
|
>
|
|
<Select
|
|
mode="multiple"
|
|
placeholder="选择加载器…"
|
|
loading={cfMeta.loading}
|
|
options={cfMeta.loaders.map((l) => ({
|
|
label: l,
|
|
value: l,
|
|
}))}
|
|
notFoundContent={
|
|
cfMeta.loading ? "加载中…" : "无可用选项"
|
|
}
|
|
/>
|
|
</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: 200 }}
|
|
/>
|
|
</Form.Item>
|
|
<DeleteOutlined
|
|
onClick={() => remove(name)}
|
|
/>
|
|
</Space>
|
|
))}
|
|
<Button
|
|
type="dashed"
|
|
onClick={() =>
|
|
add({ slug: "", type: "requiredDependency" })
|
|
}
|
|
icon={<PlusOutlined />}
|
|
block
|
|
>
|
|
添加关联项目
|
|
</Button>
|
|
</>
|
|
)}
|
|
</Form.List>
|
|
</Form.Item>
|
|
</>
|
|
),
|
|
},
|
|
]}
|
|
/>
|
|
</Form>
|
|
);
|
|
}
|