diff --git a/frontend/web/src/admin/AdminApp.tsx b/frontend/web/src/admin/AdminApp.tsx
index 00a3b96..a7de8e4 100644
--- a/frontend/web/src/admin/AdminApp.tsx
+++ b/frontend/web/src/admin/AdminApp.tsx
@@ -1,11 +1,17 @@
-import { useEffect, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import { applyTheme, readTheme, type ThemeMode } from "../lib/theme";
import { getAuthToken, getStoredRole, health } from "../lib/api";
import { useAppStore } from "../store/useAppStore";
import type { WsStatus } from "../lib/types";
import { UiModeSwitch } from "../design/UiModeSwitch";
-import { ADMIN_GROUPS, ADMIN_SECTIONS } from "./sections";
-import { navigateAdmin, navigateUserShell, type AdminSectionId } from "./router";
+import { ADMIN_SECTIONS } from "./sections";
+import { navigateAdminPath, navigateUserShell, readAdminEntry, type AdminSectionId } from "./router";
+import { activeNavKey, buildAdminNav, groupHasActive, type AdminNavGroup } from "./navModel";
+import { CollapsibleGroup } from "./CollapsibleGroup";
+import { usePluginPanels } from "./plugin/usePanels";
+import { usePluginTarget } from "./plugin/route";
+import { findPanelEntry } from "./plugin/nav";
+import { PluginPanelHost } from "./plugin/PluginPanel";
import { Button, Tag, type TagTone } from "../components/primitives";
import { Icon } from "../components/Icon";
import { DashboardModule } from "./modules/Dashboard";
@@ -53,47 +59,71 @@ function renderSection(section: AdminSectionId): React.ReactNode {
/**
* Sidebar content shared by the desktop rail and the mobile drawer (design/10).
- * Both render ADMIN_GROUPS with the same `onNavigate` hook — the drawer passes
- * its close callback (tap an item → close), the desktop rail passes a no-op — so
- * desktop and mobile never diverge.
+ * Both render the same nav model — the drawer passes its close callback (tap an item
+ * → close), the desktop rail passes a no-op — so desktop and mobile never diverge.
+ *
+ * 导航模型(`navModel.ts`)是「核心模块 + 插件面板」的合成结果:核心条目来自静态
+ * `ADMIN_SECTIONS`,插件条目来自后端快照。分组升级为可折叠手风琴(Miuix),不再平铺。
*/
-function adminNavContent({ active, onNavigate }: { active: AdminSectionId; onNavigate: () => void }): React.ReactNode {
+function adminNavContent({
+ nav,
+ activeKey,
+ onNavigate,
+}: {
+ nav: readonly AdminNavGroup[];
+ activeKey: string;
+ onNavigate: () => void;
+}): React.ReactNode {
return (
<>
- {ADMIN_GROUPS.map((group) => (
-
-
{group}
- {ADMIN_SECTIONS.filter((s) => s.group === group).map((s) => (
+ {nav.map((group) => (
+
+ {group.items.map((item) => (
{ navigateAdmin(s.id); onNavigate(); }}
+ key={item.key}
+ onClick={() => { navigateAdminPath(item.path); onNavigate(); }}
className={`flex w-full items-center gap-2.5 rounded-[var(--mu-radius)] px-3 py-2 text-sm text-left transition-all duration-200 ease-[var(--mu-spring)] active:scale-[0.98] ${
- s.id === active
+ item.key === activeKey
? "bg-[var(--mu-accent)]/15 font-medium text-[var(--mu-accent-hi)]"
: "text-[var(--mu-text-mid)] hover:bg-[var(--mu-surface-4)]"
}`}
>
-
- {s.label}
+
+ {item.label}
))}
-
+
))}
>
);
}
/** Desktop left rail — fixed, `hidden md:flex`. */
-function AdminSidebar({ active }: { active: AdminSectionId }): React.ReactNode {
+function AdminSidebar({ nav, activeKey }: { nav: readonly AdminNavGroup[]; activeKey: string }): React.ReactNode {
return (
- {adminNavContent({ active, onNavigate: () => {} })}
+ {adminNavContent({ nav, activeKey, onNavigate: () => {} })}
);
}
/** Mobile drawer — scrim + spring slide-in panel (reuses mu-drawer-in). */
-function AdminMobileNav({ open, active, onClose }: { open: boolean; active: AdminSectionId; onClose: () => void }): React.ReactNode {
+function AdminMobileNav({
+ open,
+ nav,
+ activeKey,
+ onClose,
+}: {
+ open: boolean;
+ nav: readonly AdminNavGroup[];
+ activeKey: string;
+ onClose: () => void;
+}): React.ReactNode {
if (!open) return null;
return (
@@ -116,7 +146,7 @@ function AdminMobileNav({ open, active, onClose }: { open: boolean; active: Admi
- {adminNavContent({ active, onNavigate: onClose })}
+ {adminNavContent({ nav, activeKey, onNavigate: onClose })}
);
@@ -193,10 +223,12 @@ function BackendStatusTag(): React.ReactNode {
);
}
-function AdminTopBar({ section, onMenu }: { section: AdminSectionId; onMenu?: () => void }): React.ReactNode {
+function AdminTopBar({ section, title, onMenu }: { section: AdminSectionId | undefined; title?: string; onMenu?: () => void }): React.ReactNode {
const role = getStoredRole();
const [theme, setTheme] = useState(readTheme);
- const meta = ADMIN_SECTIONS.find((s) => s.id === section);
+ const meta = section ? ADMIN_SECTIONS.find((s) => s.id === section) : undefined;
+ // 插件页没有静态模块元数据:标题取快照里的面板 title(`title` 由 AdminApp 传入)。
+ const label = title ?? meta?.label ?? "仪表盘";
const toggleTheme = () => {
const next: ThemeMode = theme === "dark" ? "light" : "dark";
applyTheme(next);
@@ -219,7 +251,7 @@ function AdminTopBar({ section, onMenu }: { section: AdminSectionId; onMenu?: ()
Cyrene · 管理面板
-
{meta?.label ?? "仪表盘"}
+
{label}
@@ -281,18 +313,28 @@ function Forbidden403(): React.ReactNode {
);
}
-export function AdminApp({ section }: { section: AdminSectionId }): React.ReactNode {
+export function AdminApp({ section }: { section: AdminSectionId | undefined }): React.ReactNode {
// Mobile nav drawer — only surfaced on the admin shell (md+ keeps the fixed
// sidebar); the login/403 guards never render it but still hold the hook.
const [adminMenuOpen, setAdminMenuOpen] = useState(false);
+ // 插件面板的**可接入点**(design/09 §5.2.1 A):条目不来自静态清单,而来自后端快照
+ // (`GET /api/v1/admin/panels`)。`section === undefined` 表示当前 URL 是插件页
+ // (`/<前缀>/plugin//`),此时页面身份从路由订阅读出来。
+ const pluginTarget = usePluginTarget();
+ const { panels, loading: panelsLoading, error: panelsError, reload: reloadPanels } = usePluginPanels();
+ const entry = readAdminEntry();
+ const nav = useMemo(() => buildAdminNav({ panels, entry }), [panels, entry]);
+ const activeKey = activeNavKey(section, pluginTarget);
+ const activePanel = pluginTarget ? findPanelEntry(panels, pluginTarget) : null;
+
// RBAC guard (design/10 §2 + design/11 §7). A logged-in viewer or an
// unauthenticated user never sees the admin module; the gateway would also
// reject them with 401/403.
if (!getAuthToken() || !getStoredRole()) {
return (
);
@@ -300,7 +342,7 @@ export function AdminApp({ section }: { section: AdminSectionId }): React.ReactN
if (getStoredRole() === "viewer") {
return (
);
@@ -308,14 +350,27 @@ export function AdminApp({ section }: { section: AdminSectionId }): React.ReactN
return (
-
setAdminMenuOpen(true)} />
+ setAdminMenuOpen(true)} />
-
+
- {renderSection(section)}
+
+ {section === undefined ? (
+
+ ) : (
+ renderSection(section)
+ )}
+
- setAdminMenuOpen(false)} />
+ setAdminMenuOpen(false)} />
);
}
diff --git a/frontend/web/src/admin/CollapsibleGroup.tsx b/frontend/web/src/admin/CollapsibleGroup.tsx
new file mode 100644
index 0000000..2cc0d8e
--- /dev/null
+++ b/frontend/web/src/admin/CollapsibleGroup.tsx
@@ -0,0 +1,61 @@
+/** 侧边栏的可折叠分组(Miuix:弹簧动效 + 平滑圆角 + 深色优先)。
+ *
+ * 为什么把分组做成手风琴:核心模块 18 条 + 插件声明的不定条数,平铺一列之后
+ * 「找一条」变成扫一屏。折叠只影响**呈现**,不动任何清单(插件条目的出现/消失由
+ * 快照 + `when` 决定,与折叠无关)。
+ *
+ * 含当前页的分组会被强制展开一次(用户仍可再折叠)——折叠着当前页等于让用户看不到
+ * 自己在哪。
+ */
+
+import { useEffect, useId, useState, type ReactNode } from "react";
+import { Icon } from "../components/Icon";
+
+export interface CollapsibleGroupProps {
+ label: string;
+ /** 条目数:折叠状态下也能看出里面有没有东西。 */
+ count: number;
+ /** 组内含当前页 ⇒ 自动展开一次。 */
+ forceOpen?: boolean;
+ children: ReactNode;
+}
+
+export function CollapsibleGroup({
+ label,
+ count,
+ forceOpen = false,
+ children,
+}: CollapsibleGroupProps): ReactNode {
+ const [open, setOpen] = useState(true);
+ const uid = useId();
+ const panelId = `nav-group-${uid}`;
+
+ useEffect(() => {
+ if (forceOpen) setOpen(true);
+ }, [forceOpen]);
+
+ return (
+
+
setOpen((v) => !v)}
+ className="flex w-full items-center gap-1.5 rounded-[var(--mu-radius)] px-2 py-2 text-left text-xs font-semibold uppercase tracking-wider text-[var(--mu-text-lo)] transition-all duration-200 ease-[var(--mu-spring)] hover:bg-[var(--mu-surface-4)]/60 active:scale-[0.99]"
+ >
+
+
+
+ {label}
+ {count}
+
+ {open ? (
+
+ {children}
+
+ ) : null}
+
+ );
+}
diff --git a/frontend/web/src/admin/modules/channelConnFields.tsx b/frontend/web/src/admin/modules/channelConnFields.tsx
index e646788..7db646c 100644
--- a/frontend/web/src/admin/modules/channelConnFields.tsx
+++ b/frontend/web/src/admin/modules/channelConnFields.tsx
@@ -1,14 +1,21 @@
import { Icon } from "../../components/Icon";
-import { Switch } from "../../components/primitives";
import { type PlatformField } from "./channelFields";
import { modeHint, modeSummary, requiredFieldKeys, visibleFields } from "./channelFieldRules";
+import {
+ FieldShell,
+ MaskedSecretFooter,
+ SchemaControl,
+} from "../schema/fields";
+import type { SchemaField, SchemaFieldType } from "../schema/fieldTypes";
/** qq-obv11 这类「字段含义随 transport × mode 变化」的平台,字段不能平铺 —— 平铺会
* 同时制造「填了用不上」和「没填起不来」两种事故。这里只渲染当前组合下真正生效的字段,
* 并逐字段给出「谁连谁 / 空值代表什么 / 现在是否必填」。
*
- * 校验规则(必填、清空、写回补丁)全在 `channelFieldRules.ts` 的纯函数里,本组件不判断
- * 规则、只呈现结果,单测因此不需要渲染 React。 */
+ * 校验规则(必填、清空、写回补丁)全在 `channelFieldRules.ts` 的纯函数里;**呈现**复用
+ * `../schema/fields.tsx` 的通用控件(与插件面板同一份渲染,design/09 §5.7 C)——
+ * 本组件只做「渠道元数据 → 通用 schema」的适配与规则结果的分发,不再自己画控件。
+ */
export interface PlatformConnFieldsProps {
platform: string;
values: Readonly>;
@@ -22,6 +29,22 @@ export interface PlatformConnFieldsProps {
onToggleClear: (key: string, cleared: boolean) => void;
}
+/** 渠道字段声明 → 通用 schema。分支顺序与旧渲染器一致(有候选先给下拉),
+ * 因此 `channelConfigForm.render.test.ts` 断言的 HTML 形状逐字不变。 */
+export function platformFieldToSchema(f: PlatformField): SchemaField {
+ const options = f.options ?? [];
+ const type: SchemaFieldType = options.length > 0 ? "select" : f.secret === true ? "secret" : "text";
+ return {
+ key: f.key,
+ label: f.label,
+ type,
+ placeholder: f.placeholder,
+ options,
+ default: f.defaultValue,
+ guide: f.guide,
+ };
+}
+
export function PlatformConnFields({
platform,
values,
@@ -50,114 +73,40 @@ export function PlatformConnFields({
) : null}
- {fields.map((f) => (
- onChange(f.key, v)}
- required={required.has(f.key)}
- missing={missing.has(f.key)}
- summary={summary}
- alreadyConfigured={configured.has(f.key)}
- clearRequested={cleared.has(f.key)}
- onToggleClear={(next) => onToggleClear(f.key, next)}
- />
- ))}
+ {fields.map((f) => {
+ const isRequired = required.has(f.key);
+ const isMissing = missing.has(f.key);
+ const clearRequested = cleared.has(f.key);
+ // 掩码秘密:面板只看到「已配置」,原文拿不回来 —— 留空保持原值,要删掉必须显式开关。
+ const maskedSecret = f.secret === true && configured.has(f.key);
+ return (
+ onToggleClear(f.key, next)}
+ />
+ }
+ >
+ onChange(f.key, v)}
+ disabled={clearRequested}
+ invalid={isMissing && isRequired}
+ />
+
+ );
+ })}
);
}
-
-/** 一个字段:标题 + 必填/可选标记 + 输入(下拉或文本框)+ 一行中文说明 + 状态行。 */
-function ConnField({
- field,
- value,
- onChange,
- required,
- missing,
- summary,
- alreadyConfigured,
- clearRequested,
- onToggleClear,
-}: {
- field: PlatformField;
- value: string;
- onChange: (v: string) => void;
- required: boolean;
- missing: boolean;
- /** 当前 transport + mode 摘要,只用于错误文案。 */
- summary: string;
- alreadyConfigured: boolean;
- clearRequested: boolean;
- onToggleClear: (next: boolean) => void;
-}): React.ReactNode {
- // 掩码秘密:面板只看到「已配置」,原文拿不回来 —— 留空保持原值,要删掉必须显式开关。
- const maskedSecret = field.secret === true && alreadyConfigured;
- const options = field.options ?? [];
- const unknownOption = value !== "" && options.length > 0 && !options.some((o) => o.value === value);
- return (
-
-
- {field.label}
- {required ? (
-
- 必填
-
- ) : (
-
- 可选
-
- )}
-
- {options.length > 0 ? (
- onChange(e.target.value)}
- aria-label={field.label}
- className={missing && required ? inputClsError : inputCls}
- >
- {unknownOption ? {value}(不在候选内,保存时原样保留) : null}
- {options.map((o) => (
-
- {o.label}
-
- ))}
-
- ) : (
- onChange(e.target.value)}
- placeholder={field.placeholder}
- disabled={clearRequested}
- className={missing && required ? inputClsError : inputCls}
- />
- )}
- {field.guide}
- {missing && required ? (
-
- 当前接入方式({summary})下必填,留空适配器起不来。
-
- ) : null}
- {maskedSecret ? (
-
-
- 已配置(后端只下发掩码,面板不持有原文):留空 = 不改动。
-
-
-
- ) : null}
-
- );
-}
-
-const inputCls =
- "w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]";
-
-const inputClsError =
- "w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none ring-1 ring-[var(--mu-err)] focus:ring-[var(--mu-err)]";
diff --git a/frontend/web/src/admin/navModel.test.ts b/frontend/web/src/admin/navModel.test.ts
new file mode 100644
index 0000000..4b54314
--- /dev/null
+++ b/frontend/web/src/admin/navModel.test.ts
@@ -0,0 +1,234 @@
+// 侧边栏导航模型的**纯逻辑**测试:把「可接入点」从口号变成可执行断言。
+//
+// 钉三件事(design/09 §5.6 / §5.7 D):
+// ① **新增一个插件页面不得改动静态清单** —— 核心模块注册表在这里被逐字写死,任何
+// 「顺手往 sections.tsx 里加一行」都会让本文件变红;插件条目则必须能从快照里冒出来,
+// 且它的 id 从不进 `AdminSectionId` / `VALID_SECTIONS`;
+// ② **`when` 不满足 ⇒ 入口不出现**(不是灰掉):三态语义与 Go 侧 `paneldecl.Eval` 同义;
+// ③ 稳定序与路径口径。
+//
+// 跑法(逻辑测试,无 DOM):
+//
+// cd frontend/web \
+// && ./node_modules/.bin/esbuild src/admin/navModel.test.ts \
+// --bundle --platform=node --format=esm \
+// --outfile=/tmp/cyrene-nav-model.test.mjs \
+// && node /tmp/cyrene-nav-model.test.mjs
+//
+// `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。
+
+import { ADMIN_GROUPS, ADMIN_SECTIONS } from "./sections";
+import { activeNavKey, buildAdminNav, groupHasActive, sectionNavKey } from "./navModel";
+import { adminPluginPath, parseAdminRoute, readAdminEntry, type AdminEntry } from "./router";
+import { PLUGIN_GROUP_ID, PLUGIN_GROUP_LABEL, panelIconName, panelNavKey, sortPanelEntries } from "./plugin/nav";
+import { evalWhen, visiblePanelEntries } from "./plugin/when";
+import type { PanelEntryDto } from "./plugin/types";
+
+let checks = 0;
+
+function ok(cond: boolean, what: string): void {
+ checks += 1;
+ if (!cond) throw new Error(`FAILED: ${what}`);
+}
+
+function eq(got: T, want: T, what: string): void {
+ ok(got === want, `${what}(实际 ${JSON.stringify(got)},期望 ${JSON.stringify(want)})`);
+}
+
+const ENTRY: AdminEntry = { prefix: "/admin" };
+
+function panel(over: Partial = {}): PanelEntryDto {
+ return {
+ pluginId: "web-search",
+ id: "providers",
+ title: "搜索提供商",
+ group: "plugins",
+ order: 20,
+ icon: "search",
+ sections: [],
+ ...over,
+ };
+}
+
+// ── ① 核心模块静态清单被逐字钉住(新增插件页不得改它) ────────────────────────────
+const EXPECTED_CORE_IDS = [
+ "dashboard",
+ "personas",
+ "sessions",
+ "memories",
+ "rag",
+ "plugins",
+ "channels",
+ "channel-events",
+ "iot",
+ "llm",
+ "automations",
+ "agents",
+ "flow",
+ "backthink",
+ "notifications",
+ "observability",
+ "users",
+ "system",
+];
+{
+ eq(
+ ADMIN_SECTIONS.map((s) => s.id).join(","),
+ EXPECTED_CORE_IDS.join(","),
+ "核心模块注册表保持静态(新增插件页只能走动态入口,不许加到这里)",
+ );
+ eq(
+ ADMIN_GROUPS.join(","),
+ "总览,内容,能力与连接,运行时,治理",
+ "核心分组顺序保持静态",
+ );
+}
+
+// ── ② 插件页走动态段:不进静态白名单,也不占用 section ──────────────────────────
+{
+ const route = parseAdminRoute("/admin/plugin/brand-new-plugin/fancy-panel", ENTRY);
+ ok(route.admin, "插件页在前缀下 ⇒ admin=true");
+ eq(route.section, undefined, "插件页不占用任何核心 section(section 为 undefined)");
+ eq(route.pluginTarget?.pluginId, "brand-new-plugin", "解析出插件 id");
+ eq(route.pluginTarget?.panelId, "fancy-panel", "解析出面板 id");
+ ok(
+ !EXPECTED_CORE_IDS.includes("brand-new-plugin") && !EXPECTED_CORE_IDS.includes("fancy-panel"),
+ "插件的两个 id 都不在静态清单里 —— 这就是「不走白名单」的可执行形式",
+ );
+ eq(
+ parseAdminRoute("/admin/brand-new-plugin", ENTRY).section,
+ "dashboard",
+ "单段未知路径仍回落 dashboard(没有把插件 id 塞进白名单)",
+ );
+ eq(parseAdminRoute("/admin/plugin", ENTRY).section, "dashboard", "缺段的 /plugin 不成路由");
+ eq(parseAdminRoute("/admin/plugin/only-one", ENTRY).pluginTarget, null, "只有一段 ⇒ 不成插件路由");
+ eq(parseAdminRoute("/admin/personas", ENTRY).section, "personas", "核心模块照旧");
+ eq(parseAdminRoute("/elsewhere/plugin/a/b", ENTRY).admin, false, "前缀不符 ⇒ 不进面板");
+
+ // URL 转义与路径构造往返。
+ eq(adminPluginPath("web-search", "providers", ENTRY), "/admin/plugin/web-search/providers", "插件页路径");
+ eq(adminPluginPath("a b", "c/d", ENTRY), "/admin/plugin/a%20b/c%2Fd", "路径段转义");
+ const decoded = parseAdminRoute("/admin/plugin/a%20b/c%2Fd", ENTRY);
+ eq(decoded.pluginTarget?.pluginId, "a b", "转义往返(插件 id)");
+ eq(decoded.pluginTarget?.panelId, "c/d", "转义往返(面板 id)");
+ eq(adminPluginPath("x", "y", { prefix: "/x/admin/" }), "/x/admin/plugin/x/y", "自定义前缀去尾斜杠");
+ eq(adminPluginPath("x", "y", null), "/", "无入口信息(用户端)⇒ 不吐面板路径");
+}
+
+// ── ③ 侧边栏合成:核心分组照旧,插件条目自动出现 ────────────────────────────────
+{
+ const coreOnly = buildAdminNav({ panels: [], entry: ENTRY });
+ eq(coreOnly.length, 5, "没有插件面板时只有 5 个核心分组(不出现空分组)");
+ eq(coreOnly.map((g) => g.label).join(","), ADMIN_GROUPS.join(","), "核心分组顺序不变");
+ eq(
+ coreOnly.every((g) => g.items.every((i) => i.pluginId === undefined)),
+ true,
+ "核心条目没有插件身份",
+ );
+ eq(coreOnly[0].items[0].path, "/admin", "dashboard 的路径是前缀本身");
+
+ const nav = buildAdminNav({ panels: [panel()], entry: ENTRY });
+ eq(nav.length, 6, "有插件面板时多出一个分组");
+ const pluginGroup = nav[nav.length - 1];
+ eq(pluginGroup.key, `plugin-group:${PLUGIN_GROUP_ID}`, "插件分组 key");
+ eq(pluginGroup.label, PLUGIN_GROUP_LABEL, "插件分组用宿主文案(分组名不在插件声明面里)");
+ eq(pluginGroup.items.length, 1, "条目来自快照");
+ eq(pluginGroup.items[0].key, panelNavKey("web-search", "providers"), "条目 key 是 plugin:/");
+ eq(pluginGroup.items[0].path, "/admin/plugin/web-search/providers", "条目路径 = 动态段路由");
+ eq(pluginGroup.items[0].label, "搜索提供商", "条目文案 = 面板 title");
+ eq(pluginGroup.items[0].icon, "plugin", "未收录的图标名(search 不在图标表里)回落 plugin,而不是画一个空图标");
+
+ // 声明没给 group ⇒ 回落唯一分组。
+ const noGroup = buildAdminNav({ panels: [panel({ group: undefined })], entry: ENTRY });
+ eq(noGroup[noGroup.length - 1].label, PLUGIN_GROUP_LABEL, "group 缺省回落 plugins 分组");
+
+ // 当前项判定 + 强制展开。
+ eq(activeNavKey(undefined, { pluginId: "web-search", panelId: "providers" }), pluginGroup.items[0].key, "插件页高亮 key");
+ eq(activeNavKey("personas", null), sectionNavKey("personas"), "核心模块高亮 key");
+ eq(activeNavKey(undefined, null), "", "都不在 ⇒ 无高亮");
+ eq(groupHasActive(pluginGroup, pluginGroup.items[0].key), true, "含当前项的分组会被强制展开一次");
+ eq(groupHasActive(coreOnly[0], pluginGroup.items[0].key), false, "不含当前项的分组不受影响");
+}
+
+// ── ④ when 三态:不满足 ⇒ 入口整个不出现 ───────────────────────────────────────
+//
+// 分工要说清:**真事实(插件启用状态)的真源在后端**(`paneldecl.Eval` + 插件管理器的
+// lifecycle),后端已按它过滤过快照;前端这一层的职责是「不把后端过滤掉的入口又画回来」。
+// `evalWhen` 是 Go 侧 `Eval` 的逐条镜像,快照里若带上启用事实(`enabled`)就复算一遍
+// —— 这是前瞻性口径:当前后端**不下发**这个键,因此生产上就是信快照。
+{
+ eq(evalWhen(undefined, { enabled: false }), true, "没写 when ⇒ 无条件 ⇒ 出现");
+ eq(evalWhen({}, { enabled: false }), true, "写了空对象 ⇒ 无条件 ⇒ 出现");
+ eq(evalWhen({ enabled: true }, { enabled: true }), true, "enabled=true 且插件启用 ⇒ 出现");
+ eq(evalWhen({ enabled: true }, { enabled: false }), false, "enabled=true 但插件未启用 ⇒ 不出现");
+ eq(evalWhen({ enabled: false }, { enabled: true }), false, "写 false 是「要求未启用」,不是「没条件」");
+ eq(evalWhen({ enabled: false }, { enabled: false }), true, "enabled=false 且插件未启用 ⇒ 出现");
+
+ const required = panel({ when: { enabled: true } });
+ eq(visiblePanelEntries([required]).length, 1, "快照没带启用事实 ⇒ 信快照(后端已按真事实过滤)");
+ eq(visiblePanelEntries([{ ...required, enabled: true }]).length, 1, "带事实且满足 ⇒ 出现");
+ eq(visiblePanelEntries([{ ...required, enabled: false }]).length, 0, "带事实且不满足 ⇒ 不出现");
+
+ const hidden = buildAdminNav({ panels: [{ ...required, enabled: false }], entry: ENTRY });
+ eq(hidden.length, 5, "when 不满足的条目连分组都不出现(不是灰掉)");
+ const shown = buildAdminNav({ panels: [{ ...required, enabled: true }], entry: ENTRY });
+ eq(shown[shown.length - 1].items.length, 1, "when 满足 ⇒ 入口出现");
+
+ // 后端的真实行为:`when` 没通过的条目**根本不在快照里**(tests: admin_panels_test.go
+ // 的 viewer/disabled 用例)。前端因此不会、也不该为它造一个入口 —— 空快照 ⇒ 没有分组。
+ const filteredOut = buildAdminNav({ panels: [], entry: ENTRY });
+ eq(filteredOut.length, 5, "被后端过滤掉的插件在侧边栏里完全不存在(不出现,且不显示「已禁用」灰条)");
+}
+
+// ── ⑤ 后端下发的 route / 分组标题优先,前端不另立一份口径 ────────────────────────
+{
+ const wired = buildAdminNav({
+ panels: [panel({ route: "plugin/web-search/providers", groupTitle: "插件" })],
+ entry: ENTRY,
+ });
+ eq(wired[wired.length - 1].label, "插件", "分组显示名用后端下发的 groupTitle");
+ eq(wired[wired.length - 1].items[0].path, "/admin/plugin/web-search/providers", "路径照后端下发的 route 拼(前缀只有前端知道)");
+
+ const custom = buildAdminNav({ panels: [panel({ groupTitle: "第三方集成" })], entry: ENTRY });
+ eq(custom[custom.length - 1].label, "第三方集成", "后端换了分组文案,前端跟着变(不改代码)");
+
+ const hostile = buildAdminNav({
+ panels: [panel({ route: "../../../etc/passwd" }), panel({ pluginId: "p2", id: "b", route: "https://evil.example/x" })],
+ entry: ENTRY,
+ });
+ const paths = hostile[hostile.length - 1].items.map((i) => i.path);
+ eq(paths[0], "/admin", "带 .. 的 route 被挡下(回落到面板根,声明不许把导航带出面板)");
+ eq(paths[1], "/admin", "带协议的 route 同样被挡下");
+}
+
+// ── ⑤ 稳定序:order 升序,同 order 按声明顺序(导航不许抖) ──────────────────────
+{
+ const list = [
+ panel({ pluginId: "p", id: "c", order: 30 }),
+ panel({ pluginId: "p", id: "a", order: 10 }),
+ panel({ pluginId: "p", id: "b", order: 10 }),
+ ];
+ eq(sortPanelEntries(list).map((p) => p.id).join(","), "a,b,c", "同 order 按声明序,小的 order 在前");
+ eq(sortPanelEntries([...list].reverse()).map((p) => p.id).join(","), "b,a,c", "同 order 时顺序跟着声明走(不按 map 迭代抖)");
+ const nav = buildAdminNav({ panels: list, entry: ENTRY });
+ eq(nav[nav.length - 1].items.map((i) => i.panelId).join(","), "a,b,c", "分组内顺序 = 稳定序");
+
+ // 多分组:按组内最小 order 排组。
+ const twoGroups = buildAdminNav({
+ panels: [panel({ pluginId: "z", id: "zz", group: "plugins", order: 50 }), panel({ pluginId: "a", id: "aa", group: "other", order: 5 })],
+ entry: ENTRY,
+ });
+ eq(twoGroups.length, 7, "两个插件分组各占一个分组");
+ eq(twoGroups[5].label, "other", "组内最小 order 小的分组在前");
+ eq(twoGroups[6].label, PLUGIN_GROUP_LABEL, "未登记的分组 id 用原样文案(不猜译名)");
+}
+
+// ── ⑥ 用户端(无入口注入)不会解析出任何面板路由 ────────────────────────────────
+{
+ eq(readAdminEntry(), null, "node 下没有 window ⇒ 读不到入口信息");
+ const route = parseAdminRoute("/admin/plugin/a/b", null);
+ eq(route.admin, false, "无入口信息 ⇒ 用户端永远不会被路径名带进面板");
+ eq(route.pluginTarget, null, "也不会解析出插件目标");
+}
+
+console.log(`admin nav model test: ${checks} checks passed`);
diff --git a/frontend/web/src/admin/navModel.ts b/frontend/web/src/admin/navModel.ts
new file mode 100644
index 0000000..c30a4f5
--- /dev/null
+++ b/frontend/web/src/admin/navModel.ts
@@ -0,0 +1,109 @@
+/** 侧边栏导航模型:**核心模块分组 + 插件分组**两段合成(design/10 §3 的导航注)。
+ *
+ * 为什么要有这一层:侧边栏原先直接遍历 `ADMIN_SECTIONS` 的静态清单。插件面板的条目来自
+ * 后端快照(`GET /api/v1/admin/panels`),静态清单里**没有也不需要**有它们。把「合并」
+ * 收敛到一个纯函数里,验收判据就成了可执行断言:
+ *
+ * > 新增一个插件页面,不得改动 `sections.tsx` / `router.ts` / `AdminApp.tsx` 的静态清单。
+ *
+ * —— 本文件只读那三处的**常量**,插件条目一律从 `panels` 参数来(见 `navModel.test.ts`)。
+ */
+
+import type { IconName } from "../components/Icon";
+import { ADMIN_GROUPS, ADMIN_SECTIONS } from "./sections";
+import { adminSectionPath, type AdminEntry, type AdminSectionId, type PluginTarget } from "./router";
+import {
+ PLUGIN_GROUP_ID,
+ PLUGIN_GROUP_LABEL,
+ panelIconName,
+ panelNavKey,
+ panelPathOf,
+ sortPanelEntries,
+} from "./plugin/nav";
+import { visiblePanelEntries } from "./plugin/when";
+import type { PanelEntryDto } from "./plugin/types";
+
+export interface AdminNavItem {
+ /** 侧边栏条目的稳定 key,同时用作「当前项」判定(避免比较易变的对象/路径)。 */
+ key: string;
+ label: string;
+ icon: IconName;
+ /** 目标绝对路径(挂载前缀由 `router.ts` 单点决定)。 */
+ path: string;
+ /** 插件条目才有:声明它的插件与面板 id。 */
+ pluginId?: string;
+ panelId?: string;
+}
+
+export interface AdminNavGroup {
+ key: string;
+ label: string;
+ items: AdminNavItem[];
+}
+
+/** 核心模块条目的 key。 */
+export function sectionNavKey(section: AdminSectionId): string {
+ return `section:${section}`;
+}
+
+/** 当前高亮条目的 key(核心模块与插件页共用一条判定路径)。 */
+export function activeNavKey(section: AdminSectionId | undefined, target: PluginTarget | null): string {
+ if (target) return panelNavKey(target.pluginId, target.panelId);
+ return section ? sectionNavKey(section) : "";
+}
+
+/** 合成侧边栏模型:核心分组(静态,按 `ADMIN_SECTIONS` 顺序)+ 插件分组(动态)。 */
+export function buildAdminNav(opts: {
+ panels: readonly PanelEntryDto[];
+ entry: AdminEntry | null;
+}): AdminNavGroup[] {
+ const groups: AdminNavGroup[] = ADMIN_GROUPS.map((label) => ({
+ key: `core:${label}`,
+ label,
+ items: ADMIN_SECTIONS.filter((s) => s.group === label).map((s) => ({
+ key: sectionNavKey(s.id),
+ label: s.label,
+ icon: s.icon,
+ path: adminSectionPath(s.id, opts.entry),
+ })),
+ }));
+
+ // 插件分组:分组 id 来自声明/快照(本期闭集只有 plugins),显示名优先用后端下发的
+ // groupTitle;组间按组内**最小 order** 排(照 §5.2.1 nav.group 消费 order 推导),
+ // 组内按 order 稳定序 —— 对后端已经排好的快照这是幂等的,对宽容形状则给出确定序。
+ const buckets = new Map();
+ for (const panel of sortPanelEntries(visiblePanelEntries(opts.panels))) {
+ const groupId = (panel.group ?? "").trim() === "" ? PLUGIN_GROUP_ID : (panel.group ?? "").trim();
+ const label =
+ (panel.groupTitle ?? "").trim() !== ""
+ ? (panel.groupTitle ?? "").trim()
+ : groupId === PLUGIN_GROUP_ID
+ ? PLUGIN_GROUP_LABEL
+ : groupId;
+ const order = panel.order ?? 0;
+ const bucket = buckets.get(groupId);
+ const item: AdminNavItem = {
+ key: panelNavKey(panel.pluginId, panel.id),
+ label: panel.title,
+ icon: panelIconName(panel.icon),
+ path: panelPathOf(panel, opts.entry),
+ pluginId: panel.pluginId,
+ panelId: panel.id,
+ };
+ if (bucket) {
+ bucket.order = Math.min(bucket.order, order);
+ bucket.items.push(item);
+ } else {
+ buckets.set(groupId, { label, order, items: [item] });
+ }
+ }
+ for (const [groupId, bucket] of [...buckets.entries()].sort((a, b) => a[1].order - b[1].order)) {
+ groups.push({ key: `plugin-group:${groupId}`, label: bucket.label, items: bucket.items });
+ }
+ return groups;
+}
+
+/** 该分组里是否含当前项(含则强制展开:折叠着当前页 = 用户看不到自己在哪)。 */
+export function groupHasActive(group: AdminNavGroup, activeKey: string): boolean {
+ return group.items.some((item) => item.key === activeKey);
+}
diff --git a/frontend/web/src/admin/plugin/FormFields.tsx b/frontend/web/src/admin/plugin/FormFields.tsx
new file mode 100644
index 0000000..b48a631
--- /dev/null
+++ b/frontend/web/src/admin/plugin/FormFields.tsx
@@ -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;
+ required: readonly string[];
+ missing: ReadonlySet;
+ configured: ReadonlySet;
+ cleared: ReadonlySet;
+ options: Record;
+ optionErrors: Record;
+ 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 (
+
+ {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 (
+
+ onToggleClear(f.key, next)}
+ />
+ }
+ >
+ onValue(f.key, next)}
+ />
+
+
+ );
+ })}
+
+ );
+}
diff --git a/frontend/web/src/admin/plugin/FormSection.tsx b/frontend/web/src/admin/plugin/FormSection.tsx
new file mode 100644
index 0000000..2a2b8c6
--- /dev/null
+++ b/frontend/web/src/admin/plugin/FormSection.tsx
@@ -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 ? {flash.code} : null}
+
+ {flash.text}
+
+ >
+ );
+}
+
+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 | 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 (
+
+
+ 当前角色没有写权限,本区块的表单不显示(写动作一律 owner / admin)。
+
+ );
+ }
+
+ return (
+
+
+ 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 ? 该表单没有声明任何字段。 : null}
+
+
+ {busy ? "提交中…" : "保存"}
+
+ {action === "" ? 声明缺少 submit.action,无法提交。 : null}
+
+
+
+ );
+}
diff --git a/frontend/web/src/admin/plugin/PluginPanel.render.test.ts b/frontend/web/src/admin/plugin/PluginPanel.render.test.ts
new file mode 100644
index 0000000..8a824e2
--- /dev/null
+++ b/frontend/web/src/admin/plugin/PluginPanel.render.test.ts
@@ -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, ">必填"), "必填字段有标记");
+ ok(has(html, "https://..."), "placeholder 生效");
+ ok(has(html, ">保存") || 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, ">只读"), "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, ">刷新"), "每个取数区块都有显式刷新");
+}
+
+// ── ② 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`);
diff --git a/frontend/web/src/admin/plugin/PluginPanel.tsx b/frontend/web/src/admin/plugin/PluginPanel.tsx
new file mode 100644
index 0000000..71db7d1
--- /dev/null
+++ b/frontend/web/src/admin/plugin/PluginPanel.tsx
@@ -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 | null;
+ onRowValues?: (values: Record) => void;
+}): ReactNode {
+ switch (section.type) {
+ case "form":
+ return (
+
+ );
+ case "table":
+ return (
+
+ );
+ case "status":
+ return ;
+ case "log":
+ return ;
+ default: {
+ // 解码已丢掉闭集外的类型,这里兜的是「契约先行、前端滞后」的一档。
+ const unknownType: string = String((section as { type?: unknown }).type ?? "?");
+ return 本版本不支持区块类型「{unknownType}」,未渲染。 ;
+ }
+ }
+}
+
+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 | 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 (
+
+
{panel.pluginId}}
+ />
+ {sections.map((section) => (
+ setPrefill(values)}
+ />
+ ))}
+ {sections.length === 0 ? (
+
+ 该面板没有可显示的区块(可能只声明了写表单,而当前角色是只读)。
+
+ ) : null}
+
+ );
+}
+
+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 (
+
+ );
+ }
+ const panel = findPanelEntry(panels, target);
+ if (panel) return ;
+ if (loading) return ;
+ if (error) {
+ return (
+
+ 读取插件面板失败:{error}
+
+ 重试
+
+
+ );
+ }
+ return (
+
+ );
+}
diff --git a/frontend/web/src/admin/plugin/ReadOnlySections.tsx b/frontend/web/src/admin/plugin/ReadOnlySections.tsx
new file mode 100644
index 0000000..c84cccf
--- /dev/null
+++ b/frontend/web/src/admin/plugin/ReadOnlySections.tsx
@@ -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 | null;
+ /** 原始响应(log 用:日志行形状与事实不同,各取所需)。 */
+ raw: unknown;
+ loading: boolean;
+ error: string | null;
+ load: () => void;
+}
+
+/** 拉一次只读数据(status 的事实 / log 的行),带刷新。 */
+function useSectionData(pluginId: string, action: string): SectionDataState {
+ const [raw, setRaw] = useState(null);
+ const [facts, setFacts] = useState | null>(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(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 (
+
+
+ 刷新
+
+ }
+ />
+ {error ? {error} : null}
+ {loading && !facts ? 加载中… : null}
+ {!loading && facts !== null && keys.length === 0 ? 该事实面没有可显示的项目。 : null}
+
+ {keys.map((key) => {
+ const field = fields.find((f) => f.key === key);
+ const declared: PanelFieldDto = field ?? { key, type: "text" };
+ return ;
+ })}
+
+
+ );
+}
+
+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 (
+
+
+ 刷新
+
+ }
+ />
+ {error ? {error} : null}
+ {loading && lines.length === 0 ? 加载中… : null}
+ {!loading && lines.length === 0 ? 暂无日志。 : null}
+ {lines.map((line, i) => (
+
+ {line.time ? {line.time} : null}
+ {line.text}
+
+ ))}
+
+ );
+}
diff --git a/frontend/web/src/admin/plugin/SectionHeader.tsx b/frontend/web/src/admin/plugin/SectionHeader.tsx
new file mode 100644
index 0000000..adb9572
--- /dev/null
+++ b/frontend/web/src/admin/plugin/SectionHeader.tsx
@@ -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 (
+
+
{title}
+ {hint ?
{hint} : null}
+
{actions}
+
+ );
+}
diff --git a/frontend/web/src/admin/plugin/TableSection.tsx b/frontend/web/src/admin/plugin/TableSection.tsx
new file mode 100644
index 0000000..c172af9
--- /dev/null
+++ b/frontend/web/src/admin/plugin/TableSection.tsx
@@ -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 (
+
+ {flash.code ? {flash.code} : null}
+
+ {flash.text}
+
+
+ );
+}
+
+export interface PanelTableSectionProps {
+ pluginId: string;
+ section: PanelSectionDto;
+ role: Role | null;
+ onReload: () => void;
+ /** 行内动作返回显式值对象时回调(reauth「把该条带回表单」,见 `formValuesOfResult`)。 */
+ onValues?: (values: Record) => 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[] = (section.columns ?? []).map((c) => ({
+ key: c.key,
+ title: c.label ?? c.key,
+ render: (row) => {cellText(row.data[c.key])} ,
+ }));
+ if (canAct) {
+ columns.push({
+ key: "__actions",
+ title: "操作",
+ render: (row) => (
+
+ {rowActions.map((a) => (
+ run(a.action, a.label, row.data)}>
+ {a.label}
+
+ ))}
+
+ ),
+ });
+ }
+
+ return (
+
+
+ 刷新
+
+ }
+ />
+
+ {!canWrite(role) ? 当前角色只读:表格可见,写动作(行内按钮)不显示。 : null}
+ r.key} emptyTitle="暂无数据" />
+
+ );
+}
diff --git a/frontend/web/src/admin/plugin/actions.ts b/frontend/web/src/admin/plugin/actions.ts
new file mode 100644
index 0000000..756515f
--- /dev/null
+++ b/frontend/web/src/admin/plugin/actions.ts
@@ -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:`,可带点分层级)→ 宿主动作名。
+ *
+ * 设计只规定「只允许 `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}`;
+}
diff --git a/frontend/web/src/admin/plugin/formState.ts b/frontend/web/src/admin/plugin/formState.ts
new file mode 100644
index 0000000..2836f0f
--- /dev/null
+++ b/frontend/web/src/admin/plugin/formState.ts
@@ -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;
+ /** 打开表单时的值:判断「用户把盘上已有值清空了」必须知道它原本是什么。 */
+ initial: Record;
+ /** 盘上已有值的键(含只有掩码可看的秘密)。 */
+ configured: Set;
+ /** 显式勾选清除的键(掩码秘密唯一能清除的方式)。 */
+ cleared: Set;
+ /** 本次提交被拦下时点名的必填字段。 */
+ missing: Set;
+ 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,
+): FormValuesState {
+ const externalKey = JSON.stringify(external);
+ const loadedKey = `${fieldsKey}|${externalKey}`;
+ const loaded = useMemo(() => loadFormValues(fields, external), [loadedKey]);
+ const [values, setValues] = useState>(loaded.values);
+ const [initial, setInitial] = useState>(loaded.values);
+ const [configured, setConfigured] = useState(loaded.configured);
+ const [cleared, setCleared] = useState([]);
+ const [missing, setMissing] = useState([]);
+ 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;
+ /** `optionsFrom` → 失败原因(动作名 + 错误码)。取不到不是致命错误,但**要说出来**。 */
+ optionErrors: Record;
+}
+
+/** 下拉候选:按 `optionsFrom` 逐条取一次(取不到就降级成文本框,由调用方在说明里写明)。 */
+export function useFieldOptions(pluginId: string, declared: readonly PanelFieldDto[]): FieldOptionsState {
+ const [options, setOptions] = useState>({});
+ const [optionErrors, setOptionErrors] = useState>({});
+ 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(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 };
+}
diff --git a/frontend/web/src/admin/plugin/nav.ts b/frontend/web/src/admin/plugin/nav.ts
new file mode 100644
index 0000000..14795d3
--- /dev/null
+++ b/frontend/web/src/admin/plugin/nav.ts
@@ -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`,而不是画一个空 ``
+ * (不知名的图标会渲染成空白,那比回落更难排查)。 */
+const ICON_ALLOW: Readonly> = {
+ 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:`,插件页用 `plugin:/`)。 */
+export function panelNavKey(pluginId: string, panelId: string): string {
+ return `plugin:${pluginId}/${panelId}`;
+}
+
+/** 条目的绝对路径:**优先用后端下发的 `route`**(路由口径由后端单点决定),
+ * 没给才按 `/plugin//` 自己拼。 */
+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));
+}
diff --git a/frontend/web/src/admin/plugin/panelsApi.ts b/frontend/web/src/admin/plugin/panelsApi.ts
new file mode 100644
index 0000000..19b0a5e
--- /dev/null
+++ b/frontend/web/src/admin/plugin/panelsApi.ts
@@ -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;
+ /** 行内动作所在的行(表格行动作把它带回去,插件才知道操作的是哪一条)。 */
+ row?: Record;
+}
+
+/** 面板快照。未接线(404/501)⇒ 空快照;其它错误抛出(调用方如实显示)。 */
+export function fetchAdminPanels(): Promise {
+ return request(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 {
+ return request(pluginActionPath(pluginId, action), {
+ method: "POST",
+ body: JSON.stringify(payload),
+ });
+}
diff --git a/frontend/web/src/admin/plugin/pluginData.test.ts b/frontend/web/src/admin/plugin/pluginData.test.ts
new file mode 100644
index 0000000..9640a3f
--- /dev/null
+++ b/frontend/web/src/admin/plugin/pluginData.test.ts
@@ -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`);
diff --git a/frontend/web/src/admin/plugin/route.ts b/frontend/web/src/admin/plugin/route.ts
new file mode 100644
index 0000000..cbaa4dc
--- /dev/null
+++ b/frontend/web/src/admin/plugin/route.ts
@@ -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;
+}
diff --git a/frontend/web/src/admin/plugin/tableState.ts b/frontend/web/src/admin/plugin/tableState.ts
new file mode 100644
index 0000000..040e10f
--- /dev/null
+++ b/frontend/web/src/admin/plugin/tableState.ts
@@ -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;
+}
+
+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, 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(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(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) => void;
+}
+
+export interface RowActionRunnerArgs {
+ pluginId: string;
+ onReload: () => void;
+ /** 行动作结束后重取本表。 */
+ reloadRows: () => void;
+ log: (what: string, result: "ok" | "error") => void;
+ /** 返回显式值对象时交给表单(reauth「把该条带回表单」)。 */
+ onValues?: (values: Record) => void;
+}
+
+/** 行内动作:一次一个在跑(`busy` 非空即禁用全部按钮,避免同一行被点两次)。 */
+export function useRowActionRunner({ pluginId, onReload, reloadRows, log, onValues }: RowActionRunnerArgs): RowActionRunner {
+ const [busy, setBusy] = useState(null);
+ const [flash, setFlash] = useState(null);
+
+ const run = useCallback(
+ (actionName: string, label: string, row: Record) => {
+ 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 };
+}
diff --git a/frontend/web/src/admin/plugin/types.ts b/frontend/web/src/admin/plugin/types.ts
new file mode 100644
index 0000000..78f385c
--- /dev/null
+++ b/frontend/web/src/admin/plugin/types.ts
@@ -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//`,所以必须随条目下发;
+ * - `route`:后端给的**相对路由片段**(`plugin//`,不含 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;
+}
+
+export interface PanelEntryDto {
+ pluginId: string;
+ id: string;
+ title: string;
+ description?: string;
+ /** 侧边栏分组 id(**分组形状**的快照里由外层 group 给出;平铺形状才逐条带)。 */
+ group?: string;
+ /** 分组的本地化显示名(后端 `panelGroupTitle` 给的宿主文案)。 */
+ groupTitle?: string;
+ order?: number;
+ icon?: string;
+ /** 后端给的**相对路由片段**(`plugin//`,不含 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 {
+ 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((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();
+ 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[] {
+ 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 | 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 {
+ 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;
+}
diff --git a/frontend/web/src/admin/plugin/usePanels.ts b/frontend/web/src/admin/plugin/usePanels.ts
new file mode 100644
index 0000000..680140a
--- /dev/null
+++ b/frontend/web/src/admin/plugin/usePanels.ts
@@ -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([]));
+ const { data, loading, error, reload } = useAsyncData(load, [token]);
+ useAutoReload(reload, token ? PANELS_POLL_MS : null);
+ return { panels: data ?? [], loading, error, reload };
+}
diff --git a/frontend/web/src/admin/plugin/when.ts b/frontend/web/src/admin/plugin/when.ts
new file mode 100644
index 0000000..23fe091
--- /dev/null
+++ b/frontend/web/src/admin/plugin/when.ts
@@ -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);
+}
diff --git a/frontend/web/src/admin/router.ts b/frontend/web/src/admin/router.ts
index 36dc6c0..a23a38e 100644
--- a/frontend/web/src/admin/router.ts
+++ b/frontend/web/src/admin/router.ts
@@ -31,9 +31,24 @@ export type AdminSectionId =
| "system"
| "users";
+/** 插件页目标:`/admin/plugin//`(design/09 §5.7 C)。
+ *
+ * 它是**动态段**:`pluginId` / `panelId` 来自后端快照(`GET /api/v1/admin/panels`),
+ * 永远不进上面的静态白名单 —— 新增一个插件页面因此不需要改动本文件、`sections.tsx`
+ * 或 `AdminApp.tsx`(design/09 §5.7 D 的可测判据)。 */
+export interface PluginTarget {
+ pluginId: string;
+ panelId: string;
+}
+
+/** 面板路由:核心模块(`section`)或插件页(`pluginTarget`),二者必有一个。
+ *
+ * `section` 对插件页是 `undefined` —— 没有给插件页伪造一个核心模块 id,也就没有把
+ * 插件 id 塞进静态联合类型。`ADMIN_SECTIONS` / `VALID_SECTIONS` 因此保持静态。 */
export interface AdminRoute {
admin: boolean;
- section: AdminSectionId;
+ section: AdminSectionId | undefined;
+ pluginTarget: PluginTarget | null;
}
/** 后端注入的面板入口信息:只有面板入口那一份 index.html 带它。 */
@@ -84,20 +99,74 @@ export function readAdminEntry(): AdminEntry | null {
* 把 pathname 解析成面板路由:前缀不匹配(或压根没有入口信息,即用户端)
* 一律 `admin: false`,因此用户端永远不会被路径名带进面板;未知的 section
* 段回落到 dashboard。
+ *
+ * `/<前缀>/plugin//` 是**插件页**:它先于静态白名单判定,
+ * 两段齐全才算命中,缺段一律回落 dashboard(不给一个「半条路由」)。
*/
export function parseAdminRoute(pathname: string, entry: AdminEntry | null): AdminRoute {
const base = entryBase(entry);
- if (!base) return { admin: false, section: "dashboard" };
+ if (!base) return { admin: false, section: "dashboard", pluginTarget: null };
if (pathname !== base && !pathname.startsWith(`${base}/`)) {
- return { admin: false, section: "dashboard" };
+ return { admin: false, section: "dashboard", pluginTarget: null };
+ }
+ const segments = pathname.slice(base.length).replace(/^\/+/, "").split("/").filter((s) => s !== "");
+ const rest = segments[0] ?? "";
+ if (rest === PLUGIN_ROUTE_SEGMENT) {
+ const target = pluginTargetFromSegments(segments);
+ if (target) return { admin: true, section: undefined, pluginTarget: target };
+ return { admin: true, section: "dashboard", pluginTarget: null };
}
- const rest = pathname.slice(base.length).replace(/^\/+/, "").split("/")[0] ?? "";
return {
admin: true,
section: VALID_SECTIONS.has(rest) ? (rest as AdminSectionId) : "dashboard",
+ pluginTarget: null,
};
}
+/** 插件页的路径段(宿主段,插件 id 从不进静态白名单)。 */
+const PLUGIN_ROUTE_SEGMENT = "plugin";
+
+/** 从已切分的路径段取插件目标(解码 URL 转义;两段都必须非空)。 */
+export function pluginTargetFromSegments(segments: readonly string[]): PluginTarget | null {
+ if (segments[0] !== PLUGIN_ROUTE_SEGMENT) return null;
+ const pluginId = decodeSegment(segments[1]);
+ const panelId = decodeSegment(segments[2]);
+ if (pluginId === "" || panelId === "") return null;
+ return { pluginId, panelId };
+}
+
+function decodeSegment(raw: string | undefined): string {
+ if (!raw) return "";
+ try {
+ return decodeURIComponent(raw);
+ } catch {
+ // 非法转义(如孤立的 %):按原样用,别让一个坏链接把整页带崩。
+ return raw;
+ }
+}
+
+/** 插件页的绝对路径。 */
+export function adminPluginPath(pluginId: string, panelId: string, entry: AdminEntry | null): string {
+ const base = entryBase(entry);
+ if (!base) return "/";
+ return `${base}/${PLUGIN_ROUTE_SEGMENT}/${encodeURIComponent(pluginId)}/${encodeURIComponent(panelId)}`;
+}
+
+/** 把**后端下发的相对路由片段**(如 `plugin/web-search/providers`)拼成绝对路径。
+ *
+ * 为什么要有它:路由口径属于后端(`admin_panels.go` 的 `AdminPanelItem.Route` 明说不含
+ * ADMIN_PATH 前缀,前缀只有前端与入口注入页知道),前端照抄即可 —— 自己再算一份就多出
+ * 一处会与后端分叉的约定。片段非法(空 / 含协议 / 想跳到别处)时回落到面板根,
+ * 绝不让一份声明把面板导航带出面板。 */
+export function adminRoutePath(route: string, entry: AdminEntry | null): string {
+ const base = entryBase(entry);
+ const rel = route.trim().replace(/^\/+/, "");
+ if (!base || rel === "" || rel.includes("://") || rel.startsWith("..") || rel.includes("//")) {
+ return base === "" ? "/" : base;
+ }
+ return `${base}/${rel}`;
+}
+
/** 某个 section 的绝对路径(面板根 = 前缀本身)。 */
export function adminSectionPath(section: AdminSectionId, entry: AdminEntry | null): string {
const base = entryBase(entry);
@@ -128,6 +197,17 @@ export function navigateAdmin(section: AdminSectionId): void {
navigateTo(adminSectionPath(section, readAdminEntry()));
}
+/** 跳转到某个插件页(侧边栏的插件分组条目用它;路由是动态段,见 `PluginTarget`)。 */
+export function navigatePluginPanel(pluginId: string, panelId: string): void {
+ navigateTo(adminPluginPath(pluginId, panelId, readAdminEntry()));
+}
+
+/** 按**绝对路径**跳转:导航模型里每条条目都自带 path(见 `navModel.ts`),
+ * 侧边栏因此不必知道「这条是核心模块还是插件页」。 */
+export function navigateAdminPath(path: string): void {
+ navigateTo(path);
+}
+
/** 离开面板回到用户端根路径(面板外壳的「返回用户端」用它)。 */
export function navigateUserShell(): void {
navigateTo("/");
diff --git a/frontend/web/src/admin/schema/controls.tsx b/frontend/web/src/admin/schema/controls.tsx
new file mode 100644
index 0000000..a7654f1
--- /dev/null
+++ b/frontend/web/src/admin/schema/controls.tsx
@@ -0,0 +1,153 @@
+/** 八种字段控件的实现(闭集,§5.2:不许插件自定义渲染器)。
+ *
+ * 拆到单独文件是因为 `SchemaControl` 的分发器一旦把八个分支平铺进来就超过 80 行的函数上限
+ * (AGENTS §10);这里每个控件都是「一个类型、十来行」,分发器留在 `fields.tsx`。
+ */
+
+import { Switch } from "../../components/primitives";
+import type { SchemaField, SchemaOption } from "./fieldTypes";
+import { fieldLabel } from "./values";
+
+export const inputCls =
+ "w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]";
+
+export const inputClsError =
+ "w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none ring-1 ring-[var(--mu-err)] focus:ring-[var(--mu-err)]";
+
+export interface ControlProps {
+ field: SchemaField;
+ value: string;
+ onChange: (next: string) => void;
+ /** 被显式清除的字段:输入框禁用(打进去也会被清除覆盖,索性不让打)。 */
+ disabled: boolean;
+ /** 必填缺失 ⇒ 红框。 */
+ invalid: boolean;
+ /** 下拉候选(缺省用 `field.options`)。 */
+ options?: readonly SchemaOption[];
+}
+
+export function TextControl({ field, value, onChange, disabled, invalid }: ControlProps): React.ReactNode {
+ return (
+ onChange(e.target.value)}
+ placeholder={field.placeholder}
+ disabled={disabled}
+ aria-label={fieldLabel(field)}
+ className={invalid ? inputClsError : inputCls}
+ />
+ );
+}
+
+export function SecretControl({ field, value, onChange, disabled, invalid }: ControlProps): React.ReactNode {
+ return (
+ onChange(e.target.value)}
+ placeholder={field.placeholder}
+ disabled={disabled}
+ aria-label={fieldLabel(field)}
+ autoComplete="new-password"
+ className={invalid ? inputClsError : inputCls}
+ />
+ );
+}
+
+export function NumberControl({ field, value, onChange, disabled, invalid }: ControlProps): React.ReactNode {
+ return (
+ onChange(e.target.value)}
+ placeholder={field.placeholder}
+ disabled={disabled}
+ aria-label={fieldLabel(field)}
+ className={invalid ? inputClsError : inputCls}
+ />
+ );
+}
+
+export function DateControl({ field, value, onChange, disabled, invalid }: ControlProps): React.ReactNode {
+ return (
+ onChange(e.target.value)}
+ disabled={disabled}
+ aria-label={fieldLabel(field)}
+ className={invalid ? inputClsError : inputCls}
+ />
+ );
+}
+
+export function SwitchControl({ field, value, onChange, disabled }: ControlProps): React.ReactNode {
+ void field;
+ return (
+
+ onChange(next ? "true" : "false")} />
+
+ );
+}
+
+/** 下拉:有候选才给下拉。自由输入一个未知值会被后端静默回落默认(值域受后端归一化约束),
+ * 没有候选时降级成文本框(调用方会在字段说明里写明原因),而不是给一个空下拉。 */
+export function SelectControl({ field, value, onChange, disabled, invalid, options }: ControlProps): React.ReactNode {
+ const opts = options ?? field.options ?? [];
+ if (opts.length === 0) return ;
+ return (
+ onChange(e.target.value)}
+ aria-label={fieldLabel(field)}
+ disabled={disabled}
+ className={invalid ? inputClsError : inputCls}
+ >
+ {value !== "" && !opts.some((o) => o.value === value) ? (
+ {value}(不在候选内,保存时原样保留)
+ ) : null}
+ {opts.map((o) => (
+
+ {o.label}
+
+ ))}
+
+ );
+}
+
+/** 多选:逗号分隔的文本框,占位符列出候选(候选多时下拉反而难选,且候选可能为空)。 */
+export function MultiSelectControl({ field, value, onChange, disabled, invalid, options }: ControlProps): React.ReactNode {
+ const opts = options ?? field.options ?? [];
+ const placeholder = opts.length > 0 ? opts.map((o) => o.value).join(", ") : field.placeholder;
+ return (
+ onChange(e.target.value)}
+ placeholder={placeholder}
+ disabled={disabled}
+ aria-label={fieldLabel(field)}
+ className={invalid ? inputClsError : inputCls}
+ />
+ );
+}
+
+/** 键值对(如自定义 header):JSON 对象文本。子形状设计未定(PLG-1 报告 §2 ⑥),
+ * 取 JSON 的好处是键值都能是任意值、且与「额外 config」那处既有先例一致。 */
+export function KvControl({ field, value, onChange, disabled, invalid }: ControlProps): React.ReactNode {
+ return (
+