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