Dashboard Resource/Page 模型
状态:In progress -- 本文是 Dashboard 页面模型的权威定义。实现、文档和 SDK 以本文的正向模型为准;旧模型按 旧模型删除清单 清理,并由
scripts/dashboard_vnext_guard.sh防回流;真实浏览器回归仍以根目录todo.md的未完成项目为准。
决策
Croupier 保留 React + Umi + Ant Design Pro + ProComponents,不集成 React Admin,也不把 React Admin 的 CRUD DataProvider 作为平台协议。
ProComponents 已经提供后台所需的成熟组件能力;Croupier 缺少的是把函数能力稳定地变成页面的领域模型,而不是另一套组件库。
SDK / OpenAPI
-> FunctionContract
-> ResourceCapability
-> CapabilitySemantics
-> PageProposal
-> PageDraft
-> PublishedPageSpec
-> ConsoleMenuSpec
-> Controlled Page Execution本模型同时满足两个事实:
- 游戏后台有大量玩家、订单、配置、邮件模板、活动等 CRUD Resource;CRUD 是默认页面生成的主路径,而不是被排斥的旧概念。
- 游戏后台也有封禁、补偿、批量发奖、任务、审批、报表、运维命令等 非 CRUD 能力;它们必须是一等页面类型,不能被强行套进 CRUD。
产品目标
运营人员的完整路径必须是:
注册函数或导入 OpenAPI
-> Server 解析并持久化能力、语义和诊断
-> 生成可追溯的默认 PageProposal
-> 用户直接接受并发布,或在 Page Studio 调整
-> Console 按菜单树(menu_items)+ 挂载的已发布 Page 生成左侧菜单
-> 页面通过已发布 binding 受控执行要求:
- 服务开发者只提交函数能力契约,不提交页面、菜单、表格列、按钮位置或前端组件。
- 只要能力足以安全执行,平台必须生成可直接发布的默认 Operation Page;用户不能从空白 JSON 开始。
- 具备标准 CRUD 语义的 Resource,平台必须生成可用的列表、详情、新建、编辑、删除和资源动作页面。
- 发布后的页面、菜单、权限、审计和 trace 必须可复现;函数变更不得静默改变已发布 UI。
- 正常用户编辑的是“资源、列表、列、表单、动作、分类”等业务概念;JSON 仅允许用于导入导出和受控诊断,不能作为主工作流。
为什么 JSON Schema 不足以直接生成完整页面
输入 JSON Schema 可以稳定生成表单字段,输出 JSON Schema 可以稳定生成候选列和详情字段;它们只描述数据形状,不描述业务用法。
例如,player.ban 的 playerId 可能来自手工输入、列表选中行或详情上下文;一个数组输出可能是表格、下拉选项或图表序列。平台不能把这些选择伪装成 JSON Schema 推论。
因此自动生成分为两层:
| 层 | 输入 | 产物 | 是否可由 SDK/OpenAPI 提供 |
|---|---|---|---|
| 数据契约 | JSON Schema、HTTP method/path、函数版本和治理字段 | FunctionContract | 可以 |
| 能力语义 | CRUD 意图、对象标识、任务/报表执行特征 | CapabilitySemantics | 可从 REST 推导;SDK 可显式提供有限语义 |
| 页面编排 | 分类、标题、列、筛选、动作位置、映射和表单展示 | PageProposal / PageSpec | 不可以;只由 Server/Page Studio 产生 |
CapabilitySemantics 不是 UI。它不含菜单、路由、标题、表格列或按钮位置;它只回答“该函数在资源生命周期中做什么”。
三层模型
FunctionContract
FunctionContract 是每个函数的可执行能力和治理契约:
interface FunctionContract {
id: string;
version: string;
enabled: boolean;
summary?: LocalizedText;
description?: LocalizedText;
inputSchema?: JSONSchema;
outputSchema?: JSONSchema;
previousInputSchema?: JSONSchema; // 上一次注册的 inputSchema(prev 列,只存一版)
previousOutputSchema?: JSONSchema; // 上一次注册的 outputSchema
risk: RiskLevel;
permission?: string;
execution: "sync" | "task";
executionState: "bound" | "unbound"; // 执行状态(D2):契约存在与运行时绑定正交
approval: ApprovalPolicy;
resourceKey?: string;
operationKey?: string;
capability?: CapabilityKind;
}executionState(数据库 execution_state 列,默认 bound)表达契约是否已有可执行 的运行时函数:OpenAPI 上传即生成 unbound 契约物料(上传即成页,不再要求前置注册); agent/SDK 运行时注册同 scope 同 functionId 的函数时自动翻转 bound(T6);不同名函数 可在编辑器绑定抽屉人工绑定(T9)。unbound 契约可正常进入模板与提案管线、可保存发布, 仅执行被阻断:binding execute 返回 409 executor_unbound(T8)。该字段不参与契约 digest/stale 判定(测试锁定),存量行迁移后一律为 bound,行为与旧模型一致。
previousInputSchema/previousOutputSchema(数据库 prev_input_schema/prev_output_schema 列)保存本次注册前的上一版 schema,只存一版(完整历史见下方 FunctionContractVersion):
- 写入时机:注册路径发现 existing 行即拷贝其 schema 进 prev 列;schema 未变的重注册被 upsert 的语义等价检查跳过,prev 不会被无意义刷新。
- 消费方:selector 一键同步(sync-selectors)用 prev→new 的字段 diff 做精确 rename 推断;freshness 的 selector 级 stale 诊断同样消费它产出 rename 候选提示。
- 精确性判定:prev 与页面发布快照的 schema digest 双双一致(freshness 双算法 digestMatch)时 rename 候选标
confidence=high;digest 缺失(旧快照)或多跳漂移(发布后又改过契约)一律降级启发式low。 - 存量行无 prev(功能上线前注册的契约):首次同步走启发式;软删后复活的契约不设 prev。
FunctionContractVersion(B2:契约变更历史)
FunctionContract 每函数只有一行;function_contract_versions 表(goose 0029)以 (game_id, env, function_id) 为主维度记录内容变更的追加式快照流:
interface FunctionContractVersion {
seq: number; // 函数内单调递增(max+1 计算,无唯一索引——并发允许并列,按 seq,id 排序)
version?: string;
source?: string; // sdk|openapi|catalog
sourceDigest?: string;
changeType: "created" | "updated" | "removed";
breaking: boolean; // input/output schema 破坏性变更(schemadiff)
actor?: string; // system|用户名(注册路径恒 system)
snapshot: FunctionSpec; // 变更后投影;removed 为删除前最后一版
diff?: Array<{
field: string;
from?: string;
to?: string;
change?: string;
findings?: SchemaFinding[];
}>;
createdAt: string;
}- 写入判据与
UpsertContract的「内容无变化跳过写」完全一致(同一contractSemanticallyEqual):心跳式重注册不产生历史;RemoveFunctionContract落removed事件。 - 保留策略(用户已定):每函数上限 50 条(
FunctionContractVersionRetention),超出按最老淘汰。 - 失败语义:历史是衍生审计数据(与提案/模板重建同档),写失败降级为告警、不阻断注册;表存在性由启动期
MinimumRequiredVersion=29保证。 - 消费面:函数详情页「变更历史」tab(列表/快照 Drawer/两版对比),REST 见 函数 API §22-24。
- 历史表不进契约 digest、不参与 stale 判定;契约行仍是唯一权威事实源。
type LocaleCode = string; type LocalizedText = Readonly<Record<LocaleCode, string>>; type JsonPointer = "" | /${string}; type JSONPrimitive = string | number | boolean | null; type JSONValue = JSONPrimitive | JSONValue[] | { [key: string]: JSONValue }; type JSONSchema = boolean | { [key: string]: JSONValue }; type RiskLevel = "safe" | "warning" | "high" | "danger";
interface Scope { gameId: string; env: string; }
interface FunctionRef { functionId: string; contractVersion: string; inputSchemaDigest: string; outputSchemaDigest: string; }
interface SourceDigest { kind: "function_contract" | "capability_semantics"; id: string; digest: string; }
interface Diagnostic { code: string; severity: "info" | "warning" | "error"; message: LocalizedText; path?: JsonPointer; }
interface ApprovalPolicy { required: boolean; policyKey?: string; }
type CapabilityKind = | "collection_query" | "item_query" | "create" | "update" | "delete" | "action" | "task" | "report";
`execution` 与 `approval` 正交:`execution: 'task'` 且 `approval.required: true` 表示审批通过后才启动异步任务;同步操作也可以要求审批。`approval.policyKey` 只引用平台已配置的治理策略,缺失时由 Server 按风险策略解析默认值;它不是页面 UI,不能由浏览器覆盖。
`resourceKey`、`operationKey` 和 `capability` 都是业务能力语义,不是页面语义。`capability` 只允许上述受控枚举;页面设计只能由 PageProposal/PageSpec 表达。
函数缺少 `resourceKey` 或 `capability` 不得阻断注册;它仍可生成独立 Operation Page。任意 SDK 函数没有 REST method/path,若想可靠加入 Resource CRUD 页面,必须提供这一个受控能力语义,不能要求 Server 从函数名猜测。
### ResourceCapability 与 CapabilitySemantics
ResourceCapability 将同一资源的函数关联起来;CapabilitySemantics 是其可验证、版本化的语义结果。
```ts
interface ResourceCapability {
resourceKey: string;
functions: FunctionContract[];
}
interface CapabilitySemantics {
resourceKey: string;
identity?: IdentitySemantic;
collection?: CollectionSemantic;
item?: ItemSemantic;
lifecycle: Partial<Record<"create" | "update" | "delete", FunctionRef>>;
actions: ActionSemantic[];
tasks: TaskSemantic[];
reports: ReportSemantic[];
sourceDigest: string;
provenance: SemanticProvenance[];
diagnostics: Diagnostic[];
}
interface IdentitySemantic {
itemPath: JsonPointer; // canonical item schema 中的唯一标识字段
valueType: JsonScalarType;
}
type JsonScalarType = "string" | "number" | "integer" | "boolean";
interface CollectionSemantic {
query: FunctionRef;
itemsPath: JsonPointer; // collection query 输出中项目数组的位置
itemSchemaDigest: string;
pagination?: OffsetPaginationSemantic | CursorPaginationSemantic;
}
interface ItemSemantic {
query?: FunctionRef;
itemPath: JsonPointer;
itemSchemaDigest: string;
}
interface OffsetPaginationSemantic {
kind: "offset";
request: { offset: JsonPointer; limit: JsonPointer };
response: { total?: JsonPointer; hasMore?: JsonPointer };
}
interface CursorPaginationSemantic {
kind: "cursor";
request: { cursor: JsonPointer; limit?: JsonPointer };
response: {
nextCursor: JsonPointer;
previousCursor?: JsonPointer;
hasMore?: JsonPointer;
};
}
interface ActionSemantic {
function: FunctionRef;
subject: "resource_item" | "resource_selection" | "none";
identityInput?: JsonPointer;
}
interface TaskSemantic {
start: FunctionRef;
taskId: { resultPath: JsonPointer; valueType: JsonScalarType };
status: {
function: FunctionRef;
taskIdInput: JsonPointer;
statePath: JsonPointer;
};
events?: {
function: FunctionRef;
taskIdInput: JsonPointer;
eventsPath: JsonPointer;
};
result?: {
function: FunctionRef;
taskIdInput: JsonPointer;
resultPath: JsonPointer;
};
cancel?: { function: FunctionRef; taskIdInput: JsonPointer };
retry?: { function: FunctionRef; taskIdInput: JsonPointer };
}
interface ReportSemantic {
query: FunctionRef;
datasetPath: JsonPointer;
dimensions: JsonPointer[];
metrics: JsonPointer[];
}
interface SemanticProvenance {
field: string;
source: "openapi_rest" | "sdk_explicit" | "platform_review";
sourceDigest: string;
confidence: "high" | "low";
status: "effective" | "overridden" | "conflict";
}IdentitySemantic.itemPath 必须在 collection item 或 item query 的输出 schema 中唯一存在;item/update/delete/action 的 identity input 由 typed selector 显式映射并校验,不得要求 collection query 的 input 包含 identity。CollectionSemantic.pagination 必须同时声明请求参数和响应元数据的 JSON Pointer;offset 分页必须至少提供 total 或 hasMore,cursor 分页必须提供 nextCursor;缺失时只生成不带分页控件的列表,不得猜测 offset/cursor 协议。
ActionSemantic.subject 是资源操作所需的业务上下文,不是按钮位置:resource_item 映射为行操作,resource_selection 映射为批量操作,none 映射为资源工具栏操作;无法安全判定 subject 或 identity input 时只生成独立 OperationPage。TaskSemantic 与 ReportSemantic 的所有 pointer 都必须可由对应 FunctionContract schema 验证,否则只能生成 needs_review。
可接受的来源优先级:
- OpenAPI 标准 REST 形态,如
GET /players、POST /players、GET/PATCH/DELETE /players/{id}。 - SDK descriptor 的受控
resourceKey + capability语义。 - 平台管理员在 Resource Catalog 中保存的语义补充;它独立于函数注册,需版本化、审计和权限控制。
不允许从 player.list、player.ban 等名称猜测对象 ID、分页字段、动作位置或页面类型。允许将确定性 REST 形态与 JSON Schema 字段产生为“高置信度建议”,但低置信度建议不得自动发布。
来源冲突按字段裁决:platform_review 是人工最终裁决,优先级最高;sdk_explicit 高于 openapi_rest 推导。每个有效值和被覆盖值都必须记录在 SemanticProvenance,不能以单一 source 掩盖多个来源。未解决冲突必须保留 diagnostic,受影响 Proposal 降级为 needs_review 并禁止发布;管理员以版本化 platform_review 明确选择后才消除冲突。Resource Catalog 的覆盖必须保存版本、记录审计,并在生效时触发受影响 ResourceCapability 与 PageProposal 的重新计算。
action 与 update 的判定:幂等修改资源自身字段的生命周期操作归为 update;触发资源相关副作用或流程(封禁、补偿、重置、发放)归为 action。例如 player.ban 是 player 资源的 action,生成行操作而不是编辑表单。
PageProposal、PageDraft 与 PublishedPageSpec
PageProposal 是可重新生成的默认页面建议,不是草稿,也不是运行页面:
interface PageProposal {
id: string;
scope: Scope;
proposalKey: string;
pageKey: string;
spec: PageSpec;
quality: "ready" | "basic" | "needs_review";
generatorVersion: string;
sourceDigests: SourceDigest[];
diagnostics: Diagnostic[];
createdAt: string;
}
// 不可物化的问题不是 Proposal:只保存诊断与修复指引,不携带 spec。
interface BlockedProposalIssue {
id: string;
scope: Scope;
sourceDigests: SourceDigest[];
diagnostics: Diagnostic[];
repairHint: LocalizedText;
status: "open" | "resolved" | "dismissed";
}proposalKey 是生成器幂等身份:一个 ResourceCapability 只能有 resource:<resourceKey>,每个独立 Operation/Task/Report 函数分别有 <kind>:<functionId>。pageKey 固定为 resource--<resourceKey> 或 <kind>--<functionId>,其中 source key 必须符合 [a-z0-9][a-z0-9._-]*;它是可读的路由与发布身份,不得从 summary、labels 或本次生成结果随机生成。分类建议的默认规则以 ProComponents 页面生成与运行时 为唯一出处;不得从带 kind 前缀的 pageKey 推断分类。Resource action 只有在 subject 可验证时才并入唯一 ResourcePage;否则保留为函数自己的 OperationPage,避免重复菜单或覆盖资源页。
ready:页面全部已声明能力都有可验证的 binding、selector、治理与 renderer 支持,可直接接受并发布。ResourcePage 的写能力是可选的,因此只读 ResourcePage 也可以是ready。basic:安全的同步 OperationPage,含输入表单、确认、受控执行和结果区;可要求审批,并在审批通过后展示真实结果;可直接接受并发布。needs_review:语义或映射不完整,必须在 Page Studio 决策后发布。- 函数不可执行、权限/风险不可校验、schema 无效或 binding 不安全时,生成 BlockedProposalIssue,禁止物化和发布;
blocked不是 Proposal quality。
PageDraft 是用户接受 Proposal 后形成的可编辑页面;PublishedPageSpec 是包含完整 PageSpec、binding snapshot、表单展示快照和 renderer version 的不可变运行产物。Proposal 重新生成绝不覆盖 Draft 或 PublishedPageSpec。
ComponentTemplate(组件模板层,V4)
组件模板是三层之间的复用中间层:多个函数(连同其 PageNode 子树编排) 封装为可整体实例化的组件,组件组合为页面。
interface ComponentTemplate {
key: string; // 唯一标识(snake/kebab 域名风格)
name: LocalizedText; // BCP47 本地化名
description?: LocalizedText;
category?: string; // 组件库分组(如「资源管理」)
icon?: string;
requiredFunctions: string[]; // 实例化前置:scope 内必须存在的函数 id
tree: PageNode[]; // 组合页编辑器 PageNode 子树(含引用关系)
builtin: boolean; // 内置模板(由 regenerate 维护)vs 用户保存
createdBy?: string;
digest: string; // 内容指纹 sha256(canonical Tree JSON),U11 更新提醒
}生成与维护:
- 契约 → 模板自动生成:agent 注册函数后,生成器按 ResourceCapability 聚合产出 builtin 模板(如
player.crud= list/create/update/delete 四函数 CRUD 子树),入口POST /api/v1/component-templates/regenerate(手动触发; agent 注册不会自动 regenerate) - 用户保存为组件:组合页编辑器选中 1..N 节点 → 顶栏「保存为组件」→ 序列化 PageNode 子树存为
builtin=false模板 - 实例化:组件库面板点击模板 → tree 复制 + id 重分配 + 函数引用重映射进画布; 可用性检查在前端本地完成(
requiredFunctions与 scope 函数集比对,缺失置灰) - digest 与更新提醒(U11):
digest= sha256(canonical Tree JSON),三个写路径 全覆盖(Create / Update handler + regenerate 落到的UpsertBuiltin)。实例化是 复制语义——页面保存的是当时内容的副本;编辑器把所用模板的{key, digest}快照进PageSpec.componentTemplates(页面级可选字段,随 proposal→draft→published JSON 透传,不参与发布校验),再次打开页面时与模板库当前 digest 比对,不一致提示 「所用模板有新版本」(只提示不自动同步) - UpsertBuiltin 内容门控(M3):
UpsertBuiltin对 builtin 行做全字段内容 比较(Name/Description/Category/Icon/RequiredFunctions/Tree;JSON 列经 规范化比对,解析失败回退字节比较——宁误写不误跳过),内容一致直接跳过写入 (不刷updated_at)。门控价值:逐函数注册触发的全量模板重建不再对未变模板 反复落库。仅限 builtin 行——custom 占 key 行(builtin=false)维持覆盖路径 且 Builtin 标记不翻转;手工改动强制回归语义保留(内容不同必写)
REST:/api/v1/component-templates(List/Get/Create/Update/Delete/Regenerate), wire 契约见 API 文档。使用层文档见 组合页编辑器 V4。
PageSpec:业务级页面协议
PageSpec 是平台唯一的页面编排协议。它不持久化 ProTable、ProForm 等具体组件名,而是强类型的业务级 DSL:
PageSpec = (pageKey, type, resourceKey?, category, title, icon, order,
navigation?, resource? | operation? | task? | report?, bindings[])四种页面类型的视图编排:
| 页面类型 | 视图节点(实际 DTO) |
|---|---|
resource | ListViewSpec(columns/filters/pagination/rowActions/batchActions/toolbarActions)、DetailViewSpec(fields/actions)、CreateForm/UpdateForm(FormPresentationSpec)、DeleteAction(ConfirmActionSpec) |
operation | Form(FormPresentationSpec)+ Confirm(ConfirmActionSpec)+ ResultViewSpec |
task | Form(FormPresentationSpec)+ TaskViewSpec(status/events/result/cancel 的 bindingId 引用)+ ResultViewSpec |
report | QueryForm(FormPresentationSpec)+ DatasetSpec + ChartSpec[] + 表格 ListViewSpec |
composite | CompositePageSpec:sections[](每区块绑定一个函数;display inline/dialog/tab/card、group 弹窗/页签/卡片分组、tab 页签标签、cardTitle 卡片标题、rowActions/toolbar 按钮动作含 chain 动作链、onSuccessRefresh、refreshOn page_state 联动(cascadePolicy 级联失败策略),另有 static 常量表单(不绑定函数,值进 page_state;sections 顺序=请求输入顺序,static 与函数区块按输入位置交错,不重排到末尾);页面级可选 componentTemplates[](U11 模板使用快照 {key, digest},仅编辑器更新提醒用,不参与发布校验与渲染) |
字段级的 wire 契约(含 FormPresentationSpec、Selector AST、Binding usage 枚举与 ABI 版本)以 PageSpec 协议规范 为唯一出处;其权威实现是 internal/dashboard/spec(Go DTO)与 web/src/types/dashboard.ts(前端共享类型),两侧逐项对应。
PageBinding 只引用发布期允许执行的 FunctionContract。输入输出映射必须使用受控的 typed selector AST,禁止保存无约束 JSON mapping、裸整行透传或运行时猜路径。
分类、标题、图标与排序是 PageSpec 的顶层强类型字段;NavigationSpec 仅承载面包屑与返回行为(breadcrumb、showBack、backPath)。它们只在 PageProposal/PageSpec 中确定,注册侧不能提供菜单事实。页面没有独立的 permissions 字段:权限由 binding 级治理(合同 permission/risk/approval 快照)与 action 级 permission 字段承载。T-M8 起分类名称与页面规格解耦:PageCategorySpec 只保留 key(分组定位键)与 order,多语言分类名称由菜单系统(menu_items.labels)统一提供;存量数据经迁移脚本(scripts/migrate-categories-to-menus.sql)归位,页面驱动菜单的分类标题回落为空、前端以 menu_items.labels 覆盖。
本地化名称契约(T12 放宽,T-M8 起 category.labels 部分移交菜单):title 的 LocalizedText 只要求任一 locale 有非空值(默认名称必填、翻译可选)——zh-CN 是第一推荐展示语言(渲染回退链首位),en-US 与其他语言一律可选,仅有 en-US 的存量页面不被误拒;全部为空白值时在保存/发布校验与发布期诊断中被拒(hasDefaultLocale)。生成器全路径经 ensureDefaultLocale 规整:空白值剔除、zh-CN 缺失时取任意既有值补位,不再强制补写 en-US。编辑器(LocalizedTextEditor)的必填基线已同步降级为仅默认语言:缺失 zh-CN 时在 🌐 气泡中给出不阻断发布的补录提示,缺失其他语言不警告、不阻断;下拉标记回归单一 ✓(已录语言),必填 ⚠ 标记与 contractHint 的强制双写表述已随 T13 移除;分类标题编辑入口已随 T-M8 从页面编辑器移除(菜单管理页维护);2026-09 起 category.key 的输入框也一并移除——导航归属唯一入口是挂载菜单(menu_items + 页面 menuId),category 仅作为协议字段随存量 spec 透传,不再提供编辑。其余 LocalizedText 字段(confirm 文案、结果提示、字段 label 等)仍为可选,渲染端按 zh-CN → en-US → 任一非空值回退。
CRUD 是主路径,非 CRUD 是一等扩展
ResourcePage
当 ResourceCapability 有可验证的 collection_query 与对象 identity 时,生成 ResourcePage;生命周期写操作是可选能力。只读资源也必须生成查询/列表/详情页面,不能因为缺少 create/update/delete 被错误降级为 OperationPage。语义到页面节点与组件的生成模板见 ProComponents 页面生成与运行时。
JSON Schema 为列表列、详情项和表单字段生成候选。CapabilitySemantics 解决分页、identity、集合与对象响应;PageProposal 决定列、默认排序、动作位置和展示文本。管理员可覆盖 Proposal,但不能绕过类型和发布校验。
OperationPage
无法可靠归入资源生命周期的同步函数生成 OperationPage,例如 mail.send、cache.refresh、broadcast.send。它的默认形态是输入表单、风险确认、受控执行和结构化结果,不应被塞入 ResourcePage。
TaskPage 与 ReportPage
异步函数生成 TaskPage;报告语义生成 ReportPage。TaskPage 必须接入真实 task 状态、事件、取消/重试和结果,不得只显示 taskId。
TaskPage 的生命周期能力来自 TaskSemantic,生成器把 start/status/events/result/cancel 转成 PageSpec binding,并在 TaskViewSpec 中保存 bindingId 引用、固定 taskId page state key 与 status.statePath。运行时仍统一走 POST /api/v1/console/pages/:pageKey/bindings/:bindingId/execute,浏览器只提交 page_state.taskId 作为 selector source,不传 functionId、target、gameId 或 env。缺少 status binding 或 statusStatePath 的 TaskPage 不可发布;events/result/cancel 只有存在对应真实函数语义时才显示入口;retry 在真实 runtime 闭环前禁止发布。
ReportPage 必须使用已验证的数据集、指标和图表字段,不得只显示 JSON。
CompositePage(自由组合页)
组合页把多个函数区块编排成一个工作台页面(如「玩家管理」= 玩家表格 + 行操作弹窗 + 提交刷新)。区块是平铺列表,编辑器(组合页编辑器 V3)内部是组件树,保存时编译为平铺 sections——发布链(提案/校验/版本/菜单)零特判。
CompositeSection 字段模型(权威实现 internal/dashboard/spec/types.go,前端 web/src/types/dashboard.ts):
| 字段 | 类型 | 语义 |
|---|---|---|
key | string | 区块唯一标识。同函数多实例时依次 fid、fid-2、fid-3(一个数据源可拖多个组件分别配置);编辑器支持声明固定 sectionKey(回读固化,不随增删/排序漂移,见组合页编辑器 V4「区块 key」);创建端点重复 key 显式报错 |
bindingId | string | 引用 PageSpec.bindings 的绑定 |
view | string | table / fields / form |
span | int | 栅格宽度 1-24(0=整行) |
autoRun | bool | 进入页面自动执行(查询类区块) |
display | string | inline(默认,栅格内)/ dialog(弹窗,不占栅格)/ tab(页签,渲染端聚合进 Tabs)/ card(卡片分组,渲染端聚合进 Card) |
group | string | 弹窗/页签/卡片分组:display=dialog 且 group 相同的区块渲染进同一弹窗(表单+字段卡+表格混排);按钮/行操作的动作目标指向 group;display=tab 且 group 相同的区块渲染进同一 Tabs(编辑器页签容器的 sectionKey 固化组名);display=card 且 group 相同的区块渲染进同一 Card(编辑器卡片容器的 sectionKey 固化组名) |
tab | LocalizedText | 页签标签(display=tab 时有效):同 group 内按标签聚合到 Tabs 对应页,页内区块整行堆叠;缺省渲染端兜底「页签 N」。回读时还原为页容器的 props.title |
cardTitle | LocalizedText | 卡片标题(display=card 时有效):同 group 区块渲染进同一 Card(整行)时的组标题,组内区块垂直堆叠;缺省回退组名。回读时还原为卡片容器的 props.title |
refreshOn | []string | 依赖的 stateKey(=上游区块 key)列表——任一变化自动重跑(page_state 联动:上游输出顶层字段同名合并进下游输入) |
onSuccessRefresh | []string | 操作成功后自动重跑的区块 key(发邮件成功→刷新玩家表格) |
events | []CompositeEventBinding | 通用事件绑定(全组件事件发布触发点):rowClick/rowSelected(table)、success/error(form)、click(fields)→ 动作步骤(6 种 kind)+ 链 |
visibleWhen | *ConditionSpec | 区块级条件显示(U10):按页面状态求值,false 的 inline/tab 区块不渲染(执行不变——autoRun/refreshOn 照常跑)。叶子条件必填 key(来源区块 key,寻址 /values/字段、/data/字段、/selectedRow/字段);支持嵌套 all/any(深度 ≤4)。dialog 区块不参与(弹窗由动作显式触发)。发布校验:kind 合法、path 为 JSON Pointer、key ∈ 页面区块、equals/notEquals 带 value |
cascadePolicy | string | refreshOn 级联失败策略(U9):任一上游依赖最新结果为失败时本区块的行为——pause(默认,不重跑、数据保持、message.warning 提示)/ keep(不重跑、静默保留上次结果)/ clear(不重跑、清空本区块数据)。三策略均不重跑;上游恢复(失败→成功翻转)后级联自动续跑。发布校验枚举合法,违规诊断码 composite_section_cascade_policy_invalid |
table | CompositeTableSpec | view=table:columns/pagination/rowSchema/identityKey/rowActions |
toolbar | CompositeToolbarSpec | 表格顶部按钮组(actions) |
行操作与按钮动作(rowActions / toolbar.actions):
| 字段 | 语义 |
|---|---|
label | 按钮文案(LocalizedText) |
targetSection | 打开的弹窗目标:区块 key 或 group 名(空=纯动作链按钮) |
params | 参数映射:行操作=行字段名→表单参数名(player_id: uid);顶部按钮=静态初值 |
danger | 危险样式 + 二次确认 |
chain | 动作链:主动作后按序执行的步骤 `[{kind: runBinding |
编辑器(web/src/pages/PageStudio/CompositeEditor)与发布渲染器(PageRenderer 的 CompositeRenderer)共用此模型;编译器(编辑器 → sections)与反编译器(sections → 编辑树,用于回读再编辑)保证配置 round-trip 不丢失。
变量名与运行时状态(V5):区块 key 同时是编辑器内的组件变量名(props.sectionKey 声明固化;拖入/模板实例化按语义规则自动命名去重——player.list 表格 → playerListTable)。绑定表达式({{变量名.路径}})只存在于编辑器层,保存时编译为现有 wire 字段(inputAssignments 的 page_state 路径 / 事件 params / 行操作 row.字段),服务端与 PageSpec 协议零改动。运行时每区块状态为 { data, selectedRow, selectedRows, values }:
data:函数输出({{var.data.total}});执行成功后以 merge 模式并入page_state[var]。selectedRow/selectedRows:表格选中行({{var.selectedRow.uid}});选择变化不触发refreshOn自动重跑。values:表单当前值(防抖;{{filterForm.values.keyword}});常量表单/函数表单的 page_state 快照为双形态(扁平值兼容遗留/字段路径 +values包装)。- 表达式→wire 映射:
{{var.path}}→inputAssignments: {kind: page_state, key: var, path: /分支/字段}(JSON Pointer);{{row.x}}→ 行操作/事件参数row.x;字面量原样。round-trip 可逆(回读还原为表达式文本)。 - 参数映射变换(U8):结构化映射(来源区块+字段)可附
transform——来源字段名 ≠ 参数名时编译为rename(映射表改名,未覆盖字段丢弃,输出即受控白名单);填了缺省值时编译为default(上游字段缺失/null 兜底该字面量,有值不覆盖)。两项均 round-trip(回读还原为字段选择与缺省值输入),语义详见 PageSpec 协议规范 的 transform 白名单。
前端运行时:ProComponents 页面渲染器
页面运行时固定使用 Ant Design Pro/ProComponents;PageSpec 节点与运行时组件的对应关系见 ProComponents 页面生成与运行时。
Renderer 只接受 PublishedPageSpec,并只通过 POST /api/v1/console/pages/:pageKey/bindings/:bindingId/execute 执行。浏览器不得传 functionId、route、target、gameId 或 env 来选择执行目标。执行前服务端做两道结构化阻断(wire 契约见 PageSpec 协议规范):契约 executionState=unbound(上传物料未绑定运行时执行器)返回 409 executor_unbound,前端渲染「未绑定执行器」空态并引导去绑定;契约漂移返回 409 binding_stale。发布页禁止 mock 数据兜底——执行失败必须显式呈现。
PageSpec 必须与组件库解耦。未来更换表单或图表库时只替换 renderer adapter,不迁移 FunctionContract、PageSpec、菜单、发布快照或审计。
表单策略
JSON Schema 是函数输入/输出的持久化标准;表单展示由 FormPresentationSpec 表达,协议定义见 PageSpec 协议规范,渲染链路与唯一 runtime 约束见 ProComponents 页面生成与运行时。
FormPresentationSpec 只负责表单展示,不改变 FunctionContract payload;保存和发布都必须经过服务端结构校验,校验失败必须报错并要求管理员修复。表单 runtime 固定为 @rjsf/antd + @rjsf/validator-ajv8,项目内禁止并行保留第二套表单运行时。
无 schema 兜底(单 payload 字段):函数没有 inputSchema 时,页面生成器不再产出空表单(渲染为空白废页),而是兑现 normalizer 诊断(input_schema_missing)的承诺——生成单 payload 字段的表单(JSON 文本域,widget: JSON,双语 label/描述)。已知边界:payload 是包裹键,提交的请求体为 {"payload": "<用户输入 JSON 文本>"},执行链不解包;要获得真表单应在注册侧补 schema(openapi provider 已自动推导,见 Agent Providers)。
Scope、菜单、发布与演进
页面身份固定为:
PageIdentity = game_id + env + pageKeyPage Studio、Console、Proposal 和执行都从全局 scope 获取 game_id + env;页面内部不得再次选择或覆盖 scope。
动态菜单唯一来源(menu_items 驱动,详见 运行控制台动态菜单):
menu_items(菜单树) + page_specs.menu_id(挂载) + active PublishedPageSpec[](页面内容)
-> ConsoleMenuSpec -> ProLayout菜单多语言文本来自 menu_items.labels,页面多语言文本来自 PublishedPageSpec 的 NavigationSpec,动态菜单项设置 locale: false,不使用静态 locale 或字典作为事实源。未挂载菜单的已发布页面不进控制台导航。
发布时必须冻结:
- PageSpec 与 FormPresentationSpec 完整快照。
- 每个 binding 的函数版本、输入/输出 schema digest、风险、权限、执行模式、审批策略和语义 digest。
- Renderer ABI version 与 generator version。
函数或 CapabilitySemantics 变化后,Server 生成新的 Proposal 并计算 diff。已发布页标记 stale 且拒绝执行;Page Studio 必须提供“查看差异、自动合并安全字段、解决冲突、重新发布”。
契约漂移的自动化收口(2026-09,internal/api/page/stale_heal.go):
- 系统愈合循环(每 5 分钟,actor
system:contract-heal)对存在发布快照且 freshness 非空的页面自动执行 selector 同步;自动发布仅限无歧义子集——输入侧只放行 kept/removed,输出侧再加 shape_updated;renamed/added/type_changed与任何 manual 项只落草稿(rename 是语义判断,实测“唯一候选”启发式会把删旧+增新误判为改名,绝不机器发布)。无变化时不落库(幂等护栏,循环可高频运行)。 - 用户一键同步(编辑器/变更面板,
pages:edit+pages:publish)在报告无 manual 时自动接续发布(响应autoPublished),一步完成“同步→发布→快照刷新→控制台恢复执行”;无发布权限时降级提示转交有权限成员。 - 除上述声明路径外,绝不静默更新 Draft 或 PublishedPageSpec。
自动合并的安全集只包含展示类字段:列顺序与显隐、字段 label/help、order、group、widget hint、导航标题、分类 key/order、图标和排序。visibleWhen 只有经校验证明不影响 required 输入、binding payload 和 selector 引用时才允许自动合并,否则归入冲突集。执行类字段——bindings、functionId、input/output assignment、confirmation、permissions、risk、approval——出现任何差异都必须人工确认,不得自动合并。
发布分级(pages.publishReview,T10)
保存是否走提案审核由 env 级策略控制:pages.publishReview: auto | required (configs/server.yaml,含 publishReviewByEnv 按 env 覆盖;优先级 publishReviewByEnv[env] > 全局 > 内置默认——dev=auto,其余 env 与 X-Env 缺失一律从严 required)。该策略是 L2 配置(见 配置分层),在保存链路同步判定,不进运行时设置。
composite 保存路径(versioning.Service.CreateCompositePage)在 auto 策略下 保存成功后直接发布(page.Service.AutoPublishComposite,经 routes 装配注入回调, 依赖保持 versioning ↛ api/page 单向):
- 新页面:走提案接受发布链(AcceptAndPublishProposal);
- 已存在页面:走「提案重生成草稿 + 发布」组合(与 BulkRepublish 单页路径 一致)——乐观锁、published_page_specs 快照与 page_versions 历史照常记录。
两条不变的门槛:质量门槛不因免审核降低(提案 error 级诊断与发布校验照常 拒绝);发布失败不回滚保存(响应 publishError 带回原因,提案保留,前端 降级人工链)。权限语义:auto 路径不额外要求 pages:publish——env 已由策略声明 免审核,权限沿用保存入口的 pages:edit(这是有意决策:免审核的 env 中保存者 即发布者,审计事件 auto_publish_composite 记录 variant 与操作者)。
Selector 一键同步(sync-selectors)
展示字段的自动合并之外,schema 漂移还会让 selector 失效(input_schema_stale/output_schema_stale/target 消失),发布被校验阻断、运行期 console 拒绝执行。整页 regenerate 会用默认 selector 重建,把 row/selection/page_state/literal 定制冲掉;手动逐 binding 重选低效且易漏。sync-selectors 是第三条路径:只修受影响的 assignment,保留全部未受影响定制(含 Source/Kind/Path/Value/Transform)。planner(spec/selector_sync.go)是纯函数,dry-run 与 apply 共用同一实现;作用对象是草稿(发布校验的就是草稿),已发布快照不可变。
策略阶梯(输入,逐 assignment 保序):
- target 存在、类型未变、源可赋值 → kept。
- target 存在但类型漂移 → 保留 assignment,报告
type_changed(不摘——required 摘掉会让发布校验失败)。 - target 消失 → prev schema diff 的 rename 候选唯一命中(
confidence=high|low,见 prev 列语义);无 prev 或不命中时启发式(同父 × 未占用 × 源可赋值,唯一命中才用,low);零或多候选 → removed。 - required 差集补齐(S2 语义命中):required 字段 = 资源 identity 字段(
CapabilitySemantics.IdentityField,service 层按页面 resourceKey 查一次注入 planner)且 row 源三关门禁(isSourceAllowed:HasDetailView/IsRowAction;RowSchema 含该 path;类型可赋值)全通过 → 自动接 row 源(added+row+high,同 generatorapplyIdentityRowSelector构造);未注入/未命中/门禁不满足 → 回落:非 composite 页补 form 同名映射(门禁:页面表单 schema 必须含该 path,否则manual_required);composite 页一律manual_required(composite 输入只应来自 page_state/literal)。
策略阶梯(输出):
- source 存在且 shape 匹配 → kept。
- source 存在但 shape 不符:必需 stateKey 重推导;非必需按新类型修正 shape(
shape_updated),task/dataset 语义不自动猜。 - source 消失:必需 stateKey(resource query→items、detail→detail、report→dataset、task→taskStatus/taskEvents/taskResult 六类矩阵,与发布校验同步)经 rename 候选 / generator 默认推导 / 根对象(
source="")三阶梯重推导,全部失败保留原 assignment +manual_required——必需输出绝不摘成缺失;非必需无候选 → removed。 - 必需输出整体缺失 → 同三阶梯补一条(
added),推导不出 →manual_required。
写路径与权限:POST /api/v1/pages/:pageKey/sync-selectors(wire 契约见 PageSpec 协议规范)走 SaveDraft 同款乐观锁(事务内 revision 重查)+ PageVersion + 审计(action=sync_selectors);权限是 pages:edit。同步只改 draft、不自动 publish——发布是 pages:publish 权限与审批/审计语义,published_page_specs 不可变快照不在同步写路径上,同步后的发布级校验结果放在 remainingDiagnostics 由用户自查后手动发布。execution mode 对齐是附带修复:task 函数绑成 sync(或反之)时按 freshness 同规则修正,不造非法组合;governance/version 漂移不可由 selector 同步修复,重跑 freshness 后透传进报告的 manual 区。
批量收口(M4,POST /api/v1/pages/bulk-sync-selectors):契约变更队列的草稿侧整体处理——逐页跑与单页 sync-selectors 同源的 planner/apply,语义不变:严格只写 draft(已发布快照不动,上线仍需 bulk-republish);revision 由服务端读取当前草稿,并发冲突在事务内 409、按页计入 failed 继续不中断;先 dry-run 判定,存在 Manual 诊断(governance/version 等不可由 selector 同步修复的漂移)的页面整体 skipped 并透传诊断,不做半吊子同步。
页面→资源关联与列表条件下推(#30)
「页面涉及哪些资源」是派生视图,不是存储事实:resourceKey 列 ∪ 顶层 bindings 对应函数契约的 resourceKey,读取时计算、不落库(无新列/新表,避免编号迁移契约面)。多资源页(一个页面绑定多个资源的函数)在此口径下自然展开。函数契约重建后,下一次列表/聚合读取即反映最新关联;契约缺失的 binding 不贡献资源(退化回列口径)。该关联服务三处消费:草稿列表条目的 resources 投影、/pages/resources 过滤选项聚合(pageCount 按页去重)、以及 resourceKey 过滤条件的命中判定(旧单列页面同样命中其 binding 关联资源)。
草稿列表的过滤与分页由服务端执行:status SQL 条件下推,resourceKey/keyword(pageKey、标题、涉及资源的包含匹配)在 scope 候选集上过滤,page/pageSize 服务端钳制(默认 20、上限 200),响应携带 total/page/pageSize。前端不拉全量自算;wire 契约见 PageSpec 协议规范。
模型边界
- SDK/OpenAPI 只能提交 FunctionContract 与受控 capability 语义。
- Resource Catalog 只能补充 CapabilitySemantics,不能编辑页面 UI。
- PageProposal/PageSpec 是页面生成、编辑、发布和动态菜单的唯一来源。
- Renderer 只能消费 PublishedPageSpec,不能从最新函数目录或运行结果临时补字段。
- 动态菜单文本来自 PublishedPageSpec 的 NavigationSpec,不能依赖静态 locale 或字典事实源。
- 浏览器只能通过 published binding execute API 执行,不能选择 function、target、route 或 scope。
- 历史页面配置无自动迁移路径。旧页面配置模型的数据只能导出、备份和人工重建;不提供自动转换桥。
完成定义
满足以下条件后才可验收发布(真实浏览器 E2E 回归入口见 web/e2e/ 与 真实 Dashboard E2E):
- 一个 OpenAPI REST Resource 可自动生成并直接发布 ResourcePage;声明写 capability 时提供完整 CRUD,未声明写 capability 时提供只读查询/详情页面。
- 一个 SDK 显式能力 Resource 可自动生成并直接发布同等 ResourcePage。
- 未提供 CRUD 语义的函数可自动生成并直接发布安全 Operation Page。
- Task 和 Report 页面使用真实任务/图表数据,不包含“最小实现”或 JSON 占位。
- Page Studio 的常用路径不要求编辑原始 PageSpec JSON 或自定义 mapping。
- 发布、动态菜单、scope、权限、审批、审计、OTel 和函数变更 stale 在真实浏览器 E2E 中闭环。
旧模型的删除记录与防回流证据见 旧模型删除清单。
