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

+134
View File
@@ -0,0 +1,134 @@
/** 插件表格区块的状态与助手(无 JSX)。
*
* 拆出来的理由同 `formState.ts`:守「函数 ≤80 行」,并把「取数」与「行动作」两条
* 独立的状态机分开(各自的失败处理不同,混在一起就会长成一个巨型组件)。
*/
import { useCallback, useEffect, useState } from "react";
import { actionFailure } from "./actions";
import { runPluginAction } from "./panelsApi";
import { decodeRows, formValuesOfResult } from "./types";
export interface PanelRow {
key: string;
data: Record<string, unknown>;
}
export interface TableFlash {
tone: "ok" | "err";
text: string;
code?: string;
}
/** 单元格文本:标量直出,数组/对象退化成可读文本(不渲染 `[object Object]`)。 */
export function cellText(v: unknown): string {
if (v === null || v === undefined) return "—";
if (typeof v === "string") return v === "" ? "—" : v;
if (typeof v === "number" || typeof v === "boolean") return String(v);
if (Array.isArray(v)) return v.length === 0 ? "—" : v.map((x) => cellText(x)).join("、");
try {
return JSON.stringify(v);
} catch {
return "—";
}
}
/** 行身份:插件给了真实键就用它,否则用**零填充下标** —— `AdminTable` 会按 rowKey 排序,
* 裸下标的自然序("10" < "2")会把后端给的顺序打乱。 */
export function rowIdentity(row: Record<string, unknown>, index: number): string {
for (const k of ["id", "key", "name", "provider"]) {
const v = row[k];
if (typeof v === "string" && v !== "") return `${k}:${v}`;
if (typeof v === "number") return `${k}:${v}`;
}
return `#${String(index).padStart(6, "0")}`;
}
export interface TableRowsState {
rows: PanelRow[] | null;
loading: boolean;
error: string | null;
load: () => void;
}
/** 取行:动作名不变就不重新取(`load` 的身份只跟 pluginId / action 走,30s 轮询的快照
* 重渲染不会触发重复请求)。 */
export function useTableRows(pluginId: string, action: string): TableRowsState {
const [rows, setRows] = useState<PanelRow[] | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = useCallback(() => {
if (action === "") {
setLoading(false);
setError("声明缺少 source.action,无法取数。");
return;
}
setLoading(true);
runPluginAction(pluginId, action, {})
.then((raw) => {
setRows(decodeRows(raw).map((data, i) => ({ key: rowIdentity(data, i), data })));
setError(null);
})
.catch((err: unknown) => setError(actionFailure(err).message))
.finally(() => setLoading(false));
}, [pluginId, action]);
useEffect(() => {
load();
}, [load]);
return { rows, loading, error, load };
}
export interface RowActionRunner {
busy: string | null;
flash: TableFlash | null;
run: (actionName: string, label: string, row: Record<string, unknown>) => void;
}
export interface RowActionRunnerArgs {
pluginId: string;
onReload: () => void;
/** 行动作结束后重取本表。 */
reloadRows: () => void;
log: (what: string, result: "ok" | "error") => void;
/** 返回显式值对象时交给表单(reauth「把该条带回表单」)。 */
onValues?: (values: Record<string, unknown>) => void;
}
/** 行内动作:一次一个在跑(`busy` 非空即禁用全部按钮,避免同一行被点两次)。 */
export function useRowActionRunner({ pluginId, onReload, reloadRows, log, onValues }: RowActionRunnerArgs): RowActionRunner {
const [busy, setBusy] = useState<string | null>(null);
const [flash, setFlash] = useState<TableFlash | null>(null);
const run = useCallback(
(actionName: string, label: string, row: Record<string, unknown>) => {
setBusy(actionName);
setFlash(null);
runPluginAction(pluginId, actionName, { row })
.then((raw) => {
log(`plugin.${pluginId}/${actionName}`, "ok");
// 「带回表单」(reauth):只认显式 values / formValues,取不到就只是刷新列表。
const values = formValuesOfResult(raw);
if (values && onValues) {
onValues(values);
setFlash({ tone: "ok", text: `${label}:已把该条带回表单,确认后保存。` });
} else {
setFlash({ tone: "ok", text: `${label}:已完成,正在刷新。` });
}
reloadRows();
onReload();
})
.catch((err: unknown) => {
const failure = actionFailure(err);
log(`plugin.${pluginId}/${actionName}`, "error");
setFlash({ tone: "err", text: failure.message, code: failure.code });
})
.finally(() => setBusy(null));
},
[pluginId, onReload, reloadRows, log, onValues],
);
return { busy, flash, run };
}