Files
AskaEth c9144c1096
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(plugins): 插件「管理面板声明」库——声明模型 + 闭集校验 + catalog(design/09 §5.2/§5.2.1)
纯库、零接线:不碰 gateway/frontend/Docs/.env*(本片刻意不接线,避免与在途切片争用文件面)。

- pkg/plugins/sdk:面板声明类型(JSON tag 与 design/09 §5.2 示例逐字同形);
  字段类型与 section 类型都是**闭集**;When 三态 + **严格 UnmarshalJSON**(未知条件键报错,
  不静默丢)
- internal/capability/paneldecl:闭集校验(47 条拒绝用例,断言的是**分类哨兵 + JSON 路径**,
  不是错误字符串)· catalog(并发安全、返回拷贝、按 (group, order, 登记序号) 的**全序**稳定排序)·
  when 求值(上下文由 Resolver 注入,本包不读环境变量/文件)· 角色过滤(viewer 只留
  status/log/table 且摘掉 rowActions,纯写面板整体隐藏;未知角色 fail-closed)
- 拒绝粒度:插件 id 非法 ⇒ 整插件拒绝;单个面板非法 ⇒ 只拒该面板 + 聚合显式报错(理由见报告)

【我的验收】隔离 worktree(19 文件)build/vet/linux/darwin 全过、69 包全绿;
我亲手打红变异:把同组同序的回退比较  反成  ⇒ TestCatalog_StableOrder FAIL。
(记录一条观察:比较器已靠 seq 构成全序,故 sort.SliceStable 的稳定性是冗余保护 ——
我第一次用「SliceStable→Slice」打不红,正是因为全序与算法无关;这不是测试虚护。)
2026-10-06 08:06:20 +08:00

