ModelSidecar
附加在列表 / 看板 / 表格视图中每条主记录上的辅助数据:批量拉取一次,按主 id 关联,通过 RecordContext 逐条暴露。
为什么
很常见:环境列表想在每行旁显示漂移状态;员工表格想在每行下显示假期余额与近期请假;客户看板想在卡片上显示未结 AR 徽章。朴素实现会变成每卡一条查询(N+1)或页面级自定义仪表盘。
ModelSidecar 用每个 sidecar 模型一次批量查询、按主 id 关联、经 context 分发到对应卡片 —— 且从不伪装成真实列。
铁律
Sidecar 数据不能变成可排序 / 可筛选 / 可导出的列。 若用户需要据此排序、筛选或导出,应提升为真实字段 —— 纳入主模型(virtual / cascaded / view),使其具备列契约。
Sidecar 只出现在两处:
- 行内附属 —— 追加在已有字段旁(徽章、状态指示)。
- 行展开(Master-Detail) —— 行下方全宽面板。
仅此两种,没有第三种位置。
快速开始 —— Board / Card 视图中的行内插槽
在 <ModelBoard> / <ModelCard> 上直接声明 sidecars。视图从自身记录推导 primaryIds,将活跃 workspace 过滤 AND 合并进各 sidecar 的 filters,经 useQueries 批量查询,并向卡片内插槽暴露数据。工具栏会增加 Refresh 按钮,使主查询与所有 sidecar 模型失效。
import { Field } from "@/components/fields";
import { ModelBoard } from "@/components/views/board";
import {
SidecarSlot,
useSidecar,
} from "@/components/views/shared/model-sidecar";
function MyBoardView() {
return (
<ModelBoard
modelName="DesignAppEnv"
groupBy={{ field: "envType" }}
sidecars={[
{
id: "lastActivity",
model: "DesignActivity",
joinField: "envId",
orders: ["id", "DESC"],
reduce: "first",
},
]}
>
<ModelBoard.Header>
<Field fieldName="name" />
</ModelBoard.Header>
<Field fieldName="connectorType" />
<SidecarSlot
slotId="lastActivity"
render={(lastActivity) => (
<LastActivityRow lastActivity={lastActivity} />
)}
/>
</ModelBoard>
);
}在任意 per-card 作用域内,需要组合多个 slot 时可直接调用 useSidecar:
function HealthDot() {
const lastActivity = useSidecar<ActivityRow>("lastActivity");
return <span className={dotColor(lastActivity)} />;
}快速开始 —— <ModelTable> 中的 Master-Detail
import { Field } from "@/components/fields";
import { ModelTable } from "@/components/views/table/ModelTable";
import {
SidecarStatPanel,
SidecarTablePanel,
SidecarListPanel,
} from "@/components/views/shared/model-sidecar";
<ModelTable
modelName="Employee"
sidecars={[
{ id: "leaveBalance", model: "LeaveBalance", joinField: "employeeId",
reduce: "first" },
{ id: "recentLeaves", model: "LeaveRecord", joinField: "employeeId",
orders: ["startDate", "DESC"], reduce: "all" },
{ id: "recentActivities", model: "EmployeeActivity", joinField: "employeeId",
orders: ["createdTime", "DESC"], reduce: "all" },
]}
expandChildren={(employee) => (
<div className="grid gap-3 md:grid-cols-3">
<SidecarStatPanel
slotId="leaveBalance"
label="Leave Balance"
render={(data) => (
<dl className="grid grid-cols-2 gap-3">
<Stat label="Annual" value={data?.annualRemaining ?? 0} />
<Stat label="Sick" value={data?.sickRemaining ?? 0} />
</dl>
)}
/>
<SidecarTablePanel
slotId="recentLeaves"
label="Recent Leaves"
maxRows={5}
columns={[
{ header: "Type", render: (r) => r.leaveType },
{ header: "Start", render: (r) => formatDate(r.startDate) },
{ header: "Days", render: (r) => r.days, align: "right" },
{ header: "Status", render: (r) => <StatusBadge status={r.status} /> },
]}
/>
<SidecarListPanel
slotId="recentActivities"
label="Activities"
maxRows={10}
renderItem={(row) => <ActivityRow activity={row} />}
/>
</div>
)}
>
<Field fieldName="name" />
<Field fieldName="department" />
<Field fieldName="status" />
</ModelTable>reduce: "all" 默认 limitSize 为 primaryIds.length * 10,适合多数「每行最近 N 条」场景。仅当面板有意多拉或少拉行时再覆盖 limitSize。
展开行跨所有列。各面板自行决定标题语义:Stat 无列头,Table 从 columns 自动渲染列头,List 仅显示面板标题。
API
<ModelBoard> / <ModelTable> / <ModelCard> 上的 sidecars prop
传入 SidecarConfig 数组。视图会:
- 从自身已渲染记录推导
primaryIds(去重、保序)。 - 将活跃 workspace 过滤 AND 合并进各 config 的
filters(用inheritWorkspaceFilter: false跳过)。 - 用
<ModelSidecarProvider>包裹卡片 / 行,使useSidecar/<SidecarSlot>/ panels 可解析。 - Board / Card:工具栏渲染 Refresh,使主模型与所有 sidecar 模型失效。ModelTable 工具栏 Refresh 尚未实现 —— 页面需要刷新入口时请手动
queryClient.invalidateQueries({ queryKey: [model] })。
这是默认路径。除需直接控制外,所有带 sidecar 的列表场景都应使用。
<ModelSidecarProvider>(底层)
独立 provider 用于视图不拥有记录列表的情况(例如自定义布局混合主列表与 detached 面板)。自行管理 primaryIds 并应用 workspace 过滤 —— 通常通过导出的 applyWorkspaceFilterToSidecars(sidecars, workspaceFilter)。
| Prop | 类型 | 必填 | 说明 |
|---|---|---|---|
primaryIds | string[] | 是 | 当前渲染记录的 id。空数组 → 不拉取。 |
configs | SidecarConfig[] | 是 | 每个 sidecar 插槽一条。顺序无关。 |
children | ReactNode | 是 | sidecar 所装饰的视图。 |
SidecarConfig
| 字段 | 类型 | 说明 |
|---|---|---|
id | string | 插槽 id;须与读取时的 slotId 一致。 |
model | string | Sidecar 模型名。 |
joinField | string | Sidecar 模型上指向主 id 的外键(如 envId)。 |
filters | FilterCondition? | 额外范围,与 [joinField, "IN", primaryIds] AND 合并。 |
orders | OrderCondition? | 排序。reduce: "first" 时决定哪行胜出。 |
reduce | "first" | "all" | 单条或列表。见下文。 |
limitSize | number? | 覆盖拉取上限。 |
inheritWorkspaceFilter | boolean? | 宿主视图应用 workspace 过滤时(<ModelBoard sidecars> / <ModelTable sidecars> / <ModelCard sidecars> 默认行为),设为 false 可退出 —— 适用于跨 workspace 查找(系统参考数据等)。直接使用 <ModelSidecarProvider> 时忽略此字段。 |
fetchRows | (primaryIds: string[]) => Promise<Record<string, unknown>[]>? | 从接口读取行,而不是对 model 做 searchList。见自定义数据源。 |
自定义数据源(fetchRows)
有些逐行数据并不是某个模型里存的行:由服务按主 id 现算(状态汇总、跨多张表的计数),从它自己的接口返回。fetchRows 让这类接口给 sidecar 供数:每批调用一次,传入屏幕上的主 id;返回的每一行都必须带 joinField —— 这些行的归并与分发和 searchList 返回的行完全一样。
const seedSummary: SidecarConfig = {
id: "seedSummary",
// Keys the cache: name the model whose writes change the answer, so invalidateModelQueries refreshes it.
model: "SeedLoadRecord",
joinField: "tenantId",
reduce: "first",
inheritWorkspaceFilter: false,
// A typed DTO (an interface, no index signature) is not assignable to Record<string, unknown>, so it is cast.
fetchRows: async (tenantIds) =>
(await seedDataService.getTenantSummaries(tenantIds)) as unknown as Record<string, unknown>[],
};filters、orders、limitSize 与 workspace 过滤描述的是一次 searchList,对自定义数据源不生效 —— 返回什么、给谁,由接口决定。model 仍是查询 key 的前缀([model, "sidecar", { ..., source: id }]),所以写入后 invalidateModelQueries(queryClient, model) 会刷新这个 sidecar。fetchRows 要调用 service 时,在 hook 里构建 config,页面本身就不必 import 这个 service。
reduce 语义
"first"→ 返回T \| undefined。配合useSidecar<T>/SidecarSlot<T>/SidecarStatPanel<T>。默认limitSize = primaryIds.length。"all"→ 返回T[](始终为数组;无匹配行为空数组)。配合SidecarTablePanel<T>/SidecarListPanel<T>。默认limitSize = primaryIds.length * 10。
1:1 关联可无 orders 使用 "first" —— 本来就只有一行。
高基数 1:N(一条主记录可能有数百子行)时不要无限增大 limitSize。尽力批量不能替代每组 Top-N;应在后端聚合。
Hooks
useSidecar<T>(slotId): T | undefined读取当前记录的 sidecar 值。从周围 RecordContext 解析主 id。无 record context、无插槽或主 id 不在预取批次中时返回 undefined。reduce: "all" 始终返回数组(无 context 时为 undefined)。
useSidecarLoading(slotId): boolean插槽底层查询是否仍在加载。
useModelSidecarRegistry(): ModelSidecarRegistry
useOptionalModelSidecarRegistry(): ModelSidecarRegistry | null底层访问。registry 暴露 getChannel(slotId) 与 refetchAll()。多数消费者用上面的 hooks 即可。
Slot
<SidecarSlot<T>
slotId="lastActivity"
render={(data, { isLoading }) => /* … */}
/>围绕 useSidecar + useSidecarLoading 的 render-prop 包装。用于行内附属。
Panels(Master-Detail 构建块)
| Panel | 形态 | Reduce | 有列头? |
|---|---|---|---|
SidecarStatPanel<T> | KV / 指标 —— 单记录量级 | "first" | 否 |
SidecarTablePanel<T> | 多记录 × 多字段子表 | "all" | 是(由 columns 自动生成) |
SidecarListPanel<T> | 多记录活动流 | "all" | 否 |
三者均接受 label?(面板标题)、emptyText?、className?。
SidecarTablePanel 与 SidecarListPanel 接受 maxRows?(限制渲染行数)与 footer?(如「View all (N)」链接)。
<ModelTable expandChildren>
<ModelTable
modelName="Employee"
expandChildren={(employee) => /* panels go here */}
>
…
</ModelTable>设置 expandChildren 时,ModelTable 在左侧增加 chevron 列,并在每条展开行下方渲染返回的 ReactNode。展开内容包在 RecordContextProvider 中,内部 useSidecar / <SidecarSlot> / panels 无需额外配置。
说明:
- 提供
expandChildren时自动禁用虚拟滚动。 - 固定列仍正确固定 ——
left偏移会加上 chevron 列宽。 - 展开列与
select/actions列独立,可共存。
陷阱
- 变更后刷新。 Sidecar 查询以 sidecar 模型名作为 key(如
["DesignActivity", …])。动作变更主模型时,框架失效仅触及[primaryModel, …]—— sidecar 不会自动重拉。Board / Card 工具栏 Refresh(设置sidecars时自动渲染)可处理;异步动作更新 sidecar 状态后请使用。ModelTable 工具栏 Refresh 尚未接入 —— 需要时请手动 invalidate。 - 形态不一致。 join 值可以是
ModelReference({id, displayName},模型的 ManyToOne 返回这种形态),也可以是裸 id(字符串或数字,fetchRows数据源通常这样返回);两者都解析为同一个主 id。其他形态会被丢弃,所以特殊形态请在 API 层归一 —— 不要让框架猜测。 - 加载闪烁。 Sidecar 查询在主记录到达后才触发。卡片首屏 sidecar 为空;数据到达后徽章填充。需要骨架屏时用
useSidecarLoading(slotId)。 - 直接使用
<ModelSidecarProvider>。 绕过宿主集成(少见)时,你负责primaryIds引用稳定 —— 请 memoize:useMemo(() => records.map(r => r.id), [records])。宿主视图已替你处理。
何时不要用
- 辅助数据已在主模型上(如
lastDeployedAt是真实字段)。直接用<Field>。 - 用户需要按该指标排序 / 筛选 / 导出。提升为真实字段;sidecar 不是正确工具。
- 关系是「按已知 id 查一条特定关联记录」,而非「跨主 id 列表 join」。普通
useGetByIdQuery足够。
文件映射
ModelSidecarProvider.tsx # Provider + context + useQueries-based batch fetcher
useSidecar.ts # useSidecar / useSidecarLoading
SidecarSlot.tsx # Inline render-prop slot
applyWorkspaceFilter.ts # Helper used by host-view integrations
panels/
SidecarPanelShell.tsx # Shared shell (border / title / loading / empty)
SidecarStatPanel.tsx # Metric / KV
SidecarTablePanel.tsx # Multi-record sub-table with column headers
SidecarListPanel.tsx # Activity feed
types.ts # SidecarConfig / SidecarReduce / channel typesModelBoard / ModelTable / ModelCard 上的 sidecars prop 是公开 surface;上文其余内容业务代码很少需要直接接触。