Add dynamic field validation for Modrinth and CurseForge lists

This commit is contained in:
CPTProgrammer 2026-07-15 12:38:50 +08:00
parent 79d21571d9
commit c1f07cc31e
No known key found for this signature in database
4 changed files with 197 additions and 79 deletions

View File

@ -30,6 +30,7 @@ import {
import { import {
useFieldValidations, useFieldValidations,
ValidatedFormItem, ValidatedFormItem,
ValidationSlot,
type FormNamePath, type FormNamePath,
type StaticOnly, type StaticOnly,
type StaticValidationDef, type StaticValidationDef,
@ -166,7 +167,38 @@ const STATIC_DEFS: StaticValidationDef<Config>[] = [
}, },
]; ];
const LIST_DEFS: ListValidationDef<Config>[] = []; const LIST_DEFS: ListValidationDef<Config>[] = [
// Modrinth 依赖 — 查询依赖项目名称
{
list: ["modrinth", "dependencies"],
target: "project_id",
deps: ["project_id"],
async validate(getVal, row) {
const id = getVal("project_id") as string | undefined;
if (!id) return null;
const r = await lookupModrinthProject(id);
return r.ok
? { status: "success", help: `项目:${r.name}` }
: { status: "error", help: r.error };
},
},
// CurseForge 关联项目 — 查询关联项目名称并自动填充 slug
{
list: ["curseforge", "relations", "projects"],
target: "projectID",
deps: ["projectID"],
async validate(getVal, row, setVal) {
const id = getVal("projectID") as string | undefined;
if (!id) return null;
const r = await lookupCurseforgeProject(Number(id));
if (r.ok) {
setVal("slug", r.slug);
return { status: "success", help: `项目:${r.name}` };
}
return { status: "error", help: r.error };
},
},
];
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Data-fetching hooks — each Select loads independently, no blocking // Data-fetching hooks — each Select loads independently, no blocking
@ -613,6 +645,7 @@ function ConfigModalForm({
/> />
</Form.Item> </Form.Item>
<DeleteOutlined onClick={() => remove(name)} /> <DeleteOutlined onClick={() => remove(name)} />
<ValidationSlot name={["modrinth", "dependencies", name, "project_id"]} />
</Space> </Space>
))} ))}
<Button <Button
@ -758,6 +791,7 @@ function ConfigModalForm({
<DeleteOutlined <DeleteOutlined
onClick={() => remove(name)} onClick={() => remove(name)}
/> />
<ValidationSlot name={["curseforge", "relations", "projects", name, "projectID"]} />
</Space> </Space>
))} ))}
<Button <Button

View File

