Commit cd281496 authored by Andy-bubu's avatar Andy-bubu

feat: improve data grid readability and navbar notifications

parent fa6182cc
......@@ -8,6 +8,7 @@
- 允许的共享组件只组合 HeroUI/HeroUI Pro 官方复合结构,不复刻旧 props,不重画组件外观。
- HeroUI/HeroUI Pro 暂无直接对应组件时,优先组合官方 compound API;确需补充基础能力时先更新架构与设计系统评审结论,不得回退到 Catalyst 或可见原生控件。
- 业务数据、接口参数、权限、校验、保存回调和状态流转继续由页面与 service 层拥有。
- 五项跨应用功能的唯一范围与业务合同见 [xinyuan-cross-app-feature-contract.md](./xinyuan-cross-app-feature-contract.md)。问题整改、审计项目管理、警示案例库、廉洁从业管理、廉洁风险防控、党风廉政建设实施全部五项;其它应用只能接入通知。共享组件不得通过默认启用或硬编码把完整功能扩散到白名单外。
## 标准实现
......@@ -15,6 +16,7 @@
- DataGrid:HeroUI Pro `DataGrid` 原生 `selectedKeys`、`onSelectionChange`、`selectionMode`、`showSelectionCheckboxes`,配合原生固定列、Pagination 和 ActionBar。
- Pagination:直接使用 HeroUI compound API,不增加页容量 Select/Autocomplete。HeroUI 不定义每页条数,项目数据层统一使用 10 条/页并继续传递 `page_size=10`。
- DataGrid 宽度:共享层直接使用 HeroUI Pro `ColumnSize`。非宽表将第一业务主列设为 `1fr`,选择/操作列固定并以 `min-w-full` 保证完整宽度链;宽表保留业务最小像素宽度,仅在 DataGrid 内横向滚动。页面不得用背景、伪元素或空列填补右侧空白。
- DataGrid 长文本:`RecordWorkbench` 从动态视图字段定义、字段语义和当前真实记录样本共同推导展示策略,不绑定具体旧表头。普通长文本最小 360px,整改情况最小 420px,最多两行换行并由共享 HeroUI Tooltip 提供全文;`DataTable` 根据列最小宽度总和与 `ResizeObserver` 获得的容器实宽决定内部横向滚动。任何单元格文本必须在自身边界内裁切,不得覆盖相邻列。
- DataGrid 行操作:共享 `TableActionButton` 直接组合 HeroUI `Button`、`Dropdown`、`Tooltip` 与 Ant Design Icons,不兼容旧按钮 API。普通查看/编辑/日期/复制/锁定/更多使用 `tertiary` 中性正圆,明确关键的审批/提交使用 `secondary` 强调正圆,删除/驳回使用 `danger-soft` 危险正圆;32px 按钮、16px 图标、8px 间距并与表头左对齐,列宽按可见按钮数动态计算。外观状态由 HeroUI variant 与 `theme.css` 管理,布局类只保证圆心和 SVG 中心重合。
- HeroUI 危险语义统一由 `theme.css` 的鑫元红系 `--danger` 驱动:浅色为主红 `#9E1B1B`,深色为同色相增亮红;`danger-soft` 、Hover 和前景色保留 HeroUI 官方派生公式。删除、驳回、退出和失败状态不得在页面层写粉色、洋红或私有红色。
- Modal:HeroUI `Modal`/`AlertDialog` compound API,标题、正文和页脚上下结构,Portal 继承根主题。层级固定为 `Backdrop -> Modal.Dialog overlay -> nested secondary -> rare static tertiary`:Header/Body/Footer 透明承载;普通详情查看态用一个 `Surface secondary` 承载整组只读标签/值,字段本身不重复套 Card;对象预览或 Logo well 等真实第三层静态内容才使用 `tertiary`。进入编辑态后不保留整组查看 Surface,由 `secondary` 表单控件直接位于 Overlay。禁止用默认 L1 Card 在 Overlay 内表达嵌套边界,也禁止给每个只读字段套 Card 或用页面 CSS 补背景。内容型新增表单与详情统一使用 `4xl`(1088px);Modal 内 Input、TextArea、DateField.Group、Autocomplete 和多选控件在两种主题下统一使用 HeroUI `secondary`。提交约束仅在用户点击确认且校验失败后通过受控 `AlertDialog` 展示,不在表单中常驻。可编辑详情默认是查看态,Footer 右下的单一“编辑”主操作切换到整窗编辑,查看态仅通过右上关闭图标退出;草稿由弹窗统一持有,Footer 原位切换取消/保存并通过原更新服务一次提交,不允许字段级即时保存。
......@@ -31,6 +33,7 @@
- KPI:页面直接组合 HeroUI Pro `KPI` compound API,Card 根使用 `rc-card-surface rc-dashboard-card`,`KPI.Icon` 使用共享 `rc-metric-icon-surface`。共享九色 Token 解决 Pro 原生 `status` 只有三种时的无背景回退,浅底统一为 9% tone(neutral 8%)。`.rc-kpi-grid data-count` 负责 1–9 项栅格和无显式 `data-tone` 时的顺序唯一配色;业务可覆盖语义 tone,但同组不得重复图标或颜色。超过九项拆分模块,页面不得自定义色值、尺寸和背景。
- 图表栅格:共享 `.rc-chart-grid` 在 1280px 及以上使用等宽 `1fr / 1fr`,以下单列。计数边界是同一当前可见业务 Section/Tab Panel 内的同级图表,不是整个路由页面;可配对图表保持双列,`:only-child` 自动跨 `1 / -1` 通铺。隐藏区域和其他 Section 的图表不得让当前单图模块退化为左半宽。
- 页面布局:`rc-app-page` 统一承担响应式页边距;页面根、模块、网格、工具栏和列表分别使用 `rc-page-stack`、`rc-page-section`、`rc-page-grid`、`rc-page-toolbar`、`rc-list-stack`。这些类只管理布局几何,不选择或覆盖 HeroUI 内部 DOM。
- Navbar 通知入口:通知中心作为全局 Shell 动作,不进入 Sidebar 菜单。使用 HeroUI `Badge.Anchor + Badge + Button + Tooltip` 直接组合,复用原通知路由和未读数量服务;通知按钮在主题按钮左侧,0 条不显示 Badge,超过 99 条显示 `99+`。禁止实现通知 Popover、伪造未读数或改变通知页、请求与已读状态流。
- 列表工具栏:数据集标题单独位于透明列表区首行,下一行才是命令工具栏。搜索与列分组并存时,左侧顺序固定为 HeroUI `SearchField primary` 在前、HeroUI `ToggleButton` 分组控件在后,右侧为业务操作;主操作使用 `Button primary` 并最右,次操作使用 `Button secondary`。移动端搜索先独占整行,分组控件在下一行自然换行。列分组 ToggleButton 直接使用官方状态和 Ant Design 16px 图标,不重画胶囊背景:列正常展示时是默认灰色未选中态,列被收起时才是主题选中态;Tooltip/aria-label 根据当前状态只描述下一步“显示”或“隐藏”动作。只有 Surface/overlay 内的嵌套字段使用 `secondary` SearchField;`outline` 不作为次按钮默认映射。
- 模块 Card 控件:Card Header 使用单行“左侧标题 + 右侧控件”。并列内容视图用 HeroUI Tabs;分类层级、日期范围、统计口径等同一内容的参数用直接 HeroUI Autocomplete 或 HeroUI-native `XinyuanAutocomplete`,使用 `secondary` 并按内容紧凑定宽。参数变化只连接原状态和回调,不改变统计口径、请求或数据结构。
......
......@@ -16,9 +16,16 @@
| 整改处理 | `/problem_rectification_form` | Catalyst | HeroUI | 填写、复核、终审状态机 | 待浏览器验收 | 待验收 |
| 整改流程 | `/problem_rectification_flow_table` | 混合 | HeroUI Pro DataGrid | OA、通知、上传和审批状态机 | 待浏览器验收 | 待验收 |
| 详情与引用 | 全局弹窗 | Catalyst + 原生控件 | HeroUI Modal/ListBox | 字段权限、保存值和引用 ID | 待浏览器验收 | 待验收 |
| Grid `shortTitle` | 动态 Grid View | 视图配置 | HeroUI Pro DataGrid | 只隐藏标题展示,不改变标题数据与入口 | 待浏览器验收 | 待验收 |
| Detail `shortTitle` | 动态 Detail View | 视图配置 | HeroUI Modal | 隐藏记录标题/字段但保留功能标题和保存协议 | 待浏览器验收 | 待验收 |
| Subject View 权限 | Grid 与详情 | 多维表格配置 | View Tabs + 字段只读态 | 目标应用授权隔离、无权限不兜底 | 待浏览器验收 | 待验收 |
| 消息通知 | `/notifications` + Navbar | 平台通知接口 | HeroUI 页面与 Badge | 未读、已读、动态 action、base-aware 跳转 | 待浏览器验收 | 待验收 |
| 审计材料文件 | 允许编辑的 file 字段 | Record/Upload | Autocomplete + DropZone | 快照协议、保存后建档、失败重试不重复 | 待浏览器验收 | 待验收 |
## 验收门槛
- 五项新增功能按 [跨应用功能扩展规范](./xinyuan-cross-app-feature-contract.md) 的白名单分流;白名单外应用只能出现消息通知。
- 全部运行代码、示例和测试中的 Catalyst、Headless UI、旧适配器和可见原生控件引用为零;历史隔离目录不得进入构建或依赖图。
- Checkbox 单选、取消、全选、半选和禁用状态无 Context/slot 错误。
- Autocomplete、SearchField、ListBox、Modal、Tooltip、ActionBar 在深浅主题和移动端正确继承 Portal 主题。
......@@ -59,6 +66,7 @@
- 筛选页面内层级已统一为页面 `background` → HeroUI `Surface default` → HeroUI `secondary` 筛选框。权威主题已将 `--default` 映射到 `--surface-secondary`,修正原值与 `surface-tertiary` 过近造成的深灰错觉;外层 Surface 直接使用 `rounded-3xl p-6`,不叠加描边或 Card 阴影,内部 Autocomplete、DateField.Group 和普通 Input 在浅色/暗色中都使用 `secondary`。筛选 Autocomplete 和日期选择的真实 Popover 已取消 `surface-tertiary` 覆盖,与 Modal、Dropdown、Tooltip 等全部浮层统一使用 HeroUI 默认 `overlay`;Popover 内 SearchField 继续使用 `secondary` 作为浮层内控件。
- Surface 层级已固化到项目设计规范、UI 架构和共享 `xinyuan-design-system` skill:`background -> surface/default -> surface-secondary -> surface-tertiary` 只描述页面内嵌套,`overlay` 是独立的浮动/阻断维度,不属于第四层 Surface。旧“白色筛选 Card + 描边/阴影”和筛选弹层私有表面规则已移除。
- 问题清单“整改情况”列已提高到 420px(固定时 360px),使用两行换行和 HeroUI Tooltip 展示全文,避免内容越过单元格;所有 user 类型列表字段已改为 HeroUI `Avatar` + 姓名的紧凑身份展示,人员列至少 220px。
- 问题清单及共享工作台的长文本识别已扩展为“动态字段类型 + 字段语义 + 当前真实样本长度”三层策略;普通长文本列至少 360px并显示两行,整改情况至少 420px。共享 DataTable 不再按列数猜测宽表,而是比较动态列最小宽度总和与容器实宽后切换 HeroUI Pro DataGrid 内部横向滚动,视图配置调整后会自动重算,单元格文字不会跨列遮挡。
- 驾驶舱与整改分析的年度范围控件已去除重复“统计年份”可见标签,保留 `aria-label`,使用 144px 宽的 HeroUI Autocomplete `primary` 触发器;年份来源、默认值和切换回调未改变。
- 问题整改详情已调整为 HeroUI Modal 的 1088px 内容型宽度,28px inset、内部滚动和右上角全屏/关闭操作组;全屏图标不再落到标题下方,字段内容移除逐字段阴影 Card,改为紧凑两列标签/值网格。
- 问题整改详情已改为弹窗级查看/编辑状态:默认字段只读,单一“编辑”入口进入整窗编辑,Footer 统一“取消 / 保存”;取消恢复原记录,保存沿用原 `updateRecord` 和嵌套 payload 一次提交变更,视图禁用、锁定和只读字段不进入编辑。生产构建与静态状态检查已通过,真实点击与接口请求仍待浏览器验收。
......
# 鑫元跨应用五项功能扩展规范
本规范定义已经在“问题整改”完成验证、后续需要按范围同步到其它鑫元应用的五项功能。它是功能与组件架构合同,不是可以自由发挥的视觉参考。除本规范明确授权的新增能力外,目标应用原有 API、路由、鉴权、校验、保存回调和业务状态流必须保持不变。
## 1. 应用范围矩阵
以下六个应用实施全部五项功能:
- 问题整改
- 审计项目管理
- 警示案例库
- 廉洁从业管理
- 廉洁风险防控
- 党风廉政建设
六项名单以应用统一展示名称为准;批量迁移时应由用户或目标项目配置明确应用名称,不能仅凭目录名或相似代码推断。
| 功能 | 六个完整功能应用 | 其它鑫元应用 |
| --- | --- | --- |
| Grid `shortTitle` | 必须 | 禁止新增 |
| Detail `shortTitle` | 必须 | 禁止新增 |
| Subject View 权限 | 必须 | 禁止新增 |
| 消息通知 | 必须 | 必须 |
| 文件接入审计材料中心 | 必须 | 禁止新增 |
“其它鑫元应用”只增加消息通知。不得因为复用共享代码而把 `shortTitle`、View 权限或材料中心文件流程带入其业务页面。
## 2. Grid shortTitle
- `view.settings.shortTitle === true` 只隐藏 DataGrid 中的 `title` 展示列;`false` 或未配置时正常显示。
- 不删除、清空或改写记录标题,不改变标题参与的查询、关键词搜索、排序、筛选、权限、详情上下文或保存数据。
- 每次读取或切换动态 View 后重新解析配置,不缓存旧标题或旧字段常量;服务端调整 View 后页面必须随配置变化。
- 标题列原本承载的详情、状态或业务入口必须迁移到独立可见的上下文列或固定操作列,不能随标题列一起丢失。
- 上下文列属于表现层,不写入 `settings.columns`,不参与 Record 查询、排序或字段权限。
## 3. Detail shortTitle
- 详情使用当前激活的 `type = "detail"` View。该 View 的 `settings.shortTitle === true` 时,同时隐藏记录标题展示和字段区的 `title` 字段。
- Modal 的功能标题和可访问名称始终保留,不能生成无标题弹窗。
- 切换详情 View 后立即重新计算;`false` 或未配置时恢复记录标题和 `title` 字段。
- 隐藏只影响展示,不改变草稿、校验、保存 payload、详情查询或记录身份。
## 4. Subject View 权限
- Grid View 的 `settings.viewAuth` 只控制视图可见性;活动 Grid View 的 `settings.auth` 控制详情字段编辑能力;`settings.disableColumns` 对所有用户始终只读。
- 普通用户遇到缺失 `viewAuth` 的 View 或缺失字段 `auth` 的字段时默认拒绝。系统管理员、系统账号和目标应用自身的“管理”角色可以旁路 View/字段授权,但不能旁路 `disableColumns`。
- 仅从 `user.applications[]` 中 `name` 等于目标应用授权标识的单个应用项读取 `groups`、`roles` 和 `permissions`。不得复制问题整改的 `integrity_risk_manager`,不得合并顶层身份或其它应用授权。
- 授权类型只接受 `group`、`role`、`permission`;缺失或不支持的类型按“管理”角色规则处理。
- 从 Grid 打开详情时,活动 Grid View 是唯一字段编辑权限来源;Detail View 只负责字段编排、条件和 `shortTitle`,不得叠加第二套字段权限。
- 没有可访问 Grid View 时不创建兜底 View、不查询记录,并隐藏新增、批量和行操作。只有一个可访问 View 时保留其 key、查询和筛选,但隐藏单项 Tabs 轨道。
- View 的 restrictions、filters、排序、分页和请求参数继续按目标应用原协议执行。
## 5. 消息通知
该功能适用于所有鑫元应用。
- 使用独立 `/notifications` 页面,支持 `message / todo`、`全部 / 未读`、300ms 搜索防抖、固定每页 10 条、单条已读、全部已读、服务端动态 `actions` 和关联流程跳转。
- 未读筛选必须传服务端 `status: "unread"`;不能只过滤当前页。通知正文按纯文本渲染,不执行服务端 HTML。
- `GET /notice/customer/count` 的真实结果进入全局 Shell;通知更新事件和 60 秒轮询刷新同一计数。不得伪造数字。
- 通知入口不占 Sidebar 导航。Navbar 使用 HeroUI `Badge.Anchor + Badge + Button + Tooltip`,36px Bell 位于最右侧主题按钮左边;0 不显示 Badge,1–99 显示真实值,100 及以上显示 `99+`,Badge 可访问文本保留真实数量。
- 点击 Bell 进入目标应用原 `/notifications` 路由,不在 Navbar 复制通知 Popover。
- 站内链接必须适配目标应用自己的 `base/publicPath`;目标应用内路由去除自身 base 后交给路由器,其它应用和外部 HTTP(S) 链接新窗口打开。
- 保留现有鉴权、通知权限、已读协议、动态 action 名称、`notice_id` 请求体和错误处理。
## 6. 文件接入审计材料中心
该功能只适用于六个完整功能应用中允许编辑的 `file` 字段;若目标应用业务文档明确限定字段范围,以该范围为准。
- UI 使用 HeroUI Tabs 在“材料中心 / 本地上传”之间切换;材料选择使用 HeroUI Autocomplete,本地文件使用 HeroUI Pro `DropZone`。必须运行时读取 Subject 字段 `multiple`。
- 材料中心通过 `POST /record/query` 查询 `am_material` 最近 20 条,按当前用户 Record 权限返回,远程搜索 `metadata.material_title` 和 `metadata.file_asset.name`,正则关键词必须转义。
- 选择已有材料只把 `{name,type,size,url}` 文件快照写入业务草稿,不保存 `am_material` ID,不重新上传、不创建材料。
- 单值字段替换原值;多值字段追加并按 URL 去重,支持逐项删除。
- 本地文件选择后立即逐文件调用 `/upload`,规范化为 `{name,type,size,url}` 并保留在当前详情草稿;上传期间禁止保存和修改附件。成功项保留,失败项给出文件级汇总并可重试。
- 只有用户点击详情“保存”时,才为仍在草稿中的本地文件逐个创建 `am_material`。`source_app` 必须使用目标应用真实稳定标识,`uploader` 使用当前用户 ID,`uploaded_at` 使用 ISO 时间;禁止复制 `issue_rectification`。
- 所有待建档材料成功后才更新业务记录。部分建档成功或材料成功而业务更新失败时,当前编辑会话必须记住已建档项;再次保存只处理未建档项,不能产生重复材料。
- 取消编辑不创建材料主档;删除或替换业务附件不删除材料中心主档或底层文件,材料后续变化也不反向修改历史快照。
- 业务记录仍按目标字段既有协议保存单值对象或对象数组,不得改成材料 ID、引用字段或新数据结构。
## 7. HeroUI 架构边界
- 新页面和共享组件直接引用 `@heroui/react`、`@heroui-pro/react` 或 Pro 子路径;禁止 Catalyst、Headless UI、旧适配器和在旧组件内嵌 HeroUI。
- DataGrid、Modal、Tabs、Autocomplete、Badge、Tooltip 和 DropZone 使用官方 compound API、slot 和 React Aria Context。
- 可新建业务无关的 HeroUI 原生组合,但应用授权标识、`source_app`、Subject、字段路径、路由和保存事务必须由目标业务层传入,不能硬编码问题整改名称。
- 这些功能允许新增其明确需要的路由、请求和状态;不得借机覆盖整页、复制问题整改业务流程或修改其它业务。
## 8. 验收
- 六个完整功能应用验证 Grid/Detail `shortTitle` 的 true、false、缺失与动态切换。
- 验证系统管理员、目标应用管理角色、普通授权、无授权、无 `viewAuth`、字段 `auth` 和 `disableColumns`。
- 所有应用验证通知 0、1、99、100+,单条/全部已读、消息/待办、未读筛选、搜索、动态 action、站内外跳转和 Navbar 布局。
- 六个完整功能应用验证单值/多值附件、材料选择、本地上传、部分失败、取消、保存失败与无重复重试。
- 在 1440x900、1280x720、768x1024、390x844 的浅色/深色真实浏览器中验收,控制台无错误或 Context/slot 警告。
- 执行构建、`git diff --check`、鑫元审计脚本、旧组件扫描、HeroUI 依赖树和本机路径扫描。
# 鑫元问题整改系统设计规范
本文件是本项目新增功能和 UI 维护的执行标准。业务接口、参数、数据结构、路由、权限、校验、回调和状态流转不属于视觉迁移范围。
本文件是本项目新增功能和 UI 维护的执行标准。业务接口、参数、数据结构、路由、权限、校验、回调和状态流转不属于普通视觉迁移范围;下述跨应用五项功能是用户明确授权的窄范围功能扩展例外,例外之外的业务仍不得修改。
跨应用新增能力的范围矩阵、`shortTitle`、Subject View 权限、消息通知和审计材料中心文件事务统一遵循 [xinyuan-cross-app-feature-contract.md](./xinyuan-cross-app-feature-contract.md)。六个指定应用实施全部五项,其它鑫元应用只实施消息通知;不得越过白名单扩展功能。
## 1. 唯一技术基线
......@@ -59,6 +61,7 @@ Surface 用于表达页面内的包含关系,Overlay 用于表达浮动关系
- Sidebar、Navbar 使用 HeroUI Pro,Logo 浅色使用 `xy-logo-red.png`、暗色使用 `xy-logo-white.png`。
- `src/design-system/shell.css` 只负责应用壳布局、导航定位、折叠、Logo 和响应式,不得延伸到列表、筛选、Tabs、Card、DataGrid 或弹窗正文。
- 侧边栏只有一个导航组时,不在菜单列表上方重复显示应用名称;组名继续作为 `Sidebar.Menu` 的可访问性标签。只有存在两个及以上确需区分的业务分组时才显示 `Sidebar.GroupLabel`。
- 通知中心不占用 Sidebar 导航层级。存在真实通知功能时,在 Navbar 右侧操作组中使用 HeroUI `Badge.Anchor + Badge + Button + Tooltip`,通知按钮位于主题切换左侧,主题切换始终最右;按钮沿用 36px 中性圆形 Navbar 动作规范,图标为 16px Ant Design Bell。未读数只能来自真实接口,0 时不显示角标,超过 99 显示 `99+`,点击仍进入应用原通知路由。
- 应用切换使用 HeroUI Modal、Card、Surface、Avatar、Chip、Skeleton、Alert 和 EmptyState。应用 Card 位于 Overlay 内,使用 `secondary`;36px Logo 区域是 Card 内的静态第三层,使用 `Surface tertiary` 承载 HeroUI `Avatar soft`。真实图片使用 `object-contain`,无图或加载失败时使用同尺寸的 `accent` 首字回退,不允许裸文字、拉伸图片或尺寸跳变。
- 该 Logo well 是跨应用固定规范:`size-9`、`shrink-0`、`rounded-[var(--rc-radius-control)]`、居中且裁切稳定,内部 Avatar 铺满、使用相同圆角并保持透明,图片使用 `object-contain p-1.5`,Skeleton 也使用 `size-9`。浅色、深色下必须分别验证真实不透明图、透明/emoji-like 图和加载失败回退,三者不得改变标题起点。
......@@ -82,7 +85,8 @@ Surface 用于表达页面内的包含关系,Overlay 用于表达浮动关系
- 上述 class 只约束宽度、flex 收缩和换行;不得覆盖 `.tabs__list-container`、`.tabs__list`、`.tabs__tab`、`.tabs__indicator` 的背景、边框、圆角、颜色、阴影、尺寸、Hover 或选中态,也不得把文档示例 CSS 复制为项目私有 Tabs 皮肤。
- 结构化筛选区直接使用 HeroUI `Surface`、Autocomplete、SearchField 和 Button;不用 Card 代替 Surface,不再套 `Card.Content`。关键词搜索和选择器弹层搜索的图标都放在官方 `SearchField.SearchIcon` slot。
- 筛选页面内层级固定为页面 `background` → `Surface default / bg-surface` → `secondary / --default = --surface-secondary` 筛选框。外层 Surface 使用官方基础示例的 `rounded-3xl p-6`,不额外添加描边、Card 阴影或页面私有背景;Surface 内的 Autocomplete、普通 Input 和真实 `DateField.Group` 在浅色/暗色中统一使用 HeroUI `secondary`。筛选 Autocomplete 与 DatePicker 的真实 Popover 脱离页面层级,直接使用 HeroUI 默认 `overlay`;其内部官方 SearchField 继续继承 `secondary` variant。不得为筛选弹层覆盖背景、前景色、圆角或阴影。
- DataGrid 长文本先按业务重要性扩宽,再允许最多两行自然换行,超出内容通过 HeroUI Tooltip 展示全文;“整改情况”基准宽度为 420px,作为左侧固定列时限制为 360px,任何文字都不得跨越单元格遮挡相邻字段。
- DataGrid 长文本必须由共享层自适应识别:优先读取动态视图字段类型,其次识别问题描述、整改情况、措施、要求、原因、意见、备注、说明等字段语义,最后以当前真实数据样本长度兜底。一般长文本列基准最小宽度为 360px,“整改情况”为 420px;作为左侧固定列时分别限制为 320px/360px。单元格最多显示两行并在自身边界内裁切,超出内容通过 HeroUI Tooltip 展示全文,任何文字都不得跨越单元格遮挡相邻字段。
- DataGrid 是否进入宽表模式不得只按列数判断。共享层必须比较所有动态列的可读最小宽度总和与 DataGrid 当前容器实宽;超过时保留像素最小宽度并只在 DataGrid 内横向滚动,未超过时才用 `1fr` 铺满。开发修改视图列、字段标题或数据内容后,列宽、换行和滚动策略必须自动重算,不得写死旧表头。
- DataGrid 的用户类型字段直接使用 HeroUI `Avatar`:列表使用 `size="sm"`、`variant="soft"`,有头像资源时显示图片,无资源时显示姓名首字回退;头像与姓名水平居中,人员列基准宽度不小于 220px,不使用页面私有渐变、边框或旧 Avatar 组件。
- 驾驶舱、分析页的单一年度范围使用紧凑 HeroUI Autocomplete,选择值本身表达“全部年份”或具体年份时不再重复显示外置“统计年份”标签;触发器使用官方 `primary` 外观和内容宽度,不添加页面私有胶囊、阴影或边框。
- 内容型详情弹窗使用 HeroUI Modal `4xl` 基准宽度 1088px、内部滚动和 28px 内容 inset;全屏与关闭图标在标题栏右上组成同一 24px 操作组并垂直居中。详情字段使用无框标签/值网格,不为每个字段重复创建带阴影 Card。
......
......@@ -20,6 +20,8 @@ function getInitialTheme() {
export default function AppShell({
children,
notificationCount,
notificationPath,
open,
onOpenChange,
title,
......@@ -52,6 +54,10 @@ export default function AppShell({
<DashboardSidebar {...sidebarProps} />
<Sidebar.Main className="app-main-glass rc-app-main">
<DashboardNavbar
notificationCount={notificationCount}
onOpenNotifications={
notificationPath ? () => history.push(notificationPath) : undefined
}
theme={theme}
title={title}
onThemeChange={setTheme}
......
import { Navbar } from '@heroui-pro/react/navbar';
import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar';
import { Button, Tooltip } from '@heroui/react';
import { Moon, SidebarPanel, Sun } from './AppIcons';
import { Badge, Button, Tooltip } from '@heroui/react';
import { Bell, Moon, SidebarPanel, Sun } from './AppIcons';
export default function DashboardNavbar({ onThemeChange, theme, title }) {
export default function DashboardNavbar({
notificationCount = 0,
onOpenNotifications,
onThemeChange,
theme,
title,
}) {
const { isMobile, isOpen } = useSidebar();
const isDark = theme === 'dark';
const themeLabel = isDark ? '切换至浅色模式' : '切换至深色模式';
const normalizedNotificationCount = Math.max(0, Number(notificationCount) || 0);
return (
<Navbar className="app-navbar rc-navbar" maxWidth="full">
......@@ -20,6 +27,34 @@ export default function DashboardNavbar({ onThemeChange, theme, title }) {
<h1 className="app-navbar-page-title rc-navbar-title">{title}</h1>
<Navbar.Spacer />
<div className="rc-navbar-actions">
{onOpenNotifications ? (
<Tooltip delay={0}>
<Badge.Anchor>
<Button
isIconOnly
aria-label="通知消息"
className="app-navbar-icon-button rc-navbar-icon-button"
size="sm"
variant="ghost"
onPress={onOpenNotifications}
>
<Bell size={16} />
</Button>
{normalizedNotificationCount > 0 ? (
<Badge
aria-label={`${normalizedNotificationCount}条未读通知`}
color="danger"
size="sm"
>
{normalizedNotificationCount > 99
? '99+'
: normalizedNotificationCount}
</Badge>
) : null}
</Badge.Anchor>
<Tooltip.Content>通知消息</Tooltip.Content>
</Tooltip>
) : null}
<Tooltip delay={0}>
<Button
isIconOnly
......
......@@ -10,6 +10,7 @@ import {
Spinner,
} from '@heroui/react';
import { Tooltip } from '@heroui/react';
import { useLayoutEffect, useRef, useState } from 'react';
function normalizeDataTableEmptyTitle(title) {
const text = String(title || '').trim();
......@@ -78,6 +79,15 @@ function resolveColumnWidth(width) {
return undefined;
}
function resolveColumnMinWidth(column) {
const isActions = column.isActions || column.key === 'actions';
const configuredWidth = resolveColumnWidth(column.width);
const numericWidth =
typeof configuredWidth === 'number' ? configuredWidth : undefined;
return Number(column.minWidth) || numericWidth || (isActions ? 104 : 128);
}
export function DataTable({
ariaLabel = '数据表格',
columns,
......@@ -95,9 +105,37 @@ export function DataTable({
onSortChange,
renderSelectionActions,
}) {
const containerRef = useRef(null);
const [containerWidth, setContainerWidth] = useState(0);
const selectionEnabled = Boolean(selectedRowKeys && onSelectedRowKeysChange);
const selectedKeys = selectionEnabled ? new Set(selectedRowKeys) : undefined;
const wide = isWide ?? columns.length > 6;
const minimumContentWidth = columns.reduce(
(total, column) => total + resolveColumnMinWidth(column),
selectionEnabled ? 40 : 0,
);
const measuredWide =
containerWidth > 0
? minimumContentWidth > containerWidth + 1
: columns.length > 6;
const wide = isWide === true || measuredWide;
useLayoutEffect(() => {
const container = containerRef.current;
if (!container) {
return undefined;
}
const updateWidth = () => setContainerWidth(container.clientWidth);
updateWidth();
if (typeof ResizeObserver === 'undefined') {
return undefined;
}
const observer = new ResizeObserver(updateWidth);
observer.observe(container);
return () => observer.disconnect();
}, []);
const firstBusinessColumnIndex = columns.findIndex(
(column) =>
!column.isSelection &&
......@@ -109,15 +147,10 @@ export function DataTable({
const isPrimary =
column.isPrimary ?? (!isActions && index === firstBusinessColumnIndex);
const configuredWidth = resolveColumnWidth(column.width);
const numericWidth =
typeof configuredWidth === 'number' ? configuredWidth : undefined;
const pinned = wide
? column.pin || (isActions ? 'end' : undefined)
: undefined;
const minWidth =
column.minWidth ||
numericWidth ||
(isActions ? 104 : pinned ? 220 : 128);
const minWidth = resolveColumnMinWidth(column);
const width =
!wide && isPrimary
? typeof configuredWidth === 'string' && configuredWidth.endsWith('fr')
......@@ -152,6 +185,7 @@ export function DataTable({
headerClassName: column.headerClassName,
cellClassName: column.cellClassName,
allowsResizing: true,
maxWidth: resolveColumnWidth(column.maxWidth),
minWidth,
width,
pinned,
......@@ -178,7 +212,10 @@ export function DataTable({
};
return (
<div className={['relative min-w-0 w-full', className].filter(Boolean).join(' ')}>
<div
ref={containerRef}
className={['relative min-w-0 w-full', className].filter(Boolean).join(' ')}
>
<div className="relative min-w-0 w-full">
{isLoading && rows.length > 0 ? (
<div
......@@ -272,20 +309,20 @@ export function DataTableText({
maxLines = 1,
}) {
const fullText = String(text ?? children ?? '').trim();
const multilineClassName =
const textClassName =
maxLines === 2
? 'overflow-hidden whitespace-normal break-words [display:-webkit-box] [-webkit-box-orient:vertical] [-webkit-line-clamp:2] [overflow-wrap:anywhere]'
: '';
? 'w-full min-w-0 max-w-full overflow-hidden whitespace-normal break-words [display:-webkit-box] [-webkit-box-orient:vertical] [-webkit-line-clamp:2] [overflow-wrap:anywhere]'
: 'block w-full min-w-0 max-w-full overflow-hidden truncate';
const trigger = showTooltip ? (
<DataTableTextTooltipTrigger
className={[multilineClassName, className].filter(Boolean).join(' ')}
className={[textClassName, className].filter(Boolean).join(' ')}
text={fullText}
truncate={maxLines === 1}
truncate={false}
>
{children ?? (fullText || '-')}
</DataTableTextTooltipTrigger>
) : (
<span className={['block min-w-0 truncate', className].filter(Boolean).join(' ')}>
<span className={[textClassName, className].filter(Boolean).join(' ')}>
{children ?? (fullText || '-')}
</span>
);
......@@ -300,9 +337,9 @@ export function DataTableText({
return (
<DataTableTextTooltipTrigger
className={[multilineClassName, className].filter(Boolean).join(' ')}
className={[textClassName, className].filter(Boolean).join(' ')}
text={fullText}
truncate={maxLines === 1}
truncate={false}
>
{children ?? (fullText || '-')}
</DataTableTextTooltipTrigger>
......
import {
DataTable,
DataTablePagination,
DataTableText,
} from '@/components/DataTable';
import { KPI } from '@heroui-pro/react/kpi';
import {
......@@ -289,16 +290,75 @@ function defaultGetRecordLabel(record) {
function defaultRenderCell(record, column) {
const sourceRecord = record?.raw || record;
const value = getFieldValue(sourceRecord, column.field);
const text = formatFieldValue(value, column.fieldDef) || '-';
return (
<div className="truncate text-sm text-foreground">
{formatFieldValue(value, column.fieldDef) || '-'}
</div>
<DataTableText
className="text-sm text-foreground"
maxLines={column.dataGridPresentation?.maxLines || 1}
showTooltip={Boolean(column.dataGridPresentation?.showTooltip)}
text={text}
/>
);
}
const LONG_TEXT_FIELD_TYPES = new Set([
'html',
'long_text',
'markdown',
'rich_text',
'textarea',
]);
const SHORT_VALUE_FIELD_TYPES = new Set([
'boolean',
'date',
'date_time',
'datetime',
'file',
'number',
'reference',
'select',
'user',
]);
function resolveColumnDataGridPresentation(column, records = []) {
const label = String(column?.label || '');
const field = String(column?.field || '').toLowerCase();
const fieldType = String(column?.fieldDef?.type || '').toLowerCase();
const isTextLike = !fieldType || !SHORT_VALUE_FIELD_TYPES.has(fieldType);
const hasLongTextSemantics =
/(problem_brief|description|rectification_situation|rectification_measure|requirement|delay_reason|remark|comment|opinion|explanation|content)/.test(field) ||
/问题简述|问题描述|具体问题|整改情况|整改措施|整改要求|延期事由|备注|说明|意见|内容/.test(label);
const longestSampleLength = isTextLike
? records.slice(0, 20).reduce((longest, record) => {
const sourceRecord = record?.raw || record;
const value = getFieldValue(sourceRecord, column.field);
const text = normalizeText(formatFieldValue(value, column.fieldDef));
return Math.max(longest, Array.from(text).length);
}, 0)
: 0;
const isLongText =
LONG_TEXT_FIELD_TYPES.has(fieldType) ||
hasLongTextSemantics ||
longestSampleLength > 36;
const isRectificationSituation =
field.includes('rectification_situation') || /整改情况/.test(label);
return {
isLongText,
maxLines: isLongText ? 2 : 1,
minWidth: isRectificationSituation ? 420 : isLongText ? 360 : undefined,
showTooltip: isLongText,
};
}
function getReadableColumnWidth(column) {
const configuredWidth = Number(column?.width) || 180;
const label = String(column?.label || '');
const presentationWidth = column?.dataGridPresentation?.minWidth;
if (presentationWidth) {
return Math.max(configuredWidth, presentationWidth);
}
if (/整改情况/.test(label)) {
return Math.max(configuredWidth, 420);
......@@ -329,6 +389,12 @@ function getWorkbenchColumnWidth(column, stickyMeta) {
}
const label = String(column?.label || '');
if (column?.dataGridPresentation?.isLongText) {
return Math.min(
readableWidth,
/整改情况/.test(label) ? 360 : 320,
);
}
if (/整改情况/.test(label)) {
return Math.min(readableWidth, 360);
}
......@@ -495,17 +561,25 @@ export default function RecordWorkbench({
const loading = Boolean(states?.loading);
const error = states?.error || '';
const totalRecords = pagination?.totalRecords ?? records.length;
const presentationColumns = useMemo(
() =>
columns.map((column) => ({
...column,
dataGridPresentation: resolveColumnDataGridPresentation(column, records),
})),
[columns, records],
);
const columnGroupMeta = useMemo(
() => buildColumnGroupMeta(columnGroups, columns),
[columnGroups, columns],
() => buildColumnGroupMeta(columnGroups, presentationColumns),
[columnGroups, presentationColumns],
);
const visibleColumns = useMemo(
() =>
columns.filter((column) => {
presentationColumns.filter((column) => {
const groupId = columnGroupMeta.fieldToGroup[column.field];
return !groupId || !collapsedColumnGroups[groupId];
}),
[columns, columnGroupMeta.fieldToGroup, collapsedColumnGroups],
[presentationColumns, columnGroupMeta.fieldToGroup, collapsedColumnGroups],
);
const orderedVisibleColumns = useMemo(
() =>
......@@ -1201,7 +1275,6 @@ export default function RecordWorkbench({
disabledRowKeys={disabledDataGridRowKeys}
emptyTitle={states?.emptyTitle || '暂无数据'}
emptyDescription={states?.emptyDescription}
isWide={dataGridColumns.length > 6}
renderSelectionActions={() => (
<>
<span className="text-sm">已选择记录</span>
......
......@@ -6,7 +6,6 @@ import AppShell from '@/components/AppShell';
import {
Archive,
BarChart,
Bell,
CheckCircleIcon,
Project,
} from '@/components/AppIcons';
......@@ -45,12 +44,6 @@ const MENU_GROUPS = [
path: '/issue_rectification_archive',
icon: Archive,
},
{
name: '通知消息',
path: '/notifications',
icon: Bell,
showsNotificationCount: true,
},
],
},
];
......@@ -63,20 +56,6 @@ export default function ProblemRectificationLayout() {
const [isApplicationDialogOpen, setIsApplicationDialogOpen] = useState(false);
const [unreadNotificationCount, setUnreadNotificationCount] = useState(0);
const menuGroups = useMemo(
() =>
MENU_GROUPS.map((group) => ({
...group,
items: group.items.map((item) => ({
...item,
count: item.showsNotificationCount
? unreadNotificationCount
: undefined,
})),
})),
[unreadNotificationCount],
);
const currentTitle = useMemo(
() => ROUTE_TITLES[location.pathname] || APPLICATION_DISPLAY_NAME,
[location.pathname],
......@@ -112,7 +91,9 @@ export default function ProblemRectificationLayout() {
return (
<>
<AppShell
menuGroups={menuGroups}
menuGroups={MENU_GROUPS}
notificationCount={unreadNotificationCount}
notificationPath="/notifications"
onLogout={userInfo?.logout}
onOpenApplicationSwitcher={() => setIsApplicationDialogOpen(true)}
open={sidebarOpen}
......
......@@ -650,7 +650,7 @@ function FieldValuePreview({ column, record }) {
return (
<DataTableText
className="text-sm leading-6 text-foreground"
maxLines={2}
maxLines={column.dataGridPresentation?.maxLines || 2}
showTooltip
text={text}
>
......@@ -694,10 +694,16 @@ function FieldValuePreview({ column, record }) {
}
const text = getRecordCellText(record, column);
const isLongText = ['long_text', 'rich_text'].includes(column.fieldDef?.type);
const isLongText =
column.dataGridPresentation?.isLongText ||
['long_text', 'rich_text'].includes(column.fieldDef?.type);
return (
<DataTableText text={text} showTooltip={isLongText}>
<DataTableText
maxLines={isLongText ? 2 : 1}
text={text}
showTooltip={isLongText}
>
{text}
</DataTableText>
);
......
......@@ -964,7 +964,7 @@ const result = await queryUsers(0, 20, {
### 17.1 功能入口
“通知消息”作为问题整改应用侧边栏一级入口,路由为 `/notifications`。侧边栏通过 `GET /notice/customer/count` 展示当前用户未读总数,超过 99 条显示 `99+`;未读数为 0 时不展示角标。页面不在顶部导航重复提供通知弹窗。
“通知消息”作为问题整改应用 Navbar 全局入口,路由仍为 `/notifications`,不占用侧边栏导航层级。Navbar 通过 `GET /notice/customer/count` 展示当前用户未读总数,超过 99 条显示 `99+`;未读数为 0 时不展示角标。入口仅负责进入通知页面,不在顶部导航重复提供通知弹窗。
### 17.2 查询与筛选
......@@ -979,7 +979,7 @@ const result = await queryUsers(0, 20, {
- 通知的 `data.logs` 中存在当前用户 `_id` 时视为已读;`message` 显示“已读 / 未读”,`todo` 显示“已完成 / 待处理”。
- “标为已读”调用 `PUT /notice/customer/read` 并传通知 `name`。
- “全部已读”调用同一接口但不传 `name`,处理当前用户全部通知;未读总数为 0 时按钮禁用。
- 待办按钮完全由通知 `actions` 配置生成,执行时只提交 `{notice_id: 通知 _id}`。操作期间禁用当前通知的重复操作,成功后刷新列表和侧边栏角标,失败时保留原状态并提示错误。
- 待办按钮完全由通知 `actions` 配置生成,执行时只提交 `{notice_id: 通知 _id}`。操作期间禁用当前通知的重复操作,成功后刷新列表和 Navbar 角标,失败时保留原状态并提示错误。
### 17.4 进入关联流程
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment