feat(admin): 前端可接入点 —— 折叠分组 + 动态插件页 + schema 渲染(PLG-3)
- sections.tsx 的分组升级为可折叠(CollapsibleGroup);新增 plugins 分组由 GET /api/v1/admin/panels 动态填充(navModel.ts 纯函数可单测) - 插件页走动态段 /admin/plugin/<pluginId>/<panelId>,不进 router.ts 的静态白名单 - 把既有三段式(字段声明表 + 纯函数规则 + 渲染器,含「留空=不改」掩码语义)泛化成 schema → 控件(src/admin/schema/),复用它渲染插件表单/表格/事实面/日志 - 可测判据:新增插件页不得改动 sections.tsx / router.ts / AdminApp.tsx 的静态清单 - when 不满足 ⇒ 入口不出现;viewer 只看到只读区块 我的复跑:npx tsc --noEmit 通过;npx vite build 通过(3.67s);50 个前端脚本式测试全部 打印 checks passed(含本片新增 pluginData 90 / PluginPanel.render 55 / navModel 62 / schema values 40)。
This commit is contained in:
1 parent
ede00d9928
commit
9e63af9063
29 files changed
+3527
-146
No files matched your search
@@ -0,0 +1,183 @@
|
||||
/** 插件表单区块的状态机(三个 hook,无 JSX)。
|
||||
*
|
||||
* 拆出来的理由有两条,都是硬约束:
|
||||
* ① 「函数 ≤80 行」(AGENTS §10)—— 表单组件把「值同步 + 候选加载 + 提交」塞进一个函数
|
||||
* 会到 170 行以上;
|
||||
* ② 「快照每 30s 重取一次也不能把用户正在输入的内容重置掉」这条行为只有在**依赖键**
|
||||
* 集中的一处才守得住,散在组件里迟早有人漏一个。
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { SchemaField } from "../schema/fieldTypes";
|
||||
import { loadFormValues, planSubmit } from "../schema/values";
|
||||
import { actionFailure, resolveOptionsAction } from "./actions";
|
||||
import { runPluginAction } from "./panelsApi";
|
||||
import { decodeOptions, type PanelFieldDto, type PanelOptionDto } from "./types";
|
||||
|
||||
export interface FormFlash {
|
||||
tone: "ok" | "err";
|
||||
text: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export interface FormValuesState {
|
||||
/** 控件里的当前值(一律字符串,与渠道配置表单同口径)。 */
|
||||
values: Record<string, string>;
|
||||
/** 打开表单时的值:判断「用户把盘上已有值清空了」必须知道它原本是什么。 */
|
||||
initial: Record<string, string>;
|
||||
/** 盘上已有值的键(含只有掩码可看的秘密)。 */
|
||||
configured: Set<string>;
|
||||
/** 显式勾选清除的键(掩码秘密唯一能清除的方式)。 */
|
||||
cleared: Set<string>;
|
||||
/** 本次提交被拦下时点名的必填字段。 */
|
||||
missing: Set<string>;
|
||||
setValue: (key: string, next: string) => void;
|
||||
toggleClear: (key: string, next: boolean) => void;
|
||||
markMissing: (keys: readonly string[]) => void;
|
||||
/** 提交成功后清掉「显式清除」勾选(否则开关会一直亮着、输入框一直禁用)。 */
|
||||
resetCleared: () => void;
|
||||
}
|
||||
|
||||
/** 表单值状态:外部值(快照 / 行内动作带回)变了才重置,其余时候保持用户输入。
|
||||
*
|
||||
* 用**渲染期派生 state** 而不是 useEffect 做首次装载:初次渲染(含服务端渲染)就该是
|
||||
* 「打开即显示现状」,而 effect 只在客户端跑,会让首帧先画一个空表单再跳成有值的。
|
||||
* `fieldsKey` / `externalKey` 用 JSON 串是因为快照每 30s 重取一次、对象身份每次都变。 */
|
||||
export function useFormValues(
|
||||
fields: readonly SchemaField[],
|
||||
fieldsKey: string,
|
||||
external: Record<string, unknown>,
|
||||
): FormValuesState {
|
||||
const externalKey = JSON.stringify(external);
|
||||
const loadedKey = `${fieldsKey}|${externalKey}`;
|
||||
const loaded = useMemo(() => loadFormValues(fields, external), [loadedKey]);
|
||||
const [values, setValues] = useState<Record<string, string>>(loaded.values);
|
||||
const [initial, setInitial] = useState<Record<string, string>>(loaded.values);
|
||||
const [configured, setConfigured] = useState<string[]>(loaded.configured);
|
||||
const [cleared, setCleared] = useState<string[]>([]);
|
||||
const [missing, setMissing] = useState<string[]>([]);
|
||||
const [syncedKey, setSyncedKey] = useState(loadedKey);
|
||||
|
||||
if (syncedKey !== loadedKey) {
|
||||
setSyncedKey(loadedKey);
|
||||
setValues(loaded.values);
|
||||
setInitial(loaded.values);
|
||||
setConfigured(loaded.configured);
|
||||
setCleared([]);
|
||||
setMissing([]);
|
||||
}
|
||||
|
||||
const setValue = useCallback((key: string, next: string) => {
|
||||
setValues((prev) => ({ ...prev, [key]: next }));
|
||||
}, []);
|
||||
const toggleClear = useCallback((key: string, next: boolean) => {
|
||||
setCleared((prev) => (next ? [...new Set([...prev, key])] : prev.filter((k) => k !== key)));
|
||||
}, []);
|
||||
const markMissing = useCallback((keys: readonly string[]) => {
|
||||
setMissing([...keys]);
|
||||
}, []);
|
||||
const resetCleared = useCallback(() => setCleared([]), []);
|
||||
|
||||
return {
|
||||
values,
|
||||
initial,
|
||||
configured: useMemo(() => new Set(configured), [configured]),
|
||||
cleared: useMemo(() => new Set(cleared), [cleared]),
|
||||
missing: useMemo(() => new Set(missing), [missing]),
|
||||
setValue,
|
||||
toggleClear,
|
||||
markMissing,
|
||||
resetCleared,
|
||||
};
|
||||
}
|
||||
|
||||
export interface FieldOptionsState {
|
||||
options: Record<string, readonly PanelOptionDto[]>;
|
||||
/** `optionsFrom` → 失败原因(动作名 + 错误码)。取不到不是致命错误,但**要说出来**。 */
|
||||
optionErrors: Record<string, string>;
|
||||
}
|
||||
|
||||
/** 下拉候选:按 `optionsFrom` 逐条取一次(取不到就降级成文本框,由调用方在说明里写明)。 */
|
||||
export function useFieldOptions(pluginId: string, declared: readonly PanelFieldDto[]): FieldOptionsState {
|
||||
const [options, setOptions] = useState<Record<string, readonly PanelOptionDto[]>>({});
|
||||
const [optionErrors, setOptionErrors] = useState<Record<string, string>>({});
|
||||
const fromKeys = Array.from(new Set(declared.map((f) => f.optionsFrom ?? "").filter((k) => k !== ""))).join("|");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const keys = fromKeys === "" ? [] : fromKeys.split("|");
|
||||
for (const key of keys) {
|
||||
const action = resolveOptionsAction(key);
|
||||
if (action === "") continue;
|
||||
runPluginAction(pluginId, action, {})
|
||||
.then((raw) => {
|
||||
if (cancelled) return;
|
||||
setOptions((prev) => ({ ...prev, [key]: decodeOptions(raw) }));
|
||||
setOptionErrors((prev) => ({ ...prev, [key]: "" }));
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return;
|
||||
setOptionErrors((prev) => ({ ...prev, [key]: `${action}:${actionFailure(err).code}` }));
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [pluginId, fromKeys]);
|
||||
|
||||
return { options, optionErrors };
|
||||
}
|
||||
|
||||
export interface FormSubmitState {
|
||||
submit: () => void;
|
||||
busy: boolean;
|
||||
flash: FormFlash | null;
|
||||
}
|
||||
|
||||
export interface FormSubmitArgs {
|
||||
pluginId: string;
|
||||
action: string;
|
||||
fields: readonly SchemaField[];
|
||||
state: FormValuesState;
|
||||
onReload: () => void;
|
||||
log: (what: string, result: "ok" | "error") => void;
|
||||
}
|
||||
|
||||
/** 提交:本地先跑必填与类型校验(拦下「填了却起不来」),失败**如实**报错码与文案。 */
|
||||
export function useFormSubmit({ pluginId, action, fields, state, onReload, log }: FormSubmitArgs): FormSubmitState {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [flash, setFlash] = useState<FormFlash | null>(null);
|
||||
const { values, initial, cleared, configured, markMissing, resetCleared } = state;
|
||||
|
||||
const submit = useCallback(() => {
|
||||
if (action === "") return;
|
||||
const plan = planSubmit(fields, values, initial, [...cleared], [...configured]);
|
||||
if (!plan.ok) {
|
||||
markMissing(plan.kind === "missing" ? plan.missing.map((f) => f.key) : []);
|
||||
setFlash({
|
||||
tone: "err",
|
||||
text: plan.message,
|
||||
code: plan.kind === "missing" ? "missing_required" : "invalid_value",
|
||||
});
|
||||
return;
|
||||
}
|
||||
markMissing([]);
|
||||
setBusy(true);
|
||||
setFlash(null);
|
||||
runPluginAction(pluginId, action, { values: plan.patch })
|
||||
.then(() => {
|
||||
log(`plugin.${pluginId}/${action}`, "ok");
|
||||
setFlash({ tone: "ok", text: "已提交,正在刷新页面数据…" });
|
||||
resetCleared();
|
||||
onReload();
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const failure = actionFailure(err);
|
||||
log(`plugin.${pluginId}/${action}`, "error");
|
||||
setFlash({ tone: "err", text: failure.message, code: failure.code });
|
||||
})
|
||||
.finally(() => setBusy(false));
|
||||
}, [action, pluginId, fields, values, initial, cleared, configured, markMissing, resetCleared, onReload, log]);
|
||||
|
||||
return { submit, busy, flash };
|
||||
}
|
||||
Reference in new issue
Block a user