应用导航与工作区组合
DirectSurface UI 不要求业务系统使用浏览器式路由。对工作台、后台管理、CIS、编辑器和多文档系统来说,更常见的模式是:左侧模块导航负责选择业务模块,中央工作区负责打开、激活和关闭页面文档。
这篇文档把 demo 中已经验证过的组合方式抽象成正式开发模式。文档只使用 ds-ui 的公共 API,不依赖 demo 内部源码。
适用场景
使用这套组合:
- 应用有稳定的模块菜单,例如患者、医嘱、病历、字典、系统管理。
- 用户可以同时打开多个页面或业务对象。
- 页面关闭前需要校验未保存数据。
- 导航项需要显示已打开、禁用、加载、警告或徽标状态。
- 页面需要独立上下文、命令、查询条件、选中行和异步请求生命周期。
不建议把它做成浏览器 URL 路由:
- 一个模块可能打开多个文档,例如
患者详情 001和患者详情 002。 - 当前 active 页面不等于全部已打开页面。
- tab 关闭、右键关闭其他、浮动窗口、dirty 状态和 beforeClose 比 path match 更重要。
- 业务系统通常需要权限、懒加载和页面级上下文,而不是只根据 URL 创建组件。
如果产品确实需要刷新恢复、浏览器前进后退或可分享链接,可以在业务层把 URL 解析结果转换成下面的模块 key 和 document id,再交给工作区打开。
推荐结构
Application / RenderWindow
RenderDockPanel appShell
top: MenuBar / CommandToolbar
left: RenderNavigationMenu
fill: RenderTabbedWorkspace
Navigation model
module key
group
title
icon
permission
page factory
Workspace model
document tabId
title
content
contextValues
dirty / beforeClose
这套结构的分工:
| 层级 | 职责 |
|---|---|
| 模块定义 | 描述模块 key、标题、分组、图标、权限和页面工厂。 |
| 导航协调器 | 把模块定义转成 NavigationMenuGroup[],并把点击转换为打开或激活文档。 |
RenderNavigationMenu |
只负责显示导航、搜索、折叠、选中和状态。 |
TabbedDocumentManager |
负责文档打开、激活、关闭、dirty、beforeClose 和页面上下文。 |
RenderTabbedWorkspace |
把文档状态渲染成 tab 工作区。 |
最小可迁移实现
下面的实现可以直接放到业务项目的应用层。它不是框架强制类型,而是一种推荐组合方式。
import { RenderBox, RenderDockPanel, RenderNavigationMenu, RenderTabbedWorkspace, RenderText, TabbedDocumentManager, type IconName, type NavigationMenuGroup,} from 'ds-ui' interface AppNavigationRoute { key: string title: string groupKey: string groupTitle: string icon?: IconName canOpen?: () => boolean load: () => RenderBox | Promise<RenderBox>} class AppNavigationCoordinator { private readonly routes: AppNavigationRoute[] private readonly manager: TabbedDocumentManager private readonly pending = new Map<string, Promise<void>>() constructor(options: { routes: AppNavigationRoute[] manager: TabbedDocumentManager }) { this.routes = options.routes this.manager = options.manager } createNavigationGroups(): NavigationMenuGroup[] { const groups = new Map<string, NavigationMenuGroup>() for (const route of this.routes) { if (route.canOpen && !route.canOpen()) continue let group = groups.get(route.groupKey) if (!group) { group = { key: route.groupKey, title: route.groupTitle, children: [], } groups.set(route.groupKey, group) } group.children.push({ key: route.key, title: route.title, icon: route.icon, opened: this.manager.hasDocument(route.key), }) } return [...groups.values()] } async open(routeKey: string): Promise<void> { const route = this.routes.find(item => item.key === routeKey) if (!route || (route.canOpen && !route.canOpen())) return if (this.manager.hasDocument(route.key)) { this.manager.activateDocument(route.key) return } const pending = this.pending.get(route.key) if (pending) { await pending this.manager.activateDocument(route.key) return } const task = this.openFresh(route) this.pending.set(route.key, task) try { await task } finally { if (this.pending.get(route.key) === task) { this.pending.delete(route.key) } } } private async openFresh(route: AppNavigationRoute): Promise<void> { const content = await route.load() if (this.manager.hasDocument(route.key)) { content.dispose() this.manager.activateDocument(route.key) return } this.manager.openDocument({ tabId: route.key, title: route.title, content, closable: true, }) }} const routes: AppNavigationRoute[] = [ { key: 'patients', title: '患者列表', groupKey: 'clinical', groupTitle: '临床业务', icon: 'search', load: () => new RenderText('患者列表页面'), }, { key: 'orders', title: '医嘱维护', groupKey: 'clinical', groupTitle: '临床业务', icon: 'copy', load: () => new RenderText('医嘱维护页面'), },] const manager = new TabbedDocumentManager()const coordinator = new AppNavigationCoordinator({ routes, manager }) const navigation = new RenderNavigationMenu({ title: '业务工作台', groups: coordinator.createNavigationGroups(), selectedKey: 'patients', onItemActivate: item => { void coordinator.open(item.key) },}) const workspace = new RenderTabbedWorkspace({ manager })const shell = new RenderDockPanel()navigation.width = 248shell.addChild(navigation, { dock: 'left' })shell.setFill(workspace)
同步选中态和已打开状态
导航菜单不是文档状态的所有者。推荐规则:
TabbedDocumentManager是已打开文档的事实来源。RenderNavigationMenu.selectedKey跟随 active document。- 导航项的
opened状态由manager.hasDocument(route.key)计算。 - manager 状态变化后,重新生成
groups并赋给navigation.groups。
用户点击导航项
-> coordinator.open(item.key)
-> manager.openDocument() 或 manager.activateDocument()
-> manager 触发订阅
-> 更新 navigation.selectedKey
-> 更新 navigation.groups 中的 opened 状态
这种方式可以覆盖三类入口:
- 用户从左侧导航打开页面。
- 用户点击 tab 切换页面。
- 用户关闭 tab 后,导航已打开状态同步消失。
页面参数和上下文
普通模块可以直接使用模块 key 作为 tabId。如果同一模块需要打开多个业务对象,应把业务对象 id 编进 document id,并通过 contextValues 注入页面。
import { RenderText, TabbedDocumentManager, createAppContextKey,} from 'ds-ui' const patientIdKey = createAppContextKey<string>('patient-id')const manager = new TabbedDocumentManager() manager.openDocument({ tabId: 'patient:001', title: '患者 001', content: new RenderText('患者详情页面'), contextValues: [ [patientIdKey, '001'], ], closable: true,})
页面内部组件通过 app context 读取 patient-id,页面关闭时由 document manager 释放该页面上下文。不要把当前患者、当前查询条件或当前选中行放到应用级全局上下文。
权限、懒加载和防重复打开
模块定义里可以保留 canOpen 和 load:
canOpen()返回false时,不生成导航项,或生成 disabled 项。load()可以同步创建页面,也可以异步加载页面模块。pendingmap 用于防止用户连续点击导致同一个页面并发创建。- 如果异步加载完成时文档已经被别的入口打开,应 dispose 刚创建的 replacement content,再激活已有文档。
这正是复杂工作台里最容易遗漏的部分。不要只在 onItemActivate 里 new Page(),否则会出现重复 tab、重复请求、悬挂页面对象和权限状态不同步。
与命令和菜单配合
应用顶部菜单、左侧导航和页面内按钮可以共享同一套模块 key:
MenuBar item
-> coordinator.open('patients')
NavigationMenu item
-> coordinator.open(item.key)
CommandButton
-> command execute
-> coordinator.open('patients')
如果按钮权限来自命令系统,导航权限来自模块权限,二者应读取同一个业务权限服务。不要在菜单、导航和按钮里各写一份权限判断。
什么时候需要 URL 集成
先实现上面的模块导航,再按需接入 URL。URL 层只做外部状态同步,不替代 document manager:
| 需求 | 推荐处理 |
|---|---|
首次进入 /patients |
解析 URL,调用 coordinator.open('patients')。 |
分享 /patient/001 |
解析对象 id,打开 tabId: 'patient:001' 的文档。 |
| 浏览器后退 | 根据历史记录激活或打开对应文档。 |
| 刷新恢复工作台 | 从持久化状态恢复 TabbedDocumentDefinition,再重建 workspace。 |
不要让 URL 直接持有 render 对象、页面 controller 或查询结果。URL 只保存可序列化的业务定位信息。
常见错误
| 问题 | 后果 | 修正 |
|---|---|---|
| 每次点击导航都创建新页面 | 同一模块重复打开多个 tab。 | 先判断 manager.hasDocument(tabId),已有则激活。 |
| 导航菜单自己保存 opened 状态 | 关闭 tab 后导航状态不同步。 | 从 document manager 重新计算 opened。 |
| 页面参数放应用全局上下文 | 多 tab 互相读错当前对象。 | 用文档 contextValues 注入页面级上下文。 |
| 懒加载未防并发 | 快速点击生成多个页面实例。 | 使用 pending map 合并同一个 key 的打开请求。 |
| 关闭文档不释放页面资源 | 订阅、定时器、缓存泄漏。 | 页面资源跟随 RenderPage、context scope 和 document close 释放。 |