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:
CPTProgrammer 2026-07-13 12:57:59 +08:00
parent d79af724e3
commit 750f2a18f8
No known key found for this signature in database
3 changed files with 869 additions and 350 deletions

View File

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

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

View File

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