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,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 };
|
||||
}
|
||||
Reference in new issue
Block a user