diff --git a/src/components/ConfigModal.tsx b/src/components/ConfigModal.tsx index 9a4a990..7033309 100644 --- a/src/components/ConfigModal.tsx +++ b/src/components/ConfigModal.tsx @@ -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; +type StaticConfigNamePath = StaticOnly; + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -43,6 +64,83 @@ const RELATION_TYPE_LABEL_MAP: Record = { }; const RELATION_TYPES = Object.entries(RELATION_TYPE_LABEL_MAP).map(v => ({ value: v[0], label: v[1] })); +// --------------------------------------------------------------------------- +// 校验定义 — 声明式配置,数组驱动表单实时反馈 +// --------------------------------------------------------------------------- + +const STATIC_DEFS: StaticValidationDef[] = [ + // 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[] = []; + // --------------------------------------------------------------------------- // 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(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 ( -
- - - )} - - - - ), - }, - - // ──── CurseForge ──── - { - key: "curseforge", - label: "CurseForge", - children: ( - <> - - - lookupCurseforgeProject( - typeof val === "number" ? val : null, - ) - } - /> - - - - - - - - ({ - label: l, - value: l, - }))} - notFoundContent={ - cfMeta.loading ? "加载中…" : "无可用选项" - } - /> - - - {/* Relations */} - - + - {(fields, { add, remove }) => ( - <> - {fields.map(({ key, name, ...rest }) => ( - + + + + + + + + + + + + + + + + + + + + + + + + + + + ), + }, + + // ──── Modrinth ──── + { + key: "modrinth", + label: "Modrinth", + children: ( + <> + + lookupModrinthProject(e.target.value)} + /> + + + + + + + + + + + + + + + {/* Dependencies */} + + + {(fields, { add, remove }) => ( + <> + {fields.map(({ key, name, ...rest }) => ( + + + + + remove(name)} /> + + ))} + - - )} - - - - ), - }, - ]} - /> - + + ))} + + + )} + +
+ + ), + }, + ]} + /> + + ); } diff --git a/src/hooks/useFieldValidations.tsx b/src/hooks/useFieldValidations.tsx new file mode 100644 index 0000000..6cc7ec9 --- /dev/null +++ b/src/hooks/useFieldValidations.tsx @@ -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 = { + [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 + : + | (Prefix extends [] ? K : [...Prefix, K]) + | FormNamePath +}[keyof T & string]; + +/** 从 NamePath 联合类型中剔除含 number 的路径,仅保留静态路径 */ +export type StaticOnly = T extends unknown[] + ? number extends T[number] ? never : T + : T; + +// --------------------------------------------------------------------------- +// 校验定义 +// --------------------------------------------------------------------------- + +export interface ValidationSlot { + status: ValidateStatus; + help: string; +} + +export interface StaticValidationDef { + /** 反馈显示的目标 Form.Item name */ + target: StaticOnly>; + /** 依赖字段,任一变化即触发校验 */ + deps: StaticOnly>[]; + /** 校验逻辑,返回 null 表示不更新状态(如必填字段未填) */ + validate( + getVal: (name: FormNamePath) => unknown, + ): Promise; +} + +export interface ListValidationDef { + /** Form.List 的完整路径 */ + list: FormNamePath; + /** 列表项中要反馈的子字段名 */ + target: string; + /** 列表项中依赖的子字段名 */ + deps: string[]; + /** 校验逻辑,row 为当前行号(0-based) */ + validate( + getVal: (name: string) => unknown, + row: number, + ): Promise; +} + +// --------------------------------------------------------------------------- +// Context — hook 与 ValidatedFormItem 的桥梁 +// --------------------------------------------------------------------------- + +const ResultsCtx = createContext>({}); + +// --------------------------------------------------------------------------- +// Hook +// --------------------------------------------------------------------------- + +export function useFieldValidations( + form: FormInstance, + staticDefs: StaticValidationDef[], + listDefs: ListValidationDef[], +) { + const [results, setResults] = useState>({}); + const timersRef = useRef>>({}); + const prevRef = useRef>({}); + + // 收集所有需要监听的字段路径 + const allDepPaths = useMemo(() => { + const set = new Set(); + 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); + }, [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) => 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 ( + + {slot.status === "success" + ? + : + } + {slot.help} + + ) + : 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(name)]; + } + let cur: unknown = obj; + for (const key of name) { + if (cur == null) return undefined; + cur = (cur as Record)[String(key)]; + } + return cur; +} diff --git a/src/services/project.ts b/src/services/project.ts index ba25050..3298954 100644 --- a/src/services/project.ts +++ b/src/services/project.ts @@ -103,3 +103,150 @@ export async function parseProject(config: Config): Promise { return { version, artifacts }; } + +// --------------------------------------------------------------------------- +// 表单校验用 server actions — 每个独立、容错、返回 { ok, ... } +// --------------------------------------------------------------------------- + +type OkResult = { 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 | 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 | 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 | 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 | 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 }; +}