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 {
useFieldValidations,
ValidatedFormItem,
ValidationSlot,
type FormNamePath,
type StaticOnly,
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
@ -613,6 +645,7 @@ function ConfigModalForm({
/>
</Form.Item>
<DeleteOutlined onClick={() => remove(name)} />
<ValidationSlot name={["modrinth", "dependencies", name, "project_id"]} />
</Space>
))}
<Button
@ -758,6 +791,7 @@ function ConfigModalForm({
<DeleteOutlined
onClick={() => remove(name)}
/>
<ValidationSlot name={["curseforge", "relations", "projects", name, "projectID"]} />
</Space>
))}
<Button

View File

@ -1,6 +1,7 @@
"use client";
import { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
import type React from "react";
import { Form, theme } from "antd";
import { CheckOutlined, CloseOutlined } from "@ant-design/icons";
import type { FormInstance, FormItemProps } from "antd";
@ -33,6 +34,21 @@ export type StaticOnly<T> = T extends unknown[]
? number extends T[number] ? never : 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>;
}
export interface ListValidationDef<T> {
/** 内部使用的宽泛 ListValidationDef防止 TypeScript 泛型类型推断失败导致报错 */
interface _ListValidationDef<T> {
/** Form.List 的完整路径 */
list: FormNamePath<T>;
/** 列表项中要反馈的子字段名 */
@ -64,9 +81,26 @@ export interface ListValidationDef<T> {
validate(
getVal: (name: string) => unknown,
row: number,
setVal: (name: string, value: unknown) => void,
): 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 的桥梁
// ---------------------------------------------------------------------------
@ -80,55 +114,53 @@ const ResultsCtx = createContext<Record<string, ValidationSlot>>({});
export function useFieldValidations<T>(
form: FormInstance,
staticDefs: StaticValidationDef<T>[],
listDefs: ListValidationDef<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 snapshotRef = useRef<Record<string, unknown>>({});
// 收集所有需要监听的字段路径
const allDepPaths = useMemo(() => {
// 静态 deps 路径
const staticPaths = 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]);
}, [staticDefs]);
// 用 selector 监听所有 deps只返回快照哈希只有 deps 真正变化时才触发渲染)
// 用 selector 返回结构化快照,只有真正变化时触发 Effect
const snapshot = Form.useWatch((values) => {
const parts: string[] = [];
for (const p of allDepPaths) {
parts.push(pathKey(p) + "=" + JSON.stringify(getFieldValue(values, p)));
const next: Record<string, unknown> = {};
for (const p of staticPaths) {
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);
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 重置独立定时器
// ── 静态 def diff ──
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;
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;
if (timersRef.current[timerKey]) clearTimeout(timersRef.current[timerKey]);
@ -136,53 +168,60 @@ export function useFieldValidations<T>(
const slot = await def.validate(getVal);
setResults((prev) => {
const next = { ...prev };
if (slot) {
next[defKey] = slot;
} else {
delete next[defKey];
}
if (slot) next[defKey] = slot; else delete next[defKey];
return next;
});
}, 800);
}
// 为每个受影响的列表 def 重置定时器
// ── 列表 def 逐行 diff ──
for (const ld of listDefs) {
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 (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;
if (!isShallowArrEqual(prevRows, curRows)) {
// 找出变化的行
const maxLen = Math.max(prevRows.length, curRows.length);
const changedRows = new Set<number>();
for (let i = 0; i < maxLen; i++) {
if (prevRows[i] !== curRows[i]) changedRows.add(i);
}
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]);
// 清除多余旧行的 timer
for (let i = curRows.length; i < prevRows.length; i++) {
const oldTimerKey = timerKey(ld, i);
if (timersRef.current[oldTimerKey]) clearTimeout(timersRef.current[oldTimerKey]);
}
// 清除已删除行的 result
for (let i = curRows.length; i < prevRows.length; i++) {
const deletedKey = pathKey([...toArray(ld.list), i, ld.target]);
setResults((prev) => {
const next = { ...prev };
if (slot) {
next[rowKey] = slot;
} else {
delete next[rowKey];
}
delete next[deletedKey];
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]);
@ -200,16 +239,32 @@ export function useFieldValidations<T>(
useEffect(() => {
setResults({});
prevRef.current = {};
snapshotRef.current = {};
}, [staticDefs, listDefs]);
return {
results: allDepPaths.length > 0 ? results : {},
results: staticPaths.length > 0 || listDefs.length > 0 ? results : {},
Provider: ResultsCtx.Provider,
} 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 {
@ -227,23 +282,29 @@ export function ValidatedFormItem({ name, ...rest }: ValidatedFormItemProps) {
{...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
}
help={slot ? renderSlot(slot, token) : 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;
}
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(
projectId: number,
): Promise<OkResult<{ name: string }> | ErrResult> {
): Promise<OkResult<{ name: string; slug: string }> | ErrResult> {
try {
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) {
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(),
}).pick({
name: true,
slug: true,
});
export type Mod = z.infer<typeof ModSchema>;
// endregion