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

feat: align OA process monitor with Xinyuan UI

parent d9575173
......@@ -23,7 +23,7 @@
7. 分页不统一:上一页/下一页与页码采用普通文本按钮,当前页、每页条数和总数摘要未使用鑫元分页视觉。
8. 提示信息过多直出:页面内存在开发背景、迁移说明和重复帮助文案,应收敛为标题旁 `info` 小提示,仅在需要时查看。
9. 表面层级不统一:页面存在 22/24/28/32px 多套圆角、彩色渐变、玻璃效果和页面私有阴影。
10. 暗色主题缺失:当前仅有单一视觉,Overlay、表面和文本未随根主题统一切换。
10. 主题策略已统一为固定浅色内容画布与深色 Sidebar;Overlay 继承根主题变量,不提供页面级主题切换。
## 本轮迁移基线
......@@ -31,7 +31,7 @@
- 使用本地完整 HeroUI Pro 镜像及 HeroUI 样式层。
- 同步已验收鑫元 Token、Shell 和组件 CSS。
- 登录页使用鑫元红 Logo、紧凑品牌区、统一输入高度和不透明浅红 filled/autofill 状态。
- Shell 使用浅色同画布侧栏、紧凑品牌区、中性选中项、全局主题切换和无阴影导航表面。
- Shell 使用深色 Sidebar、紧凑品牌区、中性选中项和无阴影导航表面;主题切换不属于当前基准交互。
- 页面说明收敛为标题旁 `info` 图标;业务状态和错误信息仍保留可见反馈。
- 分组切换改为统一页面 Tabs 视觉;筛选区按统一 Card、字段、按钮和响应式规则治理。
- 分页当前页使用鑫元红实心状态,其余操作保持中性。
......@@ -46,4 +46,3 @@
- 检查分页总数、每页条数、当前页、禁用态和页码省略符。
- 检查提示字段仅以 `info` 图标存在,Tooltip 可访问且不遮挡内容。
- 检查浅色/深色主题下 Dropdown、Modal、Tooltip、表格和图表表面。
......@@ -11,7 +11,8 @@
## 原生 HeroUI 边界
- 基础组件直接来自 `@heroui/react`,Sidebar/Navbar/DataGrid/KPI/EmptyState 等直接来自 `@heroui-pro/react` 官方子路径。
- 共享组件只做业务无关组合:Shell、应用切换、Autocomplete、详情导航、标题说明和行操作。
- 共享组件只做业务无关组合:Shell、应用切换、Autocomplete、顶部页面标题、标题说明和行操作。
- 顶部页面标题由 Navbar 内的 HeroUI `Breadcrumbs` 直接承载;当前项保持原标题字号和深色,前级项与分隔箭头使用 `muted` 灰色并支持点击返回,页面内容区不再挂载第二套页面级 Breadcrumbs。
- 应用切换直接使用 HeroUI Modal/Card/Surface/Avatar 与 HeroUI Pro EmptyState;Modal Body 是唯一滚动容器,桌面及平板固定两列、移动端一列。
- compound API 使用点语法,Button 使用 `onPress`;不提供 Catalyst/Headless 旧 props 兼容层。
- `XinyuanAutocomplete` 组合官方 Autocomplete、Popover Heading、SearchField 和 ListBox,不定义组件外观。
......@@ -26,7 +27,7 @@
@import "./design-system/tokens.css";
```
- `theme.css` 是技能权威原始副本;`tokens.css`、`shell.css`、`tailwind.css` 与问题整改同步。
- `theme.css` 是技能权威原始副本;`tokens.css`、`shell.css`、`tailwind.css` 与审计项目管理同步。
- Inter 400/500/600 的拉丁字符字体文件位于 `public/fonts/`,由 `.umirc.ts` 生成的常驻 `inter.css` link 加载,不参与 `umi.css` 热替换。
- `src/tailwind.css` 不导入 `@fontsource/inter/*.css`,并用显式 `@source` 只扫描入口、组件、页面和 wrapper;`src/.umi` 不进入 Tailwind 扫描范围。
- 开发时同一工作区只运行一个 Umi dev 实例,防止多个进程交叉改写 `src/.umi` 形成编译循环。
......@@ -36,7 +37,7 @@
## 列表与反馈
- 流程列表固定每页 10 条,使用 Pro DataGrid 与 HeroUI compound Pagination。
- 流程列表默认每页 10 条,并可通过 HeroUI Autocomplete 切换 10/20/50/100 条,使用 Pro DataGrid 与 HeroUI compound Pagination。
- 列表模块使用基准的透明三层结构:独立数据集标题行、`rc-page-toolbar` 命令行、`rc-list-data-stack` 数据与分页;不使用 Card 或页面外观把三层框在一起。
- 搜索位于左侧,业务操作在右,主操作最右;移动端搜索独占行。
- 成功反馈使用 HeroUI toast;需要用户处理的错误保留上下文 Alert。
......
......@@ -7,9 +7,9 @@
- [x] 官方 hpsetup 在目标根目录安装成功
- [x] Pro 版本、解析路径、dist 和要求子路径验证
- [x] 移除跨项目 `file:` 依赖,统一 pnpm 锁文件
- [x] 启动修改前目标应用与问题整改基准并记录截图/几何
- [x] 启动修改前目标应用与审计项目管理基准并记录截图/几何
- [x] 权威 `theme.css` 与技能副本逐字节一致
- [x] `tokens.css`、`shell.css`、`tailwind.css` 与问题整改逐字节一致
- [x] `tokens.css`、`shell.css`、`tailwind.css` 与审计项目管理当前基准一致
## 架构与 Shell
......
......@@ -3,7 +3,7 @@
## 权威来源
- 主题唯一权威为 Xinyuan Skill 的 `assets/baseline/design-system/theme.css`,项目副本必须逐字节一致。
- 视觉和交互唯一运行基准为已确认完成的“问题整改”应用。
- 视觉和交互唯一运行基准为已确认完成的“审计项目管理”应用。
- 页面不得自定义 HeroUI/HeroUI Pro 的颜色、背景、描边、圆角、阴影或交互状态。
## 字体与样式入口
......@@ -26,6 +26,7 @@
- Sidebar 宽 240px,`collapsible="offcanvas"`,收起后宽度为零且无 icon rail。
- Navbar 高 64px,直接使用 HeroUI Pro `Sidebar.Trigger` 默认图形。
- Navbar 和页面共用左右 gutter:桌面 48px、平板 32px、手机 16px。
- Navbar 标题本身使用 HeroUI `Breadcrumbs` 作为唯一页面级导航:当前层级沿用原 Navbar 标题的字号、字重、行高和深色;前级层级与分隔箭头使用 `muted` 灰色,前级节点必须可点击返回对应路由;内容区不得再重复渲染页面级面包屑或返回导航。
- Sidebar 用户入口通过 `Dropdown.Trigger` 打开菜单,退出必须调用现有真实 logout。
- 应用切换使用 HeroUI Modal,桌面宽 46rem/最大高 42rem,手机一列、`sm` 起固定两列;分组来自 `/info -> site.group`,保留后端顺序并提供“其他应用/全部应用”回退。
......@@ -45,7 +46,7 @@
- 普通业务文字 Button 不带装饰图标;主操作在最右。
- 详情/表单使用 HeroUI Modal;查看态整组字段放一个 secondary Surface,编辑态移除该 Surface。
- DataGrid 使用原生 selection、排序、固定列、横向滚动、ActionBar、EmptyState 和 compound Pagination。
- 业务列表固定每页 10 条,不显示 page-size 选择器。
- 流程业务列表默认每页 10 条,分页条使用 HeroUI Autocomplete 提供 10/20/50/100 条选择;通知中心仍固定每页 10 条。
## 主题与资产
......@@ -59,7 +60,7 @@
`shortTitle`、Subject View 授权或审计材料中心文件流程。
- 通知页路由为 `/notifications`,沿用应用 base `/oa_process_monitor_new/`。
- Navbar 使用 HeroUI `Badge.Anchor + Badge + Button + Tooltip`,Ant Design
`BellOutlined` 位于主题按钮左侧;未读数为 0 时隐藏徽标,1-99 显示真实值,100 及以上显示
`BellOutlined` 为 Navbar 全局动作;未读数为 0 时隐藏徽标,1-99 显示真实值,100 及以上显示
`99+`,可访问名称保留真实数量。
- 通知页使用 HeroUI Tabs、SearchField、Card、Chip、Pagination、Toast 和 HeroUI Pro
EmptyState。查询固定每页 10 条,搜索延迟 300ms 并转义正则字符,未读状态通过服务端
......
# OA流程监控鑫元 UI 审计
审计日期:2026-08-27
审计日期:2026-09-02
## 范围与基准
- 目标仓库:`oa_process_monitor`,分支 `codex/xinyuan-ui-ux`。
- 整体实现基准:本机 `problem_rectification` 的实际代码与运行页面;应用切换专项基准为本机 `model_analysis` 当前实现。
- 整体实现基准:本机 `audit_project_manager` 的实际代码与运行页面;其共享 Shell 和应用切换结构为唯一验收基准。
- 路由基址:`/oa_process_monitor_new/`。
- 活动路由:`/login`、`/process_monitor`、`/notifications`、`/process_monitor/:processId`、`/process_monitor/:processId/:application`。
- 业务依据:`src/services/record-api.md`、`src/services/subjectsDefinition.md`、`src/pages/process_monitor/process-monitor.business-flow.md`。
......@@ -15,51 +15,57 @@
- 实际执行目录:`/Users/liuchenlong/Desktop/job/oa_process_monitor`。
- 已在该目录成功执行用户指定的官方 `pnpm dlx hpsetup@latest` 授权安装命令;授权值未写入源码、文档、环境文件或 Git 提交。
- `@heroui-pro/react@1.0.0-beta.8` 解析到 `/Users/liuchenlong/Desktop/job/oa_process_monitor/node_modules/.pnpm/@heroui-pro+react@1.0.0-beta.8/node_modules/@heroui-pro/react/package.json`。
- `@heroui/react@3.2.4`、`@heroui/styles@3.2.4` 使用正式版本,并与问题整改当前运行依赖一致,不再存在跨项目 `file:` 依赖。
- 本次功能没有改动 `package.json` 或 `pnpm-lock.yaml`;HeroUI Pro 已在目标仓库独立安装,因而没有再次执行安装命令。
- `@heroui/react@3.2.4`、`@heroui/styles@3.2.4` 使用正式版本,并与审计项目管理当前运行依赖一致,不再存在跨项目 `file:` 依赖。
- 本次同步保留 HeroUI Pro 正式安装;新增 ECharts 依赖,`recharts` 仅作为 HeroUI Pro 图表运行时 peer 依赖保留,锁文件由 pnpm 更新。
- `package.json` 写入正式版本依赖及 Pro 运行依赖;`package-lock.json` 已移除,`pnpm-lock.yaml` 已更新并作为唯一锁文件保留,Umi 的 `npmClient` 已切换为 `pnpm`。
- `@heroui-pro/react/css`、`sidebar`、`navbar`、`data-grid`、`drop-zone`、`action-bar`、`empty-state` 均通过 ESM 导入检查,CSS 解析目标文件存在。
- 监控分组 Tabs 使用问题整改相同的 compound 布局合同;同步 HeroUI 3.2.4 后,Tabs 根轨道与各标签均按内容宽度收敛,不再均分或占满页面剩余宽度。
- Catalyst、Headless UI、Heroicons、ECharts 及其直接依赖已移除;`recharts` 是 HeroUI Pro 图表运行依赖。
- 监控分组 Tabs 使用审计项目管理相同的 compound 布局合同;同步 HeroUI 3.2.4 后,Tabs 根轨道与各标签均按内容宽度收敛,不再均分或占满页面剩余宽度。
- Catalyst、Headless UI、Heroicons 及旧组件实现引用扫描为零;OA 当前没有业务图表页面,后续图表只允许 ECharts + `echarts-for-react`,现有 `recharts` 不被业务代码直接使用。
## 修改前状态
- 修改前目标与基准均已启动并记录 1440x900 登录页截图。
- 目标登录 Card 为 448x522.8,基准为 448x488.8;目标字段根高 76,基准为 64。
- 目标缺少权威主题、Inter 字体和统一 Surface;活动页混用 Catalyst、Headless UI、自制筛选/分页及 ECharts。
- Sidebar、Navbar、应用切换、列表和弹窗的结构与问题整改不一致。
- Sidebar、Navbar、应用切换、列表和弹窗的结构与审计项目管理不一致。
## 迁移后审计结论
- 登录、Shell、Sidebar、Navbar 和用户退出菜单使用问题整改共享结构;应用切换同步 model_analysis 的 HeroUI Modal 几何、动态分组、真实数量和两列卡片结构。
- 登录、Shell、Sidebar、Navbar 和用户退出菜单使用审计项目管理共享结构;应用切换同步其 HeroUI Modal 几何、动态分组、真实数量和两列卡片结构。
- 应用切换消费现有 `SiteContext` 的 `/info -> site.group`,按应用名称匹配真实可访问应用,保持分组与组内顺序,并保留原有应用获取、合并、当前应用判断及跳转逻辑。
- 三个流程监控页面直接使用 HeroUI/HeroUI Pro;KPI 使用 Pro KPI,列表使用 Pro DataGrid,空状态使用 Pro EmptyState。
- 筛选使用 HeroUI SearchField/Autocomplete,弹窗使用 Modal/AlertDialog,分页固定 10 条并使用 compound Pagination。
- 详情查看态只保留一个 `Surface secondary`;页面不再定义组件背景、颜色、描边、圆角、阴影或交互状态。
- 删除不可达的 Catalyst、RecordDetailDrawer、record-workbench、FilterSelect 和旧组件样式文件,旧组件引用扫描为零。
- `theme.css` 与技能权威副本逐字节一致;`tokens.css`、`shell.css`、`tailwind.css` 与问题整改逐字节一致。
- `theme.css` 与技能权威副本逐字节一致;`tokens.css`、`shell.css`、`tailwind.css` 与审计项目管理基准一致。
- 本应用无业务图表页面,图表迁移和 Tooltip 验收标记为不适用,不新增虚构图表。
## 本次共享同步
- AppShell 固定 `.light`,移除主题 state、localStorage 和 Sun/Moon 按钮。
- Sidebar 对齐基准 Disclosure 分组、深色白 Logo、offcanvas 结构和无 Tooltip 应用切换按钮。
- ApplicationSwitcherDialog 使用 Pro ItemCard/ItemCardGroup、SearchField、ScrollShadow 和动态分组,保留 OA 自身 basePath、应用授权和跳转逻辑。
## 业务保护
- 未改变路由、Subject、API URL、请求方法、payload、metadata 字段或 Reference ID-only 协议。
- 未改变模板同步、分组回写、取消监控、动态列生成、附件链接获取、权限判定、保存回调或请求时机。
- 仅按批准合同把列表固定为每页 10 条并移除 page-size UI;请求仍使用原有 `page` / `page_size` 协议。
- 流程列表默认每页 10 条,并新增 HeroUI Autocomplete 条数选择器(10/20/50/100);查询仍使用原有 `page` / `page_size` 协议。
- 401 仍在相同响应时机清理登录态并跳转登录页,只修正为带 Umi base 的应用内地址。
## 通知中心审计
- 按跨应用白名单判定,OA流程监控仅新增通知中心;没有新增 shortTitle、视图权限或材料中心
文件服务/路由。
- 目标与问题整改均以 Andy 真实账号登录,在 1280x720 下打开 `/notifications` 对照,Shell、
Navbar Bell/主题按钮、Tabs、搜索、空状态和间距一致;目标真实接口返回 0 条通知。
- 目标与审计项目管理均以 Andy 真实账号登录,在 1280x720 下打开 `/notifications` 对照,Shell、
Navbar Bell、Tabs、搜索、空状态和间距一致;目标真实接口返回 0 条通知。
- 浏览器内路由拦截覆盖了 0/1/99/100 未读数、消息/待办、未读查询、正则转义搜索、分页、
单条已读、全部已读、动态 action、站内/外部跳转和纯文本 HTML 渲染。拦截只存在于 Playwright
会话,不写入仓库或服务端数据。
- 通知 API 实测:`POST /api/notice/customer/query`、`GET /api/notice/customer/count` 均
`200`;动态 action 使用 `PUT /api/notice/customer/approval/action/approve`,请求体为
`{ "notice_id": "..." }`;控制台 0 error / 0 warning。
- 目标与问题整改通知页已在 1440x900、1280x720、768x1024、390x844 的浅色/深色验证,
- 目标与审计项目管理通知页已在 1440x900、1280x720、768x1024、390x844 的浅色/深色验证,
均无页面横向溢出。1280x720 两边使用真实登录和真实 0 条通知;其余基准视口在验证码会话失效后
使用浏览器内只读认证/空通知响应夹具,只验证视觉结构,不写入仓库或服务端。截图均保存在
`/tmp`,未纳入版本控制。
......@@ -67,7 +73,7 @@
## 浏览器证据与限制
- 已覆盖 1440x900、1280x720、768x1024、390x844;包含浅色、深色、Sidebar offcanvas、移动 Sidebar、应用切换和创建分组 Modal。
- 2026-08-26 应用切换专项复验同时打开目标应用与 `model_analysis`:四个视口的弹窗位置、宽高、24px 圆角、28px 内边距、Header/Body 起点、分组顶点、两列/单列断点及卡片尺寸逐项一致;深色 Portal 无浅色孤岛。
- 2026-09-02 应用切换专项复验按审计项目管理基准检查四个视口:弹窗位置、宽高、24px 圆角、28px 内边距、Header/Body 起点、分组顶点、两列/单列断点及卡片尺寸保持一致。
- 浏览器层覆盖动态分组顺序、组内顺序、空分组隐藏、未归组应用进入“其他应用”、无分组回退“全部应用”、真实数量、唯一“当前”、长列表 Body-only 滚动、失败/透明/缺失 Logo 稳定 identity well,以及 Escape、遮罩、关闭按钮和焦点顺序。
- 最终目标与基准应用均已在 1440x900、1280x720、768x1024、390x844 的浅色和深色模式完成本地截图比对;验收截图仅作本地临时证据,不纳入版本控制。
- 另记录 `final-production-app-switcher-1440x900-light.png`、`final-production-app-switcher-1440x900-three-apps.png` 和移动 Sidebar 截图;在相同视口逐项比较登录、Shell、页面 gutter、Surface、KPI、筛选、空 DataGrid、Pagination 和 overlay 几何。
......@@ -81,7 +87,7 @@
- `git diff --check`:通过。
- Xinyuan `audit_project.sh`:退出码 0;主题、旧组件引用和四份必需文档检查通过。脚本基于 CommonJS/CSS 加载的 Pro 子路径提示属于检查器误报,独立 ESM 导入检查全部通过。
- 旧组件、原生可见控件、页面私有视觉 CSS、页面局部 CSS/LESS、`file:`/本机路径依赖扫描:均为零。
- `theme.css` 与 Skill 权威副本逐字节一致;`tokens.css`、`shell.css`、`tailwind.css` 与问题整改基准逐字节一致。
- `theme.css` 与 Skill 权威副本逐字节一致;`tokens.css`、`shell.css`、`tailwind.css` 与审计项目管理基准一致。
- HeroUI/React Aria 依赖树和 Pro 子路径检查:通过;目标仓库不存在相邻项目依赖、软链接或本机 vendor 替代包。
- `pnpm list` 显示应用运行层统一为 React/ReactDOM 19.2.8、HeroUI 3.2.4、React Aria
Components 1.20.0;完整 `npm ls` 仍报告 Umi/Ant Design 传递依赖声明的旧 React peer 警告,
......
......@@ -29,6 +29,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",
......
......@@ -71,6 +71,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
......@@ -3665,6 +3671,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==}
......@@ -7143,6 +7158,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'}
......@@ -7541,6 +7559,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==}
......@@ -7956,6 +7977,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==}
......@@ -12247,6 +12271,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: {}
......@@ -16558,6 +16594,8 @@ snapshots:
sisteransi@1.0.5: {}
size-sensor@1.0.3: {}
slash@3.0.0: {}
slash@4.0.0: {}
......@@ -17033,6 +17071,8 @@ snapshots:
tslib@1.14.1: {}
tslib@2.3.0: {}
tslib@2.8.1: {}
tsutils@3.21.0(typescript@5.9.3):
......@@ -17528,4 +17568,8 @@ snapshots:
yocto-queue@1.2.2: {}
zrender@6.1.0:
dependencies:
tslib: 2.3.0
zwitch@2.0.4: {}
......@@ -27,7 +27,6 @@ import LockOutlined from '@ant-design/icons/LockOutlined';
import InboxOutlined from '@ant-design/icons/InboxOutlined';
import InfoCircleOutlined from '@ant-design/icons/InfoCircleOutlined';
import MoreOutlined from '@ant-design/icons/MoreOutlined';
import MoonOutlined from '@ant-design/icons/MoonOutlined';
import OrderedListOutlined from '@ant-design/icons/OrderedListOutlined';
import PieChartOutlined from '@ant-design/icons/PieChartOutlined';
import PlusOutlined from '@ant-design/icons/PlusOutlined';
......@@ -37,7 +36,6 @@ import RightOutlined from '@ant-design/icons/RightOutlined';
import RiseOutlined from '@ant-design/icons/RiseOutlined';
import SearchOutlined from '@ant-design/icons/SearchOutlined';
import SendOutlined from '@ant-design/icons/SendOutlined';
import SunOutlined from '@ant-design/icons/SunOutlined';
import TeamOutlined from '@ant-design/icons/TeamOutlined';
import UserOutlined from '@ant-design/icons/UserOutlined';
import UploadOutlined from '@ant-design/icons/UploadOutlined';
......@@ -114,11 +112,9 @@ export function SidebarPanel({ size = 20, style, ...props }) {
);
}
export const More = withSize(MoreOutlined);
export const Moon = withSize(MoonOutlined);
export const Project = withSize(ProjectOutlined);
export const Search = withSize(SearchOutlined);
export const Send = withSize(SendOutlined);
export const Sun = withSize(SunOutlined);
export const Team = withSize(TeamOutlined);
export const User = withSize(UserOutlined);
export const Upload = withSize(UploadOutlined);
......
import { useLayoutEffect, useState } from 'react';
import { Sidebar } from '@heroui-pro/react/sidebar';
import { Toast } from '@heroui/react';
import { THEME_STORAGE_KEY } from '@/config/application';
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';
}
}
export default function AppShell({
children,
notificationCount,
notificationPath,
open,
onOpenChange,
breadcrumbItems,
title,
...sidebarProps
}) {
const [theme, setTheme] = useState(getInitialTheme);
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 {
// The selected theme still applies for this session when storage is unavailable.
}
}, [theme]);
return (
<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}
......@@ -58,9 +29,8 @@ export default function AppShell({
onOpenNotifications={
notificationPath ? () => history.push(notificationPath) : undefined
}
theme={theme}
breadcrumbItems={breadcrumbItems}
title={title}
onThemeChange={setTheme}
/>
<div className="app-shell-content-scroll min-h-0 flex-1 overflow-y-auto">
<div className="rc-app-page">{children}</div>
......
import xinyuanLogo from '@/assets/xy-logo-red.png';
import {
APPLICATION_BASE_PATH,
APPLICATION_DISPLAY_NAME,
APPLICATION_NAME,
} from '@/config/application';
import { getApplications } from '@/services/DataService';
import { Close, ExclamationTriangleIcon } from '@/components/AppIcons';
import { APPLICATION_BASE_PATH } from '@/config/application';
import { ExclamationTriangleIcon, MagnifyingGlassIcon } from '@/components/AppIcons';
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,
ScrollShadow,
SearchField,
Skeleton,
Surface,
} from '@heroui/react';
import { useEffect, useMemo, useState } from 'react';
const CURRENT_APPLICATION_NAME = APPLICATION_NAME;
function unwrapApplications(response) {
const candidates = [
response,
response?.data,
response?.applications,
response?.apps,
response?.records,
response?.rows,
response?.list,
response?.result,
];
return candidates.find(Array.isArray) || [];
}
function normalizeLabelList(items) {
if (!Array.isArray(items)) {
return [];
......@@ -40,20 +64,21 @@ function resolveApplicationUrl(application) {
function normalizeApplication(application, fallback = {}) {
const name = application?.name || fallback?.name || '';
const current = name === CURRENT_APPLICATION_NAME;
return {
name,
title: application?.title || fallback?.title || application?.name || fallback?.name || '未命名应用',
title: current
? APPLICATION_DISPLAY_NAME
: application?.title || fallback?.title || application?.name || fallback?.name || '未命名应用',
description:
application?.description ||
fallback?.description ||
fallback?.title ||
application?.title ||
'进入对应应用继续处理业务',
logo: application?.logo || fallback?.logo || null,
url: resolveApplicationUrl({
name,
}),
logo: xinyuanLogo,
url: resolveApplicationUrl({ name }),
access: application?.access || fallback?.access || '',
status: application?.status || fallback?.status || '',
type: application?.type || fallback?.type || '',
......@@ -86,6 +111,15 @@ function mergeApplications(fetchedApplications, fallbackApplications) {
mergedMap.set(application.name, normalizeApplication(application, application));
});
const currentApplication = mergedMap.get(CURRENT_APPLICATION_NAME);
mergedMap.set(
CURRENT_APPLICATION_NAME,
normalizeApplication(
{ name: CURRENT_APPLICATION_NAME },
currentApplication || { title: APPLICATION_DISPLAY_NAME },
),
);
return Array.from(mergedMap.values()).sort((left, right) => {
const orderDifference = Number(left.order || 0) - Number(right.order || 0);
if (orderDifference !== 0) {
......@@ -125,7 +159,7 @@ function buildApplicationSections(applications, applicationGroups) {
: [];
return {
key: `group-${index}-${group?.label || 'application-group'}`,
key: group?.label || `group-${index}`,
label: group?.label || '应用分组',
applications: groupApplications,
};
......@@ -148,6 +182,10 @@ function buildApplicationSections(applications, applicationGroups) {
}
function isCurrentApplication(application) {
if (application?.name === CURRENT_APPLICATION_NAME) {
return true;
}
if (typeof window === 'undefined') {
return false;
}
......@@ -174,65 +212,76 @@ function isCurrentApplication(application) {
function ApplicationCard({ application, current }) {
const target = application?.url?.startsWith('http') ? '_blank' : undefined;
const initial = application.title.trim().slice(0, 1).toUpperCase() || 'A';
const href = current ? APPLICATION_BASE_PATH : application.url;
const description = application.description?.trim();
const hasDistinctDescription =
description && description !== application.title.trim();
return (
<Card className="h-full" variant="secondary">
<Card.Content>
<a
href={href}
target={target}
rel={target ? 'noreferrer' : undefined}
aria-current={current ? 'page' : undefined}
className="flex min-w-0 items-center gap-3 no-underline"
>
<Surface
aria-hidden="true"
className="grid size-9 shrink-0 place-items-center overflow-hidden rounded-[var(--rc-radius-control)]"
variant="tertiary"
<ItemCard
aria-current={current ? 'page' : undefined}
className="h-full min-w-0 no-underline"
href={current ? APPLICATION_BASE_PATH : application.url}
rel={target ? 'noreferrer' : undefined}
render={(props) => <a {...props} />}
target={target}
variant={current ? 'outline' : 'default'}
>
<ItemCard.Icon aria-hidden="true">
<Surface variant="default" className="rc-application-identity-well">
<Avatar
className="rc-application-identity-avatar"
color="accent"
variant="soft"
>
<Avatar
className="size-full rounded-[var(--rc-radius-control)] bg-transparent"
color="accent"
variant="soft"
>
{application.logo ? (
<Avatar.Image className="object-contain p-1.5" src={application.logo} alt="" />
) : null}
<Avatar.Fallback>{initial}</Avatar.Fallback>
</Avatar>
</Surface>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium leading-5" title={application.title}>
{application.title}
</span>
<Description className="mt-0.5 block truncate leading-4" title={application.description}>
{application.description || application.name || '应用'}
</Description>
</span>
{current ? <Chip color="accent" size="sm">当前</Chip> : null}
</a>
</Card.Content>
</Card>
<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>
</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 ? (
<ItemCard.Action>
<Chip color="accent" size="sm">当前</Chip>
</ItemCard.Action>
) : null}
</ItemCard>
);
}
function LoadingSkeleton() {
return (
<div className="grid gap-3 sm:grid-cols-2" aria-label="正在加载应用">
<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">
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-3 w-full" />
</div>
</Card.Content>
</Card>
<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" />
</ItemCard.Content>
</ItemCard>
))}
</div>
</ItemCardGroup>
);
}
......@@ -257,37 +306,52 @@ export default function ApplicationSwitcherDialog({
.filter((application) => application.name);
}, [userInfo]);
const [applications, setApplications] = useState(fallbackApplications);
const [applications, setApplications] = useState(() =>
mergeApplications([], fallbackApplications),
);
const [status, setStatus] = useState(
fallbackApplications.length > 0 ? 'success' : 'idle',
);
const [errorMessage, setErrorMessage] = useState('');
const [applicationSearch, setApplicationSearch] = useState('');
const currentApplicationName = useMemo(() => {
const baseName = APPLICATION_BASE_PATH.split('/').filter(Boolean)[0] || '';
const acceptedNames = new Set([baseName, baseName.replace(/_new$/, '')]);
return applications.find((application) => acceptedNames.has(application.name) || isCurrentApplication(application))?.name || '';
return applications.find(isCurrentApplication)?.name || CURRENT_APPLICATION_NAME;
}, [applications]);
const applicationSections = useMemo(
() => buildApplicationSections(applications, siteInfo?.group),
[applications, siteInfo?.group],
);
const filteredApplicationSections = useMemo(() => {
const keyword = applicationSearch.trim().toLocaleLowerCase();
if (!keyword) {
return applicationSections;
}
return applicationSections
.map((section) => ({
...section,
applications: section.applications.filter((application) => [
application.title,
application.description,
application.name,
].some((value) => String(value || '').toLocaleLowerCase().includes(keyword))),
}))
.filter((section) => section.applications.length > 0);
}, [applicationSearch, applicationSections]);
useEffect(() => {
if (fallbackApplications.length === 0) {
return;
}
setApplications((previous) => {
if (previous.length > 0) {
return previous;
}
return fallbackApplications;
});
setApplications((previous) =>
mergeApplications(previous, fallbackApplications),
);
}, [fallbackApplications]);
useEffect(() => {
if (!open) {
setApplicationSearch('');
return;
}
......@@ -299,7 +363,7 @@ export default function ApplicationSwitcherDialog({
try {
const response = await getApplications();
const fetchedApplications = Array.isArray(response) ? response : [];
const fetchedApplications = unwrapApplications(response);
if (cancelled) {
return;
......@@ -317,7 +381,7 @@ export default function ApplicationSwitcherDialog({
return;
}
setApplications(fallbackApplications);
setApplications(mergeApplications([], fallbackApplications));
setStatus('error');
setErrorMessage('应用列表加载失败,已回退展示当前账号上的应用信息。');
}
......@@ -340,45 +404,70 @@ export default function ApplicationSwitcherDialog({
}}
variant="blur"
>
<Modal.Container className="w-full sm:w-full" placement="center" scroll="inside" size="lg">
<Modal.Container placement="center" scroll="inside" size="lg">
<Modal.Dialog
aria-label="应用切换"
className="p-7"
className="max-h-[min(42rem,calc(100dvh-2rem))] bg-background p-7"
style={{
width: 'min(46rem, calc(100vw - 2rem))',
maxWidth: 'min(46rem, calc(100vw - 2rem))',
maxHeight: 'min(42rem, calc(100dvh - 2rem))',
width: 'min(46rem, calc(100vw - 2rem))',
}}
>
<Modal.CloseTrigger
aria-label="关闭"
style={{ insetInlineEnd: '1.75rem', top: '1.75rem' }}
>
<Close size={16} />
</Modal.CloseTrigger>
/>
<Modal.Header>
<Modal.Heading>应用切换</Modal.Heading>
<div className="text-sm text-muted">选择你可访问的应用</div>
<div className="flex w-full min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<SearchField
aria-label="搜索应用"
className="w-full min-w-0 max-w-full sm:flex-1 sm:w-auto"
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>
</div>
</Modal.Header>
<Modal.Body style={{ marginTop: '0.75rem' }}>
{status === 'error' && errorMessage ? (
<Alert status="warning">
<Alert.Indicator><ExclamationTriangleIcon size={20} /></Alert.Indicator>
<Alert.Content><Alert.Description>{errorMessage}</Alert.Description></Alert.Content>
</Alert>
) : null}
{status === 'loading' && applications.length === 0 ? (
<LoadingSkeleton />
) : applications.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">
<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 className="mb-4" status="warning">
<Alert.Indicator><ExclamationTriangleIcon size={20} /></Alert.Indicator>
<Alert.Content><Alert.Description>{errorMessage}</Alert.Description></Alert.Content>
</Alert>
) : null}
{status === 'loading' && applications.length === 0 ? (
<LoadingSkeleton />
) : filteredApplicationSections.length > 0 ? (
<div className="space-y-6">
{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}
......@@ -386,18 +475,26 @@ export default function ApplicationSwitcherDialog({
current={application.name === currentApplicationName}
/>
))}
</div>
</section>
))}
</div>
) : (
<EmptyState size="md">
<EmptyState.Header>
<EmptyState.Title>暂无可切换应用</EmptyState.Title>
<EmptyState.Description>当前账号还没有分配其它应用权限。</EmptyState.Description>
</EmptyState.Header>
</EmptyState>
)}
</ItemCardGroup>
))}
</div>
) : (
<Surface className="w-full min-w-0 rounded-2xl p-4" variant="secondary">
<EmptyState size="md">
<EmptyState.Header>
<EmptyState.Title>
{applicationSearch.trim() ? '没有找到匹配的应用' : '暂无可切换应用'}
</EmptyState.Title>
<EmptyState.Description>
{applicationSearch.trim()
? '请尝试其他关键词。'
: '当前账号还没有分配其它应用权限。'}
</EmptyState.Description>
</EmptyState.Header>
</EmptyState>
</Surface>
)}
</ScrollShadow>
</Modal.Body>
</Modal.Dialog>
</Modal.Container>
......
import { Navbar } from '@heroui-pro/react/navbar';
import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar';
import { Badge, Button, Tooltip } from '@heroui/react';
import { Bell, Moon, SidebarPanel, Sun } from './AppIcons';
import { Badge, Breadcrumbs, Button, Tooltip } from '@heroui/react';
import { Bell, ChevronRight, SidebarPanel } from './AppIcons';
export default function DashboardNavbar({
notificationCount = 0,
onOpenNotifications,
onThemeChange,
theme,
breadcrumbItems = [],
title,
}) {
const { isMobile, isOpen } = useSidebar();
const isDark = theme === 'dark';
const themeLabel = isDark ? '切换至浅色模式' : '切换至深色模式';
const normalizedNotificationCount = Math.max(0, Number(notificationCount) || 0);
return (
......@@ -24,7 +21,21 @@ export default function DashboardNavbar({
>
<SidebarPanel />
</Sidebar.Trigger>
<h1 className="app-navbar-page-title rc-navbar-title">{title}</h1>
<Breadcrumbs
aria-label="当前页面"
className="app-navbar-page-title rc-navbar-title min-w-0"
separator={<ChevronRight size={16} />}
>
{(breadcrumbItems.length ? breadcrumbItems : [{ label: title }]).map((item, index, items) => (
<Breadcrumbs.Item
className={index === items.length - 1 ? 'rc-navbar-breadcrumb-current' : undefined}
href={index === items.length - 1 ? undefined : item.href}
key={`${item.href || 'current'}-${item.label}`}
>
{item.label}
</Breadcrumbs.Item>
))}
</Breadcrumbs>
<Navbar.Spacer />
<div className="rc-navbar-actions">
{onOpenNotifications ? (
......@@ -55,20 +66,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/xy-logo-red.png';
import xyLogoWhite from '@/assets/xy-logo-white.png';
import { APPLICATION_DISPLAY_NAME } from '@/config/application';
import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar';
import { Sheet } from '@heroui-pro/react/sheet';
import { Avatar, Button, Dropdown, Label, Tooltip } from '@heroui/react';
import clsx from 'clsx';
import { AppGrid, LogOut } from './AppIcons';
import { Sheet } from '@heroui-pro/react';
import { Avatar, Button, Disclosure, Dropdown, Label } from '@heroui/react';
import { AppGrid, ChevronDown, LogOut } from './AppIcons';
function getUserDisplay(userInfo) {
const displayName = userInfo?.display_name || userInfo?.username || '当前用户';
......@@ -65,24 +63,34 @@ function SidebarContents({
onLogout,
onOpenApplicationSwitcher,
pathname,
theme,
userInfo,
}) {
const renderMenu = (group) => (
<Sidebar.Menu aria-label={group.title}>
{group.items.map((item) => {
const Icon = item.icon;
return (
<Sidebar.MenuItem
href={item.path}
isCurrent={pathname === item.path}
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>
);
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"
......@@ -96,50 +104,44 @@ function SidebarContents({
className="app-sidebar-app-switcher rc-sidebar-app-switcher"
data-sidebar="label"
>
<Tooltip delay={0}>
<Button
isIconOnly
aria-label="切换应用"
className="app-sidebar-app-trigger rc-sidebar-app-trigger"
size="sm"
variant="ghost"
onPress={onOpenApplicationSwitcher}
>
<AppGrid size={16} />
</Button>
<Tooltip.Content>切换应用</Tooltip.Content>
</Tooltip>
<Button
isIconOnly
aria-label="切换应用"
className="app-sidebar-app-trigger rc-sidebar-app-trigger"
size="sm"
variant="ghost"
onPress={onOpenApplicationSwitcher}
>
<AppGrid size={16} />
</Button>
</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;
return (
<Sidebar.MenuItem
href={item.path}
isCurrent={pathname === item.path}
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>
))}
{menuGroups.map((group) => {
if (group.showLabel === false) {
return <Sidebar.Group key={group.title}>{renderMenu(group)}</Sidebar.Group>;
}
const hasCurrentItem = group.items.some((item) => pathname === 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>
);
})}
</Sidebar.Content>
<Sidebar.Footer>
......@@ -150,7 +152,7 @@ function SidebarContents({
}
function AccessibleMobileSidebar(props) {
const { isMobile, isMobileOpen, setMobileOpen, side, slots } = useSidebar();
const { isMobile, isMobileOpen, setMobileOpen, side } = useSidebar();
if (!isMobile) return null;
......@@ -166,13 +168,7 @@ function AccessibleMobileSidebar(props) {
aria-label={`${APPLICATION_DISPLAY_NAME}导航`}
className="sidebar__mobile-dialog"
>
<div
className={clsx(
slots?.mobile?.(),
'app-sidebar-glass rc-mobile-sidebar',
)}
data-slot="sidebar-mobile"
>
<div className="sidebar__mobile app-sidebar-glass rc-mobile-sidebar" data-slot="sidebar-mobile">
<SidebarContents {...props} />
</div>
</Sheet.Dialog>
......
import { ArrowLeft } from '@/components/AppIcons';
import { Breadcrumbs, Button, Tooltip } from '@heroui/react';
import { ArrowLeft, ChevronRight } from '@/components/AppIcons';
import { Breadcrumbs, Button } from '@heroui/react';
import { useNavigate } from 'umi';
export default function DetailNavigation({ backHref, backLabel, items, currentLabel }) {
export default function DetailNavigation({
backHref,
backLabel,
items,
currentLabel,
}) {
const navigate = useNavigate();
return (
<nav className="rc-detail-navigation flex min-w-0 items-center gap-3" aria-label="详情页导航">
<Tooltip delay={400}>
<Button
isIconOnly
aria-label={backLabel}
className="rc-detail-navigation__back shrink-0"
size="sm"
variant="outline"
onPress={() => navigate(backHref)}
>
<ArrowLeft size={16} />
</Button>
<Tooltip.Content placement="bottom">{backLabel}</Tooltip.Content>
</Tooltip>
<nav className="rc-detail-navigation flex min-w-0 items-center gap-4" aria-label="详情页导航">
<Button
isIconOnly
aria-label={backLabel}
className="rc-detail-navigation__back shrink-0"
size="lg"
variant="outline"
onPress={() => navigate(backHref)}
>
<ArrowLeft size={16} />
</Button>
<Breadcrumbs
className="rc-detail-navigation__breadcrumbs min-w-0"
separator={<ChevronRight size={16} />}
>
{items.map((item) => (
<Breadcrumbs.Item href={item.href} key={`${item.href}-${item.label}`}>
<Breadcrumbs.Item className="no-underline" href={item.href} key={`${item.href}-${item.label}`}>
{item.label}
</Breadcrumbs.Item>
))}
......
import {
buildDepartmentLookup,
buildMemberLookup,
downloadProcessAttachment,
formatDateTime,
getDisplayFieldMap,
} from '@/pages/process_monitor/processMonitorService';
import { Button, Surface } from '@heroui/react';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { FileText } from '@/components/AppIcons';
import { useMemo } from 'react';
function resolveDetailValue(field, fieldMap, lookups) {
if (field?.type === 'file') {
const files = Array.isArray(field?.value) ? field.value : [];
if (!files.length) return <span className="text-muted">暂无内容</span>;
return <div className="flex flex-wrap gap-2">{files.map((file) => <Button key={`${file.fileUrl || file.url}-${file.filename}`} size="sm" variant="secondary" onPress={() => downloadProcessAttachment(file)}>{file.filename || file.fileName || '附件'}</Button>)}</div>;
}
const fieldDefinition = fieldMap[field?.label];
if (fieldDefinition?.type === 'member') {
const value = String(field?.value || '').split(',').map((item) => item.trim()).filter(Boolean).map((item) => lookups.memberLookup[item] || item).join('、');
return value || <span className="text-muted">暂无内容</span>;
}
if (fieldDefinition?.type === 'department') {
const value = String(field?.value || '').split(',').map((item) => item.trim()).filter(Boolean).map((item) => lookups.departmentLookup[item] || item).join('、');
return value || <span className="text-muted">暂无内容</span>;
}
if (fieldDefinition?.type === 'date') return formatDateTime(field?.value, 'YYYY-MM-DD HH:mm');
return field?.value || <span className="text-muted">暂无内容</span>;
}
function DetailField({ label, children, fullWidth = false }) {
return <div className={fullWidth ? 'min-w-0 sm:col-span-2' : 'min-w-0'}><div className="text-sm font-medium text-muted">{label}</div><div className="mt-1.5 min-w-0 text-sm text-foreground">{children || <span className="text-muted">暂无内容</span>}</div></div>;
}
export default function ProcessInstanceDetailContent({ data, loading = false, withSurface = false }) {
const record = data?.record;
const fieldsDefinition = data?.fieldsDefinition || [];
const members = data?.members || [];
const departments = data?.departments || [];
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;
if (loading) return <div className="py-8 text-sm text-muted">正在加载字段详情...</div>;
if (!record) return <EmptyState size="sm"><EmptyState.Header><EmptyState.Media variant="icon"><FileText size={20} /></EmptyState.Media><EmptyState.Title>暂无详情</EmptyState.Title></EmptyState.Header></EmptyState>;
const fieldGrid = <div className="grid gap-x-6 gap-y-5 sm:grid-cols-2">
<DetailField label="提交流程模板">{relatedTemplateTitle}</DetailField>
<DetailField label="提交时间">{formatDateTime(record?.metadata?.submit_at, 'YYYY-MM-DD HH:mm')}</DetailField>
<DetailField label="提交者">{record?.metadata?.submitter || <span className="text-muted">暂无内容</span>}</DetailField>
<DetailField label="审批者">{record?.metadata?.approver || <span className="text-muted">暂无内容</span>}</DetailField>
<DetailField label="所属部门">{record?.metadata?.department || <span className="text-muted">暂无内容</span>}</DetailField>
<DetailField label="显示条件">{relatedCondition?.key ? `${relatedCondition.key} = ${relatedCondition.value || '-'}` : '未配置'}</DetailField>
{details.map((field, index) => <DetailField key={`${field.label}-${index}`} label={field.label || `字段 ${index + 1}`} fullWidth={field?.type === 'file' || String(field?.value || '').length > 80}>{resolveDetailValue(field, fieldMap, lookups)}</DetailField>)}
</div>;
return withSurface ? <Surface className="px-5 py-5 sm:px-6 sm:py-6" variant="secondary">{fieldGrid}</Surface> : fieldGrid;
}
......@@ -40,9 +40,11 @@ export default function XinyuanAutocomplete({
options = [],
placeholder = '请选择',
searchPlaceholder = '输入关键词搜索',
showSearch = true,
selectedKey,
selectedKeys,
selectionMode = 'single',
valueText: valueTextOverride,
variant = 'secondary',
}) {
const selectedKeySet = getSelectedKeySet(
......@@ -56,9 +58,9 @@ export default function XinyuanAutocomplete({
const selectedOptions = options.filter((option) =>
selectedKeyStringSet.has(toSelectionKey(option.id)),
);
const valueText = selectedOptions.length
const valueText = valueTextOverride ?? (selectedOptions.length
? selectedOptions.map((option) => option.label).join('、')
: placeholder;
: placeholder);
const rootSelectionProps =
selectionMode === 'multiple'
? { selectedKeys }
......@@ -120,18 +122,20 @@ export default function XinyuanAutocomplete({
<Popover.Heading className="sr-only">
{ariaLabel || placeholder}
</Popover.Heading>
<Autocomplete.Filter filter={filterOption}>
<SearchField aria-label={searchPlaceholder} fullWidth variant="secondary">
<SearchField.Group>
<SearchField.SearchIcon>
<Search size={16} />
</SearchField.SearchIcon>
<SearchField.Input autoFocus placeholder={searchPlaceholder} />
<SearchField.ClearButton aria-label="清空搜索">
<Close size={14} />
</SearchField.ClearButton>
</SearchField.Group>
</SearchField>
<Autocomplete.Filter filter={showSearch ? filterOption : () => true}>
{showSearch ? (
<SearchField aria-label={searchPlaceholder} fullWidth variant="secondary">
<SearchField.Group>
<SearchField.SearchIcon>
<Search size={16} />
</SearchField.SearchIcon>
<SearchField.Input autoFocus placeholder={searchPlaceholder} />
<SearchField.ClearButton aria-label="清空搜索">
<Close size={14} />
</SearchField.ClearButton>
</SearchField.Group>
</SearchField>
) : null}
<ListBox
aria-label={ariaLabel || placeholder}
items={options}
......
export const APPLICATION_DISPLAY_NAME = 'OA流程监控';
export const APPLICATION_NAME = 'oa_process_monitor_new';
export const APPLICATION_BASE_PATH = '/oa_process_monitor_new/';
export const THEME_STORAGE_KEY = 'xinyuan_color_theme';
export const ROUTE_TITLES = Object.freeze({
'/process_monitor': '流程监控',
......
......@@ -394,6 +394,27 @@
white-space: nowrap;
}
.rc-navbar-title .breadcrumbs__link {
font-size: inherit;
font-weight: inherit;
line-height: inherit;
letter-spacing: inherit;
}
.rc-navbar-title .breadcrumbs__item[data-current='true'] .breadcrumbs__link {
color: var(--foreground);
}
.rc-navbar-title .breadcrumbs__item:not([data-current='true']) .breadcrumbs__link {
color: var(--muted);
}
.rc-navbar-title .breadcrumbs__separator {
width: 1rem;
height: 1rem;
color: var(--muted);
}
.rc-navbar-spacer {
flex: 1;
}
......@@ -435,6 +456,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 +509,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 +521,10 @@
outline-offset: 2px;
}
.rc-echarts-tooltip {
position: fixed !important;
}
.rc-kpi-grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
......@@ -509,10 +569,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 +738,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 +1055,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 +1395,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 +1420,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 {
......@@ -1403,3 +1613,89 @@
border-right: 0;
}
}
/* 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);
}
.app-sidebar-user-avatar,
.app-sidebar-app-trigger.button,
.app-sidebar-user-trigger.dropdown__trigger {
color: var(--rc-sidebar-foreground);
background: transparent;
}
.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,37 @@
[data-theme="light"],
[data-theme="default"] {
/* Theme Colors (Light Mode) */
--accent: oklch(45.32% 0.1661 27.03);
--accent: oklch(57.14% 0.2156 29.20);
--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: oklch(57.14% 0.2156 29.20);
--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: oklch(73.29% 0.1935 123.85);
--success-foreground: oklch(21.03% 0.0059 123.85);
--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 +50,86 @@
/* 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: oklch(78% 0.105 29.20);
/*
* 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.
*/
--xinyuan-chart-1: #d92d20;
--xinyuan-chart-2: #ef4444;
--xinyuan-chart-3: #f97316;
--xinyuan-chart-4: #f59e0b;
--xinyuan-chart-5: #22c55e;
--xinyuan-chart-6: #14b87a;
--xinyuan-chart-7: #06b6d4;
--xinyuan-chart-8: #0ea5e9;
--xinyuan-chart-9: #3b82f6;
--xinyuan-chart-10: #6366f1;
--xinyuan-chart-11: #8b5cf6;
--xinyuan-chart-12: #7c3aed;
--xinyuan-chart-13: #b42318;
--xinyuan-chart-14: #84cc16;
--xinyuan-chart-15: #a855f7;
--xinyuan-chart-16: #64748b;
--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);
--accent: oklch(57.14% 0.2156 29.20);
--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: oklch(57.14% 0.2156 29.20);
--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: oklch(73.29% 0.1935 123.85);
--success-foreground: oklch(21.03% 0.0059 123.85);
--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,9 +2,7 @@
.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);
--rc-brand-light: color-mix(in oklab, var(--accent) 84%, white);
......@@ -27,6 +25,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 +54,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);
......@@ -138,40 +148,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-risk: var(--rc-chart-1);
--rc-chart-warning: var(--rc-chart-3);
--rc-chart-positive: var(--rc-chart-7);
--rc-chart-comparison: var(--rc-chart-5);
--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-legend-font-size: var(--xinyuan-chart-legend-font-size);
--rc-chart-legend-line-height: var(--xinyuan-chart-legend-line-height);
}
......@@ -25,10 +25,34 @@ export default function ProcessMonitorLayout() {
const [isApplicationDialogOpen, setIsApplicationDialogOpen] = useState(false);
const [unreadNotificationCount, setUnreadNotificationCount] = useState(0);
const currentTitle = useMemo(
() => ROUTE_TITLES[location.pathname] || (location.pathname === '/process_monitor' ? '流程监控' : '流程详情'),
[location.pathname],
);
const currentTitle = useMemo(() => {
if (ROUTE_TITLES[location.pathname]) return ROUTE_TITLES[location.pathname];
const segments = location.pathname.split('/').filter(Boolean);
if (segments.length === 2) return '实例列表';
if (segments.length >= 3) return '流程详情';
return '流程监控';
}, [location.pathname]);
const breadcrumbItems = useMemo(() => {
const segments = location.pathname.split('/').filter(Boolean);
const tab = new URLSearchParams(location.search).get('tab');
const tabSuffix = tab ? `?tab=${encodeURIComponent(tab)}` : '';
if (segments.length >= 3) {
const processId = segments[1];
return [
{ label: '流程监控', href: `/process_monitor${tabSuffix}` },
{ label: '实例列表', href: `/process_monitor/${processId}${tabSuffix}` },
{ label: '流程详情' },
];
}
if (segments.length === 2) {
return [
{ label: '流程监控', href: `/process_monitor${tabSuffix}` },
{ label: '实例列表' },
];
}
return [{ label: currentTitle }];
}, [currentTitle, location.pathname, location.search]);
useEffect(() => {
document.title = `${currentTitle} - ${APPLICATION_DISPLAY_NAME}`;
......@@ -68,6 +92,7 @@ export default function ProcessMonitorLayout() {
onOpenChange={setSidebarOpen}
open={sidebarOpen}
pathname={location.pathname}
breadcrumbItems={breadcrumbItems}
title={currentTitle}
userInfo={userInfo}
>
......
import {
buildDepartmentLookup,
buildMemberLookup,
downloadProcessAttachment,
formatDateTime,
getDisplayFieldMap,
loadProcessInstanceDetail,
} from '@/pages/process_monitor/processMonitorService';
import DetailNavigation from '@/components/DetailNavigation';
import ProcessInstanceDetailContent from '@/components/ProcessInstanceDetailContent';
import { TitleInfo } from '@/components/XinyuanUi';
import { Alert, Button, Surface } from '@heroui/react';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { FileText } from '@/components/AppIcons';
import { Alert } from '@heroui/react';
import { useEffect, useMemo, useState } from 'react';
import { useParams, useSearchParams } from 'umi';
function resolveDetailValue(field, fieldMap, lookups) {
if (field?.type === 'file') {
const files = Array.isArray(field?.value) ? field.value : [];
if (!files.length) return <span className="text-muted">暂无内容</span>;
return <div className="flex flex-wrap gap-2">{files.map((file) => <Button key={`${file.fileUrl || file.url}-${file.filename}`} size="sm" variant="secondary" onPress={() => downloadProcessAttachment(file)}>{file.filename || file.fileName || '附件'}</Button>)}</div>;
}
const fieldDefinition = fieldMap[field?.label];
if (fieldDefinition?.type === 'member') {
const value = String(field?.value || '').split(',').map((item) => item.trim()).filter(Boolean).map((item) => lookups.memberLookup[item] || item).join('、');
return value || <span className="text-muted">暂无内容</span>;
}
if (fieldDefinition?.type === 'department') {
const value = String(field?.value || '').split(',').map((item) => item.trim()).filter(Boolean).map((item) => lookups.departmentLookup[item] || item).join('、');
return value || <span className="text-muted">暂无内容</span>;
}
if (fieldDefinition?.type === 'date') return formatDateTime(field?.value, 'YYYY-MM-DD HH:mm');
return field?.value || <span className="text-muted">暂无内容</span>;
}
function DetailField({ label, children, fullWidth = false }) {
return <div className={fullWidth ? 'min-w-0 sm:col-span-2' : 'min-w-0'}><div className="text-sm font-medium text-muted">{label}</div><div className="mt-1.5 min-w-0 text-sm text-foreground">{children || <span className="text-muted">暂无内容</span>}</div></div>;
}
import { useParams } from 'umi';
export default function ProcessDetailPage() {
const { processId, application } = useParams();
const [searchParams] = useSearchParams();
const tabName = searchParams.get('tab') || '';
const { application } = useParams();
const [loading, setLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState('');
const [record, setRecord] = useState(null);
......@@ -55,32 +22,14 @@ export default function ProcessDetailPage() {
setRecord(result.record); setFieldsDefinition(result.fieldsDefinition); setMembers(result.members); setDepartments(result.departments); setErrorMessage('');
}).catch((error) => setErrorMessage(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;
const detailData = useMemo(() => ({ record, fieldsDefinition, members, departments }), [departments, fieldsDefinition, members, record]);
return (
<div className="rc-page-stack">
<DetailNavigation backHref={`/process_monitor/${processId}${tabName ? `?tab=${tabName}` : ''}`} backLabel="返回实例列表" currentLabel="流程实例详情" items={[{ label: '流程监控', href: tabName ? `/process_monitor?tab=${tabName}` : '/process_monitor' }, { label: relatedTemplateTitle === '-' ? '实例列表' : relatedTemplateTitle, href: `/process_monitor/${processId}${tabName ? `?tab=${tabName}` : ''}` }]} />
{errorMessage ? <Alert status="danger"><Alert.Indicator /><Alert.Content><Alert.Title>详情加载失败</Alert.Title><Alert.Description>{errorMessage}</Alert.Description></Alert.Content></Alert> : null}
<div className="rc-list-action-bar"><div className="flex items-center gap-1.5"><h2>流程实例信息</h2><TitleInfo label="流程实例信息" content="展示该实例的流程信息和动态表单字段,附件保留独立下载入口。" /></div></div>
{loading ? <Surface className="px-5 py-8 text-sm text-muted sm:px-6" variant="secondary">正在加载字段详情...</Surface> : !record ? <EmptyState size="sm"><EmptyState.Header><EmptyState.Media variant="icon"><FileText size={20} /></EmptyState.Media><EmptyState.Title>暂无详情</EmptyState.Title></EmptyState.Header></EmptyState> : (
<Surface className="px-5 py-5 sm:px-6 sm:py-6" variant="secondary">
<div className="grid gap-x-6 gap-y-5 sm:grid-cols-2">
<DetailField label="提交流程模板">{relatedTemplateTitle}</DetailField>
<DetailField label="提交时间">{formatDateTime(record?.metadata?.submit_at, 'YYYY-MM-DD HH:mm')}</DetailField>
<DetailField label="提交者">{record?.metadata?.submitter || <span className="text-muted">暂无内容</span>}</DetailField>
<DetailField label="审批者">{record?.metadata?.approver || <span className="text-muted">暂无内容</span>}</DetailField>
<DetailField label="所属部门">{record?.metadata?.department || <span className="text-muted">暂无内容</span>}</DetailField>
<DetailField label="显示条件">{relatedCondition?.key ? `${relatedCondition.key} = ${relatedCondition.value || '-'}` : '未配置'}</DetailField>
{details.map((field, index) => <DetailField key={`${field.label}-${index}`} label={field.label || `字段 ${index + 1}`} fullWidth={field?.type === 'file' || String(field?.value || '').length > 80}>{resolveDetailValue(field, fieldMap, lookups)}</DetailField>)}
</div>
</Surface>
)}
<ProcessInstanceDetailContent data={detailData} loading={loading} withSurface />
</div>
);
}
import { useAuthorization } from '@/hooks/user';
import { Check, Close, DataStore, Delete, Edit, Eye, FileText, FilterControls, FolderOpen, PlusIcon, Search, Team } from '@/components/AppIcons';
import { TableActionButton, TableActionGroup } from '@/components/TableActionButton';
import { TitleInfo } from '@/components/XinyuanUi';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import { Alert, AlertDialog, Button, Checkbox, Input, Label, Modal, Pagination, Popover, SearchField, Surface, Tabs, Tooltip, toast } from '@heroui/react';
import { DataGrid } from '@heroui-pro/react/data-grid';
......@@ -12,13 +11,14 @@ import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'umi';
const PAGE_SIZE = 10;
const PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
const EMPTY_DEPARTMENT_KEY = '__all_departments__';
function Metric({ label, value, hint, icon: Icon, tone }) {
function Metric({ label, value, icon: Icon, tone }) {
const numericValue = Number(value);
return (
<KPI className="rc-card-surface rc-dashboard-card">
<KPI.Header className="flex items-center gap-1.5"><KPI.Title>{label}</KPI.Title><TitleInfo label={label} content={hint} /></KPI.Header>
<KPI.Header><KPI.Title>{label}</KPI.Title></KPI.Header>
<KPI.Content>
<KPI.Value value={Number.isFinite(numericValue) ? numericValue : 0}>{() => value}</KPI.Value>
<KPI.Icon aria-label={label} className="rc-metric-icon-surface" data-tone={tone}><Icon size={20} /></KPI.Icon>
......@@ -36,19 +36,24 @@ function DataEmptyState({ title, action }) {
);
}
function PagePagination({ page, total, onPageChange }) {
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
function PagePagination({ page, pageSize, total, onPageChange, onPageSizeChange }) {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const start = Math.max(0, Math.min(page - 2, totalPages - 5));
const end = total ? Math.min(totalPages, start + 5) : start;
return (
<Pagination size="sm">
<Pagination.Summary>共 {total} 条,第 {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} 条,第 {total ? page + 1 : 0} / {total ? totalPages : 0} 页</span>
<XinyuanAutocomplete aria-label="每页条数" className="w-24 shrink-0" options={PAGE_SIZE_OPTIONS.map((value) => ({ id: String(value), label: `${value} 条/页` }))} placeholder="每页条数" selectedKey={String(pageSize)} showSearch={false} valueText={`${pageSize} 条/页`} variant="secondary" onSelectionChange={(key) => onPageSizeChange(Number(key))} />
</div>
<Pagination className="ml-auto w-auto shrink-0" size="sm">
<Pagination.Content>
<Pagination.Item><Pagination.Previous isDisabled={page <= 0} onPress={() => onPageChange(page - 1)}><Pagination.PreviousIcon /><span>上一页</span></Pagination.Previous></Pagination.Item>
{Array.from({ length: end - start }, (_, index) => start + index).map((pageIndex) => <Pagination.Item key={pageIndex}><Pagination.Link isActive={pageIndex === page} onPress={() => onPageChange(pageIndex)}>{pageIndex + 1}</Pagination.Link></Pagination.Item>)}
<Pagination.Item><Pagination.Next isDisabled={!total || page + 1 >= totalPages} onPress={() => onPageChange(page + 1)}><span>下一页</span><Pagination.NextIcon /></Pagination.Next></Pagination.Item>
</Pagination.Content>
</Pagination>
</Pagination>
</div>
);
}
......@@ -86,6 +91,7 @@ export default function ProcessMonitorPage() {
const [templateKeyword, setTemplateKeyword] = useState('');
const [departmentFilter, setDepartmentFilter] = useState('');
const [listPage, setListPage] = useState(0);
const [pageSize, setPageSize] = useState(PAGE_SIZE);
const [errorMessage, setErrorMessage] = useState('');
const [createGroupOpen, setCreateGroupOpen] = useState(false);
const [createGroupTitle, setCreateGroupTitle] = useState('');
......@@ -120,11 +126,11 @@ export default function ProcessMonitorPage() {
const activeGroup = useMemo(() => groups.find((group) => group.name === activeGroupName) || null, [groups, activeGroupName]);
const currentGroupExternalIds = useMemo(() => new Set((activeGroup?.monitoredTemplates || []).map((item) => item?.metadata?.external_id).filter(Boolean)), [activeGroup]);
const filteredTemplates = useMemo(() => (activeGroup?.monitoredTemplates || []).filter((template) => (!templateKeyword || template.title?.includes(templateKeyword)) && (!departmentFilter || template?.metadata?.department === departmentFilter)), [activeGroup, departmentFilter, templateKeyword]);
const pagedTemplates = useMemo(() => filteredTemplates.slice(listPage * PAGE_SIZE, (listPage + 1) * PAGE_SIZE), [filteredTemplates, listPage]);
const pagedTemplates = useMemo(() => filteredTemplates.slice(listPage * pageSize, (listPage + 1) * pageSize), [filteredTemplates, listPage, pageSize]);
const pickerTemplates = useMemo(() => availableTemplates.filter((template) => !templatePickerKeyword || template.title?.includes(templatePickerKeyword)), [availableTemplates, templatePickerKeyword]);
const departmentItems = useMemo(() => [{ id: EMPTY_DEPARTMENT_KEY, label: '全部部门' }, ...monitoredDepartmentOptions.map((option) => ({ id: String(option.value), label: option.label }))], [monitoredDepartmentOptions]);
const editDepartmentItems = useMemo(() => [{ id: EMPTY_DEPARTMENT_KEY, label: '未设置' }, ...departmentOptions.map((option) => ({ id: String(option.value), label: option.label }))], [departmentOptions]);
useEffect(() => { setListPage(0); }, [activeGroupName, departmentFilter, templateKeyword]);
useEffect(() => { setListPage(0); }, [activeGroupName, departmentFilter, templateKeyword, pageSize]);
const handleCreateGroup = async () => {
if (!createGroupTitle.trim()) { setErrorMessage('请先填写分组名称'); return; }
......@@ -211,13 +217,12 @@ export default function ProcessMonitorPage() {
<Metric label="模板库" value={availableTemplates.length} hint="同步接口返回的可选流程模板数量" icon={FolderOpen} tone="teal" />
</div>
<div className="flex min-w-0 items-center gap-3">
{groups.length ? <section className="min-w-0 flex-1 overflow-hidden" aria-label="监控分组"><Tabs className="w-fit max-w-full min-w-0" selectedKey={activeGroup?.name || ''} onSelectionChange={(key) => setActiveGroupName(String(key))}><Tabs.ListContainer className="w-fit max-w-full min-w-0"><Tabs.List aria-label="监控分组" className="w-max min-w-max">{groups.map((group) => <Tabs.Tab className="w-auto min-w-max flex-none whitespace-nowrap" id={group.name} key={group.name}>{group.title}<Tabs.Indicator /></Tabs.Tab>)}</Tabs.List></Tabs.ListContainer></Tabs></section> : null}
{canManage ? <div className="ml-auto flex shrink-0 items-center gap-2"><Tooltip delay={0}><Button aria-label="添加监控模板" className="sm:hidden" isDisabled={!activeGroup || loading} isIconOnly size="sm" variant="secondary" onPress={() => setAddTemplateOpen(true)}><FileText size={16} /></Button><Tooltip.Content>添加监控模板</Tooltip.Content></Tooltip><Button className="hidden sm:inline-flex" isDisabled={!activeGroup || loading} size="sm" variant="secondary" onPress={() => setAddTemplateOpen(true)}>添加监控模板</Button><Tooltip delay={0}><Button aria-label="删除当前分组" className="sm:hidden" isDisabled={!activeGroup || loading} isIconOnly size="sm" variant="danger-soft" onPress={() => setRemoveGroupTarget(activeGroup)}><Delete size={16} /></Button><Tooltip.Content>删除分组</Tooltip.Content></Tooltip><Button className="hidden sm:inline-flex" isDisabled={!activeGroup || loading} size="sm" variant="danger-soft" onPress={() => setRemoveGroupTarget(activeGroup)}>删除分组</Button><Tooltip delay={0}><Button aria-label="新建分组" className="sm:hidden" isIconOnly size="sm" variant="primary" onPress={() => setCreateGroupOpen(true)}><PlusIcon size={16} /></Button><Tooltip.Content>新建分组</Tooltip.Content></Tooltip><Button className="hidden sm:inline-flex" size="sm" variant="primary" onPress={() => setCreateGroupOpen(true)}>新建分组</Button></div> : null}
</div>
<section className="rc-list-stack">
<div className="flex min-w-0 items-center gap-1.5"><h2 className="text-base font-medium text-foreground">{activeGroup ? `模板列表 · ${activeGroup.title}` : '模板列表'}</h2><TitleInfo label="模板列表" content="展示当前分组下已启用监控的流程模板,可进入实例列表和单条明细。" /></div>
<div className="flex min-w-0 flex-wrap items-center justify-between gap-x-4 gap-y-3">
<h2 className="text-base font-medium text-foreground">模板列表</h2>
{canManage ? <div className="ml-auto flex shrink-0 items-center gap-2"><Tooltip delay={0}><Button aria-label="添加监控模板" className="sm:hidden" isDisabled={!activeGroup || loading} isIconOnly size="sm" variant="secondary" onPress={() => setAddTemplateOpen(true)}><FileText size={16} /></Button><Tooltip.Content>添加监控模板</Tooltip.Content></Tooltip><Button className="hidden sm:inline-flex" isDisabled={!activeGroup || loading} size="sm" variant="secondary" onPress={() => setAddTemplateOpen(true)}>添加监控模板</Button><Tooltip delay={0}><Button aria-label="删除当前分组" className="sm:hidden" isDisabled={!activeGroup || loading} isIconOnly size="sm" variant="danger-soft" onPress={() => setRemoveGroupTarget(activeGroup)}><Delete size={16} /></Button><Tooltip.Content>删除分组</Tooltip.Content></Tooltip><Button className="hidden sm:inline-flex" isDisabled={!activeGroup || loading} size="sm" variant="danger-soft" onPress={() => setRemoveGroupTarget(activeGroup)}>删除分组</Button><Tooltip delay={0}><Button aria-label="新建分组" className="sm:hidden" isIconOnly size="sm" variant="primary" onPress={() => setCreateGroupOpen(true)}><PlusIcon size={16} /></Button><Tooltip.Content>新建分组</Tooltip.Content></Tooltip><Button className="hidden sm:inline-flex" size="sm" variant="primary" onPress={() => setCreateGroupOpen(true)}>新建分组</Button></div> : null}
</div>
{groups.length ? <section className="min-w-0 overflow-hidden" aria-label="监控分组"><Tabs className="w-fit max-w-full min-w-0" selectedKey={activeGroup?.name || ''} onSelectionChange={(key) => setActiveGroupName(String(key))}><Tabs.ListContainer className="w-fit max-w-full min-w-0"><Tabs.List aria-label="监控分组" className="w-max min-w-max">{groups.map((group) => <Tabs.Tab className="w-auto min-w-max flex-none whitespace-nowrap" id={group.name} key={group.name}>{group.title}<Tabs.Indicator /></Tabs.Tab>)}</Tabs.List></Tabs.ListContainer></Tabs></section> : null}
<div className="rc-page-toolbar">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
<div className="w-full min-w-0 flex-none sm:w-80 lg:w-96"><SearchField aria-label="搜索模板标题" fullWidth value={templateKeyword} variant="primary" onChange={setTemplateKeyword}><SearchField.Group><SearchField.SearchIcon><Search size={16} /></SearchField.SearchIcon><SearchField.Input placeholder="搜索模板标题" /><SearchField.ClearButton aria-label="清空关键词"><Close size={14} /></SearchField.ClearButton></SearchField.Group></SearchField></div>
......@@ -226,8 +231,7 @@ export default function ProcessMonitorPage() {
<Popover.Content placement="bottom start" shouldCloseOnInteractOutside={(element) => !element.closest('[data-slot="autocomplete-popover"]')}>
<Popover.Dialog aria-label="所属部门筛选" className="w-80 max-w-[calc(100vw-2rem)]">
<div className="grid gap-3">
<div className="grid gap-1.5"><Label>所属部门</Label><XinyuanAutocomplete aria-label="所属部门" options={departmentItems} placeholder="全部部门" selectedKey={departmentFilter || EMPTY_DEPARTMENT_KEY} variant="secondary" onSelectionChange={(key) => setDepartmentFilter(key === EMPTY_DEPARTMENT_KEY ? '' : String(key || ''))} /></div>
<div className="flex justify-end"><Button isDisabled={!departmentFilter} size="sm" variant="secondary" onPress={() => setDepartmentFilter('')}>重置</Button></div>
<div className="grid gap-1.5"><Label>所属部门</Label><div className="flex min-w-0 items-center gap-2"><XinyuanAutocomplete aria-label="所属部门" className="min-w-0 flex-1" options={departmentItems} placeholder="全部部门" selectedKey={departmentFilter || EMPTY_DEPARTMENT_KEY} variant="secondary" onSelectionChange={(key) => setDepartmentFilter(key === EMPTY_DEPARTMENT_KEY ? '' : String(key || ''))} /><Button isDisabled={!departmentFilter} size="sm" variant="secondary" onPress={() => setDepartmentFilter('')}>重置</Button></div></div>
</div>
</Popover.Dialog>
</Popover.Content>
......@@ -237,7 +241,7 @@ export default function ProcessMonitorPage() {
<div className="rc-list-data-stack">
<section className="rc-list-data-grid"><DataGrid allowsColumnResize aria-label="流程监控模板列表" className="pm-data-grid" columns={listColumns} contentClassName="min-w-[1074px]" data={loading ? [] : pagedTemplates} getRowId={(template) => template._id || template.name} renderEmptyState={() => loading ? <div className="py-8 text-sm text-muted">正在加载流程监控列表...</div> : <DataEmptyState title={activeGroup ? '当前分组还没有监控模板' : '暂无可展示模板'} action={canManage && activeGroup ? <Button size="sm" variant="secondary" onPress={() => setAddTemplateOpen(true)}>添加监控模板</Button> : null} />} scrollContainerClassName="pm-data-grid-scroll" verticalAlign="middle" /></section>
{!loading ? <div className="pm-pagination-bar"><PagePagination page={listPage} total={filteredTemplates.length} onPageChange={setListPage} /></div> : null}
{!loading ? <div className="pm-pagination-bar"><PagePagination page={listPage} pageSize={pageSize} total={filteredTemplates.length} onPageChange={setListPage} onPageSizeChange={(nextPageSize) => { if (PAGE_SIZE_OPTIONS.includes(nextPageSize)) setPageSize(nextPageSize); }} /></div> : null}
</div>
</section>
......
import DetailNavigation from '@/components/DetailNavigation';
import { Close, DataStore, Delete, Eye, FieldTime, FileText, Filter, FolderOpen, Search } from '@/components/AppIcons';
import ProcessInstanceDetailContent from '@/components/ProcessInstanceDetailContent';
import { TableActionButton, TableActionGroup } from '@/components/TableActionButton';
import { TitleInfo } from '@/components/XinyuanUi';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
......@@ -15,14 +15,16 @@ import {
findDetailField,
formatDateTime,
loadTemplateMonitorPage,
loadProcessInstanceDetail,
normalizeDisplayFieldDefinitions,
queryProcessInstances,
updateTemplateDisplayFields,
} from '@/pages/process_monitor/processMonitorService';
import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams, useSearchParams } from 'umi';
import { useParams, useSearchParams } from 'umi';
const PAGE_SIZE = 10;
const PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
const FIELD_TYPE_OPTIONS = [
{ id: 'text', label: '文本' },
{ id: 'member', label: '人员' },
......@@ -31,18 +33,18 @@ const FIELD_TYPE_OPTIONS = [
{ id: 'attachment', label: '附件' },
];
function Metric({ label, value, hint, icon: Icon, tone }) {
function Metric({ label, value, icon: Icon, tone }) {
const numericValue = Number(value);
return (
<KPI className="rc-card-surface rc-dashboard-card">
<KPI.Header className="flex items-center gap-1.5"><KPI.Title>{label}</KPI.Title><TitleInfo label={label} content={hint} /></KPI.Header>
<KPI.Header><KPI.Title>{label}</KPI.Title></KPI.Header>
<KPI.Content><KPI.Value value={Number.isFinite(numericValue) ? numericValue : 0}>{() => value}</KPI.Value><KPI.Icon aria-label={label} className="rc-metric-icon-surface" data-tone={tone}><Icon size={20} /></KPI.Icon></KPI.Content>
</KPI>
);
}
function createFieldDraft(field = {}) {
return { ...field, _key: field._key || `${Date.now()}-${Math.random().toString(36).slice(2)}`, display_name: field.display_name || '', type: field.type || 'text' };
return { ...field, _key: field._key || `${Date.now()}-${Math.random().toString(36).slice(2)}`, display_name: field.display_name || '', type: field.type || '' };
}
function formatLookupValue(type, value, lookups) {
......@@ -70,25 +72,29 @@ function DataEmptyState() {
return <EmptyState size="sm"><EmptyState.Header><EmptyState.Media variant="icon"><FolderOpen size={20} /></EmptyState.Media><EmptyState.Title>暂无数据</EmptyState.Title></EmptyState.Header></EmptyState>;
}
function PagePagination({ page, total, onPageChange }) {
const totalPages = Math.max(Math.ceil(total / PAGE_SIZE), 1);
function PagePagination({ page, pageSize, total, onPageChange, onPageSizeChange }) {
const totalPages = Math.max(Math.ceil(total / pageSize), 1);
const pageIndex = page - 1;
const start = Math.max(0, Math.min(pageIndex - 2, totalPages - 5));
const end = total ? Math.min(totalPages, start + 5) : start;
return (
<Pagination size="sm">
<Pagination.Summary>共 {total} 条,第 {total ? page : 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} 条,第 {total ? page : 0} / {total ? totalPages : 0} 页</span>
<XinyuanAutocomplete aria-label="每页条数" className="w-24 shrink-0" options={PAGE_SIZE_OPTIONS.map((value) => ({ id: String(value), label: `${value} 条/页` }))} placeholder="每页条数" selectedKey={String(pageSize)} showSearch={false} valueText={`${pageSize} 条/页`} variant="secondary" onSelectionChange={(key) => onPageSizeChange(Number(key))} />
</div>
<Pagination className="ml-auto w-auto shrink-0" size="sm">
<Pagination.Content>
<Pagination.Item><Pagination.Previous isDisabled={page <= 1} onPress={() => onPageChange(page - 1)}><Pagination.PreviousIcon /><span>上一页</span></Pagination.Previous></Pagination.Item>
{Array.from({ length: end - start }, (_, index) => start + index + 1).map((pageNumber) => <Pagination.Item key={pageNumber}><Pagination.Link isActive={pageNumber === page} onPress={() => onPageChange(pageNumber)}>{pageNumber}</Pagination.Link></Pagination.Item>)}
<Pagination.Item><Pagination.Next isDisabled={!total || page >= totalPages} onPress={() => onPageChange(page + 1)}><span>下一页</span><Pagination.NextIcon /></Pagination.Next></Pagination.Item>
</Pagination.Content>
</Pagination>
</Pagination>
</div>
);
}
export default function ProcessTemplateListPage() {
const navigate = useNavigate();
const { processId } = useParams();
const [searchParams] = useSearchParams();
const tabName = searchParams.get('tab') || '';
......@@ -103,17 +109,22 @@ export default function ProcessTemplateListPage() {
const [instances, setInstances] = useState([]);
const [keyword, setKeyword] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(PAGE_SIZE);
const [total, setTotal] = useState(0);
const [fieldDialogOpen, setFieldDialogOpen] = useState(false);
const [fieldDrafts, setFieldDrafts] = useState([]);
const [fieldSaving, setFieldSaving] = useState(false);
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
const [detailError, setDetailError] = useState('');
const [detailRecord, setDetailRecord] = useState(null);
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: PAGE_SIZE, keyword, conditions });
const result = await queryProcessInstances({ processId, page, pageSize, keyword, conditions });
setInstances(result.records); setTotal(result.total);
};
......@@ -121,15 +132,31 @@ export default function ProcessTemplateListPage() {
setLoading(true);
Promise.all([loadTemplateMeta(), loadInstances()]).then(() => setErrorMessage('')).catch((error) => setErrorMessage(error?.message || '加载流程实例失败')).finally(() => setLoading(false));
}, [processId]);
useEffect(() => { loadInstances().catch((error) => setErrorMessage(error?.message || '查询流程实例失败')); }, [page, keyword, conditions]);
useEffect(() => { loadInstances().catch((error) => setErrorMessage(error?.message || '查询流程实例失败')); }, [page, pageSize, keyword, conditions]);
const lookups = useMemo(() => ({ memberLookup: buildMemberLookup(members), departmentLookup: buildDepartmentLookup(departments) }), [departments, members]);
const totalPages = Math.max(Math.ceil(total / PAGE_SIZE), 1);
const totalPages = Math.max(Math.ceil(total / pageSize), 1);
const instanceColumns = useMemo(() => [
{ id: 'title', header: '表单名称', isRowHeader: true, width: '1fr', minWidth: 220, allowsResizing: false, pinned: 'start', cell: (record) => <span className="pm-data-grid-primary">{record.title}</span> },
...fieldsDefinition.map((field) => ({ id: `${field.display_name}-${field.type}`, header: field.display_name, width: 220, minWidth: 180, allowsResizing: false, cell: (record) => <span className="pm-data-grid-text">{renderFieldValue(field, record, lookups)}</span> })),
{ id: 'actions', header: '操作', width: 80, minWidth: 80, maxWidth: 80, allowsResizing: false, align: 'start', pinned: 'end', cell: (record) => <TableActionGroup><TableActionButton icon={Eye} label="查看" onPress={() => navigate(`/process_monitor/${processId}/${record._id}?tab=${tabName}`)} /></TableActionGroup> },
], [fieldsDefinition, lookups, navigate, processId, tabName]);
{ id: 'actions', header: '操作', width: 80, minWidth: 80, maxWidth: 80, allowsResizing: false, align: 'start', pinned: 'end', cell: (record) => <TableActionGroup><TableActionButton icon={Eye} label="查看" onPress={() => openDetailDialog(record)} /></TableActionGroup> },
], [fieldsDefinition, lookups]);
const openDetailDialog = async (record) => {
if (!record?._id) return;
setDetailDialogOpen(true);
setDetailLoading(true);
setDetailError('');
setDetailRecord(null);
try {
const detail = await loadProcessInstanceDetail(record._id);
setDetailRecord(detail);
} catch (error) {
setDetailError(error?.message || '加载流程详情失败');
} finally {
setDetailLoading(false);
}
};
const openFieldDialog = () => {
const normalizedFields = normalizeDisplayFieldDefinitions(fieldsDefinition);
......@@ -152,23 +179,25 @@ export default function ProcessTemplateListPage() {
return (
<div className="rc-page-stack">
<div className="rc-detail-toolbar flex min-w-0 flex-wrap items-center justify-between gap-x-4 gap-y-3"><div className="min-w-0 flex-1 basis-full sm:basis-auto"><DetailNavigation backHref={tabName ? `/process_monitor?tab=${tabName}` : '/process_monitor'} backLabel="返回流程监控" currentLabel={template?.title || '模板实例列表'} items={[{ label: '流程监控', href: tabName ? `/process_monitor?tab=${tabName}` : '/process_monitor' }]} /></div><div className="rc-page-command-bar ml-auto flex shrink-0 justify-end"><div className="flex flex-nowrap gap-2"><Button isDisabled={loading} isPending={downloading} size="sm" variant="secondary" onPress={handleDownloadAll}>批量下载附件</Button><Button isDisabled={loading} size="sm" variant="primary" onPress={openFieldDialog}>定义显示字段</Button></div></div></div>
<div className="rc-detail-toolbar flex min-w-0 flex-wrap items-center justify-end gap-x-4 gap-y-3"><div className="rc-page-command-bar ml-auto flex shrink-0 justify-end"><div className="flex flex-nowrap gap-2"><Button isDisabled={loading} isPending={downloading} size="sm" variant="secondary" onPress={handleDownloadAll}>批量下载附件</Button><Button isDisabled={loading} size="sm" variant="primary" onPress={openFieldDialog}>定义显示字段</Button></div></div></div>
{errorMessage ? <Alert status="danger"><Alert.Indicator /><Alert.Content><Alert.Title>操作未完成</Alert.Title><Alert.Description>{errorMessage}</Alert.Description></Alert.Content></Alert> : null}
<div className="rc-kpi-grid" data-count="4"><Metric label="模板实例" value={total} hint="当前模板下命中的提交流程数量" icon={DataStore} tone="info" /><Metric label="显示字段" value={fieldsDefinition.length} hint="由模板配置决定表格展开的动态列" icon={FileText} tone="success" /><Metric label="条件筛选" value={conditions?.key ? '已启用' : '未启用'} hint={conditions?.key ? `${conditions.key} = ${conditions.value || '-'}` : '当前没有预设显示条件'} icon={Filter} tone="warning" /><Metric label="分页设置" value={PAGE_SIZE} hint={`当前第 ${page} / ${totalPages} 页`} icon={FieldTime} tone="teal" /></div>
<div className="rc-kpi-grid" data-count="4"><Metric label="模板实例" value={total} hint="当前模板下命中的提交流程数量" icon={DataStore} tone="info" /><Metric label="显示字段" value={fieldsDefinition.length} hint="由模板配置决定表格展开的动态列" icon={FileText} tone="success" /><Metric label="条件筛选" value={conditions?.key ? '已启用' : '未启用'} hint={conditions?.key ? `${conditions.key} = ${conditions.value || '-'}` : '当前没有预设显示条件'} icon={Filter} tone="warning" /><Metric label="分页设置" value={pageSize} hint={`当前第 ${page} / ${totalPages} 页`} icon={FieldTime} tone="teal" /></div>
<section className="rc-list-stack">
<div className="rc-list-action-bar"><div className="flex items-center gap-1.5"><h2>实例列表</h2><TitleInfo label="实例列表" content="按模板展示实际提交的流程实例,可通过操作列查看单条详情。" /></div></div>
<div className="flex flex-wrap items-center justify-between gap-3"><SearchField aria-label="搜索表单名称" className="w-full sm:w-96" value={keyword} onChange={(value) => { setPage(1); setKeyword(value); }}><SearchField.Group><SearchField.SearchIcon><Search size={16} /></SearchField.SearchIcon><SearchField.Input placeholder="搜索表单名称" /><SearchField.ClearButton aria-label="清空关键词"><Close size={14} /></SearchField.ClearButton></SearchField.Group></SearchField></div>
<div className="rc-list-data-stack">
<section className="rc-list-data-grid"><DataGrid allowsColumnResize aria-label="流程实例列表" className="pm-data-grid" columns={instanceColumns} contentClassName="min-w-[760px]" data={loading ? [] : instances} getRowId={(record) => record._id || record.name} renderEmptyState={() => loading ? <div className="py-8 text-sm text-muted">正在加载模板实例...</div> : <DataEmptyState />} scrollContainerClassName="pm-data-grid-scroll" verticalAlign="middle" /></section>
{!loading ? <div className="pm-pagination-bar"><PagePagination page={page} total={total} onPageChange={setPage} /></div> : null}
{!loading ? <div className="pm-pagination-bar"><PagePagination page={page} pageSize={pageSize} total={total} onPageChange={setPage} onPageSizeChange={(nextPageSize) => { if (PAGE_SIZE_OPTIONS.includes(nextPageSize)) { setPageSize(nextPageSize); setPage(1); } }} /></div> : null}
</div>
</section>
<Modal.Root><Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} /><Modal.Backdrop isOpen={fieldDialogOpen} onOpenChange={setFieldDialogOpen} variant="blur"><Modal.Container placement="center" scroll="inside" size="lg"><Modal.Dialog aria-label="定义显示字段" className="p-7 sm:max-w-[68rem]"><Modal.CloseTrigger aria-label="关闭"><Close size={16} /></Modal.CloseTrigger><Modal.Header><div className="flex items-center gap-1.5"><Modal.Heading>定义显示字段</Modal.Heading><TitleInfo label="定义显示字段" content="为当前监控模板指定实例表格需要展示的字段列。" /></div></Modal.Header><Modal.Body>
<div className="grid gap-3"><div className="hidden grid-cols-[minmax(0,1fr)_minmax(0,1fr)_2rem] gap-3 text-sm text-muted sm:grid"><span>字段名</span><span>字段类型</span><span className="sr-only">操作</span></div>{fieldDrafts.map((field, index) => <div key={field._key} className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_2rem] sm:items-end"><div className="grid gap-1.5"><Label>字段名</Label><Input aria-label={`字段 ${index + 1} 名称`} fullWidth variant="secondary" value={field.display_name} onChange={(event) => setFieldDrafts((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, display_name: event.target.value } : item))} placeholder="例如:提交人" /></div><div className="grid gap-1.5"><Label>字段类型</Label><XinyuanAutocomplete aria-label={`字段 ${index + 1} 类型`} options={FIELD_TYPE_OPTIONS} selectedKey={field.type} onSelectionChange={(key) => setFieldDrafts((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, type: String(key || 'text') } : item))} /></div><Tooltip delay={0}><Button isIconOnly aria-label={`删除字段 ${index + 1}`} size="sm" variant="danger-soft" onPress={() => setFieldDrafts((current) => current.filter((_, itemIndex) => itemIndex !== index))}><Delete size={16} /></Button><Tooltip.Content>删除字段</Tooltip.Content></Tooltip></div>)}{!fieldDrafts.length ? <div className="py-6 text-center text-sm text-muted">暂未添加显示字段</div> : null}<Button fullWidth size="sm" variant="secondary" onPress={() => setFieldDrafts((current) => [...current, createFieldDraft()])}>添加字段</Button></div>
<div className="grid gap-3">{fieldDrafts.map((field, index) => <div key={field._key} className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_2rem] sm:items-end"><div className="grid gap-1.5"><Label>字段名</Label><Input aria-label={`字段 ${index + 1} 名称`} fullWidth variant="secondary" value={field.display_name} onChange={(event) => setFieldDrafts((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, display_name: event.target.value } : item))} placeholder="请输入字段名" /></div><div className="grid gap-1.5"><Label>字段类型</Label><XinyuanAutocomplete aria-label={`字段 ${index + 1} 类型`} options={FIELD_TYPE_OPTIONS} placeholder="请选择" selectedKey={field.type} onSelectionChange={(key) => setFieldDrafts((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, type: String(key || '') } : item))} /></div><Tooltip delay={0}><Button isIconOnly aria-label={`删除字段 ${index + 1}`} size="sm" variant="danger-soft" onPress={() => setFieldDrafts((current) => current.filter((_, itemIndex) => itemIndex !== index))}><Delete size={16} /></Button><Tooltip.Content>删除字段</Tooltip.Content></Tooltip></div>)}{!fieldDrafts.length ? <div className="py-6 text-center text-sm text-muted">暂未添加显示字段</div> : null}<Button fullWidth size="sm" variant="secondary" onPress={() => setFieldDrafts((current) => [...current, createFieldDraft()])}>添加字段</Button></div>
</Modal.Body><Modal.Footer><Button isDisabled={fieldSaving} size="sm" variant="secondary" onPress={() => setFieldDialogOpen(false)}>取消</Button><Button isPending={fieldSaving} size="sm" variant="primary" onPress={saveFieldDefinitions}>保存字段</Button></Modal.Footer></Modal.Dialog></Modal.Container></Modal.Backdrop></Modal.Root>
<Modal.Root><Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} /><Modal.Backdrop isOpen={detailDialogOpen} onOpenChange={setDetailDialogOpen} variant="blur"><Modal.Container placement="center" scroll="inside" size="lg"><Modal.Dialog aria-label="流程实例详情" className="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>{detailError ? <Alert status="danger"><Alert.Indicator /><Alert.Content><Alert.Title>详情加载失败</Alert.Title><Alert.Description>{detailError}</Alert.Description></Alert.Content></Alert> : null}<ProcessInstanceDetailContent data={detailRecord} loading={detailLoading} /></Modal.Body><Modal.Footer><Button size="sm" variant="secondary" onPress={() => setDetailDialogOpen(false)}>关闭</Button></Modal.Footer></Modal.Dialog></Modal.Container></Modal.Backdrop></Modal.Root>
</div>
);
}
......@@ -93,7 +93,7 @@ Shell 中的应用切换入口继续读取当前登录用户可访问应用,
- 在当前分组下展示 `metadata.progresses` 关联的监控模板
- 模板名称列仅展示文本,通过操作列的“查看”图标进入模板实例列表;管理角色在其后继续使用编辑和取消监控操作
- 按模板标题和所属部门筛选当前分组模板
- “所属部门”以同名 HeroUI Popover 入口收纳在模板列表工具栏中,按需展开且不单独占用筛选 Surface;选择和重置仍沿用原有实时过滤状态
- “所属部门”以同名 HeroUI Popover 入口收纳在模板列表工具栏中,按需展开且不单独占用筛选 Surface;弹窗内标签独占一行,选择器与重置按钮同行,选择和重置仍沿用原有实时过滤状态
- 管理角色可执行:
- 新建分组
- 删除空分组
......@@ -118,10 +118,11 @@ Shell 中的应用切换入口继续读取当前登录用户可访问应用,
- 读取当前监控模板记录 `audit_related_progress`
- 根据模板的 `metadata.fields_definition` 动态生成列表列
- 查询 `audit_progress_info` 并按 `metadata.related_progress` 过滤
- 支持关键字搜索和固定每页 10 条的分页切换
- 支持关键字搜索和每页 10/20/50/100 条的分页切换,默认每页 10 条
- 支持批量触发附件下载
- 支持维护实例列表的显示字段定义
- 页面头部将返回入口与面包屑放在左侧同行,批量下载附件与定义显示字段保持同行并右对齐;窄屏空间不足时操作组整体换到下一行,两个操作按钮不拆行
- 打开“定义显示字段”时,已有字段按配置回显;无字段配置时默认展示一行空字段,字段名和字段类型分别使用“请输入字段名”“请选择”提示,不额外展示重复的字段表头
- 页面导航统一使用顶部 Navbar 页面标题,页面内容区不再重复渲染返回入口或面包屑;批量下载附件与定义显示字段保持同行并右对齐,窄屏空间不足时操作组整体换到下一行,两个操作按钮不拆行
- 实例列表的标题、搜索工具栏、DataGrid 和 Pagination 作为同一列表板块组织,标题与工具栏、工具栏与 DataGrid、DataGrid 与 Pagination 均采用 16px 关联间距
筛选规则:
......@@ -132,8 +133,8 @@ Shell 中的应用切换入口继续读取当前登录用户可访问应用,
列表交互:
- 使用 HeroUI Pro DataGrid 生成动态列并承载选择状态
- 表单名称列仅展示文本,通过操作列的“查看”图标进入单条流程实例详情
- Pagination 保留原有页码语义和 `page` / `page_size` 请求参数,页面不提供 page-size 切换控件
- 表单名称列仅展示文本,通过操作列的“查看”图标在当前列表页打开单条流程实例详情 Modal;详情路由仍保留用于直接访问和兼容既有链接
- Pagination 保留原有页码语义和 `page` / `page_size` 请求参数,条数选择器支持 10/20/50/100 条
- 有选中记录时由 HeroUI Pro ActionBar 承载批量附件下载;空数据使用 HeroUI Pro EmptyState
### 5.3 流程实例详情页
......@@ -171,7 +172,7 @@ Shell 中的应用切换入口继续读取当前登录用户可访问应用,
- 旧版实例列表批量下载会打包 zip;当前迁移版使用逐个触发浏览器下载,保持“批量下载附件”能力但不强依赖额外前端压缩库
- 旧版详情页曾预留字段类型即时修改能力,但实际入口已注释;当前迁移版不开放详情页内字段类型编辑,统一回到模板实例列表页的“定义显示字段”
- 列表按统一设计合同固定为每页 10 条,不再展示 page-size 选择器;后端分页协议没有变化
- 列表按统一设计合同默认每页 10 条,并通过 HeroUI 条数选择器支持 10/20/50/100 条;后端分页协议没有变化
- 401 响应仍在原时机清理登录态,只将登录跳转修正为包含 `/oa_process_monitor_new/` base 的应用内地址
---
......
......@@ -19,19 +19,12 @@
--font-inter:
"Inter", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
color-scheme: light !important;
--pm-brand: var(--rc-brand-primary);
--pm-brand-strong: var(--rc-brand-dark);
}
html {
color-scheme: light !important;
}
html.dark,
html[data-theme="dark"] {
color-scheme: dark !important;
}
body {
margin: 0;
font-family: var(--rc-font-sans);
......@@ -40,6 +33,43 @@ body {
color-scheme: inherit;
}
:is(.checkbox[data-selected="true"], .checkbox[data-indeterminate="true"])
.checkbox__control,
:is(
.checkbox__content[data-selected="true"],
.checkbox__content[data-indeterminate="true"]
)
.checkbox__control {
border-color: var(--rc-brand-primary) !important;
background: var(--rc-brand-primary) !important;
box-shadow: none !important;
filter: none !important;
}
:is(.checkbox[data-selected="true"], .checkbox[data-indeterminate="true"])
.checkbox__control::before,
:is(
.checkbox__content[data-selected="true"],
.checkbox__content[data-indeterminate="true"]
)
.checkbox__control::before {
border-color: var(--rc-brand-primary) !important;
background: var(--rc-brand-primary) !important;
box-shadow: none !important;
filter: none !important;
}
:is(.checkbox[data-selected="true"], .checkbox[data-indeterminate="true"])
.checkbox__indicator,
:is(
.checkbox__content[data-selected="true"],
.checkbox__content[data-indeterminate="true"]
)
.checkbox__indicator {
color: var(--accent-foreground) !important;
filter: none !important;
}
body .login-page {
--rc-login-brand-logo-size: 2.25rem;
--rc-login-card-brand-gap: 0.875rem;
......
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