feat(admin): 前端可接入点 —— 折叠分组 + 动态插件页 + schema 渲染(PLG-3)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 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:
AskaEth committed 2026-10-06 09:04:51 +08:00
1 parent ede00d9928
commit 9e63af9063
29 files changed
+3527 -146

No files matched your search

+183
View File
@@ -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 };
}