DirectSurface UIDirectSurface UI
开始使用
文档/业务指南

应用导航与工作区组合

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 释放该页面上下文。不要把当前患者、当前查询条件或当前选中行放到应用级全局上下文。

权限、懒加载和防重复打开

模块定义里可以保留 canOpenload

  • canOpen() 返回 false 时,不生成导航项,或生成 disabled 项。
  • load() 可以同步创建页面,也可以异步加载页面模块。
  • pending map 用于防止用户连续点击导致同一个页面并发创建。
  • 如果异步加载完成时文档已经被别的入口打开,应 dispose 刚创建的 replacement content,再激活已有文档。

这正是复杂工作台里最容易遗漏的部分。不要只在 onItemActivatenew 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 释放。

相关文档