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

@@ -0,0 +1,92 @@
/** 插件表单的字段栅格(呈现层):把声明字段逐个画成 `FieldShell` + 通用控件。
*
* 与 `FormSection` 分开是为了守住「函数 ≤80 行」;这里只做呈现,规则(必填/清除/写回)
* 全在 `formState.ts` 与 `schema/values.ts`,因此本文件不判断任何语义。
*/
import type { ReactNode } from "react";
import { FieldShell, MaskedSecretFooter, SchemaControl } from "../schema/fields";
import type { SchemaField } from "../schema/fieldTypes";
import type { PanelFieldDto, PanelOptionDto } from "./types";
/** 字段说明行:声明面没有 `guide`(`sdk.Field` 没这个键),这行就用来交代「候选从哪来、
* 为什么现在是手填」—— 静默降级成文本框是用户最容易误解的一档。 */
export function formFieldGuide(optionCount: number, optionError: string | undefined): string | undefined {
if (optionCount > 0) return undefined;
if (optionError && optionError !== "") {
return `候选项未能加载(动作 ${optionError} 未声明或未接线):此项可手填,保存时原样提交。`;
}
return "正在加载候选项…";
}
export interface FormFieldsGridProps {
fields: readonly SchemaField[];
/** 原始声明(取 `optionsFrom` 用)。 */
declared: readonly PanelFieldDto[];
values: Record<string, string>;
required: readonly string[];
missing: ReadonlySet<string>;
configured: ReadonlySet<string>;
cleared: ReadonlySet<string>;
options: Record<string, readonly PanelOptionDto[]>;
optionErrors: Record<string, string>;
onValue: (key: string, next: string) => void;
onToggleClear: (key: string, next: boolean) => void;
}
export function FormFieldsGrid({
fields,
declared,
values,
required,
missing,
configured,
cleared,
options,
optionErrors,
onValue,
onToggleClear,
}: FormFieldsGridProps): ReactNode {
const requiredSet = new Set(required);
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{fields.map((f) => {
const fromKey = declared.find((d) => d.key === f.key)?.optionsFrom ?? "";
const opts = fromKey === "" ? undefined : options[fromKey];
const isRequired = requiredSet.has(f.key);
const isMissing = missing.has(f.key);
const clearRequested = cleared.has(f.key);
const masked = f.type === "secret" && configured.has(f.key);
return (
<div key={f.key} className={f.type === "kv" ? "sm:col-span-2" : ""}>
<FieldShell
label={f.label ?? f.key}
required={isRequired}
missing={isMissing}
guide={fromKey === "" ? undefined : formFieldGuide(opts?.length ?? 0, optionErrors[fromKey])}
missingHint="这是必填字段,留空保存会被拦下。"
footer={
<MaskedSecretFooter
fieldKey={f.key}
masked={masked}
clearRequested={clearRequested}
clearedHint="保存后会清除该项(后端收到空字符串)。"
onToggleClear={(next) => onToggleClear(f.key, next)}
/>
}
>
<SchemaControl
field={f}
value={values[f.key] ?? ""}
options={opts}
disabled={clearRequested}
invalid={isMissing && isRequired}
onChange={(next) => onValue(f.key, next)}
/>
</FieldShell>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,114 @@
/** `section.form` 区块:把插件声明的 `fields[]` 渲染成表单并提交给声明的动作。
*
* 三条硬口径:
* ① 控件来自**通用 schema 渲染**(`../schema/fields.tsx`,与渠道配置页同一份实现),
* 本组件不自己画控件、也不重复实现「留空 = 不改动」;
* ② 提交前先在本地跑必填校验(`schema/values.planSubmit`),拦下「填了却起不来」;
* ③ viewer 一律看不到表单(后端已在快照里裁掉,这里是第二道闸)。
*
* 状态与规则在 `formState.ts`(三个 hook),字段呈现与说明文案在 `FormFields.tsx`;
* 本文件只剩「拼装 + 提交按钮 + 反馈行」,因此每个函数都在 80 行以内。
*/
import { useMemo, type ReactNode } from "react";
import { Button, Surface, Tag } from "../../components/primitives";
import type { Role } from "../../lib/types";
import { useAuditStore } from "../audit";
import { Notice } from "../components";
import type { SchemaField } from "../schema/fieldTypes";
import { FormFieldsGrid } from "./FormFields";
import { useFieldOptions, useFormSubmit, useFormValues, type FormFlash } from "./formState";
import { canWrite } from "./nav";
import { SectionHeader } from "./SectionHeader";
import type { PanelFieldDto, PanelSectionDto } from "./types";
/** 声明字段 → 通用 schema(`options` 由 `optionsFrom` 拉到后由栅格填进来)。 */
export function panelFieldToSchema(f: PanelFieldDto): SchemaField {
return {
key: f.key,
label: f.label,
type: f.type,
required: f.required,
placeholder: f.placeholder,
default: f.default,
min: f.min,
max: f.max,
};
}
/** 提交/校验反馈行:错误码与文案分两处显示(§5.4.6 的错误码 ↔ 文案分离)。 */
function FormFlashRow({ flash }: { flash: FormFlash | null }): ReactNode {
if (!flash) return null;
return (
<>
{flash.code ? <Tag tone={flash.tone === "ok" ? "ok" : "err"}>{flash.code}</Tag> : null}
<span className={`text-xs ${flash.tone === "ok" ? "text-[var(--mu-ok)]" : "text-[var(--mu-err)]"}`}>
{flash.text}
</span>
</>
);
}
export interface PanelFormSectionProps {
pluginId: string;
section: PanelSectionDto;
role: Role | null;
/** 提交成功后重取一次快照(下拉候选与 status 事实面跟着刷新)。 */
onReload: () => void;
/** 行内动作「带回表单」的值(design/09 §5.2.1 示例的 reauth);`null` = 没有。
* 只认显式 `values` / `formValues`(见 `types.formValuesOfResult`),不做形状猜测。 */
prefill?: Record<string, unknown> | null;
}
export function PanelFormSection({ pluginId, section, role, onReload, prefill = null }: PanelFormSectionProps): ReactNode {
const log = useAuditStore((s) => s.log);
// 依赖键用 JSON 串:快照每 30s 重取一次,对象身份每次都变,直接依赖对象会把用户正在
// 输入的内容重置掉(「打着字被清空」是比不刷新更糟的体验)。
const fieldsKey = JSON.stringify(section.fields ?? []);
const prefillKey = JSON.stringify(prefill ?? null);
const external = useMemo(
() => ({ ...(section.values ?? {}), ...(prefill ?? {}) }),
[JSON.stringify(section.values ?? {}), prefillKey],
);
const fields = useMemo(() => (section.fields ?? []).map((f) => panelFieldToSchema(f)), [fieldsKey]);
const state = useFormValues(fields, fieldsKey, external);
const { options, optionErrors } = useFieldOptions(pluginId, section.fields ?? []);
const action = section.submit?.action ?? "";
const { submit, busy, flash } = useFormSubmit({ pluginId, action, fields, state, onReload, log });
if (!canWrite(role)) {
return (
<Surface className="space-y-2">
<SectionHeader title={section.title} hint="只读" />
<Notice tone="warn">当前角色没有写权限,本区块的表单不显示(写动作一律 owner / admin)。</Notice>
</Surface>
);
}
return (
<Surface className="space-y-3">
<SectionHeader title={section.title} />
<FormFieldsGrid
fields={fields}
declared={section.fields ?? []}
values={state.values}
required={fields.filter((f) => f.required === true).map((f) => f.key)}
missing={state.missing}
configured={state.configured}
cleared={state.cleared}
options={options}
optionErrors={optionErrors}
onValue={state.setValue}
onToggleClear={state.toggleClear}
/>
{fields.length === 0 ? <Notice>该表单没有声明任何字段。</Notice> : null}
<div className="flex flex-wrap items-center gap-2">
<Button variant="primary" disabled={busy || action === ""} onClick={submit}>
{busy ? "提交中…" : "保存"}
</Button>
{action === "" ? <span className="text-[11px] text-[var(--mu-err)]">声明缺少 submit.action,无法提交。</span> : null}
<FormFlashRow flash={flash} />
</div>
</Surface>
);
}
@@ -0,0 +1,307 @@
// 插件页的**真实渲染**测试:把「入口出不出、区块画不画、秘密会不会漏」变成可执行断言。
//
// 为什么必须有它:纯逻辑测试(navModel / pluginData / values)只能证明规则算得对,证明不了
// 页面上真的长成那样。这一片的产品缺口是「插件能不能接入面板」,验收判据里最硬的两条
// ① `when` 不满足 ⇒ 入口**不出现**(不是灰掉);
// ② viewer 只看到只读区块
// 都必须落到**真 HTML** 上才算数。
//
// 跑法(react-dom/server 的 CJS 构建需要 require,所以必须打成 cjs):
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/admin/plugin/PluginPanel.render.test.ts \
// --bundle --platform=node --format=cjs \
// --outfile=/tmp/cyrene-plugin-panel.render.test.cjs \
// && node /tmp/cyrene-plugin-panel.render.test.cjs
//
// `tsc --noEmit`(npm run build 的一部分)会对本文件做类型检查,因此它只从项目内导入、
// 不 import node:*。渲染走 `renderToStaticMarkup`:服务端渲染不跑 effect,所以表格区块停在
// 「加载中…」是**预期**的(行动作与行的渲染由 pluginData.test.ts 的纯函数覆盖)。
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import type { PanelEntryDto, PanelSectionDto } from "./types";
import { decodePanelEntries } from "./types";
import { PluginPanelHost, PluginPanelView } from "./PluginPanel";
let checks = 0;
function ok(cond: boolean, what: string): void {
checks += 1;
if (!cond) throw new Error(`FAILED: ${what}`);
}
function has(haystack: string, needle: string): boolean {
return haystack.includes(needle);
}
/** web-search 的 `providers` 页声明(与 `plugins/tools/io/search/panel.go` 同形:
* provider 下拉必填、apiKey 是 secret 且**不标必填**(编辑时不重填凭据是常态)、
* baseUrl 文本、enabled 开关)。 */
const FORM: PanelSectionDto = {
type: "form",
id: "add",
title: "添加 / 修改提供商",
fields: [
{ key: "provider", label: "提供商", type: "select", optionsFrom: "plugin:providers", required: true },
{ key: "apiKey", label: "API Key", type: "secret", placeholder: "留空 = 保持原值" },
{ key: "baseUrl", label: "接口地址", type: "text", placeholder: "https://..." },
{ key: "enabled", label: "启用", type: "switch" },
],
submit: { action: "plugin.saveProvider" },
// 快照侧的当前值(secret 只有掩码):表单因此「打开即显示现状」+ 兑现「留空 = 不改」。
values: { provider: "searxng", apiKey: "****cdef", baseUrl: "https://s.example", enabled: "true" },
};
const STATUS: PanelSectionDto = {
type: "status",
id: "chain",
title: "当前生效的降级链",
source: { action: "plugin.facts" },
fields: [
{ key: "primary", label: "主提供商", type: "text" },
{ key: "backupKey", label: "备用凭据", type: "secret" },
],
};
const LOG: PanelSectionDto = {
type: "log",
id: "recent",
title: "最近请求",
source: { action: "plugin.recent" },
limit: 20,
};
const TABLE: PanelSectionDto = {
type: "table",
id: "list",
title: "已配置的提供商",
source: { action: "plugin.listProviders" },
columns: [
{ key: "provider", label: "提供商" },
{ key: "state", label: "状态" },
],
rowActions: [
{ id: "test", label: "测试连通", action: "plugin.testProvider" },
{ id: "reauth", label: "重配凭据", action: "plugin.reauthProvider" },
],
};
const PANEL: PanelEntryDto = {
pluginId: "web-search",
id: "providers",
title: "搜索提供商",
description: "为联网搜索配置提供商与凭据(多实例)",
group: "plugins",
order: 20,
icon: "search",
sections: [FORM, STATUS, LOG, TABLE],
};
function renderView(role: "owner" | "admin" | "viewer" | null, panel: PanelEntryDto = PANEL): string {
return renderToStaticMarkup(
createElement(PluginPanelView, { panel, role, onReload: () => {} }),
);
}
// ── ① owner:四个区块都画出来,且秘密不回显 ────────────────────────────────────
{
const html = renderView("owner");
ok(has(html, "搜索提供商"), "页面标题来自声明的 title");
ok(has(html, "为联网搜索配置提供商与凭据"), "描述来自声明的 description");
ok(has(html, "web-search"), "页面上标明是哪个插件的页面(避免社区插件同名页面互相冒充)");
// form
ok(has(html, "添加 / 修改提供商"), "form 区块标题");
ok(
has(html, "提供商") && has(html, "API Key") && has(html, "接口地址") && has(html, "启用"),
"form 四个字段都渲染",
);
ok(has(html, ">必填</span>"), "必填字段有标记");
ok(has(html, "https://..."), "placeholder 生效");
ok(has(html, ">保存</button>") || has(html, "保存"), "提交按钮存在");
ok(has(html, 'value="searxng"') && has(html, 'value="https://s.example"'), "非秘密字段回显当前值(打开即显示现状)");
ok(has(html, "留空 = 保持原值"), "secret 字段的占位文案说明留空语义");
ok(!has(html, "****cdef"), "后端下发的掩码**不出现在 HTML 里**(回填掩码 = 一次保存把掩码写成真凭据)");
ok(
has(html, "已配置(后端只下发掩码,面板不持有原文):留空 = 不改动。"),
"掩码秘密说清「留空 = 不改动」",
);
ok(has(html, "清除已配置的 apiKey"), "并且给出唯一能清空的方式(显式开关)");
ok(has(html, "正在加载候选项…"), "下拉候选未到(服务端不跑 effect)时如实说明,而不是静默变成手填");
// status(只读事实面)
ok(has(html, "当前生效的降级链"), "status 区块标题");
ok(has(html, ">只读</span>"), "status 标明只读");
ok(has(html, "主提供商") && has(html, "备用凭据"), "事实面按声明渲染键值行");
ok(has(html, "未配置"), "secret 事实只说「未配置」,不回显值");
// log(只读日志)
ok(has(html, "最近请求"), "log 区块标题");
ok(has(html, "最新在上"), "日志口径写明「最新在上」");
ok(has(html, "最近 20 条"), "日志条数用声明的 limit");
ok(has(html, "加载中…"), "服务端渲染不跑 effect ⇒ 只读区块如实显示加载中(不假装有数据)");
// table
ok(has(html, "已配置的提供商"), "table 区块标题");
ok(has(html, ">刷新</button>"), "每个取数区块都有显式刷新");
}
// ── ② admin 与 owner 同权(写动作一律 owner/admin) ───────────────────────────
{
const html = renderView("admin");
ok(has(html, "添加 / 修改提供商"), "admin 也能看到表单");
ok(has(html, "清除已配置的 apiKey"), "admin 的掩码语义与 owner 一致");
}
// ── ②b 端到端:后端快照(真实形状)解出来后直接渲染,页面照旧成立 ────────────────
{
const snapshot = {
role: "owner",
groups: [
{
id: "plugins",
title: "插件",
order: 20,
items: [
{
pluginId: "web-search",
id: "providers",
title: "搜索提供商",
description: "为 web-search 能力配置多个第三方搜索后端",
icon: "search",
order: 20,
route: "plugin/web-search/providers",
sections: [FORM, STATUS, LOG, TABLE],
},
],
},
],
};
const decoded = decodePanelEntries(snapshot);
ok(decoded.length === 1, "快照解出一条");
const html = renderToStaticMarkup(
createElement(PluginPanelView, { panel: decoded[0], role: "owner", onReload: () => {} }),
);
ok(has(html, "搜索提供商") && has(html, "已配置的提供商"), "\"解码 → 渲染\"整条链路成立(契约同形的活证据)");
ok(!has(html, "****cdef"), "整条链路上掩码都没有漏进 HTML");
}
// ── ②c 一个**全新插件**的页面同样直接渲染(可接入点的意义:不改静态清单) ────────
{
const fresh: PanelEntryDto = {
pluginId: "acme-tool",
id: "settings",
title: "Acme 工具配置",
group: "plugins",
groupTitle: "插件",
order: 5,
icon: "gear",
sections: [{ type: "status", id: "facts", title: "Acme 当前状态", source: { action: "plugin.facts" }, fields: [{ key: "mode", label: "模式", type: "text" }] }],
};
const html = renderView("owner", fresh);
ok(has(html, "Acme 工具配置"), "任意插件 id / 面板 id 都能渲染(前端不认白名单)");
ok(has(html, "acme-tool"), "并标明是哪个插件的页面");
ok(has(html, "Acme 当前状态"), "其区块照常渲染");
}
// ── ③ viewer:只看到只读区块,写面一个不剩 ────────────────────────────────────
{
const html = renderView("viewer");
ok(!has(html, "添加 / 修改提供商"), "viewer 看不到 form 区块(它是唯一自带写面的类型)");
ok(!has(html, "API Key"), "viewer 看不到写表单的字段");
ok(!has(html, ">保存"), "viewer 看不到提交按钮");
ok(!has(html, "清除已配置的 apiKey"), "viewer 看不到清除开关(那是写面)");
ok(!has(html, "测试连通") && !has(html, "重配凭据"), "viewer 看不到行内写动作");
ok(has(html, "当前生效的降级链"), "viewer 仍能看到只读事实面");
ok(has(html, "最近请求"), "viewer 仍能看到只读日志");
ok(has(html, "已配置的提供商"), "viewer 仍能看到表格(列是事实)");
ok(has(html, "当前角色只读"), "viewer 的表格上说明「只读、写动作不显示」");
ok(!has(html, "****cdef"), "viewer 的 HTML 里同样没有掩码");
}
// ── ④ 空面板与未知区块:如实说,不半渲染 ───────────────────────────────────────
{
const empty = renderView("owner", { ...PANEL, sections: [] });
ok(has(empty, "该面板没有可显示的区块"), "空面板如实说明(不画一个看起来坏掉的空页)");
ok(has(empty, "搜索提供商"), "空面板仍是这个页面(标题照旧)");
const onlyForm = renderView("viewer", { ...PANEL, sections: [FORM] });
ok(has(onlyForm, "该面板没有可显示的区块"), "只有写表单的面板对 viewer 说明「没有可读区块」");
ok(!has(onlyForm, "添加 / 修改提供商"), "而不是把表单降级成只读展示(模型里没有只读表单)");
// 契约先行、前端滞后的一档:后端给了本版本不认识的区块类型。
const unknown = renderView("owner", {
...PANEL,
sections: [{ ...LOG, type: "iframe" as PanelSectionDto["type"] }],
});
ok(has(unknown, "本版本不支持区块类型"), "闭集外的区块类型如实告知");
ok(has(unknown, "iframe"), "并点名是哪种类型(排障不用猜)");
}
// ── ⑤ 入口不出现:宿主在「找不到 / 未加载完」时不会画出一个假页面 ────────────────
{
const noTarget = renderToStaticMarkup(
createElement(PluginPanelHost, {
target: null,
panels: [PANEL],
loading: false,
error: null,
reload: () => {},
role: "owner",
}),
);
ok(has(noTarget, "未找到插件页"), "缺段 ⇒ 明确的「未找到」,不回落 dashboard 也不空白");
const loading = renderToStaticMarkup(
createElement(PluginPanelHost, {
target: { pluginId: "web-search", panelId: "providers" },
panels: [],
loading: true,
error: null,
reload: () => {},
role: "owner",
}),
);
ok(has(loading, "正在读取插件面板声明"), "快照未到 ⇒ 加载态(不先说「找不到」)");
const hidden = renderToStaticMarkup(
createElement(PluginPanelHost, {
target: { pluginId: "web-search", panelId: "providers" },
panels: [],
loading: false,
error: null,
reload: () => {},
role: "owner",
}),
);
ok(has(hidden, "未找到面板"), "快照里没有这条(未启用 / when 不满足 / 没声明)⇒ 直接输 URL 也进不来");
ok(has(hidden, "when 不满足"), "并把可能的原因说清(用户不必去猜为什么入口不见了)");
const failed = renderToStaticMarkup(
createElement(PluginPanelHost, {
target: { pluginId: "web-search", panelId: "providers" },
panels: [],
loading: false,
error: "读取插件面板失败",
reload: () => {},
role: "owner",
}),
);
ok(has(failed, "读取插件面板失败"), "取数失败如实显示");
ok(has(failed, "重试"), "并给一个重试入口(不静默失败)");
const found = renderToStaticMarkup(
createElement(PluginPanelHost, {
target: { pluginId: "web-search", panelId: "providers" },
panels: [PANEL],
loading: false,
error: null,
reload: () => {},
role: "owner",
}),
);
ok(has(found, "搜索提供商") && has(found, "添加 / 修改提供商"), "命中 ⇒ 渲染真页面");
}
console.log(`plugin panel render test: ${checks} checks passed`);
@@ -0,0 +1,153 @@
/** 插件页(`page.root` 接入点):页面壳 + 区块分发。
*
* 分成两个组件是刻意的:
* - `PluginPanelHost`(容器):从快照里找当前 URL 指向的面板,负责「加载中 / 取数失败 /
* 没这个面板」三种状态。找不到就是找不到 —— 未启用、`when` 不满足、或该插件根本没
* 声明这个页面,直接输 URL 也进不来(快照是按角色与 `when` 过滤后的结果);
* - `PluginPanelView`(纯呈现):只吃 `panel` + `role`,因此可以在单测里直接渲染,
* 不依赖路由、快照与浏览器环境(`--format=cjs` 的渲染测试就是打它)。
*/
import type { ReactNode } from "react";
import { useState } from "react";
import { Button, EmptyState, Spinner, Tag } from "../../components/primitives";
import type { Role } from "../../lib/types";
import { Notice, PageHeader } from "../components";
import { PanelFormSection } from "./FormSection";
import { PanelLogSection, PanelStatusSection } from "./ReadOnlySections";
import { PanelTableSection } from "./TableSection";
import { findPanelEntry, viewerVisibleSections } from "./nav";
import type { PanelEntryDto, PanelSectionDto } from "./types";
import type { PluginTarget } from "../router";
/** 区块分发(闭集):form / table / status / log 四种,未知类型如实说明而不是半渲染。 */
function SectionBlock({
pluginId,
section,
role,
onReload,
formPrefill,
onRowValues,
}: {
pluginId: string;
section: PanelSectionDto;
role: Role | null;
onReload: () => void;
formPrefill: Record<string, unknown> | null;
onRowValues?: (values: Record<string, unknown>) => void;
}): ReactNode {
switch (section.type) {
case "form":
return (
<PanelFormSection
pluginId={pluginId}
section={section}
role={role}
onReload={onReload}
prefill={formPrefill}
/>
);
case "table":
return (
<PanelTableSection
pluginId={pluginId}
section={section}
role={role}
onReload={onReload}
onValues={onRowValues}
/>
);
case "status":
return <PanelStatusSection pluginId={pluginId} section={section} onReload={onReload} />;
case "log":
return <PanelLogSection pluginId={pluginId} section={section} />;
default: {
// 解码已丢掉闭集外的类型,这里兜的是「契约先行、前端滞后」的一档。
const unknownType: string = String((section as { type?: unknown }).type ?? "?");
return <Notice tone="warn">本版本不支持区块类型「{unknownType}」,未渲染。</Notice>;
}
}
}
export interface PluginPanelViewProps {
panel: PanelEntryDto;
role: Role | null;
onReload: () => void;
}
/** 插件页的纯呈现:标题 + 描述 + 按角色复检后的区块列表。
*
* 行内动作「带回表单」(design/09 §5.2.1 示例的 reauth)在**这里**接线:表格返回一份
* 显式值对象时把它交给表单。只在「恰好一个 form 区块」时转交 —— 两个表单就没有归属,
* 宁可不填也不猜(猜错就是把 A 条的数据填进 B 条的表单)。 */
export function PluginPanelView({ panel, role, onReload }: PluginPanelViewProps): ReactNode {
const [prefill, setPrefill] = useState<Record<string, unknown> | null>(null);
const sections = viewerVisibleSections(panel, role);
const formSections = sections.filter((s) => s.type === "form");
const singleForm = formSections.length === 1 ? formSections[0].id : null;
return (
<div className="space-y-4">
<PageHeader
title={panel.title}
subtitle={panel.description}
actions={<Tag tone="accent">{panel.pluginId}</Tag>}
/>
{sections.map((section) => (
<SectionBlock
key={section.id}
pluginId={panel.pluginId}
section={section}
role={role}
onReload={onReload}
formPrefill={section.type === "form" && section.id === singleForm ? prefill : null}
onRowValues={singleForm === null ? undefined : (values) => setPrefill(values)}
/>
))}
{sections.length === 0 ? (
<Notice tone="warn">
该面板没有可显示的区块(可能只声明了写表单,而当前角色是只读)。
</Notice>
) : null}
</div>
);
}
export interface PluginPanelHostProps {
target: PluginTarget | null;
panels: readonly PanelEntryDto[];
loading: boolean;
error: string | null;
reload: () => void;
role: Role | null;
}
/** 插件页容器:把「当前 URL → 快照条目」这一步与三种非正常态收在这里。 */
export function PluginPanelHost({ target, panels, loading, error, reload, role }: PluginPanelHostProps): ReactNode {
if (!target) {
return (
<EmptyState
title="未找到插件页"
hint="地址里缺少插件段或面板段(形如 /plugin/<pluginId>/<panelId>)。"
/>
);
}
const panel = findPanelEntry(panels, target);
if (panel) return <PluginPanelView panel={panel} role={role} onReload={reload} />;
if (loading) return <Spinner label="正在读取插件面板声明…" />;
if (error) {
return (
<div className="space-y-2">
<Notice tone="err">读取插件面板失败:{error}</Notice>
<Button size="sm" variant="soft" onClick={reload}>
重试
</Button>
</div>
);
}
return (
<EmptyState
title={`未找到面板「${target.panelId}」`}
hint={`插件「${target.pluginId}」当前没有这个页面:可能未启用(when 不满足 ⇒ 入口不出现)、未安装,或没有声明它。`}
/>
);
}
@@ -0,0 +1,142 @@
/** 只读区块:`section.status`(事实面)与 `section.log`(最近 N 条,最新在上)。
*
* 两块都是**只读**(§5.2.1 的形状矩阵里没有写面),因此 viewer 也能看;渲染上只有一条
* 红线 —— `secret` 字段**永不回显**:有值就说「已配置」,没值就说「未配置」。后端本来
* 只下发掩码,这里再加一道,是为了「掩码字符串被当成真值贴到页面上」那种事故不再可能。
*/
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { Button, Surface } from "../../components/primitives";
import { KeyValueRow, Notice } from "../components";
import { actionFailure } from "./actions";
import { runPluginAction } from "./panelsApi";
import { SectionHeader } from "./SectionHeader";
import { decodeFacts, decodeLogLines, type PanelFieldDto, type PanelSectionDto } from "./types";
/** 日志默认条数(声明没给 `limit` 时)。 */
export const DEFAULT_LOG_LIMIT = 50;
/** 事实面单元格文本:`secret` 只说「已配置 / 未配置」,其它按类型直出。 */
export function readOnlyValue(f: PanelFieldDto, v: unknown): string {
const present =
v !== undefined && v !== null && v !== "" && !(Array.isArray(v) && v.length === 0);
if (f.type === "secret") return present ? "已配置(掩码不回显)" : "未配置";
if (f.type === "switch") return v === true || v === "true" ? "开" : "关";
if (!present) return "—";
if (Array.isArray(v)) return v.map((x) => (typeof x === "string" ? x : JSON.stringify(x))).join("、");
if (typeof v === "string") return v;
if (typeof v === "number" || typeof v === "boolean") return String(v);
return JSON.stringify(v);
}
interface SectionDataState {
/** 解成键值事实的原始响应(status 用)。 */
facts: Record<string, unknown> | null;
/** 原始响应(log 用:日志行形状与事实不同,各取所需)。 */
raw: unknown;
loading: boolean;
error: string | null;
load: () => void;
}
/** 拉一次只读数据(status 的事实 / log 的行),带刷新。 */
function useSectionData(pluginId: string, action: string): SectionDataState {
const [raw, setRaw] = useState<unknown>(null);
const [facts, setFacts] = useState<Record<string, unknown> | 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((res) => {
setRaw(res);
setFacts(decodeFacts(res));
setError(null);
})
.catch((err: unknown) => setError(actionFailure(err).message))
.finally(() => setLoading(false));
}, [pluginId, action]);
useEffect(() => {
load();
}, [load]);
return { facts, raw, loading, error, load };
}
export interface PanelStatusSectionProps {
pluginId: string;
section: PanelSectionDto;
onReload: () => void;
}
export function PanelStatusSection({ pluginId, section }: PanelStatusSectionProps): ReactNode {
const { facts, loading, error, load } = useSectionData(pluginId, section.source?.action ?? "");
const fields = section.fields ?? [];
const keys = fields.length > 0 ? fields.map((f) => f.key) : Object.keys(facts ?? {}).sort();
return (
<Surface className="space-y-2">
<SectionHeader
title={section.title}
hint="只读"
actions={
<Button size="sm" variant="ghost" disabled={loading} onClick={load}>
刷新
</Button>
}
/>
{error ? <span className="block text-xs text-[var(--mu-err)]">{error}</span> : null}
{loading && !facts ? <span className="block text-xs text-[var(--mu-text-lo)]">加载中…</span> : null}
{!loading && facts !== null && keys.length === 0 ? <Notice>该事实面没有可显示的项目。</Notice> : null}
<div className="divide-y divide-[var(--mu-glass-border)]">
{keys.map((key) => {
const field = fields.find((f) => f.key === key);
const declared: PanelFieldDto = field ?? { key, type: "text" };
return <KeyValueRow key={key} label={declared.label ?? key} value={readOnlyValue(declared, facts?.[key])} mono />;
})}
</div>
</Surface>
);
}
export interface PanelLogSectionProps {
pluginId: string;
section: PanelSectionDto;
}
export function PanelLogSection({ pluginId, section }: PanelLogSectionProps): ReactNode {
const { raw, loading, error, load } = useSectionData(pluginId, section.source?.action ?? "");
const limit = section.limit && section.limit > 0 ? section.limit : DEFAULT_LOG_LIMIT;
// 最新在上(与 L1 的日志口径一致):后端给什么顺序都先倒一次,前端不假设顺序。
const lines = decodeLogLines(raw).slice(-limit).reverse();
return (
<Surface className="space-y-2">
<SectionHeader
title={section.title}
hint={`只读 · 最近 ${limit} 条 · 最新在上`}
actions={
<Button size="sm" variant="ghost" disabled={loading} onClick={load}>
刷新
</Button>
}
/>
{error ? <span className="block text-xs text-[var(--mu-err)]">{error}</span> : null}
{loading && lines.length === 0 ? <span className="block text-xs text-[var(--mu-text-lo)]">加载中…</span> : null}
{!loading && lines.length === 0 ? <Notice>暂无日志。</Notice> : null}
{lines.map((line, i) => (
<div key={`${line.time}-${i}`} className="flex gap-2 font-mono text-[11px] leading-relaxed">
{line.time ? <span className="shrink-0 text-[var(--mu-text-lo)]">{line.time}</span> : null}
<span className="min-w-0 break-all text-[var(--mu-text-mid)]">{line.text}</span>
</div>
))}
</Surface>
);
}
@@ -0,0 +1,23 @@
/** 插件页区块的统一标题条(四个区块共用:标题 + 可选右侧动作 + 可选提示)。
* 抽出来是因为四个区块都要它(design/09 §5.2.1 的 section.* 都有 title)——
* 复制四份不是选项。 */
import type { ReactNode } from "react";
export interface SectionHeaderProps {
title: string;
/** 右侧动作区(刷新按钮 / 当前模式标记等)。 */
actions?: ReactNode;
/** 标题下的一行说明(如「最新在上」「只读」)。 */
hint?: ReactNode;
}
export function SectionHeader({ title, actions, hint }: SectionHeaderProps): ReactNode {
return (
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold">{title}</h3>
{hint ? <span className="text-[11px] text-[var(--mu-text-lo)]">{hint}</span> : null}
<div className="ml-auto flex items-center gap-2">{actions}</div>
</div>
);
}
@@ -0,0 +1,89 @@
/** `section.table` 区块:列 + 行内动作。
*
* 复用既有 `AdminTable`(含窗口化与空/错/加载三态),所以这里只做三件事:把列声明翻成
* `AdminColumn`、把行内动作接上宿主动作端点、把反馈画出来(含 viewer 的只读说明)。
* 状态机在 `tableState.ts`;纯助手 `cellText` / `rowIdentity` 从那里复出口(单测按
* 本模块路径 import,换家不必改测试)。
*
* 对 viewer:后端快照已摘掉行内动作,这里**再摘一次**(纵深防御,见 `nav.canWrite`)。
*/
import type { ReactNode } from "react";
import { Button, Surface, Tag } from "../../components/primitives";
import type { Role } from "../../lib/types";
import { useAuditStore } from "../audit";
import { AdminTable, Notice, type AdminColumn } from "../components";
import { canWrite } from "./nav";
import { SectionHeader } from "./SectionHeader";
import { cellText, rowIdentity, useRowActionRunner, useTableRows, type PanelRow, type TableFlash } from "./tableState";
import type { PanelSectionDto } from "./types";
export { cellText, rowIdentity };
export type { PanelRow };
/** 反馈行:错误码与文案分两处(§5.4.6),成功/失败用不同色。 */
function TableFlashRow({ flash }: { flash: TableFlash | null }): ReactNode {
if (!flash) return null;
return (
<span className="flex flex-wrap items-center gap-2">
{flash.code ? <Tag tone={flash.tone === "ok" ? "ok" : "err"}>{flash.code}</Tag> : null}
<span className={`text-xs ${flash.tone === "ok" ? "text-[var(--mu-ok)]" : "text-[var(--mu-err)]"}`}>
{flash.text}
</span>
</span>
);
}
export interface PanelTableSectionProps {
pluginId: string;
section: PanelSectionDto;
role: Role | null;
onReload: () => void;
/** 行内动作返回显式值对象时回调(reauth「把该条带回表单」,见 `formValuesOfResult`)。 */
onValues?: (values: Record<string, unknown>) => void;
}
export function PanelTableSection({ pluginId, section, role, onReload, onValues }: PanelTableSectionProps): ReactNode {
const log = useAuditStore((s) => s.log);
const { rows, loading, error, load } = useTableRows(pluginId, section.source?.action ?? "");
const { busy, flash, run } = useRowActionRunner({ pluginId, onReload, reloadRows: load, log, onValues });
const rowActions = section.rowActions ?? [];
const canAct = canWrite(role) && rowActions.length > 0;
const columns: AdminColumn<PanelRow>[] = (section.columns ?? []).map((c) => ({
key: c.key,
title: c.label ?? c.key,
render: (row) => <span className="block max-w-xs truncate">{cellText(row.data[c.key])}</span>,
}));
if (canAct) {
columns.push({
key: "__actions",
title: "操作",
render: (row) => (
<span className="flex flex-wrap gap-1">
{rowActions.map((a) => (
<Button key={a.id} size="sm" variant="ghost" disabled={busy !== null} onClick={() => run(a.action, a.label, row.data)}>
{a.label}
</Button>
))}
</span>
),
});
}
return (
<Surface className="space-y-2">
<SectionHeader
title={section.title}
actions={
<Button size="sm" variant="ghost" disabled={loading} onClick={load}>
刷新
</Button>
}
/>
<TableFlashRow flash={flash} />
{!canWrite(role) ? <Notice>当前角色只读:表格可见,写动作(行内按钮)不显示。</Notice> : null}
<AdminTable columns={columns} rows={rows} loading={loading} error={error} rowKey={(r) => r.key} emptyTitle="暂无数据" />
</Surface>
);
}
+31
View File
@@ -0,0 +1,31 @@
/** 插件动作的公共口径:失败形状、错误码与文案分离、`optionsFrom` → 动作名映射。
*
* design/09 §5.4.6 要求失败**诚实**:结构化错误码 + 文案分离(前端可本地化),绝不
* 把「没生效」说成「已保存」。所以这里把 `ApiError` 摊成 `{code, message}`,界面上
* 错误码与文案分两处显示。
*/
import { ApiError } from "../../lib/api/core";
export interface ActionFailure {
/** 结构化错误码(`ApiError.code`,缺省用 HTTP 状态)。 */
code: string;
/** 人话文案(后端给的中文 message,或本地兜底)。 */
message: string;
}
export function actionFailure(err: unknown): ActionFailure {
if (err instanceof ApiError) return { code: err.code ?? String(err.status), message: err.message };
return { code: "error", message: err instanceof Error ? err.message : "动作失败" };
}
/** `optionsFrom`(`plugin:<name>`,可带点分层级)→ 宿主动作名。
*
* 设计只规定「只允许 `plugin:` 前缀」,没规定候选值由哪个动作提供;本实现取
* **逐字加前缀**这一条最不容易猜错的映射:`plugin:providers` → `plugin.providers`、
* `plugin:search.regions` → `plugin.search.regions`。这是契约待定处的**我方口径**,
* 已登记在片报告里(后端按同一映射提供候选即可对齐)。 */
export function resolveOptionsAction(optionsFrom: string): string {
const name = optionsFrom.replace(/^plugin:/, "").trim();
return name === "" ? "" : `plugin.${name}`;
}
+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 };
}
+115
View File
@@ -0,0 +1,115 @@
/** 插件面板的导航模型助手(纯函数):分组常量、图标白名单、稳定序、角色复检。
*
* 与 `admin/navModel.ts` 的分工:这里只处理**插件侧**的条目形状与排序;把「核心分组 +
* 插件分组」合成一张侧边栏模型是 navModel.ts 的事。
*/
import type { IconName } from "../../components/Icon";
import type { Role } from "../../lib/types";
import { adminPluginPath, adminRoutePath, type AdminEntry } from "../router";
import { PLUGIN_GROUP_ID, type PanelEntryDto, type PanelSectionDto } from "./types";
export { PLUGIN_GROUP_ID };
/** 分组显示名走**宿主文案**(§5.2 的 `group` 只是分组 id,分组名不在插件声明面里)。
*
* 优先用后端下发的 `groupTitle`(`panelGroupTitle` 是宿主文案的单一来源),这里只是
* 快照没给 title 时的兜底。它与核心模块「插件与能力」刻意用不同字面量,避免侧边栏里
* 两个同名条目 —— 后端取的是「插件」二字,前端兜底跟它对齐(改口径只需改后端一处)。 */
export const PLUGIN_GROUP_LABEL = "插件";
/** 宿主内置图标集中**可供插件声明的子集**。
*
* 为什么是子集而不是全集:`Icon.tsx` 的图标表没有运行时导出,前端拿不到「合法名字」
* 这个集合,只能在这里显式列出能兑现的那批。声明了集合外的名字(含设计示例里的
* `search` —— 它当前不在图标表里)一律回落 `plugin`,而不是画一个空 `<svg>`
* (不知名的图标会渲染成空白,那比回落更难排查)。 */
const ICON_ALLOW: Readonly<Record<string, IconName>> = {
plugin: "plugin",
chart: "chart",
chat: "chat",
docs: "docs",
channel: "channel",
device: "device",
robot: "robot",
clock: "clock",
agent: "agent",
trend: "trend",
shield: "shield",
storage: "storage",
gear: "gear",
package: "package",
folder: "folder",
file: "file",
users: "users",
settings: "settings",
light: "light",
terminal: "terminal",
vision: "vision",
refresh: "refresh",
check: "check",
memory: "memory",
dna: "dna",
home: "home",
};
/** 声明里的图标名 → 宿主可用图标;认不出来就回落 `plugin`。 */
export function panelIconName(raw: string | undefined): IconName {
const name = (raw ?? "").trim();
return ICON_ALLOW[name] ?? "plugin";
}
/** 侧边栏条目的稳定 key(核心模块用 `section:<id>`,插件页用 `plugin:<id>/<panel>`)。 */
export function panelNavKey(pluginId: string, panelId: string): string {
return `plugin:${pluginId}/${panelId}`;
}
/** 条目的绝对路径:**优先用后端下发的 `route`**(路由口径由后端单点决定),
* 没给才按 `/plugin/<pluginId>/<panelId>` 自己拼。 */
export function panelPathOf(entry: PanelEntryDto, adminEntry: AdminEntry | null): string {
const route = (entry.route ?? "").trim();
if (route !== "") return adminRoutePath(route, adminEntry);
return adminPluginPath(entry.pluginId, entry.id, adminEntry);
}
/** 组内稳定序:order 升序,同 order 按**声明顺序**(后端 map 迭代顺序不能泄漏到导航里,
* 否则同一份声明在不同进程里排不同 —— 侧边栏抖动是真实体验问题)。 */
export function sortPanelEntries(entries: readonly PanelEntryDto[]): PanelEntryDto[] {
return entries
.map((entry, index) => ({ entry, index }))
.sort((a, b) => {
const oa = a.entry.order ?? 0;
const ob = b.entry.order ?? 0;
if (oa !== ob) return oa - ob;
return a.index - b.index;
})
.map((x) => x.entry);
}
/** 在快照里找一条面板(找不到 = 未启用 / 没声明 / 未安装)。 */
export function findPanelEntry(
entries: readonly PanelEntryDto[],
target: { pluginId: string; panelId: string },
): PanelEntryDto | null {
return entries.find((e) => e.pluginId === target.pluginId && e.id === target.panelId) ?? null;
}
/** 写权限(design/09 §5.4.1):写动作一律 owner / admin,viewer 只读。 */
export function canWrite(role: Role | null): boolean {
return role === "owner" || role === "admin";
}
/** 按角色复检区块(**与后端 `paneldecl.filterPanel` 同口径的第二道闸**)。
*
* 后端已经在快照里按调用者角色裁过一遍,这里再裁一次是**纵深防御**:前端拿着快照直接
* 渲染,若哪天后端过滤被绕过(或契约漂移),viewer 也不会在前端拿到写面。
* - viewer:丢掉 `form`(唯一自带写面的类型),表格摘掉行内动作;status / log 照留;
* - owner / admin:原样(不能顺手削掉有写权的人)。
*/
export function viewerVisibleSections(panel: PanelEntryDto, role: Role | null): PanelSectionDto[] {
const sections = panel.sections ?? [];
if (canWrite(role)) return sections;
return sections
.filter((s) => s.type !== "form")
.map((s) => (s.rowActions && s.rowActions.length > 0 ? { ...s, rowActions: undefined } : s));
}
@@ -0,0 +1,55 @@
/** 插件面板的数据面客户端 —— 全部经**插件管理器**的端点,面板不直连 `plugins.json`。
*
* - `GET /api/v1/admin/panels`:按调用者角色过滤后的接入点快照(唯一契约面;
* 线形状见 `types.ts`,实现在 `internal/services/gateway/admin_panels.go`);
* - `POST /api/v1/admin/plugins/{pluginId}/actions/{action}`:动作分发。插件声明的只是
* **动作名**、不是 URL(design/09 §5.4.4),所以这里永远拼不出任意请求面。
* 响应是 `{pluginId, action, kind, result}` 信封 —— 取数据要先解 `result`(见 `types.ts`)。
*
* **请求体形状(本片口径,待 PLG-4 的执行器对齐)**:`{values?: {…}, row?: {…}}`。
* 表单提交带 `values`(已按声明类型还原),表格行内动作带 `row`(该行的原始对象)。
* 网关把请求体**原样**交给插件执行器(`plugin.ActionRequest.Payload`,它不解释插件的数据
* 形状),所以这三个键是对齐点:执行器按 `values` / `row` 取值即可。
*
* `panelsUnavailableToEmpty` 是「后端还没接线」的诚实降级:501/404 当空快照(插件分组不出现),
* 而不是把一个未实现的端点显示成「加载失败」。其余错误照常冒泡给调用方。 */
import { isNotImplemented, request } from "../../lib/api/core";
import { decodePanelEntries, type PanelEntryDto } from "./types";
export const PANELS_PATH = "/admin/panels";
/** 动作端点的路径(插件 id / 动作名都是声明面校验过的字符集,这里再转义一次兜底)。 */
export function pluginActionPath(pluginId: string, action: string): string {
return `/admin/plugins/${encodeURIComponent(pluginId)}/actions/${encodeURIComponent(action)}`;
}
export interface PluginActionPayload {
/** 表单/动作的值(已按声明类型还原:number → 数字、switch → 布尔、multiSelect → 数组)。 */
values?: Record<string, unknown>;
/** 行内动作所在的行(表格行动作把它带回去,插件才知道操作的是哪一条)。 */
row?: Record<string, unknown>;
}
/** 面板快照。未接线(404/501)⇒ 空快照;其它错误抛出(调用方如实显示)。 */
export function fetchAdminPanels(): Promise<PanelEntryDto[]> {
return request<unknown>(PANELS_PATH).then(decodePanelEntries);
}
/** 后端「还没接这个端点」⇒ 当空快照,而不是把未实现显示成故障。 */
export function panelsUnavailableToEmpty(err: unknown, fallback: PanelEntryDto[] = []): PanelEntryDto[] {
if (isNotImplemented(err)) return fallback;
throw err;
}
/** 触发一个插件动作。返回值是插件的结构化结果(调用方按区块类型解码)。 */
export function runPluginAction(
pluginId: string,
action: string,
payload: PluginActionPayload = {},
): Promise<unknown> {
return request<unknown>(pluginActionPath(pluginId, action), {
method: "POST",
body: JSON.stringify(payload),
});
}
@@ -0,0 +1,255 @@
// 插件面板**数据面**的纯逻辑测试:宽容解码 + 只读渲染口径 + 角色复检。
//
// 契约层(`GET /api/v1/admin/panels` + 动作端点)与后端是**跨切片**的,所以这里刻意
// 钉两类东西:
// ① 形状对齐 —— 设计示例(design/09 §5.2)必须逐字段解出来;
// ② 越界不崩、秘密不泄漏 —— 未知 section 类型不渲染、`secret` 在事实面只显示「已配置」、
// viewer 拿不到写面(与后端 `paneldecl.filterPanel` 同口径的第二道闸)。
//
// 跑法:
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/admin/plugin/pluginData.test.ts \
// --bundle --platform=node --format=esm --outfile=/tmp/cyrene-plugin-data.test.mjs \
// && node /tmp/cyrene-plugin-data.test.mjs
import { ApiError } from "../../lib/api/core";
import { actionFailure, resolveOptionsAction } from "./actions";
import { canWrite, viewerVisibleSections } from "./nav";
import {
decodeFacts,
decodeLogLines,
decodeOptions,
decodePanelEntries,
decodePanelSnapshot,
decodeRows,
formValuesOfResult,
type PanelEntryDto,
} from "./types";
import { cellText, rowIdentity } from "./TableSection";
import { DEFAULT_LOG_LIMIT, readOnlyValue } from "./ReadOnlySections";
let checks = 0;
function ok(cond: boolean, what: string): void {
checks += 1;
if (!cond) throw new Error(`FAILED: ${what}`);
}
function eq(got: unknown, want: unknown, what: string): void {
const a = JSON.stringify(got);
const b = JSON.stringify(want);
ok(a === b, `${what}(实际 ${a},期望 ${b})`);
}
/** 后端(`internal/services/gateway/admin_panels.go`)的真实快照形状:
* data = `{role, groups:[{id,title,order,items:[…]}], pluginIds:[…]}`。 */
const SNAPSHOT = {
role: "owner",
groups: [
{
id: "plugins",
title: "插件",
order: 20,
items: [
{
pluginId: "web-search",
id: "providers",
title: "搜索提供商",
description: "为 web-search 能力配置多个第三方搜索后端",
icon: "search",
order: 20,
route: "plugin/web-search/providers",
sections: [
{
type: "form",
id: "add",
title: "添加提供商",
fields: [
{ key: "provider", label: "提供商", type: "select", optionsFrom: "plugin:providers", required: true },
{ key: "apiKey", label: "API Key", type: "secret", placeholder: "留空 = 保持原值" },
{ key: "baseUrl", label: "接口地址", type: "text", placeholder: "https://..." },
{ key: "enabled", label: "启用", type: "switch" },
],
submit: { action: "plugin.saveProvider" },
},
{
type: "table",
id: "list",
title: "已配置的提供商",
source: { action: "plugin.listProviders" },
columns: [
{ key: "provider", label: "提供商" },
{ key: "state", label: "状态" },
],
rowActions: [
{ id: "test", label: "测试连通", action: "plugin.testProvider" },
{ id: "reauth", label: "重配凭据", action: "plugin.reauthProvider" },
],
},
],
},
],
},
],
pluginIds: ["web-search"],
};
/** 平铺形状(容错口径)用的一条条目。 */
const DESIGN_EXAMPLE = { ...SNAPSHOT.groups[0].items[0] };
/** 动作响应的信封(网关 `adminPluginAction`):结果在 `result` 里。 */
function actionEnvelope(result: unknown): unknown {
return { pluginId: "web-search", action: "plugin.listProviders", kind: "read", result };
}
// ── ① 真实快照形状逐字段解出来(与 gateway 的 AdminPanelsPayload 同形) ─────────
{
const snap = decodePanelSnapshot(SNAPSHOT);
eq(snap.role, "owner", "回显本次快照按哪个角色过滤(便于确认裁剪档)");
eq(snap.groups.length, 1, "一个分组");
eq(snap.groups[0].id, "plugins", "分组 id");
eq(snap.groups[0].title, "插件", "分组显示名来自后端宿主文案(前端不自造)");
eq(snap.groups[0].items.length, 1, "分组内条目");
const entries = decodePanelEntries(SNAPSHOT);
eq(entries.length, 1, "平铺后一条");
const p = entries[0];
eq(p.pluginId, "web-search", "pluginId 随条目下发(组成动态路由)");
eq(p.id, "providers", "面板 id");
eq(p.order, 20, "order");
eq(p.route, "plugin/web-search/providers", "后端下发的相对路由片段");
eq(p.group, "plugins", "分组信息由外层 group 补进条目");
eq(p.groupTitle, "插件", "分组显示名同样补进来");
eq(p.sections?.length, 2, "两个区块");
eq(p.sections?.[0].type, "form", "form 区块");
eq(p.sections?.[0].fields?.length, 4, "四个字段");
eq(p.sections?.[0].fields?.[0].optionsFrom, "plugin:providers", "optionsFrom");
eq(p.sections?.[0].fields?.[1].type, "secret", "secret 字段");
eq(p.sections?.[0].submit?.action, "plugin.saveProvider", "submit.action");
eq(p.sections?.[1].source?.action, "plugin.listProviders", "source.action");
eq(p.sections?.[1].columns?.map((c) => c.key), ["provider", "state"], "列");
eq(p.sections?.[1].rowActions?.map((a) => a.id), ["test", "reauth"], "行内动作");
}
// ── ② 宽容解码:平铺形状也认,坏数据不崩、不猜 ──────────────────────────────────
{
eq(decodePanelEntries([DESIGN_EXAMPLE]).length, 1, "裸数组(平铺)也认");
eq(decodePanelEntries({ panels: [DESIGN_EXAMPLE] }).length, 1, "{panels}(平铺)也认");
eq(decodePanelEntries({ panels: [DESIGN_EXAMPLE] })[0].group, "plugins", "平铺兜底归到唯一分组(条目没带 group)");
eq(decodePanelEntries({}).length, 0, "空对象 ⇒ 空");
eq(decodePanelEntries(null).length, 0, "null ⇒ 空");
eq(decodePanelEntries("oops").length, 0, "非对象 ⇒ 空");
eq(decodePanelEntries({ groups: [{ id: "plugins", items: [] }] }).length, 0, "空分组不产出空分组(不画一个没内容的折叠标题)");
eq(decodePanelEntries({ panels: [{ id: "no-plugin" }] }).length, 0, "缺 pluginId 的条目被丢掉(没有命名空间就没有路由)");
eq(decodePanelEntries({ panels: [{ pluginId: "p" }] }).length, 0, "缺 id 的条目被丢掉");
const bad = decodePanelEntries({
panels: [
{
pluginId: "p",
id: "x",
title: "T",
sections: [
{ type: "iframe", id: "escape", title: "逃生舱" },
{ type: "form", id: "ok", title: "表单", fields: [{ key: "a", type: "number" }, { key: "b", type: "wat" }] },
],
},
],
});
eq(bad[0].sections?.length, 1, "闭集外的 section 类型被丢掉(不半渲染)");
eq(bad[0].sections?.[0].fields?.length, 1, "闭集外的字段类型被丢掉");
eq(decodePanelEntries({ panels: [{ pluginId: "p", id: "x" }] })[0].title, "x", "缺 title 回落 id(不显示空标题)");
}
// ── ③ 动作结果解码:先解 result 信封,再取行 / 日志 / 事实 / 候选 ─────────────────
{
eq(decodeRows([{ a: 1 }]), [{ a: 1 }], "裸数组");
eq(decodeRows(actionEnvelope({ rows: [{ a: 1 }] })), [{ a: 1 }], "**result 信封里的 rows**(忘了解封就会显示空表)");
eq(decodeRows(actionEnvelope([{ a: 1 }])), [{ a: 1 }], "result 直接是数组");
eq(decodeRows({ rows: [{ a: 1 }] }), [{ a: 1 }], "{rows}");
eq(decodeRows({ rows: [1, "x", null, { a: 1 }] }), [{ a: 1 }], "非对象元素被丢掉(表格只认对象行)");
eq(decodeRows(undefined), [], "取不到就是空表,不猜");
eq(decodeLogLines(["a", "b"]), [{ time: "", text: "a" }, { time: "", text: "b" }], "字符串数组");
eq(decodeLogLines({ lines: [{ time: "10:00", message: "hi" }] }), [{ time: "10:00", text: "hi" }], "对象行取 time+message");
eq(decodeLogLines(actionEnvelope({ lines: ["x"] })), [{ time: "", text: "x" }], "日志也先解 result");
eq(decodeLogLines({ entries: [{ k: 1 }] })[0].text, '{"k":1}', "认不出的就原样 JSON(不显示空白行)");
eq(decodeLogLines(null), [], "取不到 ⇒ 空");
eq(decodeFacts({ facts: { a: 1 } }), { a: 1 }, "{facts}");
eq(decodeFacts(actionEnvelope({ active: "searxng" })), { active: "searxng" }, "事实面在 result 里");
eq(decodeFacts(actionEnvelope({ facts: { a: 1 } })), { a: 1 }, "result.facts");
eq(decodeFacts([1]), {}, "数组不是事实面");
eq(decodeOptions([{ value: "searxng", label: "SearXNG" }]), [{ value: "searxng", label: "SearXNG" }], "对象候选");
eq(decodeOptions(actionEnvelope({ options: ["a", "b"] })), [{ value: "a", label: "a" }, { value: "b", label: "b" }], "result.options 里的字符串候选");
eq(decodeOptions({ options: [{ id: "x" }] }), [{ value: "x", label: "x" }], "id 别名 + label 回落");
eq(decodeOptions({ rows: [{ value: "" }] }), [], "空值候选丢掉");
// 行内动作「带回表单」只认显式键。
eq(formValuesOfResult(actionEnvelope({ values: { provider: "searxng" } })), { provider: "searxng" }, "result.values");
eq(formValuesOfResult(actionEnvelope({ formValues: { a: 1 } })), { a: 1 }, "result.formValues");
eq(formValuesOfResult(actionEnvelope({ provider: "searxng", state: "ok" })), null, "**不猜**:平铺的行对象不当表单值");
eq(formValuesOfResult(actionEnvelope("ok")), null, "非对象不当表单值");
}
// ── ④ 表格助手:单元格文本 + 行身份 ───────────────────────────────────────────
{
eq(cellText(undefined), "—", "缺失显示占位而不是 undefined");
eq(cellText(""), "—", "空串同理");
eq(cellText(0), "0", "0 是有效值(不能当成空)");
eq(cellText(false), "false", "false 是有效值");
eq(cellText(["a", "b"]), "a、b", "数组可读");
eq(cellText([]), "—", "空数组是空");
eq(cellText({ a: 1 }), '{"a":1}', "对象退化成 JSON,不出现 [object Object]");
// 行身份:真实键优先;没有就用零填充下标,保证「按 rowKey 排序」不打乱后端顺序。
const noKeyRows = Array.from({ length: 11 }, () => ({ v: 1 }));
const ids = noKeyRows.map((r, i) => rowIdentity(r, i));
eq([...ids].sort().join(","), ids.join(","), "零填充下标排序后与声明顺序一致(10 不会跑到 2 前面)");
eq(rowIdentity({ id: "a" }, 0), "id:a", "优先用 id");
eq(rowIdentity({ provider: "searxng" }, 3), "provider:searxng", "其次 provider(表里就是实体)");
eq(rowIdentity({ id: 7 }, 0), "id:7", "数字键也可用");
}
// ── ⑤ 只读渲染:掩码永不回显 ─────────────────────────────────────────────────
{
eq(readOnlyValue({ key: "apiKey", type: "secret" }, "****cdef"), "已配置(掩码不回显)", "secret 有值 ⇒ 只说已配置");
eq(readOnlyValue({ key: "apiKey", type: "secret" }, ""), "未配置", "secret 空 ⇒ 未配置");
eq(readOnlyValue({ key: "enabled", type: "switch" }, true), "开", "开关显示开/关");
eq(readOnlyValue({ key: "enabled", type: "switch" }, false), "关", "false 也是有效值(不能显示成 —)");
eq(readOnlyValue({ key: "regions", type: "multiSelect" }, ["cn", "us"]), "cn、us", "多选可读");
eq(readOnlyValue({ key: "endpoint", type: "text" }, ""), "—", "普通字段空值占位");
eq(DEFAULT_LOG_LIMIT > 0, true, "日志默认条数是正数(声明没给 limit 时用它)");
}
// ── ⑥ 角色复检:viewer 拿不到写面(与后端 filterPanel 同口径) ─────────────────
{
const panel: PanelEntryDto = decodePanelEntries({ panels: [DESIGN_EXAMPLE] })[0];
eq(canWrite("owner"), true, "owner 可写");
eq(canWrite("admin"), true, "admin 可写");
eq(canWrite("viewer"), false, "viewer 只读");
eq(canWrite(null), false, "未知角色 fail-closed(不猜权限)");
eq(viewerVisibleSections(panel, "owner").map((s) => s.type), ["form", "table"], "owner ⇒ 原样");
const forViewer = viewerVisibleSections(panel, "viewer");
eq(forViewer.map((s) => s.type), ["table"], "viewer ⇒ 丢掉唯一自带写面的 form");
eq(forViewer[0].rowActions, undefined, "viewer ⇒ 表格的行内动作被摘掉(列继续可看)");
eq(viewerVisibleSections(panel, "viewer")[0].columns?.length, 2, "摘动作不影响列");
eq(viewerVisibleSections(panel, null).map((s) => s.type), ["table"], "认不出的角色按只读处理");
eq(viewerVisibleSections({ ...panel, sections: [{ type: "form", id: "f", title: "F" }] }, "viewer").length, 0, "纯写面板对 viewer 空 ⇒ 调用方显示「没有可显示区块」");
}
// ── ⑦ 动作口径:错误码与文案分离 + optionsFrom 映射 ───────────────────────────
{
eq(actionFailure(new ApiError(403, "forbidden", "无权限")), { code: "forbidden", message: "无权限" }, "结构化错误码与文案分开");
eq(actionFailure(new ApiError(0, undefined, "网络请求失败")), { code: "0", message: "网络请求失败" }, "无 code 时回落状态码(不编造错误码)");
eq(actionFailure(new Error("boom")), { code: "error", message: "boom" }, "非 ApiError 兜底");
eq(resolveOptionsAction("plugin:providers"), "plugin.providers", "plugin:x → plugin.x");
eq(resolveOptionsAction("plugin:search.regions"), "plugin.search.regions", "点分层级保留");
eq(resolveOptionsAction("plugin:"), "", "空名字 ⇒ 空动作(调用方会跳过)");
}
console.log(`plugin data test: ${checks} checks passed`);
+33
View File
@@ -0,0 +1,33 @@
/** 插件页的路由订阅(面板路由是 history 驱动的,见 `../router.ts`)。
*
* 为什么插件页要自己订阅路由:`AdminApp` 只从 `App.tsx` 拿到 `section`(核心模块 id),
* 插件页没有对应的静态 id —— 它的身份就是 URL 里的动态两段。`useSyncExternalStore`
* 保证浏览器前进/后退与本模块的 `pushState` 都能让页面重渲染,且不对 `App.tsx` 提要求
* (那里继续只传 `section`,`undefined` 即「这是插件页宿主段」)。
*/
import { useMemo, useSyncExternalStore } from "react";
import {
parseAdminRoute,
readAdminEntry,
subscribeAdminRoute,
type AdminRoute,
type PluginTarget,
} from "../router";
/** 当前 pathname(服务端渲染时没有 window ⇒ 空串 ⇒ 解析成「不在面板里」)。 */
function currentPath(): string {
return typeof window === "undefined" ? "" : window.location.pathname;
}
/** 当前面板路由(含插件页目标)。 */
export function useAdminRoute(): AdminRoute {
const pathname = useSyncExternalStore(subscribeAdminRoute, currentPath, () => "");
const entry = readAdminEntry();
return useMemo(() => parseAdminRoute(pathname, entry), [pathname, entry]);
}
/** 当前插件页目标;不在插件页时为 null。 */
export function usePluginTarget(): PluginTarget | null {
return useAdminRoute().pluginTarget;
}
+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 };
}
+373
View File
@@ -0,0 +1,373 @@
/** 插件面板的**线形状**(`GET /api/v1/admin/panels` 的快照)。
*
* 字段名与 Go 侧 `pkg/plugins/sdk/panel.go` / `panelfield.go` 的 JSON tag **逐字同形**
* (design/09 §5.2 的声明形状是唯一权威),另外多三个宿主侧字段:
* - `pluginId`:声明该面板的插件 id(Go 侧 `paneldecl.Entry.PluginID`)——
* 它组成路由 `/admin/plugin/<pluginId>/<panelId>`,所以必须随条目下发;
* - `route`:后端给的**相对路由片段**(`plugin/<id>/<panel>`,不含 ADMIN_PATH 前缀)
* —— 有它就照它走,路由口径由后端单点决定(见 `internal/services/gateway/admin_panels.go`);
* - `enabled`(可选):该插件**当前是否启用**这一事实。当前后端已按 `when` 过滤过快照
* 且**不下发**这个键,因此它今天是前瞻性字段:带上就复算 `when`,不带就信快照
* (见 `when.ts` 的三态说明)。
*
* 快照是**分组形状**:`{role, groups:[{id,title,order,items:[…]}]}`。另外容错 `{panels:[…]}`
* 与裸数组两个平铺形状 —— 容错只为「跨切片尚未对齐时面板不至于整页空白」,不改变主口径。
* 未知键忽略、闭集外的 section 类型不渲染(但显式告知),绝不半渲染。
*/
/** 侧边栏折叠分组的 id(design/09 §5.2「本期固定 plugins」;后端 `sdk.GroupPlugins`)。 */
export const PLUGIN_GROUP_ID = "plugins";
export const PANEL_SECTION_TYPES = ["form", "table", "status", "log"] as const;
export type PanelSectionType = (typeof PANEL_SECTION_TYPES)[number];
export const PANEL_FIELD_TYPES = [
"text",
"secret",
"number",
"switch",
"select",
"multiSelect",
"date",
"kv",
] as const;
export type PanelFieldType = (typeof PANEL_FIELD_TYPES)[number];
export interface PanelFieldDto {
key: string;
label?: string;
type: PanelFieldType;
required?: boolean;
placeholder?: string;
/** 只允许 `plugin:*`(Go 侧校验),前端据此动作名拉候选。 */
optionsFrom?: string;
default?: unknown;
min?: number;
max?: number;
}
export interface PanelColumnDto {
key: string;
label?: string;
}
export interface PanelRowActionDto {
id: string;
label: string;
action: string;
}
export interface PanelWhenDto {
enabled?: boolean;
}
export interface PanelSectionDto {
type: PanelSectionType;
id: string;
title: string;
fields?: PanelFieldDto[];
submit?: { action: string };
source?: { action: string };
columns?: PanelColumnDto[];
rowActions?: PanelRowActionDto[];
limit?: number;
/** form 区块的**当前值**(快照侧数据面,非声明键):后端按声明字段给出该表单正在编辑的
* 那份配置(`secret` 只给掩码)。带上它表单才能「打开即显示现状」并兑现
* 「掩码留空 = 不改动」;不带就是空表单,功能照常。 */
values?: Record<string, unknown>;
}
export interface PanelEntryDto {
pluginId: string;
id: string;
title: string;
description?: string;
/** 侧边栏分组 id(**分组形状**的快照里由外层 group 给出;平铺形状才逐条带)。 */
group?: string;
/** 分组的本地化显示名(后端 `panelGroupTitle` 给的宿主文案)。 */
groupTitle?: string;
order?: number;
icon?: string;
/** 后端给的**相对路由片段**(`plugin/<pluginId>/<panelId>`,不含 ADMIN_PATH 前缀)。
* 有它就照它走 —— 路由口径由后端单点决定,前端不另算一份。 */
route?: string;
/** 插件当前是否启用(可选;本后端版本已按 `when` 过滤过,故通常不带)。 */
enabled?: boolean;
when?: PanelWhenDto;
sections?: PanelSectionDto[];
}
/** 侧边栏折叠分组(后端 `AdminPanelGroup`)。 */
export interface PanelGroupDto {
id: string;
title: string;
order?: number;
items: PanelEntryDto[];
}
/* ---------------------------------------------------------------------------
* 宽容解码
* ------------------------------------------------------------------------- */
function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null && !Array.isArray(v);
}
function str(v: unknown): string {
return typeof v === "string" ? v : "";
}
function num(v: unknown): number | undefined {
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
}
function bool(v: unknown): boolean | undefined {
return typeof v === "boolean" ? v : undefined;
}
function decodeField(raw: unknown): PanelFieldDto | null {
if (!isRecord(raw)) return null;
const key = str(raw.key);
const type = str(raw.type) as PanelFieldType;
if (key === "" || !(PANEL_FIELD_TYPES as readonly string[]).includes(type)) return null;
const label = raw.label === undefined ? undefined : str(raw.label);
const placeholder = raw.placeholder === undefined ? undefined : str(raw.placeholder);
const optionsFrom = raw.optionsFrom === undefined ? undefined : str(raw.optionsFrom);
return {
key,
label,
type,
required: bool(raw.required),
placeholder,
optionsFrom,
default: raw.default,
min: num(raw.min),
max: num(raw.max),
};
}
function decodeSection(raw: unknown): PanelSectionDto | null {
if (!isRecord(raw)) return null;
const type = str(raw.type);
const id = str(raw.id);
const title = str(raw.title);
// 类型不在闭集内 ⇒ 不猜、不半渲染(调用方会如实显示「本版本不支持」)。
if (id === "" || !(PANEL_SECTION_TYPES as readonly string[]).includes(type)) return null;
const fields = Array.isArray(raw.fields)
? raw.fields.map(decodeField).filter((f): f is PanelFieldDto => f !== null)
: undefined;
const columns: PanelColumnDto[] | undefined = Array.isArray(raw.columns)
? raw.columns.flatMap<PanelColumnDto>((c) =>
isRecord(c) && str(c.key) !== "" ? [{ key: str(c.key), label: str(c.label) || undefined }] : [],
)
: undefined;
const rowActions = Array.isArray(raw.rowActions)
? raw.rowActions
.map((a) =>
isRecord(a) && str(a.id) !== "" && str(a.action) !== ""
? { id: str(a.id), label: str(a.label) || str(a.id), action: str(a.action) }
: null,
)
.filter((a): a is PanelRowActionDto => a !== null)
: undefined;
const submitAction = isRecord(raw.submit) ? str(raw.submit.action) : "";
const sourceAction = isRecord(raw.source) ? str(raw.source.action) : "";
return {
type: type as PanelSectionType,
id,
title,
fields,
submit: submitAction === "" ? undefined : { action: submitAction },
source: sourceAction === "" ? undefined : { action: sourceAction },
columns,
rowActions,
limit: num(raw.limit),
values: isRecord(raw.values) ? raw.values : undefined,
};
}
function decodeEntry(raw: unknown): PanelEntryDto | null {
if (!isRecord(raw)) return null;
const pluginId = str(raw.pluginId);
const id = str(raw.id);
if (pluginId === "" || id === "") return null;
const when = isRecord(raw.when) && bool(raw.when.enabled) !== undefined ? { enabled: bool(raw.when.enabled) } : undefined;
return {
pluginId,
id,
title: str(raw.title) || id,
description: str(raw.description) || undefined,
group: str(raw.group) || undefined,
groupTitle: str(raw.groupTitle) || undefined,
order: num(raw.order),
icon: str(raw.icon) || undefined,
route: str(raw.route) || undefined,
enabled: bool(raw.enabled),
when,
sections: Array.isArray(raw.sections)
? raw.sections.map(decodeSection).filter((s): s is PanelSectionDto => s !== null)
: [],
};
}
export interface PanelSnapshotDto {
/** 快照按哪个角色过滤的(回显,便于前端确认自己拿到的是哪一档裁剪)。 */
role: string;
groups: PanelGroupDto[];
}
/** 解快照响应(`data` 原样)。**主口径是后端的 `{role, groups:[{id,title,order,items}]}`**
* (见 `internal/services/gateway/admin_panels.go`),另外容错 `{panels:[…]}` 与裸数组两个
* 平铺形状 —— 容错只为「跨切片尚未对齐时面板不至于整页空白」,不改变主口径。
*
* 后端的载荷里还有 `pluginIds`(「声明过面板的插件 id」,§5.2.1 红线③要给「插件与能力」
* 页显示「未声明管理页面」用)。本片不消费它,因此不在这里解码。 */
export function decodePanelSnapshot(raw: unknown): PanelSnapshotDto {
const role = isRecord(raw) ? str(raw.role) : "";
const groups: PanelGroupDto[] = [];
if (isRecord(raw) && Array.isArray(raw.groups)) {
for (const g of raw.groups) {
if (!isRecord(g)) continue;
const items = Array.isArray(g.items)
? g.items.map(decodeEntry).filter((e): e is PanelEntryDto => e !== null)
: [];
if (items.length === 0) continue;
const id = str(g.id) || PLUGIN_GROUP_ID;
const title = str(g.title) || id;
groups.push({
id,
title,
order: num(g.order),
// 分组信息在**外层**:逐条补进条目,条目自己不必再带一份(重复必然分叉)。
items: items.map((e) => ({ ...e, group: e.group ?? id, groupTitle: e.groupTitle ?? title })),
});
}
return { role, groups };
}
// 平铺兜底:条目自带 group(没有就归到唯一分组)。
const flat = Array.isArray(raw)
? raw.map(decodeEntry).filter((e): e is PanelEntryDto => e !== null)
: isRecord(raw) && Array.isArray(raw.panels)
? raw.panels.map(decodeEntry).filter((e): e is PanelEntryDto => e !== null)
: [];
if (flat.length === 0) return { role, groups };
const buckets = new Map<string, PanelEntryDto[]>();
for (const e of flat) {
const id = e.group ?? PLUGIN_GROUP_ID;
const list = buckets.get(id);
if (list) list.push(e);
else buckets.set(id, [e]);
}
for (const [id, items] of buckets) {
const title = items[0].groupTitle ?? id;
groups.push({ id, title, items: items.map((e) => ({ ...e, group: e.group ?? id, groupTitle: e.groupTitle ?? title })) });
}
return { role, groups };
}
/** 平铺的条目列表(导航模型与「当前页」查找都用它)。 */
export function decodePanelEntries(raw: unknown): PanelEntryDto[] {
return decodePanelSnapshot(raw).groups.flatMap((g) => g.items);
}
/** 动作响应的信封:`{pluginId, action, kind, result}`(网关 `adminPluginAction`)。
* 所有动作结果的解码都先过这一层 —— 忘了解封就会出现「接口有数据、面板显示空」。 */
function unwrapResult(raw: unknown): unknown {
if (isRecord(raw) && "result" in raw && ("action" in raw || "pluginId" in raw)) return raw.result;
return raw;
}
/** 动作响应里取出「行」数组:`{rows}` / `{entries}` / `{items}` / 裸数组都认。
* 契约未定期间的容错 —— 取不到就是空表,而不是把整页卡在加载态。 */
export function decodeRows(raw: unknown): Record<string, unknown>[] {
const body = unwrapResult(raw);
const list = Array.isArray(body)
? body
: isRecord(body) && Array.isArray(body.rows)
? body.rows
: isRecord(body) && Array.isArray(body.entries)
? body.entries
: isRecord(body) && Array.isArray(body.items)
? body.items
: [];
return list.filter(isRecord);
}
/** 行内动作若明确带回一份「值对象」(`result.values` / `result.formValues`),取出它。
*
* 用途是 design/09 §5.2.1 示例里的 reauth:「重配凭据」把该条**带回表单**。这里只认
* 显式的那两个键 —— 不猜「看起来像不像字段值」(`status` 的事实面也是平铺对象,猜就会把
* 事实面当成表单值灌进去)。也没有任何键 ⇒ `null`(不填,不猜)。 */
export function formValuesOfResult(raw: unknown): Record<string, unknown> | null {
const body = unwrapResult(raw);
if (!isRecord(body)) return null;
if (isRecord(body.values)) return body.values;
if (isRecord(body.formValues)) return body.formValues;
return null;
}
/** 动作响应里取出「日志行」:字符串数组或对象数组都认。 */
export function decodeLogLines(raw: unknown): { time: string; text: string }[] {
const body = unwrapResult(raw);
const list = Array.isArray(body)
? body
: isRecord(body) && Array.isArray(body.lines)
? body.lines
: isRecord(body) && Array.isArray(body.entries)
? body.entries
: isRecord(body) && Array.isArray(body.logs)
? body.logs
: [];
return list.map((item) => {
if (typeof item === "string") return { time: "", text: item };
if (!isRecord(item)) return { time: "", text: "" };
const time = str(item.time) || str(item.at) || str(item.ts);
const text =
str(item.text) || str(item.message) || str(item.line) || str(item.msg) || JSON.stringify(item);
return { time, text };
});
}
/** 动作响应里取出「键值事实」:`{facts:{k:v}}` / 裸对象 / `{values:{…}}` 都认。 */
export function decodeFacts(raw: unknown): Record<string, unknown> {
const body = unwrapResult(raw);
if (isRecord(body)) {
if (isRecord(body.facts)) return body.facts;
if (isRecord(body.values)) return body.values;
if (isRecord(body.row)) return body.row;
return body;
}
return {};
}
export interface PanelOptionDto {
value: string;
label: string;
}
/** 动作响应里取出「下拉候选」(`select` / `multiSelect` 的 `optionsFrom` 用):
* `{options|rows|items:[…]}` 或裸数组;元素是 `{value,label}` / `{value}` / 裸字符串都认。 */
export function decodeOptions(raw: unknown): PanelOptionDto[] {
const body = unwrapResult(raw);
const list = Array.isArray(body)
? body
: isRecord(body) && Array.isArray(body.options)
? body.options
: isRecord(body) && Array.isArray(body.rows)
? body.rows
: isRecord(body) && Array.isArray(body.items)
? body.items
: [];
const out: PanelOptionDto[] = [];
for (const item of list) {
if (typeof item === "string") {
if (item !== "") out.push({ value: item, label: item });
continue;
}
if (!isRecord(item)) continue;
const value = str(item.value) || str(item.id) || str(item.key);
if (value === "") continue;
out.push({ value, label: str(item.label) || str(item.title) || value });
}
return out;
}
@@ -0,0 +1,37 @@
/** 插件面板快照的取数 hook。
*
* 三条口径:
* ① **没有 token 就不请求** —— 面板的登录闸自己会显示「需要登录」,在那种状态下打
* `/admin/panels` 只会拿到 401,而 401 会触发全局的「登录过期」处理(清 token、
* 通知 WS 停连),等于在登录页上反复把自己登出;
* ② 未接线(501/404)当空快照(见 `panelsApi.panelsUnavailableToEmpty`)——
* 插件分组不出现,而不是把一个未上线端点显示成故障;
* ③ 定时重取(默认 30s):插件的 install / enable / disable / uninstall 都会重算面板
* 集合,后端已把变更推到既有 bus 的 **`ws.panels_changed`**(载荷 `{reason, pluginId}`,
* 不含面板内容)。**更优的做法是订阅那条帧**,但 `lib/ws` 不在本切片文件面内
* (并发切片在改),所以先用轮询兑现「不刷新页面也能出现新入口」,代价是最长 30s
* 延迟 —— 已如实登记在片报告里:把 `useAutoReload` 换成 `ws.panels_changed` 订阅即可。
*/
import { getAuthToken } from "../../lib/api/core";
import { useAsyncData } from "../../lib/useAsyncData";
import { useAutoReload } from "../hooks";
import { fetchAdminPanels, panelsUnavailableToEmpty } from "./panelsApi";
import type { PanelEntryDto } from "./types";
export const PANELS_POLL_MS = 30_000;
export interface PluginPanelsState {
panels: PanelEntryDto[];
loading: boolean;
error: string | null;
reload: () => void;
}
export function usePluginPanels(): PluginPanelsState {
const token = getAuthToken();
const load = () => (token ? fetchAdminPanels().catch((err: unknown) => panelsUnavailableToEmpty(err)) : Promise.resolve<PanelEntryDto[]>([]));
const { data, loading, error, reload } = useAsyncData(load, [token]);
useAutoReload(reload, token ? PANELS_POLL_MS : null);
return { panels: data ?? [], loading, error, reload };
}
+36
View File
@@ -0,0 +1,36 @@
/** `when` 条件显示的求值(design/09 §5.2.1 nav.item:**不满足 ⇒ 入口不出现**,不是灰掉)。
*
* 这份实现与 Go 侧 `internal/capability/paneldecl/when.go` 的 `Eval` **逐条同义**:
* - `when` 缺省 / 对象里没写 `enabled` ⇒ 无条件 ⇒ 显示;
* - `when.enabled = true` ⇒ 仅当插件启用时显示(§5.6.1);
* - `when.enabled = false` ⇒ 仅当插件**未**启用时显示 —— 条件是「要求值相等」,
* 写 `false` 不等于没写。
*
* **求值事实从哪来**:后端已经按真事实(插件管理器的启用状态)过滤过快照,前端的职责
* 只有一件 —— 不把后端过滤掉的入口又画回来。所以:快照带了 `enabled` 事实就复算一遍
* (这样一次 `panels_changed` 前后的热更新也能立刻生效),没带就**信快照**
* (那就意味着后端已按真事实算过;前端凭「没事实」去猜一定是错的)。
*/
import type { PanelEntryDto, PanelWhenDto } from "./types";
export interface WhenContext {
enabled: boolean;
}
/** 求值一份 `when` 声明,报告该面板的入口是否应当出现。 */
export function evalWhen(when: PanelWhenDto | undefined, ctx: WhenContext): boolean {
if (!when || when.enabled === undefined) return true;
return ctx.enabled === when.enabled;
}
/** 快照里**带了**启用事实时复算 `when`;没带就按「后端已过滤」处理(显示)。 */
export function whenSatisfied(entry: PanelEntryDto): boolean {
if (typeof entry.enabled !== "boolean") return true;
return evalWhen(entry.when, { enabled: entry.enabled });
}
/** 可出现的条目(顺序保持快照给的顺序 —— 稳定序是后端与 `nav.ts` 的职责)。 */
export function visiblePanelEntries(entries: readonly PanelEntryDto[]): PanelEntryDto[] {
return entries.filter(whenSatisfied);
}