154 lines
8.2 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package sdk
// 本文件是 design/09-插件生态 §5.2「声明形状(唯一权威形态)」在 Go 侧的唯一定义:
// 插件作者写下的就是这份形状(plugins/<pluginId>/manifest.json 的 adminPanels 段)。
//
// 为什么声明放在 SDK 而不是后端内部包:§5.2.1 的红线是「声明而非编码」—— 插件不注入
// 任何 JS/CSS/HTML,只说「我要什么字段、怎么校验、失败怎么说」,渲染与流程由宿主负责
// (面板可接入点 + 插件管理器)。声明既然是插件作者可见的对外契约,它就属于 SDK;
// JSON tag 与 §5.2 的示例逐字同形,改 tag 等于改对外契约。
//
// 本文件只放**数据形状 + 闭集判定**:校验、目录(catalog)、when 求值、角色过滤都在
// internal/capability/paneldecl —— 数据与策略分开,插件包不依赖任何内部包。
// GroupID 是侧边栏折叠分组 id(§5.2 的 `group`)。
type GroupID string
const (
// GroupPlugins 是本期的唯一分组。依据 §5.2「group: 侧边栏折叠分组 id(本期固定
// plugins)」—— 闭集只有一个成员,因此空值可以安全地回落到它(回落动作在
// paneldecl.Register/Validate 里做,SDK 这里只给判定)。
GroupPlugins GroupID = "plugins"
)
// Valid 报告 g 是否属于本期已知的分组闭集。
func (g GroupID) Valid() bool { return g == GroupPlugins }
// SectionType 是 section 的渲染形状(§5.2 的 section 类型闭集)。
//
// 闭集是刻意的:它让「插件声明 UI」在没有任意代码注入的前提下仍然够用,并且每个形状
// 都能复用既有 Miuix 组件(§5.2 末尾的口径)。
type SectionType string
const (
// SectionForm 是表单区块:消费 `fields[]` + `submit.action`(§5.2.1 section.form),
// 由既有字段渲染器泛化渲染。它是四种区块里**唯一含写面**的一种。
SectionForm SectionType = "form"
// SectionTable 是表格区块:消费 `columns[]` / `rowActions[]` / `source`(§5.2.1
// section.table),由既有虚拟滚动表格渲染。
SectionTable SectionType = "table"
// SectionStatus 是只读事实面(如「当前生效的降级链」):消费只读 `fields[]`
// (§5.2.1 section.status)。
SectionStatus SectionType = "status"
// SectionLog 是只读日志卡:消费 `source` / `limit`,最新在上(§5.2.1 section.log,
// 与 L1 口径一致)。
SectionLog SectionType = "log"
)
// Valid 报告 t 是否属于 section 类型闭集。
func (t SectionType) Valid() bool {
switch t {
case SectionForm, SectionTable, SectionStatus, SectionLog:
return true
default:
return false
}
}
// ReadOnly 报告该 section 类型本身是否只读。角色过滤的第一道判据
// (design/09 §5.4.1「只读事实面对 viewer 可见,写动作一律 owner/admin」):
// 只有 form 自带写面(submit),table 的行内动作需要额外摘除(见 paneldecl.filterPanel)。
func (t SectionType) ReadOnly() bool { return t != SectionForm }
// Panel 是一个插件声明的管理页面(§5.2 的 adminPanels 元素)。
type Panel struct {
// ID 是面板 id。它与插件 id 组合成前端路由 /admin/plugin/<pluginId>/<panelId>
// (§5.7 C),所以必须能直接当 URL 路径段与端点段用 —— 字符集由 paneldecl 校验,
// 不在这里放宽。
ID string `json:"id"`
// Title 是页面标题,同时是侧边栏条目文案(§5.2.1 nav.item 消费 id/title/icon/order/when)。
Title string `json:"title"`
// Description 是页面描述。§5.2 的示例里没有这一行,但 §5.2.1 的 `page.root` 一行
// 明确写「消费 title / description」—— 取并集,缺省为空(不一致处记在 PLG1-report.md)。
Description string `json:"description,omitempty"`
// Group 是侧边栏折叠分组 id。空值按 §5.2「本期固定 plugins」回落到 GroupPlugins;
// 非空但不是闭集成员 ⇒ 校验拒绝。
Group GroupID `json:"group"`
// Order 是组内排序,小在前;同 order 时按声明顺序(稳定序,前端导航不许因 map 迭代
// 抖动)。
Order int `json:"order"`
// Icon 是宿主图标名(§5.2 的 `icon`,示例为 "search")。宿主按名字取内置图标,
// 因此这里只接受图标名,不接受 URL / SVG 片段 —— 它是「无任意代码注入」的一部分。
Icon string `json:"icon,omitempty"`
// When 是条件显示声明:不满足 ⇒ 入口**不出现**(不是灰掉,§5.2.1 nav.item)。
// 缺省(nil)表示无条件显示。求值在 paneldecl.Eval,上下文由调用方注入。
When *When `json:"when,omitempty"`
// Sections 是页面内的区块列表。允许为空(§5.1「一个插件可声明**零到多个**管理页面」,
// 面板本身也可以是纯壳),不做「空面板即非法」的收紧。
Sections []Section `json:"sections"`
}
// Section 是页面内的一个区块(§5.2.1 section.form / section.table / section.status /
// section.log 四个接入点之一)。
//
// 每种类型**消费哪些键**由 §5.2.1 的接入点表定死,paneldecl 据此做形状校验:形状错了
// 到前端就是「区块在、功能不在」的半渲染(§5.2.1 点名要防的),所以按面板整体拒掉。
type Section struct {
// Type 决定这个区块怎么渲染,必须落在闭集内(form|table|status|log)。
Type SectionType `json:"type"`
// ID 是区块 id,在面板内唯一:它既是前端渲染 key,也是「哪一段坏了」的定位锚点。
ID string `json:"id"`
// Title 是区块标题。block 无标题会让用户看到一段无来由的表单/表格,因此必填。
Title string `json:"title"`
// Fields 是字段声明:form(可写)与 status(只读)消费。
Fields []Field `json:"fields,omitempty"`
// Submit 是表单提交动作:只有 form 消费(写面的唯一入口)。
Submit *Submit `json:"submit,omitempty"`
// Source 是数据来源:table 与 log 消费。它只给**动作名**,不给 URL(§5.4.4)。
Source *Source `json:"source,omitempty"`
// Columns 是表格列声明:只有 table 消费。
Columns []Column `json:"columns,omitempty"`
// RowActions 是行内动作:只有 table 消费(如「测试连通」「重配凭据」)。
RowActions []RowAction `json:"rowActions,omitempty"`
// Limit 是 log 区块只读的最近条数(§5.2.1 section.log 消费 source/limit)。
// §5.2 的示例里没有这个键 —— 不一致处记在 PLG1-report.md。
Limit int `json:"limit,omitempty"`
}
// Source 是 table / log 的数据来源。
//
// 为什么只有一个 action 字段:§5.4.4 的硬约束是「插件声明的只是**动作名**,不是任意
// URL ⇒ 不给插件开 SSRF 面」。所以这里既没有 url 也没有 path —— 想塞 URL 的插件会
// 在校验期被拒(paneldecl)。
type Source struct {
// Action 是插件声明的动作名(示例 plugin.listProviders),由宿主端点
// POST /api/v1/admin/plugins/{id}/actions/{action} 分发。
Action string `json:"action"`
}
// Submit 是 form 区块的提交动作(§5.2 的 `submit.action`)。
type Submit struct {
// Action 的语义与 Source.Action 相同:只有动作名,没有 URL。
Action string `json:"action"`
}
// Column 是 table 的一列(§5.2 的 `columns` 元素)。
type Column struct {
// Key 是行数据里的字段名,也是列的身份:列内唯一,用于渲染与排序。
Key string `json:"key"`
// Label 是表头文案。缺省时宿主回落到 Key(Key 是机器可读标识且列内唯一,
// 有可靠回落 ⇒ 不作为必填)。
Label string `json:"label,omitempty"`
}
// RowAction 是 table 的行内动作(§5.2 的 `rowActions` 元素,示例「测试连通」/「重配凭据」)。
type RowAction struct {
// ID 是动作在行内的身份,必须唯一 —— 同一行里两个同名动作会让用户点错。
ID string `json:"id"`
// Label 是按钮文案。**必填且没有可回落的东西**(不像 Column.Label 能回落到 Key):
// 一个没有文字的按钮就是半渲染。
Label string `json:"label"`
// Action 是插件声明的动作名(同样只有名字、没有 URL)。
Action string `json:"action"`
}