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

feat: align audit manager with xinyuan ui baseline

parent 2a30208c
......@@ -54,7 +54,7 @@
### 颜色、图标与暗色主题
- 源码中存在蓝、sky、rose、pink、渐变和页面私有品牌色;主色未统一为 `#9E1B1B`。
- 源码中存在蓝、sky、rose、pink、渐变和页面私有品牌色;专项优化后主色统一为 `#DA241A`,历史问题记录保留用于追踪。
- 同时存在 Heroicons 和局部自绘图标,缺少 Ant Design Icons 适配层。
- 没有根级暗色主题,Portal 中的 Dropdown、Popover、Modal、Tooltip 和上传面板无法保证一致暗色表面。
- 当前活动路由未渲染数据图表;图表规范作为后续功能的强制合同写入设计文档。
......
......@@ -5,7 +5,7 @@
## 1. 品牌与技术基线
- 应用显示名:`监察稽核管理`。登录、Sidebar、Navbar、应用切换当前项和浏览器标题必须一致。
- 主色:`#9E1B1B`。选中、焦点、Checkbox、日期、Pagination 和信息态统一使用品牌 Token。
- 主色:`#DA241A`。选中、焦点、Checkbox、日期、Pagination 和信息态统一使用品牌 Token;HeroUI 的 `--accent`、显式 `--primary-*` 色阶和 hover/pressed 派生均不得切换到默认蓝紫主题。
- 浅色 Logo 使用 `src/assets/identity/xy-logo-red.png`;暗色 Logo 使用 `xy-logo-white.png`。不得滤镜、反色、加底板或替换为 RSM 标识。
- 基础组件使用 `@heroui/react`,高级导航、DataGrid、DropZone、ActionBar 和 EmptyState 使用本仓独立正式安装的 `@heroui-pro/react`。
- 图标只从 `src/components/AppIcons.jsx` 导出,底层统一使用 Ant Design Icons。
......@@ -13,7 +13,7 @@
## 2. Token 与尺寸
- 页面画布:浅色 `#F5F5F5`,暗色 `#18181B`。
- 页面画布:使用 HeroUI `background` Token,暗色仅由固定 Shell 规则控制。
- 页面左右 inset:桌面 68px,移动端 72px;真实滚动容器 `.rc-app-page` 直接持有该边距。
- 工作台模块和 KPI Card:24px 圆角;DataGrid:20px;普通控件:8px;Popover:12px;Modal:24px。
- 页面标题:20px/600/28px;模块标题:16px/600;普通正文:14px/400。
......@@ -31,7 +31,7 @@
- 登录采用居中认证 Card。花形 Logo 约 36px,标题约 32px/600;不重复显示“登录”标题。
- 账号、密码、验证码可见高度统一为 40px,字段组可见间距 24px,提交按钮上方保留 24px。
- 登录字段有值或自动填充时使用 `#fbf1f1`,Hover 使用 `#faeaea`,Focus 使用鑫元红描边和浅红焦点环。
- 应用切换器固定为居中的紧凑两列 HeroUI Modal,移动端一列;标题“应用切换”、副标题“选择你可访问的应用”。分组与成员顺序读取站点 `site.group`,组标题显示真实应用数量,空组隐藏,未分组可访问项归入“其他应用”,无配置时回退“全部应用”;最多一个“当前”标签,不显示权限标签或外链装饰。
- 应用切换器固定为居中的紧凑两列 HeroUI Modal,移动端一列;保留 `aria-label="应用切换"` 作为无障碍名称,不显示可见标题、副标题或关闭图标,仅保留搜索字段。面板背景跟随页面画布;分组与成员顺序读取站点 `site.group`,组标题显示真实应用数量,空组隐藏,未分组可访问项归入“其他应用”,无配置时回退“全部应用”;最多一个“当前”标签,不显示权限标签或外链装饰。
## 4. 页面、筛选与 Tab
......@@ -52,6 +52,7 @@
- 多选动作仅显示在当前视口底部居中的 ActionBar,右侧使用关闭图标清除选择。
- 业务列表沿用各模块既有分页口径;通知列表固定每页 10 条且不显示页容量选择器。Pagination 距离 DataGrid 约 8px。
- 成功反馈使用顶部居中的 3-4 秒 Toast,不得用页面流 Alert 推挤筛选或表格。
- 图表和 KPI 使用独立的低饱和色板:品牌红、暖棕、灰紫、蓝灰依次承载系列,不使用绿色、荧光色或彩虹式相邻颜色;单系列固定使用 `#DA241A`。
## 6. 表单、上传与弹窗
......
......@@ -17,6 +17,8 @@
-> theme.css(不可变)+ tokens.css(别名/布局扩展)
```
共享 Navbar 支持可选的 breadcrumb header 状态。详情页面通过 `useAppShellHeader` 在数据加载完成后提供父级 HeroUI `Breadcrumbs` 与通用详情标题;真实记录标题留在内容区并与上下文操作同行。Shell 在路由标题变化时清理该覆盖状态,避免跨路由残留。该通道只改变标题呈现,不改变数据请求、路由或业务回调。
允许共享 AppShell、DataGrid composition、Autocomplete reference field、DropZone field、Application switcher 和确认弹窗,但它们必须暴露当前 HeroUI selection/value/event 合同。禁止兼容 Catalyst props、DOM event、class hook 或旧 DOM 结构。
## 组件决策
......@@ -24,12 +26,13 @@
- Shell:Pro Sidebar.Provider/Sidebar/Main/Mobile + Pro Navbar。
- 表单:HeroUI TextField/Input/TextArea/Checkbox/Autocomplete/DatePicker。
- 多选:共享 Autocomplete composition 在 `Autocomplete.Value` 内使用 HeroUI `TagGroup + Tag + Tag.RemoveButton`;根 `selectedKeys` 是唯一选择状态,移除操作回写同一个 `onSelectionChange`,业务页面只负责值协议映射。
- 单选和多选选择器都在 `Autocomplete.Trigger` 内使用官方 `Autocomplete.ClearButton`;单选清除回写原字段空值,多选既可逐项移除 Tag 也可清除全部,禁止仅清理内部 ListBox 状态。
- 列表:Pro DataGrid 原生 selection/sort/fixed columns + HeroUI Pagination + Pro ActionBar/EmptyState。
- 弹层:HeroUI Modal/AlertDialog/Dropdown/Tooltip,根主题覆盖 body Portal。
- 通知:页面直接组合 HeroUI Tabs/SearchField/Card/Chip/Badge/Tooltip 与 Pro EmptyState;业务 service 复用 `DataService` 的 `/notice/customer/*` 接口,Shell 只持有真实未读计数和 60 秒刷新事件,不在 Sidebar 建立第二入口。
- 应用切换:共享 `ApplicationSwitcherDialog` 直接组合 HeroUI Modal/Card/Surface/Avatar/Chip 与 Pro EmptyState;`SiteContext.siteInfo.group` 只负责动态分组与顺序,`UserContext.applications` 和 `/application/info` 继续负责访问集合与详情。授权域 `audit_manager` 规范化为唯一当前项,并导航到部署基路径 `/audit_manager_new/`。
- 应用切换:共享 `ApplicationSwitcherDialog` 直接组合 HeroUI Modal、Pro ItemCardGroup/ItemCard、Surface/Avatar/Chip 与 Pro EmptyState;Modal 仅保留无障碍名称和搜索字段,不显示重复标题/关闭图标,面板使用页面 `background`。`SiteContext.siteInfo.group` 只负责动态分组与顺序并在跨组时去重,`UserContext.applications` 和 `/application/info` 继续负责访问集合与详情。授权域 `audit_manager` 规范化为唯一当前项,并导航到部署基路径 `/audit_manager_new/`。
- 上传:Pro DropZone,页面边界接收真实 File。
- KPI/图表:仅业务存在时使用 Pro KPI/PieChart/BarChart/ComposedChart/ChartTooltip。
- KPI/图表:仅业务存在时使用 Pro KPI/PieChart/BarChart/ComposedChart/ChartTooltip;当前活动路由无真实图表,后续图表必须使用 `--xinyuan-chart-*` 低饱和 token。
- 报告视图:一级 HeroUI Tabs 切换 current/history,二级内容宽度 Tabs 以稳定项目 ID 切换报告快照;当前项目只挂载工具栏和 DataGrid,不重复 Tabs 已表达的项目标题,整体进度由 KPI 承担。项目与控制点仍携带原数组索引进入现有更新/确认回调。
- 当前报告搜索由 `AuditReportContent` 放在二级 Tabs 下方,并与当前项目确认、备注保存组成标准响应式列表工具栏;它继续使用父页面原受控 keyword 与保存回调。
- 报告附件单元格由 HeroUI `Chip/Link` 与 Pro `DropZone` 直接组合,不创建 Card/Surface 包装层;上传、追加和删除仍进入原 `onUpdate(processIndex, pointIndex, patch)` 协议。
......@@ -53,3 +56,6 @@ UI 迁移不得改变 `record-api.md` 和 `subjectsDefinition.md` 的 URL、meth
## 完成定义
活动路由旧 UI 引用与私有视觉 CSS 为零;依赖树单一;所有 Pro 子路径可用;构建、diff check、技能审计脚本通过;双应用四视口双主题的打开态和 console 均有证据。任何一项未完成时不声明迁移完成。当前代码与登录页门禁已通过,但登录后浏览器证据因缺少有效真实登录态尚未完成。
# 2026-09-02 架构同步
共享层固定为 `AppShell -> Sidebar.Provider -> Sidebar + Sidebar.Main/Navbar -> page scroll`。业务列表使用 `DataTable`(HeroUI Pro DataGrid)和 `DataTablePagination`,弹窗使用 HeroUI Modal/AlertDialog,引用/多选使用 `XinyuanAutocomplete`。流程监控业务仅替换呈现层,保留 `processMonitorService` 的 Subject、请求参数、保存回调和删除时机。
......@@ -35,7 +35,7 @@
- [x] 并列报告项目整合为内容宽度二级 HeroUI Tabs,单项目时隐藏轨道;搜索只过滤当前项目列表。
- [x] 当前报告搜索位于二级 Tabs 下方的标准工具栏,移动端独占一行;项目区不重复 Tabs 标题和 KPI 进度。
- [x] 报告附件内容使用紧凑 HeroUI Chip/Link,DataGrid 单元格内无 Card/Surface 或页面私有外观。
- [x] 应用切换按站点配置动态分组,移动一列/桌面两列,使用 HeroUI 原生 Card/Surface/Avatar 并保持唯一当前态。
- [x] 应用切换按站点配置动态分组并去重,移动一列/桌面两列,使用 HeroUI Pro ItemCardGroup/ItemCard + Surface/Avatar,并保持唯一当前态。
- [ ] 四视口、浅色/暗色、目标/基准同时打开完成(登录完成,鉴权页面待有效登录态)。
- [ ] 所有可达 popup、Modal、Tooltip、Dropdown、Sidebar 状态检查完成。
- [x] `npm run build` 与 `git diff --check` 通过;`audit_project.sh` 退出码 0,并报告非活动遗留。
......@@ -48,3 +48,11 @@
## 非活动遗留
`src/pages/process_monitor`、`src/pages/RecordDetailDrawer`、`src/components/record-workbench` 不在当前路由树;排除 Catalyst Kit 自身目录后,共有 7 个存量源码文件仍含 Catalyst/Headless 引用。不能把这些目录误报为已迁移。
# 本轮迁移清单(2026-09-02)
- [x] 权威 theme.css / tokens.css / shell.css 与审计项目管理同步
- [x] 固定深色 Sidebar、浅色内容区、HeroUI Pro Navbar.Trigger、应用切换 Modal
- [x] 共享 DataTable 增强固定列、sticky/横向滚动、EmptyState、ActionBar、20/50/100 页容量接口
- [x] `process_monitor` 页面移除 Catalyst、Headless UI、Heroicons 和私有渐变卡片表格
- [x] API、Subject、路由、权限、校验、保存回调和请求时机未改动
- [ ] `RecordDetailDrawer`、`record-workbench` 仍为未挂载历史存量,恢复路由前继续迁移
# 监察稽核管理鑫元设计系统
唯一实现基准为“问题整改”应用。以下合同适用于活动路由及后续新增页面,不允许页面私有外观覆盖 HeroUI/HeroUI Pro。
唯一实现基准为“审计项目管理”应用。以下合同适用于活动路由及后续新增页面,不允许页面私有外观覆盖 HeroUI/HeroUI Pro。
## 主题与品牌
- 应用显示名统一为“监察稽核管理”。
- 浅色/暗色 Logo 分别使用 `src/assets/identity/xy-logo-red.png` 与 `xy-logo-white.png`。
- 权威 `src/design-system/theme.css` 必须与技能资产逐字节一致;加载顺序为 Tailwind、HeroUI、HeroUI Pro、权威主题、鑫元 Tokens。
- 品牌主色 `#9E1B1B` 由主题 Token 提供。页面禁止硬编码品牌/表面/状态颜色、渐变、阴影和组件状态。
- `src/design-system/theme.css` 是本项目 HeroUI 主题权威;加载顺序为 Tailwind、HeroUI、HeroUI Pro、主题、鑫元 Tokens。
- 品牌主色固定为 `#DA241A`,由 HeroUI `--accent` 与显式 `--primary`、`--primary-50` 至 `--primary-950` 色阶 Token 提供;同时映射 `--color-primary-*` 供 Tailwind 集成使用。primary hover/pressed 使用 `#C91F17`/`#B51B14` 深色阶,focus 固定为品牌红。页面禁止硬编码品牌/表面/状态颜色、渐变、阴影和组件状态。
- Surface 层级固定为 L0 background、L1 surface、L2 surface-secondary、稀有 L3 surface-tertiary;Portal 使用 overlay,不把 Card 当 Surface。
## Shell
- HeroUI Pro Sidebar 宽 240px,`collapsible="offcanvas"`,关闭后宽度为零且无图标轨道。
- HeroUI Pro Navbar 高 64px;`Sidebar.Trigger` 使用 Pro 原生图形。
- 顶部栏按 route/breadcrumb 模型提供唯一可见页面标题:普通路由显示单一标题,详情路由使用 HeroUI `Breadcrumbs` 展示可返回的父级路径与通用详情标题。详情内容区展示真实记录标题并与导出等上下文操作同一行;状态和时间等记录元信息不重复放入 Navbar。返回动作使用面包屑父级链接。
- Navbar 与页面内容采用对称 48px/32px/16px gutter;主题操作在最右。
- 通知使用 HeroUI `Badge.Anchor + Badge + Button + Tooltip`,36px Ant Bell 紧邻主题操作左侧;0 隐藏 Badge、1-99 显示真实值、100 及以上显示 `99+`。点击进入独立 `/notifications` 页面,Sidebar 不重复放置通知入口。
- 用户行由 `Dropdown.Trigger` 打开真实菜单,退出调用目标应用现有服务。
- 应用切换使用 HeroUI Modal,标题固定为“应用切换”,副标题固定为“选择你可访问的应用”;弹窗宽 `min(46rem, 100vw - 2rem)`、高不超过 `min(42rem, 100dvh - 2rem)`,28px 内边距,只有 Modal Body 滚动。
- 应用切换使用 HeroUI Modal;弹窗保留 `aria-label="应用切换"` 作为无障碍名称,但不显示冗余标题和右上关闭图标,仅保留搜索字段。面板背景跟随页面 `background`,宽 `min(46rem, 100vw - 2rem)`、高不超过 `min(42rem, 100dvh - 2rem)`,28px 内边距,只有 Modal Body 滚动;通过遮罩点击或 Escape 关闭。
- 应用按 `/info` 的 `site.group` 与组内成员顺序动态分区,隐藏无可访问应用的分组,未被配置引用的可访问应用进入“其他应用”;无分组配置时显示“全部应用”。分组标题右侧显示真实可访问数量,禁止硬编码分组或应用。
- 每个分组在移动端一列、`sm` 及以上严格两列,组间 24px、标题到卡片 12px、卡片间 12px;应用项使用 HeroUI `Card variant="secondary"`、`Surface variant="tertiary"` 与 `Avatar variant="soft"`,identity well 固定 36px。
- 应用列表合并 `/application/info` 与当前用户授权应用;接口失败回退用户授权信息。授权域 `audit_manager` 映射到部署路径 `/audit_manager_new/`,整个弹窗最多一个 accent“当前” Chip。
......@@ -38,8 +39,9 @@
- 所有可见单选/远程引用使用 Autocomplete;不使用 native select、可见 date/file 控件或 OS popup。
- 枚举多选和远程引用多选统一使用 Autocomplete 的 `selectionMode="multiple"`、`selectedKeys` 与 `onSelectionChange`。已选项必须在 `Autocomplete.Value` 内使用 HeroUI `TagGroup > TagGroup.List > Tag + Tag.RemoveButton` 展示,Tag 在触发框内换行并支持键盘访问和逐项移除;禁止在选择框下方或外部另起 Chip/Badge/Tag 列表。
- 多选触发框、选项选中态、Tag 与移除动作必须由同一个受控数组派生。引用字段查询时机不变,移除和提交仍只传 `_id[]`,不得传完整对象。单选字段继续显示普通 `Autocomplete.Value` 文本;状态 Chip、只读 DataGrid 标签和文件列表不套用多选字段规则。
- 所有已选 Autocomplete 都提供触发框内的官方清除操作:单选清除后回写空字符串或原有空值协议,多选支持逐项 Tag 移除和清除全部;清除动作必须回写同一受控 `selectedKey/selectedKeys`,不能只改变弹层内部状态。
- 页面级 Tabs 的根节点、ListContainer 和 List 必须按自身文本内容收缩,不得使用 `flex-1`、`w-full` 或剩余空间填充轨道;同行业务操作通过外层布局靠右。只有一个可选视图时隐藏轨道。
- 通知页沿用问题整改基准的消息/待办、全部/未读两级内容宽度 Tabs,300ms 搜索、固定 10 条分页、单条/全部已读和动态 action。通知正文只作纯文本;自身 base 链接站内跳转,其他应用与外部链接新窗口打开。
- 通知页沿用审计项目管理基准的消息/待办、全部/未读两级内容宽度 Tabs,300ms 搜索、固定 10 条分页、单条/全部已读和动态 action。通知正文只作纯文本;自身 base 链接站内跳转,其他应用与外部链接新窗口打开。
- 同一业务视图内存在结构与操作一致的并列数据集时,使用第二级 HeroUI Tabs 切换并一次只展示当前数据集,不顺序堆叠重复表头。监察稽核报告固定采用“进行中/历史报告”一级 Tabs 和“稽核项目”二级 Tabs;两级轨道均为内容宽度,不使用等分或整行拉伸样式。
- 二级项目 Tabs 只控制可见快照,不改变整体 KPI、保存、提交、权限或数据索引;列表搜索作用于当前项目,Tabs 保持完整稳定,切换项目后继续使用该项目原始 `processIndex / pointIndex` 回调。
- 项目名称已经出现在二级 Tabs 时,当前项目列表区不重复项目标题或完成数;整体完成数由页面 KPI 承担。二级 Tabs 后使用 16px 间距进入列表工具栏,再以 16px 间距进入 DataGrid。
......@@ -52,7 +54,8 @@
- 业务列表存在“操作”列时,查看 Eye 图标必须作为首个常驻操作,使用 `TableActionButton` 的 `tertiary`/`view` 语义和 Tooltip;编辑、审批、删除按权限与原业务条件排列其后。只有真实详情路由或只读 HeroUI Modal 可以承接查看操作,禁止空按钮和无反馈图标。
- DataGrid 附件单元格禁止嵌套 Card、Surface 或自制背景块。附件分组使用紧凑无框行:分组名为普通文本,空值用 HeroUI `Chip size="sm"`,已有地址用 HeroUI `Link + Link.Icon`,无地址的文件名用 Chip;编辑态的 Pro DropZone 位于对应分组下一行。只允许 flex、换行、间距和截断布局,保持同行数据可比较且避免单个附件撑高整行。
- Pagination 固定业务默认每页 10 条,不显示页容量选择器。
- 查看/表单 Modal 使用 4xl 适用宽度;详情查看态用单一 L2 Surface,编辑态控件直接在 Overlay 上使用 secondary。
- 查看/表单 Modal 按统一尺寸阶梯选用宽度;普通详情和控制点详情使用 4xl(约 1088px)HeroUI Modal,字段按当前视图顺序单列纵向排列,普通标量字段直接落在 Dialog 背景上,不叠加同层级 Surface;编辑态控件直接在 Overlay 上使用 secondary。控制点查看与编辑共用同一 Modal,查看态底部使用“编辑”进入编辑态,编辑态使用“取消/保存”并保留现有保存回调。
- 结构化表单字段在查看态使用紧凑摘要显示“表单名称 · N 条记录”;后端返回“(无标题)”“(无标题)”等占位标题时回退到字段名称。不得直接把表单 JSON 序列化为详情正文,记录数量必须使用真实数据计算。
- 最终删除确认使用 AlertDialog/Modal + solid danger;例行成功提示使用浮动 Toast。
## KPI 与图表
......@@ -60,9 +63,17 @@
- 业务存在数值 KPI 时必须使用 Pro KPI compound API;模块 24px radius、16px grid gap、业务图标和唯一 tone。
- 业务存在图表时使用 Pro PieChart、BarChart、ComposedChart 与 ChartTooltipContent,并遵循共享 chart Tokens。
- 稽核报告的现有业务统计使用 Pro KPI compound API;活动路由没有图表,不新增演示数字或伪造业务数据。
- 图表色板独立于 UI 状态色,按“品牌红 -> 暖色 -> 中性灰 -> 蓝灰”顺序使用 `--xinyuan-chart-1..16`;单系列统一 `#DA241A`,多系列按稳定业务键分配相近低饱和色,不使用绿色、荧光色或彩虹序列。KPI 图标 tone 复用同一色板,避免蓝/绿/紫高对比跳色。当前活动路由没有真实图表调用,因此只维护 token 合同,不添加伪造图表或数据。
## 验收
每个活动模块与“问题整改”同时打开,在 1440x900、1280x720、768x1024、390x844 的浅色/暗色模式比较。必须覆盖登录、Sidebar、Navbar、退出菜单、应用切换、Tabs、筛选、打开态 popup、DataGrid、Pagination、ActionBar、Modal、Tooltip 和 console。
每个活动模块与“审计项目管理”同时打开,在 1440x900、1280x720、768x1024、390x844 的浅色/暗色模式比较。必须覆盖登录、Sidebar、Navbar、退出菜单、应用切换、Tabs、筛选、打开态 popup、DataGrid、Pagination、ActionBar、Modal、Tooltip 和 console。
当前登录页已完成上述四视口双主题与 filled 状态对照。登录后页面必须在获得有效真实登录态后补齐,不允许使用伪造 token 或业务数据绕过验收。
# 本轮同步记录(2026-09-02)
- 唯一视觉基准为 `/Users/liuchenlong/Desktop/job/audit_project_manager`,主题、Tokens、Shell 已逐文件同步。
- AppShell 固定浅色内容画布,Sidebar 使用 HeroUI Pro offcanvas 深色布局,移除主题切换入口和持久化状态。
- 应用切换使用 HeroUI Modal,支持实时搜索、分组计数、双列桌面/单列移动布局、36px identity well。
- 流程监控页面的活动可复用组件已改为 HeroUI Surface、Tabs、Autocomplete、Modal、DataGrid、Pagination;service/API/权限/回调保持原样。
- `XinyuanAutocomplete` 多选受控 key 逻辑继续使用字符串化 selection boundary,避免外层 dirty 状态不同步。
......@@ -2,7 +2,7 @@
审计日期:2026-08-26
当前分支:`codex/ui-ux-xinyuan`
唯一实现基准:`/Users/liuchenlong/Desktop/job/problem_rectification`
唯一实现基准:`/Users/liuchenlong/Desktop/job/audit_project_manager`
本次只迁移 UI、交互表现和前端组件结构。API、参数、Record/metadata 数据结构、路由、权限、校验、分页口径、保存回调、登录退出协议、请求时机和业务状态流均属于保护范围。
......@@ -35,7 +35,7 @@
| `/audit_report_fill` | 外部填报 | Site、ticket/report/owner 参数 |
| `/notifications` | 消息通知 | Site + Auth、平台通知查询/已读/动态动作 |
`process_monitor`、`RecordDetailDrawer`、`record-workbench` 未进入 `.umirc.ts`,属于非活动存量范围,不计入活动页面完成声明;它们仍有 Catalyst/Headless 引用,恢复路由前必须迁移。
`process_monitor`、`RecordDetailDrawer`、`record-workbench` 未进入 `.umirc.ts`。本轮已将 `process_monitor` 页面代码同步到 HeroUI/HeroUI Pro 结构,但它仍是非活动路由;`RecordDetailDrawer` 与 `record-workbench` 保留为未挂载历史存量,恢复路由前需继续迁移。
## 业务保护清单
......@@ -50,8 +50,8 @@
- 登录已同步基准的居中 Card、双主题 Logo、字段节奏、验证码 Button、filled/autofill 与响应式几何。
- 鉴权 Shell 使用共享 `AppShell`、Pro Navbar、Pro Sidebar offcanvas、用户 Dropdown、真实退出回调和应用切换 Modal。
- 应用切换已按 `model_analysis` 基准改为动态分组结构:读取 `SiteContext.siteInfo.group` 保留后端分组及成员顺序,隐藏空组,将未配置的可访问应用归入“其他应用”,并显示真实组内数量。
- 应用项使用严格一列移动/两列桌面结构、L2 HeroUI Card 与 L3 Surface + Avatar identity well;弹窗保持 46rem/42rem、28px padding、Body 单独滚动及唯一“当前” Chip。
- 应用切换已按审计项目管理基准改为动态分组结构:读取 `SiteContext.siteInfo.group` 保留后端分组及成员顺序,隐藏空组,将未配置的可访问应用归入“其他应用”,并在跨分组重复时去重、显示真实组内数量。
- 应用项使用 Pro `ItemCardGroup + ItemCard`,严格一列移动/两列桌面结构,统一红色鑫元 Logo identity well;弹窗保持 46rem/42rem、28px padding、Body 单独滚动及唯一“当前” Chip。
- 应用数据仍来自既有 `/application/info` 与 `userInfo.applications` 回退。仅将授权域 `audit_manager` 规范化为当前应用并导航至既有 `/audit_manager_new/` 部署基路径,未修改接口、权限或登录状态流。
- 活动列表使用 Pro DataGrid、Pagination、ActionBar、EmptyState;选择、排序、固定列及分页业务回调保留。
- 活动 DataGrid 操作列统一将 `tertiary` Eye 查看按钮放在首位:项目控制点与报告控制点打开当前行的只读 HeroUI Modal,历史报告进入原详情路由;编辑、确认、删除权限和回调不变。
......@@ -62,9 +62,9 @@
- 报告附件内容已移除 DataGrid 单元格内逐分组 Card;空附件使用 HeroUI Chip,已有附件使用 HeroUI Link/Chip,编辑上传继续使用 Pro DropZone。原附件数组、上传请求、追加和删除回调保持不变,未新增页面视觉 CSS。
- 活动选择/引用字段使用 HeroUI Autocomplete compound API;上传使用 Pro DropZone。
- 活动路由仅有“相关标签”“相关制度”两个多值引用入口,均经过共享 Autocomplete composition;已选值在触发框内以 HeroUI TagGroup 展示并逐项移除,选择框外无第二套 Chip 列表。单值引用、状态 Chip、DataGrid 摘要和文件列表保持各自语义。
- 活动弹窗使用 HeroUI Modal/AlertDialog;普通辅助按钮使用 `secondary`,删除确认使用 danger。
- 全应用弹窗已统一到鑫元尺寸阶梯:确认弹窗 `md/480px`,单列表单与填报链接 `lg/640px`,控制点双列表单 `3xl/960px`;应用切换保持基准 `46rem/736px`。大尺寸面板通过共享 Token 控制,不再把无效的 HeroUI Container `3xl` 当作官方尺寸。
- 报告页已有真实业务统计改为 Pro KPI compound API;活动路由没有图表,不新增图表或演示数据。
- 活动弹窗使用 HeroUI Modal/AlertDialog;普通辅助按钮使用 `secondary`,删除确认使用 danger。控制点查看态采用无框单列字段布局,字段标签使用 muted、字段值使用 foreground,避免在 Overlay 上叠加同层级 Surface;控制点查看和编辑在同一 Modal 内切换,编辑入口保留权限与原保存回调。
- 全应用弹窗已统一到鑫元尺寸阶梯:确认弹窗 `md/480px`,单列表单与填报链接 `lg/640px`,控制点详情/表单使用 `4xl/1088px`;应用切换保持基准 `46rem/736px`。大尺寸面板通过 `bg-background p-7 sm:max-w-[68rem]` 与 HeroUI `Modal.Container size="lg"` 组合实现,正文保持独立滚动。
- 报告页已有真实业务统计改为 Pro KPI compound API;活动路由没有图表,不新增图表或演示数据。主题专项已固定 HeroUI `--accent`/`--focus` 为 `#DA241A`,并提供完整 `--primary-50..950` 与 `--color-primary-*` 色阶;success 映射为低饱和蓝灰,图表 token 不含绿色。
- 全仓库无 ECharts 源码引用,未使用的 `echarts` 与 `echarts-for-react` 根依赖已移除。
- 权威 `theme.css` 逐字节一致;活动页不存在页面级 CSS,私有外观 utility 扫描为零。
......@@ -108,3 +108,11 @@
- 已填写态三个字段均为 40px,目标与基准的 filled Surface 计算值一致,未提交测试登录请求。
- `audit_project.sh` 退出码为 0;其启发式警告与同一脚本扫描“问题整改”的结果基本一致。目标额外审计确认 7 个非活动存量文件仍有 Catalyst/Headless 引用;脚本因 Node 不能直接 `import` CSS 误报 Pro 子路径,独立解析/动态导入检查已通过全部要求子路径。
- 2026-08-17 保存的真实浏览器会话已过期,服务端鉴权校验后清除了登录态。未伪造 token、用户或业务记录,因此 Sidebar、Navbar、退出菜单、应用切换、Tabs、DataGrid、Modal、KPI 等登录后浏览器验收仍未完成,不能声明全量迁移完成。
### 2026-09-02 本轮真实浏览器复验
- 开发服务:`http://localhost:8003/audit_manager_new/`;使用真实账号 Andy 与后端验证码完成登录。
- 在 1440x900、1280x720、768x1024、390x844 逐一检查登录后 Shell、固定深色 Sidebar、offcanvas 折叠、移动端导航 Sheet、Navbar Bell、用户菜单、应用切换 Modal、项目 Tabs、搜索、DataGrid、行查看详情 Modal 和移动端横向列表。
- 应用切换打开态确认 Pro `ItemCardGroup + ItemCard`、统一鑫元 Logo、分组数量、跨分组去重、搜索过滤和唯一当前态;详情弹窗宽度符合 4xl 级别。
- 重新加载后的当前页面控制台为 0 error / 0 warning;登录过程中两次错误验证码仅产生后端拒绝日志,不涉及代码或业务数据变更。
- 本轮截图保存在 `/tmp/audit-manager-1440.png`、`/tmp/audit-switcher-fixed.png`、`/tmp/controlpoint-modal-1440.png`、`/tmp/audit-1280x720.png`、`/tmp/audit-768x1024.png`、`/tmp/audit-390x844.png` 和 `/tmp/audit-mobile-nav.png`。
......@@ -31,6 +31,8 @@
"clsx": "^2.1.1",
"crypto-js": "^4.2.0",
"dayjs": "^1.11.20",
"echarts": "^6.1.0",
"echarts-for-react": "^3.0.6",
"embla-carousel": "^8.6.0",
"embla-carousel-react": "^8.6.0",
"maplibre-gl": "^6.4.1",
......
......@@ -77,6 +77,12 @@ importers:
dayjs:
specifier: ^1.11.20
version: 1.11.21
echarts:
specifier: ^6.1.0
version: 6.1.0
echarts-for-react:
specifier: ^3.0.6
version: 3.0.6(echarts@6.1.0)(react@19.2.8)
embla-carousel:
specifier: ^8.6.0
version: 8.6.0
......@@ -3730,6 +3736,15 @@ packages:
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
echarts-for-react@3.0.6:
resolution: {integrity: sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==}
peerDependencies:
echarts: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0
react: ^15.0.0 || >=16.0.0
echarts@6.1.0:
resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==}
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
......@@ -7228,6 +7243,9 @@ packages:
sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
size-sensor@1.0.3:
resolution: {integrity: sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==}
slash@3.0.0:
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
engines: {node: '>=8'}
......@@ -7632,6 +7650,9 @@ packages:
tslib@1.14.1:
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
tslib@2.3.0:
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
......@@ -8047,6 +8068,9 @@ packages:
resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==}
engines: {node: '>=12.20'}
zrender@6.1.0:
resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==}
zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
......@@ -9517,12 +9541,12 @@ snapshots:
'@react-aria/i18n@3.13.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@internationalized/date': 3.12.2
'@internationalized/date': 3.12.3
'@internationalized/message': 3.1.10
'@internationalized/string': 3.2.9
'@internationalized/string': 3.2.10
'@swc/helpers': 0.5.23
react: 19.2.8
react-aria: 3.50.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
react-aria: 3.51.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
react-dom: 19.2.8(react@19.2.8)
'@react-aria/interactions@3.28.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
......@@ -9537,7 +9561,7 @@ snapshots:
dependencies:
'@swc/helpers': 0.5.23
react: 19.2.8
react-aria: 3.50.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
react-aria: 3.51.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
react-dom: 19.2.8(react@19.2.8)
'@react-aria/utils@3.34.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
......@@ -12404,6 +12428,18 @@ snapshots:
eastasianwidth@0.2.0: {}
echarts-for-react@3.0.6(echarts@6.1.0)(react@19.2.8):
dependencies:
echarts: 6.1.0
fast-deep-equal: 3.1.3
react: 19.2.8
size-sensor: 1.0.3
echarts@6.1.0:
dependencies:
tslib: 2.3.0
zrender: 6.1.0
ee-first@1.1.1: {}
electron-to-chromium@1.5.398: {}
......@@ -16746,6 +16782,8 @@ snapshots:
sisteransi@1.0.5: {}
size-sensor@1.0.3: {}
slash@3.0.0: {}
slash@4.0.0: {}
......@@ -17225,6 +17263,8 @@ snapshots:
tslib@1.14.1: {}
tslib@2.3.0: {}
tslib@2.8.1: {}
tsutils@3.21.0(typescript@5.9.3):
......@@ -17720,4 +17760,8 @@ snapshots:
yocto-queue@1.2.2: {}
zrender@6.1.0:
dependencies:
tslib: 2.3.0
zwitch@2.0.4: {}
import { THEME_STORAGE_KEY } from '@/constants/app';
import { Sidebar } from '@heroui-pro/react/sidebar';
import { Toast } from '@heroui/react';
import { useLayoutEffect, useState } from 'react';
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { history } from 'umi';
import DashboardNavbar from './DashboardNavbar';
import DashboardSidebar from './DashboardSidebar';
function getInitialTheme() {
if (typeof window === 'undefined') return 'light';
try {
return window.localStorage.getItem(THEME_STORAGE_KEY) === 'dark' ? 'dark' : 'light';
} catch {
return 'light';
}
const AppShellHeaderContext = createContext(null);
export function useAppShellHeader() {
return useContext(AppShellHeaderContext);
}
export default function AppShell({
......@@ -24,24 +20,22 @@ export default function AppShell({
title,
...sidebarProps
}) {
const [theme, setTheme] = useState(getInitialTheme);
const [headerOverride, setHeaderOverrideState] = useState(null);
const setHeaderOverride = useCallback((nextHeader) => {
setHeaderOverrideState(nextHeader || null);
}, []);
useEffect(() => {
setHeaderOverrideState(null);
}, [title]);
useLayoutEffect(() => {
const root = document.documentElement;
root.classList.toggle('dark', theme === 'dark');
root.classList.toggle('light', theme === 'light');
root.dataset.theme = theme;
root.style.colorScheme = theme;
try {
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
} catch {
// Keep the in-memory theme when storage is unavailable.
}
}, [theme]);
const header = headerOverride || { title };
const headerContext = useMemo(() => ({ setHeaderOverride }), [setHeaderOverride]);
return (
<AppShellHeaderContext.Provider value={headerContext}>
<Sidebar.Provider
className={`template-dashboard-shell rc-app-shell ${theme}`}
className="template-dashboard-shell rc-app-shell light"
collapsible="offcanvas"
navigate={(href) => history.push(href)}
open={open}
......@@ -50,13 +44,10 @@ export default function AppShell({
<DashboardSidebar {...sidebarProps} />
<Sidebar.Main className="app-main-glass rc-app-main">
<DashboardNavbar
breadcrumbs={header.breadcrumbs}
notificationCount={notificationCount}
onOpenNotifications={
notificationPath ? () => history.push(notificationPath) : undefined
}
theme={theme}
title={title}
onThemeChange={setTheme}
onOpenNotifications={notificationPath ? () => history.push(notificationPath) : undefined}
title={header.title || title}
/>
<div className="app-shell-content-scroll min-h-0 flex-1 overflow-y-auto">
<div className="rc-app-page">{children}</div>
......@@ -64,5 +55,6 @@ export default function AppShell({
</Sidebar.Main>
<Toast.Provider maxVisibleToasts={3} placement="top" />
</Sidebar.Provider>
</AppShellHeaderContext.Provider>
);
}
import xinyuanLogo from '@/assets/identity/xy-logo-red.png';
import { ExclamationTriangleIcon } from '@/components/AppIcons';
import { ExclamationTriangleIcon, MagnifyingGlassIcon } from '@/components/AppIcons';
import { APP_BASE_PATH, APP_DISPLAY_NAME, APP_ROUTE_NAME } from '@/constants/app';
import { EmptyStateSurface } from '@/components/DataTable';
import { getApplications } from '@/services/DataService';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { ItemCard } from '@heroui-pro/react/item-card';
import { ItemCardGroup } from '@heroui-pro/react/item-card-group';
import {
Alert,
Avatar,
Card,
Chip,
Description,
Modal,
SearchField,
Skeleton,
ScrollShadow,
Surface,
} from '@heroui/react';
import { useEffect, useMemo, useState } from 'react';
......@@ -49,7 +52,7 @@ function normalizeApplication(application = {}) {
application.subtitle ||
application.title ||
'进入对应应用继续处理业务',
logo: current ? xinyuanLogo : application.logo || application.icon || null,
logo: xinyuanLogo,
order: Number(application.order || 0),
url: current
? APP_BASE_PATH
......@@ -95,6 +98,7 @@ function buildApplicationSections(applications, applicationGroups) {
const groupApplications = Array.isArray(group?.applications)
? group.applications
.map((name) => {
if (groupedApplicationNames.has(name)) return null;
const application = applicationMap.get(name);
if (application) groupedApplicationNames.add(name);
return application;
......@@ -129,72 +133,55 @@ function buildApplicationSections(applications, applicationGroups) {
function ApplicationCard({ application, current }) {
const target = /^https?:\/\//i.test(application.url) ? '_blank' : undefined;
const initial = application.title.trim().slice(0, 1).toUpperCase() || 'A';
const description = application.description?.trim();
const hasDistinctDescription = description && description !== application.title.trim();
return (
<Card className="h-full" variant="secondary">
<Card.Content>
<a
<ItemCard
aria-current={current ? 'page' : undefined}
className="flex min-w-0 items-center gap-3 no-underline"
className="h-full min-w-0 no-underline"
href={application.url}
rel={target ? 'noreferrer' : undefined}
render={(props) => <a {...props} />}
target={target}
variant={current ? 'outline' : 'default'}
>
<Surface
aria-hidden="true"
className="rc-app-identity-well grid size-9 shrink-0 place-items-center overflow-hidden"
variant="tertiary"
>
<Avatar
className="rc-app-identity-avatar size-full"
color="accent"
variant="soft"
>
{application.logo ? (
<Avatar.Image
alt=""
className="object-contain p-1.5"
src={application.logo}
/>
) : null}
<Avatar.Fallback>{initial}</Avatar.Fallback>
<ItemCard.Icon aria-hidden="true">
<Surface className="rc-app-identity-well" variant="default">
<Avatar className="rc-app-identity-avatar" color="accent" variant="soft">
<Avatar.Image alt="" className="object-contain p-1.5" src={application.logo || xinyuanLogo} />
<Avatar.Fallback>
<img alt="" className="object-contain p-1.5" src={xinyuanLogo} />
</Avatar.Fallback>
</Avatar>
</Surface>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium leading-5">
{application.title}
</span>
<Description className="mt-0.5 block truncate leading-4">
{application.description}
</Description>
</span>
</ItemCard.Icon>
<ItemCard.Content>
<ItemCard.Title title={application.title}>{application.title}</ItemCard.Title>
{hasDistinctDescription ? (
<ItemCard.Description title={description}>{description}</ItemCard.Description>
) : null}
</ItemCard.Content>
{current ? (
<Chip color="accent" size="sm">
当前
</Chip>
<ItemCard.Action><Chip color="accent" size="sm">当前</Chip></ItemCard.Action>
) : null}
</a>
</Card.Content>
</Card>
</ItemCard>
);
}
function LoadingSkeleton() {
return (
<div aria-label="正在加载应用" className="grid gap-3 sm:grid-cols-2">
<ItemCardGroup aria-label="正在加载应用" className="grid-cols-1 sm:grid-cols-2" columns={2} layout="grid" variant="transparent">
{Array.from({ length: 6 }).map((_, index) => (
<Card key={index} variant="secondary">
<Card.Content className="flex items-center gap-3">
<Skeleton className="size-9" />
<div className="flex-1 space-y-2">
<ItemCard key={index} variant="default">
<ItemCard.Icon><Skeleton className="size-9" /></ItemCard.Icon>
<ItemCard.Content className="gap-2">
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-3 w-full" />
</div>
</Card.Content>
</Card>
</ItemCard.Content>
</ItemCard>
))}
</div>
</ItemCardGroup>
);
}
......@@ -213,6 +200,7 @@ export default function ApplicationSwitcherDialog({
);
const [status, setStatus] = useState('idle');
const [errorMessage, setErrorMessage] = useState('');
const [applicationSearch, setApplicationSearch] = useState('');
const currentApplicationName = useMemo(() => {
return (
applications.find(
......@@ -226,6 +214,20 @@ export default function ApplicationSwitcherDialog({
() => buildApplicationSections(applications, siteInfo?.group),
[applications, siteInfo?.group],
);
const filteredApplicationSections = useMemo(() => {
const query = applicationSearch.trim().toLocaleLowerCase();
if (!query) return applicationSections;
return applicationSections
.map((section) => ({
...section,
applications: section.applications.filter((application) =>
[application.name, application.title, application.description]
.filter(Boolean)
.some((value) => String(value).toLocaleLowerCase().includes(query)),
),
}))
.filter((section) => section.applications.length > 0);
}, [applicationSearch, applicationSections]);
useEffect(() => {
if (!open) return undefined;
......@@ -256,7 +258,7 @@ export default function ApplicationSwitcherDialog({
return (
<Modal.Root>
<Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
<Modal.Trigger aria-hidden="true" aria-label="打开应用切换" className="sr-only" tabIndex={-1} />
<Modal.Backdrop
isOpen={open}
variant="blur"
......@@ -267,17 +269,29 @@ export default function ApplicationSwitcherDialog({
<Modal.Container placement="center" scroll="inside" size="lg">
<Modal.Dialog
aria-label="应用切换"
className="rc-app-switcher-dialog p-7"
className="max-h-[min(42rem,calc(100dvh-2rem))] bg-background p-7"
style={{
maxWidth: 'min(46rem, calc(100vw - 2rem))',
width: 'min(46rem, calc(100vw - 2rem))',
}}
>
<Modal.CloseTrigger
aria-label="关闭"
style={{ insetInlineEnd: '1.75rem', top: '1.75rem' }}
/>
<Modal.Header>
<Modal.Heading>应用切换</Modal.Heading>
<Description>选择你可访问的应用</Description>
<SearchField
aria-label="搜索应用"
className="w-full min-w-0 sm:flex-1"
value={applicationSearch}
variant="secondary"
onChange={setApplicationSearch}
>
<SearchField.Group>
<SearchField.SearchIcon><MagnifyingGlassIcon size={16} /></SearchField.SearchIcon>
<SearchField.Input placeholder="搜索应用" />
<SearchField.ClearButton aria-label="清空应用搜索" />
</SearchField.Group>
</SearchField>
</Modal.Header>
<Modal.Body style={{ marginTop: '0.75rem' }}>
<Modal.Body className="flex min-h-0 overflow-hidden" style={{ marginTop: '0.75rem' }}>
<ScrollShadow className="h-full min-h-0 flex-1 overflow-y-auto pe-1 pb-1" hideScrollBar={false} offset={12} size={24}>
{status === 'error' && errorMessage ? (
<Alert status="warning">
<Alert.Indicator>
......@@ -291,17 +305,22 @@ export default function ApplicationSwitcherDialog({
{status === 'loading' && applications.length === 0 ? (
<LoadingSkeleton />
) : applications.length > 0 ? (
) : filteredApplicationSections.length > 0 ? (
<div className="space-y-6">
{applicationSections.map((section) => (
<section key={section.key}>
<div className="mb-3 flex items-center justify-between gap-3">
<h3 className="text-sm font-medium leading-5">
{section.label}
</h3>
<Description>{section.applications.length} 个应用</Description>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{filteredApplicationSections.map((section) => (
<ItemCardGroup
key={section.key}
className="grid-cols-1 sm:grid-cols-2"
columns={2}
layout="grid"
variant="transparent"
>
<ItemCardGroup.Header className="flex items-center justify-between gap-3">
<ItemCardGroup.Title>{section.label}</ItemCardGroup.Title>
<ItemCardGroup.Description className="mt-0">
{section.applications.length} 个应用
</ItemCardGroup.Description>
</ItemCardGroup.Header>
{section.applications.map((application) => (
<ApplicationCard
key={application.name}
......@@ -309,20 +328,22 @@ export default function ApplicationSwitcherDialog({
current={application.name === currentApplicationName}
/>
))}
</div>
</section>
</ItemCardGroup>
))}
</div>
) : (
<EmptyStateSurface>
<EmptyState size="md">
<EmptyState.Header>
<EmptyState.Title>暂无可切换应用</EmptyState.Title>
<EmptyState.Title>{applicationSearch.trim() ? '没有找到匹配的应用' : '暂无可切换应用'}</EmptyState.Title>
<EmptyState.Description>
当前账号还没有分配其它应用权限。
{applicationSearch.trim() ? '请尝试其他关键词。' : '当前账号还没有分配其它应用权限。'}
</EmptyState.Description>
</EmptyState.Header>
</EmptyState>
</EmptyStateSurface>
)}
</ScrollShadow>
</Modal.Body>
</Modal.Dialog>
</Modal.Container>
......
import { Bell, Moon, SidebarPanel, Sun } from '@/components/AppIcons';
import { Bell, SidebarPanel } from '@/components/AppIcons';
import { Navbar } from '@heroui-pro/react/navbar';
import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar';
import { Badge, Button, Tooltip } from '@heroui/react';
import { Badge, Breadcrumbs, Button, Tooltip } from '@heroui/react';
import { history } from 'umi';
export default function DashboardNavbar({
breadcrumbs = [],
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);
const hasBreadcrumbs = breadcrumbs.length > 0;
return (
<Navbar className="app-navbar rc-navbar" maxWidth="full">
......@@ -24,7 +23,37 @@ export default function DashboardNavbar({
>
<SidebarPanel />
</Sidebar.Trigger>
<h1 className="app-navbar-page-title rc-navbar-title">{title}</h1>
{hasBreadcrumbs ? (
<div className="flex min-w-0 flex-1 flex-col justify-center gap-0.5">
<div className="flex min-w-0 items-center gap-2">
<Breadcrumbs aria-label="页面层级" className="rc-navbar-breadcrumbs min-w-0">
{breadcrumbs.map((item) => (
<Breadcrumbs.Item
key={`${item.href || 'current'}-${item.label}`}
href={item.href}
isCurrent={!item.href}
onPress={
item.href
? (event) => {
event?.preventDefault?.();
if (item.onPress) {
item.onPress(event);
} else {
history.push(item.href);
}
}
: undefined
}
>
<span className="truncate">{item.label}</span>
</Breadcrumbs.Item>
))}
</Breadcrumbs>
</div>
</div>
) : (
<h1 className="app-navbar-page-title rc-navbar-title min-w-0 truncate">{title}</h1>
)}
<Navbar.Spacer />
<div className="rc-navbar-actions">
{onOpenNotifications ? (
......@@ -55,20 +84,6 @@ export default function DashboardNavbar({
<Tooltip.Content>通知消息</Tooltip.Content>
</Tooltip>
) : null}
<Tooltip delay={0}>
<Button
isIconOnly
aria-label={themeLabel}
aria-pressed={isDark}
className="app-navbar-icon-button rc-navbar-icon-button"
size="sm"
variant="ghost"
onPress={() => onThemeChange(isDark ? 'light' : 'dark')}
>
{isDark ? <Sun size={16} /> : <Moon size={16} />}
</Button>
<Tooltip.Content>{themeLabel}</Tooltip.Content>
</Tooltip>
</div>
</Navbar.Header>
</Navbar>
......
import xyLogoRed from '@/assets/identity/xy-logo-red.png';
import xyLogoWhite from '@/assets/identity/xy-logo-white.png';
import { AppGrid, LogOut } from '@/components/AppIcons';
import { AppGrid, ChevronDown, LogOut } from '@/components/AppIcons';
import { APP_DISPLAY_NAME } from '@/constants/app';
import { Sidebar } from '@heroui-pro/react/sidebar';
import { Avatar, Button, Dropdown, Label, Tooltip } from '@heroui/react';
import { Sheet } from '@heroui-pro/react/sheet';
import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar';
import { Avatar, Button, Disclosure, Dropdown, Label } from '@heroui/react';
function getUserDisplay(userInfo) {
const displayName = userInfo?.display_name || userInfo?.username || '当前用户';
......@@ -44,19 +44,65 @@ function SidebarUserMenu({ userInfo, onLogout }) {
}
function SidebarContents({ menuGroups, onLogout, onOpenApplicationSwitcher, pathname, userInfo }) {
const renderMenu = (group) => (
<Sidebar.Menu aria-label={group.title}>
{group.items.map((item) => {
const Icon = item.icon;
const isCurrent = pathname === item.path || pathname.startsWith(`${item.path}/`);
return (
<Sidebar.MenuItem
href={item.path}
isCurrent={isCurrent}
key={item.path}
textValue={item.name}
tooltip={item.name}
>
<Sidebar.MenuIcon><Icon size={16} /></Sidebar.MenuIcon>
<Sidebar.MenuLabel>{item.name}</Sidebar.MenuLabel>
</Sidebar.MenuItem>
);
})}
</Sidebar.Menu>
);
const renderGroup = (group) => {
const hasCurrentItem = group.items.some(
(item) => pathname === item.path || pathname.startsWith(`${item.path}/`),
);
const GroupIcon = group.icon;
return (
<Sidebar.Group key={group.title}>
<Disclosure defaultExpanded={hasCurrentItem}>
<Disclosure.Heading>
<Sidebar.GroupLabel>
<Disclosure.Trigger className="flex w-full min-w-0 items-center gap-2 text-left">
{GroupIcon ? <GroupIcon size={16} /> : null}
<span className="min-w-0 flex-1 truncate">{group.title}</span>
<Disclosure.Indicator><ChevronDown size={14} /></Disclosure.Indicator>
</Disclosure.Trigger>
</Sidebar.GroupLabel>
</Disclosure.Heading>
<Disclosure.Content>
<Disclosure.Body>{renderMenu(group)}</Disclosure.Body>
</Disclosure.Content>
</Disclosure>
</Sidebar.Group>
);
};
return (
<>
<Sidebar.Header>
<div className="app-sidebar-brand rc-sidebar-brand">
<div className="app-sidebar-brand-mark rc-sidebar-brand-mark">
<img alt="鑫元" className="rc-sidebar-logo rc-sidebar-logo--light" src={xyLogoRed} />
<img alt="鑫元" className="rc-sidebar-logo rc-sidebar-logo--dark" src={xyLogoWhite} />
<img alt="鑫元" className="rc-sidebar-logo" src={xyLogoWhite} />
</div>
<div className="app-sidebar-brand-main rc-sidebar-brand-main" data-sidebar="label">
<span className="app-sidebar-title rc-sidebar-title">{APP_DISPLAY_NAME}</span>
</div>
<div className="app-sidebar-app-switcher rc-sidebar-app-switcher" data-sidebar="label">
<Tooltip delay={0}>
<Button
isIconOnly
aria-label="切换应用"
......@@ -67,34 +113,14 @@ function SidebarContents({ menuGroups, onLogout, onOpenApplicationSwitcher, path
>
<AppGrid size={16} />
</Button>
<Tooltip.Content>切换应用</Tooltip.Content>
</Tooltip>
</div>
</div>
</Sidebar.Header>
<Sidebar.Content>
{menuGroups.map((group) => (
<Sidebar.Group key={group.title}>
{group.showLabel !== false ? <Sidebar.GroupLabel>{group.title}</Sidebar.GroupLabel> : null}
<Sidebar.Menu aria-label={group.title}>
{group.items.map((item) => {
const Icon = item.icon;
const isCurrent = pathname === item.path || pathname.startsWith(`${item.path}/`);
return (
<Sidebar.MenuItem
href={item.path}
isCurrent={isCurrent}
key={item.path}
textValue={item.name}
tooltip={item.name}
>
<Sidebar.MenuIcon><Icon size={16} /></Sidebar.MenuIcon>
<Sidebar.MenuLabel>{item.name}</Sidebar.MenuLabel>
</Sidebar.MenuItem>
);
})}
</Sidebar.Menu>
</Sidebar.Group>
group.showLabel === false ? (
<Sidebar.Group key={group.title}>{renderMenu(group)}</Sidebar.Group>
) : renderGroup(group)
))}
</Sidebar.Content>
<Sidebar.Footer><SidebarUserMenu userInfo={userInfo} onLogout={onLogout} /></Sidebar.Footer>
......@@ -102,14 +128,31 @@ function SidebarContents({ menuGroups, onLogout, onOpenApplicationSwitcher, path
);
}
function AccessibleMobileSidebar(props) {
const { isMobile, isMobileOpen, setMobileOpen, side } = useSidebar();
if (!isMobile) return null;
return (
<Sheet.Root isOpen={isMobileOpen} placement={side} onOpenChange={setMobileOpen}>
<Sheet.Backdrop variant="blur">
<Sheet.Content className="sidebar__mobile-sheet">
<Sheet.Dialog aria-label={`${APP_DISPLAY_NAME}导航`} className="sidebar__mobile-dialog">
<div className="sidebar__mobile app-sidebar-glass rc-mobile-sidebar" data-slot="sidebar-mobile">
<SidebarContents {...props} />
</div>
</Sheet.Dialog>
</Sheet.Content>
</Sheet.Backdrop>
</Sheet.Root>
);
}
export default function DashboardSidebar(props) {
return (
<>
<Sidebar className="app-sidebar-glass rc-sidebar"><SidebarContents {...props} /></Sidebar>
<Sidebar.Mobile className="app-sidebar-glass rc-mobile-sidebar">
<h2 slot="title" className="sr-only">{APP_DISPLAY_NAME}导航</h2>
<SidebarContents {...props} />
</Sidebar.Mobile>
<AccessibleMobileSidebar {...props} />
</>
);
}
import { Close } from '@/components/AppIcons';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import { ActionBar } from '@heroui-pro/react/action-bar';
import { DataGrid } from '@heroui-pro/react/data-grid';
import { EmptyState } from '@heroui-pro/react/empty-state';
......@@ -8,8 +9,28 @@ import {
Pagination,
Separator,
Spinner,
Surface,
} from '@heroui/react';
import { Tooltip } from '@heroui/react';
import { useLayoutEffect, useRef, useState } from 'react';
const DATA_TABLE_LONG_TEXT_TYPES = new Set([
'long_text',
'rich_text',
'object',
'form',
'subject',
]);
const DATA_TABLE_LONG_TEXT_LABEL_PATTERN =
/需求明细|需求内容|材料清单|工作内容|检查内容|审计范围|整改情况|问题简述|整改措施|问题描述|具体问题|整改要求|描述|措施|原因|意见|备注|说明/;
export function isDataTableLongTextField(fieldDef, label = '') {
return DATA_TABLE_LONG_TEXT_TYPES.has(
String(fieldDef?.type || '').toLowerCase(),
) || DATA_TABLE_LONG_TEXT_LABEL_PATTERN.test(
String(label || fieldDef?.label || ''),
);
}
function normalizeDataTableEmptyTitle(title) {
const text = String(title || '').trim();
......@@ -24,23 +45,36 @@ function normalizeDataTableEmptyTitle(title) {
return title;
}
export function DataTableEmptyState({ title = '暂无数据' }) {
export function EmptyStateSurface({ children, className = '' }) {
return (
<Surface
className={['w-full min-w-0 rounded-2xl p-4', className].filter(Boolean).join(' ')}
variant="secondary"
>
{children}
</Surface>
);
}
export function DataTableEmptyState({ title = '暂无数据', size = 'md' }) {
const resolvedTitle = normalizeDataTableEmptyTitle(title);
return (
<EmptyState size="md">
<EmptyStateSurface>
<EmptyState size={size}>
<EmptyState.Header>
<EmptyState.Title>{resolvedTitle}</EmptyState.Title>
<EmptyState.Description className="rc-empty-state-message">
{resolvedTitle}
</EmptyState.Description>
</EmptyState.Header>
</EmptyState>
</EmptyStateSurface>
);
}
function DataTableLoadingState() {
return (
<div className="flex min-h-48 items-center justify-center" aria-label="数据加载中">
<Spinner size="sm" />
</div>
<div className="min-h-48 w-full" aria-hidden="true" />
);
}
......@@ -87,6 +121,7 @@ export function DataTable({
selectedRowKeys,
onSelectedRowKeysChange,
emptyTitle = '暂无数据',
emptyStateSize = 'md',
className = '',
isWide,
disabledRowKeys,
......@@ -94,16 +129,55 @@ export function DataTable({
sortDescriptor,
onSortChange,
renderSelectionActions,
allowsColumnResize = true,
}) {
const selectionEnabled = Boolean(selectedRowKeys && onSelectedRowKeysChange);
const selectedKeys = selectionEnabled ? new Set(selectedRowKeys) : undefined;
const wide = isWide ?? columns.length > 6;
const gridViewportRef = useRef(null);
const [hasHorizontalOverflow, setHasHorizontalOverflow] = useState(false);
const firstBusinessColumnIndex = columns.findIndex(
(column) =>
!column.isSelection &&
!column.isActions &&
column.key !== 'actions',
);
useLayoutEffect(() => {
if (typeof isWide === 'boolean') {
setHasHorizontalOverflow(isWide);
return undefined;
}
const viewport = gridViewportRef.current;
if (!viewport) {
return undefined;
}
const updateOverflow = () => {
const scrollContainer =
viewport.querySelector('.table__resizable-container') ||
viewport.querySelector('.data-table-scroll');
if (!scrollContainer) return;
setHasHorizontalOverflow(
scrollContainer.scrollWidth > scrollContainer.clientWidth + 1,
);
};
updateOverflow();
if (typeof ResizeObserver === 'undefined') {
return undefined;
}
const resizeObserver = new ResizeObserver(updateOverflow);
resizeObserver.observe(viewport);
const scrollContainer =
viewport.querySelector('.table__resizable-container') ||
viewport.querySelector('.data-table-scroll');
if (scrollContainer) resizeObserver.observe(scrollContainer);
return () => resizeObserver.disconnect();
}, [columns, isWide, rows]);
const wide = typeof isWide === 'boolean' ? isWide : hasHorizontalOverflow;
const gridColumns = columns.map((column, index) => {
const isActions = column.isActions || column.key === 'actions';
const isPrimary =
......@@ -112,7 +186,7 @@ export function DataTable({
const numericWidth =
typeof configuredWidth === 'number' ? configuredWidth : undefined;
const pinned = wide
? column.pin || (isActions ? 'end' : undefined)
? column.pin || (isActions ? 'end' : isPrimary ? 'start' : undefined)
: undefined;
const minWidth =
column.minWidth ||
......@@ -139,18 +213,26 @@ export function DataTable({
return (
<DataTableText
text={value}
showTooltip={false}
className={
isPrimary ? 'font-medium' : ''
}
/>
);
}
return value;
return isActions ? value : (
<div className="min-w-0 max-w-full">
{value}
</div>
);
},
isRowHeader: index === 0,
isRowHeader: index === firstBusinessColumnIndex,
allowsSorting: column.sortable === true,
headerClassName: column.headerClassName,
cellClassName: column.cellClassName,
cellClassName: [
'min-w-0',
column.cellClassName,
].filter(Boolean).join(' '),
allowsResizing: true,
minWidth,
width,
......@@ -179,8 +261,8 @@ export function DataTable({
return (
<div className={['relative min-w-0 w-full', className].filter(Boolean).join(' ')}>
<div className="relative min-w-0 w-full">
{isLoading && rows.length > 0 ? (
<div ref={gridViewportRef} className="relative min-w-0 w-full">
{isLoading ? (
<div
className="absolute inset-0 z-10 grid place-items-center"
aria-label="数据加载中"
......@@ -190,7 +272,7 @@ export function DataTable({
) : null}
<DataGrid
showSelectionCheckboxes={selectionEnabled}
allowsColumnResize
allowsColumnResize={allowsColumnResize}
aria-label={ariaLabel}
columns={gridColumns}
contentClassName={wide ? 'min-w-max' : 'min-w-full'}
......@@ -198,7 +280,11 @@ export function DataTable({
disabledKeys={disabledRowKeys}
getRowId={(row) => resolveRowKey(row, rows.indexOf(row), getRowKey)}
renderEmptyState={() =>
isLoading ? <DataTableLoadingState /> : <DataTableEmptyState title={emptyTitle} />
isLoading ? (
<DataTableLoadingState />
) : (
<DataTableEmptyState title={emptyTitle} size={emptyStateSize} />
)
}
scrollContainerClassName="data-table-scroll"
selectedKeys={selectedKeys}
......@@ -239,6 +325,7 @@ export function DataTableActionBar({
return (
<ActionBar
aria-label={ariaLabel}
className="rc-data-grid-action-bar"
isOpen={selectedCount > 0}
>
<ActionBar.Prefix>
......@@ -273,8 +360,10 @@ export function DataTableText({
}) {
const fullText = String(text ?? children ?? '').trim();
const multilineClassName =
maxLines === 2
? 'overflow-hidden whitespace-normal break-words [display:-webkit-box] [-webkit-box-orient:vertical] [-webkit-line-clamp:2] [overflow-wrap:anywhere]'
maxLines > 1
? `overflow-hidden whitespace-normal break-words [display:-webkit-box] [-webkit-box-orient:vertical] ${
maxLines === 3 ? '[-webkit-line-clamp:3]' : '[-webkit-line-clamp:2]'
} [overflow-wrap:anywhere]`
: '';
const trigger = showTooltip ? (
<DataTableTextTooltipTrigger
......@@ -285,16 +374,16 @@ export function DataTableText({
{children ?? (fullText || '-')}
</DataTableTextTooltipTrigger>
) : (
<span className={['block min-w-0 truncate', className].filter(Boolean).join(' ')}>
<span className={[
maxLines > 1 ? multilineClassName : 'truncate',
'block min-w-0 max-w-full overflow-hidden',
className,
].filter(Boolean).join(' ')}>
{children ?? (fullText || '-')}
</span>
);
if (
!fullText ||
fullText === '-' ||
(!showTooltip && fullText.length <= 18)
) {
if (!showTooltip || !fullText || fullText === '-') {
return trigger;
}
......@@ -319,13 +408,18 @@ function DataTableTextTooltipTrigger({
<Tooltip>
<Tooltip.Trigger
className={[
'block min-w-0 max-w-full overflow-hidden',
truncate ? 'min-w-0 truncate' : '',
className,
].filter(Boolean).join(' ')}
>
{children}
</Tooltip.Trigger>
<Tooltip.Content>{text}</Tooltip.Content>
<Tooltip.Content>
<span className="block max-w-[min(32rem,calc(100vw-2rem))] whitespace-pre-wrap break-words">
{text}
</span>
</Tooltip.Content>
</Tooltip>
);
}
......@@ -361,7 +455,6 @@ export function DataTablePrimaryAction({
const handlePress = onPress || onClick;
return (
<Tooltip>
<Button
aria-label={ariaLabel || fullText}
className={className}
......@@ -374,8 +467,6 @@ export function DataTablePrimaryAction({
{children ?? fullText}
</span>
</Button>
<Tooltip.Content>{fullText}</Tooltip.Content>
</Tooltip>
);
}
......@@ -474,8 +565,10 @@ export function DataTableChip({ children, text, className = '' }) {
export function DataTablePagination({
page,
pageSize,
pageSizeOptions = [],
total,
onPageChange,
onPageSizeChange,
isDisabled = false,
itemLabel = '条',
}) {
......@@ -484,13 +577,43 @@ export function DataTablePagination({
const pageWindowEnd = total
? Math.min(totalPages, pageWindowStart + 5)
: pageWindowStart;
const normalizedPageSizeOptions = pageSizeOptions
.map((value) => Number(value))
.filter((value, index, values) => Number.isFinite(value) && value > 0 && values.indexOf(value) === index);
const pageSizeOptionsForPicker = normalizedPageSizeOptions.map((value) => ({
id: String(value),
label: `${value} 条/页`,
}));
const hasPageSizePicker =
pageSizeOptionsForPicker.length > 0 && typeof onPageSizeChange === 'function';
return (
<div className="flex flex-wrap items-center justify-between gap-3">
<Pagination size="sm">
<Pagination.Summary>
共 {total} {itemLabel},第{' '}
{total ? page + 1 : 0} / {total ? totalPages : 0} 页
</Pagination.Summary>
<div className="flex min-w-0 flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 shrink-0 items-center gap-3">
<span className="min-w-0 text-sm text-muted">
共 {total} {itemLabel},第 {total ? page + 1 : 0} / {total ? totalPages : 0} 页
</span>
{hasPageSizePicker ? (
<XinyuanAutocomplete
aria-label="每页条数"
className="w-24 shrink-0"
isDisabled={isDisabled}
options={pageSizeOptionsForPicker}
placeholder="每页条数"
selectedKey={String(pageSize)}
showSearch={false}
valueText={`${pageSize} 条/页`}
variant="secondary"
onSelectionChange={(key) => {
const nextPageSize = Number(key);
if (normalizedPageSizeOptions.includes(nextPageSize)) {
onPageSizeChange(nextPageSize);
}
}}
/>
) : null}
</div>
<Pagination className="ml-auto w-auto shrink-0" size={hasPageSizePicker ? 'lg' : 'sm'}>
<Pagination.Content>
<Pagination.Item>
<Pagination.Previous
......
......@@ -59,6 +59,7 @@ export default function FilterSelect({
const nextValue = key === EMPTY_OPTION_KEY ? '' : String(key ?? '');
onChange?.(nextValue);
}}
onClear={() => onChange?.('')}
valueText={selectedOption?.label || placeholder}
/>
);
......
......@@ -21,7 +21,7 @@ import { Children, Fragment, isValidElement } from 'react';
const ACTION_ICON_CLASS =
'grid size-4 shrink-0 place-items-center [line-height:0] [&>svg]:!m-0 [&>svg]:block [&>svg]:size-4';
const ACTION_BUTTON_LAYOUT_CLASS = 'grid place-items-center rounded-full p-0';
const ACTION_BUTTON_LAYOUT_CLASS = 'grid place-items-center p-0';
const IMPORTANT_ACTION_TONES = new Set(['approve', 'submit']);
function getActionVariant(tone) {
......
......@@ -22,10 +22,14 @@ function defaultFilter(textValue, inputValue) {
function getSelectedKeySet(selectionMode, selectedKey, selectedKeys) {
if (selectionMode === 'multiple') {
return new Set(selectedKeys || []);
return new Set(Array.from(selectedKeys || []).map(toSelectionKey).filter(Boolean));
}
return new Set(selectedKey === null || selectedKey === undefined ? [] : [selectedKey]);
return new Set(
selectedKey === null || selectedKey === undefined
? []
: [toSelectionKey(selectedKey)].filter(Boolean),
);
}
function toSelectionKey(value) {
......@@ -46,6 +50,7 @@ export default function XinyuanAutocomplete({
isRequired = false,
name,
onInputChange,
onClear,
onOpenChange,
onSelectionChange,
options = [],
......@@ -81,13 +86,13 @@ export default function XinyuanAutocomplete({
: placeholder);
const rootSelectionProps =
selectionMode === 'multiple'
? { selectedKeys }
: { selectedKey };
? { selectedKeys: selectedKeyStringSet }
: { selectedKey: selectedKey === null || selectedKey === undefined ? null : toSelectionKey(selectedKey) };
const listBoxSelectionProps =
selectionMode === 'multiple'
? {
selectionMode,
selectedKeys,
selectedKeys: selectedKeyStringSet,
}
: {};
const visibleOptions = isLoading || errorText ? [] : options;
......@@ -165,6 +170,7 @@ export default function XinyuanAutocomplete({
name={name}
selectionMode={selectionMode}
variant={variant}
onClear={onClear || (() => onSelectionChange?.(selectionMode === 'multiple' ? new Set() : null))}
onOpenChange={onOpenChange}
onSelectionChange={onSelectionChange}
>
......@@ -172,6 +178,9 @@ export default function XinyuanAutocomplete({
<Autocomplete.Value className="min-w-0">
{renderValue ? renderValue(selectedOptions) : defaultValue}
</Autocomplete.Value>
<Autocomplete.ClearButton aria-label="清除已选内容">
<Close size={14} />
</Autocomplete.ClearButton>
<Autocomplete.Indicator>
<ChevronDown size={16} style={FIELD_SUFFIX_ICON_STYLE} />
</Autocomplete.Indicator>
......
......@@ -2,7 +2,6 @@ export const APP_DISPLAY_NAME = '监察稽核管理';
export const APP_ROUTE_NAME = 'audit_manager_new';
export const APP_BASE_PATH = '/audit_manager_new/';
export const APP_LOGIN_SUBTITLE = '内部控制与报告归档';
export const THEME_STORAGE_KEY = 'xinyuan_color_theme';
export const APP_NAV_ITEMS = Object.freeze([
{ label: '稽核项目', path: '/audit_process', icon: 'project' },
......
......@@ -394,6 +394,35 @@
white-space: nowrap;
}
.rc-navbar-breadcrumbs {
min-width: 0;
overflow: hidden;
}
.rc-navbar-breadcrumbs .breadcrumbs__item {
min-width: 0;
}
.rc-navbar-breadcrumbs .breadcrumbs__link {
max-width: 100%;
overflow: hidden;
color: var(--muted);
font-size: var(--rc-navbar-title-size);
font-weight: var(--rc-navbar-title-weight);
line-height: var(--rc-navbar-title-line-height);
letter-spacing: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.rc-navbar-breadcrumbs .breadcrumbs__item:last-child .breadcrumbs__link {
color: var(--foreground);
}
.rc-navbar-breadcrumbs .breadcrumbs__separator {
color: var(--muted);
}
.rc-navbar-spacer {
flex: 1;
}
......@@ -435,6 +464,41 @@
gap: var(--rc-page-module-gap);
}
.rc-section-title {
margin: 0;
color: var(--rc-text-primary);
font-size: 1.125rem;
font-weight: var(--rc-font-bold);
line-height: 1.5rem;
letter-spacing: 0;
}
.rc-module-title {
margin: 0;
color: var(--rc-text-primary);
font-size: 1rem;
font-weight: var(--rc-font-semibold);
line-height: 1.5rem;
letter-spacing: 0;
}
.rc-modal-header-actions {
align-self: flex-start;
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.5rem;
margin-inline-start: auto;
max-width: 100%;
}
@media (max-width: 40rem) {
.rc-modal-header-actions {
flex-basis: 100%;
}
}
.rc-page-grid {
min-width: 0;
gap: var(--rc-page-module-gap);
......@@ -453,7 +517,7 @@
grid-column: 1 / -1;
}
/* Recharts keeps keyboard navigation while pointer focus stays visually quiet. */
/* Charts keep keyboard navigation while pointer focus stays visually quiet. */
.rc-chart-interaction:focus:not(:focus-visible),
.rc-chart-interaction :focus:not(:focus-visible) {
outline: none;
......@@ -465,6 +529,10 @@
outline-offset: 2px;
}
.rc-echarts-tooltip {
position: fixed !important;
}
.rc-kpi-grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
......@@ -509,10 +577,33 @@
box-shadow: var(--rc-shadow-card);
}
.rc-filter-surface {
border-radius: var(--rc-radius-overlay);
}
.rc-dashboard-card {
border-radius: 1.5rem;
}
.rc-workspace-module {
border-radius: 1.5rem;
border: 1px solid var(--border);
background: transparent;
box-shadow: none;
}
.rc-empty-state-message {
color: var(--rc-text-empty);
font-size: var(--text-sm);
font-weight: var(--font-weight-normal);
}
.rc-empty-field-text {
color: var(--rc-text-empty);
font-size: var(--text-sm);
font-weight: var(--font-weight-normal);
}
/* HeroUI Pro KPI.Icon keeps its compound role; this class supplies Xinyuan tones. */
.rc-metric-icon-surface {
--rc-metric-icon-color: var(--rc-metric-tone-neutral);
......@@ -655,6 +746,116 @@
gap: var(--rc-list-stack-gap);
}
/* Keep long data sets inside the table while the column headings remain visible. */
.rc-data-table-sticky .data-table-scroll {
position: relative;
max-height: min(60dvh, 42rem);
overflow-x: auto;
overflow-y: auto;
overscroll-behavior: contain;
}
/* DataGrid adds a non-scrolling resize wrapper. Let sticky headers use the
outer scroll container as their containing block instead of this wrapper. */
.rc-data-table-sticky .table__resizable-container {
overflow: visible;
}
.rc-data-table-sticky [data-slot='table-header'] .table__column {
position: sticky;
top: 0;
inset-block-start: 0;
z-index: 4;
display: table-cell;
color: var(--muted);
background: var(--surface-secondary);
box-shadow: inset 0 -1px 0 var(--separator);
}
.rc-data-table-sticky [data-slot='table-header'] .table__column.bg-accent-soft {
background: var(--accent-soft);
color: var(--accent-soft-foreground);
}
/* The leading pinned column must sit above scrolling body cells. Keep its
surface opaque so text from adjacent columns cannot bleed through while
horizontal or vertical scrolling. */
.rc-data-table-sticky [data-pinned='start'] {
position: sticky;
z-index: 6;
background: var(--surface);
}
.rc-data-table-sticky [data-slot='table-header'] [data-pinned='start'] {
z-index: 8;
background: var(--surface-secondary);
}
/* Mirror HeroUI's row states on the opaque pinned surface so the leading
column still responds to hover and selection instead of looking detached. */
@media (hover: hover) {
.rc-data-table-sticky [data-slot='data-grid']
.table__row:hover
[data-pinned='start'].table__cell,
.rc-data-table-sticky [data-slot='data-grid']
.table__row[data-hovered='true']
[data-pinned='start'].table__cell {
/* Keep the pinned cell opaque; transparent hover fills let scrolled
sibling-cell text show through the fixed column. */
background-color: var(--color-surface-secondary);
}
@supports (color: color-mix(in oklab, white, black)) {
.rc-data-table-sticky [data-slot='data-grid']
.table__row:hover
[data-pinned='start'].table__cell,
.rc-data-table-sticky [data-slot='data-grid']
.table__row[data-hovered='true']
[data-pinned='start'].table__cell {
background-color: color-mix(
in oklab,
var(--color-surface) 40%,
var(--color-surface-secondary)
);
}
}
}
.rc-data-table-sticky [data-slot='data-grid']
.table__row[data-selected='true']
[data-pinned='start'].table__cell {
background-color: var(--color-surface-secondary);
}
@supports (color: color-mix(in oklab, white, black)) {
.rc-data-table-sticky [data-slot='data-grid']
.table__row[data-selected='true']
[data-pinned='start'].table__cell {
background-color: color-mix(
in oklab,
var(--color-surface) 10%,
var(--color-surface-secondary)
);
}
}
@media (max-width: 48rem) {
.rc-data-table-sticky .data-table-scroll {
max-height: min(54dvh, 32rem);
}
}
@media (min-width: 769px) {
.action-bar:has(.rc-data-grid-action-bar) {
inset-inline: 0;
}
body:has(.rc-app-shell[data-state='expanded'])
.action-bar:has(.rc-data-grid-action-bar) {
inset-inline-start: var(--rc-sidebar-width);
}
}
@media (min-width: 80rem) {
.rc-chart-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
......@@ -862,6 +1063,23 @@
font-weight: var(--rc-font-semibold);
}
.rc-application-identity-well {
display: grid;
width: 2.25rem;
height: 2.25rem;
flex: none;
place-items: center;
overflow: hidden;
border-radius: var(--rc-radius-control);
}
.rc-application-identity-avatar {
width: 100%;
height: 100%;
border-radius: var(--rc-radius-control);
background: transparent;
}
.rc-app-shell .sidebar__content,
.rc-mobile-sidebar .sidebar__content {
min-height: 0;
......@@ -1185,12 +1403,12 @@
place-items: center;
padding: 0;
border-radius: 50%;
color: var(--foreground);
color: var(--rc-sidebar-foreground);
background-color: transparent;
line-height: 1;
--button-bg-hover: var(--accent-soft);
--button-bg-pressed: var(--accent-soft);
--button-fg: var(--foreground);
--button-bg-hover: var(--rc-sidebar-item-selected);
--button-bg-pressed: var(--rc-sidebar-item-selected);
--button-fg: var(--rc-sidebar-item-selected-foreground);
}
.app-sidebar-app-trigger .anticon {
......@@ -1210,16 +1428,16 @@
@media (hover: hover) {
.app-sidebar-app-trigger.button:hover,
.app-sidebar-app-trigger.button[data-hovered='true'] {
color: var(--foreground);
background-color: var(--accent-soft);
color: var(--rc-sidebar-item-selected-foreground);
background-color: var(--rc-sidebar-item-selected);
}
}
.app-sidebar-app-trigger.button:active,
.app-sidebar-app-trigger.button[data-pressed='true'],
.app-sidebar-app-trigger.button[aria-expanded='true'] {
color: var(--foreground);
background-color: var(--accent-soft);
color: var(--rc-sidebar-item-selected-foreground);
background-color: var(--rc-sidebar-item-selected);
}
.app-sidebar-app-dialog {
......@@ -1397,25 +1615,95 @@
line-height: 1rem;
}
.rc-app-switcher-dialog {
min-width: 0;
width: min(46rem, calc(100vw - 2rem));
max-width: min(46rem, calc(100vw - 2rem));
max-height: min(42rem, calc(100dvh - 2rem));
@media (max-width: 768px) {
.rc-mobile-sidebar.app-sidebar-glass {
width: 100%;
border-right: 0;
}
}
.rc-app-identity-well,
.rc-app-identity-avatar {
border-radius: var(--rc-radius-control);
/* Fixed split-shell theme: dark navigation, permanently light content canvas. */
.template-dashboard-shell .app-sidebar-glass,
.rc-mobile-sidebar.app-sidebar-glass {
color: var(--rc-sidebar-foreground);
border-color: var(--rc-sidebar-border) !important;
background-color: var(--rc-sidebar-background) !important;
}
.template-dashboard-shell
.sidebar__menu-item[data-current='true']
.sidebar__menu-item-content,
.rc-mobile-sidebar
.sidebar__menu-item[data-current='true']
.sidebar__menu-item-content {
background: var(--rc-sidebar-item-selected);
}
.template-dashboard-shell
.sidebar__menu-item[data-current='true']
.sidebar__menu-item-content,
.template-dashboard-shell
.sidebar__menu-item[data-current='true']
.sidebar__menu-item-content *,
.rc-mobile-sidebar
.sidebar__menu-item[data-current='true']
.sidebar__menu-item-content,
.rc-mobile-sidebar
.sidebar__menu-item[data-current='true']
.sidebar__menu-item-content * {
color: var(--rc-sidebar-item-selected-foreground) !important;
}
.template-dashboard-shell .sidebar__menu-item:hover .sidebar__menu-item-content,
.template-dashboard-shell
.sidebar__menu-item[data-hovered='true']
.sidebar__menu-item-content,
.rc-mobile-sidebar .sidebar__menu-item:hover .sidebar__menu-item-content,
.rc-mobile-sidebar
.sidebar__menu-item[data-hovered='true']
.sidebar__menu-item-content,
.app-sidebar-user-trigger.dropdown__trigger:hover,
.app-sidebar-user-trigger.dropdown__trigger[data-hovered='true'] {
background: var(--rc-sidebar-item-hover);
}
.app-sidebar-user-trigger.dropdown__trigger[aria-expanded='true'],
.app-sidebar-app-trigger.button[data-pressed='true'],
.app-sidebar-app-trigger.button[aria-expanded='true'] {
background: var(--rc-sidebar-item-selected);
}
.rc-app-identity-avatar {
.app-sidebar-user-avatar,
.app-sidebar-app-trigger.button,
.app-sidebar-user-trigger.dropdown__trigger {
color: var(--rc-sidebar-foreground);
background: transparent;
}
@media (max-width: 768px) {
.rc-mobile-sidebar.app-sidebar-glass {
width: 100%;
border-right: 0;
}
.app-sidebar-user-avatar {
background: var(--rc-sidebar-item-hover);
}
.template-dashboard-shell .sidebar__group-label,
.rc-mobile-sidebar .sidebar__group-label,
.template-dashboard-shell .sidebar__menu-icon,
.rc-mobile-sidebar .sidebar__menu-icon,
.app-sidebar-user-description {
color: var(--rc-sidebar-muted);
}
.template-dashboard-shell .sidebar__menu-label,
.rc-mobile-sidebar .sidebar__menu-label,
.app-sidebar-title,
.app-sidebar-user-name,
.app-sidebar-app-trigger.button {
color: var(--rc-sidebar-foreground);
}
.app-sidebar-user-trigger.dropdown__trigger:focus-visible,
.app-sidebar-app-trigger.button:focus-visible,
.template-dashboard-shell .sidebar__menu-item:focus-visible,
.rc-mobile-sidebar .sidebar__menu-item:focus-visible {
outline: 2px solid var(--rc-sidebar-focus);
outline-offset: 2px;
}
......@@ -11,38 +11,66 @@
[data-theme="light"],
[data-theme="default"] {
/* Theme Colors (Light Mode) */
--accent: oklch(45.32% 0.1661 27.03);
/* Fixed Xinyuan brand scale. HeroUI v3 consumes --accent for primary
* controls; these aliases keep every integration on the same red family. */
--primary-50: #FDF0EF;
--primary-100: #FBE1DF;
--primary-200: #F7C4C0;
--primary-300: #F29A93;
--primary-400: #EA5C52;
--primary-500: #DA241A;
--primary-600: #C91F17;
--primary-700: #B51B14;
--primary-800: #951710;
--primary-900: #74110D;
--primary-950: #4B0B08;
--primary: var(--primary-500);
--primary-foreground: #FFFFFF;
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-primary-50: var(--primary-50);
--color-primary-100: var(--primary-100);
--color-primary-200: var(--primary-200);
--color-primary-300: var(--primary-300);
--color-primary-400: var(--primary-400);
--color-primary-500: var(--primary-500);
--color-primary-600: var(--primary-600);
--color-primary-700: var(--primary-700);
--color-primary-800: var(--primary-800);
--color-primary-900: var(--primary-900);
--color-primary-950: var(--primary-950);
--accent: #DA241A;
--accent-hover: #C91F17;
--accent-foreground: oklch(99.11% 0 0);
--background: oklch(97.02% 0.0045 27.03);
--border: oklch(90.00% 0.0045 27.03);
--background: oklch(97.02% 0.0000 29.20);
--border: oklch(90.00% 0.0000 29.20);
--danger: oklch(45.32% 0.1661 27.03);
--danger-foreground: oklch(99.11% 0 0);
/* HeroUI control aliases: primary = L1, secondary = L2. */
--default: var(--surface-secondary);
--default-foreground: var(--surface-secondary-foreground);
--field-background: var(--surface);
--default: oklch(94.00% 0.0000 29.20);
--default-foreground: oklch(21.03% 0.0059 29.20);
--field-background: oklch(100.00% 0.0000 29.20);
--field-border: transparent;
--field-foreground: var(--surface-foreground);
--field-placeholder: oklch(55.17% 0.0090 27.03);
--focus: oklch(45.32% 0.1661 27.03);
--foreground: oklch(21.03% 0.0045 27.03);
--muted: oklch(55.17% 0.0090 27.03);
--overlay: var(--surface);
--overlay-foreground: var(--surface-foreground);
--scrollbar: oklch(87.10% 0.0045 27.03);
--segment: oklch(100.00% 0.0045 27.03);
--segment-foreground: oklch(21.03% 0.0045 27.03);
--separator: oklch(92.00% 0.0045 27.03);
--success: oklch(73.29% 0.1952 123.59);
--success-foreground: oklch(21.03% 0.0059 123.59);
--surface: oklch(100.00% 0.0022 27.03);
--surface-foreground: oklch(21.03% 0.0045 27.03);
--surface-secondary: oklch(95.24% 0.0036 27.03);
--surface-secondary-foreground: oklch(21.03% 0.0045 27.03);
--surface-tertiary: oklch(93.73% 0.0036 27.03);
--surface-tertiary-foreground: oklch(21.03% 0.0045 27.03);
--warning: oklch(78.19% 0.1599 45.11);
--warning-foreground: oklch(21.03% 0.0059 45.11);
--field-foreground: oklch(21.03% 0.0000 29.20);
--field-placeholder: oklch(55.17% 0.0000 29.20);
--focus: #DA241A;
--foreground: oklch(21.03% 0.0000 29.20);
--muted: oklch(55.17% 0.0000 29.20);
--overlay: oklch(100.00% 0.0000 29.20);
--overlay-foreground: oklch(21.03% 0.0000 29.20);
--scrollbar: oklch(87.10% 0.0000 29.20);
--segment: oklch(100.00% 0.0000 29.20);
--segment-foreground: oklch(21.03% 0.0000 29.20);
--separator: oklch(92.00% 0.0000 29.20);
--success: #61758F;
--success-foreground: #FFFFFF;
--surface: oklch(100.00% 0.0000 29.20);
--surface-foreground: oklch(21.03% 0.0000 29.20);
--surface-secondary: oklch(95.24% 0.0000 29.20);
--surface-secondary-foreground: oklch(21.03% 0.0000 29.20);
--surface-tertiary: oklch(93.73% 0.0000 29.20);
--surface-tertiary-foreground: oklch(21.03% 0.0000 29.20);
--warning: oklch(78.19% 0.1585 45.37);
--warning-foreground: oklch(21.03% 0.0059 45.37);
/* Border Radius */
--radius: 0.5rem;
......@@ -51,42 +79,117 @@
/* Font Family */
/* Make sure to load Inter font in your app */
--font-sans: var(--font-inter);
/*
* Xinyuan fixed split-shell contract.
* The application canvas always uses this light theme. The Sidebar is the
* only persistent dark plane and must consume these semantic tokens.
*/
--xinyuan-sidebar-background: oklch(16% 0.020 255);
--xinyuan-sidebar-border: oklch(26% 0.022 255);
--xinyuan-sidebar-foreground: oklch(97% 0.004 255);
--xinyuan-sidebar-muted: oklch(76% 0.018 255);
--xinyuan-sidebar-item-hover: oklch(22% 0.026 255);
--xinyuan-sidebar-item-selected: oklch(28% 0.016 255);
--xinyuan-sidebar-item-selected-foreground: oklch(99% 0.002 255);
--xinyuan-sidebar-focus: #DA241A;
/*
* ECharts is the required chart renderer for Xinyuan applications.
* Ant Design remains the icon source. Resolve these CSS variables to
* concrete colors before assigning them to an ECharts option object.
*/
/* Muted categorical palette: brand reds -> warm neutrals -> cool blue-grays. */
--xinyuan-chart-1: #DA241A;
--xinyuan-chart-2: #B83A32;
--xinyuan-chart-3: #C95A52;
--xinyuan-chart-4: #D47A70;
--xinyuan-chart-5: #A65A45;
--xinyuan-chart-6: #B87958;
--xinyuan-chart-7: #C99772;
--xinyuan-chart-8: #6F6674;
--xinyuan-chart-9: #807783;
--xinyuan-chart-10: #938B96;
--xinyuan-chart-11: #4F6380;
--xinyuan-chart-12: #61758F;
--xinyuan-chart-13: #74879A;
--xinyuan-chart-14: #8A6F63;
--xinyuan-chart-15: #9A8174;
--xinyuan-chart-16: #5B5964;
--xinyuan-chart-sector-gap: var(--surface);
--xinyuan-chart-sector-radius: 10px;
--xinyuan-chart-grid: #e8e8e8;
--xinyuan-chart-axis: #737373;
--xinyuan-chart-label: #737373;
--xinyuan-chart-tooltip-background: #ffffff;
--xinyuan-chart-tooltip-border: #dedede;
--xinyuan-chart-tooltip-foreground: #222222;
--xinyuan-chart-tooltip-shadow: 0 16px 40px rgb(15 23 42 / 14%);
--xinyuan-chart-legend-font-size: 0.75rem;
--xinyuan-chart-legend-line-height: 1rem;
}
.dark,
[data-theme="dark"] {
color-scheme: dark;
/* Theme Colors (Dark Mode) */
--accent: oklch(45.32% 0.1661 27.03);
/* Keep the brand scale stable when a dark portal or integration is active. */
--primary-50: #FDF0EF;
--primary-100: #FBE1DF;
--primary-200: #F7C4C0;
--primary-300: #F29A93;
--primary-400: #EA5C52;
--primary-500: #DA241A;
--primary-600: #C91F17;
--primary-700: #B51B14;
--primary-800: #951710;
--primary-900: #74110D;
--primary-950: #4B0B08;
--primary: var(--primary-500);
--primary-foreground: #FFFFFF;
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-primary-50: var(--primary-50);
--color-primary-100: var(--primary-100);
--color-primary-200: var(--primary-200);
--color-primary-300: var(--primary-300);
--color-primary-400: var(--primary-400);
--color-primary-500: var(--primary-500);
--color-primary-600: var(--primary-600);
--color-primary-700: var(--primary-700);
--color-primary-800: var(--primary-800);
--color-primary-900: var(--primary-900);
--color-primary-950: var(--primary-950);
--accent: #DA241A;
--accent-hover: #C91F17;
--accent-foreground: oklch(99.11% 0 0);
--background: oklch(12.00% 0.0045 27.03);
--border: oklch(28.00% 0.0045 27.03);
--danger: oklch(62.00% 0.1600 27.03);
--background: oklch(12.00% 0.0000 29.20);
--border: oklch(28.00% 0.0000 29.20);
--danger: oklch(62% 0.16 27.03);
--danger-foreground: oklch(99.11% 0 0);
/* HeroUI control aliases: primary = L1, secondary = L2. */
--default: var(--surface-secondary);
--default-foreground: var(--surface-secondary-foreground);
--field-background: var(--surface);
--default: oklch(27.40% 0.0000 29.20);
--default-foreground: oklch(99.11% 0 0);
--field-background: oklch(21.03% 0.0000 29.20);
--field-border: transparent;
--field-foreground: var(--surface-foreground);
--field-placeholder: oklch(70.50% 0.0090 27.03);
--focus: oklch(45.32% 0.1661 27.03);
--foreground: oklch(99.11% 0.0045 27.03);
--muted: oklch(70.50% 0.0090 27.03);
--overlay: var(--surface);
--overlay-foreground: var(--surface-foreground);
--scrollbar: oklch(70.50% 0.0045 27.03);
--segment: oklch(39.64% 0.0045 27.03);
--segment-foreground: oklch(99.11% 0.0045 27.03);
--separator: oklch(25.00% 0.0045 27.03);
--success: oklch(73.29% 0.1952 123.59);
--success-foreground: oklch(21.03% 0.0059 123.59);
--surface: oklch(21.03% 0.0090 27.03);
--surface-foreground: oklch(99.11% 0.0045 27.03);
--surface-secondary: oklch(25.70% 0.0067 27.03);
--surface-secondary-foreground: oklch(99.11% 0.0045 27.03);
--surface-tertiary: oklch(27.21% 0.0067 27.03);
--surface-tertiary-foreground: oklch(99.11% 0.0045 27.03);
--warning: oklch(82.03% 0.1400 49.12);
--warning-foreground: oklch(21.03% 0.0059 49.12);
--field-foreground: oklch(99.11% 0.0000 29.20);
--field-placeholder: oklch(70.50% 0.0000 29.20);
--focus: #DA241A;
--foreground: oklch(99.11% 0.0000 29.20);
--muted: oklch(70.50% 0.0000 29.20);
--overlay: oklch(21.03% 0.0000 29.20);
--overlay-foreground: oklch(99.11% 0.0000 29.20);
--scrollbar: oklch(70.50% 0.0000 29.20);
--segment: oklch(39.64% 0.0000 29.20);
--segment-foreground: oklch(99.11% 0.0000 29.20);
--separator: oklch(25.00% 0.0000 29.20);
--success: #74879A;
--success-foreground: #FFFFFF;
--surface: oklch(21.03% 0.0000 29.20);
--surface-foreground: oklch(99.11% 0.0000 29.20);
--surface-secondary: oklch(25.70% 0.0000 29.20);
--surface-secondary-foreground: oklch(99.11% 0.0000 29.20);
--surface-tertiary: oklch(27.21% 0.0000 29.20);
--surface-tertiary-foreground: oklch(99.11% 0.0000 29.20);
--warning: oklch(82.03% 0.1388 49.38);
--warning-foreground: oklch(21.03% 0.0059 49.38);
}
......@@ -2,11 +2,41 @@
.light,
.default,
[data-theme='light'],
[data-theme='default'],
.dark,
[data-theme='dark'] {
[data-theme='default'] {
/* Xinyuan aliases and extension tokens. HeroUI core tokens are owned by theme.css. */
--rc-brand-primary: var(--accent);
/* HeroUI v3 calls the brand channel accent; keep an explicit primary scale
for Tailwind integrations and chart/card consumers. */
--primary: #DA241A;
--primary-foreground: #FFFFFF;
--primary-hover: #C91F17;
--primary-pressed: #B51B14;
--primary-50: #FDF0EF;
--primary-100: #FBE1DF;
--primary-200: #F7C4C0;
--primary-300: #F29A93;
--primary-400: #EA5C52;
--primary-500: #DA241A;
--primary-600: #C91F17;
--primary-700: #B51B14;
--primary-800: #951710;
--primary-900: #74110D;
--primary-950: #4B0B08;
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-primary-hover: var(--primary-hover);
--color-primary-pressed: var(--primary-pressed);
--color-primary-50: var(--primary-50);
--color-primary-100: var(--primary-100);
--color-primary-200: var(--primary-200);
--color-primary-300: var(--primary-300);
--color-primary-400: var(--primary-400);
--color-primary-500: var(--primary-500);
--color-primary-600: var(--primary-600);
--color-primary-700: var(--primary-700);
--color-primary-800: var(--primary-800);
--color-primary-900: var(--primary-900);
--color-primary-950: var(--primary-950);
--rc-brand-light: color-mix(in oklab, var(--accent) 84%, white);
--rc-brand-dark: color-mix(in oklab, var(--accent) 82%, black);
......@@ -27,6 +57,17 @@
var(--background)
);
--rc-text-muted: var(--muted);
--rc-text-empty: color-mix(in oklab, var(--muted) 72%, var(--background));
/* Fixed dark Sidebar aliases. The content canvas remains light. */
--rc-sidebar-background: var(--xinyuan-sidebar-background);
--rc-sidebar-border: var(--xinyuan-sidebar-border);
--rc-sidebar-foreground: var(--xinyuan-sidebar-foreground);
--rc-sidebar-muted: var(--xinyuan-sidebar-muted);
--rc-sidebar-item-hover: var(--xinyuan-sidebar-item-hover);
--rc-sidebar-item-selected: var(--xinyuan-sidebar-item-selected);
--rc-sidebar-item-selected-foreground: var(--xinyuan-sidebar-item-selected-foreground);
--rc-sidebar-focus: var(--xinyuan-sidebar-focus);
--rc-font-sans: var(--font-sans);
--rc-font-medium: 500;
......@@ -45,6 +86,7 @@
/* Page geometry only. Component appearance remains owned by HeroUI. */
--rc-shell-inline-gutter: 3rem;
--rc-sidebar-width: 15rem;
--rc-navbar-inline-padding: var(--rc-shell-inline-gutter);
--rc-page-inline-start: var(--rc-shell-inline-gutter);
--rc-page-inline-end: var(--rc-shell-inline-gutter);
......@@ -105,15 +147,15 @@
0 4px 12px rgb(15 23 42 / 2%);
/* KPI icon surfaces. Card bodies remain neutral; only the icon well carries tone. */
--rc-metric-tone-info: oklch(55% 0.14 255);
--rc-metric-tone-success: oklch(54% 0.13 145);
--rc-metric-tone-teal: oklch(55% 0.1 205);
--rc-metric-tone-danger: oklch(52% 0.17 25);
--rc-metric-tone-warning: oklch(62% 0.12 75);
--rc-metric-tone-violet: oklch(56% 0.12 300);
--rc-metric-tone-indigo: oklch(53% 0.13 275);
--rc-metric-tone-coral: oklch(60% 0.12 48);
--rc-metric-tone-neutral: var(--muted);
--rc-metric-tone-info: var(--rc-chart-11);
--rc-metric-tone-success: var(--rc-chart-6);
--rc-metric-tone-teal: var(--rc-chart-12);
--rc-metric-tone-danger: var(--rc-chart-primary);
--rc-metric-tone-warning: var(--rc-chart-5);
--rc-metric-tone-violet: var(--rc-chart-8);
--rc-metric-tone-indigo: var(--rc-chart-13);
--rc-metric-tone-coral: var(--rc-chart-3);
--rc-metric-tone-neutral: var(--rc-chart-10);
--rc-metric-tone-info-soft: color-mix(
in oklab,
var(--rc-metric-tone-info) 9%,
......@@ -161,40 +203,28 @@
);
/* Data visualization only. Do not reuse UI status colors as a series palette. */
--rc-chart-1: oklch(45.32% 0.1661 27.03);
--rc-chart-2: oklch(56% 0.135 255);
--rc-chart-3: oklch(64% 0.11 220);
--rc-chart-4: oklch(56% 0.115 280);
--rc-chart-5: oklch(60% 0.095 200);
--rc-chart-6: oklch(70% 0.12 80);
--rc-chart-7: oklch(60% 0.1 295);
--rc-chart-8: oklch(57% 0.035 250);
--rc-chart-1: var(--xinyuan-chart-1);
--rc-chart-2: var(--xinyuan-chart-2);
--rc-chart-3: var(--xinyuan-chart-3);
--rc-chart-4: var(--xinyuan-chart-4);
--rc-chart-5: var(--xinyuan-chart-5);
--rc-chart-6: var(--xinyuan-chart-6);
--rc-chart-7: var(--xinyuan-chart-7);
--rc-chart-8: var(--xinyuan-chart-8);
--rc-chart-9: var(--xinyuan-chart-9);
--rc-chart-10: var(--xinyuan-chart-10);
--rc-chart-11: var(--xinyuan-chart-11);
--rc-chart-12: var(--xinyuan-chart-12);
--rc-chart-13: var(--xinyuan-chart-13);
--rc-chart-14: var(--xinyuan-chart-14);
--rc-chart-15: var(--xinyuan-chart-15);
--rc-chart-16: var(--xinyuan-chart-16);
--rc-chart-primary: var(--rc-chart-1);
--rc-chart-risk: oklch(47% 0.145 27.03);
--rc-chart-warning: var(--rc-chart-6);
--rc-chart-positive: var(--rc-chart-2);
--rc-chart-comparison: var(--rc-chart-4);
--rc-chart-neutral: var(--rc-chart-8);
--rc-chart-legend-font-size: 0.75rem;
--rc-chart-legend-line-height: 1rem;
}
.dark,
[data-theme='dark'] {
color-scheme: dark;
--rc-text-secondary: color-mix(
in oklab,
var(--foreground) 76%,
var(--background)
);
--rc-button-secondary-border: color-mix(
in oklab,
var(--foreground) 18%,
transparent
);
--rc-shadow-raised: 0 1px 2px rgb(0 0 0 / 24%),
0 8px 24px rgb(0 0 0 / 24%);
--rc-shadow-overlay: 0 16px 40px rgb(0 0 0 / 38%);
--rc-shadow-card: 0 1px 2px rgb(0 0 0 / 20%),
0 4px 12px rgb(0 0 0 / 18%);
--rc-chart-risk: var(--rc-chart-1);
--rc-chart-warning: var(--rc-chart-3);
--rc-chart-positive: var(--rc-chart-12);
--rc-chart-comparison: var(--rc-chart-11);
--rc-chart-neutral: var(--rc-chart-10);
--rc-chart-legend-font-size: var(--xinyuan-chart-legend-font-size);
--rc-chart-legend-line-height: var(--xinyuan-chart-legend-line-height);
}
......@@ -4,12 +4,14 @@ import { Checkbox } from '@/catalyst-ui-kit/javascript/checkbox';
import { Input } from '@/catalyst-ui-kit/javascript/input';
import { Select } from '@/catalyst-ui-kit/javascript/select';
import { Textarea } from '@/catalyst-ui-kit/javascript/textarea';
import { Chip } from '@heroui/react';
import { queryUsers, uploadFile } from '@/services/DataService';
import ReferenceSearchSelect from './ReferenceSearchSelect';
import ReferenceRecordsTable from './ReferenceRecordsTable';
import {
dateInputValue,
formatFieldValue,
getFormValueSummaries,
getSelectOptions,
isDateFieldType,
isReferenceFieldType,
......@@ -103,6 +105,20 @@ function FieldValue({ value, fieldDef }) {
}
if (fieldDef?.type === 'object' || fieldDef?.type === 'form' || fieldDef?.type === 'subject') {
if (fieldDef?.type === 'form') {
const summaries = getFormValueSummaries(value, fieldDef?.label || '表单');
return (
<div className="flex min-w-0 flex-col gap-2">
{summaries.map((summary) => (
<div className="flex min-w-0 items-center justify-between gap-3" key={summary.key}>
<span className="min-w-0 truncate" title={summary.label}>{summary.label}</span>
<Chip size="sm" variant="secondary">{summary.rowCount} 条记录</Chip>
</div>
))}
</div>
);
}
return (
<pre className="max-h-48 overflow-auto rounded-2xl bg-zinc-950 px-4 py-3 text-xs leading-5 text-zinc-100">
{JSON.stringify(value, null, 2)}
......
import { DataTable, DataTableChip, DataTableTextPair } from '@/components/DataTable';
import FilterSelect from '@/components/FilterSelect';
import { Delete, Edit, Eye, Search } from '@/components/AppIcons';
import { TableActionButton, TableActionGroup } from '@/components/TableActionButton';
import { useAuthorization } from '@/hooks/user';
import { AlertDialog, Button, FieldError, Input, Label, Modal, SearchField, Tabs, TextArea, TextField, Tooltip } from '@heroui/react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { AlertDialog, Button, Modal, SearchField, Tabs, TextArea, TextField, FieldError, Input, Label } from '@heroui/react';
import { useCallback, useEffect, useState } from 'react';
import { EmptyState, ErrorState, InlineNotice, LoadingState } from './PageState';
import ReferenceSearchSelect from './ReferenceSearchSelect';
import ControlPointPreviewDialog from './ControlPointPreviewDialog';
import UnifiedControlPointDialog from './ControlPointDialog';
import {
AUDIT_SUBJECTS,
createAuditProcess,
deleteAuditProcess,
loadAuditDefinitions,
normalizeRecordId,
queryAuditProcesses,
saveAuditControlPoints,
searchReferenceRecords,
searchTags,
} from './auditManagementService';
const EMPTY_PROJECT_FORM = {
......@@ -26,36 +21,10 @@ const EMPTY_PROJECT_FORM = {
description: '',
};
const EMPTY_POINT_FORM = {
name: '',
description: '',
type: '',
owner: null,
related_tags: [],
related_regulations: [],
form_label: '',
attachment_label: [''],
indicator: null,
computed_interval: '',
};
function fieldByName(definition, name) {
return (definition?.fields || []).find((field) => field.name === name);
}
function requiredLabel(label, required) {
return (
<>
{label}
{required ? <span aria-hidden="true"> *</span> : null}
</>
);
}
function compactLabels(labels) {
return (labels || []).map((label) => label.trim()).filter(Boolean);
}
function AuditProjectDialog({ open, onClose, onSaved }) {
const [form, setForm] = useState(EMPTY_PROJECT_FORM);
const [saving, setSaving] = useState(false);
......@@ -120,181 +89,6 @@ function AuditProjectDialog({ open, onClose, onSaved }) {
);
}
function ControlPointDialog({ open, onClose, onSaved, point, definition }) {
const typeField = fieldByName(definition, 'type');
const typeOptions = typeField?.settings?.options || [];
const [form, setForm] = useState(EMPTY_POINT_FORM);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const loadDepartmentOptions = useCallback(
(keyword) => searchReferenceRecords('department', keyword),
[],
);
const loadRegulationOptions = useCallback(
(keyword) => searchReferenceRecords('internal_regulation', keyword),
[],
);
const loadIndicatorOptions = useCallback(
(keyword) => searchReferenceRecords(AUDIT_SUBJECTS.INDICATOR, keyword),
[],
);
useEffect(() => {
if (!open) return;
setForm({
...EMPTY_POINT_FORM,
...(point || {}),
type: point?.type || typeOptions[0]?.value || '',
attachment_label: point?.attachment_label?.length ? [...point.attachment_label] : [''],
});
setError('');
}, [open, point, typeOptions]);
const change = (name, value) => setForm((current) => ({ ...current, [name]: value }));
const handleSave = async () => {
if (!form.name.trim()) {
setError('请输入控制点名称');
return;
}
if (!normalizeRecordId(form.owner)) {
setError('请选择责任人');
return;
}
setSaving(true);
setError('');
try {
await onSaved({
...form,
name: form.name.trim(),
description: form.description.trim(),
owner: normalizeRecordId(form.owner),
related_tags: (form.related_tags || []).map(normalizeRecordId).filter(Boolean),
related_regulations: (form.related_regulations || []).map(normalizeRecordId).filter(Boolean),
indicator: normalizeRecordId(form.indicator) || null,
computed_interval: form.computed_interval === '' ? null : Number(form.computed_interval),
attachment_label: compactLabels(form.attachment_label),
});
} catch (requestError) {
setError(requestError?.message || '保存控制点失败');
} finally {
setSaving(false);
}
};
return (
<Modal.Root isOpen={open} onOpenChange={(isOpen) => { if (!isOpen && !saving) onClose(); }}>
<Modal.Trigger aria-hidden="true" aria-label={`打开${point ? '编辑' : '添加'}控制点弹窗`} className="sr-only" tabIndex={-1} />
<Modal.Backdrop className="rc-dialog-backdrop" isDismissable={!saving} variant="blur">
<Modal.Container placement="center" scroll="inside" size="lg">
<Modal.Dialog className="rc-dialog-panel rc-dialog-panel--3xl">
<Modal.CloseTrigger aria-label="关闭" className="rc-dialog-close" />
<Modal.Header><Modal.Heading className="rc-dialog-title">{point ? '编辑控制点' : '添加控制点'}</Modal.Heading></Modal.Header>
<Modal.Body className="rc-dialog-body grid gap-5 sm:grid-cols-2">
<TextField className="rc-form-field sm:col-span-2" isRequired value={form.name} onChange={(value) => change('name', value)}>
<Label>名称</Label>
<Input placeholder="请输入控制点名称" variant="secondary" />
</TextField>
<TextField className="rc-form-field sm:col-span-2" value={form.description} onChange={(value) => change('description', value)}>
<Label>描述信息</Label>
<TextArea placeholder="请输入控制点描述" rows={4} variant="secondary" />
</TextField>
<div className="rc-form-field">
<Label>{requiredLabel('责任人', true)}</Label>
<ReferenceSearchSelect
value={form.owner}
onChange={(value) => change('owner', value)}
loadOptions={loadDepartmentOptions}
placeholder="搜索责任部门"
/>
</div>
<div className="rc-form-field">
<Label>相关标签</Label>
<ReferenceSearchSelect
multiple
value={form.related_tags}
onChange={(value) => change('related_tags', value)}
loadOptions={searchTags}
placeholder="搜索并添加标签"
/>
</div>
<div className="rc-form-field sm:col-span-2">
<Label>相关制度</Label>
<ReferenceSearchSelect
multiple
value={form.related_regulations}
onChange={(value) => change('related_regulations', value)}
loadOptions={loadRegulationOptions}
placeholder="搜索并添加制度"
/>
</div>
<div className="rc-form-field">
<Label>{requiredLabel('控制措施', Boolean(typeField?.required))}</Label>
<FilterSelect aria-label="控制措施" options={typeOptions} value={form.type} variant="secondary" onChange={(value) => change('type', value)} />
</div>
{form.type === 'form' ? (
<TextField className="rc-form-field" value={form.form_label} onChange={(value) => change('form_label', value)}>
<Label>文本描述</Label>
<Input placeholder="请输入文本描述" variant="secondary" />
</TextField>
) : null}
{form.type === 'attachment' ? (
<div className="rc-form-field sm:col-span-2">
<Label>文件描述</Label>
<div className="space-y-2">
{form.attachment_label.map((label, index) => (
<div key={index} className="flex items-center gap-2">
<TextField aria-label={`文件描述 ${index + 1}`} className="min-w-0 flex-1" value={label} onChange={(value) => {
const labels = [...form.attachment_label];
labels[index] = value;
change('attachment_label', labels);
}}><Input placeholder="请输入文件类别" variant="secondary" /></TextField>
<Tooltip delay={0}><Button isIconOnly type="button" aria-label="删除文件描述" size="sm" variant="ghost" onPress={() => change('attachment_label', form.attachment_label.filter((_, itemIndex) => itemIndex !== index))}><Delete size={16} /></Button><Tooltip.Content>删除文件描述</Tooltip.Content></Tooltip>
</div>
))}
<Button variant="secondary" type="button" onPress={() => change('attachment_label', [...form.attachment_label, ''])}>
添加文件描述
</Button>
</div>
</div>
) : null}
{form.type === 'computed' ? (
<>
<div className="rc-form-field">
<Label>量化指标</Label>
<ReferenceSearchSelect
value={form.indicator}
onChange={(value) => change('indicator', value)}
loadOptions={loadIndicatorOptions}
placeholder="搜索量化指标"
/>
</div>
<TextField className="rc-form-field" value={String(form.computed_interval)} onChange={(value) => change('computed_interval', value)}>
<Label>执行频率(天)</Label>
<Input type="number" min="1" variant="secondary" />
</TextField>
</>
) : null}
{error ? <FieldError className="sm:col-span-2">{error}</FieldError> : null}
</Modal.Body>
<Modal.Footer className="rc-dialog-actions">
<Button variant="secondary" onPress={onClose} isDisabled={saving}>取消</Button>
<Button variant="primary" onPress={handleSave} isDisabled={saving}>
{saving ? '保存中...' : '保存'}
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal.Root>
);
}
export default function AuditProcess() {
const { check } = useAuthorization('audit_manager');
const canManage = check('管理');
......@@ -309,7 +103,6 @@ export default function AuditProcess() {
const [projectToDelete, setProjectToDelete] = useState(null);
const [deletingProject, setDeletingProject] = useState(false);
const [pointDialog, setPointDialog] = useState(null);
const [pointToPreview, setPointToPreview] = useState(null);
const [deletePointIndex, setDeletePointIndex] = useState(null);
const loadData = useCallback(async (preferredId) => {
......@@ -389,8 +182,8 @@ export default function AuditProcess() {
isActions: true,
render: ({ point, index }) => (
<TableActionGroup className="justify-start">
<TableActionButton icon={Eye} label="查看控制点" onPress={() => setPointToPreview({ point, index })} tone="view" />
{canManage ? <TableActionButton icon={Edit} label="编辑控制点" onPress={() => setPointDialog({ point, index })} tone="edit" /> : null}
<TableActionButton icon={Eye} label="查看控制点" onPress={() => setPointDialog({ point, index, mode: 'view' })} tone="view" />
{canManage ? <TableActionButton icon={Edit} label="编辑控制点" onPress={() => setPointDialog({ point, index, mode: 'edit' })} tone="edit" /> : null}
{canManage ? <TableActionButton icon={Delete} label="删除控制点" onPress={() => setDeletePointIndex(index)} tone="delete" /> : null}
</TableActionGroup>
),
......@@ -464,7 +257,7 @@ export default function AuditProcess() {
<div className="ml-auto flex shrink-0 items-center gap-2">
{activeProcess ? (
<>
<Button size="sm" variant="secondary" onPress={() => setPointDialog({ point: null, index: null })}>添加控制点</Button>
<Button size="sm" variant="secondary" onPress={() => setPointDialog({ point: null, index: null, mode: 'edit' })}>添加控制点</Button>
<Button size="sm" variant="danger-soft" onPress={() => setProjectToDelete(activeProcess)}>删除项目</Button>
</>
) : null}
......@@ -516,21 +309,20 @@ export default function AuditProcess() {
await loadData(normalizeRecordId(record));
}}
/>
<ControlPointDialog
<UnifiedControlPointDialog
open={Boolean(pointDialog)}
onClose={() => setPointDialog(null)}
onSaved={savePoint}
point={pointDialog?.point}
definition={definition}
/>
<ControlPointPreviewDialog
open={Boolean(pointToPreview)}
onClose={() => setPointToPreview(null)}
orderLabel={pointToPreview ? `${activeProcess?.order || ''}.${pointToPreview.index + 1}` : ''}
point={pointToPreview?.point}
typeLabel={pointToPreview
initialMode={pointDialog?.mode || 'edit'}
canEdit={canManage}
orderLabel={pointDialog && pointDialog.index !== null && pointDialog.index !== undefined
? `${activeProcess?.order || ''}.${pointDialog.index + 1}`
: ''}
typeLabel={pointDialog
? fieldByName(definition, 'type')?.settings?.options?.find(
(option) => option.value === pointToPreview.point?.type,
(option) => option.value === pointDialog.point?.type,
)?.label
: ''}
/>
......
import { Back, CheckCircleIcon, ClipboardDocumentListIcon, Project } from '@/components/AppIcons';
import { CheckCircleIcon, ClipboardDocumentListIcon, Project } from '@/components/AppIcons';
import { useAppShellHeader } from '@/components/AppShell';
import { KPI } from '@heroui-pro/react/kpi';
import { Button, Chip, Tooltip } from '@heroui/react';
import dayjs from 'dayjs';
import { Button } from '@heroui/react';
import { useCallback, useEffect, useState } from 'react';
import { history, useParams } from 'umi';
import AuditReportContent from './AuditReportContent';
......@@ -15,6 +15,7 @@ import { exportAuditReport, getReportStats } from './auditReportModel';
export default function AuditReportDetail() {
const { reportId } = useParams();
const shellHeader = useAppShellHeader();
const [report, setReport] = useState(null);
const [issueOptions, setIssueOptions] = useState([]);
const [loading, setLoading] = useState(true);
......@@ -43,6 +44,23 @@ export default function AuditReportDetail() {
loadData();
}, [loadData]);
useEffect(() => {
if (!report || !shellHeader?.setHeaderOverride) {
shellHeader?.setHeaderOverride(null);
return undefined;
}
shellHeader.setHeaderOverride({
breadcrumbs: [
{ label: '稽核报告', href: '/audit_report', onPress: () => history.back() },
{ label: '报告详情' },
],
title: '报告详情',
});
return () => shellHeader.setHeaderOverride(null);
}, [report, shellHeader]);
if (loading) return <LoadingState label="正在加载历史报告..." />;
if (error || !report) return <ErrorState message={error || '报告不存在'} onRetry={loadData} />;
......@@ -56,18 +74,7 @@ export default function AuditReportDetail() {
return (
<div className="rc-page-stack">
<div className="rc-page-toolbar">
<div className="flex min-w-0 items-center gap-3">
<Tooltip delay={0}><Button isIconOnly aria-label="返回历史报告" size="sm" variant="tertiary" onPress={() => history.back()}><Back size={16} /></Button><Tooltip.Content>返回</Tooltip.Content></Tooltip>
<div>
<div className="flex flex-wrap items-center gap-2">
<h2>{report.title}</h2>
<Chip color="success" size="sm" variant="soft">已归档</Chip>
</div>
<p>
提交时间:{report.metadata?.submitted_time ? dayjs(report.metadata.submitted_time).format('YYYY-MM-DD HH:mm') : '-'}
</p>
</div>
</div>
<h2 className="rc-section-title min-w-0 truncate">{report.title || '报告详情'}</h2>
<Button size="sm" variant="secondary" onPress={() => exportAuditReport(report)}>导出</Button>
</div>
......
......@@ -2,7 +2,7 @@ import xyLogo from '@/assets/identity/xy-logo-red.png';
import xyLogoWhite from '@/assets/identity/xy-logo-white.png';
import { APP_DISPLAY_NAME } from '@/constants/app';
import { AlertDialog, Button } from '@heroui/react';
import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useLocation } from 'umi';
import AuditReportContent from './AuditReportContent';
import { ErrorState, InlineNotice, LoadingState } from './PageState';
......@@ -60,15 +60,7 @@ export default function AuditReportFill() {
const [notice, setNotice] = useState(null);
const [resetOpen, setResetOpen] = useState(false);
useLayoutEffect(() => {
let theme = 'light';
try {
theme = window.localStorage.getItem('xinyuan_color_theme') === 'dark' ? 'dark' : 'light';
} catch {}
document.documentElement.classList.toggle('dark', theme === 'dark');
document.documentElement.classList.toggle('light', theme === 'light');
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
useEffect(() => {
document.title = `填报任务 - ${APP_DISPLAY_NAME}`;
}, []);
......
import { Close, Delete } from '@/components/AppIcons';
import FilterSelect from '@/components/FilterSelect';
import { Button, FieldError, Input, Label, Modal, TextArea, TextField, Tooltip } from '@heroui/react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import ReferenceSearchSelect from './ReferenceSearchSelect';
import { ControlPointPreviewFields } from './ControlPointPreviewDialog';
import {
AUDIT_SUBJECTS,
normalizeRecordId,
searchReferenceRecords,
searchTags,
} from './auditManagementService';
const EMPTY_POINT_FORM = {
name: '',
description: '',
type: '',
owner: null,
related_tags: [],
related_regulations: [],
form_label: '',
attachment_label: [''],
indicator: null,
computed_interval: '',
};
function compactLabels(labels) {
return (labels || []).map((label) => label.trim()).filter(Boolean);
}
function requiredLabel(label, required) {
return <>{label}{required ? <span aria-hidden="true"> *</span> : null}</>;
}
export default function UnifiedControlPointDialog({
canEdit = true,
definition,
initialMode = 'edit',
onClose,
onSaved,
open,
orderLabel,
point,
typeLabel,
}) {
const typeField = (definition?.fields || []).find((field) => field.name === 'type');
const typeOptions = useMemo(() => typeField?.settings?.options || [], [typeField]);
const [form, setForm] = useState(EMPTY_POINT_FORM);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [isEditing, setIsEditing] = useState(initialMode === 'edit' || !point);
const loadDepartmentOptions = useCallback((keyword) => searchReferenceRecords('department', keyword), []);
const loadRegulationOptions = useCallback((keyword) => searchReferenceRecords('internal_regulation', keyword), []);
const loadIndicatorOptions = useCallback((keyword) => searchReferenceRecords(AUDIT_SUBJECTS.INDICATOR, keyword), []);
useEffect(() => {
if (!open) return;
setForm({
...EMPTY_POINT_FORM,
...(point || {}),
type: point?.type || typeOptions[0]?.value || '',
attachment_label: point?.attachment_label?.length ? [...point.attachment_label] : [''],
});
setError('');
setIsEditing(initialMode === 'edit' || !point);
}, [initialMode, open, point, typeOptions]);
const change = (name, value) => setForm((current) => ({ ...current, [name]: value }));
const handleSave = async () => {
if (!form.name.trim()) {
setError('请输入控制点名称');
return;
}
if (!normalizeRecordId(form.owner)) {
setError('请选择责任人');
return;
}
setSaving(true);
setError('');
try {
await onSaved({
...form,
name: form.name.trim(),
description: form.description.trim(),
owner: normalizeRecordId(form.owner),
related_tags: (form.related_tags || []).map(normalizeRecordId).filter(Boolean),
related_regulations: (form.related_regulations || []).map(normalizeRecordId).filter(Boolean),
indicator: normalizeRecordId(form.indicator) || null,
computed_interval: form.computed_interval === '' ? null : Number(form.computed_interval),
attachment_label: compactLabels(form.attachment_label),
});
} catch (requestError) {
setError(requestError?.message || '保存控制点失败');
} finally {
setSaving(false);
}
};
const handleCancel = () => {
if (initialMode === 'view' && point) {
setForm({
...EMPTY_POINT_FORM,
...point,
type: point.type || typeOptions[0]?.value || '',
attachment_label: point.attachment_label?.length ? [...point.attachment_label] : [''],
});
setError('');
setIsEditing(false);
return;
}
onClose();
};
const title = isEditing ? (point ? '编辑控制点' : '添加控制点') : '查看控制点';
const canShowEdit = Boolean(point && canEdit && !isEditing);
return (
<Modal.Root isOpen={open} onOpenChange={(isOpen) => { if (!isOpen && !saving) onClose(); }}>
<Modal.Trigger aria-hidden="true" aria-label={`打开${title}弹窗`} className="sr-only" tabIndex={-1} />
<Modal.Backdrop isDismissable={!saving} variant="blur">
<Modal.Container placement="center" scroll="inside" size="lg">
<Modal.Dialog className="bg-background p-7 sm:max-w-[68rem]">
<Modal.CloseTrigger aria-label="关闭">
<Close size={16} />
</Modal.CloseTrigger>
<Modal.Header><Modal.Heading>{title}</Modal.Heading></Modal.Header>
{isEditing ? (
<Modal.Body className="grid gap-5 sm:grid-cols-2">
<TextField className="rc-form-field sm:col-span-2" isRequired value={form.name} onChange={(value) => change('name', value)}><Label>名称</Label><Input placeholder="请输入控制点名称" variant="secondary" /></TextField>
<TextField className="rc-form-field sm:col-span-2" value={form.description} onChange={(value) => change('description', value)}><Label>描述信息</Label><TextArea placeholder="请输入控制点描述" rows={4} variant="secondary" /></TextField>
<div className="rc-form-field"><Label>{requiredLabel('责任人', true)}</Label><ReferenceSearchSelect value={form.owner} onChange={(value) => change('owner', value)} loadOptions={loadDepartmentOptions} placeholder="搜索责任部门" /></div>
<div className="rc-form-field"><Label>相关标签</Label><ReferenceSearchSelect multiple value={form.related_tags} onChange={(value) => change('related_tags', value)} loadOptions={searchTags} placeholder="搜索并添加标签" /></div>
<div className="rc-form-field sm:col-span-2"><Label>相关制度</Label><ReferenceSearchSelect multiple value={form.related_regulations} onChange={(value) => change('related_regulations', value)} loadOptions={loadRegulationOptions} placeholder="搜索并添加制度" /></div>
<div className="rc-form-field"><Label>{requiredLabel('控制措施', Boolean(typeField?.required))}</Label><FilterSelect aria-label="控制措施" options={typeOptions} value={form.type} variant="secondary" onChange={(value) => change('type', value)} /></div>
{form.type === 'form' ? <TextField className="rc-form-field" value={form.form_label} onChange={(value) => change('form_label', value)}><Label>文本描述</Label><Input placeholder="请输入文本描述" variant="secondary" /></TextField> : null}
{form.type === 'attachment' ? <div className="rc-form-field sm:col-span-2"><Label>文件描述</Label><div className="space-y-2">{form.attachment_label.map((label, index) => <div key={index} className="flex items-center gap-2"><TextField aria-label={`文件描述 ${index + 1}`} className="min-w-0 flex-1" value={label} onChange={(value) => { const labels = [...form.attachment_label]; labels[index] = value; change('attachment_label', labels); }}><Input placeholder="请输入文件类别" variant="secondary" /></TextField><Tooltip delay={0}><Button isIconOnly type="button" aria-label="删除文件描述" size="sm" variant="ghost" onPress={() => change('attachment_label', form.attachment_label.filter((_, itemIndex) => itemIndex !== index))}><Delete size={16} /></Button><Tooltip.Content>删除文件描述</Tooltip.Content></Tooltip></div>)}<Button variant="secondary" type="button" onPress={() => change('attachment_label', [...form.attachment_label, ''])}>添加文件描述</Button></div></div> : null}
{form.type === 'computed' ? <><div className="rc-form-field"><Label>量化指标</Label><ReferenceSearchSelect value={form.indicator} onChange={(value) => change('indicator', value)} loadOptions={loadIndicatorOptions} placeholder="搜索量化指标" /></div><TextField className="rc-form-field" value={String(form.computed_interval)} onChange={(value) => change('computed_interval', value)}><Label>执行频率(天)</Label><Input type="number" min="1" variant="secondary" /></TextField></> : null}
{error ? <FieldError className="sm:col-span-2">{error}</FieldError> : null}
</Modal.Body>
) : (
<Modal.Body><ControlPointPreviewFields orderLabel={orderLabel} point={point} typeLabel={typeLabel} /></Modal.Body>
)}
<Modal.Footer>
{isEditing ? <><Button variant="secondary" isDisabled={saving} onPress={handleCancel}>取消</Button><Button variant="primary" isDisabled={saving} onPress={handleSave}>{saving ? '保存中...' : '保存'}</Button></> : canShowEdit ? <Button variant="primary" onPress={() => setIsEditing(true)}>编辑</Button> : <Button size="sm" variant="secondary" onPress={onClose}>关闭</Button>}
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal.Root>
);
}
import { OpenExternal } from '@/components/AppIcons';
import { Button, Chip, Description, Modal, Surface } from '@heroui/react';
import { Close, OpenExternal } from '@/components/AppIcons';
import { Button, Chip, Description, Modal } from '@heroui/react';
import dayjs from 'dayjs';
import {
CONTROL_POINT_STATUS,
......@@ -40,9 +40,9 @@ function displayValue(value) {
function PreviewField({ children, className, label }) {
return (
<div className={`grid min-w-0 gap-1 ${className || ''}`}>
<Description>{label}</Description>
<div className="min-w-0 whitespace-pre-wrap break-words">
<div className={`grid min-w-0 gap-1 py-3 ${className || ''}`}>
<Description className="text-sm font-medium text-muted">{label}</Description>
<div className="min-w-0 whitespace-pre-wrap break-words text-foreground">
{children === null || children === undefined || children === '' ? '-' : children}
</div>
</div>
......@@ -128,13 +128,7 @@ function PointContentPreview({ point }) {
return displayValue(point.value);
}
export default function ControlPointPreviewDialog({
open,
onClose,
orderLabel,
point,
typeLabel,
}) {
export function ControlPointPreviewFields({ orderLabel, point, typeLabel }) {
const hasRuntimeState = point && (
Object.prototype.hasOwnProperty.call(point, 'value')
|| Object.prototype.hasOwnProperty.call(point, 'issue')
......@@ -143,29 +137,19 @@ export default function ControlPointPreviewDialog({
);
return (
<Modal.Root isOpen={open} onOpenChange={(isOpen) => { if (!isOpen) onClose(); }}>
<Modal.Trigger aria-hidden="true" aria-label="打开控制点查看弹窗" className="sr-only" tabIndex={-1} />
<Modal.Backdrop className="rc-dialog-backdrop" variant="blur">
<Modal.Container placement="center" scroll="inside" size="lg">
<Modal.Dialog className="rc-dialog-panel rc-dialog-panel--lg">
<Modal.CloseTrigger aria-label="关闭" className="rc-dialog-close" />
<Modal.Header>
<Modal.Heading className="rc-dialog-title">查看控制点</Modal.Heading>
</Modal.Header>
<Modal.Body className="rc-dialog-body">
<Surface className="grid gap-5 p-5 sm:grid-cols-2" variant="secondary">
<section className="grid min-w-0 grid-cols-1 gap-y-0">
<PreviewField label="序号">{orderLabel || '-'}</PreviewField>
<PreviewField label="控制措施">{typeLabel || point?.type || '-'}</PreviewField>
<PreviewField className="sm:col-span-2" label="名称">{point?.name || '-'}</PreviewField>
<PreviewField className="sm:col-span-2" label="描述信息">{point?.description || '-'}</PreviewField>
<PreviewField label="名称">{point?.name || '-'}</PreviewField>
<PreviewField label="描述信息">{point?.description || '-'}</PreviewField>
<PreviewField label="责任人">{recordLabel(point?.owner)}</PreviewField>
<PreviewField label="相关标签">{listLabel(point?.related_tags)}</PreviewField>
<PreviewField className="sm:col-span-2" label="相关制度">{listLabel(point?.related_regulations)}</PreviewField>
<PreviewField label="相关制度">{listLabel(point?.related_regulations)}</PreviewField>
{point?.type === 'form' ? (
<PreviewField className="sm:col-span-2" label="文本描述">{point.form_label || '-'}</PreviewField>
<PreviewField label="文本描述">{point.form_label || '-'}</PreviewField>
) : null}
{point?.type === 'attachment' ? (
<PreviewField className="sm:col-span-2" label="文件描述">{listLabel(point.attachment_label)}</PreviewField>
<PreviewField label="文件描述">{listLabel(point.attachment_label)}</PreviewField>
) : null}
{point?.type === 'computed' ? (
<>
......@@ -175,15 +159,39 @@ export default function ControlPointPreviewDialog({
) : null}
{hasRuntimeState ? (
<>
<PreviewField className="sm:col-span-2" label="内容"><PointContentPreview point={point} /></PreviewField>
<PreviewField label="内容"><PointContentPreview point={point} /></PreviewField>
<PreviewField label="异常">{point.issue || '-'}</PreviewField>
<PreviewField label="状态"><PointStatus point={point} /></PreviewField>
<PreviewField className="sm:col-span-2" label="备注">{point.remark || '-'}</PreviewField>
<PreviewField label="备注">{point.remark || '-'}</PreviewField>
</>
) : null}
</Surface>
</section>
);
}
export default function ControlPointPreviewDialog({
open,
onClose,
orderLabel,
point,
typeLabel,
}) {
return (
<Modal.Root isOpen={open} onOpenChange={(isOpen) => { if (!isOpen) onClose(); }}>
<Modal.Trigger aria-hidden="true" aria-label="打开控制点查看弹窗" className="sr-only" tabIndex={-1} />
<Modal.Backdrop variant="blur">
<Modal.Container placement="center" scroll="inside" size="lg">
<Modal.Dialog className="bg-background p-7 sm:max-w-[68rem]">
<Modal.CloseTrigger aria-label="关闭">
<Close size={16} />
</Modal.CloseTrigger>
<Modal.Header>
<Modal.Heading>查看控制点</Modal.Heading>
</Modal.Header>
<Modal.Body>
<ControlPointPreviewFields orderLabel={orderLabel} point={point} typeLabel={typeLabel} />
</Modal.Body>
<Modal.Footer className="rc-dialog-actions">
<Modal.Footer>
<Button size="sm" variant="secondary" onPress={onClose}>关闭</Button>
</Modal.Footer>
</Modal.Dialog>
......
......@@ -96,6 +96,10 @@ export default function ReferenceSearchSelect({
}
setQuery('');
}}
onClear={() => {
onChange(multiple ? [] : '');
setQuery('');
}}
/>
);
}
import ApplicationSwitcherDialog from '@/components/ApplicationSwitcherDialog';
import xyLogoSmall from '@/assets/xy-logo_small.png';
import { useUserInfo } from '@/hooks/user';
import AppShell from '@/components/AppShell';
import { FileText, Project } from '@/components/AppIcons';
import { APP_DISPLAY_NAME } from '@/constants/app';
import { UserContext } from '@/wrapper/Auth';
import { SiteContext } from '@/wrapper/Site';
import {
Bars3Icon,
QueueListIcon,
Squares2X2Icon,
XMarkIcon,
} from '@heroicons/react/24/outline';
import clsx from 'clsx';
import { useContext, useEffect, useMemo, useState } from 'react';
import { Link, Outlet, useLocation } from 'umi';
const APP_NAME = '流程监控';
const menuGroups = [
{
title: '监控模块',
items: [
{
name: '流程监控',
path: '/process_monitor',
icon: QueueListIcon,
},
],
},
];
function NavbarItem({ className, ...props }) {
return (
<button
type="button"
className={clsx(
'inline-flex items-center justify-center rounded-lg p-2 text-zinc-500 transition hover:bg-zinc-100 hover:text-zinc-900',
className,
)}
{...props}
/>
);
}
function NavItem({ name, path, active, collapsed, icon: Icon, onNavigate }) {
return (
<Link
to={path}
onClick={onNavigate}
title={collapsed ? name : undefined}
aria-label={collapsed ? name : undefined}
className={clsx(
'group relative flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200',
active
? 'bg-[var(--pm-brand)] text-white shadow-lg'
: 'text-white/90 hover:bg-zinc-800 hover:text-white',
collapsed && 'justify-center px-2',
)}
>
{Icon ? (
<Icon
className={clsx(
'size-5 shrink-0 transition-colors',
active ? 'text-white' : 'text-white/80 group-hover:text-white',
)}
/>
) : null}
{!collapsed ? (
<span
className={clsx(
'min-w-0 flex-1 truncate',
active ? 'text-white' : 'text-white/90 group-hover:text-white',
)}
>
{name}
</span>
) : null}
{active && !collapsed ? (
<span className="absolute right-2 size-1.5 rounded-full bg-white/50" />
) : null}
</Link>
);
}
function MobileSidebar({ open, close, children }) {
if (!open) {
return null;
}
return (
<div className="relative z-[140] lg:hidden">
<div className="fixed inset-0 z-[140] bg-black/30" onClick={close} aria-hidden="true" />
<div className="fixed inset-y-0 z-[150] w-full max-w-80 p-2">
<div className="flex h-full flex-col rounded-lg bg-zinc-900 shadow-xs ring-1 ring-white/10">
<div className="-mb-3 flex justify-end px-4 pt-3">
<NavbarItem
onClick={close}
aria-label="Close navigation"
className="text-zinc-400 hover:text-white"
>
<XMarkIcon className="size-5" />
</NavbarItem>
</div>
{children}
</div>
</div>
</div>
);
}
function CollapseSidebarIcon() {
return (
<svg data-slot="icon" viewBox="0 0 20 20" aria-hidden="true" fill="currentColor">
<path d="M12.78 15.53a.75.75 0 0 1-1.06 0l-4.5-4.5a.75.75 0 0 1 0-1.06l4.5-4.5a.75.75 0 1 1 1.06 1.06L8.81 10l3.97 3.97a.75.75 0 0 1 0 1.06Z" />
</svg>
);
}
function ExpandSidebarIcon() {
return (
<svg data-slot="icon" viewBox="0 0 20 20" aria-hidden="true" fill="currentColor">
<path d="M7.22 15.53a.75.75 0 0 0 1.06 0l4.5-4.5a.75.75 0 0 0 0-1.06l-4.5-4.5a.75.75 0 1 0-1.06 1.06L11.19 10l-3.97 3.97a.75.75 0 0 0 0 1.06Z" />
</svg>
);
}
import { Outlet, useLocation } from 'umi';
import ApplicationSwitcherDialog from '@/components/ApplicationSwitcherDialog';
import { loadUnreadNotificationCount, NOTIFICATION_STATE_EVENT } from '@/pages/audit_management/notifications/notificationService';
const MENU_GROUPS = [{ title: APP_DISPLAY_NAME, showLabel: false, items: [{ name: '流程监控', path: '/process_monitor', icon: Project }, { name: '监控说明', path: '/process_monitor/help', icon: FileText }] }];
export default function ProcessMonitorLayout() {
const location = useLocation();
const userInfo = useUserInfo();
const siteInfo = useContext(SiteContext);
const [isMobile, setIsMobile] = useState(false);
const [isApplicationDialogOpen, setIsApplicationDialogOpen] = useState(false);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const media = window.matchMedia('(max-width: 1023px)');
const sync = () => {
const mobile = !!media.matches;
setIsMobile(mobile);
if (mobile) {
setIsMobileSidebarOpen(false);
setIsSidebarCollapsed(true);
} else {
const storedCollapsed = window.localStorage?.getItem?.('pm_sidebar_collapsed');
if (storedCollapsed !== null && storedCollapsed !== undefined) {
setIsSidebarCollapsed(storedCollapsed === '1');
}
}
};
sync();
media.addEventListener?.('change', sync);
return () => {
media.removeEventListener?.('change', sync);
};
}, []);
const sidebarCollapsed = isMobile ? true : isSidebarCollapsed;
const currentGroupTitle = useMemo(
() =>
menuGroups.find((group) => group.items.some((item) => location.pathname.startsWith(item.path)))
?.title || '',
[location.pathname],
);
const applicationSwitcherButton = (
<button
type="button"
onClick={() => setIsApplicationDialogOpen(true)}
aria-label="Open application switcher"
title="应用切换"
className={clsx(
'inline-flex items-center justify-center rounded-[20px] border border-[#f97316]/70 bg-[linear-gradient(180deg,rgba(39,39,42,0.92),rgba(24,24,27,0.96))] text-[#fb923c] shadow-[0_16px_30px_rgba(249,115,22,0.16)] transition duration-200 hover:-translate-y-0.5 hover:border-[#fb923c] hover:text-[#fdba74] hover:shadow-[0_18px_36px_rgba(249,115,22,0.26)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#fb923c]/50',
sidebarCollapsed ? 'size-11' : 'size-14',
)}
>
<Squares2X2Icon className={clsx(sidebarCollapsed ? 'size-5' : 'size-7')} />
</button>
);
return (
<div
className="flex h-screen overflow-hidden bg-[radial-gradient(circle_at_top_left,_rgba(251,191,188,0.22),_transparent_26%),radial-gradient(circle_at_bottom_right,_rgba(251,207,232,0.2),_transparent_30%),#f8fafc]"
style={{
'--pm-brand': '#b42318',
'--pm-brand-strong': '#8f1d15',
}}
>
<aside
className={clsx(
'relative z-30 hidden shrink-0 flex-col border-r border-zinc-800 bg-zinc-900 p-5 lg:flex',
sidebarCollapsed ? 'w-20' : 'w-72',
)}
>
<div
className={clsx(
'mb-7',
sidebarCollapsed
? 'flex flex-col items-center gap-3'
: 'flex items-start justify-between gap-4 px-1',
)}
>
{sidebarCollapsed ? (
<>
<img
src={xyLogoSmall}
alt="XY Logo"
className="h-8 w-auto max-w-8 shrink-0 object-contain"
/>
{applicationSwitcherButton}
</>
) : (
<>
<div className="min-w-0 flex-1">
<h1 className="truncate text-sm font-semibold tracking-[0.02em] text-white/95">
{APP_NAME}
</h1>
</div>
{applicationSwitcherButton}
</>
)}
</div>
<nav className="flex-1 space-y-5 overflow-y-auto">
{menuGroups.map((group) => (
<div key={group.title}>
{!sidebarCollapsed ? (
<h2 className="mb-2 px-1 text-xs font-medium tracking-wide text-zinc-500">
{group.title}
</h2>
) : null}
<div className="space-y-1">
{group.items.map((item) => (
<NavItem
key={item.path}
name={item.name}
path={item.path}
active={location.pathname.startsWith(item.path)}
collapsed={sidebarCollapsed}
icon={item.icon}
/>
))}
</div>
</div>
))}
</nav>
{!sidebarCollapsed ? (
<div className="mt-6 rounded-2xl border border-zinc-800 bg-zinc-950/70 p-4 text-sm text-zinc-300">
<div className="font-medium text-white/90">
{userInfo?.display_name || userInfo?.username || '当前用户'}
</div>
<div className="mt-1 text-xs text-zinc-500">当前正在查看流程监控重构模块</div>
</div>
) : null}
</aside>
<div className="relative z-30 hidden lg:flex">
<button
type="button"
onClick={() =>
setIsSidebarCollapsed((prev) => {
const next = !prev;
window?.localStorage?.setItem?.('pm_sidebar_collapsed', next ? '1' : '0');
return next;
})
}
aria-label={sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
className="absolute -left-3.5 top-16 z-10 flex size-7 items-center justify-center rounded-full border border-zinc-700 bg-zinc-800 text-zinc-400 shadow hover:bg-zinc-700 hover:text-white [&_svg]:size-4"
>
{sidebarCollapsed ? <ExpandSidebarIcon /> : <CollapseSidebarIcon />}
</button>
</div>
<MobileSidebar open={isMobileSidebarOpen} close={() => setIsMobileSidebarOpen(false)}>
<div className="px-5 pt-6">
<h1 className="text-base font-semibold tracking-[0.02em] text-white">{APP_NAME}</h1>
<div className="text-xs text-zinc-500">流程监控重构版</div>
</div>
<nav className="space-y-5 overflow-y-auto px-5 pb-6 pt-6">
{menuGroups.map((group) => (
<div key={group.title}>
<h2 className="mb-2 px-1 text-xs font-medium tracking-wide text-zinc-600">
{group.title}
</h2>
<div className="space-y-1">
{group.items.map((item) => (
<NavItem
key={item.path}
name={item.name}
path={item.path}
active={location.pathname.startsWith(item.path)}
icon={item.icon}
onNavigate={() => setIsMobileSidebarOpen(false)}
/>
))}
</div>
</div>
))}
</nav>
</MobileSidebar>
<main className="flex flex-1 flex-col overflow-hidden p-4 sm:p-6">
<div className="mb-4 flex items-center justify-between gap-3 lg:hidden">
<NavbarItem onClick={() => setIsMobileSidebarOpen(true)} aria-label="Open navigation">
<Bars3Icon className="size-5" />
</NavbarItem>
<div className="text-sm text-zinc-500">{currentGroupTitle}</div>
<NavbarItem
onClick={() => setIsApplicationDialogOpen(true)}
aria-label="Open application switcher"
className="border border-rose-200 bg-white text-rose-500 shadow-sm hover:bg-rose-50 hover:text-rose-600"
>
<Squares2X2Icon className="size-5" />
</NavbarItem>
</div>
<div className="flex-1 overflow-y-auto rounded-[28px] border border-white/80 bg-white/80 p-4 shadow-sm backdrop-blur sm:p-6">
<Outlet />
</div>
</main>
<ApplicationSwitcherDialog
open={isApplicationDialogOpen}
onClose={() => setIsApplicationDialogOpen(false)}
userInfo={userInfo}
siteInfo={siteInfo}
/>
</div>
);
const location = useLocation(); const userInfo = useContext(UserContext); const siteInfo = useContext(SiteContext); const [open, setOpen] = useState(true); const [switcherOpen, setSwitcherOpen] = useState(false); const [count, setCount] = useState(0);
const title = useMemo(() => location.pathname.includes('/process_monitor/') ? '流程监控详情' : '流程监控', [location.pathname]);
useEffect(() => { document.title = `${title} - ${APP_DISPLAY_NAME}`; }, [title]);
useEffect(() => { let cancelled = false; const refresh = async () => { try { const value = await loadUnreadNotificationCount(); if (!cancelled) setCount(value); } catch { if (!cancelled) setCount(0); } }; refresh(); window.addEventListener(NOTIFICATION_STATE_EVENT, refresh); return () => { cancelled = true; window.removeEventListener(NOTIFICATION_STATE_EVENT, refresh); }; }, []);
return <><AppShell menuGroups={MENU_GROUPS} notificationCount={count} notificationPath="/notifications" onLogout={userInfo?.logout} onOpenApplicationSwitcher={() => setSwitcherOpen(true)} open={open} pathname={location.pathname} title={title} userInfo={userInfo} onOpenChange={setOpen}><Outlet /></AppShell><ApplicationSwitcherDialog open={switcherOpen} onClose={() => setSwitcherOpen(false)} siteInfo={siteInfo} userInfo={userInfo} /></>;
}
import {
buildDepartmentLookup,
buildMemberLookup,
downloadProcessAttachment,
formatDateTime,
getDisplayFieldMap,
loadProcessInstanceDetail,
} from '@/pages/process_monitor/processMonitorService';
import { ArrowLeftIcon } from '@heroicons/react/24/outline';
import { ArrowLeft, Download } from '@/components/AppIcons';
import { Description, Modal, Spinner, Surface } from '@heroui/react';
import { useEffect, useMemo, useState } from 'react';
import { Link, useParams, useSearchParams } from 'umi';
import { buildDepartmentLookup, buildMemberLookup, downloadProcessAttachment, formatDateTime, getDisplayFieldMap, loadProcessInstanceDetail } from './processMonitorService';
function InfoCard({ label, value }) {
return (
<div className="rounded-[22px] border border-slate-200 bg-white/92 p-4 shadow-sm">
<div className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">{label}</div>
<div className="mt-2 text-base font-medium text-slate-950">{value || '-'}</div>
</div>
);
}
function SectionCard({ title, description, children }) {
return (
<section className="rounded-[28px] border border-slate-200/80 bg-white/92 p-5 shadow-sm">
<div>
<h2 className="text-lg font-semibold text-slate-950">{title}</h2>
{description ? <p className="mt-1 text-sm text-slate-500">{description}</p> : null}
</div>
<div className="mt-5">{children}</div>
</section>
);
}
function resolveDetailValue(field, fieldMap, lookups) {
if (field?.type === 'file') {
const files = Array.isArray(field?.value) ? field.value : [];
if (!files.length) {
return '-';
}
return (
<div className="flex flex-wrap gap-2">
{files.map((file) => (
<button
key={`${file.fileUrl || file.url}-${file.filename}`}
type="button"
onClick={() => downloadProcessAttachment(file)}
className="rounded-full bg-rose-50 px-2.5 py-1 text-xs font-medium text-rose-700 transition hover:bg-rose-100"
>
{file.filename || file.fileName || '附件'}
</button>
))}
</div>
);
}
const fieldDefinition = fieldMap[field?.label];
if (fieldDefinition?.type === 'member') {
return String(field?.value || '')
.split(',')
.map((item) => item.trim())
.filter(Boolean)
.map((item) => lookups.memberLookup[item] || item)
.join('、') || '-';
}
if (fieldDefinition?.type === 'department') {
return String(field?.value || '')
.split(',')
.map((item) => item.trim())
.filter(Boolean)
.map((item) => lookups.departmentLookup[item] || item)
.join('、') || '-';
}
if (fieldDefinition?.type === 'date') {
return formatDateTime(field?.value, 'YYYY-MM-DD HH:mm');
}
return field?.value || '-';
}
function resolveValue(field, fieldMap, lookups) { const definition = fieldMap[field?.label]; const value = field?.value; if (field?.type === 'file') { const files = Array.isArray(value) ? value : []; return files.length ? <div className="flex flex-wrap gap-2">{files.map((file, index) => <button className="text-sm text-accent underline" key={`${file.fileUrl || file.url}-${index}`} type="button" onClick={() => downloadProcessAttachment(file)}>{file.filename || file.fileName || '附件'}</button>)}</div> : <Description>暂无内容</Description>; } if (definition?.type === 'date') return formatDateTime(value, 'YYYY-MM-DD HH:mm'); if (definition?.type === 'member') return String(value || '').split(',').map((item) => lookups.members[item.trim()] || item.trim()).filter(Boolean).join('、') || '暂无内容'; if (definition?.type === 'department') return String(value || '').split(',').map((item) => lookups.departments[item.trim()] || item.trim()).filter(Boolean).join('、') || '暂无内容'; return value || <Description>暂无内容</Description>; }
export default function ProcessDetailPage() {
const { processId, application } = useParams();
const [searchParams] = useSearchParams();
const tabName = searchParams.get('tab') || '';
const [loading, setLoading] = useState(true);
const [message, setMessage] = useState('');
const [record, setRecord] = useState(null);
const [fieldsDefinition, setFieldsDefinition] = useState([]);
const [members, setMembers] = useState([]);
const [departments, setDepartments] = useState([]);
useEffect(() => {
setLoading(true);
loadProcessInstanceDetail(application)
.then((result) => {
setRecord(result.record);
setFieldsDefinition(result.fieldsDefinition);
setMembers(result.members);
setDepartments(result.departments);
})
.catch((error) => {
setMessage(error?.message || '加载流程详情失败');
})
.finally(() => {
setLoading(false);
});
}, [application]);
const lookups = useMemo(
() => ({
memberLookup: buildMemberLookup(members),
departmentLookup: buildDepartmentLookup(departments),
}),
[departments, members],
);
const fieldMap = useMemo(() => getDisplayFieldMap(fieldsDefinition), [fieldsDefinition]);
const details = useMemo(() => record?.metadata?.detail || [], [record]);
const relatedTemplateTitle = record?.metadata?.related_progress?.title || '-';
const relatedCondition = record?.metadata?.related_progress?.metadata?.conditions;
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center gap-3 text-sm text-slate-500">
<Link
to={tabName ? `/process_monitor?tab=${tabName}` : '/process_monitor'}
className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1.5 font-medium text-slate-600 transition hover:border-rose-200 hover:text-rose-700"
>
<ArrowLeftIcon className="size-4" />
返回流程监控
</Link>
<Link
to={`/process_monitor/${processId}${tabName ? `?tab=${tabName}` : ''}`}
className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1.5 font-medium text-slate-600 transition hover:border-rose-200 hover:text-rose-700"
>
返回实例列表
</Link>
</div>
<section className="overflow-hidden rounded-[32px] border border-rose-100 bg-[linear-gradient(135deg,rgba(254,226,226,0.72),rgba(255,228,230,0.82))] px-6 py-7 shadow-sm sm:px-8">
<h1 className="text-3xl font-semibold tracking-tight text-slate-950 sm:text-4xl">
{record?.title || '流程实例详情'}
</h1>
<p className="mt-3 max-w-3xl text-sm leading-6 text-slate-600 sm:text-base">
对应旧版 `ProcessDetail` 的单条流程实例详情页,使用新的卡片布局展示提交人、部门、时间和动态表单字段。
</p>
{message ? (
<div className="mt-4 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
{message}
</div>
) : null}
</section>
<div className="grid gap-4 lg:grid-cols-4">
<InfoCard label="提交流程模板" value={relatedTemplateTitle} />
<InfoCard label="提交者" value={record?.metadata?.submitter || '-'} />
<InfoCard label="审批者" value={record?.metadata?.approver || '-'} />
<InfoCard label="提交时间" value={formatDateTime(record?.metadata?.submit_at, 'YYYY-MM-DD HH:mm')} />
</div>
<div className="grid gap-4 lg:grid-cols-3">
<InfoCard label="所属部门" value={record?.metadata?.department || '-'} />
<InfoCard
label="显示条件"
value={
relatedCondition?.key
? `${relatedCondition.key} = ${relatedCondition.value || '-'}`
: '未配置'
}
/>
<InfoCard label="动态字段数" value={String(details.length || 0)} />
</div>
<SectionCard title="表单字段详情" description="按实际提交流水中的明细字段逐项展开,附件保持单独下载入口。">
{loading ? (
<div className="rounded-[24px] border border-slate-200 bg-slate-50/70 px-6 py-10 text-sm text-slate-500">
正在加载字段详情...
</div>
) : !details.length ? (
<div className="rounded-[24px] border border-dashed border-slate-300 bg-slate-50/60 px-6 py-10 text-center text-sm text-slate-500">
当前实例没有可展示的字段详情。
</div>
) : (
<div className="grid gap-4 xl:grid-cols-2">
{details.map((field, index) => (
<article
key={`${field.label}-${index}`}
className="rounded-[24px] border border-slate-200 bg-[linear-gradient(180deg,rgba(255,255,255,0.96),rgba(248,250,252,0.96))] p-5 shadow-sm"
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-slate-950">{field.label || `字段 ${index + 1}`}</div>
<div className="mt-1 text-xs text-slate-400">
{field.type || fieldMap[field.label]?.type || 'text'}
</div>
</div>
{fieldMap[field.label]?.type ? (
<span className="rounded-full bg-rose-50 px-2.5 py-1 text-xs font-medium text-rose-700">
{fieldMap[field.label].type}
</span>
) : null}
</div>
<div className="mt-4 text-sm leading-6 text-slate-700">
{resolveDetailValue(field, fieldMap, lookups)}
</div>
</article>
))}
</div>
)}
</SectionCard>
</div>
);
const { processId, application } = useParams(); const [searchParams] = useSearchParams(); const tabName = searchParams.get('tab') || ''; const [record, setRecord] = useState(null); const [fieldsDefinition, setFieldsDefinition] = useState([]); const [members, setMembers] = useState([]); const [departments, setDepartments] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState('');
useEffect(() => { let cancelled = false; setLoading(true); loadProcessInstanceDetail(application).then((result) => { if (cancelled) return; setRecord(result.record); setFieldsDefinition(result.fieldsDefinition); setMembers(result.members); setDepartments(result.departments); }).catch((requestError) => { if (!cancelled) setError(requestError?.message || '加载流程详情失败'); }).finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [application]);
const lookups = useMemo(() => ({ members: buildMemberLookup(members), departments: buildDepartmentLookup(departments) }), [members, departments]); const fieldMap = useMemo(() => getDisplayFieldMap(fieldsDefinition), [fieldsDefinition]); const details = record?.metadata?.detail || [];
return <div className="space-y-6"><div className="flex flex-wrap items-center gap-3"><Link className="inline-flex items-center gap-2 text-sm text-muted" to={tabName ? `/process_monitor/${processId}?tab=${tabName}` : `/process_monitor/${processId}`}><ArrowLeft size={16} />返回实例列表</Link></div><h1 className="rc-section-title">{record?.title || '流程实例详情'}</h1>{error ? <Surface className="p-4" variant="secondary">{error}</Surface> : null}{loading ? <div className="flex min-h-48 items-center justify-center"><Spinner size="sm" /></div> : <section className="grid min-w-0 grid-cols-1 gap-x-8 gap-y-2 md:grid-cols-2">{details.length ? details.map((field, index) => <div className="min-w-0 py-3" key={`${field.label || 'field'}-${index}`}><div className="text-sm font-medium text-muted">{field.label || `字段 ${index + 1}`}</div><div className="mt-1 break-words text-sm text-foreground">{resolveValue(field, fieldMap, lookups)}</div></div>) : <Description>暂无内容</Description>}</section>}</div>;
}
import { Button } from '@/catalyst-ui-kit/javascript/button';
import { Checkbox } from '@/catalyst-ui-kit/javascript/checkbox';
import {
Dialog,
DialogActions,
DialogBody,
DialogDescription,
DialogTitle,
} from '@/catalyst-ui-kit/javascript/dialog';
import { Field, FieldGroup, Label } from '@/catalyst-ui-kit/javascript/fieldset';
import { Input } from '@/catalyst-ui-kit/javascript/input';
import { Select } from '@/catalyst-ui-kit/javascript/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/catalyst-ui-kit/javascript/table';
import { DataTable, DataTablePagination, DataTablePrimaryAction, DataTableSecondaryText } from '@/components/DataTable';
import FilterSelect from '@/components/FilterSelect';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import { Delete, Edit, Eye, Plus, Search } from '@/components/AppIcons';
import { useAuthorization } from '@/hooks/user';
import {
addTemplatesToGroup,
createProcessGroup,
loadProcessGroup,
loadProcessMonitorOverview,
removeMonitoredTemplate,
updateMonitoredTemplate,
} from '@/pages/process_monitor/processMonitorService';
import {
MagnifyingGlassIcon,
PencilSquareIcon,
PlusIcon,
QueueListIcon,
TrashIcon,
} from '@heroicons/react/24/outline';
import clsx from 'clsx';
import { AlertDialog, Button, FieldError, Input, Label, Modal, SearchField, Surface, Tabs, TextField } from '@heroui/react';
import { useEffect, useMemo, useState } from 'react';
import { Link, useSearchParams } from 'umi';
import { history, useSearchParams } from 'umi';
import { ErrorState, LoadingState } from '@/pages/audit_management/PageState';
import { addTemplatesToGroup, createProcessGroup, loadProcessMonitorOverview, removeMonitoredTemplate, updateMonitoredTemplate } from './processMonitorService';
function SummaryCard({ label, value, hint }) {
return (
<div className="rounded-[24px] border border-slate-200/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.94),rgba(240,249,255,0.94))] p-5 shadow-sm">
<div className="text-xs font-semibold uppercase tracking-[0.22em] text-slate-400">
{label}
</div>
<div className="mt-3 text-3xl font-semibold tracking-tight text-slate-950">{value}</div>
<div className="mt-2 text-sm text-slate-500">{hint}</div>
</div>
);
}
function InlineNotice({ notice, onClose }) {
if (!notice?.message) {
return null;
}
const toneClass =
notice.type === 'error'
? 'border-red-200 bg-red-50 text-red-700'
: 'border-emerald-200 bg-emerald-50 text-emerald-700';
return (
<div className={clsx('rounded-2xl border px-4 py-3 text-sm', toneClass)}>
<div className="flex items-center justify-between gap-3">
<span>{notice.message}</span>
<button type="button" onClick={onClose} className="text-xs font-medium">
关闭
</button>
</div>
</div>
);
}
function SectionCard({ title, description, action, children }) {
return (
<section className="rounded-[28px] border border-slate-200/80 bg-white/92 p-5 shadow-sm">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 className="text-lg font-semibold text-slate-950">{title}</h2>
{description ? <p className="mt-1 text-sm text-slate-500">{description}</p> : null}
</div>
{action}
</div>
<div className="mt-5">{children}</div>
</section>
);
}
const EMPTY_TEMPLATE_FORM = { title: '', department: '', templateCode: '', conditionKey: '', conditionValue: '' };
function ActionIcon({ label, children, onPress, isDisabled }) { return <Button aria-label={label} isDisabled={isDisabled} isIconOnly size="sm" variant="ghost" onPress={onPress}>{children}</Button>; }
function GroupTabs({ groups, activeGroupName, onChange }) { if (groups.length <= 1) return null; return <Tabs className="w-fit max-w-full min-w-0" selectedKey={activeGroupName} variant="primary" onSelectionChange={(key) => onChange(String(key))}><Tabs.ListContainer className="max-w-full overflow-x-auto"><Tabs.List>{groups.map((group) => <Tabs.Tab id={group.name} key={group.name}>{group.title}<Tabs.Indicator /></Tabs.Tab>)}</Tabs.List></Tabs.ListContainer></Tabs>; }
function StatusPill({ children, tone = 'default' }) {
const toneClass =
tone === 'brand'
? 'bg-rose-50 text-rose-700'
: tone === 'emerald'
? 'bg-emerald-50 text-emerald-700'
: 'bg-zinc-100 text-zinc-700';
return <span className={clsx('rounded-full px-2.5 py-1 text-xs font-medium', toneClass)}>{children}</span>;
}
function EmptyState({ title, description, action }) {
return (
<div className="rounded-[28px] border border-dashed border-slate-300 bg-slate-50/60 px-6 py-14 text-center">
<div className="mx-auto flex size-14 items-center justify-center rounded-2xl bg-rose-100 text-rose-700">
<QueueListIcon className="size-7" />
</div>
<div className="mt-4 text-lg font-semibold text-slate-950">{title}</div>
<div className="mt-2 text-sm leading-6 text-slate-500">{description}</div>
{action ? <div className="mt-5">{action}</div> : null}
</div>
);
}
function CreateGroupDialog({ open, saving, value, onChange, onClose, onSave, error }) { return <Modal.Root isOpen={open} onOpenChange={(next) => { if (!next && !saving) onClose(); }}><Modal.Trigger aria-hidden="true" aria-label="打开新建监控分组弹窗" className="sr-only" tabIndex={-1} /><Modal.Backdrop isDismissable={!saving} variant="blur"><Modal.Container placement="center" scroll="inside" size="md"><Modal.Dialog className="rc-dialog-panel rc-dialog-panel--md"><Modal.CloseTrigger aria-label="关闭" /><Modal.Header><Modal.Heading>新建监控分组</Modal.Heading></Modal.Header><Modal.Body className="space-y-4"><TextField isRequired value={value} onChange={onChange}><Label>分组名称</Label><Input autoFocus placeholder="请输入分组名称" variant="secondary" /></TextField>{error ? <FieldError>{error}</FieldError> : null}</Modal.Body><Modal.Footer className="rc-dialog-actions"><Button variant="secondary" isDisabled={saving} onPress={onClose}>取消</Button><Button variant="primary" isDisabled={saving} onPress={onSave}>{saving ? '保存中...' : '保存'}</Button></Modal.Footer></Modal.Dialog></Modal.Container></Modal.Backdrop></Modal.Root>; }
function AddTemplatesDialog({ open, saving, templates, selectedIds, onChange, onClose, onSave, error }) { const options = templates.map((template) => ({ id: template._id || template.id, label: template.title })); return <Modal.Root isOpen={open} onOpenChange={(next) => { if (!next && !saving) onClose(); }}><Modal.Trigger aria-hidden="true" aria-label="打开添加监控模板弹窗" className="sr-only" tabIndex={-1} /><Modal.Backdrop isDismissable={!saving} variant="blur"><Modal.Container placement="center" scroll="inside" size="lg"><Modal.Dialog className="rc-dialog-panel rc-dialog-panel--lg"><Modal.CloseTrigger aria-label="关闭" /><Modal.Header><Modal.Heading>添加监控模板</Modal.Heading></Modal.Header><Modal.Body className="space-y-4"><XinyuanAutocomplete aria-label="选择监控模板" options={options} placeholder="搜索并选择模板" searchPlaceholder="搜索模板" selectedKeys={new Set(selectedIds)} selectionMode="multiple" variant="secondary" onSelectionChange={(keys) => onChange(Array.from(keys || []).map(String))} onClear={() => onChange([])} />{error ? <FieldError>{error}</FieldError> : null}</Modal.Body><Modal.Footer className="rc-dialog-actions"><Button variant="secondary" isDisabled={saving} onPress={onClose}>取消</Button><Button variant="primary" isDisabled={saving || selectedIds.length === 0} onPress={onSave}>{saving ? '保存中...' : '添加'}</Button></Modal.Footer></Modal.Dialog></Modal.Container></Modal.Backdrop></Modal.Root>; }
function EditTemplateDialog({ open, saving, form, onChange, onClose, onSave, error }) { return <Modal.Root isOpen={open} onOpenChange={(next) => { if (!next && !saving) onClose(); }}><Modal.Trigger aria-hidden="true" aria-label="打开编辑监控模板弹窗" className="sr-only" tabIndex={-1} /><Modal.Backdrop isDismissable={!saving} variant="blur"><Modal.Container placement="center" scroll="inside" size="lg"><Modal.Dialog className="rc-dialog-panel rc-dialog-panel--lg"><Modal.CloseTrigger aria-label="关闭" /><Modal.Header><Modal.Heading>编辑监控模板</Modal.Heading></Modal.Header><Modal.Body className="grid gap-4 sm:grid-cols-2"><TextField className="sm:col-span-2" isRequired value={form.title} onChange={(value) => onChange('title', value)}><Label>模板名称</Label><Input variant="secondary" /></TextField><TextField value={form.department} onChange={(value) => onChange('department', value)}><Label>所属部门</Label><Input variant="secondary" /></TextField><TextField value={form.templateCode} onChange={(value) => onChange('templateCode', value)}><Label>模板编码</Label><Input variant="secondary" /></TextField><TextField value={form.conditionKey} onChange={(value) => onChange('conditionKey', value)}><Label>条件字段</Label><Input variant="secondary" /></TextField><TextField value={form.conditionValue} onChange={(value) => onChange('conditionValue', value)}><Label>条件值</Label><Input variant="secondary" /></TextField>{error ? <FieldError className="sm:col-span-2">{error}</FieldError> : null}</Modal.Body><Modal.Footer className="rc-dialog-actions"><Button variant="secondary" isDisabled={saving} onPress={onClose}>取消</Button><Button variant="primary" isDisabled={saving} onPress={onSave}>{saving ? '保存中...' : '保存'}</Button></Modal.Footer></Modal.Dialog></Modal.Container></Modal.Backdrop></Modal.Root>; }
export default function ProcessMonitorPage() {
const [searchParams] = useSearchParams();
const initialTab = searchParams.get('tab') || '';
const { check } = useAuthorization();
const canManage = check('管理');
const [loading, setLoading] = useState(true);
const [groups, setGroups] = useState([]);
const [availableTemplates, setAvailableTemplates] = useState([]);
const [departmentOptions, setDepartmentOptions] = useState([]);
const [monitoredDepartmentOptions, setMonitoredDepartmentOptions] = useState([]);
const [activeGroupName, setActiveGroupName] = useState(initialTab);
const [templateKeyword, setTemplateKeyword] = useState('');
const [departmentFilter, setDepartmentFilter] = useState('');
const [notice, setNotice] = useState(null);
const [createGroupOpen, setCreateGroupOpen] = useState(false);
const [createGroupTitle, setCreateGroupTitle] = useState('');
const [addTemplateOpen, setAddTemplateOpen] = useState(false);
const [templatePickerKeyword, setTemplatePickerKeyword] = useState('');
const [selectedTemplateIds, setSelectedTemplateIds] = useState([]);
const [editTemplateOpen, setEditTemplateOpen] = useState(false);
const [editingTemplate, setEditingTemplate] = useState(null);
const [templateForm, setTemplateForm] = useState({
title: '',
department: '',
templateCode: '',
conditionKey: '',
conditionValue: '',
});
const [removeTarget, setRemoveTarget] = useState(null);
const [submitting, setSubmitting] = useState(false);
const loadPage = async (preferredGroupName) => {
setLoading(true);
try {
const result = await loadProcessMonitorOverview();
setGroups(result.groups);
setAvailableTemplates(result.availableTemplates);
setDepartmentOptions(result.departmentOptions);
setMonitoredDepartmentOptions(result.monitoredDepartmentOptions);
const nextActiveGroup =
result.groups.find((group) => group.name === preferredGroupName) ||
result.groups.find((group) => group.name === activeGroupName) ||
result.groups[0] ||
null;
setActiveGroupName(nextActiveGroup?.name || '');
} catch (error) {
setNotice({
type: 'error',
message: error?.message || '加载流程监控数据失败',
});
} finally {
setLoading(false);
}
};
useEffect(() => {
loadPage(initialTab);
}, []);
useEffect(() => {
if (!groups.length) {
return;
}
const matched = groups.some((group) => group.name === activeGroupName);
if (!matched) {
setActiveGroupName(groups[0].name);
}
}, [groups, activeGroupName]);
const activeGroup = useMemo(
() => groups.find((group) => group.name === activeGroupName) || null,
[groups, activeGroupName],
);
const currentGroupExternalIds = useMemo(
() =>
new Set(
(activeGroup?.monitoredTemplates || [])
.map((template) => template?.metadata?.external_id)
.filter(Boolean),
),
[activeGroup],
);
const filteredTemplates = useMemo(() => {
return (activeGroup?.monitoredTemplates || []).filter((template) => {
const matchKeyword = !templateKeyword || template.title?.includes(templateKeyword);
const matchDepartment =
!departmentFilter || template?.metadata?.department === departmentFilter;
return matchKeyword && matchDepartment;
});
}, [activeGroup, departmentFilter, templateKeyword]);
const pickerTemplates = useMemo(() => {
return availableTemplates.filter((template) =>
!templatePickerKeyword || template.title?.includes(templatePickerKeyword),
);
}, [availableTemplates, templatePickerKeyword]);
const monitoredTemplateCount = activeGroup?.monitoredCount || 0;
const monitoredTotalCount = groups.reduce(
(count, group) => count + Number(group.monitoredCount || 0),
0,
);
const handleCreateGroup = async () => {
if (!createGroupTitle.trim()) {
setNotice({ type: 'error', message: '请先填写分组名称' });
return;
}
setSubmitting(true);
try {
const record = await createProcessGroup(createGroupTitle.trim());
setCreateGroupOpen(false);
setCreateGroupTitle('');
await loadPage(record?.name);
setNotice({ type: 'success', message: `已创建分组“${record?.title || createGroupTitle.trim()}”` });
} catch (error) {
setNotice({ type: 'error', message: error?.message || '创建分组失败' });
} finally {
setSubmitting(false);
}
};
const handleAddTemplates = async () => {
if (!activeGroup) {
setNotice({ type: 'error', message: '请先创建或选择一个分组' });
return;
}
const selectedTemplates = pickerTemplates.filter((template) =>
selectedTemplateIds.includes(template._id),
);
if (!selectedTemplates.length) {
setNotice({ type: 'error', message: '请至少选择一个流程模板' });
return;
}
setSubmitting(true);
try {
await addTemplatesToGroup(activeGroup, selectedTemplates);
setAddTemplateOpen(false);
setTemplatePickerKeyword('');
setSelectedTemplateIds([]);
await loadPage(activeGroup.name);
setNotice({ type: 'success', message: `已添加 ${selectedTemplates.length} 个流程监控模板` });
} catch (error) {
setNotice({ type: 'error', message: error?.message || '添加流程监控失败' });
} finally {
setSubmitting(false);
}
};
const openEditDialog = (template) => {
setEditingTemplate(template);
setTemplateForm({
title: template?.title || '',
department: template?.metadata?.department || '',
templateCode: template?.metadata?.template_code || '',
conditionKey: template?.metadata?.conditions?.key || '',
conditionValue: template?.metadata?.conditions?.value || '',
});
setEditTemplateOpen(true);
};
const handleUpdateTemplate = async () => {
if (!editingTemplate?._id) {
return;
}
if (!templateForm.title.trim()) {
setNotice({ type: 'error', message: '模板标题不能为空' });
return;
}
setSubmitting(true);
try {
await updateMonitoredTemplate(editingTemplate._id, templateForm);
setEditTemplateOpen(false);
setEditingTemplate(null);
await loadPage(activeGroup?.name);
setNotice({ type: 'success', message: '模板配置已更新' });
} catch (error) {
setNotice({ type: 'error', message: error?.message || '模板更新失败' });
} finally {
setSubmitting(false);
}
};
const handleRemoveTemplate = async () => {
if (!activeGroup || !removeTarget) {
return;
}
setSubmitting(true);
try {
const latestGroup = await loadProcessGroup(activeGroup.name);
await removeMonitoredTemplate(latestGroup, removeTarget);
setRemoveTarget(null);
await loadPage(activeGroup.name);
setNotice({ type: 'success', message: '已取消该流程模板监控' });
} catch (error) {
setNotice({ type: 'error', message: error?.message || '取消监控失败' });
} finally {
setSubmitting(false);
}
};
return (
<div className="space-y-6">
<section className="overflow-hidden rounded-[32px] border border-white/80 bg-[linear-gradient(135deg,rgba(124,24,24,0.96),rgba(190,24,93,0.92)_54%,rgba(244,63,94,0.9))] px-6 py-7 text-white shadow-[0_28px_80px_rgba(159,18,57,0.24)] sm:px-8">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="max-w-3xl">
<div className="inline-flex items-center gap-2 rounded-full bg-white/14 px-3 py-1 text-xs font-medium text-white/90">
<QueueListIcon className="size-4" />
OA Process Monitor
</div>
<h1 className="mt-4 text-3xl font-semibold tracking-tight sm:text-4xl">流程监控工作台</h1>
<p className="mt-3 max-w-2xl text-sm leading-6 text-white/78 sm:text-base">
这里承接 `streams-admin` 中 `process_monitor` 的核心逻辑,用当前项目的新组件体系重建分组管理、模板监控、实例追踪和明细查看。
</p>
</div>
<div className="flex flex-wrap gap-3">
{canManage ? (
<Button color="blue" onClick={() => setCreateGroupOpen(true)}>
<PlusIcon data-slot="icon" />
新建分组
</Button>
) : null}
{canManage ? (
<Button
outline
onClick={() => setAddTemplateOpen(true)}
disabled={!activeGroup || loading}
>
<PlusIcon data-slot="icon" />
添加监控模板
</Button>
) : null}
</div>
</div>
</section>
<InlineNotice notice={notice} onClose={() => setNotice(null)} />
<div className="grid gap-4 lg:grid-cols-4">
<SummaryCard label="监控分组" value={groups.length} hint="按业务视角组织模板监控范围" />
<SummaryCard label="当前分组模板" value={monitoredTemplateCount} hint="当前选中分组的监控模板数量" />
<SummaryCard label="累计监控模板" value={monitoredTotalCount} hint="所有分组合计已纳入监控的模板数" />
<SummaryCard label="模板库" value={availableTemplates.length} hint="同步接口返回的可选流程模板数量" />
</div>
<SectionCard
title="分组切换"
description="保留旧版“分组 tab + 当前分组模板列表”的工作方式,但改成更轻的卡片导航。"
>
{!groups.length && !loading ? (
<EmptyState
title="还没有流程监控分组"
description="先创建分组,再把同步模板加入监控范围。"
action={
canManage ? (
<Button color="blue" onClick={() => setCreateGroupOpen(true)}>
立即创建分组
</Button>
) : null
}
/>
) : (
<div className="flex flex-wrap gap-3">
{groups.map((group) => {
const active = activeGroup?.name === group.name;
return (
<button
key={group.name}
type="button"
onClick={() => setActiveGroupName(group.name)}
className={clsx(
'min-w-[180px] rounded-[22px] border px-4 py-3 text-left transition',
active
? 'border-rose-400 bg-rose-50 shadow-sm'
: 'border-slate-200 bg-white hover:border-rose-200 hover:bg-rose-50/50',
)}
>
<div className="flex items-center justify-between gap-3">
<div className="truncate text-sm font-semibold text-slate-950">{group.title}</div>
<StatusPill tone={active ? 'brand' : 'default'}>{group.monitoredCount} 项</StatusPill>
</div>
<div className="mt-2 text-xs text-slate-500">点击查看该分组下的流程模板监控清单</div>
</button>
);
})}
</div>
)}
</SectionCard>
<SectionCard
title={activeGroup ? `模板列表 · ${activeGroup.title}` : '模板列表'}
description="这里展示当前分组下已启用监控的流程模板,可继续进入实例列表与单条明细。"
action={
<div className="flex flex-wrap gap-3">
<div className="relative min-w-[220px]">
<MagnifyingGlassIcon className="pointer-events-none absolute left-3 top-3 size-4 text-slate-400" />
<Input
value={templateKeyword}
onChange={(event) => setTemplateKeyword(event.target.value)}
placeholder="搜索模板标题"
className="pl-9"
/>
</div>
<div className="min-w-[180px]">
<Select value={departmentFilter} onChange={(event) => setDepartmentFilter(event.target.value)}>
<option value="">全部部门</option>
{monitoredDepartmentOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
</div>
</div>
}
>
{loading ? (
<div className="rounded-[24px] border border-slate-200 bg-slate-50/70 px-6 py-10 text-sm text-slate-500">
正在加载流程监控列表...
</div>
) : !filteredTemplates.length ? (
<EmptyState
title={activeGroup ? '当前分组还没有监控模板' : '暂无可展示模板'}
description="可以从同步模板库中挑选模板加入当前分组,后续再进入实例详情查看实际提交数据。"
action={
canManage && activeGroup ? (
<Button color="blue" onClick={() => setAddTemplateOpen(true)}>
添加监控模板
</Button>
) : null
}
/>
) : (
<Table bleed striped grid>
<TableHead>
<TableRow>
<TableHeader>模板名称</TableHeader>
<TableHeader>所属部门</TableHeader>
<TableHeader>模板编码</TableHeader>
<TableHeader>显示条件</TableHeader>
<TableHeader>状态</TableHeader>
{canManage ? <TableHeader>操作</TableHeader> : null}
</TableRow>
</TableHead>
<TableBody>
{filteredTemplates.map((template) => (
<TableRow key={template._id || template.name}>
<TableCell>
<div className="space-y-1">
<Link
to={`/process_monitor/${template._id}?tab=${activeGroup?.name || ''}`}
className="font-medium text-slate-950 transition hover:text-rose-700"
>
{template.title}
</Link>
<div className="text-xs text-slate-500">
外部模板 ID:{template?.metadata?.external_id || '-'}
</div>
</div>
</TableCell>
<TableCell>{template?.metadata?.department || '-'}</TableCell>
<TableCell>{template?.metadata?.template_code || '-'}</TableCell>
<TableCell>
{template?.metadata?.conditions?.key ? (
<div className="text-sm text-slate-600">
{template.metadata.conditions.key} = {template.metadata.conditions.value || '-'}
</div>
) : (
'-'
)}
</TableCell>
<TableCell>
<StatusPill tone="emerald">监控中</StatusPill>
</TableCell>
{canManage ? (
<TableCell>
<div className="flex flex-wrap gap-2">
<Button plain onClick={() => openEditDialog(template)}>
<PencilSquareIcon data-slot="icon" />
编辑
</Button>
<Button plain color="red" onClick={() => setRemoveTarget(template)}>
<TrashIcon data-slot="icon" />
取消监控
</Button>
</div>
</TableCell>
) : null}
</TableRow>
))}
</TableBody>
</Table>
)}
</SectionCard>
<Dialog open={createGroupOpen} onClose={setCreateGroupOpen}>
<DialogTitle>新建流程监控分组</DialogTitle>
<DialogDescription>分组用于承载一组需要一起观察的流程模板。</DialogDescription>
<DialogBody>
<FieldGroup>
<Field>
<Label>分组名称</Label>
<Input
value={createGroupTitle}
onChange={(event) => setCreateGroupTitle(event.target.value)}
placeholder="例如:投资业务流程"
/>
</Field>
</FieldGroup>
</DialogBody>
<DialogActions>
<Button outline onClick={() => setCreateGroupOpen(false)} disabled={submitting}>
取消
</Button>
<Button color="blue" onClick={handleCreateGroup} disabled={submitting}>
创建分组
</Button>
</DialogActions>
</Dialog>
<Dialog
open={addTemplateOpen}
onClose={(open) => {
setAddTemplateOpen(open);
if (!open) {
setSelectedTemplateIds([]);
setTemplatePickerKeyword('');
}
}}
size="3xl"
>
<DialogTitle>添加流程监控模板</DialogTitle>
<DialogDescription>从同步模板库中选择一个或多个模板,加入当前分组的监控范围。</DialogDescription>
<DialogBody>
<div className="space-y-4">
<Input
value={templatePickerKeyword}
onChange={(event) => setTemplatePickerKeyword(event.target.value)}
placeholder="搜索模板标题"
/>
<div className="max-h-80 space-y-2 overflow-y-auto rounded-2xl border border-slate-200 bg-slate-50/60 p-3">
{pickerTemplates.map((template) => {
const disabled = currentGroupExternalIds.has(template._id);
const checked = selectedTemplateIds.includes(template._id);
return (
<label
key={template._id}
className={clsx(
'flex items-start gap-3 rounded-2xl border px-3 py-3 transition',
disabled
? 'border-slate-200 bg-slate-100/70 opacity-60'
: checked
? 'border-rose-300 bg-rose-50'
: 'border-white bg-white hover:border-rose-200',
)}
>
<Checkbox
checked={checked}
disabled={disabled}
onChange={(value) =>
setSelectedTemplateIds((current) =>
value
? [...current, template._id]
: current.filter((id) => id !== template._id),
)
}
color="red"
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-slate-950">{template.title}</div>
<div className="mt-1 text-xs text-slate-500">
模板 ID:{template._id}
{disabled ? ' · 当前分组已添加' : ''}
</div>
</div>
</label>
);
})}
{!pickerTemplates.length ? (
<div className="px-2 py-4 text-sm text-slate-500">没有找到匹配的模板。</div>
) : null}
</div>
</div>
</DialogBody>
<DialogActions>
<Button outline onClick={() => setAddTemplateOpen(false)} disabled={submitting}>
取消
</Button>
<Button color="blue" onClick={handleAddTemplates} disabled={submitting || !activeGroup}>
确认添加
</Button>
</DialogActions>
</Dialog>
<Dialog open={editTemplateOpen} onClose={setEditTemplateOpen} size="2xl">
<DialogTitle>编辑模板监控配置</DialogTitle>
<DialogDescription>延续旧版的部门、模板编码和显示条件配置逻辑。</DialogDescription>
<DialogBody>
<FieldGroup>
<Field>
<Label>模板标题</Label>
<Input
value={templateForm.title}
onChange={(event) =>
setTemplateForm((current) => ({ ...current, title: event.target.value }))
}
/>
</Field>
<Field>
<Label>所属部门</Label>
<Select
value={templateForm.department}
onChange={(event) =>
setTemplateForm((current) => ({ ...current, department: event.target.value }))
}
>
<option value="">未设置</option>
{departmentOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
</Field>
<Field>
<Label>模板 Code</Label>
<Input
value={templateForm.templateCode}
onChange={(event) =>
setTemplateForm((current) => ({ ...current, templateCode: event.target.value }))
}
/>
</Field>
<div className="grid gap-4 sm:grid-cols-2">
<Field>
<Label>显示条件字段</Label>
<Input
value={templateForm.conditionKey}
onChange={(event) =>
setTemplateForm((current) => ({ ...current, conditionKey: event.target.value }))
}
placeholder="例如:来源系统"
/>
</Field>
<Field>
<Label>显示条件值</Label>
<Input
value={templateForm.conditionValue}
onChange={(event) =>
setTemplateForm((current) => ({ ...current, conditionValue: event.target.value }))
}
placeholder="例如:OA"
/>
</Field>
</div>
</FieldGroup>
</DialogBody>
<DialogActions>
<Button outline onClick={() => setEditTemplateOpen(false)} disabled={submitting}>
取消
</Button>
<Button color="blue" onClick={handleUpdateTemplate} disabled={submitting}>
保存配置
</Button>
</DialogActions>
</Dialog>
<Dialog open={Boolean(removeTarget)} onClose={() => setRemoveTarget(null)}>
<DialogTitle>取消流程模板监控</DialogTitle>
<DialogDescription>
{removeTarget ? `确认把“${removeTarget.title}”从当前分组移除吗?` : '确认移除该流程模板吗?'}
</DialogDescription>
<DialogActions>
<Button outline onClick={() => setRemoveTarget(null)} disabled={submitting}>
取消
</Button>
<Button color="red" onClick={handleRemoveTemplate} disabled={submitting}>
确认移除
</Button>
</DialogActions>
</Dialog>
</div>
);
const [searchParams] = useSearchParams(); const { check } = useAuthorization(); const canManage = check('管理');
const [groups, setGroups] = useState([]); const [availableTemplates, setAvailableTemplates] = useState([]); const [departmentOptions, setDepartmentOptions] = useState([]); const [activeGroupName, setActiveGroupName] = useState(searchParams.get('tab') || ''); const [keyword, setKeyword] = useState(''); const [department, setDepartment] = useState(''); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [notice, setNotice] = useState(''); const [createOpen, setCreateOpen] = useState(false); const [createTitle, setCreateTitle] = useState(''); const [addOpen, setAddOpen] = useState(false); const [selectedIds, setSelectedIds] = useState([]); const [editTarget, setEditTarget] = useState(null); const [editForm, setEditForm] = useState(EMPTY_TEMPLATE_FORM); const [removeTarget, setRemoveTarget] = useState(null); const [saving, setSaving] = useState(false);
const loadPage = async (preferred) => { setLoading(true); setError(''); try { const result = await loadProcessMonitorOverview(); setGroups(result.groups); setAvailableTemplates(result.availableTemplates); setDepartmentOptions(result.departmentOptions); setActiveGroupName(result.groups.find((group) => group.name === preferred)?.name || result.groups[0]?.name || ''); } catch (requestError) { setError(requestError?.message || '加载流程监控数据失败'); } finally { setLoading(false); } };
useEffect(() => { loadPage(searchParams.get('tab') || ''); }, []);
const activeGroup = useMemo(() => groups.find((group) => group.name === activeGroupName) || null, [groups, activeGroupName]); const rows = useMemo(() => (activeGroup?.monitoredTemplates || []).filter((template) => (!keyword || String(template.title || '').includes(keyword)) && (!department || template.metadata?.department === department)), [activeGroup, keyword, department]);
const saveCreate = async () => { if (!createTitle.trim()) return; setSaving(true); try { const record = await createProcessGroup(createTitle.trim()); setCreateOpen(false); setCreateTitle(''); await loadPage(record?.name); setNotice('分组已创建'); } catch (requestError) { setError(requestError?.message || '创建分组失败'); } finally { setSaving(false); } };
const saveAdd = async () => { if (!activeGroup || !selectedIds.length) return; const templates = availableTemplates.filter((template) => selectedIds.includes(String(template._id || template.id))); setSaving(true); try { await addTemplatesToGroup(activeGroup, templates); setAddOpen(false); setSelectedIds([]); await loadPage(activeGroup.name); setNotice('模板已添加'); } catch (requestError) { setError(requestError?.message || '添加模板失败'); } finally { setSaving(false); } };
const saveEdit = async () => { if (!editTarget) return; setSaving(true); try { await updateMonitoredTemplate(editTarget._id || editTarget.name, editForm); setEditTarget(null); await loadPage(activeGroupName); setNotice('模板已更新'); } catch (requestError) { setError(requestError?.message || '保存模板失败'); } finally { setSaving(false); } };
const confirmRemove = async () => { if (!removeTarget || !activeGroup) return; setSaving(true); try { await removeMonitoredTemplate(activeGroup, removeTarget); setRemoveTarget(null); await loadPage(activeGroupName); setNotice('模板已移除'); } catch (requestError) { setError(requestError?.message || '移除模板失败'); } finally { setSaving(false); } };
if (loading && !groups.length) return <LoadingState label="正在加载流程监控" />; if (error && !groups.length) return <ErrorState message={error} onRetry={() => loadPage(activeGroupName)} />;
const columns = [{ key: 'title', title: '模板名称', isPrimary: true, width: 300, render: (row) => <DataTablePrimaryAction text={row.title} onPress={() => history.push(`/process_monitor/${row._id}?tab=${activeGroupName}`)}>{row.title}</DataTablePrimaryAction> }, { key: 'department', title: '所属部门', width: 180, render: (row) => row.metadata?.department || '-' }, { key: 'templateCode', title: '模板编码', width: 180, render: (row) => row.metadata?.template_code || '-' }, { key: 'condition', title: '显示条件', width: 240, render: (row) => row.metadata?.conditions?.key ? `${row.metadata.conditions.key} = ${row.metadata.conditions.value || '-'}` : <DataTableSecondaryText>未配置</DataTableSecondaryText> }, { key: 'actions', title: '操作', isActions: true, width: 128, render: (row) => <div className="flex items-center justify-end gap-1"><ActionIcon label="查看模板" onPress={() => history.push(`/process_monitor/${row._id}?tab=${activeGroupName}`)}><Eye size={16} /></ActionIcon>{canManage ? <ActionIcon label="编辑模板" onPress={() => { setEditTarget(row); setEditForm({ title: row.title || '', department: row.metadata?.department || '', templateCode: row.metadata?.template_code || '', conditionKey: row.metadata?.conditions?.key || '', conditionValue: row.metadata?.conditions?.value || '' }); }}><Edit size={16} /></ActionIcon> : null}{canManage ? <ActionIcon label="移除模板" onPress={() => setRemoveTarget(row)}><Delete size={16} /></ActionIcon> : null}</div> }];
return <div className="space-y-6"><div className="flex min-w-0 flex-wrap items-center justify-between gap-4"><GroupTabs groups={groups} activeGroupName={activeGroupName} onChange={setActiveGroupName} /><div className="flex flex-wrap items-center gap-2">{canManage ? <Button variant="secondary" onPress={() => setCreateOpen(true)}><Plus size={16} />新建分组</Button> : null}{canManage && activeGroup ? <Button variant="primary" onPress={() => setAddOpen(true)}><Plus size={16} />添加模板</Button> : null}</div></div>{notice ? <div role="status" className="rc-toast-success">{notice}</div> : null}<Surface className="rc-filter-surface p-4" variant="secondary"><div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_220px_auto]"><SearchField aria-label="搜索模板" value={keyword} variant="secondary" onChange={setKeyword}><SearchField.Group><SearchField.SearchIcon><Search size={16} /></SearchField.SearchIcon><SearchField.Input placeholder="搜索模板名称" /><SearchField.ClearButton aria-label="清空搜索" /></SearchField.Group></SearchField><FilterSelect aria-label="所属部门" options={departmentOptions} value={department} variant="secondary" onChange={setDepartment} /><Button isIconOnly aria-label="刷新" size="sm" variant="ghost" onPress={() => loadPage(activeGroupName)}><Search size={16} /></Button></div></Surface><div className="flex min-w-0 items-center justify-between gap-3"><h2 className="rc-section-title">{activeGroup?.title || '流程监控'}</h2><span className="text-sm text-muted">共 {rows.length} 个模板</span></div><DataTable ariaLabel="流程监控模板列表" columns={columns} rows={rows} isLoading={loading} emptyTitle="暂无监控模板" /><DataTablePagination page={0} pageSize={20} total={rows.length} onPageChange={() => {}} /><CreateGroupDialog open={createOpen} saving={saving} value={createTitle} error={error} onChange={setCreateTitle} onClose={() => setCreateOpen(false)} onSave={saveCreate} /><AddTemplatesDialog open={addOpen} saving={saving} templates={availableTemplates} selectedIds={selectedIds} error={error} onChange={setSelectedIds} onClose={() => setAddOpen(false)} onSave={saveAdd} /><EditTemplateDialog open={Boolean(editTarget)} saving={saving} form={editForm} error={error} onChange={(key, value) => setEditForm((current) => ({ ...current, [key]: value }))} onClose={() => setEditTarget(null)} onSave={saveEdit} /><AlertDialog.Root isOpen={Boolean(removeTarget)} onOpenChange={(open) => { if (!open && !saving) setRemoveTarget(null); }}><AlertDialog.Trigger className="sr-only" /><AlertDialog.Backdrop variant="blur"><AlertDialog.Container><AlertDialog.Dialog className="rc-dialog-panel rc-dialog-panel--md"><AlertDialog.Heading>移除监控模板</AlertDialog.Heading><AlertDialog.Description>确认从当前分组移除“{removeTarget?.title || ''}”吗?</AlertDialog.Description><AlertDialog.Footer><Button variant="secondary" isDisabled={saving} onPress={() => setRemoveTarget(null)}>取消</Button><Button variant="danger" isDisabled={saving} onPress={confirmRemove}>确认移除</Button></AlertDialog.Footer></AlertDialog.Dialog></AlertDialog.Container></AlertDialog.Backdrop></AlertDialog.Root></div>;
}
import { Button } from '@/catalyst-ui-kit/javascript/button';
import {
Dialog,
DialogActions,
DialogBody,
DialogDescription,
DialogTitle,
} from '@/catalyst-ui-kit/javascript/dialog';
import { Field, FieldGroup, Label } from '@/catalyst-ui-kit/javascript/fieldset';
import { Input } from '@/catalyst-ui-kit/javascript/input';
import { Select } from '@/catalyst-ui-kit/javascript/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/catalyst-ui-kit/javascript/table';
import {
buildDepartmentLookup,
buildMemberLookup,
downloadAllProcessAttachments,
downloadProcessAttachment,
findDetailField,
formatDateTime,
loadTemplateMonitorPage,
normalizeDisplayFieldDefinitions,
queryProcessInstances,
updateTemplateDisplayFields,
} from '@/pages/process_monitor/processMonitorService';
import {
ArrowLeftIcon,
ArrowDownTrayIcon,
MagnifyingGlassIcon,
PlusIcon,
TrashIcon,
} from '@heroicons/react/24/outline';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Link, useParams, useSearchParams } from 'umi';
import { DataTable, DataTablePagination, DataTablePrimaryAction, DataTableSecondaryText } from '@/components/DataTable';
import { ArrowLeft, Download, Edit, Eye, Plus, Search, Trash } from '@/components/AppIcons';
import { AlertDialog, Button, FieldError, Input, Label, Modal, SearchField, TextField } from '@heroui/react';
import { useEffect, useMemo, useState } from 'react';
import { history, Link, useParams, useSearchParams } from 'umi';
import { ErrorState, LoadingState } from '@/pages/audit_management/PageState';
import { buildDepartmentLookup, buildMemberLookup, downloadAllProcessAttachments, downloadProcessAttachment, findDetailField, formatDateTime, loadTemplateMonitorPage, normalizeDisplayFieldDefinitions, queryProcessInstances, updateTemplateDisplayFields } from './processMonitorService';
function SectionCard({ title, description, action, children }) {
return (
<section className="rounded-[28px] border border-slate-200/80 bg-white/92 p-5 shadow-sm">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 className="text-lg font-semibold text-slate-950">{title}</h2>
{description ? <p className="mt-1 text-sm text-slate-500">{description}</p> : null}
</div>
{action}
</div>
<div className="mt-5">{children}</div>
</section>
);
}
function StatusNotice({ message }) {
if (!message) {
return null;
}
return (
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">
{message}
</div>
);
}
function SummaryCard({ label, value, hint }) {
return (
<div className="rounded-[24px] border border-slate-200/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.94),rgba(240,249,255,0.94))] p-5 shadow-sm">
<div className="text-xs font-semibold uppercase tracking-[0.22em] text-slate-400">{label}</div>
<div className="mt-3 text-3xl font-semibold tracking-tight text-slate-950">{value}</div>
<div className="mt-2 text-sm text-slate-500">{hint}</div>
</div>
);
}
function formatLookupValue(type, value, lookups) {
const values = String(value || '')
.split(',')
.map((item) => item.trim())
.filter(Boolean);
if (!values.length) {
return '-';
}
const mapping = type === 'member' ? lookups.memberLookup : lookups.departmentLookup;
return values.map((item) => mapping[item] || item).join('、');
}
function renderFieldValue(fieldDefinition, record, lookups, onDownload) {
const fieldDetail = findDetailField(record, fieldDefinition.display_name);
if (!fieldDetail) {
return '-';
}
if (fieldDetail.type === 'file') {
const files = Array.isArray(fieldDetail.value) ? fieldDetail.value : [];
if (!files.length) {
return '-';
}
return (
<div className="flex flex-wrap gap-2">
{files.map((file) => (
<button
key={`${file.fileUrl || file.url}-${file.filename}`}
type="button"
onClick={() => onDownload(file)}
className="rounded-full bg-rose-50 px-2.5 py-1 text-xs font-medium text-rose-700 transition hover:bg-rose-100"
>
{file.filename || file.fileName || '附件'}
</button>
))}
</div>
);
}
if (fieldDefinition.type === 'member') {
return formatLookupValue('member', fieldDetail.value, lookups);
}
if (fieldDefinition.type === 'department') {
return formatLookupValue('department', fieldDetail.value, lookups);
}
if (fieldDefinition.type === 'date') {
return formatDateTime(fieldDetail.value, 'YYYY-MM-DD HH:mm');
}
return fieldDetail.value || '-';
}
function renderFieldValue(field, record, lookups) { const value = findDetailField(record, field.display_name)?.value; if (field.type === 'date') return formatDateTime(value, 'YYYY-MM-DD HH:mm'); if (field.type === 'member') return String(value || '').split(',').map((item) => lookups.members[item.trim()] || item.trim()).filter(Boolean).join('、') || '-'; if (field.type === 'department') return String(value || '').split(',').map((item) => lookups.departments[item.trim()] || item.trim()).filter(Boolean).join('、') || '-'; if (field.type === 'attachment') return Array.isArray(value) && value.length ? value.map((file) => file.filename || file.fileName || '附件').join('、') : '-'; return value || '-'; }
function FieldDialog({ open, saving, fields, onChange, onClose, onSave, error }) { return <Modal.Root isOpen={open} onOpenChange={(next) => { if (!next && !saving) onClose(); }}><Modal.Trigger aria-hidden="true" aria-label="打开显示字段弹窗" className="sr-only" tabIndex={-1} /><Modal.Backdrop isDismissable={!saving} variant="blur"><Modal.Container placement="center" scroll="inside" size="lg"><Modal.Dialog className="rc-dialog-panel rc-dialog-panel--lg"><Modal.CloseTrigger aria-label="关闭" /><Modal.Header><Modal.Heading>定义显示字段</Modal.Heading></Modal.Header><Modal.Body className="space-y-3">{fields.map((field, index) => <div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_180px_auto]" key={field.id}><TextField value={field.display_name} onChange={(value) => onChange(index, 'display_name', value)}><Label>字段名称</Label><Input variant="secondary" /></TextField><TextField value={field.type} onChange={(value) => onChange(index, 'type', value)}><Label>字段类型</Label><Input variant="secondary" /></TextField><Button isIconOnly aria-label="删除字段" size="sm" variant="ghost" onPress={() => onChange(index, 'remove')}><Trash size={16} /></Button></div>)}{error ? <FieldError>{error}</FieldError> : null}<Button variant="secondary" onPress={() => onChange(fields.length, 'add')}><Plus size={16} />添加字段</Button></Modal.Body><Modal.Footer className="rc-dialog-actions"><Button variant="secondary" isDisabled={saving} onPress={onClose}>取消</Button><Button variant="primary" isDisabled={saving} onPress={onSave}>{saving ? '保存中...' : '保存'}</Button></Modal.Footer></Modal.Dialog></Modal.Container></Modal.Backdrop></Modal.Root>; }
export default function ProcessTemplateListPage() {
const { processId } = useParams();
const [searchParams] = useSearchParams();
const tabName = searchParams.get('tab') || '';
const [loading, setLoading] = useState(true);
const [downloading, setDownloading] = useState(false);
const [message, setMessage] = useState('');
const [template, setTemplate] = useState(null);
const [fieldsDefinition, setFieldsDefinition] = useState([]);
const [members, setMembers] = useState([]);
const [departments, setDepartments] = useState([]);
const [conditions, setConditions] = useState(null);
const [instances, setInstances] = useState([]);
const [keyword, setKeyword] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [total, setTotal] = useState(0);
const [fieldDialogOpen, setFieldDialogOpen] = useState(false);
const [fieldDrafts, setFieldDrafts] = useState([]);
const [fieldSaving, setFieldSaving] = useState(false);
const nextFieldDraftId = useRef(0);
const createFieldDraft = (field = {}) => ({
...field,
draftId: nextFieldDraftId.current++,
});
const loadTemplateMeta = async () => {
const result = await loadTemplateMonitorPage(processId);
setTemplate(result.template);
setFieldsDefinition(result.fieldsDefinition);
setMembers(result.members);
setDepartments(result.departments);
setConditions(result.conditions);
};
const loadInstances = async () => {
const result = await queryProcessInstances({
processId,
page,
pageSize,
keyword,
conditions,
});
setInstances(result.records);
setTotal(result.total);
};
useEffect(() => {
setLoading(true);
Promise.all([loadTemplateMeta(), loadInstances()])
.catch((error) => {
setMessage(error?.message || '加载流程实例失败');
})
.finally(() => {
setLoading(false);
});
}, [processId]);
useEffect(() => {
loadInstances().catch((error) => {
setMessage(error?.message || '查询流程实例失败');
});
}, [page, pageSize, keyword, conditions]);
const lookups = useMemo(
() => ({
memberLookup: buildMemberLookup(members),
departmentLookup: buildDepartmentLookup(departments),
}),
[departments, members],
);
const totalPages = Math.max(Math.ceil(total / pageSize), 1);
const openFieldDialog = () => {
const normalizedFields = normalizeDisplayFieldDefinitions(fieldsDefinition);
setFieldDrafts(
(normalizedFields.length ? normalizedFields : [{ display_name: '', type: 'text' }]).map(
createFieldDraft,
),
);
setFieldDialogOpen(true);
};
const saveFieldDefinitions = async () => {
const normalizedFields = fieldDrafts
.map((field) => ({
display_name: field.display_name?.trim() || '',
type: field.type || 'text',
}))
.filter((field) => field.display_name);
setFieldSaving(true);
try {
await updateTemplateDisplayFields(processId, normalizedFields);
setFieldsDefinition(normalizedFields);
setFieldDialogOpen(false);
setMessage('显示字段配置已更新');
} catch (error) {
setMessage(error?.message || '显示字段保存失败');
} finally {
setFieldSaving(false);
}
};
const handleDownloadAll = async () => {
setDownloading(true);
try {
const count = await downloadAllProcessAttachments(processId);
setMessage(count > 0 ? `已触发 ${count} 个附件下载` : '当前模板下没有可下载附件');
} catch (error) {
setMessage(error?.message || '批量下载失败');
} finally {
setDownloading(false);
}
};
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center gap-3 text-sm text-slate-500">
<Link
to={tabName ? `/process_monitor?tab=${tabName}` : '/process_monitor'}
className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1.5 font-medium text-slate-600 transition hover:border-rose-200 hover:text-rose-700"
>
<ArrowLeftIcon className="size-4" />
返回流程监控
</Link>
<span>/</span>
<span>{template?.title || '模板实例列表'}</span>
</div>
<section className="overflow-hidden rounded-[32px] border border-rose-100 bg-[linear-gradient(135deg,rgba(254,226,226,0.72),rgba(255,228,230,0.82))] px-6 py-7 shadow-sm sm:px-8">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="max-w-3xl">
<h1 className="text-3xl font-semibold tracking-tight text-slate-950 sm:text-4xl">
{template?.title || '流程模板监控'}
</h1>
<p className="mt-3 text-sm leading-6 text-slate-600 sm:text-base">
这里复刻旧版 `ProcessList` 的模板实例追踪逻辑,包括动态显示字段、关键字检索和附件批量下载。
</p>
</div>
<div className="flex flex-wrap gap-3">
<Button outline onClick={handleDownloadAll} disabled={downloading || loading}>
<ArrowDownTrayIcon data-slot="icon" />
批量下载附件
</Button>
<Button color="blue" onClick={openFieldDialog} disabled={loading}>
<PlusIcon data-slot="icon" />
定义显示字段
</Button>
</div>
</div>
</section>
<StatusNotice message={message} />
<div className="grid gap-4 lg:grid-cols-4">
<SummaryCard label="模板实例" value={total} hint="当前模板下命中的提交流程数量" />
<SummaryCard label="显示字段" value={fieldsDefinition.length} hint="由模板配置决定表格展开的动态列" />
<SummaryCard
label="条件筛选"
value={conditions?.key ? '已启用' : '未启用'}
hint={conditions?.key ? `${conditions.key} = ${conditions.value || '-'}` : '当前没有预设显示条件'}
/>
<SummaryCard label="分页设置" value={pageSize} hint={`当前第 ${page} / ${totalPages} 页`} />
</div>
<SectionCard
title="实例列表"
description="按模板展示实际提交的流程实例,点击表单名称进入单条详情。"
action={
<div className="flex flex-wrap gap-3">
<div className="relative min-w-[240px]">
<MagnifyingGlassIcon className="pointer-events-none absolute left-3 top-3 size-4 text-slate-400" />
<Input
value={keyword}
onChange={(event) => {
setPage(1);
setKeyword(event.target.value);
}}
placeholder="搜索表单名称"
className="pl-9"
/>
</div>
<Select
value={String(pageSize)}
onChange={(event) => {
setPage(1);
setPageSize(Number(event.target.value));
}}
>
{[10, 20, 50, 100].map((value) => (
<option key={value} value={String(value)}>
每页 {value} 条
</option>
))}
</Select>
</div>
}
>
{loading ? (
<div className="rounded-[24px] border border-slate-200 bg-slate-50/70 px-6 py-10 text-sm text-slate-500">
正在加载模板实例...
</div>
) : (
<div className="space-y-4">
<Table bleed striped grid>
<TableHead>
<TableRow>
<TableHeader>表单名称</TableHeader>
{fieldsDefinition.map((field) => (
<TableHeader key={`${field.display_name}-${field.type}`}>
{field.display_name}
</TableHeader>
))}
</TableRow>
</TableHead>
<TableBody>
{instances.map((record) => (
<TableRow key={record._id || record.name}>
<TableCell>
<Link
to={`/process_monitor/${processId}/${record._id}?tab=${tabName}`}
className="font-medium text-slate-950 transition hover:text-rose-700"
>
{record.title}
</Link>
</TableCell>
{fieldsDefinition.map((field) => (
<TableCell key={`${record._id}-${field.display_name}`}>
{renderFieldValue(field, record, lookups, downloadProcessAttachment)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
{!instances.length ? (
<div className="rounded-[24px] border border-dashed border-slate-300 bg-slate-50/60 px-6 py-10 text-center text-sm text-slate-500">
当前筛选条件下没有找到流程实例。
</div>
) : null}
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-slate-200 bg-slate-50/70 px-4 py-3 text-sm text-slate-600">
<div>
共 {total} 条,当前第 {page} / {totalPages} 页
</div>
<div className="flex gap-2">
<Button outline onClick={() => setPage((current) => Math.max(current - 1, 1))} disabled={page <= 1}>
上一页
</Button>
<Button
outline
onClick={() => setPage((current) => Math.min(current + 1, totalPages))}
disabled={page >= totalPages}
>
下一页
</Button>
</div>
</div>
</div>
)}
</SectionCard>
<Dialog open={fieldDialogOpen} onClose={setFieldDialogOpen} size="3xl">
<DialogTitle>定义显示字段</DialogTitle>
<DialogDescription>保持旧版能力,允许为当前监控模板指定实例表格要展开的字段列。</DialogDescription>
<DialogBody>
<FieldGroup>
{fieldDrafts.map((field, index) => (
<div key={field.draftId} className="grid gap-4 rounded-2xl border border-slate-200 bg-slate-50/60 p-4 sm:grid-cols-[1.4fr_1fr_auto]">
<Field>
<Label>字段名</Label>
<Input
value={field.display_name}
onChange={(event) =>
setFieldDrafts((current) =>
current.map((item, itemIndex) =>
itemIndex === index
? { ...item, display_name: event.target.value }
: item,
),
)
}
placeholder="例如:提交人"
/>
</Field>
<Field>
<Label>字段类型</Label>
<Select
value={field.type}
onChange={(event) =>
setFieldDrafts((current) =>
current.map((item, itemIndex) =>
itemIndex === index ? { ...item, type: event.target.value } : item,
),
)
}
>
<option value="text">文本</option>
<option value="member">人员</option>
<option value="department">部门</option>
<option value="date">日期</option>
<option value="attachment">附件</option>
</Select>
</Field>
<div className="flex items-end">
<Button
plain
color="red"
onClick={() =>
setFieldDrafts((current) => current.filter((_, itemIndex) => itemIndex !== index))
}
>
<TrashIcon data-slot="icon" />
删除
</Button>
</div>
</div>
))}
</FieldGroup>
<div className="mt-4">
<Button
outline
onClick={() =>
setFieldDrafts((current) => [
...current,
createFieldDraft({ display_name: '', type: 'text' }),
])
}
>
<PlusIcon data-slot="icon" />
添加字段
</Button>
</div>
</DialogBody>
<DialogActions>
<Button outline onClick={() => setFieldDialogOpen(false)} disabled={fieldSaving}>
取消
</Button>
<Button color="blue" onClick={saveFieldDefinitions} disabled={fieldSaving}>
保存字段
</Button>
</DialogActions>
</Dialog>
</div>
);
const { processId } = useParams(); const [searchParams] = useSearchParams(); const tabName = searchParams.get('tab') || ''; const [template, setTemplate] = useState(null); const [fields, setFields] = useState([]); const [members, setMembers] = useState([]); const [departments, setDepartments] = useState([]); const [instances, setInstances] = useState([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(20); const [keyword, setKeyword] = useState(''); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [fieldOpen, setFieldOpen] = useState(false); const [fieldDrafts, setFieldDrafts] = useState([]); const [saving, setSaving] = useState(false);
const load = async () => { setLoading(true); setError(''); try { const result = await loadTemplateMonitorPage(processId); setTemplate(result.template); setFields(result.fieldsDefinition); setMembers(result.members); setDepartments(result.departments); const pageResult = await queryProcessInstances({ processId, page, pageSize, keyword, conditions: result.conditions }); setInstances(pageResult.records); setTotal(pageResult.total); } catch (requestError) { setError(requestError?.message || '加载模板实例失败'); } finally { setLoading(false); } };
useEffect(() => { load(); }, [processId, page, pageSize, keyword]);
const lookups = useMemo(() => ({ members: buildMemberLookup(members), departments: buildDepartmentLookup(departments) }), [members, departments]);
const columns = [{ key: 'title', title: '表单名称', isPrimary: true, width: 300, render: (row) => <DataTablePrimaryAction text={row.title} onPress={() => history.push(`/process_monitor/${processId}/${row._id}?tab=${tabName}`)}>{row.title}</DataTablePrimaryAction> }, ...fields.map((field) => ({ key: field.display_name, title: field.display_name, width: 180, render: (row) => renderFieldValue(field, row, lookups) })), { key: 'actions', title: '操作', isActions: true, width: 112, render: (row) => <Button isIconOnly aria-label="查看实例" size="sm" variant="ghost" onPress={() => history.push(`/process_monitor/${processId}/${row._id}?tab=${tabName}`)}><Eye size={16} /></Button> }];
const openFields = () => { setFieldDrafts((fields.length ? fields : [{ display_name: '', type: 'text' }]).map((field, index) => ({ ...field, id: `${field.display_name}-${index}` }))); setFieldOpen(true); };
const changeField = (index, key, value) => setFieldDrafts((current) => key === 'remove' ? current.filter((_, itemIndex) => itemIndex !== index) : key === 'add' ? [...current, { id: `field-${Date.now()}`, display_name: '', type: 'text' }] : current.map((item, itemIndex) => itemIndex === index ? { ...item, [key]: value } : item));
const saveFields = async () => { setSaving(true); try { const next = fieldDrafts.filter((field) => field.display_name.trim()).map(({ id, ...field }) => ({ display_name: field.display_name.trim(), type: field.type || 'text' })); await updateTemplateDisplayFields(processId, next); setFields(next); setFieldOpen(false); } catch (requestError) { setError(requestError?.message || '保存显示字段失败'); } finally { setSaving(false); } };
if (loading && !template) return <LoadingState label="正在加载模板实例" />; if (error && !template) return <ErrorState message={error} onRetry={load} />;
return <div className="space-y-6"><div className="flex flex-wrap items-center justify-between gap-4"><Link className="inline-flex items-center gap-2 text-sm text-muted" to={tabName ? `/process_monitor?tab=${tabName}` : '/process_monitor'}><ArrowLeft size={16} />返回流程监控</Link><div className="flex flex-wrap gap-2"><Button variant="secondary" isDisabled={loading} onPress={() => downloadAllProcessAttachments(processId)}><Download size={16} />批量下载附件</Button><Button variant="primary" onPress={openFields}><Edit size={16} />定义显示字段</Button></div></div><h1 className="rc-section-title">{template?.title || '模板实例列表'}</h1><div className="flex flex-wrap items-center justify-between gap-3"><SearchField aria-label="搜索表单" value={keyword} variant="secondary" onChange={(value) => { setPage(1); setKeyword(value); }}><SearchField.Group><SearchField.SearchIcon><Search size={16} /></SearchField.SearchIcon><SearchField.Input placeholder="搜索表单名称" /><SearchField.ClearButton aria-label="清空搜索" /></SearchField.Group></SearchField><span className="text-sm text-muted">共 {total} 条实例</span></div><DataTable ariaLabel="流程实例列表" columns={columns} rows={instances} isLoading={loading} emptyTitle="暂无流程实例" /><DataTablePagination page={page - 1} pageSize={pageSize} total={total} onPageChange={(next) => setPage(next + 1)} /><FieldDialog open={fieldOpen} saving={saving} fields={fieldDrafts} error={error} onChange={changeField} onClose={() => setFieldOpen(false)} onSave={saveFields} /></div>;
}
......@@ -418,6 +418,50 @@ export function formatDateText(value) {
return /^\d{4}-\d{2}-\d{2}/.test(text) ? text.slice(0, 10) : text;
}
function parseFormValue(value) {
if (typeof value !== 'string') return value;
const text = value.trim();
if (!text) return value;
try {
return JSON.parse(text);
} catch {
return value;
}
}
const FORM_PLACEHOLDER_LABELS = new Set(['(无标题)', '(无标题)', '无标题', '未命名表单']);
function getFormDisplayLabel(form, fallbackLabel) {
const safeFallback = FORM_PLACEHOLDER_LABELS.has(normalizeText(fallbackLabel))
? '表单'
: normalizeText(fallbackLabel) || '表单';
const candidate = form && typeof form === 'object'
? form.title || form.label || form.name
: '';
const normalizedCandidate = normalizeText(candidate);
return normalizedCandidate && !FORM_PLACEHOLDER_LABELS.has(normalizedCandidate)
? normalizedCandidate
: safeFallback;
}
export function getFormValueSummaries(value, fallbackLabel = '表单') {
const parsedValue = parseFormValue(value);
return toArray(parsedValue)
.filter(Boolean)
.map((instance, index) => {
const normalizedInstance = instance && typeof instance === 'object' && !Array.isArray(instance)
? instance
: { form: instance, data: [] };
const form = normalizedInstance.form;
const label = getFormDisplayLabel(form, fallbackLabel);
return {
key: `${form?._id || form?.name || label}-${index}`,
label,
rowCount: Array.isArray(normalizedInstance.data) ? normalizedInstance.data.length : 0,
};
});
}
export function formatFieldValue(value, fieldDef) {
if (value === null || value === undefined || value === '') {
return '';
......@@ -464,6 +508,12 @@ export function formatFieldValue(value, fieldDef) {
.join('、');
}
if (fieldDef?.type === 'form') {
return getFormValueSummaries(value, fieldDef?.label || '表单')
.map((item) => `${item.label} · ${item.rowCount} 条记录`)
.join('、');
}
if (typeof value === 'object') {
return JSON.stringify(value);
}
......
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