feat(admin): 前端可接入点 —— 折叠分组 + 动态插件页 + schema 渲染(PLG-3)
- sections.tsx 的分组升级为可折叠(CollapsibleGroup);新增 plugins 分组由 GET /api/v1/admin/panels 动态填充(navModel.ts 纯函数可单测) - 插件页走动态段 /admin/plugin/<pluginId>/<panelId>,不进 router.ts 的静态白名单 - 把既有三段式(字段声明表 + 纯函数规则 + 渲染器,含「留空=不改」掩码语义)泛化成 schema → 控件(src/admin/schema/),复用它渲染插件表单/表格/事实面/日志 - 可测判据:新增插件页不得改动 sections.tsx / router.ts / AdminApp.tsx 的静态清单 - when 不满足 ⇒ 入口不出现;viewer 只看到只读区块 我的复跑:npx tsc --noEmit 通过;npx vite build 通过(3.67s);50 个前端脚本式测试全部 打印 checks passed(含本片新增 pluginData 90 / PluginPanel.render 55 / navModel 62 / schema values 40)。
This commit is contained in:
1 parent
ede00d9928
commit
9e63af9063
29 files changed
+3527
-146
No files matched your search
@@ -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) => (
|
||||
<div key={group} className="mb-1">
|
||||
<div className="px-2 py-2 text-xs font-semibold uppercase tracking-wider text-[var(--mu-text-lo)]">{group}</div>
|
||||
{ADMIN_SECTIONS.filter((s) => s.group === group).map((s) => (
|
||||
{nav.map((group) => (
|
||||
<CollapsibleGroup
|
||||
key={group.key}
|
||||
label={group.label}
|
||||
count={group.items.length}
|
||||
forceOpen={groupHasActive(group, activeKey)}
|
||||
>
|
||||
{group.items.map((item) => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => { navigateAdmin(s.id); onNavigate(); }}
|
||||
key={item.key}
|
||||
onClick={() => { navigateAdminPath(item.path); onNavigate(); }}
|
||||
className={`flex w-full items-center gap-2.5 rounded-[var(--mu-radius)] px-3 py-2 text-sm text-left transition-all duration-200 ease-[var(--mu-spring)] active:scale-[0.98] ${
|
||||
s.id === active
|
||||
item.key === activeKey
|
||||
? "bg-[var(--mu-accent)]/15 font-medium text-[var(--mu-accent-hi)]"
|
||||
: "text-[var(--mu-text-mid)] hover:bg-[var(--mu-surface-4)]"
|
||||
}`}
|
||||
>
|
||||
<Icon name={s.icon} size={18} />
|
||||
<span>{s.label}</span>
|
||||
<Icon name={item.icon} size={18} />
|
||||
<span className="min-w-0 truncate">{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleGroup>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<nav className="glass hidden h-full w-[var(--mu-sidebar-w)] shrink-0 flex-col gap-1 overflow-y-auto border-r border-[var(--mu-glass-border)] p-3 md:flex">
|
||||
{adminNavContent({ active, onNavigate: () => {} })}
|
||||
{adminNavContent({ nav, activeKey, onNavigate: () => {} })}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div className="fixed inset-0 z-40 md:hidden" role="dialog" aria-modal="true">
|
||||
@@ -116,7 +146,7 @@ function AdminMobileNav({ open, active, onClose }: { open: boolean; active: Admi
|
||||
<Icon name="close" size={18} />
|
||||
</button>
|
||||
</div>
|
||||
{adminNavContent({ active, onNavigate: onClose })}
|
||||
{adminNavContent({ nav, activeKey, onNavigate: onClose })}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
@@ -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<ThemeMode>(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?: ()
|
||||
<img src="/images/Cyrene_Avatar/3rd_Form/Cyrene-3F-Q-Happy-1.png" alt="Cyrene" className="h-7 w-7 shrink-0 rounded-full object-cover" />
|
||||
<div className="min-w-0 leading-tight">
|
||||
<div className="truncate whitespace-nowrap text-sm font-semibold">Cyrene · 管理面板</div>
|
||||
<div className="truncate whitespace-nowrap text-[11px] text-[var(--mu-text-lo)]">{meta?.label ?? "仪表盘"}</div>
|
||||
<div className="truncate whitespace-nowrap text-[11px] text-[var(--mu-text-lo)]">{label}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
@@ -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/<pluginId>/<panelId>`),此时页面身份从路由订阅读出来。
|
||||
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 (
|
||||
<div className="flex h-full flex-col">
|
||||
<AdminTopBar section={section} />
|
||||
<AdminTopBar section={section} title={activePanel?.title} />
|
||||
<LoginGate />
|
||||
</div>
|
||||
);
|
||||
@@ -300,7 +342,7 @@ export function AdminApp({ section }: { section: AdminSectionId }): React.ReactN
|
||||
if (getStoredRole() === "viewer") {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<AdminTopBar section={section} />
|
||||
<AdminTopBar section={section} title={activePanel?.title} />
|
||||
<Forbidden403 />
|
||||
</div>
|
||||
);
|
||||
@@ -308,14 +350,27 @@ export function AdminApp({ section }: { section: AdminSectionId }): React.ReactN
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<AdminTopBar section={section} onMenu={() => setAdminMenuOpen(true)} />
|
||||
<AdminTopBar section={section} title={activePanel?.title} onMenu={() => setAdminMenuOpen(true)} />
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<AdminSidebar active={section} />
|
||||
<AdminSidebar nav={nav} activeKey={activeKey} />
|
||||
<main className="relative flex-1 overflow-y-auto p-4 md:p-6">
|
||||
<div className="mx-auto max-w-6xl">{renderSection(section)}</div>
|
||||
<div className="mx-auto max-w-6xl">
|
||||
{section === undefined ? (
|
||||
<PluginPanelHost
|
||||
target={pluginTarget}
|
||||
panels={panels}
|
||||
loading={panelsLoading}
|
||||
error={panelsError}
|
||||
reload={reloadPanels}
|
||||
role={getStoredRole()}
|
||||
/>
|
||||
) : (
|
||||
renderSection(section)
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<AdminMobileNav open={adminMenuOpen} active={section} onClose={() => setAdminMenuOpen(false)} />
|
||||
<AdminMobileNav open={adminMenuOpen} nav={nav} activeKey={activeKey} onClose={() => setAdminMenuOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="mb-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls={panelId}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-center gap-1.5 rounded-[var(--mu-radius)] px-2 py-2 text-left text-xs font-semibold uppercase tracking-wider text-[var(--mu-text-lo)] transition-all duration-200 ease-[var(--mu-spring)] hover:bg-[var(--mu-surface-4)]/60 active:scale-[0.99]"
|
||||
>
|
||||
<span
|
||||
className={`shrink-0 transition-transform duration-200 ease-[var(--mu-spring)] ${open ? "rotate-90" : ""}`}
|
||||
>
|
||||
<Icon name="arrow" size={12} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||
<span className="shrink-0 text-[10px] font-normal normal-case text-[var(--mu-text-lo)]">{count}</span>
|
||||
</button>
|
||||
{open ? (
|
||||
<div id={panelId} className="space-y-0.5">
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<Record<string, string>>;
|
||||
@@ -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({
|
||||
</div>
|
||||
) : null}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{fields.map((f) => (
|
||||
<ConnField
|
||||
key={f.key}
|
||||
field={f}
|
||||
value={values[f.key] ?? ""}
|
||||
onChange={(v) => 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 (
|
||||
<FieldShell
|
||||
key={f.key}
|
||||
label={f.label}
|
||||
required={isRequired}
|
||||
missing={isMissing}
|
||||
guide={f.guide}
|
||||
missingHint={`当前接入方式(${summary})下必填,留空适配器起不来。`}
|
||||
footer={
|
||||
<MaskedSecretFooter
|
||||
fieldKey={f.key}
|
||||
masked={maskedSecret}
|
||||
clearRequested={clearRequested}
|
||||
onToggleClear={(next) => onToggleClear(f.key, next)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<SchemaControl
|
||||
field={platformFieldToSchema(f)}
|
||||
value={values[f.key] ?? ""}
|
||||
onChange={(v) => onChange(f.key, v)}
|
||||
disabled={clearRequested}
|
||||
invalid={isMissing && isRequired}
|
||||
/>
|
||||
</FieldShell>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 一个字段:标题 + 必填/可选标记 + 输入(下拉或文本框)+ 一行中文说明 + 状态行。 */
|
||||
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 (
|
||||
<div className="space-y-1">
|
||||
<span className="flex flex-wrap items-center gap-1.5 text-xs text-[var(--mu-text-lo)]">
|
||||
{field.label}
|
||||
{required ? (
|
||||
<span className="rounded-[var(--mu-radius-xs)] bg-[var(--mu-err)]/15 px-1.5 py-0.5 text-[10px] font-medium text-[var(--mu-err)]">
|
||||
必填
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded-[var(--mu-radius-xs)] bg-[var(--mu-surface-5)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--mu-text-lo)]">
|
||||
可选
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{options.length > 0 ? (
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
aria-label={field.label}
|
||||
className={missing && required ? inputClsError : inputCls}
|
||||
>
|
||||
{unknownOption ? <option value={value}>{value}(不在候选内,保存时原样保留)</option> : null}
|
||||
{options.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type={field.secret ? "password" : "text"}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
disabled={clearRequested}
|
||||
className={missing && required ? inputClsError : inputCls}
|
||||
/>
|
||||
)}
|
||||
<span className="block text-[11px] leading-relaxed text-[var(--mu-text-lo)]">{field.guide}</span>
|
||||
{missing && required ? (
|
||||
<span className="block text-[11px] text-[var(--mu-err)]">
|
||||
当前接入方式({summary})下必填,留空适配器起不来。
|
||||
</span>
|
||||
) : null}
|
||||
{maskedSecret ? (
|
||||
<span className="block space-y-1">
|
||||
<span className="block text-[11px] text-[var(--mu-text-lo)]">
|
||||
已配置(后端只下发掩码,面板不持有原文):留空 = 不改动。
|
||||
</span>
|
||||
<Switch
|
||||
checked={clearRequested}
|
||||
onChange={onToggleClear}
|
||||
label={`清除已配置的 ${field.key}(保存后生效)`}
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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)]";
|
||||
@@ -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<T>(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> = {}): 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:<id>/<panel>");
|
||||
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`);
|
||||
@@ -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<string, { label: string; order: number; items: AdminNavItem[] }>();
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/** 插件表单的字段栅格(呈现层):把声明字段逐个画成 `FieldShell` + 通用控件。
|
||||
*
|
||||
* 与 `FormSection` 分开是为了守住「函数 ≤80 行」;这里只做呈现,规则(必填/清除/写回)
|
||||
* 全在 `formState.ts` 与 `schema/values.ts`,因此本文件不判断任何语义。
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { FieldShell, MaskedSecretFooter, SchemaControl } from "../schema/fields";
|
||||
import type { SchemaField } from "../schema/fieldTypes";
|
||||
import type { PanelFieldDto, PanelOptionDto } from "./types";
|
||||
|
||||
/** 字段说明行:声明面没有 `guide`(`sdk.Field` 没这个键),这行就用来交代「候选从哪来、
|
||||
* 为什么现在是手填」—— 静默降级成文本框是用户最容易误解的一档。 */
|
||||
export function formFieldGuide(optionCount: number, optionError: string | undefined): string | undefined {
|
||||
if (optionCount > 0) return undefined;
|
||||
if (optionError && optionError !== "") {
|
||||
return `候选项未能加载(动作 ${optionError} 未声明或未接线):此项可手填,保存时原样提交。`;
|
||||
}
|
||||
return "正在加载候选项…";
|
||||
}
|
||||
|
||||
export interface FormFieldsGridProps {
|
||||
fields: readonly SchemaField[];
|
||||
/** 原始声明(取 `optionsFrom` 用)。 */
|
||||
declared: readonly PanelFieldDto[];
|
||||
values: Record<string, string>;
|
||||
required: readonly string[];
|
||||
missing: ReadonlySet<string>;
|
||||
configured: ReadonlySet<string>;
|
||||
cleared: ReadonlySet<string>;
|
||||
options: Record<string, readonly PanelOptionDto[]>;
|
||||
optionErrors: Record<string, string>;
|
||||
onValue: (key: string, next: string) => void;
|
||||
onToggleClear: (key: string, next: boolean) => void;
|
||||
}
|
||||
|
||||
export function FormFieldsGrid({
|
||||
fields,
|
||||
declared,
|
||||
values,
|
||||
required,
|
||||
missing,
|
||||
configured,
|
||||
cleared,
|
||||
options,
|
||||
optionErrors,
|
||||
onValue,
|
||||
onToggleClear,
|
||||
}: FormFieldsGridProps): ReactNode {
|
||||
const requiredSet = new Set(required);
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{fields.map((f) => {
|
||||
const fromKey = declared.find((d) => d.key === f.key)?.optionsFrom ?? "";
|
||||
const opts = fromKey === "" ? undefined : options[fromKey];
|
||||
const isRequired = requiredSet.has(f.key);
|
||||
const isMissing = missing.has(f.key);
|
||||
const clearRequested = cleared.has(f.key);
|
||||
const masked = f.type === "secret" && configured.has(f.key);
|
||||
return (
|
||||
<div key={f.key} className={f.type === "kv" ? "sm:col-span-2" : ""}>
|
||||
<FieldShell
|
||||
label={f.label ?? f.key}
|
||||
required={isRequired}
|
||||
missing={isMissing}
|
||||
guide={fromKey === "" ? undefined : formFieldGuide(opts?.length ?? 0, optionErrors[fromKey])}
|
||||
missingHint="这是必填字段,留空保存会被拦下。"
|
||||
footer={
|
||||
<MaskedSecretFooter
|
||||
fieldKey={f.key}
|
||||
masked={masked}
|
||||
clearRequested={clearRequested}
|
||||
clearedHint="保存后会清除该项(后端收到空字符串)。"
|
||||
onToggleClear={(next) => onToggleClear(f.key, next)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<SchemaControl
|
||||
field={f}
|
||||
value={values[f.key] ?? ""}
|
||||
options={opts}
|
||||
disabled={clearRequested}
|
||||
invalid={isMissing && isRequired}
|
||||
onChange={(next) => onValue(f.key, next)}
|
||||
/>
|
||||
</FieldShell>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/** `section.form` 区块:把插件声明的 `fields[]` 渲染成表单并提交给声明的动作。
|
||||
*
|
||||
* 三条硬口径:
|
||||
* ① 控件来自**通用 schema 渲染**(`../schema/fields.tsx`,与渠道配置页同一份实现),
|
||||
* 本组件不自己画控件、也不重复实现「留空 = 不改动」;
|
||||
* ② 提交前先在本地跑必填校验(`schema/values.planSubmit`),拦下「填了却起不来」;
|
||||
* ③ viewer 一律看不到表单(后端已在快照里裁掉,这里是第二道闸)。
|
||||
*
|
||||
* 状态与规则在 `formState.ts`(三个 hook),字段呈现与说明文案在 `FormFields.tsx`;
|
||||
* 本文件只剩「拼装 + 提交按钮 + 反馈行」,因此每个函数都在 80 行以内。
|
||||
*/
|
||||
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { Button, Surface, Tag } from "../../components/primitives";
|
||||
import type { Role } from "../../lib/types";
|
||||
import { useAuditStore } from "../audit";
|
||||
import { Notice } from "../components";
|
||||
import type { SchemaField } from "../schema/fieldTypes";
|
||||
import { FormFieldsGrid } from "./FormFields";
|
||||
import { useFieldOptions, useFormSubmit, useFormValues, type FormFlash } from "./formState";
|
||||
import { canWrite } from "./nav";
|
||||
import { SectionHeader } from "./SectionHeader";
|
||||
import type { PanelFieldDto, PanelSectionDto } from "./types";
|
||||
|
||||
/** 声明字段 → 通用 schema(`options` 由 `optionsFrom` 拉到后由栅格填进来)。 */
|
||||
export function panelFieldToSchema(f: PanelFieldDto): SchemaField {
|
||||
return {
|
||||
key: f.key,
|
||||
label: f.label,
|
||||
type: f.type,
|
||||
required: f.required,
|
||||
placeholder: f.placeholder,
|
||||
default: f.default,
|
||||
min: f.min,
|
||||
max: f.max,
|
||||
};
|
||||
}
|
||||
|
||||
/** 提交/校验反馈行:错误码与文案分两处显示(§5.4.6 的错误码 ↔ 文案分离)。 */
|
||||
function FormFlashRow({ flash }: { flash: FormFlash | null }): ReactNode {
|
||||
if (!flash) return null;
|
||||
return (
|
||||
<>
|
||||
{flash.code ? <Tag tone={flash.tone === "ok" ? "ok" : "err"}>{flash.code}</Tag> : null}
|
||||
<span className={`text-xs ${flash.tone === "ok" ? "text-[var(--mu-ok)]" : "text-[var(--mu-err)]"}`}>
|
||||
{flash.text}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export interface PanelFormSectionProps {
|
||||
pluginId: string;
|
||||
section: PanelSectionDto;
|
||||
role: Role | null;
|
||||
/** 提交成功后重取一次快照(下拉候选与 status 事实面跟着刷新)。 */
|
||||
onReload: () => void;
|
||||
/** 行内动作「带回表单」的值(design/09 §5.2.1 示例的 reauth);`null` = 没有。
|
||||
* 只认显式 `values` / `formValues`(见 `types.formValuesOfResult`),不做形状猜测。 */
|
||||
prefill?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export function PanelFormSection({ pluginId, section, role, onReload, prefill = null }: PanelFormSectionProps): ReactNode {
|
||||
const log = useAuditStore((s) => s.log);
|
||||
// 依赖键用 JSON 串:快照每 30s 重取一次,对象身份每次都变,直接依赖对象会把用户正在
|
||||
// 输入的内容重置掉(「打着字被清空」是比不刷新更糟的体验)。
|
||||
const fieldsKey = JSON.stringify(section.fields ?? []);
|
||||
const prefillKey = JSON.stringify(prefill ?? null);
|
||||
const external = useMemo(
|
||||
() => ({ ...(section.values ?? {}), ...(prefill ?? {}) }),
|
||||
[JSON.stringify(section.values ?? {}), prefillKey],
|
||||
);
|
||||
const fields = useMemo(() => (section.fields ?? []).map((f) => panelFieldToSchema(f)), [fieldsKey]);
|
||||
const state = useFormValues(fields, fieldsKey, external);
|
||||
const { options, optionErrors } = useFieldOptions(pluginId, section.fields ?? []);
|
||||
const action = section.submit?.action ?? "";
|
||||
const { submit, busy, flash } = useFormSubmit({ pluginId, action, fields, state, onReload, log });
|
||||
|
||||
if (!canWrite(role)) {
|
||||
return (
|
||||
<Surface className="space-y-2">
|
||||
<SectionHeader title={section.title} hint="只读" />
|
||||
<Notice tone="warn">当前角色没有写权限,本区块的表单不显示(写动作一律 owner / admin)。</Notice>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Surface className="space-y-3">
|
||||
<SectionHeader title={section.title} />
|
||||
<FormFieldsGrid
|
||||
fields={fields}
|
||||
declared={section.fields ?? []}
|
||||
values={state.values}
|
||||
required={fields.filter((f) => f.required === true).map((f) => f.key)}
|
||||
missing={state.missing}
|
||||
configured={state.configured}
|
||||
cleared={state.cleared}
|
||||
options={options}
|
||||
optionErrors={optionErrors}
|
||||
onValue={state.setValue}
|
||||
onToggleClear={state.toggleClear}
|
||||
/>
|
||||
{fields.length === 0 ? <Notice>该表单没有声明任何字段。</Notice> : null}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="primary" disabled={busy || action === ""} onClick={submit}>
|
||||
{busy ? "提交中…" : "保存"}
|
||||
</Button>
|
||||
{action === "" ? <span className="text-[11px] text-[var(--mu-err)]">声明缺少 submit.action,无法提交。</span> : null}
|
||||
<FormFlashRow flash={flash} />
|
||||
</div>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
// 插件页的**真实渲染**测试:把「入口出不出、区块画不画、秘密会不会漏」变成可执行断言。
|
||||
//
|
||||
// 为什么必须有它:纯逻辑测试(navModel / pluginData / values)只能证明规则算得对,证明不了
|
||||
// 页面上真的长成那样。这一片的产品缺口是「插件能不能接入面板」,验收判据里最硬的两条
|
||||
// ① `when` 不满足 ⇒ 入口**不出现**(不是灰掉);
|
||||
// ② viewer 只看到只读区块
|
||||
// 都必须落到**真 HTML** 上才算数。
|
||||
//
|
||||
// 跑法(react-dom/server 的 CJS 构建需要 require,所以必须打成 cjs):
|
||||
//
|
||||
// cd frontend/web \
|
||||
// && ./node_modules/.bin/esbuild src/admin/plugin/PluginPanel.render.test.ts \
|
||||
// --bundle --platform=node --format=cjs \
|
||||
// --outfile=/tmp/cyrene-plugin-panel.render.test.cjs \
|
||||
// && node /tmp/cyrene-plugin-panel.render.test.cjs
|
||||
//
|
||||
// `tsc --noEmit`(npm run build 的一部分)会对本文件做类型检查,因此它只从项目内导入、
|
||||
// 不 import node:*。渲染走 `renderToStaticMarkup`:服务端渲染不跑 effect,所以表格区块停在
|
||||
// 「加载中…」是**预期**的(行动作与行的渲染由 pluginData.test.ts 的纯函数覆盖)。
|
||||
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import type { PanelEntryDto, PanelSectionDto } from "./types";
|
||||
import { decodePanelEntries } from "./types";
|
||||
import { PluginPanelHost, PluginPanelView } from "./PluginPanel";
|
||||
|
||||
let checks = 0;
|
||||
|
||||
function ok(cond: boolean, what: string): void {
|
||||
checks += 1;
|
||||
if (!cond) throw new Error(`FAILED: ${what}`);
|
||||
}
|
||||
|
||||
function has(haystack: string, needle: string): boolean {
|
||||
return haystack.includes(needle);
|
||||
}
|
||||
|
||||
/** web-search 的 `providers` 页声明(与 `plugins/tools/io/search/panel.go` 同形:
|
||||
* provider 下拉必填、apiKey 是 secret 且**不标必填**(编辑时不重填凭据是常态)、
|
||||
* baseUrl 文本、enabled 开关)。 */
|
||||
const FORM: PanelSectionDto = {
|
||||
type: "form",
|
||||
id: "add",
|
||||
title: "添加 / 修改提供商",
|
||||
fields: [
|
||||
{ key: "provider", label: "提供商", type: "select", optionsFrom: "plugin:providers", required: true },
|
||||
{ key: "apiKey", label: "API Key", type: "secret", placeholder: "留空 = 保持原值" },
|
||||
{ key: "baseUrl", label: "接口地址", type: "text", placeholder: "https://..." },
|
||||
{ key: "enabled", label: "启用", type: "switch" },
|
||||
],
|
||||
submit: { action: "plugin.saveProvider" },
|
||||
// 快照侧的当前值(secret 只有掩码):表单因此「打开即显示现状」+ 兑现「留空 = 不改」。
|
||||
values: { provider: "searxng", apiKey: "****cdef", baseUrl: "https://s.example", enabled: "true" },
|
||||
};
|
||||
|
||||
const STATUS: PanelSectionDto = {
|
||||
type: "status",
|
||||
id: "chain",
|
||||
title: "当前生效的降级链",
|
||||
source: { action: "plugin.facts" },
|
||||
fields: [
|
||||
{ key: "primary", label: "主提供商", type: "text" },
|
||||
{ key: "backupKey", label: "备用凭据", type: "secret" },
|
||||
],
|
||||
};
|
||||
|
||||
const LOG: PanelSectionDto = {
|
||||
type: "log",
|
||||
id: "recent",
|
||||
title: "最近请求",
|
||||
source: { action: "plugin.recent" },
|
||||
limit: 20,
|
||||
};
|
||||
|
||||
const TABLE: PanelSectionDto = {
|
||||
type: "table",
|
||||
id: "list",
|
||||
title: "已配置的提供商",
|
||||
source: { action: "plugin.listProviders" },
|
||||
columns: [
|
||||
{ key: "provider", label: "提供商" },
|
||||
{ key: "state", label: "状态" },
|
||||
],
|
||||
rowActions: [
|
||||
{ id: "test", label: "测试连通", action: "plugin.testProvider" },
|
||||
{ id: "reauth", label: "重配凭据", action: "plugin.reauthProvider" },
|
||||
],
|
||||
};
|
||||
|
||||
const PANEL: PanelEntryDto = {
|
||||
pluginId: "web-search",
|
||||
id: "providers",
|
||||
title: "搜索提供商",
|
||||
description: "为联网搜索配置提供商与凭据(多实例)",
|
||||
group: "plugins",
|
||||
order: 20,
|
||||
icon: "search",
|
||||
sections: [FORM, STATUS, LOG, TABLE],
|
||||
};
|
||||
|
||||
function renderView(role: "owner" | "admin" | "viewer" | null, panel: PanelEntryDto = PANEL): string {
|
||||
return renderToStaticMarkup(
|
||||
createElement(PluginPanelView, { panel, role, onReload: () => {} }),
|
||||
);
|
||||
}
|
||||
|
||||
// ── ① owner:四个区块都画出来,且秘密不回显 ────────────────────────────────────
|
||||
{
|
||||
const html = renderView("owner");
|
||||
ok(has(html, "搜索提供商"), "页面标题来自声明的 title");
|
||||
ok(has(html, "为联网搜索配置提供商与凭据"), "描述来自声明的 description");
|
||||
ok(has(html, "web-search"), "页面上标明是哪个插件的页面(避免社区插件同名页面互相冒充)");
|
||||
|
||||
// form
|
||||
ok(has(html, "添加 / 修改提供商"), "form 区块标题");
|
||||
ok(
|
||||
has(html, "提供商") && has(html, "API Key") && has(html, "接口地址") && has(html, "启用"),
|
||||
"form 四个字段都渲染",
|
||||
);
|
||||
ok(has(html, ">必填</span>"), "必填字段有标记");
|
||||
ok(has(html, "https://..."), "placeholder 生效");
|
||||
ok(has(html, ">保存</button>") || has(html, "保存"), "提交按钮存在");
|
||||
ok(has(html, 'value="searxng"') && has(html, 'value="https://s.example"'), "非秘密字段回显当前值(打开即显示现状)");
|
||||
ok(has(html, "留空 = 保持原值"), "secret 字段的占位文案说明留空语义");
|
||||
ok(!has(html, "****cdef"), "后端下发的掩码**不出现在 HTML 里**(回填掩码 = 一次保存把掩码写成真凭据)");
|
||||
ok(
|
||||
has(html, "已配置(后端只下发掩码,面板不持有原文):留空 = 不改动。"),
|
||||
"掩码秘密说清「留空 = 不改动」",
|
||||
);
|
||||
ok(has(html, "清除已配置的 apiKey"), "并且给出唯一能清空的方式(显式开关)");
|
||||
ok(has(html, "正在加载候选项…"), "下拉候选未到(服务端不跑 effect)时如实说明,而不是静默变成手填");
|
||||
|
||||
// status(只读事实面)
|
||||
ok(has(html, "当前生效的降级链"), "status 区块标题");
|
||||
ok(has(html, ">只读</span>"), "status 标明只读");
|
||||
ok(has(html, "主提供商") && has(html, "备用凭据"), "事实面按声明渲染键值行");
|
||||
ok(has(html, "未配置"), "secret 事实只说「未配置」,不回显值");
|
||||
|
||||
// log(只读日志)
|
||||
ok(has(html, "最近请求"), "log 区块标题");
|
||||
ok(has(html, "最新在上"), "日志口径写明「最新在上」");
|
||||
ok(has(html, "最近 20 条"), "日志条数用声明的 limit");
|
||||
ok(has(html, "加载中…"), "服务端渲染不跑 effect ⇒ 只读区块如实显示加载中(不假装有数据)");
|
||||
|
||||
// table
|
||||
ok(has(html, "已配置的提供商"), "table 区块标题");
|
||||
ok(has(html, ">刷新</button>"), "每个取数区块都有显式刷新");
|
||||
}
|
||||
|
||||
// ── ② admin 与 owner 同权(写动作一律 owner/admin) ───────────────────────────
|
||||
{
|
||||
const html = renderView("admin");
|
||||
ok(has(html, "添加 / 修改提供商"), "admin 也能看到表单");
|
||||
ok(has(html, "清除已配置的 apiKey"), "admin 的掩码语义与 owner 一致");
|
||||
}
|
||||
|
||||
// ── ②b 端到端:后端快照(真实形状)解出来后直接渲染,页面照旧成立 ────────────────
|
||||
{
|
||||
const snapshot = {
|
||||
role: "owner",
|
||||
groups: [
|
||||
{
|
||||
id: "plugins",
|
||||
title: "插件",
|
||||
order: 20,
|
||||
items: [
|
||||
{
|
||||
pluginId: "web-search",
|
||||
id: "providers",
|
||||
title: "搜索提供商",
|
||||
description: "为 web-search 能力配置多个第三方搜索后端",
|
||||
icon: "search",
|
||||
order: 20,
|
||||
route: "plugin/web-search/providers",
|
||||
sections: [FORM, STATUS, LOG, TABLE],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const decoded = decodePanelEntries(snapshot);
|
||||
ok(decoded.length === 1, "快照解出一条");
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(PluginPanelView, { panel: decoded[0], role: "owner", onReload: () => {} }),
|
||||
);
|
||||
ok(has(html, "搜索提供商") && has(html, "已配置的提供商"), "\"解码 → 渲染\"整条链路成立(契约同形的活证据)");
|
||||
ok(!has(html, "****cdef"), "整条链路上掩码都没有漏进 HTML");
|
||||
}
|
||||
|
||||
// ── ②c 一个**全新插件**的页面同样直接渲染(可接入点的意义:不改静态清单) ────────
|
||||
{
|
||||
const fresh: PanelEntryDto = {
|
||||
pluginId: "acme-tool",
|
||||
id: "settings",
|
||||
title: "Acme 工具配置",
|
||||
group: "plugins",
|
||||
groupTitle: "插件",
|
||||
order: 5,
|
||||
icon: "gear",
|
||||
sections: [{ type: "status", id: "facts", title: "Acme 当前状态", source: { action: "plugin.facts" }, fields: [{ key: "mode", label: "模式", type: "text" }] }],
|
||||
};
|
||||
const html = renderView("owner", fresh);
|
||||
ok(has(html, "Acme 工具配置"), "任意插件 id / 面板 id 都能渲染(前端不认白名单)");
|
||||
ok(has(html, "acme-tool"), "并标明是哪个插件的页面");
|
||||
ok(has(html, "Acme 当前状态"), "其区块照常渲染");
|
||||
}
|
||||
|
||||
// ── ③ viewer:只看到只读区块,写面一个不剩 ────────────────────────────────────
|
||||
{
|
||||
const html = renderView("viewer");
|
||||
ok(!has(html, "添加 / 修改提供商"), "viewer 看不到 form 区块(它是唯一自带写面的类型)");
|
||||
ok(!has(html, "API Key"), "viewer 看不到写表单的字段");
|
||||
ok(!has(html, ">保存"), "viewer 看不到提交按钮");
|
||||
ok(!has(html, "清除已配置的 apiKey"), "viewer 看不到清除开关(那是写面)");
|
||||
ok(!has(html, "测试连通") && !has(html, "重配凭据"), "viewer 看不到行内写动作");
|
||||
ok(has(html, "当前生效的降级链"), "viewer 仍能看到只读事实面");
|
||||
ok(has(html, "最近请求"), "viewer 仍能看到只读日志");
|
||||
ok(has(html, "已配置的提供商"), "viewer 仍能看到表格(列是事实)");
|
||||
ok(has(html, "当前角色只读"), "viewer 的表格上说明「只读、写动作不显示」");
|
||||
ok(!has(html, "****cdef"), "viewer 的 HTML 里同样没有掩码");
|
||||
}
|
||||
|
||||
// ── ④ 空面板与未知区块:如实说,不半渲染 ───────────────────────────────────────
|
||||
{
|
||||
const empty = renderView("owner", { ...PANEL, sections: [] });
|
||||
ok(has(empty, "该面板没有可显示的区块"), "空面板如实说明(不画一个看起来坏掉的空页)");
|
||||
ok(has(empty, "搜索提供商"), "空面板仍是这个页面(标题照旧)");
|
||||
|
||||
const onlyForm = renderView("viewer", { ...PANEL, sections: [FORM] });
|
||||
ok(has(onlyForm, "该面板没有可显示的区块"), "只有写表单的面板对 viewer 说明「没有可读区块」");
|
||||
ok(!has(onlyForm, "添加 / 修改提供商"), "而不是把表单降级成只读展示(模型里没有只读表单)");
|
||||
|
||||
// 契约先行、前端滞后的一档:后端给了本版本不认识的区块类型。
|
||||
const unknown = renderView("owner", {
|
||||
...PANEL,
|
||||
sections: [{ ...LOG, type: "iframe" as PanelSectionDto["type"] }],
|
||||
});
|
||||
ok(has(unknown, "本版本不支持区块类型"), "闭集外的区块类型如实告知");
|
||||
ok(has(unknown, "iframe"), "并点名是哪种类型(排障不用猜)");
|
||||
}
|
||||
|
||||
// ── ⑤ 入口不出现:宿主在「找不到 / 未加载完」时不会画出一个假页面 ────────────────
|
||||
{
|
||||
const noTarget = renderToStaticMarkup(
|
||||
createElement(PluginPanelHost, {
|
||||
target: null,
|
||||
panels: [PANEL],
|
||||
loading: false,
|
||||
error: null,
|
||||
reload: () => {},
|
||||
role: "owner",
|
||||
}),
|
||||
);
|
||||
ok(has(noTarget, "未找到插件页"), "缺段 ⇒ 明确的「未找到」,不回落 dashboard 也不空白");
|
||||
|
||||
const loading = renderToStaticMarkup(
|
||||
createElement(PluginPanelHost, {
|
||||
target: { pluginId: "web-search", panelId: "providers" },
|
||||
panels: [],
|
||||
loading: true,
|
||||
error: null,
|
||||
reload: () => {},
|
||||
role: "owner",
|
||||
}),
|
||||
);
|
||||
ok(has(loading, "正在读取插件面板声明"), "快照未到 ⇒ 加载态(不先说「找不到」)");
|
||||
|
||||
const hidden = renderToStaticMarkup(
|
||||
createElement(PluginPanelHost, {
|
||||
target: { pluginId: "web-search", panelId: "providers" },
|
||||
panels: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
reload: () => {},
|
||||
role: "owner",
|
||||
}),
|
||||
);
|
||||
ok(has(hidden, "未找到面板"), "快照里没有这条(未启用 / when 不满足 / 没声明)⇒ 直接输 URL 也进不来");
|
||||
ok(has(hidden, "when 不满足"), "并把可能的原因说清(用户不必去猜为什么入口不见了)");
|
||||
|
||||
const failed = renderToStaticMarkup(
|
||||
createElement(PluginPanelHost, {
|
||||
target: { pluginId: "web-search", panelId: "providers" },
|
||||
panels: [],
|
||||
loading: false,
|
||||
error: "读取插件面板失败",
|
||||
reload: () => {},
|
||||
role: "owner",
|
||||
}),
|
||||
);
|
||||
ok(has(failed, "读取插件面板失败"), "取数失败如实显示");
|
||||
ok(has(failed, "重试"), "并给一个重试入口(不静默失败)");
|
||||
|
||||
const found = renderToStaticMarkup(
|
||||
createElement(PluginPanelHost, {
|
||||
target: { pluginId: "web-search", panelId: "providers" },
|
||||
panels: [PANEL],
|
||||
loading: false,
|
||||
error: null,
|
||||
reload: () => {},
|
||||
role: "owner",
|
||||
}),
|
||||
);
|
||||
ok(has(found, "搜索提供商") && has(found, "添加 / 修改提供商"), "命中 ⇒ 渲染真页面");
|
||||
}
|
||||
|
||||
console.log(`plugin panel render test: ${checks} checks passed`);
|
||||
@@ -0,0 +1,153 @@
|
||||
/** 插件页(`page.root` 接入点):页面壳 + 区块分发。
|
||||
*
|
||||
* 分成两个组件是刻意的:
|
||||
* - `PluginPanelHost`(容器):从快照里找当前 URL 指向的面板,负责「加载中 / 取数失败 /
|
||||
* 没这个面板」三种状态。找不到就是找不到 —— 未启用、`when` 不满足、或该插件根本没
|
||||
* 声明这个页面,直接输 URL 也进不来(快照是按角色与 `when` 过滤后的结果);
|
||||
* - `PluginPanelView`(纯呈现):只吃 `panel` + `role`,因此可以在单测里直接渲染,
|
||||
* 不依赖路由、快照与浏览器环境(`--format=cjs` 的渲染测试就是打它)。
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import { Button, EmptyState, Spinner, Tag } from "../../components/primitives";
|
||||
import type { Role } from "../../lib/types";
|
||||
import { Notice, PageHeader } from "../components";
|
||||
import { PanelFormSection } from "./FormSection";
|
||||
import { PanelLogSection, PanelStatusSection } from "./ReadOnlySections";
|
||||
import { PanelTableSection } from "./TableSection";
|
||||
import { findPanelEntry, viewerVisibleSections } from "./nav";
|
||||
import type { PanelEntryDto, PanelSectionDto } from "./types";
|
||||
import type { PluginTarget } from "../router";
|
||||
|
||||
/** 区块分发(闭集):form / table / status / log 四种,未知类型如实说明而不是半渲染。 */
|
||||
function SectionBlock({
|
||||
pluginId,
|
||||
section,
|
||||
role,
|
||||
onReload,
|
||||
formPrefill,
|
||||
onRowValues,
|
||||
}: {
|
||||
pluginId: string;
|
||||
section: PanelSectionDto;
|
||||
role: Role | null;
|
||||
onReload: () => void;
|
||||
formPrefill: Record<string, unknown> | null;
|
||||
onRowValues?: (values: Record<string, unknown>) => void;
|
||||
}): ReactNode {
|
||||
switch (section.type) {
|
||||
case "form":
|
||||
return (
|
||||
<PanelFormSection
|
||||
pluginId={pluginId}
|
||||
section={section}
|
||||
role={role}
|
||||
onReload={onReload}
|
||||
prefill={formPrefill}
|
||||
/>
|
||||
);
|
||||
case "table":
|
||||
return (
|
||||
<PanelTableSection
|
||||
pluginId={pluginId}
|
||||
section={section}
|
||||
role={role}
|
||||
onReload={onReload}
|
||||
onValues={onRowValues}
|
||||
/>
|
||||
);
|
||||
case "status":
|
||||
return <PanelStatusSection pluginId={pluginId} section={section} onReload={onReload} />;
|
||||
case "log":
|
||||
return <PanelLogSection pluginId={pluginId} section={section} />;
|
||||
default: {
|
||||
// 解码已丢掉闭集外的类型,这里兜的是「契约先行、前端滞后」的一档。
|
||||
const unknownType: string = String((section as { type?: unknown }).type ?? "?");
|
||||
return <Notice tone="warn">本版本不支持区块类型「{unknownType}」,未渲染。</Notice>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface PluginPanelViewProps {
|
||||
panel: PanelEntryDto;
|
||||
role: Role | null;
|
||||
onReload: () => void;
|
||||
}
|
||||
|
||||
/** 插件页的纯呈现:标题 + 描述 + 按角色复检后的区块列表。
|
||||
*
|
||||
* 行内动作「带回表单」(design/09 §5.2.1 示例的 reauth)在**这里**接线:表格返回一份
|
||||
* 显式值对象时把它交给表单。只在「恰好一个 form 区块」时转交 —— 两个表单就没有归属,
|
||||
* 宁可不填也不猜(猜错就是把 A 条的数据填进 B 条的表单)。 */
|
||||
export function PluginPanelView({ panel, role, onReload }: PluginPanelViewProps): ReactNode {
|
||||
const [prefill, setPrefill] = useState<Record<string, unknown> | null>(null);
|
||||
const sections = viewerVisibleSections(panel, role);
|
||||
const formSections = sections.filter((s) => s.type === "form");
|
||||
const singleForm = formSections.length === 1 ? formSections[0].id : null;
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title={panel.title}
|
||||
subtitle={panel.description}
|
||||
actions={<Tag tone="accent">{panel.pluginId}</Tag>}
|
||||
/>
|
||||
{sections.map((section) => (
|
||||
<SectionBlock
|
||||
key={section.id}
|
||||
pluginId={panel.pluginId}
|
||||
section={section}
|
||||
role={role}
|
||||
onReload={onReload}
|
||||
formPrefill={section.type === "form" && section.id === singleForm ? prefill : null}
|
||||
onRowValues={singleForm === null ? undefined : (values) => setPrefill(values)}
|
||||
/>
|
||||
))}
|
||||
{sections.length === 0 ? (
|
||||
<Notice tone="warn">
|
||||
该面板没有可显示的区块(可能只声明了写表单,而当前角色是只读)。
|
||||
</Notice>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface PluginPanelHostProps {
|
||||
target: PluginTarget | null;
|
||||
panels: readonly PanelEntryDto[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
reload: () => void;
|
||||
role: Role | null;
|
||||
}
|
||||
|
||||
/** 插件页容器:把「当前 URL → 快照条目」这一步与三种非正常态收在这里。 */
|
||||
export function PluginPanelHost({ target, panels, loading, error, reload, role }: PluginPanelHostProps): ReactNode {
|
||||
if (!target) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="未找到插件页"
|
||||
hint="地址里缺少插件段或面板段(形如 /plugin/<pluginId>/<panelId>)。"
|
||||
/>
|
||||
);
|
||||
}
|
||||
const panel = findPanelEntry(panels, target);
|
||||
if (panel) return <PluginPanelView panel={panel} role={role} onReload={reload} />;
|
||||
if (loading) return <Spinner label="正在读取插件面板声明…" />;
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Notice tone="err">读取插件面板失败:{error}</Notice>
|
||||
<Button size="sm" variant="soft" onClick={reload}>
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<EmptyState
|
||||
title={`未找到面板「${target.panelId}」`}
|
||||
hint={`插件「${target.pluginId}」当前没有这个页面:可能未启用(when 不满足 ⇒ 入口不出现)、未安装,或没有声明它。`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/** 只读区块:`section.status`(事实面)与 `section.log`(最近 N 条,最新在上)。
|
||||
*
|
||||
* 两块都是**只读**(§5.2.1 的形状矩阵里没有写面),因此 viewer 也能看;渲染上只有一条
|
||||
* 红线 —— `secret` 字段**永不回显**:有值就说「已配置」,没值就说「未配置」。后端本来
|
||||
* 只下发掩码,这里再加一道,是为了「掩码字符串被当成真值贴到页面上」那种事故不再可能。
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
import { Button, Surface } from "../../components/primitives";
|
||||
import { KeyValueRow, Notice } from "../components";
|
||||
import { actionFailure } from "./actions";
|
||||
import { runPluginAction } from "./panelsApi";
|
||||
import { SectionHeader } from "./SectionHeader";
|
||||
import { decodeFacts, decodeLogLines, type PanelFieldDto, type PanelSectionDto } from "./types";
|
||||
|
||||
/** 日志默认条数(声明没给 `limit` 时)。 */
|
||||
export const DEFAULT_LOG_LIMIT = 50;
|
||||
|
||||
/** 事实面单元格文本:`secret` 只说「已配置 / 未配置」,其它按类型直出。 */
|
||||
export function readOnlyValue(f: PanelFieldDto, v: unknown): string {
|
||||
const present =
|
||||
v !== undefined && v !== null && v !== "" && !(Array.isArray(v) && v.length === 0);
|
||||
if (f.type === "secret") return present ? "已配置(掩码不回显)" : "未配置";
|
||||
if (f.type === "switch") return v === true || v === "true" ? "开" : "关";
|
||||
if (!present) return "—";
|
||||
if (Array.isArray(v)) return v.map((x) => (typeof x === "string" ? x : JSON.stringify(x))).join("、");
|
||||
if (typeof v === "string") return v;
|
||||
if (typeof v === "number" || typeof v === "boolean") return String(v);
|
||||
return JSON.stringify(v);
|
||||
}
|
||||
|
||||
interface SectionDataState {
|
||||
/** 解成键值事实的原始响应(status 用)。 */
|
||||
facts: Record<string, unknown> | null;
|
||||
/** 原始响应(log 用:日志行形状与事实不同,各取所需)。 */
|
||||
raw: unknown;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
load: () => void;
|
||||
}
|
||||
|
||||
/** 拉一次只读数据(status 的事实 / log 的行),带刷新。 */
|
||||
function useSectionData(pluginId: string, action: string): SectionDataState {
|
||||
const [raw, setRaw] = useState<unknown>(null);
|
||||
const [facts, setFacts] = useState<Record<string, unknown> | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (action === "") {
|
||||
setLoading(false);
|
||||
setError("声明缺少 source.action,无法取数。");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
runPluginAction(pluginId, action, {})
|
||||
.then((res) => {
|
||||
setRaw(res);
|
||||
setFacts(decodeFacts(res));
|
||||
setError(null);
|
||||
})
|
||||
.catch((err: unknown) => setError(actionFailure(err).message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [pluginId, action]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
return { facts, raw, loading, error, load };
|
||||
}
|
||||
|
||||
export interface PanelStatusSectionProps {
|
||||
pluginId: string;
|
||||
section: PanelSectionDto;
|
||||
onReload: () => void;
|
||||
}
|
||||
|
||||
export function PanelStatusSection({ pluginId, section }: PanelStatusSectionProps): ReactNode {
|
||||
const { facts, loading, error, load } = useSectionData(pluginId, section.source?.action ?? "");
|
||||
const fields = section.fields ?? [];
|
||||
const keys = fields.length > 0 ? fields.map((f) => f.key) : Object.keys(facts ?? {}).sort();
|
||||
|
||||
return (
|
||||
<Surface className="space-y-2">
|
||||
<SectionHeader
|
||||
title={section.title}
|
||||
hint="只读"
|
||||
actions={
|
||||
<Button size="sm" variant="ghost" disabled={loading} onClick={load}>
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{error ? <span className="block text-xs text-[var(--mu-err)]">{error}</span> : null}
|
||||
{loading && !facts ? <span className="block text-xs text-[var(--mu-text-lo)]">加载中…</span> : null}
|
||||
{!loading && facts !== null && keys.length === 0 ? <Notice>该事实面没有可显示的项目。</Notice> : null}
|
||||
<div className="divide-y divide-[var(--mu-glass-border)]">
|
||||
{keys.map((key) => {
|
||||
const field = fields.find((f) => f.key === key);
|
||||
const declared: PanelFieldDto = field ?? { key, type: "text" };
|
||||
return <KeyValueRow key={key} label={declared.label ?? key} value={readOnlyValue(declared, facts?.[key])} mono />;
|
||||
})}
|
||||
</div>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
export interface PanelLogSectionProps {
|
||||
pluginId: string;
|
||||
section: PanelSectionDto;
|
||||
}
|
||||
|
||||
export function PanelLogSection({ pluginId, section }: PanelLogSectionProps): ReactNode {
|
||||
const { raw, loading, error, load } = useSectionData(pluginId, section.source?.action ?? "");
|
||||
const limit = section.limit && section.limit > 0 ? section.limit : DEFAULT_LOG_LIMIT;
|
||||
// 最新在上(与 L1 的日志口径一致):后端给什么顺序都先倒一次,前端不假设顺序。
|
||||
const lines = decodeLogLines(raw).slice(-limit).reverse();
|
||||
|
||||
return (
|
||||
<Surface className="space-y-2">
|
||||
<SectionHeader
|
||||
title={section.title}
|
||||
hint={`只读 · 最近 ${limit} 条 · 最新在上`}
|
||||
actions={
|
||||
<Button size="sm" variant="ghost" disabled={loading} onClick={load}>
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{error ? <span className="block text-xs text-[var(--mu-err)]">{error}</span> : null}
|
||||
{loading && lines.length === 0 ? <span className="block text-xs text-[var(--mu-text-lo)]">加载中…</span> : null}
|
||||
{!loading && lines.length === 0 ? <Notice>暂无日志。</Notice> : null}
|
||||
{lines.map((line, i) => (
|
||||
<div key={`${line.time}-${i}`} className="flex gap-2 font-mono text-[11px] leading-relaxed">
|
||||
{line.time ? <span className="shrink-0 text-[var(--mu-text-lo)]">{line.time}</span> : null}
|
||||
<span className="min-w-0 break-all text-[var(--mu-text-mid)]">{line.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/** 插件页区块的统一标题条(四个区块共用:标题 + 可选右侧动作 + 可选提示)。
|
||||
* 抽出来是因为四个区块都要它(design/09 §5.2.1 的 section.* 都有 title)——
|
||||
* 复制四份不是选项。 */
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface SectionHeaderProps {
|
||||
title: string;
|
||||
/** 右侧动作区(刷新按钮 / 当前模式标记等)。 */
|
||||
actions?: ReactNode;
|
||||
/** 标题下的一行说明(如「最新在上」「只读」)。 */
|
||||
hint?: ReactNode;
|
||||
}
|
||||
|
||||
export function SectionHeader({ title, actions, hint }: SectionHeaderProps): ReactNode {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-sm font-semibold">{title}</h3>
|
||||
{hint ? <span className="text-[11px] text-[var(--mu-text-lo)]">{hint}</span> : null}
|
||||
<div className="ml-auto flex items-center gap-2">{actions}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/** `section.table` 区块:列 + 行内动作。
|
||||
*
|
||||
* 复用既有 `AdminTable`(含窗口化与空/错/加载三态),所以这里只做三件事:把列声明翻成
|
||||
* `AdminColumn`、把行内动作接上宿主动作端点、把反馈画出来(含 viewer 的只读说明)。
|
||||
* 状态机在 `tableState.ts`;纯助手 `cellText` / `rowIdentity` 从那里复出口(单测按
|
||||
* 本模块路径 import,换家不必改测试)。
|
||||
*
|
||||
* 对 viewer:后端快照已摘掉行内动作,这里**再摘一次**(纵深防御,见 `nav.canWrite`)。
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Button, Surface, Tag } from "../../components/primitives";
|
||||
import type { Role } from "../../lib/types";
|
||||
import { useAuditStore } from "../audit";
|
||||
import { AdminTable, Notice, type AdminColumn } from "../components";
|
||||
import { canWrite } from "./nav";
|
||||
import { SectionHeader } from "./SectionHeader";
|
||||
import { cellText, rowIdentity, useRowActionRunner, useTableRows, type PanelRow, type TableFlash } from "./tableState";
|
||||
import type { PanelSectionDto } from "./types";
|
||||
|
||||
export { cellText, rowIdentity };
|
||||
export type { PanelRow };
|
||||
|
||||
/** 反馈行:错误码与文案分两处(§5.4.6),成功/失败用不同色。 */
|
||||
function TableFlashRow({ flash }: { flash: TableFlash | null }): ReactNode {
|
||||
if (!flash) return null;
|
||||
return (
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
{flash.code ? <Tag tone={flash.tone === "ok" ? "ok" : "err"}>{flash.code}</Tag> : null}
|
||||
<span className={`text-xs ${flash.tone === "ok" ? "text-[var(--mu-ok)]" : "text-[var(--mu-err)]"}`}>
|
||||
{flash.text}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export interface PanelTableSectionProps {
|
||||
pluginId: string;
|
||||
section: PanelSectionDto;
|
||||
role: Role | null;
|
||||
onReload: () => void;
|
||||
/** 行内动作返回显式值对象时回调(reauth「把该条带回表单」,见 `formValuesOfResult`)。 */
|
||||
onValues?: (values: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function PanelTableSection({ pluginId, section, role, onReload, onValues }: PanelTableSectionProps): ReactNode {
|
||||
const log = useAuditStore((s) => s.log);
|
||||
const { rows, loading, error, load } = useTableRows(pluginId, section.source?.action ?? "");
|
||||
const { busy, flash, run } = useRowActionRunner({ pluginId, onReload, reloadRows: load, log, onValues });
|
||||
|
||||
const rowActions = section.rowActions ?? [];
|
||||
const canAct = canWrite(role) && rowActions.length > 0;
|
||||
const columns: AdminColumn<PanelRow>[] = (section.columns ?? []).map((c) => ({
|
||||
key: c.key,
|
||||
title: c.label ?? c.key,
|
||||
render: (row) => <span className="block max-w-xs truncate">{cellText(row.data[c.key])}</span>,
|
||||
}));
|
||||
if (canAct) {
|
||||
columns.push({
|
||||
key: "__actions",
|
||||
title: "操作",
|
||||
render: (row) => (
|
||||
<span className="flex flex-wrap gap-1">
|
||||
{rowActions.map((a) => (
|
||||
<Button key={a.id} size="sm" variant="ghost" disabled={busy !== null} onClick={() => run(a.action, a.label, row.data)}>
|
||||
{a.label}
|
||||
</Button>
|
||||
))}
|
||||
</span>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Surface className="space-y-2">
|
||||
<SectionHeader
|
||||
title={section.title}
|
||||
actions={
|
||||
<Button size="sm" variant="ghost" disabled={loading} onClick={load}>
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TableFlashRow flash={flash} />
|
||||
{!canWrite(role) ? <Notice>当前角色只读:表格可见,写动作(行内按钮)不显示。</Notice> : null}
|
||||
<AdminTable columns={columns} rows={rows} loading={loading} error={error} rowKey={(r) => r.key} emptyTitle="暂无数据" />
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/** 插件动作的公共口径:失败形状、错误码与文案分离、`optionsFrom` → 动作名映射。
|
||||
*
|
||||
* design/09 §5.4.6 要求失败**诚实**:结构化错误码 + 文案分离(前端可本地化),绝不
|
||||
* 把「没生效」说成「已保存」。所以这里把 `ApiError` 摊成 `{code, message}`,界面上
|
||||
* 错误码与文案分两处显示。
|
||||
*/
|
||||
|
||||
import { ApiError } from "../../lib/api/core";
|
||||
|
||||
export interface ActionFailure {
|
||||
/** 结构化错误码(`ApiError.code`,缺省用 HTTP 状态)。 */
|
||||
code: string;
|
||||
/** 人话文案(后端给的中文 message,或本地兜底)。 */
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function actionFailure(err: unknown): ActionFailure {
|
||||
if (err instanceof ApiError) return { code: err.code ?? String(err.status), message: err.message };
|
||||
return { code: "error", message: err instanceof Error ? err.message : "动作失败" };
|
||||
}
|
||||
|
||||
/** `optionsFrom`(`plugin:<name>`,可带点分层级)→ 宿主动作名。
|
||||
*
|
||||
* 设计只规定「只允许 `plugin:` 前缀」,没规定候选值由哪个动作提供;本实现取
|
||||
* **逐字加前缀**这一条最不容易猜错的映射:`plugin:providers` → `plugin.providers`、
|
||||
* `plugin:search.regions` → `plugin.search.regions`。这是契约待定处的**我方口径**,
|
||||
* 已登记在片报告里(后端按同一映射提供候选即可对齐)。 */
|
||||
export function resolveOptionsAction(optionsFrom: string): string {
|
||||
const name = optionsFrom.replace(/^plugin:/, "").trim();
|
||||
return name === "" ? "" : `plugin.${name}`;
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/** 插件表单区块的状态机(三个 hook,无 JSX)。
|
||||
*
|
||||
* 拆出来的理由有两条,都是硬约束:
|
||||
* ① 「函数 ≤80 行」(AGENTS §10)—— 表单组件把「值同步 + 候选加载 + 提交」塞进一个函数
|
||||
* 会到 170 行以上;
|
||||
* ② 「快照每 30s 重取一次也不能把用户正在输入的内容重置掉」这条行为只有在**依赖键**
|
||||
* 集中的一处才守得住,散在组件里迟早有人漏一个。
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { SchemaField } from "../schema/fieldTypes";
|
||||
import { loadFormValues, planSubmit } from "../schema/values";
|
||||
import { actionFailure, resolveOptionsAction } from "./actions";
|
||||
import { runPluginAction } from "./panelsApi";
|
||||
import { decodeOptions, type PanelFieldDto, type PanelOptionDto } from "./types";
|
||||
|
||||
export interface FormFlash {
|
||||
tone: "ok" | "err";
|
||||
text: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export interface FormValuesState {
|
||||
/** 控件里的当前值(一律字符串,与渠道配置表单同口径)。 */
|
||||
values: Record<string, string>;
|
||||
/** 打开表单时的值:判断「用户把盘上已有值清空了」必须知道它原本是什么。 */
|
||||
initial: Record<string, string>;
|
||||
/** 盘上已有值的键(含只有掩码可看的秘密)。 */
|
||||
configured: Set<string>;
|
||||
/** 显式勾选清除的键(掩码秘密唯一能清除的方式)。 */
|
||||
cleared: Set<string>;
|
||||
/** 本次提交被拦下时点名的必填字段。 */
|
||||
missing: Set<string>;
|
||||
setValue: (key: string, next: string) => void;
|
||||
toggleClear: (key: string, next: boolean) => void;
|
||||
markMissing: (keys: readonly string[]) => void;
|
||||
/** 提交成功后清掉「显式清除」勾选(否则开关会一直亮着、输入框一直禁用)。 */
|
||||
resetCleared: () => void;
|
||||
}
|
||||
|
||||
/** 表单值状态:外部值(快照 / 行内动作带回)变了才重置,其余时候保持用户输入。
|
||||
*
|
||||
* 用**渲染期派生 state** 而不是 useEffect 做首次装载:初次渲染(含服务端渲染)就该是
|
||||
* 「打开即显示现状」,而 effect 只在客户端跑,会让首帧先画一个空表单再跳成有值的。
|
||||
* `fieldsKey` / `externalKey` 用 JSON 串是因为快照每 30s 重取一次、对象身份每次都变。 */
|
||||
export function useFormValues(
|
||||
fields: readonly SchemaField[],
|
||||
fieldsKey: string,
|
||||
external: Record<string, unknown>,
|
||||
): FormValuesState {
|
||||
const externalKey = JSON.stringify(external);
|
||||
const loadedKey = `${fieldsKey}|${externalKey}`;
|
||||
const loaded = useMemo(() => loadFormValues(fields, external), [loadedKey]);
|
||||
const [values, setValues] = useState<Record<string, string>>(loaded.values);
|
||||
const [initial, setInitial] = useState<Record<string, string>>(loaded.values);
|
||||
const [configured, setConfigured] = useState<string[]>(loaded.configured);
|
||||
const [cleared, setCleared] = useState<string[]>([]);
|
||||
const [missing, setMissing] = useState<string[]>([]);
|
||||
const [syncedKey, setSyncedKey] = useState(loadedKey);
|
||||
|
||||
if (syncedKey !== loadedKey) {
|
||||
setSyncedKey(loadedKey);
|
||||
setValues(loaded.values);
|
||||
setInitial(loaded.values);
|
||||
setConfigured(loaded.configured);
|
||||
setCleared([]);
|
||||
setMissing([]);
|
||||
}
|
||||
|
||||
const setValue = useCallback((key: string, next: string) => {
|
||||
setValues((prev) => ({ ...prev, [key]: next }));
|
||||
}, []);
|
||||
const toggleClear = useCallback((key: string, next: boolean) => {
|
||||
setCleared((prev) => (next ? [...new Set([...prev, key])] : prev.filter((k) => k !== key)));
|
||||
}, []);
|
||||
const markMissing = useCallback((keys: readonly string[]) => {
|
||||
setMissing([...keys]);
|
||||
}, []);
|
||||
const resetCleared = useCallback(() => setCleared([]), []);
|
||||
|
||||
return {
|
||||
values,
|
||||
initial,
|
||||
configured: useMemo(() => new Set(configured), [configured]),
|
||||
cleared: useMemo(() => new Set(cleared), [cleared]),
|
||||
missing: useMemo(() => new Set(missing), [missing]),
|
||||
setValue,
|
||||
toggleClear,
|
||||
markMissing,
|
||||
resetCleared,
|
||||
};
|
||||
}
|
||||
|
||||
export interface FieldOptionsState {
|
||||
options: Record<string, readonly PanelOptionDto[]>;
|
||||
/** `optionsFrom` → 失败原因(动作名 + 错误码)。取不到不是致命错误,但**要说出来**。 */
|
||||
optionErrors: Record<string, string>;
|
||||
}
|
||||
|
||||
/** 下拉候选:按 `optionsFrom` 逐条取一次(取不到就降级成文本框,由调用方在说明里写明)。 */
|
||||
export function useFieldOptions(pluginId: string, declared: readonly PanelFieldDto[]): FieldOptionsState {
|
||||
const [options, setOptions] = useState<Record<string, readonly PanelOptionDto[]>>({});
|
||||
const [optionErrors, setOptionErrors] = useState<Record<string, string>>({});
|
||||
const fromKeys = Array.from(new Set(declared.map((f) => f.optionsFrom ?? "").filter((k) => k !== ""))).join("|");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const keys = fromKeys === "" ? [] : fromKeys.split("|");
|
||||
for (const key of keys) {
|
||||
const action = resolveOptionsAction(key);
|
||||
if (action === "") continue;
|
||||
runPluginAction(pluginId, action, {})
|
||||
.then((raw) => {
|
||||
if (cancelled) return;
|
||||
setOptions((prev) => ({ ...prev, [key]: decodeOptions(raw) }));
|
||||
setOptionErrors((prev) => ({ ...prev, [key]: "" }));
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return;
|
||||
setOptionErrors((prev) => ({ ...prev, [key]: `${action}:${actionFailure(err).code}` }));
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [pluginId, fromKeys]);
|
||||
|
||||
return { options, optionErrors };
|
||||
}
|
||||
|
||||
export interface FormSubmitState {
|
||||
submit: () => void;
|
||||
busy: boolean;
|
||||
flash: FormFlash | null;
|
||||
}
|
||||
|
||||
export interface FormSubmitArgs {
|
||||
pluginId: string;
|
||||
action: string;
|
||||
fields: readonly SchemaField[];
|
||||
state: FormValuesState;
|
||||
onReload: () => void;
|
||||
log: (what: string, result: "ok" | "error") => void;
|
||||
}
|
||||
|
||||
/** 提交:本地先跑必填与类型校验(拦下「填了却起不来」),失败**如实**报错码与文案。 */
|
||||
export function useFormSubmit({ pluginId, action, fields, state, onReload, log }: FormSubmitArgs): FormSubmitState {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [flash, setFlash] = useState<FormFlash | null>(null);
|
||||
const { values, initial, cleared, configured, markMissing, resetCleared } = state;
|
||||
|
||||
const submit = useCallback(() => {
|
||||
if (action === "") return;
|
||||
const plan = planSubmit(fields, values, initial, [...cleared], [...configured]);
|
||||
if (!plan.ok) {
|
||||
markMissing(plan.kind === "missing" ? plan.missing.map((f) => f.key) : []);
|
||||
setFlash({
|
||||
tone: "err",
|
||||
text: plan.message,
|
||||
code: plan.kind === "missing" ? "missing_required" : "invalid_value",
|
||||
});
|
||||
return;
|
||||
}
|
||||
markMissing([]);
|
||||
setBusy(true);
|
||||
setFlash(null);
|
||||
runPluginAction(pluginId, action, { values: plan.patch })
|
||||
.then(() => {
|
||||
log(`plugin.${pluginId}/${action}`, "ok");
|
||||
setFlash({ tone: "ok", text: "已提交,正在刷新页面数据…" });
|
||||
resetCleared();
|
||||
onReload();
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const failure = actionFailure(err);
|
||||
log(`plugin.${pluginId}/${action}`, "error");
|
||||
setFlash({ tone: "err", text: failure.message, code: failure.code });
|
||||
})
|
||||
.finally(() => setBusy(false));
|
||||
}, [action, pluginId, fields, values, initial, cleared, configured, markMissing, resetCleared, onReload, log]);
|
||||
|
||||
return { submit, busy, flash };
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/** 插件面板的导航模型助手(纯函数):分组常量、图标白名单、稳定序、角色复检。
|
||||
*
|
||||
* 与 `admin/navModel.ts` 的分工:这里只处理**插件侧**的条目形状与排序;把「核心分组 +
|
||||
* 插件分组」合成一张侧边栏模型是 navModel.ts 的事。
|
||||
*/
|
||||
|
||||
import type { IconName } from "../../components/Icon";
|
||||
import type { Role } from "../../lib/types";
|
||||
import { adminPluginPath, adminRoutePath, type AdminEntry } from "../router";
|
||||
import { PLUGIN_GROUP_ID, type PanelEntryDto, type PanelSectionDto } from "./types";
|
||||
|
||||
export { PLUGIN_GROUP_ID };
|
||||
|
||||
/** 分组显示名走**宿主文案**(§5.2 的 `group` 只是分组 id,分组名不在插件声明面里)。
|
||||
*
|
||||
* 优先用后端下发的 `groupTitle`(`panelGroupTitle` 是宿主文案的单一来源),这里只是
|
||||
* 快照没给 title 时的兜底。它与核心模块「插件与能力」刻意用不同字面量,避免侧边栏里
|
||||
* 两个同名条目 —— 后端取的是「插件」二字,前端兜底跟它对齐(改口径只需改后端一处)。 */
|
||||
export const PLUGIN_GROUP_LABEL = "插件";
|
||||
|
||||
/** 宿主内置图标集中**可供插件声明的子集**。
|
||||
*
|
||||
* 为什么是子集而不是全集:`Icon.tsx` 的图标表没有运行时导出,前端拿不到「合法名字」
|
||||
* 这个集合,只能在这里显式列出能兑现的那批。声明了集合外的名字(含设计示例里的
|
||||
* `search` —— 它当前不在图标表里)一律回落 `plugin`,而不是画一个空 `<svg>`
|
||||
* (不知名的图标会渲染成空白,那比回落更难排查)。 */
|
||||
const ICON_ALLOW: Readonly<Record<string, IconName>> = {
|
||||
plugin: "plugin",
|
||||
chart: "chart",
|
||||
chat: "chat",
|
||||
docs: "docs",
|
||||
channel: "channel",
|
||||
device: "device",
|
||||
robot: "robot",
|
||||
clock: "clock",
|
||||
agent: "agent",
|
||||
trend: "trend",
|
||||
shield: "shield",
|
||||
storage: "storage",
|
||||
gear: "gear",
|
||||
package: "package",
|
||||
folder: "folder",
|
||||
file: "file",
|
||||
users: "users",
|
||||
settings: "settings",
|
||||
light: "light",
|
||||
terminal: "terminal",
|
||||
vision: "vision",
|
||||
refresh: "refresh",
|
||||
check: "check",
|
||||
memory: "memory",
|
||||
dna: "dna",
|
||||
home: "home",
|
||||
};
|
||||
|
||||
/** 声明里的图标名 → 宿主可用图标;认不出来就回落 `plugin`。 */
|
||||
export function panelIconName(raw: string | undefined): IconName {
|
||||
const name = (raw ?? "").trim();
|
||||
return ICON_ALLOW[name] ?? "plugin";
|
||||
}
|
||||
|
||||
/** 侧边栏条目的稳定 key(核心模块用 `section:<id>`,插件页用 `plugin:<id>/<panel>`)。 */
|
||||
export function panelNavKey(pluginId: string, panelId: string): string {
|
||||
return `plugin:${pluginId}/${panelId}`;
|
||||
}
|
||||
|
||||
/** 条目的绝对路径:**优先用后端下发的 `route`**(路由口径由后端单点决定),
|
||||
* 没给才按 `/plugin/<pluginId>/<panelId>` 自己拼。 */
|
||||
export function panelPathOf(entry: PanelEntryDto, adminEntry: AdminEntry | null): string {
|
||||
const route = (entry.route ?? "").trim();
|
||||
if (route !== "") return adminRoutePath(route, adminEntry);
|
||||
return adminPluginPath(entry.pluginId, entry.id, adminEntry);
|
||||
}
|
||||
|
||||
/** 组内稳定序:order 升序,同 order 按**声明顺序**(后端 map 迭代顺序不能泄漏到导航里,
|
||||
* 否则同一份声明在不同进程里排不同 —— 侧边栏抖动是真实体验问题)。 */
|
||||
export function sortPanelEntries(entries: readonly PanelEntryDto[]): PanelEntryDto[] {
|
||||
return entries
|
||||
.map((entry, index) => ({ entry, index }))
|
||||
.sort((a, b) => {
|
||||
const oa = a.entry.order ?? 0;
|
||||
const ob = b.entry.order ?? 0;
|
||||
if (oa !== ob) return oa - ob;
|
||||
return a.index - b.index;
|
||||
})
|
||||
.map((x) => x.entry);
|
||||
}
|
||||
|
||||
/** 在快照里找一条面板(找不到 = 未启用 / 没声明 / 未安装)。 */
|
||||
export function findPanelEntry(
|
||||
entries: readonly PanelEntryDto[],
|
||||
target: { pluginId: string; panelId: string },
|
||||
): PanelEntryDto | null {
|
||||
return entries.find((e) => e.pluginId === target.pluginId && e.id === target.panelId) ?? null;
|
||||
}
|
||||
|
||||
/** 写权限(design/09 §5.4.1):写动作一律 owner / admin,viewer 只读。 */
|
||||
export function canWrite(role: Role | null): boolean {
|
||||
return role === "owner" || role === "admin";
|
||||
}
|
||||
|
||||
/** 按角色复检区块(**与后端 `paneldecl.filterPanel` 同口径的第二道闸**)。
|
||||
*
|
||||
* 后端已经在快照里按调用者角色裁过一遍,这里再裁一次是**纵深防御**:前端拿着快照直接
|
||||
* 渲染,若哪天后端过滤被绕过(或契约漂移),viewer 也不会在前端拿到写面。
|
||||
* - viewer:丢掉 `form`(唯一自带写面的类型),表格摘掉行内动作;status / log 照留;
|
||||
* - owner / admin:原样(不能顺手削掉有写权的人)。
|
||||
*/
|
||||
export function viewerVisibleSections(panel: PanelEntryDto, role: Role | null): PanelSectionDto[] {
|
||||
const sections = panel.sections ?? [];
|
||||
if (canWrite(role)) return sections;
|
||||
return sections
|
||||
.filter((s) => s.type !== "form")
|
||||
.map((s) => (s.rowActions && s.rowActions.length > 0 ? { ...s, rowActions: undefined } : s));
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/** 插件面板的数据面客户端 —— 全部经**插件管理器**的端点,面板不直连 `plugins.json`。
|
||||
*
|
||||
* - `GET /api/v1/admin/panels`:按调用者角色过滤后的接入点快照(唯一契约面;
|
||||
* 线形状见 `types.ts`,实现在 `internal/services/gateway/admin_panels.go`);
|
||||
* - `POST /api/v1/admin/plugins/{pluginId}/actions/{action}`:动作分发。插件声明的只是
|
||||
* **动作名**、不是 URL(design/09 §5.4.4),所以这里永远拼不出任意请求面。
|
||||
* 响应是 `{pluginId, action, kind, result}` 信封 —— 取数据要先解 `result`(见 `types.ts`)。
|
||||
*
|
||||
* **请求体形状(本片口径,待 PLG-4 的执行器对齐)**:`{values?: {…}, row?: {…}}`。
|
||||
* 表单提交带 `values`(已按声明类型还原),表格行内动作带 `row`(该行的原始对象)。
|
||||
* 网关把请求体**原样**交给插件执行器(`plugin.ActionRequest.Payload`,它不解释插件的数据
|
||||
* 形状),所以这三个键是对齐点:执行器按 `values` / `row` 取值即可。
|
||||
*
|
||||
* `panelsUnavailableToEmpty` 是「后端还没接线」的诚实降级:501/404 当空快照(插件分组不出现),
|
||||
* 而不是把一个未实现的端点显示成「加载失败」。其余错误照常冒泡给调用方。 */
|
||||
|
||||
import { isNotImplemented, request } from "../../lib/api/core";
|
||||
import { decodePanelEntries, type PanelEntryDto } from "./types";
|
||||
|
||||
export const PANELS_PATH = "/admin/panels";
|
||||
|
||||
/** 动作端点的路径(插件 id / 动作名都是声明面校验过的字符集,这里再转义一次兜底)。 */
|
||||
export function pluginActionPath(pluginId: string, action: string): string {
|
||||
return `/admin/plugins/${encodeURIComponent(pluginId)}/actions/${encodeURIComponent(action)}`;
|
||||
}
|
||||
|
||||
export interface PluginActionPayload {
|
||||
/** 表单/动作的值(已按声明类型还原:number → 数字、switch → 布尔、multiSelect → 数组)。 */
|
||||
values?: Record<string, unknown>;
|
||||
/** 行内动作所在的行(表格行动作把它带回去,插件才知道操作的是哪一条)。 */
|
||||
row?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 面板快照。未接线(404/501)⇒ 空快照;其它错误抛出(调用方如实显示)。 */
|
||||
export function fetchAdminPanels(): Promise<PanelEntryDto[]> {
|
||||
return request<unknown>(PANELS_PATH).then(decodePanelEntries);
|
||||
}
|
||||
|
||||
/** 后端「还没接这个端点」⇒ 当空快照,而不是把未实现显示成故障。 */
|
||||
export function panelsUnavailableToEmpty(err: unknown, fallback: PanelEntryDto[] = []): PanelEntryDto[] {
|
||||
if (isNotImplemented(err)) return fallback;
|
||||
throw err;
|
||||
}
|
||||
|
||||
/** 触发一个插件动作。返回值是插件的结构化结果(调用方按区块类型解码)。 */
|
||||
export function runPluginAction(
|
||||
pluginId: string,
|
||||
action: string,
|
||||
payload: PluginActionPayload = {},
|
||||
): Promise<unknown> {
|
||||
return request<unknown>(pluginActionPath(pluginId, action), {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
// 插件面板**数据面**的纯逻辑测试:宽容解码 + 只读渲染口径 + 角色复检。
|
||||
//
|
||||
// 契约层(`GET /api/v1/admin/panels` + 动作端点)与后端是**跨切片**的,所以这里刻意
|
||||
// 钉两类东西:
|
||||
// ① 形状对齐 —— 设计示例(design/09 §5.2)必须逐字段解出来;
|
||||
// ② 越界不崩、秘密不泄漏 —— 未知 section 类型不渲染、`secret` 在事实面只显示「已配置」、
|
||||
// viewer 拿不到写面(与后端 `paneldecl.filterPanel` 同口径的第二道闸)。
|
||||
//
|
||||
// 跑法:
|
||||
//
|
||||
// cd frontend/web \
|
||||
// && ./node_modules/.bin/esbuild src/admin/plugin/pluginData.test.ts \
|
||||
// --bundle --platform=node --format=esm --outfile=/tmp/cyrene-plugin-data.test.mjs \
|
||||
// && node /tmp/cyrene-plugin-data.test.mjs
|
||||
|
||||
import { ApiError } from "../../lib/api/core";
|
||||
import { actionFailure, resolveOptionsAction } from "./actions";
|
||||
import { canWrite, viewerVisibleSections } from "./nav";
|
||||
import {
|
||||
decodeFacts,
|
||||
decodeLogLines,
|
||||
decodeOptions,
|
||||
decodePanelEntries,
|
||||
decodePanelSnapshot,
|
||||
decodeRows,
|
||||
formValuesOfResult,
|
||||
type PanelEntryDto,
|
||||
} from "./types";
|
||||
import { cellText, rowIdentity } from "./TableSection";
|
||||
import { DEFAULT_LOG_LIMIT, readOnlyValue } from "./ReadOnlySections";
|
||||
|
||||
let checks = 0;
|
||||
|
||||
function ok(cond: boolean, what: string): void {
|
||||
checks += 1;
|
||||
if (!cond) throw new Error(`FAILED: ${what}`);
|
||||
}
|
||||
|
||||
function eq(got: unknown, want: unknown, what: string): void {
|
||||
const a = JSON.stringify(got);
|
||||
const b = JSON.stringify(want);
|
||||
ok(a === b, `${what}(实际 ${a},期望 ${b})`);
|
||||
}
|
||||
|
||||
/** 后端(`internal/services/gateway/admin_panels.go`)的真实快照形状:
|
||||
* data = `{role, groups:[{id,title,order,items:[…]}], pluginIds:[…]}`。 */
|
||||
const SNAPSHOT = {
|
||||
role: "owner",
|
||||
groups: [
|
||||
{
|
||||
id: "plugins",
|
||||
title: "插件",
|
||||
order: 20,
|
||||
items: [
|
||||
{
|
||||
pluginId: "web-search",
|
||||
id: "providers",
|
||||
title: "搜索提供商",
|
||||
description: "为 web-search 能力配置多个第三方搜索后端",
|
||||
icon: "search",
|
||||
order: 20,
|
||||
route: "plugin/web-search/providers",
|
||||
sections: [
|
||||
{
|
||||
type: "form",
|
||||
id: "add",
|
||||
title: "添加提供商",
|
||||
fields: [
|
||||
{ key: "provider", label: "提供商", type: "select", optionsFrom: "plugin:providers", required: true },
|
||||
{ key: "apiKey", label: "API Key", type: "secret", placeholder: "留空 = 保持原值" },
|
||||
{ key: "baseUrl", label: "接口地址", type: "text", placeholder: "https://..." },
|
||||
{ key: "enabled", label: "启用", type: "switch" },
|
||||
],
|
||||
submit: { action: "plugin.saveProvider" },
|
||||
},
|
||||
{
|
||||
type: "table",
|
||||
id: "list",
|
||||
title: "已配置的提供商",
|
||||
source: { action: "plugin.listProviders" },
|
||||
columns: [
|
||||
{ key: "provider", label: "提供商" },
|
||||
{ key: "state", label: "状态" },
|
||||
],
|
||||
rowActions: [
|
||||
{ id: "test", label: "测试连通", action: "plugin.testProvider" },
|
||||
{ id: "reauth", label: "重配凭据", action: "plugin.reauthProvider" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
pluginIds: ["web-search"],
|
||||
};
|
||||
|
||||
/** 平铺形状(容错口径)用的一条条目。 */
|
||||
const DESIGN_EXAMPLE = { ...SNAPSHOT.groups[0].items[0] };
|
||||
|
||||
/** 动作响应的信封(网关 `adminPluginAction`):结果在 `result` 里。 */
|
||||
function actionEnvelope(result: unknown): unknown {
|
||||
return { pluginId: "web-search", action: "plugin.listProviders", kind: "read", result };
|
||||
}
|
||||
|
||||
// ── ① 真实快照形状逐字段解出来(与 gateway 的 AdminPanelsPayload 同形) ─────────
|
||||
{
|
||||
const snap = decodePanelSnapshot(SNAPSHOT);
|
||||
eq(snap.role, "owner", "回显本次快照按哪个角色过滤(便于确认裁剪档)");
|
||||
eq(snap.groups.length, 1, "一个分组");
|
||||
eq(snap.groups[0].id, "plugins", "分组 id");
|
||||
eq(snap.groups[0].title, "插件", "分组显示名来自后端宿主文案(前端不自造)");
|
||||
eq(snap.groups[0].items.length, 1, "分组内条目");
|
||||
|
||||
const entries = decodePanelEntries(SNAPSHOT);
|
||||
eq(entries.length, 1, "平铺后一条");
|
||||
const p = entries[0];
|
||||
eq(p.pluginId, "web-search", "pluginId 随条目下发(组成动态路由)");
|
||||
eq(p.id, "providers", "面板 id");
|
||||
eq(p.order, 20, "order");
|
||||
eq(p.route, "plugin/web-search/providers", "后端下发的相对路由片段");
|
||||
eq(p.group, "plugins", "分组信息由外层 group 补进条目");
|
||||
eq(p.groupTitle, "插件", "分组显示名同样补进来");
|
||||
eq(p.sections?.length, 2, "两个区块");
|
||||
eq(p.sections?.[0].type, "form", "form 区块");
|
||||
eq(p.sections?.[0].fields?.length, 4, "四个字段");
|
||||
eq(p.sections?.[0].fields?.[0].optionsFrom, "plugin:providers", "optionsFrom");
|
||||
eq(p.sections?.[0].fields?.[1].type, "secret", "secret 字段");
|
||||
eq(p.sections?.[0].submit?.action, "plugin.saveProvider", "submit.action");
|
||||
eq(p.sections?.[1].source?.action, "plugin.listProviders", "source.action");
|
||||
eq(p.sections?.[1].columns?.map((c) => c.key), ["provider", "state"], "列");
|
||||
eq(p.sections?.[1].rowActions?.map((a) => a.id), ["test", "reauth"], "行内动作");
|
||||
}
|
||||
|
||||
// ── ② 宽容解码:平铺形状也认,坏数据不崩、不猜 ──────────────────────────────────
|
||||
{
|
||||
eq(decodePanelEntries([DESIGN_EXAMPLE]).length, 1, "裸数组(平铺)也认");
|
||||
eq(decodePanelEntries({ panels: [DESIGN_EXAMPLE] }).length, 1, "{panels}(平铺)也认");
|
||||
eq(decodePanelEntries({ panels: [DESIGN_EXAMPLE] })[0].group, "plugins", "平铺兜底归到唯一分组(条目没带 group)");
|
||||
eq(decodePanelEntries({}).length, 0, "空对象 ⇒ 空");
|
||||
eq(decodePanelEntries(null).length, 0, "null ⇒ 空");
|
||||
eq(decodePanelEntries("oops").length, 0, "非对象 ⇒ 空");
|
||||
eq(decodePanelEntries({ groups: [{ id: "plugins", items: [] }] }).length, 0, "空分组不产出空分组(不画一个没内容的折叠标题)");
|
||||
eq(decodePanelEntries({ panels: [{ id: "no-plugin" }] }).length, 0, "缺 pluginId 的条目被丢掉(没有命名空间就没有路由)");
|
||||
eq(decodePanelEntries({ panels: [{ pluginId: "p" }] }).length, 0, "缺 id 的条目被丢掉");
|
||||
|
||||
const bad = decodePanelEntries({
|
||||
panels: [
|
||||
{
|
||||
pluginId: "p",
|
||||
id: "x",
|
||||
title: "T",
|
||||
sections: [
|
||||
{ type: "iframe", id: "escape", title: "逃生舱" },
|
||||
{ type: "form", id: "ok", title: "表单", fields: [{ key: "a", type: "number" }, { key: "b", type: "wat" }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
eq(bad[0].sections?.length, 1, "闭集外的 section 类型被丢掉(不半渲染)");
|
||||
eq(bad[0].sections?.[0].fields?.length, 1, "闭集外的字段类型被丢掉");
|
||||
eq(decodePanelEntries({ panels: [{ pluginId: "p", id: "x" }] })[0].title, "x", "缺 title 回落 id(不显示空标题)");
|
||||
}
|
||||
|
||||
// ── ③ 动作结果解码:先解 result 信封,再取行 / 日志 / 事实 / 候选 ─────────────────
|
||||
{
|
||||
eq(decodeRows([{ a: 1 }]), [{ a: 1 }], "裸数组");
|
||||
eq(decodeRows(actionEnvelope({ rows: [{ a: 1 }] })), [{ a: 1 }], "**result 信封里的 rows**(忘了解封就会显示空表)");
|
||||
eq(decodeRows(actionEnvelope([{ a: 1 }])), [{ a: 1 }], "result 直接是数组");
|
||||
eq(decodeRows({ rows: [{ a: 1 }] }), [{ a: 1 }], "{rows}");
|
||||
eq(decodeRows({ rows: [1, "x", null, { a: 1 }] }), [{ a: 1 }], "非对象元素被丢掉(表格只认对象行)");
|
||||
eq(decodeRows(undefined), [], "取不到就是空表,不猜");
|
||||
|
||||
eq(decodeLogLines(["a", "b"]), [{ time: "", text: "a" }, { time: "", text: "b" }], "字符串数组");
|
||||
eq(decodeLogLines({ lines: [{ time: "10:00", message: "hi" }] }), [{ time: "10:00", text: "hi" }], "对象行取 time+message");
|
||||
eq(decodeLogLines(actionEnvelope({ lines: ["x"] })), [{ time: "", text: "x" }], "日志也先解 result");
|
||||
eq(decodeLogLines({ entries: [{ k: 1 }] })[0].text, '{"k":1}', "认不出的就原样 JSON(不显示空白行)");
|
||||
eq(decodeLogLines(null), [], "取不到 ⇒ 空");
|
||||
|
||||
eq(decodeFacts({ facts: { a: 1 } }), { a: 1 }, "{facts}");
|
||||
eq(decodeFacts(actionEnvelope({ active: "searxng" })), { active: "searxng" }, "事实面在 result 里");
|
||||
eq(decodeFacts(actionEnvelope({ facts: { a: 1 } })), { a: 1 }, "result.facts");
|
||||
eq(decodeFacts([1]), {}, "数组不是事实面");
|
||||
|
||||
eq(decodeOptions([{ value: "searxng", label: "SearXNG" }]), [{ value: "searxng", label: "SearXNG" }], "对象候选");
|
||||
eq(decodeOptions(actionEnvelope({ options: ["a", "b"] })), [{ value: "a", label: "a" }, { value: "b", label: "b" }], "result.options 里的字符串候选");
|
||||
eq(decodeOptions({ options: [{ id: "x" }] }), [{ value: "x", label: "x" }], "id 别名 + label 回落");
|
||||
eq(decodeOptions({ rows: [{ value: "" }] }), [], "空值候选丢掉");
|
||||
|
||||
// 行内动作「带回表单」只认显式键。
|
||||
eq(formValuesOfResult(actionEnvelope({ values: { provider: "searxng" } })), { provider: "searxng" }, "result.values");
|
||||
eq(formValuesOfResult(actionEnvelope({ formValues: { a: 1 } })), { a: 1 }, "result.formValues");
|
||||
eq(formValuesOfResult(actionEnvelope({ provider: "searxng", state: "ok" })), null, "**不猜**:平铺的行对象不当表单值");
|
||||
eq(formValuesOfResult(actionEnvelope("ok")), null, "非对象不当表单值");
|
||||
}
|
||||
|
||||
// ── ④ 表格助手:单元格文本 + 行身份 ───────────────────────────────────────────
|
||||
{
|
||||
eq(cellText(undefined), "—", "缺失显示占位而不是 undefined");
|
||||
eq(cellText(""), "—", "空串同理");
|
||||
eq(cellText(0), "0", "0 是有效值(不能当成空)");
|
||||
eq(cellText(false), "false", "false 是有效值");
|
||||
eq(cellText(["a", "b"]), "a、b", "数组可读");
|
||||
eq(cellText([]), "—", "空数组是空");
|
||||
eq(cellText({ a: 1 }), '{"a":1}', "对象退化成 JSON,不出现 [object Object]");
|
||||
|
||||
// 行身份:真实键优先;没有就用零填充下标,保证「按 rowKey 排序」不打乱后端顺序。
|
||||
const noKeyRows = Array.from({ length: 11 }, () => ({ v: 1 }));
|
||||
const ids = noKeyRows.map((r, i) => rowIdentity(r, i));
|
||||
eq([...ids].sort().join(","), ids.join(","), "零填充下标排序后与声明顺序一致(10 不会跑到 2 前面)");
|
||||
eq(rowIdentity({ id: "a" }, 0), "id:a", "优先用 id");
|
||||
eq(rowIdentity({ provider: "searxng" }, 3), "provider:searxng", "其次 provider(表里就是实体)");
|
||||
eq(rowIdentity({ id: 7 }, 0), "id:7", "数字键也可用");
|
||||
}
|
||||
|
||||
// ── ⑤ 只读渲染:掩码永不回显 ─────────────────────────────────────────────────
|
||||
{
|
||||
eq(readOnlyValue({ key: "apiKey", type: "secret" }, "****cdef"), "已配置(掩码不回显)", "secret 有值 ⇒ 只说已配置");
|
||||
eq(readOnlyValue({ key: "apiKey", type: "secret" }, ""), "未配置", "secret 空 ⇒ 未配置");
|
||||
eq(readOnlyValue({ key: "enabled", type: "switch" }, true), "开", "开关显示开/关");
|
||||
eq(readOnlyValue({ key: "enabled", type: "switch" }, false), "关", "false 也是有效值(不能显示成 —)");
|
||||
eq(readOnlyValue({ key: "regions", type: "multiSelect" }, ["cn", "us"]), "cn、us", "多选可读");
|
||||
eq(readOnlyValue({ key: "endpoint", type: "text" }, ""), "—", "普通字段空值占位");
|
||||
eq(DEFAULT_LOG_LIMIT > 0, true, "日志默认条数是正数(声明没给 limit 时用它)");
|
||||
}
|
||||
|
||||
// ── ⑥ 角色复检:viewer 拿不到写面(与后端 filterPanel 同口径) ─────────────────
|
||||
{
|
||||
const panel: PanelEntryDto = decodePanelEntries({ panels: [DESIGN_EXAMPLE] })[0];
|
||||
eq(canWrite("owner"), true, "owner 可写");
|
||||
eq(canWrite("admin"), true, "admin 可写");
|
||||
eq(canWrite("viewer"), false, "viewer 只读");
|
||||
eq(canWrite(null), false, "未知角色 fail-closed(不猜权限)");
|
||||
|
||||
eq(viewerVisibleSections(panel, "owner").map((s) => s.type), ["form", "table"], "owner ⇒ 原样");
|
||||
const forViewer = viewerVisibleSections(panel, "viewer");
|
||||
eq(forViewer.map((s) => s.type), ["table"], "viewer ⇒ 丢掉唯一自带写面的 form");
|
||||
eq(forViewer[0].rowActions, undefined, "viewer ⇒ 表格的行内动作被摘掉(列继续可看)");
|
||||
eq(viewerVisibleSections(panel, "viewer")[0].columns?.length, 2, "摘动作不影响列");
|
||||
eq(viewerVisibleSections(panel, null).map((s) => s.type), ["table"], "认不出的角色按只读处理");
|
||||
eq(viewerVisibleSections({ ...panel, sections: [{ type: "form", id: "f", title: "F" }] }, "viewer").length, 0, "纯写面板对 viewer 空 ⇒ 调用方显示「没有可显示区块」");
|
||||
}
|
||||
|
||||
// ── ⑦ 动作口径:错误码与文案分离 + optionsFrom 映射 ───────────────────────────
|
||||
{
|
||||
eq(actionFailure(new ApiError(403, "forbidden", "无权限")), { code: "forbidden", message: "无权限" }, "结构化错误码与文案分开");
|
||||
eq(actionFailure(new ApiError(0, undefined, "网络请求失败")), { code: "0", message: "网络请求失败" }, "无 code 时回落状态码(不编造错误码)");
|
||||
eq(actionFailure(new Error("boom")), { code: "error", message: "boom" }, "非 ApiError 兜底");
|
||||
|
||||
eq(resolveOptionsAction("plugin:providers"), "plugin.providers", "plugin:x → plugin.x");
|
||||
eq(resolveOptionsAction("plugin:search.regions"), "plugin.search.regions", "点分层级保留");
|
||||
eq(resolveOptionsAction("plugin:"), "", "空名字 ⇒ 空动作(调用方会跳过)");
|
||||
}
|
||||
|
||||
console.log(`plugin data test: ${checks} checks passed`);
|
||||
@@ -0,0 +1,33 @@
|
||||
/** 插件页的路由订阅(面板路由是 history 驱动的,见 `../router.ts`)。
|
||||
*
|
||||
* 为什么插件页要自己订阅路由:`AdminApp` 只从 `App.tsx` 拿到 `section`(核心模块 id),
|
||||
* 插件页没有对应的静态 id —— 它的身份就是 URL 里的动态两段。`useSyncExternalStore`
|
||||
* 保证浏览器前进/后退与本模块的 `pushState` 都能让页面重渲染,且不对 `App.tsx` 提要求
|
||||
* (那里继续只传 `section`,`undefined` 即「这是插件页宿主段」)。
|
||||
*/
|
||||
|
||||
import { useMemo, useSyncExternalStore } from "react";
|
||||
import {
|
||||
parseAdminRoute,
|
||||
readAdminEntry,
|
||||
subscribeAdminRoute,
|
||||
type AdminRoute,
|
||||
type PluginTarget,
|
||||
} from "../router";
|
||||
|
||||
/** 当前 pathname(服务端渲染时没有 window ⇒ 空串 ⇒ 解析成「不在面板里」)。 */
|
||||
function currentPath(): string {
|
||||
return typeof window === "undefined" ? "" : window.location.pathname;
|
||||
}
|
||||
|
||||
/** 当前面板路由(含插件页目标)。 */
|
||||
export function useAdminRoute(): AdminRoute {
|
||||
const pathname = useSyncExternalStore(subscribeAdminRoute, currentPath, () => "");
|
||||
const entry = readAdminEntry();
|
||||
return useMemo(() => parseAdminRoute(pathname, entry), [pathname, entry]);
|
||||
}
|
||||
|
||||
/** 当前插件页目标;不在插件页时为 null。 */
|
||||
export function usePluginTarget(): PluginTarget | null {
|
||||
return useAdminRoute().pluginTarget;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/** 插件表格区块的状态与助手(无 JSX)。
|
||||
*
|
||||
* 拆出来的理由同 `formState.ts`:守「函数 ≤80 行」,并把「取数」与「行动作」两条
|
||||
* 独立的状态机分开(各自的失败处理不同,混在一起就会长成一个巨型组件)。
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { actionFailure } from "./actions";
|
||||
import { runPluginAction } from "./panelsApi";
|
||||
import { decodeRows, formValuesOfResult } from "./types";
|
||||
|
||||
export interface PanelRow {
|
||||
key: string;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TableFlash {
|
||||
tone: "ok" | "err";
|
||||
text: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
/** 单元格文本:标量直出,数组/对象退化成可读文本(不渲染 `[object Object]`)。 */
|
||||
export function cellText(v: unknown): string {
|
||||
if (v === null || v === undefined) return "—";
|
||||
if (typeof v === "string") return v === "" ? "—" : v;
|
||||
if (typeof v === "number" || typeof v === "boolean") return String(v);
|
||||
if (Array.isArray(v)) return v.length === 0 ? "—" : v.map((x) => cellText(x)).join("、");
|
||||
try {
|
||||
return JSON.stringify(v);
|
||||
} catch {
|
||||
return "—";
|
||||
}
|
||||
}
|
||||
|
||||
/** 行身份:插件给了真实键就用它,否则用**零填充下标** —— `AdminTable` 会按 rowKey 排序,
|
||||
* 裸下标的自然序("10" < "2")会把后端给的顺序打乱。 */
|
||||
export function rowIdentity(row: Record<string, unknown>, index: number): string {
|
||||
for (const k of ["id", "key", "name", "provider"]) {
|
||||
const v = row[k];
|
||||
if (typeof v === "string" && v !== "") return `${k}:${v}`;
|
||||
if (typeof v === "number") return `${k}:${v}`;
|
||||
}
|
||||
return `#${String(index).padStart(6, "0")}`;
|
||||
}
|
||||
|
||||
export interface TableRowsState {
|
||||
rows: PanelRow[] | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
load: () => void;
|
||||
}
|
||||
|
||||
/** 取行:动作名不变就不重新取(`load` 的身份只跟 pluginId / action 走,30s 轮询的快照
|
||||
* 重渲染不会触发重复请求)。 */
|
||||
export function useTableRows(pluginId: string, action: string): TableRowsState {
|
||||
const [rows, setRows] = useState<PanelRow[] | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (action === "") {
|
||||
setLoading(false);
|
||||
setError("声明缺少 source.action,无法取数。");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
runPluginAction(pluginId, action, {})
|
||||
.then((raw) => {
|
||||
setRows(decodeRows(raw).map((data, i) => ({ key: rowIdentity(data, i), data })));
|
||||
setError(null);
|
||||
})
|
||||
.catch((err: unknown) => setError(actionFailure(err).message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [pluginId, action]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
return { rows, loading, error, load };
|
||||
}
|
||||
|
||||
export interface RowActionRunner {
|
||||
busy: string | null;
|
||||
flash: TableFlash | null;
|
||||
run: (actionName: string, label: string, row: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export interface RowActionRunnerArgs {
|
||||
pluginId: string;
|
||||
onReload: () => void;
|
||||
/** 行动作结束后重取本表。 */
|
||||
reloadRows: () => void;
|
||||
log: (what: string, result: "ok" | "error") => void;
|
||||
/** 返回显式值对象时交给表单(reauth「把该条带回表单」)。 */
|
||||
onValues?: (values: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
/** 行内动作:一次一个在跑(`busy` 非空即禁用全部按钮,避免同一行被点两次)。 */
|
||||
export function useRowActionRunner({ pluginId, onReload, reloadRows, log, onValues }: RowActionRunnerArgs): RowActionRunner {
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [flash, setFlash] = useState<TableFlash | null>(null);
|
||||
|
||||
const run = useCallback(
|
||||
(actionName: string, label: string, row: Record<string, unknown>) => {
|
||||
setBusy(actionName);
|
||||
setFlash(null);
|
||||
runPluginAction(pluginId, actionName, { row })
|
||||
.then((raw) => {
|
||||
log(`plugin.${pluginId}/${actionName}`, "ok");
|
||||
// 「带回表单」(reauth):只认显式 values / formValues,取不到就只是刷新列表。
|
||||
const values = formValuesOfResult(raw);
|
||||
if (values && onValues) {
|
||||
onValues(values);
|
||||
setFlash({ tone: "ok", text: `${label}:已把该条带回表单,确认后保存。` });
|
||||
} else {
|
||||
setFlash({ tone: "ok", text: `${label}:已完成,正在刷新。` });
|
||||
}
|
||||
reloadRows();
|
||||
onReload();
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const failure = actionFailure(err);
|
||||
log(`plugin.${pluginId}/${actionName}`, "error");
|
||||
setFlash({ tone: "err", text: failure.message, code: failure.code });
|
||||
})
|
||||
.finally(() => setBusy(null));
|
||||
},
|
||||
[pluginId, onReload, reloadRows, log, onValues],
|
||||
);
|
||||
|
||||
return { busy, flash, run };
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
/** 插件面板的**线形状**(`GET /api/v1/admin/panels` 的快照)。
|
||||
*
|
||||
* 字段名与 Go 侧 `pkg/plugins/sdk/panel.go` / `panelfield.go` 的 JSON tag **逐字同形**
|
||||
* (design/09 §5.2 的声明形状是唯一权威),另外多三个宿主侧字段:
|
||||
* - `pluginId`:声明该面板的插件 id(Go 侧 `paneldecl.Entry.PluginID`)——
|
||||
* 它组成路由 `/admin/plugin/<pluginId>/<panelId>`,所以必须随条目下发;
|
||||
* - `route`:后端给的**相对路由片段**(`plugin/<id>/<panel>`,不含 ADMIN_PATH 前缀)
|
||||
* —— 有它就照它走,路由口径由后端单点决定(见 `internal/services/gateway/admin_panels.go`);
|
||||
* - `enabled`(可选):该插件**当前是否启用**这一事实。当前后端已按 `when` 过滤过快照
|
||||
* 且**不下发**这个键,因此它今天是前瞻性字段:带上就复算 `when`,不带就信快照
|
||||
* (见 `when.ts` 的三态说明)。
|
||||
*
|
||||
* 快照是**分组形状**:`{role, groups:[{id,title,order,items:[…]}]}`。另外容错 `{panels:[…]}`
|
||||
* 与裸数组两个平铺形状 —— 容错只为「跨切片尚未对齐时面板不至于整页空白」,不改变主口径。
|
||||
* 未知键忽略、闭集外的 section 类型不渲染(但显式告知),绝不半渲染。
|
||||
*/
|
||||
|
||||
/** 侧边栏折叠分组的 id(design/09 §5.2「本期固定 plugins」;后端 `sdk.GroupPlugins`)。 */
|
||||
export const PLUGIN_GROUP_ID = "plugins";
|
||||
|
||||
export const PANEL_SECTION_TYPES = ["form", "table", "status", "log"] as const;
|
||||
export type PanelSectionType = (typeof PANEL_SECTION_TYPES)[number];
|
||||
|
||||
export const PANEL_FIELD_TYPES = [
|
||||
"text",
|
||||
"secret",
|
||||
"number",
|
||||
"switch",
|
||||
"select",
|
||||
"multiSelect",
|
||||
"date",
|
||||
"kv",
|
||||
] as const;
|
||||
export type PanelFieldType = (typeof PANEL_FIELD_TYPES)[number];
|
||||
|
||||
export interface PanelFieldDto {
|
||||
key: string;
|
||||
label?: string;
|
||||
type: PanelFieldType;
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
/** 只允许 `plugin:*`(Go 侧校验),前端据此动作名拉候选。 */
|
||||
optionsFrom?: string;
|
||||
default?: unknown;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
export interface PanelColumnDto {
|
||||
key: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface PanelRowActionDto {
|
||||
id: string;
|
||||
label: string;
|
||||
action: string;
|
||||
}
|
||||
|
||||
export interface PanelWhenDto {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface PanelSectionDto {
|
||||
type: PanelSectionType;
|
||||
id: string;
|
||||
title: string;
|
||||
fields?: PanelFieldDto[];
|
||||
submit?: { action: string };
|
||||
source?: { action: string };
|
||||
columns?: PanelColumnDto[];
|
||||
rowActions?: PanelRowActionDto[];
|
||||
limit?: number;
|
||||
/** form 区块的**当前值**(快照侧数据面,非声明键):后端按声明字段给出该表单正在编辑的
|
||||
* 那份配置(`secret` 只给掩码)。带上它表单才能「打开即显示现状」并兑现
|
||||
* 「掩码留空 = 不改动」;不带就是空表单,功能照常。 */
|
||||
values?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PanelEntryDto {
|
||||
pluginId: string;
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
/** 侧边栏分组 id(**分组形状**的快照里由外层 group 给出;平铺形状才逐条带)。 */
|
||||
group?: string;
|
||||
/** 分组的本地化显示名(后端 `panelGroupTitle` 给的宿主文案)。 */
|
||||
groupTitle?: string;
|
||||
order?: number;
|
||||
icon?: string;
|
||||
/** 后端给的**相对路由片段**(`plugin/<pluginId>/<panelId>`,不含 ADMIN_PATH 前缀)。
|
||||
* 有它就照它走 —— 路由口径由后端单点决定,前端不另算一份。 */
|
||||
route?: string;
|
||||
/** 插件当前是否启用(可选;本后端版本已按 `when` 过滤过,故通常不带)。 */
|
||||
enabled?: boolean;
|
||||
when?: PanelWhenDto;
|
||||
sections?: PanelSectionDto[];
|
||||
}
|
||||
|
||||
/** 侧边栏折叠分组(后端 `AdminPanelGroup`)。 */
|
||||
export interface PanelGroupDto {
|
||||
id: string;
|
||||
title: string;
|
||||
order?: number;
|
||||
items: PanelEntryDto[];
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* 宽容解码
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
function isRecord(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
function str(v: unknown): string {
|
||||
return typeof v === "string" ? v : "";
|
||||
}
|
||||
|
||||
function num(v: unknown): number | undefined {
|
||||
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
||||
}
|
||||
|
||||
function bool(v: unknown): boolean | undefined {
|
||||
return typeof v === "boolean" ? v : undefined;
|
||||
}
|
||||
|
||||
function decodeField(raw: unknown): PanelFieldDto | null {
|
||||
if (!isRecord(raw)) return null;
|
||||
const key = str(raw.key);
|
||||
const type = str(raw.type) as PanelFieldType;
|
||||
if (key === "" || !(PANEL_FIELD_TYPES as readonly string[]).includes(type)) return null;
|
||||
const label = raw.label === undefined ? undefined : str(raw.label);
|
||||
const placeholder = raw.placeholder === undefined ? undefined : str(raw.placeholder);
|
||||
const optionsFrom = raw.optionsFrom === undefined ? undefined : str(raw.optionsFrom);
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
type,
|
||||
required: bool(raw.required),
|
||||
placeholder,
|
||||
optionsFrom,
|
||||
default: raw.default,
|
||||
min: num(raw.min),
|
||||
max: num(raw.max),
|
||||
};
|
||||
}
|
||||
|
||||
function decodeSection(raw: unknown): PanelSectionDto | null {
|
||||
if (!isRecord(raw)) return null;
|
||||
const type = str(raw.type);
|
||||
const id = str(raw.id);
|
||||
const title = str(raw.title);
|
||||
// 类型不在闭集内 ⇒ 不猜、不半渲染(调用方会如实显示「本版本不支持」)。
|
||||
if (id === "" || !(PANEL_SECTION_TYPES as readonly string[]).includes(type)) return null;
|
||||
const fields = Array.isArray(raw.fields)
|
||||
? raw.fields.map(decodeField).filter((f): f is PanelFieldDto => f !== null)
|
||||
: undefined;
|
||||
const columns: PanelColumnDto[] | undefined = Array.isArray(raw.columns)
|
||||
? raw.columns.flatMap<PanelColumnDto>((c) =>
|
||||
isRecord(c) && str(c.key) !== "" ? [{ key: str(c.key), label: str(c.label) || undefined }] : [],
|
||||
)
|
||||
: undefined;
|
||||
const rowActions = Array.isArray(raw.rowActions)
|
||||
? raw.rowActions
|
||||
.map((a) =>
|
||||
isRecord(a) && str(a.id) !== "" && str(a.action) !== ""
|
||||
? { id: str(a.id), label: str(a.label) || str(a.id), action: str(a.action) }
|
||||
: null,
|
||||
)
|
||||
.filter((a): a is PanelRowActionDto => a !== null)
|
||||
: undefined;
|
||||
const submitAction = isRecord(raw.submit) ? str(raw.submit.action) : "";
|
||||
const sourceAction = isRecord(raw.source) ? str(raw.source.action) : "";
|
||||
return {
|
||||
type: type as PanelSectionType,
|
||||
id,
|
||||
title,
|
||||
fields,
|
||||
submit: submitAction === "" ? undefined : { action: submitAction },
|
||||
source: sourceAction === "" ? undefined : { action: sourceAction },
|
||||
columns,
|
||||
rowActions,
|
||||
limit: num(raw.limit),
|
||||
values: isRecord(raw.values) ? raw.values : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function decodeEntry(raw: unknown): PanelEntryDto | null {
|
||||
if (!isRecord(raw)) return null;
|
||||
const pluginId = str(raw.pluginId);
|
||||
const id = str(raw.id);
|
||||
if (pluginId === "" || id === "") return null;
|
||||
const when = isRecord(raw.when) && bool(raw.when.enabled) !== undefined ? { enabled: bool(raw.when.enabled) } : undefined;
|
||||
return {
|
||||
pluginId,
|
||||
id,
|
||||
title: str(raw.title) || id,
|
||||
description: str(raw.description) || undefined,
|
||||
group: str(raw.group) || undefined,
|
||||
groupTitle: str(raw.groupTitle) || undefined,
|
||||
order: num(raw.order),
|
||||
icon: str(raw.icon) || undefined,
|
||||
route: str(raw.route) || undefined,
|
||||
enabled: bool(raw.enabled),
|
||||
when,
|
||||
sections: Array.isArray(raw.sections)
|
||||
? raw.sections.map(decodeSection).filter((s): s is PanelSectionDto => s !== null)
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
export interface PanelSnapshotDto {
|
||||
/** 快照按哪个角色过滤的(回显,便于前端确认自己拿到的是哪一档裁剪)。 */
|
||||
role: string;
|
||||
groups: PanelGroupDto[];
|
||||
}
|
||||
|
||||
/** 解快照响应(`data` 原样)。**主口径是后端的 `{role, groups:[{id,title,order,items}]}`**
|
||||
* (见 `internal/services/gateway/admin_panels.go`),另外容错 `{panels:[…]}` 与裸数组两个
|
||||
* 平铺形状 —— 容错只为「跨切片尚未对齐时面板不至于整页空白」,不改变主口径。
|
||||
*
|
||||
* 后端的载荷里还有 `pluginIds`(「声明过面板的插件 id」,§5.2.1 红线③要给「插件与能力」
|
||||
* 页显示「未声明管理页面」用)。本片不消费它,因此不在这里解码。 */
|
||||
export function decodePanelSnapshot(raw: unknown): PanelSnapshotDto {
|
||||
const role = isRecord(raw) ? str(raw.role) : "";
|
||||
const groups: PanelGroupDto[] = [];
|
||||
if (isRecord(raw) && Array.isArray(raw.groups)) {
|
||||
for (const g of raw.groups) {
|
||||
if (!isRecord(g)) continue;
|
||||
const items = Array.isArray(g.items)
|
||||
? g.items.map(decodeEntry).filter((e): e is PanelEntryDto => e !== null)
|
||||
: [];
|
||||
if (items.length === 0) continue;
|
||||
const id = str(g.id) || PLUGIN_GROUP_ID;
|
||||
const title = str(g.title) || id;
|
||||
groups.push({
|
||||
id,
|
||||
title,
|
||||
order: num(g.order),
|
||||
// 分组信息在**外层**:逐条补进条目,条目自己不必再带一份(重复必然分叉)。
|
||||
items: items.map((e) => ({ ...e, group: e.group ?? id, groupTitle: e.groupTitle ?? title })),
|
||||
});
|
||||
}
|
||||
return { role, groups };
|
||||
}
|
||||
// 平铺兜底:条目自带 group(没有就归到唯一分组)。
|
||||
const flat = Array.isArray(raw)
|
||||
? raw.map(decodeEntry).filter((e): e is PanelEntryDto => e !== null)
|
||||
: isRecord(raw) && Array.isArray(raw.panels)
|
||||
? raw.panels.map(decodeEntry).filter((e): e is PanelEntryDto => e !== null)
|
||||
: [];
|
||||
if (flat.length === 0) return { role, groups };
|
||||
const buckets = new Map<string, PanelEntryDto[]>();
|
||||
for (const e of flat) {
|
||||
const id = e.group ?? PLUGIN_GROUP_ID;
|
||||
const list = buckets.get(id);
|
||||
if (list) list.push(e);
|
||||
else buckets.set(id, [e]);
|
||||
}
|
||||
for (const [id, items] of buckets) {
|
||||
const title = items[0].groupTitle ?? id;
|
||||
groups.push({ id, title, items: items.map((e) => ({ ...e, group: e.group ?? id, groupTitle: e.groupTitle ?? title })) });
|
||||
}
|
||||
return { role, groups };
|
||||
}
|
||||
|
||||
/** 平铺的条目列表(导航模型与「当前页」查找都用它)。 */
|
||||
export function decodePanelEntries(raw: unknown): PanelEntryDto[] {
|
||||
return decodePanelSnapshot(raw).groups.flatMap((g) => g.items);
|
||||
}
|
||||
|
||||
/** 动作响应的信封:`{pluginId, action, kind, result}`(网关 `adminPluginAction`)。
|
||||
* 所有动作结果的解码都先过这一层 —— 忘了解封就会出现「接口有数据、面板显示空」。 */
|
||||
function unwrapResult(raw: unknown): unknown {
|
||||
if (isRecord(raw) && "result" in raw && ("action" in raw || "pluginId" in raw)) return raw.result;
|
||||
return raw;
|
||||
}
|
||||
|
||||
/** 动作响应里取出「行」数组:`{rows}` / `{entries}` / `{items}` / 裸数组都认。
|
||||
* 契约未定期间的容错 —— 取不到就是空表,而不是把整页卡在加载态。 */
|
||||
export function decodeRows(raw: unknown): Record<string, unknown>[] {
|
||||
const body = unwrapResult(raw);
|
||||
const list = Array.isArray(body)
|
||||
? body
|
||||
: isRecord(body) && Array.isArray(body.rows)
|
||||
? body.rows
|
||||
: isRecord(body) && Array.isArray(body.entries)
|
||||
? body.entries
|
||||
: isRecord(body) && Array.isArray(body.items)
|
||||
? body.items
|
||||
: [];
|
||||
return list.filter(isRecord);
|
||||
}
|
||||
|
||||
/** 行内动作若明确带回一份「值对象」(`result.values` / `result.formValues`),取出它。
|
||||
*
|
||||
* 用途是 design/09 §5.2.1 示例里的 reauth:「重配凭据」把该条**带回表单**。这里只认
|
||||
* 显式的那两个键 —— 不猜「看起来像不像字段值」(`status` 的事实面也是平铺对象,猜就会把
|
||||
* 事实面当成表单值灌进去)。也没有任何键 ⇒ `null`(不填,不猜)。 */
|
||||
export function formValuesOfResult(raw: unknown): Record<string, unknown> | null {
|
||||
const body = unwrapResult(raw);
|
||||
if (!isRecord(body)) return null;
|
||||
if (isRecord(body.values)) return body.values;
|
||||
if (isRecord(body.formValues)) return body.formValues;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 动作响应里取出「日志行」:字符串数组或对象数组都认。 */
|
||||
export function decodeLogLines(raw: unknown): { time: string; text: string }[] {
|
||||
const body = unwrapResult(raw);
|
||||
const list = Array.isArray(body)
|
||||
? body
|
||||
: isRecord(body) && Array.isArray(body.lines)
|
||||
? body.lines
|
||||
: isRecord(body) && Array.isArray(body.entries)
|
||||
? body.entries
|
||||
: isRecord(body) && Array.isArray(body.logs)
|
||||
? body.logs
|
||||
: [];
|
||||
return list.map((item) => {
|
||||
if (typeof item === "string") return { time: "", text: item };
|
||||
if (!isRecord(item)) return { time: "", text: "" };
|
||||
const time = str(item.time) || str(item.at) || str(item.ts);
|
||||
const text =
|
||||
str(item.text) || str(item.message) || str(item.line) || str(item.msg) || JSON.stringify(item);
|
||||
return { time, text };
|
||||
});
|
||||
}
|
||||
|
||||
/** 动作响应里取出「键值事实」:`{facts:{k:v}}` / 裸对象 / `{values:{…}}` 都认。 */
|
||||
export function decodeFacts(raw: unknown): Record<string, unknown> {
|
||||
const body = unwrapResult(raw);
|
||||
if (isRecord(body)) {
|
||||
if (isRecord(body.facts)) return body.facts;
|
||||
if (isRecord(body.values)) return body.values;
|
||||
if (isRecord(body.row)) return body.row;
|
||||
return body;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export interface PanelOptionDto {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** 动作响应里取出「下拉候选」(`select` / `multiSelect` 的 `optionsFrom` 用):
|
||||
* `{options|rows|items:[…]}` 或裸数组;元素是 `{value,label}` / `{value}` / 裸字符串都认。 */
|
||||
export function decodeOptions(raw: unknown): PanelOptionDto[] {
|
||||
const body = unwrapResult(raw);
|
||||
const list = Array.isArray(body)
|
||||
? body
|
||||
: isRecord(body) && Array.isArray(body.options)
|
||||
? body.options
|
||||
: isRecord(body) && Array.isArray(body.rows)
|
||||
? body.rows
|
||||
: isRecord(body) && Array.isArray(body.items)
|
||||
? body.items
|
||||
: [];
|
||||
const out: PanelOptionDto[] = [];
|
||||
for (const item of list) {
|
||||
if (typeof item === "string") {
|
||||
if (item !== "") out.push({ value: item, label: item });
|
||||
continue;
|
||||
}
|
||||
if (!isRecord(item)) continue;
|
||||
const value = str(item.value) || str(item.id) || str(item.key);
|
||||
if (value === "") continue;
|
||||
out.push({ value, label: str(item.label) || str(item.title) || value });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/** 插件面板快照的取数 hook。
|
||||
*
|
||||
* 三条口径:
|
||||
* ① **没有 token 就不请求** —— 面板的登录闸自己会显示「需要登录」,在那种状态下打
|
||||
* `/admin/panels` 只会拿到 401,而 401 会触发全局的「登录过期」处理(清 token、
|
||||
* 通知 WS 停连),等于在登录页上反复把自己登出;
|
||||
* ② 未接线(501/404)当空快照(见 `panelsApi.panelsUnavailableToEmpty`)——
|
||||
* 插件分组不出现,而不是把一个未上线端点显示成故障;
|
||||
* ③ 定时重取(默认 30s):插件的 install / enable / disable / uninstall 都会重算面板
|
||||
* 集合,后端已把变更推到既有 bus 的 **`ws.panels_changed`**(载荷 `{reason, pluginId}`,
|
||||
* 不含面板内容)。**更优的做法是订阅那条帧**,但 `lib/ws` 不在本切片文件面内
|
||||
* (并发切片在改),所以先用轮询兑现「不刷新页面也能出现新入口」,代价是最长 30s
|
||||
* 延迟 —— 已如实登记在片报告里:把 `useAutoReload` 换成 `ws.panels_changed` 订阅即可。
|
||||
*/
|
||||
|
||||
import { getAuthToken } from "../../lib/api/core";
|
||||
import { useAsyncData } from "../../lib/useAsyncData";
|
||||
import { useAutoReload } from "../hooks";
|
||||
import { fetchAdminPanels, panelsUnavailableToEmpty } from "./panelsApi";
|
||||
import type { PanelEntryDto } from "./types";
|
||||
|
||||
export const PANELS_POLL_MS = 30_000;
|
||||
|
||||
export interface PluginPanelsState {
|
||||
panels: PanelEntryDto[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
reload: () => void;
|
||||
}
|
||||
|
||||
export function usePluginPanels(): PluginPanelsState {
|
||||
const token = getAuthToken();
|
||||
const load = () => (token ? fetchAdminPanels().catch((err: unknown) => panelsUnavailableToEmpty(err)) : Promise.resolve<PanelEntryDto[]>([]));
|
||||
const { data, loading, error, reload } = useAsyncData(load, [token]);
|
||||
useAutoReload(reload, token ? PANELS_POLL_MS : null);
|
||||
return { panels: data ?? [], loading, error, reload };
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/** `when` 条件显示的求值(design/09 §5.2.1 nav.item:**不满足 ⇒ 入口不出现**,不是灰掉)。
|
||||
*
|
||||
* 这份实现与 Go 侧 `internal/capability/paneldecl/when.go` 的 `Eval` **逐条同义**:
|
||||
* - `when` 缺省 / 对象里没写 `enabled` ⇒ 无条件 ⇒ 显示;
|
||||
* - `when.enabled = true` ⇒ 仅当插件启用时显示(§5.6.1);
|
||||
* - `when.enabled = false` ⇒ 仅当插件**未**启用时显示 —— 条件是「要求值相等」,
|
||||
* 写 `false` 不等于没写。
|
||||
*
|
||||
* **求值事实从哪来**:后端已经按真事实(插件管理器的启用状态)过滤过快照,前端的职责
|
||||
* 只有一件 —— 不把后端过滤掉的入口又画回来。所以:快照带了 `enabled` 事实就复算一遍
|
||||
* (这样一次 `panels_changed` 前后的热更新也能立刻生效),没带就**信快照**
|
||||
* (那就意味着后端已按真事实算过;前端凭「没事实」去猜一定是错的)。
|
||||
*/
|
||||
|
||||
import type { PanelEntryDto, PanelWhenDto } from "./types";
|
||||
|
||||
export interface WhenContext {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/** 求值一份 `when` 声明,报告该面板的入口是否应当出现。 */
|
||||
export function evalWhen(when: PanelWhenDto | undefined, ctx: WhenContext): boolean {
|
||||
if (!when || when.enabled === undefined) return true;
|
||||
return ctx.enabled === when.enabled;
|
||||
}
|
||||
|
||||
/** 快照里**带了**启用事实时复算 `when`;没带就按「后端已过滤」处理(显示)。 */
|
||||
export function whenSatisfied(entry: PanelEntryDto): boolean {
|
||||
if (typeof entry.enabled !== "boolean") return true;
|
||||
return evalWhen(entry.when, { enabled: entry.enabled });
|
||||
}
|
||||
|
||||
/** 可出现的条目(顺序保持快照给的顺序 —— 稳定序是后端与 `nav.ts` 的职责)。 */
|
||||
export function visiblePanelEntries(entries: readonly PanelEntryDto[]): PanelEntryDto[] {
|
||||
return entries.filter(whenSatisfied);
|
||||
}
|
||||
@@ -31,9 +31,24 @@ export type AdminSectionId =
|
||||
| "system"
|
||||
| "users";
|
||||
|
||||
/** 插件页目标:`/admin/plugin/<pluginId>/<panelId>`(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/<pluginId>/<panelId>` 是**插件页**:它先于静态白名单判定,
|
||||
* 两段齐全才算命中,缺段一律回落 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("/");
|
||||
|
||||
@@ -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 (
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
disabled={disabled}
|
||||
aria-label={fieldLabel(field)}
|
||||
className={invalid ? inputClsError : inputCls}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SecretControl({ field, value, onChange, disabled, invalid }: ControlProps): React.ReactNode {
|
||||
return (
|
||||
<input
|
||||
type="password"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
disabled={disabled}
|
||||
aria-label={fieldLabel(field)}
|
||||
autoComplete="new-password"
|
||||
className={invalid ? inputClsError : inputCls}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function NumberControl({ field, value, onChange, disabled, invalid }: ControlProps): React.ReactNode {
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={value}
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
disabled={disabled}
|
||||
aria-label={fieldLabel(field)}
|
||||
className={invalid ? inputClsError : inputCls}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function DateControl({ field, value, onChange, disabled, invalid }: ControlProps): React.ReactNode {
|
||||
return (
|
||||
<input
|
||||
type="date"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
aria-label={fieldLabel(field)}
|
||||
className={invalid ? inputClsError : inputCls}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SwitchControl({ field, value, onChange, disabled }: ControlProps): React.ReactNode {
|
||||
void field;
|
||||
return (
|
||||
<span className="block">
|
||||
<Switch checked={value === "true"} disabled={disabled} onChange={(next) => onChange(next ? "true" : "false")} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** 下拉:有候选才给下拉。自由输入一个未知值会被后端静默回落默认(值域受后端归一化约束),
|
||||
* 没有候选时降级成文本框(调用方会在字段说明里写明原因),而不是给一个空下拉。 */
|
||||
export function SelectControl({ field, value, onChange, disabled, invalid, options }: ControlProps): React.ReactNode {
|
||||
const opts = options ?? field.options ?? [];
|
||||
if (opts.length === 0) return <TextControl field={field} value={value} onChange={onChange} disabled={disabled} invalid={invalid} />;
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
aria-label={fieldLabel(field)}
|
||||
disabled={disabled}
|
||||
className={invalid ? inputClsError : inputCls}
|
||||
>
|
||||
{value !== "" && !opts.some((o) => o.value === value) ? (
|
||||
<option value={value}>{value}(不在候选内,保存时原样保留)</option>
|
||||
) : null}
|
||||
{opts.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
/** 多选:逗号分隔的文本框,占位符列出候选(候选多时下拉反而难选,且候选可能为空)。 */
|
||||
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 (
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => 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 (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
rows={2}
|
||||
placeholder='{"X-Header":"value"}'
|
||||
disabled={disabled}
|
||||
aria-label={fieldLabel(field)}
|
||||
className={invalid ? inputClsError : inputCls}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/** 通用「schema → 控件」的声明类型(design/09 §5.2 字段闭集)。
|
||||
*
|
||||
* 这一层是既有三段式的**泛化**:渠道页那套(`modules/channelFields.ts` 声明表 +
|
||||
* `modules/channelFieldRules.ts` 纯函数 + `modules/channelConnFields.tsx` 渲染器)
|
||||
* 把它抽成与来源无关的形状 —— 声明来自渠道元数据还是插件下发的 schema,控件都一样。
|
||||
* 于是不产生第二份表单实现(design/09 §5.7 C 的硬要求)。
|
||||
*
|
||||
* 类型闭集与 Go 侧 `pkg/plugins/sdk/panelfield.go` 的 `FieldType` **逐字对齐**:
|
||||
* text | secret | number | switch | select | multiSelect | date | kv。多一个都不认。
|
||||
*/
|
||||
|
||||
export type SchemaFieldType =
|
||||
| "text"
|
||||
| "secret"
|
||||
| "number"
|
||||
| "switch"
|
||||
| "select"
|
||||
| "multiSelect"
|
||||
| "date"
|
||||
| "kv";
|
||||
|
||||
export interface SchemaOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** 一条字段声明。渠道页的 `PlatformField` 是它的**超集**(多出 showIn/requiredIn 这类
|
||||
* 平台选择器条件),由渠道侧适配成这个形状后交给同一套控件渲染。 */
|
||||
export interface SchemaField {
|
||||
/** 配置键 / 行字段名(写回后端时逐字使用,写错 = 配了不生效)。 */
|
||||
key: string;
|
||||
/** 标签;缺省回落 `key`(与 Go 侧 Column.Label 同一取舍)。 */
|
||||
label?: string;
|
||||
type: SchemaFieldType;
|
||||
/** **配置值**必填标记(不是声明层必填)。 */
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
/** 下拉候选:来自插件声明的 optionsFrom 解析结果;为空时下拉降级为文本框并明说。 */
|
||||
options?: readonly SchemaOption[];
|
||||
/** 初始值(secret 一律忽略:掩码不回填,见 `values.ts`)。 */
|
||||
default?: unknown;
|
||||
/** 数值边界,仅 number。 */
|
||||
min?: number;
|
||||
max?: number;
|
||||
/** 一行中文说明(渠道页的 guide 同义):写清「空值代表什么」「哪个组合下必填」。 */
|
||||
guide?: string;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/** 通用字段控件:**唯一**的一份「声明 → 控件」渲染(渠道页与插件页共用)。
|
||||
*
|
||||
* 这一段原先只在 `modules/channelConnFields.tsx` 里,插件面板要复用它(design/09 §5.7 C:
|
||||
* 「两者共用同一套渲染组件,不产生第二份表单实现」),所以抽到这里。抽出的是**呈现**,
|
||||
* 判定(显隐 / 必填 / 写回)仍在各自的规则文件里 —— 组件不判断规则,只画结果,因此
|
||||
* 单测不必渲染 React 就能覆盖语义。
|
||||
*
|
||||
* 文件分工:本文件 = 字段骨架(标题/必填标记/说明/状态行)+ 类型分发;八种控件本身在
|
||||
* `controls.tsx`(拆开是为了守住「函数 ≤80 行」)。
|
||||
*
|
||||
* 掩码秘密的「留空 = 不改动」是这条链路的红线(design/09 §5.4.2、design/17 §4.4):
|
||||
* 面板不持有原文,输入框永远不预填掩码,要清空必须显式开关;开关文案里点名键名。
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Switch } from "../../components/primitives";
|
||||
import {
|
||||
DateControl,
|
||||
KvControl,
|
||||
MultiSelectControl,
|
||||
NumberControl,
|
||||
SecretControl,
|
||||
SelectControl,
|
||||
SwitchControl,
|
||||
TextControl,
|
||||
inputCls,
|
||||
inputClsError,
|
||||
type ControlProps,
|
||||
} from "./controls";
|
||||
import type { SchemaField, SchemaOption } from "./fieldTypes";
|
||||
|
||||
export { inputCls, inputClsError };
|
||||
|
||||
export interface FieldShellProps {
|
||||
/** 字段标签(渠道页是「键(中文名)」,插件页缺省回落 key)。 */
|
||||
label: string;
|
||||
/** 必填:渲染「必填 / 可选」标记(两个都要画,别让用户猜)。 */
|
||||
required: boolean;
|
||||
/** 本次保存被拦下时点名的字段:红框 + 一行「为什么」由调用方给。 */
|
||||
missing?: boolean;
|
||||
/** 一行中文说明:写清「谁连谁」「空值代表什么」「哪种组合下必填」。 */
|
||||
guide?: ReactNode;
|
||||
/** 缺失时的错误行(调用方给文案,宿主不做推断)。 */
|
||||
missingHint?: ReactNode;
|
||||
/** 控件本身。 */
|
||||
children: ReactNode;
|
||||
/** 控件下方的状态行(如「已配置 / 留空 = 不改动」+ 显式清除开关)。 */
|
||||
footer?: ReactNode;
|
||||
}
|
||||
|
||||
/** 一个字段的骨架:标题 + 必填标记 + 控件 + 说明 + 状态行。 */
|
||||
export function FieldShell({
|
||||
label,
|
||||
required,
|
||||
missing = false,
|
||||
guide,
|
||||
missingHint,
|
||||
children,
|
||||
footer,
|
||||
}: FieldShellProps): ReactNode {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<span className="flex flex-wrap items-center gap-1.5 text-xs text-[var(--mu-text-lo)]">
|
||||
{label}
|
||||
{required ? (
|
||||
<span className="rounded-[var(--mu-radius-xs)] bg-[var(--mu-err)]/15 px-1.5 py-0.5 text-[10px] font-medium text-[var(--mu-err)]">
|
||||
必填
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded-[var(--mu-radius-xs)] bg-[var(--mu-surface-5)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--mu-text-lo)]">
|
||||
可选
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{children}
|
||||
{guide ? <span className="block text-[11px] leading-relaxed text-[var(--mu-text-lo)]">{guide}</span> : null}
|
||||
{missing && required && missingHint ? (
|
||||
<span className="block text-[11px] text-[var(--mu-err)]">{missingHint}</span>
|
||||
) : null}
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface MaskedSecretFooterProps {
|
||||
/** 缺省键名,用于「清除已配置的 X」——文案里必须点名键,否则用户不知道清的是哪个。 */
|
||||
fieldKey: string;
|
||||
clearRequested: boolean;
|
||||
onToggleClear: (next: boolean) => void;
|
||||
/** 已配置但本次输入框留空 —— 这正是「留空 = 不改动」。 */
|
||||
masked: boolean;
|
||||
/** 被勾选清除时的提示行(渠道页与插件页的用词略不同)。 */
|
||||
clearedHint?: ReactNode;
|
||||
}
|
||||
|
||||
/** 掩码秘密的状态行:说清「留空 = 不改动」,并给出唯一能清空的方式(显式开关)。 */
|
||||
export function MaskedSecretFooter({
|
||||
fieldKey,
|
||||
clearRequested,
|
||||
onToggleClear,
|
||||
masked,
|
||||
clearedHint,
|
||||
}: MaskedSecretFooterProps): ReactNode {
|
||||
if (!masked && !clearRequested) return null;
|
||||
return (
|
||||
<span className="block space-y-1">
|
||||
<span className="block text-[11px] text-[var(--mu-text-lo)]">
|
||||
已配置(后端只下发掩码,面板不持有原文):留空 = 不改动。
|
||||
</span>
|
||||
<Switch
|
||||
checked={clearRequested}
|
||||
onChange={onToggleClear}
|
||||
label={`清除已配置的 ${fieldKey}(保存后生效)`}
|
||||
/>
|
||||
{clearRequested && clearedHint ? (
|
||||
<span className="block text-[11px] text-[var(--mu-warn)]">{clearedHint}</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export interface SchemaControlProps {
|
||||
field: SchemaField;
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
disabled?: boolean;
|
||||
invalid?: boolean;
|
||||
/** 下拉候选;缺省用 `field.options`。插件页把 optionsFrom 拉到的候选填在这里。 */
|
||||
options?: readonly SchemaOption[];
|
||||
}
|
||||
|
||||
/** 按声明类型分发到对应控件(闭集;未知类型走文本,绝不半渲染)。 */
|
||||
export function SchemaControl(props: SchemaControlProps): ReactNode {
|
||||
const args: ControlProps = {
|
||||
field: props.field,
|
||||
value: props.value,
|
||||
onChange: props.onChange,
|
||||
disabled: props.disabled ?? false,
|
||||
invalid: props.invalid ?? false,
|
||||
options: props.options,
|
||||
};
|
||||
switch (props.field.type) {
|
||||
case "secret":
|
||||
return <SecretControl {...args} />;
|
||||
case "number":
|
||||
return <NumberControl {...args} />;
|
||||
case "switch":
|
||||
return <SwitchControl {...args} />;
|
||||
case "select":
|
||||
return <SelectControl {...args} />;
|
||||
case "multiSelect":
|
||||
return <MultiSelectControl {...args} />;
|
||||
case "date":
|
||||
return <DateControl {...args} />;
|
||||
case "kv":
|
||||
return <KvControl {...args} />;
|
||||
default:
|
||||
return <TextControl {...args} />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
// 通用字段规则(装载 / 必填 / 三态写回)的**纯逻辑**测试。
|
||||
//
|
||||
// 这一层是渠道配置那套规则的泛化版(`modules/channelFieldRules.ts` 是它的初版),所以
|
||||
// 断言逐条对齐那边的口径 —— 尤其两条红线:
|
||||
// ① **掩码秘密留空 = 不改动**(面板不持有原文,空串既不表示「不改」也不表示「清空」);
|
||||
// ② 看得见的字段被清空 = **显式清除**(写 `""`),看不见的 / 从没配过的一律**缺位**(保留)。
|
||||
//
|
||||
// 跑法:
|
||||
//
|
||||
// cd frontend/web \
|
||||
// && ./node_modules/.bin/esbuild src/admin/schema/values.test.ts \
|
||||
// --bundle --platform=node --format=esm --outfile=/tmp/cyrene-schema-values.test.mjs \
|
||||
// && node /tmp/cyrene-schema-values.test.mjs
|
||||
|
||||
import {
|
||||
loadFormValues,
|
||||
missingRequiredFields,
|
||||
missingRequiredMessage,
|
||||
parseFieldValue,
|
||||
planSubmit,
|
||||
type FieldValues,
|
||||
} from "./values";
|
||||
import type { SchemaField } from "./fieldTypes";
|
||||
|
||||
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})`);
|
||||
}
|
||||
|
||||
const F = {
|
||||
provider: (over: Partial<SchemaField> = {}): SchemaField => ({ key: "provider", label: "提供商", type: "select", ...over }),
|
||||
apiKey: (over: Partial<SchemaField> = {}): SchemaField => ({ key: "apiKey", label: "API Key", type: "secret", ...over }),
|
||||
endpoint: (over: Partial<SchemaField> = {}): SchemaField => ({ key: "endpoint", label: "接口地址", type: "text", ...over }),
|
||||
limit: (over: Partial<SchemaField> = {}): SchemaField => ({ key: "limit", label: "上限", type: "number", min: 1, max: 10, ...over }),
|
||||
enabled: (): SchemaField => ({ key: "enabled", label: "启用", type: "switch" }),
|
||||
regions: (): SchemaField => ({ key: "regions", label: "区域", type: "multiSelect" }),
|
||||
headers: (): SchemaField => ({ key: "headers", label: "自定义头", type: "kv" }),
|
||||
since: (): SchemaField => ({ key: "since", label: "起始日期", type: "date" }),
|
||||
};
|
||||
|
||||
// ── 装载:秘密永不回填,其余按类型还原 ────────────────────────────────────────
|
||||
{
|
||||
const fields = [F.provider(), F.apiKey(), F.enabled(), F.regions(), F.headers(), F.limit(), F.since()];
|
||||
const loaded = loadFormValues(fields, {
|
||||
provider: "searxng",
|
||||
apiKey: "****cdef",
|
||||
enabled: true,
|
||||
regions: ["cn", "us"],
|
||||
headers: { "X-Token": "abc" },
|
||||
limit: 5,
|
||||
since: "2026-01-01",
|
||||
});
|
||||
eq(loaded.values.apiKey, "", "敏感字段**永不回填**(后端只下发掩码,回填等于把掩码写成真值)");
|
||||
eq(loaded.configured.includes("apiKey"), true, "敏感字段记为「已配置」(留空 = 不改的依据)");
|
||||
eq(loaded.values.provider, "searxng", "下拉回显已存值");
|
||||
eq(loaded.values.enabled, "true", "开关还原成控件字符串");
|
||||
eq(loaded.values.regions, "cn, us", "多选还原成逗号串");
|
||||
eq(loaded.values.headers, '{\n "X-Token": "abc"\n}', "键值对还原成 JSON 对象文本");
|
||||
eq(loaded.values.limit, "5", "数字还原成字符串");
|
||||
eq(loaded.values.since, "2026-01-01", "日期直出");
|
||||
eq(loaded.configured.join(","), fields.map((f) => f.key).join(","), "盘上有值的键都进 configured");
|
||||
|
||||
const withDefault = loadFormValues([F.endpoint({ default: "https://example.com" })], {});
|
||||
eq(withDefault.values.endpoint, "https://example.com", "盘上没值时用声明缺省值");
|
||||
eq(withDefault.configured.length, 0, "缺省值不算「盘上已配置」");
|
||||
eq(loadFormValues([F.apiKey()], { apiKey: "" }).values.apiKey, "", "盘上空值不叫已配置");
|
||||
}
|
||||
|
||||
// ── 必填:已配置的键算满足(否则「已配置凭据」每次打开都报缺失) ─────────────────
|
||||
{
|
||||
const fields = [F.provider({ required: true }), F.apiKey({ required: true })];
|
||||
const empty: FieldValues = { provider: "", apiKey: "" };
|
||||
eq(missingRequiredFields(fields, empty).map((f) => f.key), ["provider", "apiKey"], "都空 ⇒ 都缺");
|
||||
eq(missingRequiredFields(fields, empty, ["apiKey"]).map((f) => f.key), ["provider"], "已配置的秘密不算缺");
|
||||
eq(missingRequiredFields(fields, { provider: "searxng", apiKey: "" }, ["apiKey"]).length, 0, "填全了就不缺");
|
||||
eq(
|
||||
missingRequiredMessage([F.provider({ required: true })]),
|
||||
"缺少必填字段:提供商",
|
||||
"错误文案点名到字段(用户不该去猜哪一项)",
|
||||
);
|
||||
}
|
||||
|
||||
// ── 三态写回:值 / 显式清除 / 缺位(保留) ─────────────────────────────────────
|
||||
{
|
||||
// ① 有值 ⇒ 覆盖
|
||||
const p1 = planSubmit([F.provider(), F.limit()], { provider: "searxng", limit: "7" }, { provider: "old", limit: "" });
|
||||
eq(p1, { ok: true, patch: { provider: "searxng", limit: 7 } }, "有值 ⇒ 写入并按类型还原(number → 数字)");
|
||||
|
||||
// ② 看得见的字段被清空 ⇒ 显式清除(写 "")
|
||||
const p2 = planSubmit([F.endpoint()], { endpoint: "" }, { endpoint: "https://old" });
|
||||
eq(p2, { ok: true, patch: { endpoint: "" } }, "盘上有值而被清空 ⇒ 写 \"\"(用户意图是清掉,不许静默保留)");
|
||||
|
||||
// ③ 从没配过的字段留空 ⇒ 缺位(不写 "" 进一个从没配过的键)
|
||||
eq(planSubmit([F.endpoint()], { endpoint: "" }, {}), { ok: true, patch: {} }, "从未配置 + 留空 ⇒ 缺位 = 保留");
|
||||
|
||||
// ④ 掩码秘密:留空一律不改动(无论盘上有没有值);要清空必须勾显式清除
|
||||
eq(
|
||||
planSubmit([F.apiKey()], { apiKey: "" }, { apiKey: "" }, [], ["apiKey"]),
|
||||
{ ok: true, patch: {} },
|
||||
"掩码秘密留空 ⇒ **缺位 = 不改动**(空串 ≠ 不改动,也 ≠ 清空)",
|
||||
);
|
||||
eq(planSubmit([F.apiKey()], { apiKey: "" }, { apiKey: "" }, [], []), { ok: true, patch: {} }, "秘密未配置 + 留空 ⇒ 缺位");
|
||||
eq(
|
||||
planSubmit([F.apiKey()], { apiKey: "" }, { apiKey: "****cdef" }, [], ["apiKey"]),
|
||||
{ ok: true, patch: {} },
|
||||
"就算初值里混进了掩码,留空也绝不写回(不给「把掩码存成真值」留路径)",
|
||||
);
|
||||
eq(planSubmit([F.apiKey()], { apiKey: "" }, {}, ["apiKey"]), { ok: true, patch: { apiKey: "" } }, "勾了清除 ⇒ 写 \"\"");
|
||||
eq(planSubmit([F.apiKey()], { apiKey: "new-secret" }, {}, ["apiKey"]), { ok: true, patch: { apiKey: "" } }, "清除优先于新值");
|
||||
eq(planSubmit([F.apiKey()], { apiKey: "new-secret" }, {}), { ok: true, patch: { apiKey: "new-secret" } }, "重打了新值 ⇒ 写入新值");
|
||||
|
||||
// ⑤ 非秘密字段的类型还原
|
||||
eq(
|
||||
planSubmit([F.enabled(), F.regions(), F.headers()], { enabled: "true", regions: "cn, us", headers: '{"a":1}' }, {}),
|
||||
{ ok: true, patch: { enabled: true, regions: ["cn", "us"], headers: { a: 1 } } },
|
||||
"switch → 布尔、multiSelect → 数组、kv → 对象",
|
||||
);
|
||||
eq(planSubmit([F.enabled()], { enabled: "false" }, {}), { ok: true, patch: { enabled: false } }, "switch 关 ⇒ false");
|
||||
}
|
||||
|
||||
// ── 校验拦截:必填缺失 / 数字 / 键值对 ────────────────────────────────────────
|
||||
{
|
||||
const missingPlan = planSubmit([F.provider({ required: true })], { provider: "" }, {});
|
||||
ok(!missingPlan.ok && missingPlan.kind === "missing", "必填缺失 ⇒ 拦在本地,不带病提交");
|
||||
if (!missingPlan.ok && missingPlan.kind === "missing") {
|
||||
eq(missingPlan.missing.map((f) => f.key), ["provider"], "点名缺了哪个字段");
|
||||
}
|
||||
|
||||
const badNumber = planSubmit([F.limit()], { limit: "abc" }, {});
|
||||
ok(!badNumber.ok && badNumber.kind === "invalid", "非数字 ⇒ invalid");
|
||||
eq(
|
||||
planSubmit([F.limit()], { limit: "99" }, {}).ok,
|
||||
false,
|
||||
"超出 max ⇒ invalid(声明给了边界就得真用上)",
|
||||
);
|
||||
eq(planSubmit([F.limit()], { limit: "1" }, {}), { ok: true, patch: { limit: 1 } }, "边界值可用(min=1)");
|
||||
|
||||
const badKv = planSubmit([F.headers()], { headers: "[1,2]" }, {});
|
||||
ok(!badKv.ok && badKv.kind === "invalid", "kv 只认 JSON 对象,数组要报错(别把数组当键值对存进去)");
|
||||
eq(planSubmit([F.headers()], { headers: "not json" }, {}).ok, false, "kv 非法 JSON ⇒ invalid");
|
||||
|
||||
// 「无效值」优先于「缺位」:能看见的错误必须报出来,不能因为解析失败就静默丢字段。
|
||||
eq(planSubmit([F.limit()], { limit: "x" }, { limit: "3" }).ok, false, "非法值即便盘上有值也要拦下");
|
||||
}
|
||||
|
||||
// ── 单字段解析器:类型/边界的细节 ─────────────────────────────────────────────
|
||||
{
|
||||
eq(parseFieldValue(F.limit(), " 7 "), { ok: true, value: 7 }, "trim 后解析数字");
|
||||
eq(parseFieldValue(F.limit(), "0"), { ok: false, message: "上限 不能小于 1。" }, "低于 min 的文案点名字段与边界");
|
||||
eq(parseFieldValue(F.regions(), "cn, us ,"), { ok: true, value: ["cn", "us"] }, "多选去空白项");
|
||||
eq(parseFieldValue(F.headers(), "{}"), { ok: true, value: {} }, "空对象是合法的键值对");
|
||||
eq(parseFieldValue(F.endpoint(), " https://a "), { ok: true, value: "https://a" }, "文本 trim");
|
||||
}
|
||||
|
||||
console.log(`schema values test: ${checks} checks passed`);
|
||||
@@ -0,0 +1,192 @@
|
||||
/** 通用字段的**装载 / 校验 / 写回**规则(纯函数,无 React、无 IO)。
|
||||
*
|
||||
* 与 `modules/channelFieldRules.ts` 是同一份口径的泛化版:那边带平台选择器(transport ×
|
||||
* mode 的显隐 / 必填),这边只保留与来源无关的部分 —— 控件全部可见(插件声明面没有
|
||||
* `showIn`),但**必填判定**与**三态写回**必须逐条同义,否则同一份「留空 = 不改」会在
|
||||
* 两个页面给出两种结果。
|
||||
*
|
||||
* 三态写回(对齐后端 `runtimeconfig.PatchChannel` 的补丁语义):
|
||||
* - 字段有值 → 写入该值(覆盖旧值);
|
||||
* - 显式清除(勾了清除开关)→ 写入 `""`;
|
||||
* - 留空且盘上原本有值 → 写入 `""`(可见字段被清空是用户意图,不许静默保留);
|
||||
* - 留空且盘上原本没值 → **缺位 = 保留**,不把 `""` 写进一个从没配过的键;
|
||||
* - **掩码秘密留空** → 一律**缺位 = 不改动**(唯一例外见下),面板不持有原文,
|
||||
* 空字符串不能表达「不改」也不能表达「清空」—— 要清空必须勾显式开关
|
||||
* (design/09 §5.4.2 的哨兵语义)。
|
||||
*/
|
||||
|
||||
import type { SchemaField, SchemaFieldType } from "./fieldTypes";
|
||||
|
||||
/** 表单值一律以字符串承载(与渠道配置表单同一口径):控件层只认 string,
|
||||
* 提交时再按声明的 type 还原成后端要的类型。 */
|
||||
export type FieldValues = Readonly<Record<string, string>>;
|
||||
|
||||
/** 后端数据面(status / 表格行)里的一条记录。 */
|
||||
export type FieldData = Readonly<Record<string, unknown>>;
|
||||
|
||||
export function fieldLabel(f: SchemaField): string {
|
||||
const label = (f.label ?? "").trim();
|
||||
return label !== "" ? label : f.key;
|
||||
}
|
||||
|
||||
/** 敏感字段:读侧永不回显明文,写侧留空 = 不改动(design/09 §5.4.2)。 */
|
||||
export function isSensitive(f: SchemaField): boolean {
|
||||
return f.type === "secret";
|
||||
}
|
||||
|
||||
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
/** 把后端下发的值还原成控件字符串(下拉 / 文本 / 开关 / 多选 / 键值对)。 */
|
||||
export function fieldValueToString(type: SchemaFieldType, v: unknown): string {
|
||||
if (v === null || v === undefined) return "";
|
||||
switch (type) {
|
||||
case "switch":
|
||||
if (typeof v === "boolean") return v ? "true" : "false";
|
||||
return v === "true" ? "true" : "";
|
||||
case "multiSelect":
|
||||
if (Array.isArray(v)) return v.map((x) => String(x)).join(", ");
|
||||
return typeof v === "string" ? v : "";
|
||||
case "kv":
|
||||
return isPlainObject(v) ? JSON.stringify(v, null, 2) : "";
|
||||
case "number":
|
||||
return typeof v === "number" ? String(v) : typeof v === "string" ? v : "";
|
||||
default:
|
||||
return typeof v === "string" ? v : typeof v === "number" || typeof v === "boolean" ? String(v) : "";
|
||||
}
|
||||
}
|
||||
|
||||
export interface LoadedForm {
|
||||
values: Record<string, string>;
|
||||
/** 盘上已有值的键(含只有掩码可看的秘密):算作必填已满足,且是「留空 = 不改」的依据。 */
|
||||
configured: string[];
|
||||
}
|
||||
|
||||
/** 从后端数据面装载表单初值。
|
||||
*
|
||||
* 两条口径写死在这里:
|
||||
* ① 敏感字段**永不回填**(后端只下发掩码;把掩码回填进输入框 = 一次保存把 `****abcd`
|
||||
* 写成真凭据)。它只被记进 `configured`,于是「留空 = 不改动」在装载这一步就成立。
|
||||
* ② 其余字段按声明类型还原;声明缺省值只在盘上没有该键时生效。 */
|
||||
export function loadFormValues(fields: readonly SchemaField[], data: FieldData): LoadedForm {
|
||||
const values: Record<string, string> = {};
|
||||
const configured: string[] = [];
|
||||
for (const f of fields) {
|
||||
const raw = data[f.key];
|
||||
const hasStored = raw !== undefined && raw !== null && raw !== "" && !(Array.isArray(raw) && raw.length === 0);
|
||||
if (hasStored) configured.push(f.key);
|
||||
if (isSensitive(f)) {
|
||||
values[f.key] = "";
|
||||
continue;
|
||||
}
|
||||
const loaded = hasStored ? fieldValueToString(f.type, raw) : "";
|
||||
if (loaded !== "") {
|
||||
values[f.key] = loaded;
|
||||
continue;
|
||||
}
|
||||
values[f.key] = f.default === undefined ? "" : fieldValueToString(f.type, f.default);
|
||||
}
|
||||
return { values, configured };
|
||||
}
|
||||
|
||||
/** 当前必填字段里**还没值**的那些。`configuredKeys` 里的键算已满足 —— 否则「已配置
|
||||
* 凭据」的必填字段会在每次打开表单时被误报缺失,逼用户把秘密重打一遍。 */
|
||||
export function missingRequiredFields(
|
||||
fields: readonly SchemaField[],
|
||||
values: FieldValues,
|
||||
configuredKeys: readonly string[] = [],
|
||||
): SchemaField[] {
|
||||
const configured = new Set(configuredKeys);
|
||||
return fields.filter(
|
||||
(f) => f.required === true && (values[f.key] ?? "").trim() === "" && !configured.has(f.key),
|
||||
);
|
||||
}
|
||||
|
||||
/** 必填缺失的文案:把「保存不了」变成页面上点名的一行话。 */
|
||||
export function missingRequiredMessage(fields: readonly SchemaField[]): string {
|
||||
const names = fields.map((f) => fieldLabel(f)).join("、");
|
||||
return `缺少必填字段:${names}`;
|
||||
}
|
||||
|
||||
/** 「当前必填字段已满足」的正向清单,供界面打「必填」标记。 */
|
||||
export function requiredFieldKeys(fields: readonly SchemaField[]): string[] {
|
||||
return fields.filter((f) => f.required === true).map((f) => f.key);
|
||||
}
|
||||
|
||||
/** 把控件字符串解析成后端要的类型。只有 number / kv / multiSelect 会失败。 */
|
||||
export function parseFieldValue(f: SchemaField, raw: string): { ok: true; value: unknown } | { ok: false; message: string } {
|
||||
const text = raw.trim();
|
||||
const name = fieldLabel(f);
|
||||
switch (f.type) {
|
||||
case "number": {
|
||||
const n = Number(text);
|
||||
if (!Number.isFinite(n)) return { ok: false, message: `${name} 需要一个数字(当前「${text}」)。` };
|
||||
if (f.min !== undefined && n < f.min) return { ok: false, message: `${name} 不能小于 ${f.min}。` };
|
||||
if (f.max !== undefined && n > f.max) return { ok: false, message: `${name} 不能大于 ${f.max}。` };
|
||||
return { ok: true, value: n };
|
||||
}
|
||||
case "switch":
|
||||
return { ok: true, value: text === "true" };
|
||||
case "multiSelect":
|
||||
return { ok: true, value: text.split(/[,,\n]/).map((s) => s.trim()).filter(Boolean) };
|
||||
case "kv": {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
return { ok: false, message: `${name} 需要 JSON 对象(形如 {"X-Header":"v"})。` };
|
||||
}
|
||||
if (!isPlainObject(parsed)) return { ok: false, message: `${name} 需要 JSON **对象**,不是数组或单值。` };
|
||||
return { ok: true, value: parsed };
|
||||
}
|
||||
default:
|
||||
return { ok: true, value: text };
|
||||
}
|
||||
}
|
||||
|
||||
export type SubmitPlan =
|
||||
| { ok: true; patch: Record<string, unknown> }
|
||||
| { ok: false; kind: "missing"; missing: SchemaField[]; message: string }
|
||||
| { ok: false; kind: "invalid"; message: string };
|
||||
|
||||
/** 保存时的写回补丁(三态语义见文件头注释)。
|
||||
*
|
||||
* `initial` 是打开表单时的值(渠道页的 `initialVals` 同义):判断「用户把盘上已有值清空
|
||||
* 了」必须知道它原本是什么,只有看得见的值才谈得上「显式清空」。
|
||||
* `clearedKeys` 是主人显式勾选清除的键(掩码秘密唯一能清除的方式)。 */
|
||||
export function planSubmit(
|
||||
fields: readonly SchemaField[],
|
||||
values: FieldValues,
|
||||
initial: FieldValues = {},
|
||||
clearedKeys: readonly string[] = [],
|
||||
configuredKeys: readonly string[] = [],
|
||||
): SubmitPlan {
|
||||
const missing = missingRequiredFields(fields, values, configuredKeys);
|
||||
if (missing.length > 0) {
|
||||
return { ok: false, kind: "missing", missing, message: missingRequiredMessage(missing) };
|
||||
}
|
||||
const cleared = new Set(clearedKeys);
|
||||
const patch: Record<string, unknown> = {};
|
||||
for (const f of fields) {
|
||||
const raw = (values[f.key] ?? "").trim();
|
||||
if (cleared.has(f.key)) {
|
||||
patch[f.key] = "";
|
||||
continue;
|
||||
}
|
||||
if (isSensitive(f)) {
|
||||
// 敏感字段:只有「用户重打了一个新值」才写入;留空一律**缺位**(不改动)。
|
||||
// 无论盘上有没有值都如此 —— 面板从不持有原文,凭「初始值」推算清空意图是猜的。
|
||||
if (raw !== "") patch[f.key] = raw;
|
||||
continue;
|
||||
}
|
||||
if (raw !== "") {
|
||||
const parsed = parseFieldValue(f, raw);
|
||||
if (!parsed.ok) return { ok: false, kind: "invalid", message: parsed.message };
|
||||
patch[f.key] = parsed.value;
|
||||
continue;
|
||||
}
|
||||
if ((initial[f.key] ?? "").trim() !== "") patch[f.key] = "";
|
||||
}
|
||||
return { ok: true, patch };
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
import type { AdminSectionId } from "./router";
|
||||
import type { IconName } from "../components/Icon";
|
||||
|
||||
/** Sidebar grouping for the 16 admin modules. Groups are visual only. */
|
||||
/** Sidebar grouping for the 16 admin modules. Groups are visual only.
|
||||
*
|
||||
* ⚠️ 这份清单是**静态核心模块**的注册表,故意只描述内置模块。**插件声明的管理页面
|
||||
* 不在这里**:它们由 `GET /api/v1/admin/panels` 下发的快照驱动,经 `navModel.ts` 合成
|
||||
* 到侧边栏的 `plugins` 折叠分组里(design/09 §5.2.1 / §5.7 D)。新增一个插件页**不得**
|
||||
* 在本文件加一行 —— 那是「可接入点」是否成立的判据,`navModel.test.ts` 会钉住它。
|
||||
*/
|
||||
export interface AdminSectionMeta {
|
||||
id: AdminSectionId;
|
||||
label: string;
|
||||
|
||||
Reference in new issue
Block a user