From 9e63af90637ad244d447105b220bafba6de2ca0f Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 6 Oct 2026 09:04:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E5=89=8D=E7=AB=AF=E5=8F=AF?= =?UTF-8?q?=E6=8E=A5=E5=85=A5=E7=82=B9=20=E2=80=94=E2=80=94=20=E6=8A=98?= =?UTF-8?q?=E5=8F=A0=E5=88=86=E7=BB=84=20+=20=E5=8A=A8=E6=80=81=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E9=A1=B5=20+=20schema=20=E6=B8=B2=E6=9F=93=EF=BC=88PL?= =?UTF-8?q?G-3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sections.tsx 的分组升级为可折叠(CollapsibleGroup);新增 plugins 分组由 GET /api/v1/admin/panels 动态填充(navModel.ts 纯函数可单测) - 插件页走动态段 /admin/plugin//,不进 router.ts 的静态白名单 - 把既有三段式(字段声明表 + 纯函数规则 + 渲染器,含「留空=不改」掩码语义)泛化成 schema → 控件(src/admin/schema/),复用它渲染插件表单/表格/事实面/日志 - 可测判据:新增插件页不得改动 sections.tsx / router.ts / AdminApp.tsx 的静态清单 - when 不满足 ⇒ 入口不出现;viewer 只看到只读区块 我的复跑:npx tsc --noEmit 通过;npx vite build 通过(3.67s);50 个前端脚本式测试全部 打印 checks passed(含本片新增 pluginData 90 / PluginPanel.render 55 / navModel 62 / schema values 40)。 --- frontend/web/src/admin/AdminApp.tsx | 117 ++++-- frontend/web/src/admin/CollapsibleGroup.tsx | 61 +++ .../src/admin/modules/channelConnFields.tsx | 169 +++----- frontend/web/src/admin/navModel.test.ts | 234 +++++++++++ frontend/web/src/admin/navModel.ts | 109 +++++ frontend/web/src/admin/plugin/FormFields.tsx | 92 +++++ frontend/web/src/admin/plugin/FormSection.tsx | 114 ++++++ .../admin/plugin/PluginPanel.render.test.ts | 307 ++++++++++++++ frontend/web/src/admin/plugin/PluginPanel.tsx | 153 +++++++ .../web/src/admin/plugin/ReadOnlySections.tsx | 142 +++++++ .../web/src/admin/plugin/SectionHeader.tsx | 23 ++ .../web/src/admin/plugin/TableSection.tsx | 89 +++++ frontend/web/src/admin/plugin/actions.ts | 31 ++ frontend/web/src/admin/plugin/formState.ts | 183 +++++++++ frontend/web/src/admin/plugin/nav.ts | 115 ++++++ frontend/web/src/admin/plugin/panelsApi.ts | 55 +++ .../web/src/admin/plugin/pluginData.test.ts | 255 ++++++++++++ frontend/web/src/admin/plugin/route.ts | 33 ++ frontend/web/src/admin/plugin/tableState.ts | 134 +++++++ frontend/web/src/admin/plugin/types.ts | 373 ++++++++++++++++++ frontend/web/src/admin/plugin/usePanels.ts | 37 ++ frontend/web/src/admin/plugin/when.ts | 36 ++ frontend/web/src/admin/router.ts | 88 ++++- frontend/web/src/admin/schema/controls.tsx | 153 +++++++ frontend/web/src/admin/schema/fieldTypes.ts | 47 +++ frontend/web/src/admin/schema/fields.tsx | 160 ++++++++ frontend/web/src/admin/schema/values.test.ts | 163 ++++++++ frontend/web/src/admin/schema/values.ts | 192 +++++++++ frontend/web/src/admin/sections.tsx | 8 +- 29 files changed, 3527 insertions(+), 146 deletions(-) create mode 100644 frontend/web/src/admin/CollapsibleGroup.tsx create mode 100644 frontend/web/src/admin/navModel.test.ts create mode 100644 frontend/web/src/admin/navModel.ts create mode 100644 frontend/web/src/admin/plugin/FormFields.tsx create mode 100644 frontend/web/src/admin/plugin/FormSection.tsx create mode 100644 frontend/web/src/admin/plugin/PluginPanel.render.test.ts create mode 100644 frontend/web/src/admin/plugin/PluginPanel.tsx create mode 100644 frontend/web/src/admin/plugin/ReadOnlySections.tsx create mode 100644 frontend/web/src/admin/plugin/SectionHeader.tsx create mode 100644 frontend/web/src/admin/plugin/TableSection.tsx create mode 100644 frontend/web/src/admin/plugin/actions.ts create mode 100644 frontend/web/src/admin/plugin/formState.ts create mode 100644 frontend/web/src/admin/plugin/nav.ts create mode 100644 frontend/web/src/admin/plugin/panelsApi.ts create mode 100644 frontend/web/src/admin/plugin/pluginData.test.ts create mode 100644 frontend/web/src/admin/plugin/route.ts create mode 100644 frontend/web/src/admin/plugin/tableState.ts create mode 100644 frontend/web/src/admin/plugin/types.ts create mode 100644 frontend/web/src/admin/plugin/usePanels.ts create mode 100644 frontend/web/src/admin/plugin/when.ts create mode 100644 frontend/web/src/admin/schema/controls.tsx create mode 100644 frontend/web/src/admin/schema/fieldTypes.ts create mode 100644 frontend/web/src/admin/schema/fields.tsx create mode 100644 frontend/web/src/admin/schema/values.test.ts create mode 100644 frontend/web/src/admin/schema/values.ts 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) => ( ))} -
+ ))} ); } /** 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 ( ); } /** 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
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 ( +
+ + {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)} - 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} +
+ + {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) => ( + + ))} + + ), + }); + } + + 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(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 ( + + ); +} + +/** 多选:逗号分隔的文本框,占位符列出候选(候选多时下拉反而难选,且候选可能为空)。 */ +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 ( +