@ -1,6 +1,7 @@
"use client"; "use client";
import { createContext, useContext, useEffect, useMemo, useRef, useState } from "react"; import { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
import type React from "react";
import { Form, theme } from "antd"; import { Form, theme } from "antd";
import { CheckOutlined, CloseOutlined } from "@ant-design/icons"; import { CheckOutlined, CloseOutlined } from "@ant-design/icons";
import type { FormInstance, FormItemProps } from "antd"; import type { FormInstance, FormItemProps } from "antd";
@ -33,6 +34,21 @@ export type StaticOnly<T> = T extends unknown[]
? number extends T[number] ? never : T ? number extends T[number] ? never : T
: T; : T;
/** 根据路径推导类型的辅助工具 */
export type GetFieldType<T, P> = P extends keyof T & string
? T[P]
: P extends []
? T
: P extends [infer K, ...infer Rest]
? K extends keyof T
? GetFieldType<T[K], Rest>
: K extends number
? T extends readonly (infer U)[]
? GetFieldType<U, Rest>
: never
: never
: never;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 校验定义 // 校验定义
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -53,7 +69,8 @@ export interface StaticValidationDef<T> {
): Promise<ValidationSlot | null>; ): Promise<ValidationSlot | null>;
} }
export interface ListValidationDef<T> { /** 内部使用的宽泛 ListValidationDef防止 TypeScript 泛型类型推断失败导致报错 */
interface _ListValidationDef<T> {
/** Form.List 的完整路径 */ /** Form.List 的完整路径 */
list: FormNamePath<T>; list: FormNamePath<T>;
/** 列表项中要反馈的子字段名 */ /** 列表项中要反馈的子字段名 */
@ -64,9 +81,26 @@ export interface ListValidationDef<T> {
validate( validate(
getVal: (name: string) => unknown, getVal: (name: string) => unknown,
row: number, row: number,
setVal: (name: string, value: unknown) => void,
): Promise<ValidationSlot | null>; ): Promise<ValidationSlot | null>;
} }
export type ListValidationDef<T, L = FormNamePath<T>> =
L extends FormNamePath<T>
? GetFieldType<T, L> extends readonly (infer ItemType)[]
? {
list: L;
target: StaticOnly<FormNamePath<ItemType>>;
deps: StaticOnly<FormNamePath<ItemType>>[];
validate(
getVal: (name: FormNamePath<ItemType>) => unknown,
row: number,
setVal: (name: FormNamePath<ItemType>, value: unknown) => void,
): Promise<ValidationSlot | null>;
}
: never
: never;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Context — hook 与 ValidatedFormItem 的桥梁 // Context — hook 与 ValidatedFormItem 的桥梁
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -80,55 +114,53 @@ const ResultsCtx = createContext<Record<string, ValidationSlot>>({});
export function useFieldValidations<T>( export function useFieldValidations<T>(
form: FormInstance, form: FormInstance,
staticDefs: StaticValidationDef<T>[], staticDefs: StaticValidationDef<T>[],
listDefs: ListValidationDef<T>[], listDefs: _ListValidationDef<T>[],
) { ) {
const [results, setResults] = useState<Record<string, ValidationSlot>>({}); const [results, setResults] = useState<Record<string, ValidationSlot>>({});
const timersRef = useRef<Record<string, ReturnType<typeof setTimeout>>>({}); const timersRef = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
const prevRef = useRef<Record<string, unknown>>({}); const prevRef = useRef<Record<string, unknown>>({});
const snapshotRef = useRef<Record<string, unknown>>({});
// 收集所有需要监听的字段路径 // 静态 deps 路径
const allDepPaths = useMemo(() => { const staticPaths = useMemo(() => {
const set = new Set<string>(); const set = new Set<string>();
for (const d of staticDefs) { for (const d of staticDefs) {
set.add(pathKey(d.target)); set.add(pathKey(d.target));
for (const dep of d.deps) set.add(pathKey(dep)); 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>); return [...set].map((k) => JSON.parse(k) as FormNamePath<T>);
}, [staticDefs, listDefs]); }, [staticDefs]);
// 用 selector 监听所有 deps只返回快照哈希只有 deps 真正变化时才触发渲染) // 用 selector 返回结构化快照,只有真正变化时触发 Effect
const snapshot = Form.useWatch((values) => { const snapshot = Form.useWatch((values) => {
const parts: string[] = []; const next: Record<string, unknown> = {};
for (const p of allDepPaths) { for (const p of staticPaths) {
parts.push(pathKey(p) + "=" + JSON.stringify(getFieldValue(values, p))); next[pathKey(p)] = getFieldValue(values, p);
} }
return parts.join("|"); for (const ld of listDefs) {
const listVal = getFieldValue(values, ld.list) as Record<string, unknown>[] | undefined;
next[pathKey(ld.list)] = listVal?.map(
(row) => ld.deps.map((d) => JSON.stringify(row[d])).join("|")
);
}
if (shallowEqual(snapshotRef.current, next)) return snapshotRef.current;
snapshotRef.current = next;
return next;
}, form); }, form);
useEffect(() => { 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); const getVal = (name: FormNamePath<T>) => form.getFieldValue(name);
// 为每个受影响的静态 def 重置独立定时器 // ── 静态 def diff ──
for (const def of staticDefs) { for (const def of staticDefs) {
const defKey = pathKey(def.target); const defKey = pathKey(def.target);
const depKeys = def.deps.map((d) => pathKey(d)); const depKeys = def.deps.map((d) => pathKey(d));
if (!changedKeys.some((k) => k === defKey || depKeys.includes(k))) continue; let changed = false;
for (const dk of [defKey, ...depKeys]) {
const cur = JSON.stringify(snapshot[dk]);
if (prevRef.current[dk] !== cur) { changed = true; prevRef.current[dk] = cur; }
}
if (!changed) continue;
const timerKey = "s_" + defKey; const timerKey = "s_" + defKey;
if (timersRef.current[timerKey]) clearTimeout(timersRef.current[timerKey]); if (timersRef.current[timerKey]) clearTimeout(timersRef.current[timerKey]);
@ -136,53 +168,60 @@ export function useFieldValidations<T>(
const slot = await def.validate(getVal); const slot = await def.validate(getVal);
setResults((prev) => { setResults((prev) => {
const next = { ...prev }; const next = { ...prev };
if (slot) { if (slot) next[defKey] = slot; else delete next[defKey];
next[defKey] = slot;
} else {
delete next[defKey];
}
return next; return next;
}); });
}, 800); }, 800);
} }
// 为每个受影响的列表 def 重置定时器 // ── 列表 def 逐行 diff ──
for (const ld of listDefs) { for (const ld of listDefs) {
const listKey = pathKey(ld.list); const listKey = pathKey(ld.list);
if (!changedKeys.includes(listKey)) continue; const prevRows = (prevRef.current[listKey] ?? []) as string[];
const curRows = (snapshot[listKey] ?? []) as string[];
prevRef.current[listKey] = curRows;
const timerKey = "l_" + listKey; if (!isShallowArrEqual(prevRows, curRows)) {
if (timersRef.current[timerKey]) clearTimeout(timersRef.current[timerKey]); // 找出变化的行
timersRef.current[timerKey] = setTimeout(async () => { const maxLen = Math.max(prevRows.length, curRows.length);
const listVal = form.getFieldValue(ld.list) as unknown[] | undefined; const changedRows = new Set<number>();
if (!listVal?.length) { for (let i = 0; i < maxLen; i++) {
// 列表为空时清除所有该列表的校验结果 if (prevRows[i] !== curRows[i]) changedRows.add(i);
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++) { // 清除多余旧行的 timer
const getRowVal = (name: string) => for (let i = curRows.length; i < prevRows.length; i++) {
form.getFieldValue([...toArray(ld.list), i, name]); const oldTimerKey = timerKey(ld, i);
const slot = await ld.validate(getRowVal, i); if (timersRef.current[oldTimerKey]) clearTimeout(timersRef.current[oldTimerKey]);
const rowKey = pathKey([...toArray(ld.list), i, ld.target]); }
// 清除已删除行的 result
for (let i = curRows.length; i < prevRows.length; i++) {
const deletedKey = pathKey([...toArray(ld.list), i, ld.target]);
setResults((prev) => { setResults((prev) => {
const next = { ...prev }; const next = { ...prev };
if (slot) { delete next[deletedKey];
next[rowKey] = slot;
} else {
delete next[rowKey];
}
return next; return next;
}); });
} }
}, 800); // 为变化行重置定时器
for (const i of changedRows) {
if (i >= curRows.length) continue;
const rowTimerKey = timerKey(ld, i);
if (timersRef.current[rowTimerKey]) clearTimeout(timersRef.current[rowTimerKey]);
timersRef.current[rowTimerKey] = setTimeout(async () => {
const getRowVal = (name: string) =>
form.getFieldValue([...toArray(ld.list), i, name]);
const setRowVal = (name: string, value: unknown) =>
form.setFieldValue([...toArray(ld.list), i, name], value);
const slot = await ld.validate(getRowVal, i, setRowVal);
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]); }, [snapshot, staticDefs, listDefs, form]);
@ -200,16 +239,32 @@ export function useFieldValidations<T>(
useEffect(() => { useEffect(() => {
setResults({}); setResults({});
prevRef.current = {}; prevRef.current = {};
snapshotRef.current = {};
}, [staticDefs, listDefs]); }, [staticDefs, listDefs]);
return { return {
results: allDepPaths.length > 0 ? results : {}, results: staticPaths.length > 0 || listDefs.length > 0 ? results : {},
Provider: ResultsCtx.Provider, Provider: ResultsCtx.Provider,
} as const; } as const;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// ValidatedFormItem — 替代 Form.Item自动注入 validateStatus / hasFeedback / help // 渲染工具 — 统一样式
// ---------------------------------------------------------------------------
function renderSlot(
slot: ValidationSlot,
token: { colorSuccess: string; colorError: string; marginXXS: number },
): React.ReactNode {
const color = slot.status === "success" ? token.colorSuccess : token.colorError;
const icon = slot.status === "success"
? <CheckOutlined style={{ marginInlineEnd: token.marginXXS }} />
: <CloseOutlined style={{ marginInlineEnd: token.marginXXS }} />;
return <span style={{ color }}>{icon}{slot.help}</span>;
}
// ---------------------------------------------------------------------------
// ValidatedFormItem — 替代 Form.Item自动注入 validateStatus / help
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
interface ValidatedFormItemProps extends FormItemProps { interface ValidatedFormItemProps extends FormItemProps {
@ -227,23 +282,29 @@ export function ValidatedFormItem({ name, ...rest }: ValidatedFormItemProps) {
{...rest} {...rest}
name={name} name={name}
validateStatus={slot?.status} validateStatus={slot?.status}
help={ help={slot ? renderSlot(slot, token) : undefined}
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
}
/> />
); );
} }
// ---------------------------------------------------------------------------
// ValidationSlot — 内联渲染校验结果,放在任意位置
// ---------------------------------------------------------------------------
interface ValidationSlotProps {
name: NamePath;
}
export function ValidationSlot({ name }: ValidationSlotProps) {
const results = useContext(ResultsCtx);
const { token } = theme.useToken();
const key = pathKey(name);
const slot = results[key];
if (!slot) return null;
return <>{renderSlot(slot, token)}</>;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 工具 // 工具
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -267,3 +328,25 @@ function getFieldValue(obj: unknown, name: NamePath): unknown {
} }
return cur; return cur;
} }
function timerKey<T>(ld: _ListValidationDef<T>, row: number): string {
return "l_" + pathKey(ld.list) + "_" + row;
}
function shallowEqual(a: Record<string, unknown>, b: Record<string, unknown>): boolean {
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
for (const k of keysA) {
if (a[k] !== b[k]) return false;
}
return true;
}
function isShallowArrEqual(a: string[], b: string[]): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}

View File

@ -272,10 +272,10 @@ export async function lookupModrinthProject(
export async function lookupCurseforgeProject( export async function lookupCurseforgeProject(
projectId: number, projectId: number,
): Promise<OkResult<{ name: string }> | ErrResult> { ): Promise<OkResult<{ name: string; slug: string }> | ErrResult> {
try { try {
const mod = await getMod(curseforgeClient, projectId); const mod = await getMod(curseforgeClient, projectId);
return { ok: true, name: mod.data.name }; return { ok: true, name: mod.data.name, slug: mod.data.slug };
} catch (err) { } catch (err) {
return { ok: false, error: err instanceof Error ? err.message : "查询 CurseForge 项目失败" }; return { ok: false, error: err instanceof Error ? err.message : "查询 CurseForge 项目失败" };
} }

View File

@ -169,6 +169,7 @@ export const ModSchema = z.object({
rating: z.number().nullable().optional(), rating: z.number().nullable().optional(),
}).pick({ }).pick({
name: true, name: true,
slug: true,
}); });
export type Mod = z.infer<typeof ModSchema>; export type Mod = z.infer<typeof ModSchema>;
// endregion // endregion