Add dynamic field validation for Modrinth and CurseForge lists
This commit is contained in:
parent
79d21571d9
commit
c1f07cc31e
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 项目失败" };
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user