MultiView
**分类:**页面编排器 —— 不是数据视图。MultiView 用共享顶栏 + 标签栏包裹单个
page.tsx;它自身不渲染数据,而是把其他视图(Model* / 自定义仪表盘等)组合成标签页。
| 层级 | 范围 | 示例 |
|---|---|---|
| 应用壳层 | 整个应用 | Header / Sidebar(见 layout/) |
| 页面编排器(本页) | 单个 page.tsx | MultiView |
| 数据视图 | 一套数据 | ModelTable / ModelBoard / ModelCard / ModelForm |
完整分层说明见 Index。
MultiView 提供的能力:
- 由 MultiView 渲染共享页头(标题 + 描述 + 标签 pill)
- 每个标签的
view是组件引用——用于渲染标签正文的ComponentType - 每个标签的
filters/orders通过 React Context 注入内部 Model* 视图 - 当前标签自动与
?tab=<id>同步(浏览器前进/后退可用) - 标签之间状态完全隔离(切换时卸载再挂载)
- 与模型无关:MultiView 不拉取元数据;各内层视图自行持有
modelName
相关文档
- ModelTable — 表格视图,可作为标签
view - ModelBoard — 看板视图,可作为标签
view - ModelCard — 卡片网格,可作为标签
view
快速开始
每个标签正文抽到各自的 <视图类型>-view.tsx(例如
board-view.tsx、table-view.tsx)并导出组件。页面通过 MultiView 组合:
// design-app-version/board-view.tsx
"use client";
import { Field } from "@/components/fields";
import { ModelBoard } from "@/components/views/board";
export function BoardView() {
return (
<ModelBoard
modelName="DesignAppVersion"
groupBy={{
type: "enum",
field: "status",
columns: [
{ value: "Draft", label: "Draft" },
{ value: "Sealed", label: "Sealed" },
{ value: "Frozen", label: "Frozen" },
],
}}
>
<ModelBoard.Header>
<Field fieldName="name" />
<Field fieldName="versionType" />
</ModelBoard.Header>
<Field fieldName="sealedTime" />
</ModelBoard>
);
}// design-app-version/table-view.tsx
"use client";
import { Field } from "@/components/fields";
import { ModelTable } from "@/components/views/table/ModelTable";
export function TableView() {
return (
<ModelTable modelName="DesignAppVersion">
<Field fieldName="name" />
<Field fieldName="status" />
<Field fieldName="sealedTime" />
<Field fieldName="updatedTime" />
</ModelTable>
);
}// design-app-version/page.tsx
"use client";
import { MultiView } from "@/components/views/multi-view";
import { BoardView } from "./board-view";
import { TableView } from "./table-view";
export default function DesignAppVersionPage() {
return (
<MultiView labelName="Design App Version">
<MultiView.Tab
id="board"
label="Board"
orders={["updatedTime", "DESC"]}
view={BoardView}
/>
<MultiView.Tab id="table" label="Table" view={TableView} />
</MultiView>
);
}labelName 是页头展示的标题文案。MultiView 与模型无关,不拉取元数据——标题请直接传入字符串。
各视图组件从 useMultiViewContext() 读取 filters / orders / linkTo / embedded(Model* 内部已处理)。
当前标签 id 会自动同步到 ?tab=<id>。首次进入在 mount 时读 URL;点击标签通过 router.push 更新 URL(因此前进/后退可切换标签)。无需额外开关。
概念
标签 view 是 ComponentType
view 是组件引用(不是元素)。MultiView 在标签激活时以 <view /> 实例化。该组件:
- 通常包裹单一 Model* 视图(
ModelTable/ModelBoard/ModelCard)——这些组件从上下文读取当前标签的orders/filters - 也可以是任意其他组件(自定义仪表盘、图表、第三方)——原样渲染
MultiView.Tab 不接受 children。属于标签正文的一切都写在视图组件内。视图特有 props(groupBy、columns 等)应放在内层 Model* 上。
每个标签的 filters 与 orders
在 MultiView.Tab 上声明的 filters / orders 通过 React Context 暴露。
视图组件内的 Model* 会自动拾取:
// sys-model/table-view.tsx
"use client";
import { Field } from "@/components/fields";
import { ModelTable } from "@/components/views/table/ModelTable";
export function TableView() {
return (
<ModelTable modelName="SysModel">
<Field fieldName="modelName" />
<Field fieldName="labelName" />
{/* ... */}
</ModelTable>
);
}// sys-model/page.tsx
<MultiView labelName="Sys Model">
<MultiView.Tab
id="all"
label="All"
orders={["modelName", "ASC"]}
view={TableView}
/>
<MultiView.Tab
id="timeline"
label="Timeline Model"
orders={["modelName", "ASC"]}
filters={[["timeline", "=", true]]}
view={TableView}
/>
</MultiView>同一个 TableView 可在两个标签复用。各标签通过上下文区分 filters/orders;切换时正文通过 key={active.id} 重挂载。
层内与跨层的完整优先级规则见下文 Filter & order precedence。
每个标签不同模型
各视图在内层 Model* 上自行提供 modelName,因此不同标签可展示不同模型。配合每个标签各自的 linkTo,使行点击进入正确详情子目录:
// app-overview/page.tsx
import { VersionsView } from "./versions/table-view";
import { EnvsView } from "./envs/table-view";
<MultiView labelName="App Overview">
<MultiView.Tab
id="versions"
label="Versions"
linkTo="versions" // 行点击 → ./versions/{id}?mode=read
view={VersionsView}
/>
<MultiView.Tab
id="envs"
label="Environments"
linkTo="envs" // 行点击 → ./envs/{id}?mode=read
view={EnvsView}
/>
</MultiView>共享页头(标题 + 描述)是页面级文案——不从任何模型元数据推导。请直接传 labelName / description。
点击导航(linkTo)
默认点击记录会导航到 ${pathname}/{id}?mode=read,即当前目录下的 [id]/page.tsx。适用于详情页直接在列表同级的单模型页面。
多模型 MultiView(或详情页落在子目录)时,用 linkTo 指定子目录名:
| 设置位置 | 说明 |
|---|---|
<MultiView.Tab linkTo="x"> | 经上下文传给当前激活视图。 |
<ModelTable linkTo="x"> 等 | 直接使用;若与 Tab 同时设置,以内层为准。 |
| 全部省略 | 默认:${pathname}/{id}?mode=read。 |
linkTo 必须是匹配 /^[a-zA-Z0-9_-]+$/ 的单子目录名(无斜杠、无 ..、无句点前导)。非法值回退为默认行为,并在开发环境输出 console.warn。
此约束有意为之:点击导航始终留在当前路由子树内,与权限边界一致。Model* 视图上不提供自由形式的点击处理与跨路由 URL——若确实需要,应在页面层围绕视图实现点击逻辑,而不是写在视图本身上。
自定义(非模型)视图
任意组件均可。自定义视图可忽略上下文,直接渲染:
import { EnvDashboard } from "./components/env-dashboard";
import { TableView } from "./table-view";
<MultiView labelName="Design App Env">
<MultiView.Tab id="dashboard" label="Dashboard" view={EnvDashboard} />
<MultiView.Tab
id="table"
label="Table"
orders={["sequence", "ASC"]}
view={TableView}
/>
</MultiView>共享页头由 MultiView 持有。若自定义视图也渲染标题区,请用 useMultiViewContext()?.embedded 门禁,避免双标题:
import { useMultiViewContext } from "@/components/views/multi-view";
import { ViewTitle } from "@/components/views/shared/ViewTitle";
export function EnvDashboard() {
const isEmbedded = !!useMultiViewContext()?.embedded;
return (
<div className="flex h-full flex-col">
{!isEmbedded && (
<div className="border-b border-border/60" style={{ padding: "var(--ui-page-padding)" }}>
<ViewTitle labelName="Design App Env" />
</div>
)}
{/* dashboard body — refresh button, cards, etc. */}
</div>
);
}URL 同步
当前标签 id 始终同步到 ?tab=<id>:
- mount 时读取 URL;若与已知标签 id 匹配则作为初始激活标签,否则第一个声明的标签生效。
- 点击标签通过
router.push写入 id,每次切换产生历史记录——前进/后退在标签间导航。 - 外部 URL 变化(后退/前进)时,激活标签随之更新。
同一页面多个 MultiView 共用 ?tab 参数。若标签 id 互不重叠(例如一组 board/table,另一组 dashboard/chart),可共存——各自忽略不认识的值。
标签切换与缓存
切换标签会卸载前一视图(正文使用 key={active.id})并挂载新视图。内层 Model* 会重新初始化查询钩子。是否发网络请求取决于 TanStack Query 缓存:
| 查询类型 | staleTime | 跨标签切换行为 |
|---|---|---|
元数据(useMetadataQuery) | Infinity | 按 modelName 永久缓存;每个模型的元数据在页面生命周期内至多请求一次。 |
| 列表 / count / lookup(数据查询) | 5 分钟 | 每个标签首次激活会发请求(不同 filters / orders → 不同 queryKey → 独立缓存)。5 分钟内再次激活同一标签为缓存命中(无网络、即时渲染)。超过 5 分钟则先返回缓存再在后台重新请求。 |
默认值在 query-provider.tsx 全局配置。v1 下标签切换不会在工具栏状态间合并或共享;每次挂载都是全新状态。
标签状态隔离
切换标签卸载旧视图、挂载新视图。工具栏筛选、排序、搜索、分页、选择在切换时全部重置。v1 标签间无共享状态。key={active.id} 的重挂载保证即使用相同视图组件(如 sys-model、sys-field)也会重置。
Filter & order precedence
filters 与 orders 出现在多层——需分清何时覆盖、何时合并。
三层
| 层 | 来源 | 作用 |
|---|---|---|
| A. 开发者声明 | ModelTable.filters / ModelTable.initialParams.filters / MultiView.Tab.filters(上下文) | 页面级基础条件 |
| B. 系统作用域 | useWorkspaceFilter() 的 workspaceFilter | 强制数据隔离(安全边界) |
| C. 用户运行时 | 搜索、列筛选、工具栏筛选、侧栏选中 | 页面上的即时收窄 |
Filters:A 层内 → 覆盖;跨层 → AND
A 层内(取第一个非 undefined,不合并):
顶层 filters > initialParams.filters > MultiView.Tab.filters(上下文)若在 <MultiView.Tab filters={Y}> 内渲染 <ModelTable filters={X}>,则有效基础为 X——Y 被覆盖,不会做 AND 合并,从而避免「两套筛选被静默合并」的意外。
跨层(全部 AND):
finalFilter =(A 层:选中的基础)
AND (B 层:workspaceFilter)
AND (C 层:树筛选、搜索、列筛选、工具栏筛选)每一层都会追加自身约束;最终查询需同时满足所有层。该逻辑在 buildModelTableFilterCondition 中实现。
Orders:A 层内 → 覆盖;用户运行时 → 替换
A 层内(取第一个非 undefined):
顶层 orders > initialParams.orders > MultiView.Tab.orders(上下文)
↓
(为初始排序状态提供种子)
↓
运行时(替换,非合并):
用户工具栏排序 > 列头点击排序
(用户实际操作的那一侧会完整替换先前的排序)workspaceFilter 不参与排序(它是行可见性约束,不是排序提示)。
运行时为何「替换」而非「合并」?排序只有一个有效顺序(多级优先级仍是一条顺序)。用户选新排序是在改变主意,而不是叠加约束。
常见陷阱 — Tab filters 不会与内层 filters 合并
// ❌ 多半不是你想要的行为
<MultiView.Tab filters={[["active", "=", true]]} view={SomeTable} />
// SomeTable 内为:
<ModelTable filters={[["status", "=", "Live"]]} />
// 有效基础筛选: [["status", "=", "Live"]]
//(Tab 的 [["active","=",true]] 被覆盖 —— 不会做 AND 合并)
// ✅ 内层 ModelTable 不要重复声明;让 Tab 的 filter 透传
<MultiView.Tab filters={[["active", "=", true]]} view={SomeTable} />
// SomeTable 内:
<ModelTable />
// 有效基础筛选: [["active", "=", true]]
// ✅ 或把 AND 写显式
<ModelTable filters={[
["active", "=", true], "AND", ["status", "=", "Live"]
]} />API
<MultiView> props
| Prop | Type | Required | Default | Notes |
|---|---|---|---|---|
labelName | string | No | - | 共享页头标题。 |
description | string | No | - | 共享页头副标题。 |
className | string | No | "flex h-full flex-col" | 外层 wrapper className。 |
children | ReactNode | Yes | - | 一个或多个 <MultiView.Tab> 标记。非 Tab 子节点会被忽略。 |
激活标签跟踪、默认标签与 URL 同步由内部管理——无受控模式出口。URL 无 ?tab 时以第一个声明的标签为默认。
<MultiView.Tab> props
| Prop | Type | Required | Default | Notes |
|---|---|---|---|---|
id | string | Yes | - | 稳定 id:激活跟踪、?tab=<id>、正文 remount key。 |
label | string | Yes | - | 标签 pill 文案。 |
icon | ReactNode | No | - | pill 内标签前的可选图标。 |
filters | FilterCondition | No | - | 标签级基础筛选,经上下文传给激活视图。 |
orders | OrderCondition | No | - | 标签级默认排序,经上下文传给激活视图。 |
linkTo | string | No | - | 行点击导航子目录名。见上文「点击导航」。 |
view | ComponentType | Yes | - | 该标签激活时实例化的组件。必须是组件引用,不能是 JSX 元素(view={MyView},勿 view={<MyView />})。 |
useMultiViewContext()
返回当前上下文(在 MultiView 外为 null):
type MultiViewContextValue = {
filters?: FilterCondition;
orders?: OrderCondition;
linkTo?: string;
/** MultiView 内恒为 true。自定义视图用它判断是否抑制重复标题。 */
embedded: true;
};内层 Model* 内部已使用该 hook——多数调用方不需要。 自定义视图(仪表盘、图表)用它:
- 嵌入时跳过重复标题
- 判断是否处于多视图容器内
何时使用 MultiView
| 页面形态 | 建议 |
|---|---|
| 单模型、单视图、无筛选标签 | 直接使用 ModelTable / ModelBoard / ModelCard |
| 单模型、单视图、多筛选标签 | MultiView + 跨标签复用同一 view 组件 |
| 单模型、多种视图(看板/表格等) | MultiView,每个标签一个视图组件 |
| 单容器内多个相关模型 | MultiView,每标签自有视图组件(各自 modelName) |
文件组织
MultiView 页面的惯例目录:
<page>/
├── page.tsx # MultiView 组合(约 30 行)
├── board-view.tsx # 有看板标签时导出 BoardView
├── table-view.tsx # 有表格标签时导出 TableView
└── [id]/page.tsx # 详情(单模型)每标签详情在不同子目录的多模型 MultiView:
<page>/
├── page.tsx
├── <entity-a>/
│ ├── table-view.tsx # exports TableView
│ └── [id]/page.tsx # 实体 A 详情
└── <entity-b>/
├── table-view.tsx # exports TableView
└── [id]/page.tsx # 实体 B 详情同页两个 view 文件都导出同名 TableView 时,在引用处用别名:
import { TableView as LoginHistoryView } from "./login-history/table-view";
import { TableView as AuthFailuresView } from "./auth-failures/table-view";备选目录结构(合并文件 / 内联)
默认「每路由文件夹一份视图」在每个标签各有独立 [id]/page.tsx 时最划算——视图文件与详情页同目录。若所有标签共用同一个 [id]/page.tsx(例如同一模型上的状态筛选标签),按文件夹拆分会增加心智负担却得不到详情拆分收益,此时可考虑两种替代方案:
1. 单文件合并导出 — 在 <page>/table-views.tsx 中为每个标签导出一个组件:
<page>/
├── page.tsx # MultiView 组合
├── table-views.tsx # 导出 PendingView、HiredView、CancelledView 等
└── [id]/page.tsx # 所有标签共用的详情页2. 在 page.tsx 内联 — 在与 MultiView 同一文件中声明视图组件。
如何选型看最重的那份视图的结构复杂度,而非总行数:
| 条件 | 建议目录结构 |
|---|---|
每个标签各有自己的 [id]/page.tsx | 按文件夹(默认) |
共用 [id]/page.tsx,且任意标签的视图含对话框 / 处理函数 / 较重状态 | 按文件夹(覆盖全部标签) |
共用 [id]/page.tsx,每个视图 ≲ 80 行(筛选 + Fields + Actions,无每标签独立状态) | 合并为 table-views.tsx |
共用 [id]/page.tsx,每个视图 ≲ 20 行(几乎只有 ModelTable + Fields) | 在 page.tsx 内联 |
若某个标签需要独立处理函数或对话框,建议所有标签统一用按文件夹结构,而不要混用(一个特重文件 + 一个合并文件往往比多个统一文件夹更难读)。
共用 [id] 时,还应省略 MultiView.Tab 上的 linkTo,让默认 ${pathname}/{id} 命中共享详情页。
限制(v1)
- 标签间不共享状态。切换时整标签卸载,工具栏筛选/排序/搜索/分页/选择重置。若将来某页需要跨标签保留状态,计划以
keepMounted等形式提供逃生口。 - 共享页头仅标题 + 描述 + 标签 pill。不支持页头级动作位(例如标题旁额外按钮)——请把各视图工具栏放在视图正文内。EnvDashboard 的 Refresh 即如此。
- 需要
embedded的自定义视图须自行调用useMultiViewContext()。无 props 注入(无cloneElement),无关组件不会被动接收 props。 - Model* 点击导航限定在
${pathname}/${linkTo?}/${id}。无onClick/href逃生口。确需跳别处时请在页面层包装,而非改视图。 - URL 同步使用 Next.js
router.push,每次点击标签产生历史记录——有意为之,后退回到上一标签。 - 同页多个
MultiView共用?tab。若需独立共存请使用互不重叠的标签 id。 view必须是组件引用(如view={TableView}),不能是 JSX 元素(view={<TableView />})。MultiView 内部实例化组件,避免与上下文注入模型冲突的元素级 baked-in props。