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」打不红,正是因为全序与算法无关;这不是测试虚护。)
This commit is contained in:
1 parent
6e28b38b08
commit
c9144c1096
19 files changed
+3053
No files matched your search
@@ -0,0 +1,153 @@
|
||||
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"`
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package sdk
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件锁定两件事:
|
||||
// 1. 声明的 JSON tag 与 design/09 §5.2 的示例**逐字同形**(示例 JSON 直接解码进来);
|
||||
// 2. 闭集(分组 / section 类型 / 字段类型)与 when 的严格解码确实按闭集工作。
|
||||
|
||||
// designExample 是 design/09-插件生态 §5.2 示例里 adminPanels 段的**逐字拷贝**
|
||||
// (只去掉 JSONC 注释)。它在这里是权威输入:tag 一旦改坏,本用例必红。
|
||||
const designExample = `[
|
||||
{
|
||||
"id": "providers",
|
||||
"title": "搜索提供商",
|
||||
"group": "plugins",
|
||||
"order": 20,
|
||||
"icon": "search",
|
||||
"when": { "enabled": true },
|
||||
"sections": [
|
||||
{ "type": "form", "id": "add", "title": "添加提供商",
|
||||
"fields": [
|
||||
{ "key": "provider", "label": "提供商", "type": "select", "optionsFrom": "plugin:providers", "required": true },
|
||||
{ "key": "apiKey", "label": "API Key", "type": "secret", "required": true },
|
||||
{ "key": "endpoint", "label": "接口地址", "type": "text", "placeholder": "https://..." }
|
||||
],
|
||||
"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" }
|
||||
] }
|
||||
]
|
||||
}]`
|
||||
|
||||
// TestPanel_DecodesDesignExample 证明 §5.2 的示例能原样解码进我们的类型。
|
||||
func TestPanel_DecodesDesignExample(t *testing.T) {
|
||||
t.Parallel()
|
||||
var panels []Panel
|
||||
if err := json.Unmarshal([]byte(designExample), &panels); err != nil {
|
||||
t.Fatalf("§5.2 示例解码失败(说明 JSON tag 与文档不同形):%v", err)
|
||||
}
|
||||
if len(panels) != 1 {
|
||||
t.Fatalf("面板数 = %d,期望 1", len(panels))
|
||||
}
|
||||
p := panels[0]
|
||||
if p.ID != "providers" || p.Title != "搜索提供商" || p.Group != GroupPlugins {
|
||||
t.Fatalf("面板头字段解码错误:%+v", p)
|
||||
}
|
||||
if p.Order != 20 || p.Icon != "search" {
|
||||
t.Fatalf("order/icon 解码错误:%+v", p)
|
||||
}
|
||||
if p.When == nil || p.When.Enabled == nil || !*p.When.Enabled {
|
||||
t.Fatalf("when 解码错误:%+v", p.When)
|
||||
}
|
||||
if len(p.Sections) != 2 {
|
||||
t.Fatalf("区块数 = %d,期望 2", len(p.Sections))
|
||||
}
|
||||
form := p.Sections[0]
|
||||
if form.Type != SectionForm || form.ID != "add" || form.Submit == nil ||
|
||||
form.Submit.Action != "plugin.saveProvider" {
|
||||
t.Fatalf("form 区块解码错误:%+v", form)
|
||||
}
|
||||
if len(form.Fields) != 3 || form.Fields[0].OptionsFrom != "plugin:providers" ||
|
||||
!form.Fields[0].Required || form.Fields[1].Type != FieldSecret ||
|
||||
form.Fields[2].Placeholder != "https://..." {
|
||||
t.Fatalf("字段解码错误:%+v", form.Fields)
|
||||
}
|
||||
table := p.Sections[1]
|
||||
if table.Type != SectionTable || table.Source == nil ||
|
||||
table.Source.Action != "plugin.listProviders" {
|
||||
t.Fatalf("table 区块解码错误:%+v", table)
|
||||
}
|
||||
if len(table.Columns) != 2 || table.Columns[1].Key != "state" {
|
||||
t.Fatalf("列解码错误:%+v", table.Columns)
|
||||
}
|
||||
if len(table.RowActions) != 2 || table.RowActions[1].ID != "reauth" ||
|
||||
table.RowActions[1].Action != "plugin.reauthProvider" {
|
||||
t.Fatalf("行内动作解码错误:%+v", table.RowActions)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPanel_RoundTrips 锁反向:编码再解码必须一模一样(tag 只对了一半就会在这里露出来)。
|
||||
func TestPanel_RoundTrips(t *testing.T) {
|
||||
t.Parallel()
|
||||
var first []Panel
|
||||
if err := json.Unmarshal([]byte(designExample), &first); err != nil {
|
||||
t.Fatalf("解码: %v", err)
|
||||
}
|
||||
raw, err := json.Marshal(first)
|
||||
if err != nil {
|
||||
t.Fatalf("编码: %v", err)
|
||||
}
|
||||
var second []Panel
|
||||
if err := json.Unmarshal(raw, &second); err != nil {
|
||||
t.Fatalf("再解码: %v (%s)", err, raw)
|
||||
}
|
||||
if !reflect.DeepEqual(first, second) {
|
||||
t.Fatalf("编码往返不一致:\n%+v\n%+v", first, second)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhen_StrictDecode 锁定 when 是**闭集**:未知条件键必须显式报错,不能被静默丢掉。
|
||||
func TestWhen_StrictDecode(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
want error
|
||||
}{
|
||||
{"只有 enabled", `{"enabled":true}`, nil},
|
||||
{"空对象(无条件)", `{}`, nil},
|
||||
{"未知条件键", `{"configured":true}`, ErrUnknownWhenKey},
|
||||
{"未知条件键(多个,报错必须确定)", `{"zed":1,"alpha":2}`, ErrUnknownWhenKey},
|
||||
{"enabled 不是布尔", `{"enabled":"yes"}`, ErrInvalidWhen},
|
||||
{"不是对象", `true`, ErrInvalidWhen},
|
||||
{"是 null", `null`, ErrInvalidWhen},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var w When
|
||||
err := json.Unmarshal([]byte(tc.raw), &w)
|
||||
if tc.want == nil {
|
||||
if err != nil {
|
||||
t.Fatalf("解码 %s 失败: %v", tc.raw, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !errors.Is(err, tc.want) {
|
||||
t.Fatalf("解码 %s err = %v,期望 %v", tc.raw, err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhen_UnknownKeyIsDeterministic 锁定多未知键时报的是同一个键(错误信息不能随 map 迭代变)。
|
||||
func TestWhen_UnknownKeyIsDeterministic(t *testing.T) {
|
||||
t.Parallel()
|
||||
var first error
|
||||
for i := 0; i < 20; i++ {
|
||||
var w When
|
||||
err := json.Unmarshal([]byte(`{"zeta":1,"alpha":2,"mu":3}`), &w)
|
||||
if first == nil {
|
||||
first = err
|
||||
continue
|
||||
}
|
||||
if err == nil || err.Error() != first.Error() {
|
||||
t.Fatalf("第 %d 次报错 %v,与首次 %v 不一致", i, err, first)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhen_PropagatesThroughPanel 锁定严格性会穿透面板解码 —— 插件管理器拿到的就是这一层。
|
||||
func TestWhen_PropagatesThroughPanel(t *testing.T) {
|
||||
t.Parallel()
|
||||
raw := `[{"id":"p","title":"t","group":"plugins","order":1,` +
|
||||
`"when":{"enabled":true,"configured":true},"sections":[]}]`
|
||||
var panels []Panel
|
||||
err := json.Unmarshal([]byte(raw), &panels)
|
||||
if !errors.Is(err, ErrUnknownWhenKey) {
|
||||
t.Fatalf("err = %v,期望 ErrUnknownWhenKey 穿透到面板解码", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClosedSets 锁定三个闭集与它们的判定方法。新增成员必须同时改这里 —— 这正是闭集的意义。
|
||||
func TestClosedSets(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tt := range []FieldType{FieldText, FieldSecret, FieldNumber, FieldSwitch,
|
||||
FieldSelect, FieldMultiSelect, FieldDate, FieldKV} {
|
||||
if !tt.Valid() {
|
||||
t.Errorf("字段类型 %q 应当合法", tt)
|
||||
}
|
||||
}
|
||||
if FieldType("chart").Valid() {
|
||||
t.Error("字段类型 chart 不该合法(闭集外)")
|
||||
}
|
||||
if FieldType("").Valid() {
|
||||
t.Error("空字段类型不该合法")
|
||||
}
|
||||
for _, st := range []SectionType{SectionForm, SectionTable, SectionStatus, SectionLog} {
|
||||
if !st.Valid() {
|
||||
t.Errorf("区块类型 %q 应当合法", st)
|
||||
}
|
||||
}
|
||||
if SectionType("chart").Valid() {
|
||||
t.Error("区块类型 chart 不该合法(闭集外)")
|
||||
}
|
||||
if !GroupPlugins.Valid() || GroupID("search").Valid() {
|
||||
t.Error("分组闭集判定错误(本期只有 plugins)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTypeHelpers 锁定三个语义判定:敏感、数值、要拉选项。
|
||||
func TestTypeHelpers(t *testing.T) {
|
||||
t.Parallel()
|
||||
if !FieldSecret.Sensitive() || FieldText.Sensitive() {
|
||||
t.Error("Sensitive 判定错误")
|
||||
}
|
||||
if !FieldNumber.Numeric() || FieldDate.Numeric() {
|
||||
t.Error("Numeric 判定错误(只有 number 接受 min/max)")
|
||||
}
|
||||
if !FieldSelect.PullsOptions() || !FieldMultiSelect.PullsOptions() || FieldText.PullsOptions() {
|
||||
t.Error("PullsOptions 判定错误")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSectionType_ReadOnly 锁定角色过滤的第一道判据:只有 form 自带写面。
|
||||
func TestSectionType_ReadOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
if SectionForm.ReadOnly() {
|
||||
t.Error("form 不是只读区块(它有 submit)")
|
||||
}
|
||||
for _, st := range []SectionType{SectionTable, SectionStatus, SectionLog} {
|
||||
if !st.ReadOnly() {
|
||||
t.Errorf("%q 应当是只读区块", st)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package sdk
|
||||
|
||||
// 本文件是 design/09-插件生态 §5.2「字段类型集合(闭集)」在 Go 侧的唯一定义,
|
||||
// 以及字段声明本身(form / status 区块的 `fields[]` 元素)。
|
||||
|
||||
// FieldType 是字段控件类型(闭集,§5.2:不许插件自定义渲染器)。
|
||||
//
|
||||
// 闭集的意义:每个形状都能复用既有 Miuix 组件(渠道配置那套字段渲染 + 虚拟滚动表格),
|
||||
// 因此「插件声明 UI」在无任意代码注入的前提下依然够用 —— 配置类插件 90% 的需求就是
|
||||
// 这几个形状。
|
||||
type FieldType string
|
||||
|
||||
const (
|
||||
// FieldText 是单行文本(示例 endpoint:接口地址)。
|
||||
FieldText FieldType = "text"
|
||||
// FieldSecret 是凭据输入(示例 apiKey)。读侧永不回显明文(§5.4.2),因此
|
||||
// paneldecl 拒绝它声明默认值。
|
||||
FieldSecret FieldType = "secret"
|
||||
// FieldNumber 是数值输入(唯一接受 min/max 边界的类型)。
|
||||
FieldNumber FieldType = "number"
|
||||
// FieldSwitch 是布尔开关。
|
||||
FieldSwitch FieldType = "switch"
|
||||
// FieldSelect 是单选下拉(选项来自 optionsFrom)。
|
||||
FieldSelect FieldType = "select"
|
||||
// FieldMultiSelect 是多选下拉(选项来自 optionsFrom)。
|
||||
FieldMultiSelect FieldType = "multiSelect"
|
||||
// FieldDate 是日期。
|
||||
FieldDate FieldType = "date"
|
||||
// FieldKV 是键值对(§5.2 的例子:自定义 header)。
|
||||
FieldKV FieldType = "kv"
|
||||
)
|
||||
|
||||
// Valid 报告 t 是否属于字段类型闭集。
|
||||
func (t FieldType) Valid() bool {
|
||||
switch t {
|
||||
case FieldText, FieldSecret, FieldNumber, FieldSwitch,
|
||||
FieldSelect, FieldMultiSelect, FieldDate, FieldKV:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Sensitive 报告该类型是否承载凭据。§5.4.2 要求读侧永不回显明文(只回「是否已配置」+
|
||||
// 尾 4 位或哈希前缀),所以敏感字段不得声明任何默认值 —— 校验在 paneldecl。
|
||||
func (t FieldType) Sensitive() bool { return t == FieldSecret }
|
||||
|
||||
// Numeric 报告该类型是否接受 min/max 数值边界。依据:闭集里只有 number 是数值控件,
|
||||
// date 在声明里是日期字符串,用数值边界表达日期范围只会产生歧义。
|
||||
func (t FieldType) Numeric() bool { return t == FieldNumber }
|
||||
|
||||
// PullsOptions 报告该类型是否需要宿主去拉下拉选项。依据:只有 select / multiSelect 会
|
||||
// 渲染选项列表,其它类型上的 optionsFrom 是无效声明(会被 paneldecl 拒绝)。
|
||||
func (t FieldType) PullsOptions() bool { return t == FieldSelect || t == FieldMultiSelect }
|
||||
|
||||
// Field 是一个字段声明(§5.2 的 fields 元素,form 与 status 共用)。
|
||||
type Field struct {
|
||||
// Key 是配置键 / 行字段名,形如 §5.2 示例的 provider / apiKey / endpoint。
|
||||
// 它同时是表单的提交键与 status 行的数据键,因此在区块内必须唯一。
|
||||
Key string `json:"key"`
|
||||
// Label 是字段标签。缺省时宿主回落到 Key(与 Column.Label 同一取舍)。
|
||||
Label string `json:"label,omitempty"`
|
||||
// Type 决定控件,必须落在闭集内。
|
||||
Type FieldType `json:"type"`
|
||||
// Required 是**配置值**的必填标记(§5.2 示例的 `"required": true`),不是声明层的
|
||||
// 必填 —— 声明层的必填只认 Key 与 Type。
|
||||
Required bool `json:"required,omitempty"`
|
||||
// Placeholder 是输入提示(示例 `https://...`)。
|
||||
Placeholder string `json:"placeholder,omitempty"`
|
||||
// OptionsFrom 是下拉选项来源。§5.2 只给了插件内的一种来源(示例 plugin:providers);
|
||||
// **只允许 `plugin:` 前缀**,其它一律拒 —— 防「声明里塞任意 URL」。
|
||||
OptionsFrom string `json:"optionsFrom,omitempty"`
|
||||
// Default 是字段初始值。§5.2 的示例里没有这个键;它按任务书「若你定义了这些可选键」
|
||||
// 的口径加入,且对 secret 类型被 paneldecl 硬拒(§5.4.2 读侧无明文)。
|
||||
Default any `json:"default,omitempty"`
|
||||
// Min 是数值下界,仅 number 类型可声明(§5.2 示例里没有这个键,同上)。
|
||||
Min *float64 `json:"min,omitempty"`
|
||||
// Max 是数值上界,仅 number 类型可声明,且必须 >= Min。
|
||||
Max *float64 `json:"max,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package sdk
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// 本文件定义面板入口的**条件显示**声明 `when`(design/09 §5.2.1 nav.item:
|
||||
// 「`when` 不满足 ⇒ 条目不出现」,不是灰掉)。
|
||||
|
||||
const (
|
||||
// whenKeyEnabled 是本期唯一支持的条件键:§5.2 的 `"when": { "enabled": true }`
|
||||
// 是设计里出现过的唯一条件形态,§5.6 的验收判据也只有「未启用的插件 ⇒ 入口不出现」。
|
||||
// 刻意不留第二个键 —— 闭集外的条件属于「先改设计再加」的范畴。
|
||||
whenKeyEnabled = "enabled"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrInvalidWhen 报告 when 不是合法的条件对象(非对象、null、或 enabled 不是布尔)。
|
||||
ErrInvalidWhen = errors.New("sdk: invalid when clause")
|
||||
// ErrUnknownWhenKey 报告 when 里出现了闭集外的条件键。
|
||||
//
|
||||
// 刻意**报错而不是忽略**:静默忽略会让插件作者以为条件生效了,而入口照常出现 ——
|
||||
// §5.2.1 的口径是「绝不静默丢弃」。这条只能在解码期拦住(结构体解码本身会丢弃未知
|
||||
// 键),所以这里自己实现 UnmarshalJSON。
|
||||
ErrUnknownWhenKey = errors.New("sdk: unknown when key")
|
||||
)
|
||||
|
||||
// When 是面板入口的条件显示声明。零值语义是「没有条件」,见 paneldecl.Eval。
|
||||
type When struct {
|
||||
// Enabled 有三个状态,用指针而不是 bool 表达:
|
||||
// - nil:没声明这个条件(含整份 when 缺省的 nil 指针)⇒ 不参与判定;
|
||||
// - &true:要求插件当前已启用(§5.6.1「未启用 ⇒ 入口不出现」);
|
||||
// - &false:要求插件当前**未**启用 —— 条件是「要求值相等」,写 false 不等于没写。
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON 严格解码 when:只接受对象,只接受闭集内的键,enabled 必须是布尔。
|
||||
//
|
||||
// 为什么不用默认的 struct 解码:默认解码会**静默丢弃**未知键(`{"enabled":true,"x":1}`
|
||||
// 会解码成一个看似正常的 When),插件作者会以为自己写了条件却没有任何效果。这里把
|
||||
// 未知键变成显式拒绝,由插件管理器按 §5.2.1「该面板不注册 + 显式报错」处理。
|
||||
func (w *When) UnmarshalJSON(raw []byte) error {
|
||||
trimmed := bytes.TrimSpace(raw)
|
||||
if bytes.Equal(trimmed, []byte("null")) {
|
||||
return fmt.Errorf("%w: null is not a condition set", ErrInvalidWhen)
|
||||
}
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(trimmed, &fields); err != nil {
|
||||
return fmt.Errorf("%w: %w", ErrInvalidWhen, err)
|
||||
}
|
||||
// 按键名排序后再判定:多个未知键时错误信息必须确定(否则同一个坏声明在不同进程里
|
||||
// 报不同的键,排障与测试都会被搞乱)。
|
||||
keys := make([]string, 0, len(fields))
|
||||
for k := range fields {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
if k != whenKeyEnabled {
|
||||
return fmt.Errorf("%w: %q", ErrUnknownWhenKey, k)
|
||||
}
|
||||
}
|
||||
rawEnabled, ok := fields[whenKeyEnabled]
|
||||
if !ok {
|
||||
w.Enabled = nil
|
||||
return nil
|
||||
}
|
||||
var enabled bool
|
||||
if err := json.Unmarshal(rawEnabled, &enabled); err != nil {
|
||||
return fmt.Errorf("%w: enabled must be a boolean: %w", ErrInvalidWhen, err)
|
||||
}
|
||||
w.Enabled = &enabled
|
||||
return nil
|
||||
}
|
||||
Reference in new issue
Block a user