Commit 6a36f6ce authored by Andy-bubu's avatar Andy-bubu

feat: align audit manager with Xinyuan HeroUI

parent ef2b315a
# UI 架构合同 # UI 架构
## 1. 直接引用原则 ## 依赖边界
- 页面和新建共享组件必须直接从 `@heroui/react`、`@heroui-pro/react` 或已验证的 Pro 子路径导入。 - 每个项目独立拥有 `node_modules` 和锁文件,不依赖相邻仓库。
- 禁止在 `catalyst-ui-kit`、旧 Button/Input/Checkbox/Dialog/Table/Select/DatePicker/Pagination/Upload 内嵌 HeroUI。 - 当前正式安装结果:Pro `1.0.0-beta.8`、HeroUI/Styles `3.2.4`、React Aria Components `1.20.0`、React/DOM `19.2.8`。
- 禁止继续扩展或修改 `src/catalyst-ui-kit`。新功能不得引用旧 UI 组件。 - 当前仅保留 `pnpm-lock.yaml`;旧 `package-lock.json` 因包含相邻项目 `file:` 解析记录已删除。
- 禁止创建复刻旧 props、DOM event、class hook 或 DOM 结构的兼容包装层。 - 根项目不得出现 `file:../project_manager_new`、绝对 `/Users/...`、相邻 `node_modules`、vendor 包或软链接交付。
- 一个功能面只能使用一套 UI 交互模型。允许的共享组件必须是业务中立、HeroUI-native 的复合组件,并暴露当前 HeroUI selection/value/event 合同。 - 页面和共享 UI 直接从 `@heroui/react`、`@heroui-pro/react` 或 Pro 子路径导入。
## 2. 标准实现 ## 分层
- **DataGrid**:使用 HeroUI Pro `DataGrid` 原生列、排序、固定列、`selectedKeys`、`onSelectionChange`、`selectionMode` 与 `showSelectionCheckboxes`。分页使用 compound Pagination,批量操作使用 Pro ActionBar。禁止手写选择列或在 Cell 内嵌旧 Checkbox。
- **Modal**:使用 `Modal.Root > Modal.Backdrop > Modal.Container > Modal.Dialog`,业务页直接传受控 open/close、异步状态和提交回调。删除/重置使用受控 AlertDialog/Modal,不使用 `window.confirm`。
- **表单**:使用 HeroUI TextField/Input/TextArea/Checkbox/Select/DatePicker 的 compound 结构。Select 必须包含 ListBox;Checkbox、Select、Tabs、Modal 在 React Aria Context 中按官方 slot/compound 结构实现。
- **Tabs**:主数据集切换使用 HeroUI Tabs;只有一个可访问视图时隐藏轨道但保留原 key 与查询行为。模块级模式切换使用紧凑分段控件。
- **上传**:使用 `@heroui-pro/react/drop-zone`,向业务回调传真实 `File` 或 `File[]`,不伪造旧 input event。
- **图表**:ECharts 配置从鑫元 Token 读取颜色、文字、网格、Tooltip、图例和暗色表面;统计口径和数据转换留在现有业务层。
## 3. 分层
```text ```text
页面/controller(请求、权限、状态、业务回调) 页面 controller:请求、权限、校验、业务状态与回调
-> HeroUI/HeroUI Pro 官方组件 -> HeroUI/HeroUI Pro 官方 compound API
-> 少量 HeroUI-native 业务中立复合组件(设计、布局、无障碍) -> 业务无关的 HeroUI-native compositions
-> 鑫元 Token 与项目语义样式 -> theme.css(不可变)+ tokens.css(别名/布局扩展)
``` ```
旧 Catalyst/Headless 组件不在这条链路中。`assets/portable` 只作为历史视觉参考,不作为代码 API。 允许共享 AppShell、DataGrid composition、Autocomplete reference field、DropZone field、Application switcher 和确认弹窗,但它们必须暴露当前 HeroUI selection/value/event 合同。禁止兼容 Catalyst props、DOM event、class hook 或旧 DOM 结构。
## 组件决策
## 4. HeroUI Pro 版本与交付 - Shell:Pro Sidebar.Provider/Sidebar/Main/Mobile + Pro Navbar。
- 表单:HeroUI TextField/Input/TextArea/Checkbox/Autocomplete/DatePicker。
- 多选:共享 Autocomplete composition 在 `Autocomplete.Value` 内使用 HeroUI `TagGroup + Tag + Tag.RemoveButton`;根 `selectedKeys` 是唯一选择状态,移除操作回写同一个 `onSelectionChange`,业务页面只负责值协议映射。
- 列表:Pro DataGrid 原生 selection/sort/fixed columns + HeroUI Pagination + Pro ActionBar/EmptyState。
- 弹层:HeroUI Modal/AlertDialog/Dropdown/Tooltip,根主题覆盖 body Portal。
- 上传:Pro DropZone,页面边界接收真实 File。
- KPI/图表:仅业务存在时使用 Pro KPI/PieChart/BarChart/ComposedChart/ChartTooltip。
- 报告视图:一级 HeroUI Tabs 切换 current/history,二级内容宽度 Tabs 以稳定项目 ID 切换报告快照;当前项目只挂载工具栏和 DataGrid,不重复 Tabs 已表达的项目标题,整体进度由 KPI 承担。项目与控制点仍携带原数组索引进入现有更新/确认回调。
- 当前报告搜索由 `AuditReportContent` 放在二级 Tabs 下方,并与当前项目确认、备注保存组成标准响应式列表工具栏;它继续使用父页面原受控 keyword 与保存回调。
- 报告附件单元格由 HeroUI `Chip/Link` 与 Pro `DropZone` 直接组合,不创建 Card/Surface 包装层;上传、追加和删除仍进入原 `onUpdate(processIndex, pointIndex, patch)` 协议。
- 本机开发镜像:`@heroui-pro/react@1.0.0-beta.7`,来源为本机 `project_manager_new` 的完整安装。 ## 样式所有权
- 本地 bootstrap 可执行 `npm install --legacy-peer-deps ../project_manager_new/node_modules/@heroui-pro/react`,但该 `file:` 路径不能作为跨机器交付方案。
- 团队正式安装必须使用私有 npm 或统一授权、带版本和校验信息的 `.tgz`。需要同时固定 `@heroui/react@3.2.2`、`@heroui/styles@3.2.2`、`react-aria-components@1.19.0` 和兼容 React 19 运行时。
- 正式同步前验证 `css`、`sidebar`、`navbar`、`data-grid`、`drop-zone`、`action-bar`、`empty-state` 子路径,并运行 `npm ls @heroui-pro/react @heroui/react @heroui/styles react-aria-components @react-aria/utils`。
- 当前 sibling `file:` 图存在 React 18/19 双实例风险,尚不能视为可复现交付。
## 5. 业务保护 - `theme.css` 提供 HeroUI 核心颜色、半径、阴影、字段和 dark token,必须与技能资产逐字节一致。
- `tokens.css` 只能添加 `--rc-*` 别名、布局尺寸、业务语义 chip/chart tokens,不能重定义核心变量。
- 共享 Shell/layout CSS 只负责几何和技能批准的跨应用模式。
- 页面 className 只负责 flex、grid、宽度、间距、对齐和响应式;禁止外观、状态、slot/BEM 和伪元素覆盖。
UI 层不得修改 API 地址、请求方法/参数、响应映射、权限与禁用条件、校验规则、提交数据、登录退出协议、表格选择结果、分页排序筛选语义或图表统计口径。发现业务缺陷时只记录,另开业务变更。 ## 业务边界
## 6. 当前依赖状态 UI 迁移不得改变 `record-api.md` 和 `subjectsDefinition.md` 的 URL、method、参数、metadata 结构、引用 ID、ISO 日期、权限、分页/排序口径或请求时机。业务流程变化需同步 `src/pages/audit_management/audit-management.business-flow.md`;报告项目 Tabs 仅改变并列快照的可见方式,搜索作用域收敛到当前项目,不改变查询请求或持久化数据。
- 浏览器运行时通过 Umi alias 使用顶层 React/React DOM 19,活动页面没有出现重复 React Aria Context、slot 或 provider 异常。 多值引用 composition 只在页面边界把 HeroUI 的 key 集合映射为原有 `_id[]`。禁止保存完整引用对象、维护第二套内部选择状态,或在 Autocomplete 外部渲染已选 Chip 列表。
- 用户指定的 `npm ls @heroui-pro/react @heroui/react @heroui/styles react-aria-components @react-aria/utils` 当前通过,并显示一套 HeroUI/React Aria 版本。加入 `react react-dom` 后仍会报告 `ELSPROBLEMS`:HeroUI 3 使用 React 19,而 Umi 构建依赖保留 React 16-18 peer 声明及嵌套 React 18。这不是可复现的干净完整依赖树。
- 当前 `file:../project_manager_new/node_modules/...` 只保证本机启动。团队未提供私有 npm 或授权 `.tgz` 前,不得提交为跨机器安装方案,也不得宣称依赖交付完成。
## 7. 代码审查门槛 ## 完成定义
每个迁移模块都要扫描 Catalyst/Headless/旧本地 UI import,真实浏览器检查打开态和控制台。活动范围旧引用必须为零;非活动旧模块单独列出。构建和浏览器验收通过可以确认本机运行质量,但不能覆盖当前依赖树和团队包源的可复现风险。 活动路由旧 UI 引用与私有视觉 CSS 为零;依赖树单一;所有 Pro 子路径可用;构建、diff check、技能审计脚本通过;双应用四视口双主题的打开态和 console 均有证据。任何一项未完成时不声明迁移完成。当前代码与登录页门禁已通过,但登录后浏览器证据因缺少有效真实登录态尚未完成。
# UI 迁移清单 # UI 迁移清单
状态说明:`已迁移`表示活动运行时入口已经直接使用 HeroUI/HeroUI Pro;浏览器状态只记录真实数据和真实业务条件下实际执行过的交互,不用伪造数据补齐场景。 状态日期:2026-08-21。`完成` 仅表示代码、业务保护、双应用浏览器对照和扫描门槛全部通过。
| 页面/模块 | 路由 | 迁移结果 | 保留的业务回调 | 状态 | 浏览器验收 | | 模块 | 路由/入口 | 代码迁移 | 浏览器对照 | 业务保护 |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| 登录 | `/login` | HeroUI Card/TextField/Input/Button/Alert | captcha、AES 加密、userLogin、redirect | 已迁移 | 通过:登录、跳转、统一 Logo/标题、输入状态和响应式 | | HeroUI 安装/依赖 | 根项目 | 完成 | 子路径完成 | 不适用 |
| AppShell | 活动鉴权路由 | HeroUI Pro Sidebar/Navbar + HeroUI Dropdown/Tooltip | history 路由、主题持久化 | 已迁移 | 通过:桌面完全收起/展开、移动 offcanvas、浅色/暗色 | | 原始业务基线 | `/login` | 未改页面启动完成 | 1440x900 已记录 | 完成 |
| 左侧用户/退出 | 活动鉴权路由 | HeroUI Dropdown compound API | userLogout、clearUser、base-aware 登录跳转 | 已迁移 | 通过:菜单打开、退出并回到登录页 | | Login | `/login` | 完成 | 四视口双主题及 filled 完成 | 完成 |
| 应用切换 | 全局 | HeroUI Modal compound API | getApplications、URL 导航 | 已迁移 | 通过:打开/关闭、当前项唯一、浅色/暗色 Portal | | AppShell/Navbar/Sidebar | 鉴权路由 | 完成 | 缺有效登录态 | 代码复核完成 |
| 稽核项目 | `/audit_process` | HeroUI 表单/Modal/Tabs + HeroUI Pro DataGrid | 查询、筛选、增删改、引用选择 | 已迁移 | 通过:真实数据、Tab、实时筛选、新增/编辑/删除弹窗、枚举和远程引用关闭态回显/打开态搜索 | | 用户菜单/退出 | Sidebar footer | 完成 | 缺有效登录态 | 回调保留 |
| 稽核报告 | `/audit_report` | HeroUI Tabs/Modal + HeroUI Pro DataGrid | 当前/历史查询、填报链接、提交、删除 | 已迁移 | 通过:当前/历史 Tab、填报链接、历史详情跳转;提交按钮因 8 个控制点未完成而禁用,未绕过业务限制 | | 应用切换 | Shell Modal | 完成 | 缺有效登录态 | 数据/导航保留 |
| 报告内容 | 活动报告与历史详情复用 | HeroUI 表单 + HeroUI Pro DataGrid/DropZone | update、confirm point/process | 已迁移 | 通过:真实内容表、横向滚动、固定列、暗色表面;未提交真实变更 | | 全应用弹窗尺寸 | HeroUI Modal / AlertDialog | 完成 | 待有效登录态复验 | md 480 / lg 640 / 3xl 960 / 应用切换 736 |
| 历史详情 | `/audit_report/:reportId` | HeroUI Button/Chip + HeroUI Pro DataGrid | 加载、返回、导出 | 已迁移 | 通过:真实历史报告加载和多组明细表 | | 稽核项目 | `/audit_process` | 完成 | 缺有效登录态 | 代码复核完成 |
| 外部填报 | `/audit_report_fill` | HeroUI 表单/AlertDialog + HeroUI Pro DataGrid/DropZone | ticket 鉴权、保存、重置、beforeunload | 已迁移 | 通过:真实填报链接、DropZone、Select 打开态、根主题、标题和移动端;未选择文件,避免立即调用真实上传接口 | | 多选字段 | 稽核项目表单 | 完成 | 缺有效登录态/打开态复验 | 框内 Tag;`_id[]` 与请求时机保留 |
| PageState | 多路由复用 | HeroUI Spinner/Button/Alert | retry | 已迁移 | 通过:活动页面加载/空状态可见,控制台无异常 | | 列表查看操作 | 全部活动 DataGrid | 完成 | 缺有效登录态/打开态复验 | Eye 首位;原权限和回调保留 |
| 稽核报告 | `/audit_report` | 完成 | 缺有效登录态 | 代码复核完成 |
## 浏览器验收记录 | 报告项目二级 Tabs | `/audit_report`、详情、填报 | 完成 | 缺有效登录态/真实报告 | 单项目可见;无重复标题/进度;索引与回调保留 |
| 报告附件单元格 | 报告控制点 DataGrid | 完成 | 缺有效登录态/真实附件 | HeroUI Chip/Link;无嵌套 Card |
- 视口:`1440x900`、`1280x720`、`768x1024`、`390x844`。 | 历史详情 | `/audit_report/:reportId` | 完成 | 缺有效登录态/真实记录 | 代码复核完成 |
- Shell:Sidebar 展开后 240px;桌面收起后移出视口且主内容扩展到完整宽度;移动 offcanvas 铺满面板。 | 外部填报 | `/audit_report_fill` | 完成 | 缺真实 ticket/report | 代码复核完成 |
- 响应式:四个视口均无页面级横向滚动;Tabs 和 DataGrid 在自己的容器内滚动。 | KPI/图表 | 报告 KPI;无图表 | KPI 完成/图表不适用 | 缺有效登录态 | 不制造数据 |
- 表格:稽核项目、当前报告、历史详情和外部填报均使用真实记录验收;固定列和操作列在宽表内部滚动时保持可见。
- 表单与弹层:新增/编辑 Modal、删除 AlertDialog、应用切换、枚举 Select、远程引用 ListBox、外部填报 Select 已检查打开态和暗色表面。
- 选择回显:空值显示占位符;责任部门等单选关闭态显示真实名称;标签/制度多选关闭态显示已选名称并保留可移除标签;打开后切换为搜索词,不改变提交 ID/数组。
- 主题:浅色和暗色的 Sidebar、Navbar、Modal、Select/ListBox、DataGrid、DropZone 与移动 offcanvas 已检查;外部填报页同步根主题和浏览器标题。
- 控制台:最终代码修改后的全新认证会话为 `0 errors / 0 warnings`;仅有 Webpack 连接日志和 React DevTools 开发提示。
## 受真实业务状态限制的场景
- 当前报告仍有 8 个控制点未完成,提交按钮按原业务规则禁用,因此没有打开提交确认,也没有绕过禁用条件。
- DropZone 选中文件后会立即调用真实后端上传。本次只验证可见表面、打开态、暗色和响应式,没有选择文件或制造上传数据。
- 当前真实数据量不足一页,运行时没有出现 Pagination;当前活动流程也没有选择依赖的批量业务动作,因此 ActionBar 没有可触发入口。未伪造记录,也不把这两项写成已交互通过。
- 没有执行新增、编辑、删除、保存、提交或重置的最终确认,避免修改真实业务数据;只验证弹窗、字段、选择器和取消路径。
## 非活动旧模块
以下目录未进入当前 `.umirc.ts` 活动路由,仍含 Catalyst/Headless/Heroicons 引用,不计入已迁移范围:
- `src/pages/process_monitor`
- `src/pages/RecordDetailDrawer`
- `src/components/record-workbench`
恢复这些入口前必须先按同一 HeroUI 原生架构迁移,不能直接重新挂载。
## 最终门槛 ## 最终门槛
- [x] 活动路由 Catalyst/Headless/Heroicons 引用为零。 - [x] 指定 `hpsetup` 命令在目标根目录成功。
- [x] `npm run build` 通过(Webpack 编译成功)。 - [x] Pro `1.0.0-beta.8` 和要求子路径(含 KPI)通过验证。
- [x] `git diff --check` 通过。 - [x] 根依赖无跨项目 `file:`、绝对 `/Users/...` 或相邻 `node_modules`。
- [x] 本地 Pro CSS 与六个 JS 子路径全部可解析/导入。 - [x] 原始应用启动、登录结构、console 和截图已记录。
- [x] 指定 HeroUI/React Aria `npm ls` 命令通过并显示一套版本。 - [x] 权威 theme.css 与技能资产一致。
- [ ] 包含 React/Umi 的完整依赖树可跨机器复现。当前本地镜像、Umi peer 声明和嵌套 React 18 仍不满足此项。 - [x] 活动路由 Catalyst/Headless/旧 UI 引用为零。
- [x] 鑫元审计脚本完成最终重跑;非活动旧代码剩余引用已单独报告。 - [x] 活动页面私有视觉 CSS 扫描为零。
- [x] 活动路由多选字段统一为 Autocomplete 框内 TagGroup,选择框外无已选 Chip 列表。
- [x] 并列报告项目整合为内容宽度二级 HeroUI Tabs,单项目时隐藏轨道;搜索只过滤当前项目列表。
- [x] 当前报告搜索位于二级 Tabs 下方的标准工具栏,移动端独占一行;项目区不重复 Tabs 标题和 KPI 进度。
- [x] 报告附件内容使用紧凑 HeroUI Chip/Link,DataGrid 单元格内无 Card/Surface 或页面私有外观。
- [ ] 四视口、浅色/暗色、目标/基准同时打开完成(登录完成,鉴权页面待有效登录态)。
- [ ] 所有可达 popup、Modal、Tooltip、Dropdown、Sidebar 状态检查完成。
- [x] `npm run build` 与 `git diff --check` 通过;`audit_project.sh` 退出码 0,并报告非活动遗留。
- [x] HeroUI/React Aria 依赖树、Pro 子路径、file/path 扫描通过。
- [x] 无活动页面业务协议变化。
## 非活动遗留
`src/pages/process_monitor`、`src/pages/RecordDetailDrawer`、`src/components/record-workbench` 不在当前路由树;排除 Catalyst Kit 自身目录后,共有 7 个存量源码文件仍含 Catalyst/Headless 引用。不能把这些目录误报为已迁移。
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
...@@ -8,25 +8,46 @@ ...@@ -8,25 +8,46 @@
"start": "npm run dev" "start": "npm run dev"
}, },
"dependencies": { "dependencies": {
"@ant-design/icons": "file:../project_manager_new/node_modules/@ant-design/icons", "@ant-design/icons": "^6.3.2",
"@fontsource/inter": "^5.3.0",
"@headlessui/react": "^2.2.10", "@headlessui/react": "^2.2.10",
"@heroicons/react": "^2.2.0", "@heroicons/react": "^2.2.0",
"@heroui-pro/react": "file:../project_manager_new/node_modules/@heroui-pro/react", "@heroui-pro/react": "1.0.0-beta.8",
"@heroui/react": "file:../project_manager_new/node_modules/@heroui/react", "@heroui/react": "^3.2.4",
"@heroui/styles": "file:../project_manager_new/node_modules/@heroui/styles", "@heroui/styles": "^3.2.4",
"@internationalized/date": "^3.12.2",
"@number-flow/react": "^0.6.2",
"@react-aria/utils": "^3.34.1",
"@tailwindcss/postcss": "^4.2.0", "@tailwindcss/postcss": "^4.2.0",
"@tiptap/core": "^3.30.2",
"@tiptap/extension-link": "^3.30.2",
"@tiptap/extension-underline": "^3.30.2",
"@tiptap/extensions": "^3.30.2",
"@tiptap/pm": "^3.30.2",
"@tiptap/react": "^3.30.2",
"@tiptap/starter-kit": "^3.30.2",
"@tiptap/suggestion": "^3.30.2",
"@umijs/max": "^4.5.3", "@umijs/max": "^4.5.3",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"dayjs": "^1.11.20", "dayjs": "^1.11.20",
"echarts": "^6.0.0", "embla-carousel": "^8.6.0",
"echarts-for-react": "^3.0.6", "embla-carousel-react": "^8.6.0",
"motion": "file:../project_manager_new/node_modules/motion", "maplibre-gl": "^6.4.1",
"react": "file:../project_manager_new/node_modules/react", "marked": "^18.0.10",
"react-aria-components": "file:../project_manager_new/node_modules/react-aria-components", "motion": "^12.43.0",
"react-dom": "file:../project_manager_new/node_modules/react-dom", "react": "^19.2.8",
"tailwind-merge": "file:../project_manager_new/node_modules/tailwind-merge", "react-aria-components": "^1.20.0",
"tailwind-variants": "file:../project_manager_new/node_modules/tailwind-variants", "react-dom": "^19.2.8",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.12.3",
"recharts": "^3.10.1",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"shiki": "^4.4.3",
"streamdown": "^2.5.0",
"tailwind-merge": "^3.6.0",
"tailwind-variants": "^3.3.1",
"tailwindcss": "^4.2.0", "tailwindcss": "^4.2.0",
"umi-request": "^1.4.0", "umi-request": "^1.4.0",
"umi-request-progress": "^1.1.0", "umi-request-progress": "^1.1.0",
......
This diff is collapsed.
...@@ -3,3 +3,5 @@ allowBuilds: ...@@ -3,3 +3,5 @@ allowBuilds:
core-js-pure: false core-js-pure: false
es5-ext: false es5-ext: false
esbuild: false esbuild: false
"@heroui-pro/react": true
"@zowe/secrets-for-zowe-sdk": true
...@@ -8,6 +8,7 @@ import DownloadOutlined from '@ant-design/icons/DownloadOutlined'; ...@@ -8,6 +8,7 @@ import DownloadOutlined from '@ant-design/icons/DownloadOutlined';
import DownOutlined from '@ant-design/icons/DownOutlined'; import DownOutlined from '@ant-design/icons/DownOutlined';
import ClockCircleOutlined from '@ant-design/icons/ClockCircleOutlined'; import ClockCircleOutlined from '@ant-design/icons/ClockCircleOutlined';
import CloseOutlined from '@ant-design/icons/CloseOutlined'; import CloseOutlined from '@ant-design/icons/CloseOutlined';
import CopyOutlined from '@ant-design/icons/CopyOutlined';
import DeleteOutlined from '@ant-design/icons/DeleteOutlined'; import DeleteOutlined from '@ant-design/icons/DeleteOutlined';
import DollarOutlined from '@ant-design/icons/DollarOutlined'; import DollarOutlined from '@ant-design/icons/DollarOutlined';
import DatabaseOutlined from '@ant-design/icons/DatabaseOutlined'; import DatabaseOutlined from '@ant-design/icons/DatabaseOutlined';
...@@ -16,9 +17,11 @@ import EyeOutlined from '@ant-design/icons/EyeOutlined'; ...@@ -16,9 +17,11 @@ import EyeOutlined from '@ant-design/icons/EyeOutlined';
import ExportOutlined from '@ant-design/icons/ExportOutlined'; import ExportOutlined from '@ant-design/icons/ExportOutlined';
import FileTextOutlined from '@ant-design/icons/FileTextOutlined'; import FileTextOutlined from '@ant-design/icons/FileTextOutlined';
import FolderOpenOutlined from '@ant-design/icons/FolderOpenOutlined'; import FolderOpenOutlined from '@ant-design/icons/FolderOpenOutlined';
import GroupOutlined from '@ant-design/icons/GroupOutlined';
import PaperClipOutlined from '@ant-design/icons/PaperClipOutlined'; import PaperClipOutlined from '@ant-design/icons/PaperClipOutlined';
import LeftOutlined from '@ant-design/icons/LeftOutlined'; import LeftOutlined from '@ant-design/icons/LeftOutlined';
import LogoutOutlined from '@ant-design/icons/LogoutOutlined'; import LogoutOutlined from '@ant-design/icons/LogoutOutlined';
import LockOutlined from '@ant-design/icons/LockOutlined';
import InboxOutlined from '@ant-design/icons/InboxOutlined'; import InboxOutlined from '@ant-design/icons/InboxOutlined';
import InfoCircleOutlined from '@ant-design/icons/InfoCircleOutlined'; import InfoCircleOutlined from '@ant-design/icons/InfoCircleOutlined';
import MoreOutlined from '@ant-design/icons/MoreOutlined'; import MoreOutlined from '@ant-design/icons/MoreOutlined';
...@@ -36,12 +39,17 @@ import SunOutlined from '@ant-design/icons/SunOutlined'; ...@@ -36,12 +39,17 @@ import SunOutlined from '@ant-design/icons/SunOutlined';
import TeamOutlined from '@ant-design/icons/TeamOutlined'; import TeamOutlined from '@ant-design/icons/TeamOutlined';
import UserOutlined from '@ant-design/icons/UserOutlined'; import UserOutlined from '@ant-design/icons/UserOutlined';
import UploadOutlined from '@ant-design/icons/UploadOutlined'; import UploadOutlined from '@ant-design/icons/UploadOutlined';
import UnlockOutlined from '@ant-design/icons/UnlockOutlined';
import WarningOutlined from '@ant-design/icons/WarningOutlined'; import WarningOutlined from '@ant-design/icons/WarningOutlined';
function iconStyle(size, style) { function iconStyle(size, style) {
return size === undefined ? style : { fontSize: size, ...style }; return size === undefined ? style : { fontSize: size, ...style };
} }
export const FIELD_SUFFIX_ICON_STYLE = Object.freeze({
color: 'var(--field-placeholder, var(--muted))',
});
function withSize(Icon) { function withSize(Icon) {
return function AppIcon({ size, style, ...props }) { return function AppIcon({ size, style, ...props }) {
return ( return (
...@@ -60,6 +68,7 @@ export const ChevronDown = withSize(DownOutlined); ...@@ -60,6 +68,7 @@ export const ChevronDown = withSize(DownOutlined);
export const ChevronRight = withSize(RightOutlined); export const ChevronRight = withSize(RightOutlined);
export const Clock = withSize(ClockCircleOutlined); export const Clock = withSize(ClockCircleOutlined);
export const Close = withSize(CloseOutlined); export const Close = withSize(CloseOutlined);
export const Copy = withSize(CopyOutlined);
export const Delete = withSize(DeleteOutlined); export const Delete = withSize(DeleteOutlined);
export const Dollar = withSize(DollarOutlined); export const Dollar = withSize(DollarOutlined);
export const Database = withSize(DatabaseOutlined); export const Database = withSize(DatabaseOutlined);
...@@ -69,7 +78,9 @@ export const OpenExternal = withSize(ExportOutlined); ...@@ -69,7 +78,9 @@ export const OpenExternal = withSize(ExportOutlined);
export const FileText = withSize(FileTextOutlined); export const FileText = withSize(FileTextOutlined);
export const Info = withSize(InfoCircleOutlined); export const Info = withSize(InfoCircleOutlined);
export const Folder = withSize(FolderOpenOutlined); export const Folder = withSize(FolderOpenOutlined);
export const Group = withSize(GroupOutlined);
export const LogOut = withSize(LogoutOutlined); export const LogOut = withSize(LogoutOutlined);
export const Lock = withSize(LockOutlined);
export function SidebarPanel({ size = 20, style, ...props }) { export function SidebarPanel({ size = 20, style, ...props }) {
return ( return (
<span <span
...@@ -107,6 +118,7 @@ export const Sun = withSize(SunOutlined); ...@@ -107,6 +118,7 @@ export const Sun = withSize(SunOutlined);
export const Team = withSize(TeamOutlined); export const Team = withSize(TeamOutlined);
export const User = withSize(UserOutlined); export const User = withSize(UserOutlined);
export const Upload = withSize(UploadOutlined); export const Upload = withSize(UploadOutlined);
export const Unlock = withSize(UnlockOutlined);
export const Back = withSize(LeftOutlined); export const Back = withSize(LeftOutlined);
export const ArrowDownTrayIcon = withSize(DownloadOutlined); export const ArrowDownTrayIcon = withSize(DownloadOutlined);
...@@ -120,10 +132,13 @@ export const CheckCircleIcon = withSize(CheckCircleOutlined); ...@@ -120,10 +132,13 @@ export const CheckCircleIcon = withSize(CheckCircleOutlined);
export const ChevronDownIcon = ChevronDown; export const ChevronDownIcon = ChevronDown;
export const ChevronRightIcon = ChevronRight; export const ChevronRightIcon = ChevronRight;
export const ClipboardDocumentListIcon = withSize(OrderedListOutlined); export const ClipboardDocumentListIcon = withSize(OrderedListOutlined);
export const DocumentDuplicateIcon = Copy;
export const ClockIcon = Clock; export const ClockIcon = Clock;
export const ExclamationTriangleIcon = withSize(WarningOutlined); export const ExclamationTriangleIcon = withSize(WarningOutlined);
export const FolderOpenIcon = Folder; export const FolderOpenIcon = Folder;
export const InformationCircleIcon = Info; export const InformationCircleIcon = Info;
export const LockClosedIcon = Lock;
export const LockOpenIcon = Unlock;
export const MagnifyingGlassIcon = Search; export const MagnifyingGlassIcon = Search;
export const PaperClipIcon = withSize(PaperClipOutlined); export const PaperClipIcon = withSize(PaperClipOutlined);
export const PlusIcon = withSize(PlusOutlined); export const PlusIcon = withSize(PlusOutlined);
......
import { THEME_STORAGE_KEY } from '@/constants/app';
import { Sidebar } from '@heroui-pro/react/sidebar';
import { Toast } from '@heroui/react';
import { useLayoutEffect, useState } from 'react';
import { 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, open, onOpenChange, 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 {
// Keep the in-memory theme when storage is unavailable.
}
}, [theme]);
return (
<Sidebar.Provider
className={`template-dashboard-shell rc-app-shell ${theme}`}
collapsible="offcanvas"
navigate={(href) => history.push(href)}
open={open}
onOpenChange={onOpenChange}
>
<DashboardSidebar {...sidebarProps} />
<Sidebar.Main className="app-main-glass rc-app-main">
<DashboardNavbar theme={theme} 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>
</div>
</Sidebar.Main>
<Toast.Provider maxVisibleToasts={3} placement="top" />
</Sidebar.Provider>
);
}
import { Moon, SidebarPanel, Sun } from '@/components/AppIcons';
import { Navbar } from '@heroui-pro/react/navbar';
import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar';
import { Button, Tooltip } from '@heroui/react';
export default function DashboardNavbar({ onThemeChange, theme, title }) {
const { isMobile, isOpen } = useSidebar();
const isDark = theme === 'dark';
const themeLabel = isDark ? '切换至浅色模式' : '切换至深色模式';
return (
<Navbar className="app-navbar rc-navbar" maxWidth="full">
<Navbar.Header>
<Sidebar.Trigger
aria-label={isMobile ? '打开导航' : isOpen ? '收起侧栏' : '展开侧栏'}
className="app-navbar-trigger rc-navbar-trigger"
>
<SidebarPanel />
</Sidebar.Trigger>
<h1 className="app-navbar-page-title rc-navbar-title">{title}</h1>
<Navbar.Spacer />
<div className="rc-navbar-actions">
<Tooltip delay={0}>
<Button
isIconOnly
aria-label={themeLabel}
aria-pressed={isDark}
className="app-navbar-icon-button rc-navbar-icon-button"
size="sm"
variant="ghost"
onPress={() => onThemeChange(isDark ? 'light' : 'dark')}
>
{isDark ? <Sun size={16} /> : <Moon size={16} />}
</Button>
<Tooltip.Content>{themeLabel}</Tooltip.Content>
</Tooltip>
</div>
</Navbar.Header>
</Navbar>
);
}
import xyLogoRed from '@/assets/identity/xy-logo-red.png';
import xyLogoWhite from '@/assets/identity/xy-logo-white.png';
import { AppGrid, LogOut } from '@/components/AppIcons';
import { APP_DISPLAY_NAME } from '@/constants/app';
import { Sidebar } from '@heroui-pro/react/sidebar';
import { Avatar, Button, Dropdown, Label, Tooltip } from '@heroui/react';
function getUserDisplay(userInfo) {
const displayName = userInfo?.display_name || userInfo?.username || '当前用户';
const username = userInfo?.username || userInfo?.email || '已登录';
return {
displayName,
username,
initials: String(displayName).trim().slice(0, 1).toUpperCase() || 'U',
};
}
function SidebarUserMenu({ userInfo, onLogout }) {
const user = getUserDisplay(userInfo);
return (
<Dropdown>
<Dropdown.Trigger aria-label="打开用户菜单" className="app-sidebar-user-trigger rc-sidebar-user-trigger">
<Avatar className="app-sidebar-user-avatar rc-sidebar-avatar">
{userInfo?.avatar ? <Avatar.Image alt={user.displayName} src={userInfo.avatar} /> : null}
<Avatar.Fallback>{user.initials}</Avatar.Fallback>
</Avatar>
<span className="app-sidebar-user-copy rc-sidebar-user-copy" data-sidebar="label">
<span className="app-sidebar-user-name">{user.displayName}</span>
<span className="app-sidebar-user-description">{user.username}</span>
</span>
</Dropdown.Trigger>
<Dropdown.Popover placement="top start">
<Dropdown.Menu aria-label="用户菜单" onAction={(key) => { if (key === 'logout') onLogout?.(); }}>
<Dropdown.Item id="logout" textValue="退出登录" variant="danger">
<div className="rc-sidebar-user-menu-item">
<Label>退出登录</Label>
<LogOut size={14} />
</div>
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown.Popover>
</Dropdown>
);
}
function SidebarContents({ menuGroups, onLogout, onOpenApplicationSwitcher, pathname, userInfo }) {
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} />
</div>
<div className="app-sidebar-brand-main rc-sidebar-brand-main" data-sidebar="label">
<span className="app-sidebar-title rc-sidebar-title">{APP_DISPLAY_NAME}</span>
</div>
<div className="app-sidebar-app-switcher rc-sidebar-app-switcher" data-sidebar="label">
<Tooltip delay={0}>
<Button
isIconOnly
aria-label="切换应用"
className="app-sidebar-app-trigger rc-sidebar-app-trigger"
size="sm"
variant="ghost"
onPress={onOpenApplicationSwitcher}
>
<AppGrid size={16} />
</Button>
<Tooltip.Content>切换应用</Tooltip.Content>
</Tooltip>
</div>
</div>
</Sidebar.Header>
<Sidebar.Content>
{menuGroups.map((group) => (
<Sidebar.Group key={group.title}>
{group.showLabel !== false ? <Sidebar.GroupLabel>{group.title}</Sidebar.GroupLabel> : null}
<Sidebar.Menu aria-label={group.title}>
{group.items.map((item) => {
const Icon = item.icon;
const isCurrent = pathname === item.path || pathname.startsWith(`${item.path}/`);
return (
<Sidebar.MenuItem
href={item.path}
isCurrent={isCurrent}
key={item.path}
textValue={item.name}
tooltip={item.name}
>
<Sidebar.MenuIcon><Icon size={16} /></Sidebar.MenuIcon>
<Sidebar.MenuLabel>{item.name}</Sidebar.MenuLabel>
</Sidebar.MenuItem>
);
})}
</Sidebar.Menu>
</Sidebar.Group>
))}
</Sidebar.Content>
<Sidebar.Footer><SidebarUserMenu userInfo={userInfo} onLogout={onLogout} /></Sidebar.Footer>
</>
);
}
export default function DashboardSidebar(props) {
return (
<>
<Sidebar className="app-sidebar-glass rc-sidebar"><SidebarContents {...props} /></Sidebar>
<Sidebar.Mobile className="app-sidebar-glass rc-mobile-sidebar">
<h2 slot="title" className="sr-only">{APP_DISPLAY_NAME}导航</h2>
<SidebarContents {...props} />
</Sidebar.Mobile>
</>
);
}
This diff is collapsed.
...@@ -15,7 +15,7 @@ export default function FileUploadField({ ...@@ -15,7 +15,7 @@ export default function FileUploadField({
const emitFiles = useCallback( const emitFiles = useCallback(
(files) => { (files) => {
if (!isDisabled) { if (!isDisabled) {
onFilesChange?.(files); onFilesChange?.(Array.from(files || []));
} }
}, },
[isDisabled, onFilesChange], [isDisabled, onFilesChange],
...@@ -35,7 +35,7 @@ export default function FileUploadField({ ...@@ -35,7 +35,7 @@ export default function FileUploadField({
); );
return ( return (
<DropZone className={`rc-file-upload ${className}`.trim()}> <DropZone className={className}>
<DropZone.Area <DropZone.Area
aria-label={ariaLabel} aria-label={ariaLabel}
isDisabled={isDisabled} isDisabled={isDisabled}
...@@ -53,7 +53,10 @@ export default function FileUploadField({ ...@@ -53,7 +53,10 @@ export default function FileUploadField({
<DropZone.Description> <DropZone.Description>
{fileName ? '已选择文件,可重新选择进行替换。' : description} {fileName ? '已选择文件,可重新选择进行替换。' : description}
</DropZone.Description> </DropZone.Description>
<DropZone.Trigger isDisabled={isDisabled}>{triggerLabel}</DropZone.Trigger> <DropZone.Trigger isDisabled={isDisabled}>
<Upload aria-hidden="true" />
{triggerLabel}
</DropZone.Trigger>
</DropZone.Area> </DropZone.Area>
</DropZone> </DropZone>
); );
......
import { Label, ListBox, Select } from '@heroui/react'; import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import clsx from 'clsx';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { ChevronDown } from './AppIcons';
const EMPTY_OPTION_KEY = '__rc_filter_empty__'; const EMPTY_OPTION_KEY = '__rc_filter_empty__';
...@@ -24,83 +22,44 @@ function normalizeOption(option) { ...@@ -24,83 +22,44 @@ function normalizeOption(option) {
export default function FilterSelect({ export default function FilterSelect({
'aria-label': ariaLabel, 'aria-label': ariaLabel,
className, className,
isDisabled = false, disabled = false,
emptyText = '暂无可选项', emptyText = '暂无可选项',
onChange, onChange,
options = [], options = [],
placeholder = '请选择', placeholder = '请选择',
value = '', value = '',
variant,
}) { }) {
const normalizedOptions = useMemo( const normalizedOptions = useMemo(
() => options.map(normalizeOption), () =>
options.map(normalizeOption).map((option) => ({
...option,
id: option.value || EMPTY_OPTION_KEY,
isDisabled: option.disabled,
})),
[options], [options],
); );
const selectedValue = String(value ?? ''); const selectedValue = String(value ?? '');
const selectedOption = normalizedOptions.find( const selectedOption = normalizedOptions.find(
(option) => option.value === selectedValue, (option) => option.value === selectedValue,
); );
const hasValue = selectedValue !== '';
const displayLabel = selectedOption?.label || (hasValue ? selectedValue : placeholder);
const selectedKey = selectedValue === '' ? EMPTY_OPTION_KEY : selectedValue; const selectedKey = selectedValue === '' ? EMPTY_OPTION_KEY : selectedValue;
return ( return (
<Select <XinyuanAutocomplete
aria-label={ariaLabel || placeholder} aria-label={ariaLabel || placeholder}
className={clsx('rc-filter-select', className)} className={className}
fullWidth isDisabled={disabled}
isDisabled={isDisabled} emptyText={emptyText}
options={normalizedOptions}
placeholder={placeholder}
selectedKey={selectedKey} selectedKey={selectedKey}
variant="secondary" variant={variant}
onSelectionChange={(key) => { onSelectionChange={(key) => {
const nextValue = key === EMPTY_OPTION_KEY ? '' : String(key ?? ''); const nextValue = key === EMPTY_OPTION_KEY ? '' : String(key ?? '');
onChange?.(nextValue); onChange?.(nextValue);
}} }}
> valueText={selectedOption?.label || placeholder}
<Select.Trigger className="rc-filter-select__trigger" data-has-value={hasValue ? 'true' : undefined}> />
<Select.Value className="rc-filter-select__value">
<span
className={clsx(
'block min-w-0 truncate',
!hasValue && 'rc-filter-select__placeholder',
)}
title={displayLabel}
>
{displayLabel}
</span>
</Select.Value>
<ChevronDown className="rc-filter-select__indicator" />
</Select.Trigger>
<Select.Popover
className="rc-filter-select__popover"
placement="bottom start"
>
<ListBox
aria-label={ariaLabel || placeholder}
className="rc-filter-select__listbox"
>
{normalizedOptions.length > 0 ? (
normalizedOptions.map((option) => (
<ListBox.Item
key={option.value || EMPTY_OPTION_KEY}
id={option.value || EMPTY_OPTION_KEY}
className="rc-filter-select__option"
isDisabled={option.disabled}
textValue={option.label}
>
<Label
className="rc-filter-select__option-label"
title={option.label}
>
{option.label}
</Label>
<ListBox.ItemIndicator className="rc-filter-select__option-indicator" />
</ListBox.Item>
))
) : (
<div className="rc-filter-select__empty">{emptyText}</div>
)}
</ListBox>
</Select.Popover>
</Select>
); );
} }
import { Back, Check, Clock, Close, Delete, Edit, Eye, More, Send } from '@/components/AppIcons'; import {
import { Button, Dropdown, Tooltip } from '@heroui/react'; Back,
Check,
Clock,
Close,
Delete,
Edit,
Eye,
More,
Send,
} from '@/components/AppIcons';
import {
Button,
Dropdown,
Label,
Tooltip,
buttonVariants,
} from '@heroui/react';
import clsx from 'clsx'; import clsx from 'clsx';
import { Children, Fragment, isValidElement } from 'react'; import { Children, Fragment, isValidElement } from 'react';
const ACTION_TONE_CLASSES = { const ACTION_ICON_CLASS =
approve: 'pm-table-action--approve', 'grid size-4 shrink-0 place-items-center [line-height:0] [&>svg]:!m-0 [&>svg]:block [&>svg]:size-4';
delete: 'pm-table-action--danger', const ACTION_BUTTON_LAYOUT_CLASS = 'grid place-items-center rounded-full p-0';
edit: 'pm-table-action--default', const IMPORTANT_ACTION_TONES = new Set(['approve', 'submit']);
history: 'pm-table-action--default',
reject: 'pm-table-action--danger', function getActionVariant(tone) {
revoke: 'pm-table-action--warning', if (['delete', 'reject'].includes(tone)) return 'danger-soft';
submit: 'pm-table-action--primary', if (IMPORTANT_ACTION_TONES.has(tone)) return 'secondary';
view: 'pm-table-action--default', return 'tertiary';
}; }
function resolveActionTone({ icon, tone }) {
if (tone) {
return tone;
}
const iconTone = Object.entries(TABLE_ACTION_ICONS).find(
([, ActionIcon]) => ActionIcon === icon,
)?.[0];
if (iconTone) {
return iconTone;
}
return 'view';
}
export const TABLE_ACTION_ICONS = { export const TABLE_ACTION_ICONS = {
approve: Check, approve: Check,
...@@ -28,7 +60,9 @@ export const TABLE_ACTION_ICONS = { ...@@ -28,7 +60,9 @@ export const TABLE_ACTION_ICONS = {
function flattenActions(children) { function flattenActions(children) {
const actions = []; const actions = [];
Children.forEach(children, (child) => { Children.forEach(children, (child) => {
if (!child) return; if (!child) {
return;
}
if (isValidElement(child) && child.type === Fragment) { if (isValidElement(child) && child.type === Fragment) {
actions.push(...flattenActions(child.props.children)); actions.push(...flattenActions(child.props.children));
return; return;
...@@ -38,29 +72,92 @@ function flattenActions(children) { ...@@ -38,29 +72,92 @@ function flattenActions(children) {
return actions; return actions;
} }
const ACTION_PRIORITY = {
查看详情: 10,
查看延期申请: 20,
锁定: 30,
解锁: 30,
复制: 40,
删除: 90,
};
function sortTableActions(actions) {
return actions
.map((action, index) => ({ action, index }))
.sort((left, right) => {
const leftPriority = ACTION_PRIORITY[left.action?.props?.label] ?? 50;
const rightPriority = ACTION_PRIORITY[right.action?.props?.label] ?? 50;
return leftPriority - rightPriority || left.index - right.index;
})
.map(({ action }) => action);
}
export function TableActionGroup({ className, children, ...props }) { export function TableActionGroup({ className, children, ...props }) {
const actions = flattenActions(children); const actions = sortTableActions(flattenActions(children));
const hasOverflow = actions.length >= 4; const shouldCollapseOverflow = actions.length >= 4;
const visibleActions = hasOverflow ? actions.slice(0, 2) : actions; const visibleActions = shouldCollapseOverflow ? actions.slice(0, 2) : actions;
const overflowActions = hasOverflow ? actions.slice(2) : []; const overflowActions = shouldCollapseOverflow ? actions.slice(2) : [];
return ( return (
<div {...props} className={clsx('icon-actions flex flex-nowrap items-center gap-1.5 whitespace-nowrap', className)}> <div
{...props}
className={clsx(
'icon-actions flex flex-nowrap items-center justify-start gap-2 whitespace-nowrap',
className,
)}
>
{visibleActions} {visibleActions}
{overflowActions.length > 0 ? ( {overflowActions.length > 0 ? (
<Dropdown> <Dropdown>
<Tooltip delay={0}> <Dropdown.Trigger
<Dropdown.Trigger aria-label="更多操作" className="pm-table-action pm-table-action--default"><More size={16} /></Dropdown.Trigger> aria-label="更多操作"
<Tooltip.Content>更多操作</Tooltip.Content> className={clsx(
</Tooltip> buttonVariants({
<Dropdown.Popover className="rc-dropdown-popover min-w-36" placement="bottom end"> isIconOnly: true,
<Dropdown.Menu aria-label="更多操作" onAction={(key) => overflowActions[Number(key)]?.props?.onPress?.()}> size: 'sm',
variant: 'tertiary',
}),
ACTION_BUTTON_LAYOUT_CLASS,
'pm-table-action',
)}
title="更多操作"
>
<More className={ACTION_ICON_CLASS} size={16} aria-hidden="true" />
</Dropdown.Trigger>
<Dropdown.Popover placement="bottom end">
<Dropdown.Menu
aria-label="更多操作"
onAction={(key) => overflowActions[Number(key)]?.props?.onPress?.()}
>
{overflowActions.map((action, index) => { {overflowActions.map((action, index) => {
const label = action?.props?.label || '操作 ' + (index + 3); const label = action?.props?.label;
if (!label) {
return null;
}
const Icon = action?.props?.icon; const Icon = action?.props?.icon;
const actionTone = resolveActionTone({
icon: Icon,
tone: action?.props?.tone,
});
const isDanger = ['delete', 'reject'].includes(actionTone);
return ( return (
<Dropdown.Item id={String(index)} isDisabled={action?.props?.isDisabled} key={label + '-' + index} className="rc-dropdown-option" textValue={label}> <Dropdown.Item
<span className="flex items-center gap-2">{Icon ? <Icon size={16} aria-hidden="true" /> : null}<span>{label}</span></span> id={String(index)}
isDisabled={action?.props?.isDisabled}
key={`${label}-${index}`}
className={isDanger ? 'text-danger' : undefined}
textValue={label}
>
<span className="flex items-center gap-2">
{Icon ? (
<Icon
className={ACTION_ICON_CLASS}
size={16}
aria-hidden="true"
/>
) : null}
<Label>{label}</Label>
</span>
</Dropdown.Item> </Dropdown.Item>
); );
})} })}
...@@ -72,19 +169,44 @@ export function TableActionGroup({ className, children, ...props }) { ...@@ -72,19 +169,44 @@ export function TableActionGroup({ className, children, ...props }) {
); );
} }
export function TableActionButton({ className, icon: Icon, isDisabled = false, label, onPress, tone = 'view' }) { export function TableActionButton({
className,
isDisabled,
icon: Icon,
label,
onPress,
tone,
...props
}) {
const resolvedTone = resolveActionTone({
icon: Icon,
tone,
});
return ( return (
<Tooltip delay={0}> <Tooltip delay={0}>
<Button <Button
{...props}
isIconOnly isIconOnly
aria-label={label} aria-label={label}
className={clsx('pm-table-action', ACTION_TONE_CLASSES[tone] || ACTION_TONE_CLASSES.view, className)} title={label}
isDisabled={isDisabled} isDisabled={isDisabled}
size="sm" size="sm"
variant="ghost" variant={getActionVariant(resolvedTone)}
onPress={onPress} onPress={onPress}
className={clsx(
ACTION_BUTTON_LAYOUT_CLASS,
'pm-table-action',
IMPORTANT_ACTION_TONES.has(resolvedTone) && 'pm-table-action--important',
['delete', 'reject'].includes(resolvedTone) && 'pm-table-action--danger',
className,
)}
> >
<Icon size={16} /> <Icon
className={ACTION_ICON_CLASS}
size={16}
aria-hidden="true"
/>
</Button> </Button>
<Tooltip.Content>{label}</Tooltip.Content> <Tooltip.Content>{label}</Tooltip.Content>
</Tooltip> </Tooltip>
......
import {
ChevronDown,
Close,
FIELD_SUFFIX_ICON_STYLE,
Search,
} from '@/components/AppIcons';
import {
Autocomplete,
Label,
ListBox,
SearchField,
Spinner,
Tag,
TagGroup,
} from '@heroui/react';
function defaultFilter(textValue, inputValue) {
const query = String(inputValue || '').trim().toLocaleLowerCase();
return !query || String(textValue || '').toLocaleLowerCase().includes(query);
}
function getSelectedKeySet(selectionMode, selectedKey, selectedKeys) {
if (selectionMode === 'multiple') {
return new Set(selectedKeys || []);
}
return new Set(selectedKey === null || selectedKey === undefined ? [] : [selectedKey]);
}
export default function XinyuanAutocomplete({
'aria-label': ariaLabel,
className,
emptyText = '暂无可选项',
errorText = '',
filter,
fullWidth = true,
inputValue,
isDisabled = false,
isLoading = false,
isOpen,
isRequired = false,
name,
onInputChange,
onOpenChange,
onSelectionChange,
options = [],
placeholder = '请选择',
placement = 'bottom start',
popoverHeader,
renderOption,
renderValue,
searchPlaceholder = '输入关键词搜索',
selectedKey,
selectedKeys,
selectionMode = 'single',
shouldFilter = true,
valueText,
variant,
}) {
const selectedKeySet = getSelectedKeySet(
selectionMode,
selectedKey,
selectedKeys,
);
const selectedOptions = options.filter((option) =>
selectedKeySet.has(option.id),
);
const resolvedValueText =
valueText ??
(selectedOptions.length > 0
? selectedOptions.map((option) => option.label).join('、')
: placeholder);
const rootSelectionProps =
selectionMode === 'multiple'
? { selectedKeys }
: { selectedKey };
const visibleOptions = isLoading || errorText ? [] : options;
const defaultValue = selectionMode === 'multiple' && selectedOptions.length > 0 ? (
<TagGroup
aria-label="已选择项"
className="min-w-0"
selectionMode="multiple"
selectedKeys={selectedKeySet}
size="sm"
onRemove={(keys) => {
const removedKeys = new Set(Array.from(keys).map(String));
onSelectionChange?.(
new Set(Array.from(selectedKeySet).filter(
(key) => !removedKeys.has(String(key)),
)),
);
}}
onSelectionChange={() => {}}
>
<TagGroup.List className="min-w-0 flex-wrap" items={selectedOptions}>
{(option) => (
<Tag id={option.id} textValue={option.label}>
{option.label}
<Tag.RemoveButton
aria-label={`移除${option.label}`}
onClick={(event) => event.stopPropagation()}
/>
</Tag>
)}
</TagGroup.List>
</TagGroup>
) : (
<span
className="block min-w-0 truncate"
title={resolvedValueText}
>
{resolvedValueText}
</span>
);
return (
<Autocomplete
{...rootSelectionProps}
aria-label={ariaLabel || placeholder}
className={className}
fullWidth={fullWidth}
isDisabled={isDisabled}
isOpen={isOpen}
isRequired={isRequired}
name={name}
selectionMode={selectionMode}
variant={variant}
onOpenChange={onOpenChange}
onSelectionChange={onSelectionChange}
>
<Autocomplete.Trigger>
<Autocomplete.Value className="min-w-0">
{renderValue ? renderValue(selectedOptions) : defaultValue}
</Autocomplete.Value>
<Autocomplete.Indicator>
<ChevronDown size={16} style={FIELD_SUFFIX_ICON_STYLE} />
</Autocomplete.Indicator>
</Autocomplete.Trigger>
<Autocomplete.Popover placement={placement}>
<Autocomplete.Filter
filter={shouldFilter ? filter || defaultFilter : () => true}
inputValue={inputValue}
onInputChange={onInputChange}
>
{popoverHeader}
<SearchField
aria-label={searchPlaceholder}
fullWidth
variant={variant}
>
<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>
<ListBox
aria-label={ariaLabel || placeholder}
items={visibleOptions}
renderEmptyState={() => (
<div
className="grid min-h-20 place-items-center px-3 py-4"
data-slot="empty-state"
>
{isLoading ? <Spinner size="sm" /> : errorText || emptyText}
</div>
)}
>
{(option) => (
<ListBox.Item
id={option.id}
isDisabled={option.isDisabled}
textValue={option.textValue || option.label}
>
{renderOption ? (
renderOption(option)
) : (
<Label className="truncate" title={option.label}>
{option.label}
</Label>
)}
<ListBox.ItemIndicator />
</ListBox.Item>
)}
</ListBox>
</Autocomplete.Filter>
</Autocomplete.Popover>
</Autocomplete>
);
}
export const APP_DISPLAY_NAME = '监察稽核管理'; export const APP_DISPLAY_NAME = '监察稽核管理';
export const APP_ROUTE_NAME = 'audit_manager_new'; export const APP_ROUTE_NAME = 'audit_manager_new';
export const APP_BASE_PATH = '/audit_manager_new/';
export const APP_LOGIN_SUBTITLE = '内部控制与报告归档'; export const APP_LOGIN_SUBTITLE = '内部控制与报告归档';
export const THEME_STORAGE_KEY = 'xinyuan_color_theme';
export const APP_NAV_ITEMS = Object.freeze([ export const APP_NAV_ITEMS = Object.freeze([
{ label: '稽核项目', path: '/audit_process', icon: 'project' }, { label: '稽核项目', path: '/audit_process', icon: 'project' },
......
This diff is collapsed.
/*
* HeroUI Theme Customization
* Add this to your global.css after importing @heroui/styles
* Only includes variables users need to customize
* @see https://heroui.com/docs/react/getting-started/theming
*/
:root,
.light,
.default,
[data-theme="light"],
[data-theme="default"] {
/* Theme Colors (Light Mode) */
--accent: oklch(45.32% 0.1661 27.03);
--accent-foreground: oklch(99.11% 0 0);
--background: oklch(97.02% 0.0045 27.03);
--border: oklch(90.00% 0.0045 27.03);
--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);
--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);
/* Border Radius */
--radius: 0.5rem;
--field-radius: 0.75rem;
/* Font Family */
/* Make sure to load Inter font in your app */
--font-sans: var(--font-inter);
}
.dark,
[data-theme="dark"] {
color-scheme: dark;
/* Theme Colors (Dark Mode) */
--accent: oklch(45.32% 0.1661 27.03);
--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);
--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);
--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);
}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
import { Back } from '@/components/AppIcons'; import { Back, CheckCircleIcon, ClipboardDocumentListIcon, Project } from '@/components/AppIcons';
import { KPI } from '@heroui-pro/react/kpi';
import { Button, Chip, Tooltip } from '@heroui/react'; import { Button, Chip, Tooltip } from '@heroui/react';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
...@@ -46,29 +47,40 @@ export default function AuditReportDetail() { ...@@ -46,29 +47,40 @@ export default function AuditReportDetail() {
if (error || !report) return <ErrorState message={error || '报告不存在'} onRetry={loadData} />; if (error || !report) return <ErrorState message={error || '报告不存在'} onRetry={loadData} />;
const stats = getReportStats(report); const stats = getReportStats(report);
const summaryItems = [
['全部', stats.total, Project, 'teal'],
['未填写', stats.empty, ClipboardDocumentListIcon, 'danger'],
['已完成', stats.completed, CheckCircleIcon, 'success'],
];
return ( return (
<div className="audit-page audit-report-detail"> <div className="rc-page-stack">
<div className="audit-detail-header"> <div className="rc-page-toolbar">
<div className="audit-detail-heading"> <div className="flex min-w-0 items-center gap-3">
<Tooltip delay={0}><Button className="audit-back-button" isIconOnly aria-label="返回历史报告" size="sm" variant="ghost" onPress={() => history.back()}><Back size={16} /></Button><Tooltip.Content>返回</Tooltip.Content></Tooltip> <Tooltip delay={0}><Button isIconOnly aria-label="返回历史报告" size="sm" variant="tertiary" onPress={() => history.back()}><Back size={16} /></Button><Tooltip.Content>返回</Tooltip.Content></Tooltip>
<div> <div>
<div className="audit-detail-title-row"> <div className="flex flex-wrap items-center gap-2">
<h2>{report.title}</h2> <h2>{report.title}</h2>
<Chip className="rc-status-chip--success" size="sm">已归档</Chip> <Chip color="success" size="sm" variant="soft">已归档</Chip>
</div> </div>
<p> <p>
提交时间:{report.metadata?.submitted_time ? dayjs(report.metadata.submitted_time).format('YYYY-MM-DD HH:mm') : '-'} 提交时间:{report.metadata?.submitted_time ? dayjs(report.metadata.submitted_time).format('YYYY-MM-DD HH:mm') : '-'}
</p> </p>
</div> </div>
</div> </div>
<Button size="sm" variant="outline" onPress={() => exportAuditReport(report)}>导出</Button> <Button size="sm" variant="secondary" onPress={() => exportAuditReport(report)}>导出</Button>
</div> </div>
<div className="audit-detail-summary"> <div className="rc-kpi-grid" data-count="3">
<span>全部 <strong>{stats.total}</strong></span> {summaryItems.map(([label, value, Icon, tone]) => (
<span>未填写 <strong>{stats.empty}</strong></span> <KPI className="rc-card-surface rc-dashboard-card" key={label}>
<span>已完成 <strong className="is-success">{stats.completed}</strong></span> <KPI.Header><KPI.Title>{label}</KPI.Title></KPI.Header>
<KPI.Content>
<KPI.Value value={Number(value) || 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>
))}
</div> </div>
<AuditReportContent report={report} mode="readonly" issueOptions={issueOptions} /> <AuditReportContent report={report} mode="readonly" issueOptions={issueOptions} />
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
...@@ -6,6 +6,7 @@ import { ...@@ -6,6 +6,7 @@ import {
loadSubject, loadSubject,
queryRecords, queryRecords,
queryTags, queryTags,
removeRecord,
updateRecord, updateRecord,
userLogin, userLogin,
} from '@/services/DataService'; } from '@/services/DataService';
...@@ -111,6 +112,10 @@ export async function createAuditProcess({ title, order, description }) { ...@@ -111,6 +112,10 @@ export async function createAuditProcess({ title, order, description }) {
}); });
} }
export async function deleteAuditProcess(processRecord) {
return removeRecord(normalizeRecordId(processRecord) || processRecord?.name);
}
export async function saveAuditControlPoints(processRecord, controlPoints) { export async function saveAuditControlPoints(processRecord, controlPoints) {
return updateRecord(normalizeRecordId(processRecord) || processRecord?.name, { return updateRecord(normalizeRecordId(processRecord) || processRecord?.name, {
metadata: { metadata: {
......
This diff is collapsed.
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