Repository navigation
详情页相关子表:降低对自定义 page 的依赖(relatedList 三态 + 列派生 + 确定性排序) #2579
Description
目标
如今要让记录详情页把子表显示成独立 tab(如 showcase_account 的 Projects / Invoices),必须手写一整张自定义 record page(examples/app-showcase/src/pages/account-detail.page.ts)。零配置派生出的详情页太弱,不足以作默认:
- 所有相关列表被堆进一个 "Related" tab;
- 每个相关列表都得手写列;
- 没有办法标记某个子表是「核心」;
- 每个子对象只派生一条相关列表(同一子对象的多个外键会丢关系)。
本 issue 让**「不写 page / 轻量标注」这条路足够好用,使自定义 page 退回为逃生舱(Tier 2)**——只服务真正复杂/定制的布局,而不是默认路径。下面每一项改动,都是把对象从「必须写 page」往下拉到「零配置或一句字段标注」。
延续 ADR-0085(语义角色优先于表面提示块)。按仓库惯例不新开 ADR(这是在执行 ADR-0085 的既定方向);是否为 relatedList: 'primary' 补一条 ADR-0085 addendum 见文末备注。
三层模型
| 层 | 作者要做什么 | 用 page 吗 |
|---|---|---|
| 0 · 零配置 | 什么都不做;从关系图自动派生,列取自子对象 highlightFields/默认列,按确定性规则排布 |
❌ |
| 1 · 字段级意图 | 在子表外键上标注:relatedList: false | true | 'primary'、relatedListTitle、relatedListColumns |
❌ |
| 2 · 自定义 page | 扇出(Open/Closed 分裂)、条件 tab、一个 tab 放多个列表、非关系 tab(图表/报表/嵌入)、精确排序 | ✅ |
每项改动都在把对象从 Tier 2 往 Tier 0/1 拉。
默认布局规则(修正:删去「数量自适应」)
只有两件事让一个子表获得独立 tab:
- 在它的关系字段上标
relatedList: 'primary';或 - 你在自定义 page 里显式排了它。
其余所有子表统一进一个聚合的 "Related" tab(纵向堆叠);relatedList: false 则完全不显示。
⚠️ 这修正了本 issue 早期草案里的「数量自适应」(≤3 个各占一 tab、超过则折叠进 Related)。删除它,因为:① 与primary冗余——已有显式的核心信号,就不该再靠数量去猜;② 阈值有「悬崖」(加第 4 个子表会突然从 4 个 tab 塌成 1 个 Related);③ 同一 app 内不一致、难解释;④ 对 AI 授权不友好(结果由 AI 无法预判的计数决定)。改后:完全可预测、无悬崖、对齐主流平台、AI 友好。代价:未标注的简单对象会显示成一个 Related tab 而非多个 tab——但标一个词即可,且更一致。
详情页固定框架:顶部 highlights 条 →Details→ 各 primary tab →Related→Activity/History。
相关子表的排序(tab 顺序 与 Related 内堆叠顺序)
排序对是否 tab 都适用:既决定 tab 条里各 primary tab 的先后,也决定 Related tab 内堆叠列表的先后。
决策:排序沿用「确定性默认 + 需要精确顺序就写 page」,不在对象层加排序键。
- 派生默认顺序(Tier 0/1,确定性、可预测):
master_detail(拥有/组合型,如行项)在前 →lookup在后;组内用稳定次序兜底(对象注册顺序 / 标题)。primary tab 之间、Related 堆叠之间都用这条规则。 - 精确顺序 = 自定义 page(Tier 2):
page:tabs.items[]数组本身就是那份「有序列表」,而顺序本质是排布(ADR-0085 准入测试判它归 page);主流平台(Salesforce)也把相关列表顺序放在页面布局属性上,不放在字段上。
为什么不在字段上加数字
relatedListOrder?任何精确全序都需要一份「有序列表」;放在分散于各子对象的外键上,只能用数字(有间隙/并列/重排的坑,AI 易错)或父对象上的名单(= 已否决的「page 换皮」)。唯一自然的有序列表就是 page 的tabs.items[]——所以精确排序天然归 page。若实测证明「仅为重排就得写 page」太重,退路是字段上的可选数字relatedListOrder(带上述代价),但不作首选。
场景 cookbook(不同业务需求 → 写什么元数据 → 详情页长什么样)
记号:【X】=一个 tab;★=独立 tab;▾Related=聚合 tab 内堆叠。
S1 · 零配置(最常见的默认)
account 下挂 contact / opportunity / case,三个普通 lookup,什么都不额外标:
// contact / opportunity / case 各自:
account: Field.lookup('account', { label: 'Account' }),→ 【Details】【▾Related(Contacts、Opportunities、Cases 堆叠)】【Activity】(可预测兜底,对齐 Salesforce 默认)。
S2 · 标出核心子表(Tier 1 主力用法)
opportunity、case 是命脉要各自独立 tab;contact 留在 Related:
// opportunity.object.ts
account: Field.lookup('account', { label: 'Account', relatedList: 'primary' }),
// case.object.ts
account: Field.lookup('account', { label: 'Account', relatedList: 'primary' }),
// contact 保持普通 lookup(不标)→ 【Details】【Opportunities ★】【Cases ★】【▾Related(Contacts)】【Activity】
S3 · 主子表 / 行项(master_detail,写侧 + 读侧)
invoice 下有 invoice_line,级联删除,在发票表单里内联编辑,并在发票详情页给独立 tab:
// invoice_line.object.ts —— 父是 invoice
invoice: Field.masterDetail('invoice', {
label: 'Invoice',
deleteBehavior: 'cascade',
inlineEdit: 'grid', // 写侧:invoice 表单里内联行项网格
inlineTitle: 'Line Items',
relatedList: 'primary', // 读侧:invoice 详情页给独立 tab
}),→ 发票编辑表单:可编辑 Line Items 行项网格(父子原子保存)。
→ 发票详情页:【Details】【Line Items ★(可编辑行项网格)】【▾Related(…)】【Activity】
S4 · 隐藏噪音子表
// account_sync_log.object.ts
account: Field.lookup('account', { relatedList: false }),→ 该列表完全不出现(连 Related 里也没有)。(created_by/updated_by/owner 等审计外键本就自动跳过;false 用于业务噪音表。)
S5 · 自定义列 / 标题
account 的发票 tab 标题叫 "Billing",列要 name/total/status/issued_on:
// invoice.object.ts
account: Field.lookup('account', {
label: 'Account',
relatedList: 'primary',
relatedListTitle: 'Billing',
relatedListColumns: ['name', 'total', 'status', 'issued_on'],
}),→ 【…【Billing ★(列:name/total/status/issued_on)】…】
→ 不写 relatedListColumns 时,列自动派生自 invoice 的 highlightFields(「columns 变可选」那条改动的效果)。
S6 · 同一子对象多条关系(multi-FK)
opportunity 有 primary_account 和 partner_account 两个字段指向 account:
// opportunity.object.ts
primary_account: Field.lookup('account', { label: 'Primary Account',
relatedList: 'primary', relatedListTitle: 'Primary Opportunities' }),
partner_account: Field.lookup('account', { label: 'Partner Account',
relatedListTitle: 'Partner Opportunities' }), // 不标 primary → 进 Related→ 【…【Primary Opportunities ★】…【▾Related(Partner Opportunities、…)】】
→ 修复前:「第一个外键胜出」,partner 那条整个消失。
S7 · 自引用层级
// account.object.ts
parent_account: Field.lookup('account', { label: 'Parent Account',
relatedList: 'primary', relatedListTitle: 'Child Accounts' }),→ 【…【Child Accounts ★(parent_account = 当前账户 的那些账户)】…】
→ 修复前:自引用(child===parent)被整体跳过,没有这个列表。
S8 · 复杂需求 = 自定义 page(Tier 2 逃生舱)
account 的发票拆 Unpaid/Paid 两 tab + 一个 Revenue 图表 tab + 只对 status=customer 显示的 Contracts tab —— 字段标注做不到(扇出/非关系/条件),写 page:
definePage({ type:'record', object:'account', kind:'slotted', slots:{ tabs:{ type:'page:tabs', properties:{ items:[
{ key:'details', label:'Details', children:[{ type:'record:details' }] },
{ key:'unpaid', label:'Unpaid', children:[{ type:'record:related_list',
properties:{ objectName:'invoice', relationshipField:'account',
filter:[{ field:'status', operator:'ne', value:'paid' }] } }] },
{ key:'paid', label:'Paid', children:[{ type:'record:related_list',
properties:{ objectName:'invoice', relationshipField:'account',
filter:[{ field:'status', operator:'eq', value:'paid' }] } }] },
{ key:'revenue', label:'Revenue', children:[{ type:'record:chart', /* 非关系 tab */ }] },
]}}}})→ 完全按你排的 tab。条件 tab(Contracts 仅 customer)目前靠组件级 visibility(内容隐藏、tab 头暂留)——tab 头级 visibility 是单列的 follow-up。
改动清单
1. relatedList 三态提示 — protocol:data(已实现)
packages/spec/src/data/field.zod.ts:relatedList 从 z.boolean() 升为 z.union([z.boolean(), z.literal('primary')])。false=隐藏;true/缺省=进 Related;'primary'=核心,独立 tab。这是显著度意图(跨表面成立),不是布局开关——「primary→独立 tab」只是详情页渲染器的解读,这也是它能通过 ADR-0085 准入测试的原因(对比被否决的 relatedLayout)。加性、向后兼容 → minor(string|false 已有先例 stageField)。
2. record:related_list 的 columns 改为可选 + 子对象派生 — protocol:ui(spec 侧已实现,objectui 派生待做)
packages/spec/src/ui/component.zod.ts:RecordRelatedListProps.columns 当前必填,改为可选;省略时列取自子对象 highlightFields/默认列。覆盖链(全是本地值,无跨引用):子对象 highlightFields → 字段级 relatedListColumns → 每 tab 内联 columns。放宽(必填→可选)→ 向后兼容。
3. 默认布局与排序 — objectui
plugin-detail/src/synth/buildDefaultPageSchema.ts + app-shell/src/utils/deriveRelatedLists.ts:实现上文「primary/page→tab,其余→Related」规则 + 确定性排序(master_detail 在前,lookup 在后,稳定次序兜底);删去 count-aware 阈值逻辑。列省略时从子对象 highlightFields 派生。可保留一个应用级全局开关(全 tab / 全 Related),但不设对象级开关(ADR-0085)。
4. 多外键派生 — objectui
deriveRelatedLists.ts:当前「第一个外键胜出」(seenChild 去重 + break)→ 同一子对象两外键指向同一父只出一条。改为每个合格外键出一条;标题默认 relatedListTitle,否则 ${childLabel} · ${fkFieldLabel}。
5. 自引用相关列表 — objectui
deriveRelatedLists.ts 当前整体跳过 child.name === parentName → 自引用父外键无自动「下级」列表。改为:非审计外键且 relatedList !== false 时允许自引用;按 where <selfFk> == 本记录id 过滤。
6. lookup 弹出框(选择器)的列 — 澄清 + 一处一致性 — protocol:data / objectui
- 不复用
relatedList*:选择器是反方向(子选父),展示的是目标对象的候选行(不是子记录),列属于不同对象,不能与相关列表共用键。 - 选择器保留自己的键:
displayField/descriptionField/lookupColumns(富列{field,label,type})/lookupFilters/lookupPageSize。 - 一致性(小改动):选择器省略
lookupColumns时的默认列,统一到目标对象的highlightFields(与相关列表同源)——「如何列出对象 X」只有一处真源(X 的highlightFields),相关列表与选择器都从它派生;两侧各自的覆盖键(relatedListColumnsvslookupColumns)保持分离,因为描述不同对象/方向。
明确不做 / 已否决
- ❌
record:related_list加view引用 —— AI 易错面(悬空引用、引错对象、与内联 filter 双写打架、build 绿运行期静默空)。列从子对象派生(✨ Set up Copilot instructions #2);相关列表的过滤本就是结构性外键,不是作者写的业务逻辑。AI 授权平台里「能内联就别引用」。 - ❌ 对象层
relatedLists: [...]数组 —— page 换皮;ADR-0085 否决对象层信息重排。 - ❌ 对象层
relatedLayout开关 —— ADR-0085 已否决。 - ❌ 字段上数字
relatedListOrder(暂不做) —— 见「排序」一节;精确顺序归 page。若实测嫌重再议。 - 业务子过滤分裂(Open/Closed tab) —— 留在 Tier 2(page 内联
filter)。设计如此,对象层不设键。
相关 follow-up(单独,不阻塞本 issue)
page:tabs的 item 级visibility(CEL) 做条件 tab。tab item 形状({label,icon,children})缺visibility,而PageComponentSchema已有。属 Tier 2 页面能力,与本 issue「降低对 page 的依赖」正交,单独跟踪。
验收
examples/app-showcase:删除account-detail.page.ts,仅靠在showcase_project.account、showcase_invoice.account上标relatedList: 'primary'+ 派生默认,复现等价的 Projects/Invoices 独立子表 tab。这是「常见情况不再需要自定义 page」的 dogfood 证明。field-zoo/ dogfood 回归覆盖:primary 提级、列派生、多外键、自引用、确定性排序。- CI 绿:spec api-surface 保持 minor(无删除导出);example-apps typecheck;dogfood gate。
备注
- 跨 framework(spec)+ objectui(渲染/派生),配套 objectui PR。
- 按仓库惯例(bugfix/follow-up 不新开 ADR):理由写进 changeset + 代码注释。仅当要把「显著度=意图 / 布局=排布」这条线固化时,才补一条 ADR-0085 addendum——由 owner 定。
Activity
[-]Detail-page related lists: cut reliance on custom pages (relatedList: 'primary' + derived columns + count-aware tab default)[/-][+]详情页相关子表:降低对自定义 page 的依赖(relatedList 三态 + 列派生 + 确定性排序)[/+]feat(spec): detail-page related-list tabs via `relatedList: 'primary'` (
feat(spec): detail-page related-list tabs via `relatedList: 'primary'` (
chore: bump objectui to 19f2533ae572
docs(spec,skills,adr): correct related-list docs for relatedList 'pri…
os-zhuang commentedon Jul 5, 2026
Follow-up filed: #2606 — 条件 tab(给
page:tabs的 item 加 CELvisibility)。本 issue 里明确 defer 的那块 Tier-2 页面能力。feat(spec): conditional tabs — page:tabs items accept a visibleWhen C…