Add field validation engine with per-field debounce
Introduce useFieldValidations hook and ValidatedFormItem component driven by declarative StaticValidationDef arrays. Four server actions scan the project directory to validate MC properties dir, version field, artifact and source filename templates in real time.
This commit is contained in:
parent
d79af724e3
commit
750f2a18f8
@ -20,8 +20,29 @@ import {
|
||||
getModrinthEnvironments,
|
||||
getCurseForgeMeta,
|
||||
} from "@/services/meta";
|
||||
import {
|
||||
scanMcPropertiesDir,
|
||||
readProjectVersion,
|
||||
checkArtifactFiles,
|
||||
checkSourceFiles,
|
||||
} from "@/services/project";
|
||||
import {
|
||||
useFieldValidations,
|
||||
ValidatedFormItem,
|
||||
type FormNamePath,
|
||||
type StaticOnly,
|
||||
type StaticValidationDef,
|
||||
type ListValidationDef,
|
||||
} from "@/hooks/useFieldValidations";
|
||||
import type { Config, DependencyType, UploadRelationType } from "@/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 表单路径类型(从 Config 推导)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ConfigNamePath = FormNamePath<Config>;
|
||||
type StaticConfigNamePath = StaticOnly<ConfigNamePath>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -43,6 +64,83 @@ const RELATION_TYPE_LABEL_MAP: Record<UploadRelationType, string> = {
|
||||
};
|
||||
const RELATION_TYPES = Object.entries(RELATION_TYPE_LABEL_MAP).map(v => ({ value: v[0], label: v[1] }));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 校验定义 — 声明式配置,数组驱动表单实时反馈
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const STATIC_DEFS: StaticValidationDef<Config>[] = [
|
||||
// 1. MC 属性目录
|
||||
{
|
||||
target: "minecraft_properties_dir",
|
||||
deps: ["project_dir", "minecraft_properties_dir"],
|
||||
async validate(getVal) {
|
||||
const dir = getVal("project_dir") as string | undefined;
|
||||
const mcDir = getVal("minecraft_properties_dir") as string | undefined;
|
||||
if (!dir || !mcDir) return null;
|
||||
const r = await scanMcPropertiesDir(dir, mcDir);
|
||||
return r.ok
|
||||
? { status: "success", help: `找到 ${r.versions.length} 个 MC 版本:${r.versions.slice(0, 5).join(",")}${r.versions.length > 5 ? "..." : ""}` }
|
||||
: { status: "error", help: r.error };
|
||||
},
|
||||
},
|
||||
// 2. 版本号字段名
|
||||
{
|
||||
target: "mod_version_field",
|
||||
deps: ["project_dir", "project_properties_path", "mod_version_field"],
|
||||
async validate(getVal) {
|
||||
const dir = getVal("project_dir") as string | undefined;
|
||||
const path = getVal("project_properties_path") as string | undefined;
|
||||
const field = getVal("mod_version_field") as string | undefined;
|
||||
if (!dir || !path || !field) return null;
|
||||
const r = await readProjectVersion(dir, path, field);
|
||||
if (!r.ok) return { status: "error", help: r.error };
|
||||
return r.version
|
||||
? { status: "success", help: `版本号:${r.version}` }
|
||||
: { status: "error", help: `未找到字段 \u201c${field}\u201d,文件中的键:${r.keys.join(",") || "(无)"}` };
|
||||
},
|
||||
},
|
||||
// 3. 构建产物文件名模板
|
||||
{
|
||||
target: "filename_format",
|
||||
deps: ["project_dir", "project_properties_path", "mod_version_field", "filename_format", "minecraft_properties_dir"],
|
||||
async validate(getVal) {
|
||||
const dir = getVal("project_dir") as string | undefined;
|
||||
const propsPath = getVal("project_properties_path") as string | undefined;
|
||||
const field = getVal("mod_version_field") as string | undefined;
|
||||
const fmt = getVal("filename_format") as string | undefined;
|
||||
const mcDir = getVal("minecraft_properties_dir") as string | undefined;
|
||||
if (!dir || !propsPath || !field || !fmt || !mcDir) return null;
|
||||
const ver = await readProjectVersion(dir, propsPath, field);
|
||||
if (!ver.ok || !ver.version) return { status: "error", help: ver.ok ? `未找到字段 \u201c${field}\u201d` : ver.error };
|
||||
const r = await checkArtifactFiles(dir, fmt, ver.version, mcDir);
|
||||
return r.ok
|
||||
? { status: "success", help: `匹配到 ${r.matched.length} 个文件:${r.matched.map(m => m.filename).slice(0, 3).join(",")}${r.matched.length > 3 ? "..." : ""}` }
|
||||
: { status: "error", help: r.error };
|
||||
},
|
||||
},
|
||||
// 4. 源码文件名模板
|
||||
{
|
||||
target: "source_filename_format",
|
||||
deps: ["project_dir", "project_properties_path", "mod_version_field", "source_filename_format", "minecraft_properties_dir"],
|
||||
async validate(getVal) {
|
||||
const dir = getVal("project_dir") as string | undefined;
|
||||
const propsPath = getVal("project_properties_path") as string | undefined;
|
||||
const field = getVal("mod_version_field") as string | undefined;
|
||||
const fmt = getVal("source_filename_format") as string | undefined;
|
||||
const mcDir = getVal("minecraft_properties_dir") as string | undefined;
|
||||
if (!dir || !propsPath || !field || !fmt || !mcDir) return null;
|
||||
const ver = await readProjectVersion(dir, propsPath, field);
|
||||
if (!ver.ok || !ver.version) return { status: "error", help: ver.ok ? `未找到字段 \u201c${field}\u201d` : ver.error };
|
||||
const r = await checkSourceFiles(dir, fmt, ver.version, mcDir);
|
||||
return r.ok
|
||||
? { status: "success", help: `匹配到 ${r.matched.length} 个文件:${r.matched.map(m => m.filename).slice(0, 3).join(",")}${r.matched.length > 3 ? "..." : ""}` }
|
||||
: { status: "error", help: r.error };
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const LIST_DEFS: ListValidationDef<Config>[] = [];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data-fetching hooks — each Select loads independently, no blocking
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -293,6 +391,9 @@ function ConfigModalForm({
|
||||
const mrEnvs = useModrinthEnvironments();
|
||||
const cfMeta = useCurseforgeMeta();
|
||||
|
||||
// 表单项实时校验
|
||||
const { Provider, results } = useFieldValidations<Config>(form, STATIC_DEFS, LIST_DEFS);
|
||||
|
||||
// Reset form when modal opens or mode/config changes
|
||||
useEffect(() => {
|
||||
if (mode === "edit" && initialConfig) {
|
||||
@ -335,360 +436,362 @@ function ConfigModalForm({
|
||||
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" }}
|
||||
/>
|
||||
<Provider value={results}>
|
||||
<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"]}
|
||||
<Tabs
|
||||
items={[
|
||||
// ──── 通用 ────
|
||||
{
|
||||
key: "general",
|
||||
label: "通用",
|
||||
children: (
|
||||
<>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="配置名称"
|
||||
rules={[{ required: true, message: "请输入配置名称" }]}
|
||||
>
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
{fields.map(({ key, name, ...rest }) => (
|
||||
<Space
|
||||
key={key}
|
||||
style={{ display: "flex", marginBottom: 8 }}
|
||||
align="baseline"
|
||||
<Input placeholder="模组名称" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="project_dir"
|
||||
label="项目目录"
|
||||
rules={[
|
||||
{ required: true, message: "请输入项目目录绝对路径" },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="/path/to/project" />
|
||||
</Form.Item>
|
||||
|
||||
<ValidatedFormItem
|
||||
name="minecraft_properties_dir"
|
||||
label="MC 属性目录"
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<Input placeholder="./properties" />
|
||||
</ValidatedFormItem>
|
||||
|
||||
<Form.Item
|
||||
name="project_properties_path"
|
||||
label="项目属性文件路径"
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<Input placeholder="./gradle.properties" />
|
||||
</Form.Item>
|
||||
|
||||
<ValidatedFormItem
|
||||
name="mod_version_field"
|
||||
label="版本号字段名"
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<Input placeholder="mod_version" />
|
||||
</ValidatedFormItem>
|
||||
|
||||
<ValidatedFormItem
|
||||
name="filename_format"
|
||||
label="构建产物文件名模板"
|
||||
rules={[{ required: true, message: "请输入文件名模板" }]}
|
||||
extra="支持 ${version} 和 ${mc_version} 占位符"
|
||||
>
|
||||
<Input placeholder="modname-${version}-mc${mc_version}.jar" />
|
||||
</ValidatedFormItem>
|
||||
|
||||
<ValidatedFormItem
|
||||
name="source_filename_format"
|
||||
label="源码文件名模板"
|
||||
rules={[{ required: true }]}
|
||||
extra="支持 ${version} 和 ${mc_version} 占位符"
|
||||
>
|
||||
<Input placeholder="modname-${version}-mc${mc_version}-sources.jar" />
|
||||
</ValidatedFormItem>
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
||||
// ──── 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
|
||||
>
|
||||
<Form.Item
|
||||
{...rest}
|
||||
name={[name, "slug"]}
|
||||
rules={[{ required: true, message: "必填" }]}
|
||||
style={{ marginBottom: 0 }}
|
||||
添加依赖
|
||||
</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"
|
||||
>
|
||||
<Input
|
||||
placeholder="Slug"
|
||||
style={{ width: 160 }}
|
||||
<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)}
|
||||
/>
|
||||
</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>
|
||||
</Space>
|
||||
))}
|
||||
<Button
|
||||
type="dashed"
|
||||
onClick={() =>
|
||||
add({ slug: "", type: "requiredDependency" })
|
||||
}
|
||||
icon={<PlusOutlined />}
|
||||
block
|
||||
>
|
||||
添加关联项目
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
</Form.Item>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Form>
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
269
src/hooks/useFieldValidations.tsx
Normal file
269
src/hooks/useFieldValidations.tsx
Normal file
@ -0,0 +1,269 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Form, theme } from "antd";
|
||||
import { CheckOutlined, CloseOutlined } from "@ant-design/icons";
|
||||
import type { FormInstance, FormItemProps } from "antd";
|
||||
import type { NamePath } from "antd/es/form/interface";
|
||||
import type { ValidateStatus } from "antd/es/form/FormItem";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 类型体操:从任意表单值类型生成合法的 Form NamePath
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Terminal = string | number | boolean | null | undefined;
|
||||
|
||||
export type FormNamePath<T, Prefix extends (string | number)[] = []> = {
|
||||
[K in keyof T & string]:
|
||||
T[K] extends Terminal
|
||||
? Prefix extends [] ? K : [...Prefix, K]
|
||||
: T[K] extends string[] | number[] | boolean[]
|
||||
? Prefix extends [] ? K | [...Prefix, K] : [...Prefix, K]
|
||||
: T[K] extends (infer U)[]
|
||||
?
|
||||
| (Prefix extends [] ? K : [...Prefix, K])
|
||||
| FormNamePath<U, [...Prefix, K, number]>
|
||||
:
|
||||
| (Prefix extends [] ? K : [...Prefix, K])
|
||||
| FormNamePath<T[K], Prefix extends [] ? [K] : [...Prefix, K]>
|
||||
}[keyof T & string];
|
||||
|
||||
/** 从 NamePath 联合类型中剔除含 number 的路径,仅保留静态路径 */
|
||||
export type StaticOnly<T> = T extends unknown[]
|
||||
? number extends T[number] ? never : T
|
||||
: T;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 校验定义
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ValidationSlot {
|
||||
status: ValidateStatus;
|
||||
help: string;
|
||||
}
|
||||
|
||||
export interface StaticValidationDef<T> {
|
||||
/** 反馈显示的目标 Form.Item name */
|
||||
target: StaticOnly<FormNamePath<T>>;
|
||||
/** 依赖字段,任一变化即触发校验 */
|
||||
deps: StaticOnly<FormNamePath<T>>[];
|
||||
/** 校验逻辑,返回 null 表示不更新状态(如必填字段未填) */
|
||||
validate(
|
||||
getVal: (name: FormNamePath<T>) => unknown,
|
||||
): Promise<ValidationSlot | null>;
|
||||
}
|
||||
|
||||
export interface ListValidationDef<T> {
|
||||
/** Form.List 的完整路径 */
|
||||
list: FormNamePath<T>;
|
||||
/** 列表项中要反馈的子字段名 */
|
||||
target: string;
|
||||
/** 列表项中依赖的子字段名 */
|
||||
deps: string[];
|
||||
/** 校验逻辑,row 为当前行号(0-based) */
|
||||
validate(
|
||||
getVal: (name: string) => unknown,
|
||||
row: number,
|
||||
): Promise<ValidationSlot | null>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context — hook 与 ValidatedFormItem 的桥梁
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ResultsCtx = createContext<Record<string, ValidationSlot>>({});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useFieldValidations<T>(
|
||||
form: FormInstance,
|
||||
staticDefs: StaticValidationDef<T>[],
|
||||
listDefs: ListValidationDef<T>[],
|
||||
) {
|
||||
const [results, setResults] = useState<Record<string, ValidationSlot>>({});
|
||||
const timersRef = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
|
||||
const prevRef = useRef<Record<string, unknown>>({});
|
||||
|
||||
// 收集所有需要监听的字段路径
|
||||
const allDepPaths = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const d of staticDefs) {
|
||||
set.add(pathKey(d.target));
|
||||
for (const dep of d.deps) set.add(pathKey(dep));
|
||||
}
|
||||
for (const ld of listDefs) {
|
||||
set.add(pathKey(ld.list));
|
||||
}
|
||||
return [...set].map((k) => JSON.parse(k) as FormNamePath<T>);
|
||||
}, [staticDefs, listDefs]);
|
||||
|
||||
// 用 selector 监听所有 deps,只返回快照哈希(只有 deps 真正变化时才触发渲染)
|
||||
const snapshot = Form.useWatch((values) => {
|
||||
const parts: string[] = [];
|
||||
for (const p of allDepPaths) {
|
||||
parts.push(pathKey(p) + "=" + JSON.stringify(getFieldValue(values, p)));
|
||||
}
|
||||
return parts.join("|");
|
||||
}, form);
|
||||
|
||||
useEffect(() => {
|
||||
// 对比 prev,找出变化的字段
|
||||
const changedKeys: string[] = [];
|
||||
for (const p of allDepPaths) {
|
||||
const key = pathKey(p);
|
||||
const cur = JSON.stringify(getFieldValue(form.getFieldsValue(), p));
|
||||
if (prevRef.current[key] !== cur) {
|
||||
changedKeys.push(key);
|
||||
}
|
||||
prevRef.current[key] = cur;
|
||||
}
|
||||
|
||||
if (changedKeys.length === 0) return;
|
||||
|
||||
const getVal = (name: FormNamePath<T>) => form.getFieldValue(name);
|
||||
|
||||
// 为每个受影响的静态 def 重置独立定时器
|
||||
for (const def of staticDefs) {
|
||||
const defKey = pathKey(def.target);
|
||||
const depKeys = def.deps.map((d) => pathKey(d));
|
||||
if (!changedKeys.some((k) => k === defKey || depKeys.includes(k))) continue;
|
||||
|
||||
const timerKey = "s_" + defKey;
|
||||
if (timersRef.current[timerKey]) clearTimeout(timersRef.current[timerKey]);
|
||||
timersRef.current[timerKey] = setTimeout(async () => {
|
||||
const slot = await def.validate(getVal);
|
||||
setResults((prev) => {
|
||||
const next = { ...prev };
|
||||
if (slot) {
|
||||
next[defKey] = slot;
|
||||
} else {
|
||||
delete next[defKey];
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, 800);
|
||||
}
|
||||
|
||||
// 为每个受影响的列表 def 重置定时器
|
||||
for (const ld of listDefs) {
|
||||
const listKey = pathKey(ld.list);
|
||||
if (!changedKeys.includes(listKey)) continue;
|
||||
|
||||
const timerKey = "l_" + listKey;
|
||||
if (timersRef.current[timerKey]) clearTimeout(timersRef.current[timerKey]);
|
||||
timersRef.current[timerKey] = setTimeout(async () => {
|
||||
const listVal = form.getFieldValue(ld.list) as unknown[] | undefined;
|
||||
if (!listVal?.length) {
|
||||
// 列表为空时清除所有该列表的校验结果
|
||||
setResults((prev) => {
|
||||
const next = { ...prev };
|
||||
const prefix = JSON.stringify(ld.list).slice(0, -1);
|
||||
for (const k of Object.keys(next)) {
|
||||
if (k.startsWith(prefix)) delete next[k];
|
||||
}
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < listVal.length; i++) {
|
||||
const getRowVal = (name: string) =>
|
||||
form.getFieldValue([...toArray(ld.list), i, name]);
|
||||
const slot = await ld.validate(getRowVal, i);
|
||||
const rowKey = pathKey([...toArray(ld.list), i, ld.target]);
|
||||
setResults((prev) => {
|
||||
const next = { ...prev };
|
||||
if (slot) {
|
||||
next[rowKey] = slot;
|
||||
} else {
|
||||
delete next[rowKey];
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, 800);
|
||||
}
|
||||
}, [snapshot, staticDefs, listDefs, form]);
|
||||
|
||||
// 卸载时清除所有定时器
|
||||
useEffect(() => {
|
||||
const timers = timersRef.current;
|
||||
return () => {
|
||||
for (const key of Object.keys(timers)) {
|
||||
clearTimeout(timers[key]);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 表单 reset / 切换配置时清空
|
||||
useEffect(() => {
|
||||
setResults({});
|
||||
prevRef.current = {};
|
||||
}, [staticDefs, listDefs]);
|
||||
|
||||
return {
|
||||
results: allDepPaths.length > 0 ? results : {},
|
||||
Provider: ResultsCtx.Provider,
|
||||
} as const;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ValidatedFormItem — 替代 Form.Item,自动注入 validateStatus / hasFeedback / help
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ValidatedFormItemProps extends FormItemProps {
|
||||
name: NamePath;
|
||||
}
|
||||
|
||||
export function ValidatedFormItem({ name, ...rest }: ValidatedFormItemProps) {
|
||||
const results = useContext(ResultsCtx);
|
||||
const { token } = theme.useToken();
|
||||
const key = pathKey(name);
|
||||
const slot = results[key];
|
||||
|
||||
return (
|
||||
<Form.Item
|
||||
{...rest}
|
||||
name={name}
|
||||
validateStatus={slot?.status}
|
||||
help={
|
||||
slot
|
||||
? (
|
||||
<span style={{ color: slot.status === "success" ? token.colorSuccess : token.colorError }}>
|
||||
{slot.status === "success"
|
||||
? <CheckOutlined style={{ marginInlineEnd: token.marginXXS }} />
|
||||
: <CloseOutlined style={{ marginInlineEnd: token.marginXXS }} />
|
||||
}
|
||||
{slot.help}
|
||||
</span>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 工具
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function pathKey(p: NamePath | (string | number)[]): string {
|
||||
return JSON.stringify(p);
|
||||
}
|
||||
|
||||
function toArray(p: NamePath): (string | number)[] {
|
||||
return Array.isArray(p) ? p : [p];
|
||||
}
|
||||
|
||||
function getFieldValue(obj: unknown, name: NamePath): unknown {
|
||||
if (typeof name === "string" || typeof name === "number") {
|
||||
return (obj as Record<string, unknown>)?.[String(name)];
|
||||
}
|
||||
let cur: unknown = obj;
|
||||
for (const key of name) {
|
||||
if (cur == null) return undefined;
|
||||
cur = (cur as Record<string, unknown>)[String(key)];
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
@ -103,3 +103,150 @@ export async function parseProject(config: Config): Promise<ParsedProject> {
|
||||
|
||||
return { version, artifacts };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 表单校验用 server actions — 每个独立、容错、返回 { ok, ... }
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type OkResult<T> = { ok: true } & T;
|
||||
|
||||
interface ErrResult {
|
||||
ok: false;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface McDirResult {
|
||||
versions: string[];
|
||||
}
|
||||
|
||||
export interface ProjectVersionResult {
|
||||
version: string | null;
|
||||
keys: string[];
|
||||
}
|
||||
|
||||
export interface FileMatch {
|
||||
mc_version: string;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
export interface FileCheckResult {
|
||||
matched: FileMatch[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描 MC 属性目录,返回所有 *.properties 文件对应的版本名。
|
||||
*/
|
||||
export async function scanMcPropertiesDir(
|
||||
projectDir: string,
|
||||
mcPropertiesDir: string,
|
||||
): Promise<OkResult<McDirResult> | ErrResult> {
|
||||
const fullPath = path.join(projectDir, mcPropertiesDir);
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await readdir(fullPath);
|
||||
} catch {
|
||||
return { ok: false, error: "目录不存在或无法读取" };
|
||||
}
|
||||
const versions = entries
|
||||
.filter((f) => f.endsWith(".properties"))
|
||||
.map((f) => f.slice(0, -".properties".length));
|
||||
if (versions.length === 0) {
|
||||
return { ok: false, error: "目录中未找到 .properties 文件" };
|
||||
}
|
||||
return { ok: true, versions };
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取项目属性文件,提取版本号键值和所有键名。
|
||||
*/
|
||||
export async function readProjectVersion(
|
||||
projectDir: string,
|
||||
propsPath: string,
|
||||
fieldName: string,
|
||||
): Promise<OkResult<ProjectVersionResult> | ErrResult> {
|
||||
const fullPath = path.join(projectDir, propsPath);
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await readFile(fullPath, "utf-8");
|
||||
} catch {
|
||||
return { ok: false, error: "属性文件不存在或无法读取" };
|
||||
}
|
||||
const props = parseProperties(raw);
|
||||
const keys = Object.keys(props);
|
||||
const version = props[fieldName] ?? null;
|
||||
return { ok: true, version, keys };
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查构建产物文件名模板匹配到的实际文件。
|
||||
*/
|
||||
export async function checkArtifactFiles(
|
||||
projectDir: string,
|
||||
filenameFormat: string,
|
||||
version: string,
|
||||
mcPropertiesDir: string,
|
||||
): Promise<OkResult<FileCheckResult> | ErrResult> {
|
||||
const mcResult = await scanMcPropertiesDir(projectDir, mcPropertiesDir);
|
||||
if (!mcResult.ok) return mcResult;
|
||||
|
||||
const versions = mcResult.versions;
|
||||
const buildDir = getBuildDir(projectDir);
|
||||
const tmpl = new Template(filenameFormat);
|
||||
|
||||
const matched: FileMatch[] = [];
|
||||
for (const mc_version of versions) {
|
||||
let filename: string;
|
||||
try {
|
||||
filename = tmpl.format({ version, mc_version });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const filePath = path.join(buildDir, filename);
|
||||
const exists = await stat(filePath).then(() => true).catch(() => false);
|
||||
if (exists) {
|
||||
matched.push({ mc_version, filename });
|
||||
}
|
||||
}
|
||||
|
||||
if (matched.length === 0) {
|
||||
return { ok: false, error: "未匹配到任何构建产物(请先执行 Gradle build)" };
|
||||
}
|
||||
return { ok: true, matched };
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查源码文件名模板匹配到的实际文件。
|
||||
*/
|
||||
export async function checkSourceFiles(
|
||||
projectDir: string,
|
||||
sourceFormat: string,
|
||||
version: string,
|
||||
mcPropertiesDir: string,
|
||||
): Promise<OkResult<FileCheckResult> | ErrResult> {
|
||||
const mcResult = await scanMcPropertiesDir(projectDir, mcPropertiesDir);
|
||||
if (!mcResult.ok) return mcResult;
|
||||
|
||||
const versions = mcResult.versions;
|
||||
const buildDir = getBuildDir(projectDir);
|
||||
const tmpl = new Template(sourceFormat);
|
||||
|
||||
const matched: FileMatch[] = [];
|
||||
for (const mc_version of versions) {
|
||||
let filename: string;
|
||||
try {
|
||||
filename = tmpl.format({ version, mc_version });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const filePath = path.join(buildDir, filename);
|
||||
const exists = await stat(filePath).then(() => true).catch(() => false);
|
||||
if (exists) {
|
||||
matched.push({ mc_version, filename });
|
||||
}
|
||||
}
|
||||
|
||||
if (matched.length === 0) {
|
||||
return { ok: false, error: "未匹配到任何源码产物(请先执行 Gradle build)" };
|
||||
}
|
||||
return { ok: true, matched };
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user