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 子路径导入。
- 禁止在 `catalyst-ui-kit`、旧 Button/Input/Checkbox/Dialog/Table/Select/DatePicker/Pagination/Upload 内嵌 HeroUI。
- 禁止继续扩展或修改 `src/catalyst-ui-kit`。新功能不得引用旧 UI 组件。
- 禁止创建复刻旧 props、DOM event、class hook 或 DOM 结构的兼容包装层。
- 一个功能面只能使用一套 UI 交互模型。允许的共享组件必须是业务中立、HeroUI-native 的复合组件,并暴露当前 HeroUI selection/value/event 合同。
- 每个项目独立拥有 `node_modules` 和锁文件,不依赖相邻仓库。
- 当前正式安装结果:Pro `1.0.0-beta.8`、HeroUI/Styles `3.2.4`、React Aria Components `1.20.0`、React/DOM `19.2.8`。
- 当前仅保留 `pnpm-lock.yaml`;旧 `package-lock.json` 因包含相邻项目 `file:` 解析记录已删除。
- 根项目不得出现 `file:../project_manager_new`、绝对 `/Users/...`、相邻 `node_modules`、vendor 包或软链接交付。
- 页面和共享 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
页面/controller(请求、权限、状态、业务回调)
-> HeroUI/HeroUI Pro 官方组件
-> 少量 HeroUI-native 业务中立复合组件(设计、布局、无障碍)
-> 鑫元 Token 与项目语义样式
页面 controller:请求、权限、校验、业务状态与回调
-> HeroUI/HeroUI Pro 官方 compound API
-> 业务无关的 HeroUI-native compositions
-> 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 异常。
- 用户指定的 `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` 前,不得提交为跨机器安装方案,也不得宣称依赖交付完成。
多值引用 composition 只在页面边界把 HeroUI 的 key 集合映射为原有 `_id[]`。禁止保存完整引用对象、维护第二套内部选择状态,或在 Autocomplete 外部渲染已选 Chip 列表。
## 7. 代码审查门槛
## 完成定义
每个迁移模块都要扫描 Catalyst/Headless/旧本地 UI import,真实浏览器检查打开态和控制台。活动范围旧引用必须为零;非活动旧模块单独列出。构建和浏览器验收通过可以确认本机运行质量,但不能覆盖当前依赖树和团队包源的可复现风险。
活动路由旧 UI 引用与私有视觉 CSS 为零;依赖树单一;所有 Pro 子路径可用;构建、diff check、技能审计脚本通过;双应用四视口双主题的打开态和 console 均有证据。任何一项未完成时不声明迁移完成。当前代码与登录页门禁已通过,但登录后浏览器证据因缺少有效真实登录态尚未完成。
# UI 迁移清单
状态说明:`已迁移`表示活动运行时入口已经直接使用 HeroUI/HeroUI Pro;浏览器状态只记录真实数据和真实业务条件下实际执行过的交互,不用伪造数据补齐场景。
| 页面/模块 | 路由 | 迁移结果 | 保留的业务回调 | 状态 | 浏览器验收 |
| --- | --- | --- | --- | --- | --- |
| 登录 | `/login` | HeroUI Card/TextField/Input/Button/Alert | captcha、AES 加密、userLogin、redirect | 已迁移 | 通过:登录、跳转、统一 Logo/标题、输入状态和响应式 |
| AppShell | 活动鉴权路由 | HeroUI Pro Sidebar/Navbar + HeroUI Dropdown/Tooltip | history 路由、主题持久化 | 已迁移 | 通过:桌面完全收起/展开、移动 offcanvas、浅色/暗色 |
| 左侧用户/退出 | 活动鉴权路由 | HeroUI Dropdown compound API | userLogout、clearUser、base-aware 登录跳转 | 已迁移 | 通过:菜单打开、退出并回到登录页 |
| 应用切换 | 全局 | HeroUI Modal compound API | getApplications、URL 导航 | 已迁移 | 通过:打开/关闭、当前项唯一、浅色/暗色 Portal |
| 稽核项目 | `/audit_process` | HeroUI 表单/Modal/Tabs + HeroUI Pro DataGrid | 查询、筛选、增删改、引用选择 | 已迁移 | 通过:真实数据、Tab、实时筛选、新增/编辑/删除弹窗、枚举和远程引用关闭态回显/打开态搜索 |
| 稽核报告 | `/audit_report` | HeroUI Tabs/Modal + HeroUI Pro DataGrid | 当前/历史查询、填报链接、提交、删除 | 已迁移 | 通过:当前/历史 Tab、填报链接、历史详情跳转;提交按钮因 8 个控制点未完成而禁用,未绕过业务限制 |
| 报告内容 | 活动报告与历史详情复用 | HeroUI 表单 + HeroUI Pro DataGrid/DropZone | update、confirm point/process | 已迁移 | 通过:真实内容表、横向滚动、固定列、暗色表面;未提交真实变更 |
| 历史详情 | `/audit_report/:reportId` | HeroUI Button/Chip + HeroUI Pro DataGrid | 加载、返回、导出 | 已迁移 | 通过:真实历史报告加载和多组明细表 |
| 外部填报 | `/audit_report_fill` | HeroUI 表单/AlertDialog + HeroUI Pro DataGrid/DropZone | ticket 鉴权、保存、重置、beforeunload | 已迁移 | 通过:真实填报链接、DropZone、Select 打开态、根主题、标题和移动端;未选择文件,避免立即调用真实上传接口 |
| PageState | 多路由复用 | HeroUI Spinner/Button/Alert | retry | 已迁移 | 通过:活动页面加载/空状态可见,控制台无异常 |
## 浏览器验收记录
- 视口:`1440x900`、`1280x720`、`768x1024`、`390x844`。
- Shell:Sidebar 展开后 240px;桌面收起后移出视口且主内容扩展到完整宽度;移动 offcanvas 铺满面板。
- 响应式:四个视口均无页面级横向滚动;Tabs 和 DataGrid 在自己的容器内滚动。
- 表格:稽核项目、当前报告、历史详情和外部填报均使用真实记录验收;固定列和操作列在宽表内部滚动时保持可见。
- 表单与弹层:新增/编辑 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 原生架构迁移,不能直接重新挂载。
状态日期:2026-08-21。`完成` 仅表示代码、业务保护、双应用浏览器对照和扫描门槛全部通过。
| 模块 | 路由/入口 | 代码迁移 | 浏览器对照 | 业务保护 |
| --- | --- | --- | --- | --- |
| HeroUI 安装/依赖 | 根项目 | 完成 | 子路径完成 | 不适用 |
| 原始业务基线 | `/login` | 未改页面启动完成 | 1440x900 已记录 | 完成 |
| Login | `/login` | 完成 | 四视口双主题及 filled 完成 | 完成 |
| AppShell/Navbar/Sidebar | 鉴权路由 | 完成 | 缺有效登录态 | 代码复核完成 |
| 用户菜单/退出 | Sidebar footer | 完成 | 缺有效登录态 | 回调保留 |
| 应用切换 | Shell Modal | 完成 | 缺有效登录态 | 数据/导航保留 |
| 全应用弹窗尺寸 | HeroUI Modal / AlertDialog | 完成 | 待有效登录态复验 | md 480 / lg 640 / 3xl 960 / 应用切换 736 |
| 稽核项目 | `/audit_process` | 完成 | 缺有效登录态 | 代码复核完成 |
| 多选字段 | 稽核项目表单 | 完成 | 缺有效登录态/打开态复验 | 框内 Tag;`_id[]` 与请求时机保留 |
| 列表查看操作 | 全部活动 DataGrid | 完成 | 缺有效登录态/打开态复验 | Eye 首位;原权限和回调保留 |
| 稽核报告 | `/audit_report` | 完成 | 缺有效登录态 | 代码复核完成 |
| 报告项目二级 Tabs | `/audit_report`、详情、填报 | 完成 | 缺有效登录态/真实报告 | 单项目可见;无重复标题/进度;索引与回调保留 |
| 报告附件单元格 | 报告控制点 DataGrid | 完成 | 缺有效登录态/真实附件 | HeroUI Chip/Link;无嵌套 Card |
| 历史详情 | `/audit_report/:reportId` | 完成 | 缺有效登录态/真实记录 | 代码复核完成 |
| 外部填报 | `/audit_report_fill` | 完成 | 缺真实 ticket/report | 代码复核完成 |
| KPI/图表 | 报告 KPI;无图表 | KPI 完成/图表不适用 | 缺有效登录态 | 不制造数据 |
## 最终门槛
- [x] 活动路由 Catalyst/Headless/Heroicons 引用为零。
- [x] `npm run build` 通过(Webpack 编译成功)。
- [x] `git diff --check` 通过。
- [x] 本地 Pro CSS 与六个 JS 子路径全部可解析/导入。
- [x] 指定 HeroUI/React Aria `npm ls` 命令通过并显示一套版本。
- [ ] 包含 React/Umi 的完整依赖树可跨机器复现。当前本地镜像、Umi peer 声明和嵌套 React 18 仍不满足此项。
- [x] 鑫元审计脚本完成最终重跑;非活动旧代码剩余引用已单独报告。
- [x] 指定 `hpsetup` 命令在目标根目录成功。
- [x] Pro `1.0.0-beta.8` 和要求子路径(含 KPI)通过验证。
- [x] 根依赖无跨项目 `file:`、绝对 `/Users/...` 或相邻 `node_modules`。
- [x] 原始应用启动、登录结构、console 和截图已记录。
- [x] 权威 theme.css 与技能资产一致。
- [x] 活动路由 Catalyst/Headless/旧 UI 引用为零。
- [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 @@
"start": "npm run dev"
},
"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",
"@heroicons/react": "^2.2.0",
"@heroui-pro/react": "file:../project_manager_new/node_modules/@heroui-pro/react",
"@heroui/react": "file:../project_manager_new/node_modules/@heroui/react",
"@heroui/styles": "file:../project_manager_new/node_modules/@heroui/styles",
"@heroui-pro/react": "1.0.0-beta.8",
"@heroui/react": "^3.2.4",
"@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",
"@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",
"clsx": "^2.1.1",
"crypto-js": "^4.2.0",
"dayjs": "^1.11.20",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.6",
"motion": "file:../project_manager_new/node_modules/motion",
"react": "file:../project_manager_new/node_modules/react",
"react-aria-components": "file:../project_manager_new/node_modules/react-aria-components",
"react-dom": "file:../project_manager_new/node_modules/react-dom",
"tailwind-merge": "file:../project_manager_new/node_modules/tailwind-merge",
"tailwind-variants": "file:../project_manager_new/node_modules/tailwind-variants",
"embla-carousel": "^8.6.0",
"embla-carousel-react": "^8.6.0",
"maplibre-gl": "^6.4.1",
"marked": "^18.0.10",
"motion": "^12.43.0",
"react": "^19.2.8",
"react-aria-components": "^1.20.0",
"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",
"umi-request": "^1.4.0",
"umi-request-progress": "^1.1.0",
......
This diff is collapsed.
......@@ -3,3 +3,5 @@ allowBuilds:
core-js-pure: false
es5-ext: false
esbuild: false
"@heroui-pro/react": true
"@zowe/secrets-for-zowe-sdk": true
......@@ -8,6 +8,7 @@ import DownloadOutlined from '@ant-design/icons/DownloadOutlined';
import DownOutlined from '@ant-design/icons/DownOutlined';
import ClockCircleOutlined from '@ant-design/icons/ClockCircleOutlined';
import CloseOutlined from '@ant-design/icons/CloseOutlined';
import CopyOutlined from '@ant-design/icons/CopyOutlined';
import DeleteOutlined from '@ant-design/icons/DeleteOutlined';
import DollarOutlined from '@ant-design/icons/DollarOutlined';
import DatabaseOutlined from '@ant-design/icons/DatabaseOutlined';
......@@ -16,9 +17,11 @@ import EyeOutlined from '@ant-design/icons/EyeOutlined';
import ExportOutlined from '@ant-design/icons/ExportOutlined';
import FileTextOutlined from '@ant-design/icons/FileTextOutlined';
import FolderOpenOutlined from '@ant-design/icons/FolderOpenOutlined';
import GroupOutlined from '@ant-design/icons/GroupOutlined';
import PaperClipOutlined from '@ant-design/icons/PaperClipOutlined';
import LeftOutlined from '@ant-design/icons/LeftOutlined';
import LogoutOutlined from '@ant-design/icons/LogoutOutlined';
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';
......@@ -36,12 +39,17 @@ 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';
import UnlockOutlined from '@ant-design/icons/UnlockOutlined';
import WarningOutlined from '@ant-design/icons/WarningOutlined';
function iconStyle(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) {
return function AppIcon({ size, style, ...props }) {
return (
......@@ -60,6 +68,7 @@ export const ChevronDown = withSize(DownOutlined);
export const ChevronRight = withSize(RightOutlined);
export const Clock = withSize(ClockCircleOutlined);
export const Close = withSize(CloseOutlined);
export const Copy = withSize(CopyOutlined);
export const Delete = withSize(DeleteOutlined);
export const Dollar = withSize(DollarOutlined);
export const Database = withSize(DatabaseOutlined);
......@@ -69,7 +78,9 @@ export const OpenExternal = withSize(ExportOutlined);
export const FileText = withSize(FileTextOutlined);
export const Info = withSize(InfoCircleOutlined);
export const Folder = withSize(FolderOpenOutlined);
export const Group = withSize(GroupOutlined);
export const LogOut = withSize(LogoutOutlined);
export const Lock = withSize(LockOutlined);
export function SidebarPanel({ size = 20, style, ...props }) {
return (
<span
......@@ -107,6 +118,7 @@ export const Sun = withSize(SunOutlined);
export const Team = withSize(TeamOutlined);
export const User = withSize(UserOutlined);
export const Upload = withSize(UploadOutlined);
export const Unlock = withSize(UnlockOutlined);
export const Back = withSize(LeftOutlined);
export const ArrowDownTrayIcon = withSize(DownloadOutlined);
......@@ -120,10 +132,13 @@ export const CheckCircleIcon = withSize(CheckCircleOutlined);
export const ChevronDownIcon = ChevronDown;
export const ChevronRightIcon = ChevronRight;
export const ClipboardDocumentListIcon = withSize(OrderedListOutlined);
export const DocumentDuplicateIcon = Copy;
export const ClockIcon = Clock;
export const ExclamationTriangleIcon = withSize(WarningOutlined);
export const FolderOpenIcon = Folder;
export const InformationCircleIcon = Info;
export const LockClosedIcon = Lock;
export const LockOpenIcon = Unlock;
export const MagnifyingGlassIcon = Search;
export const PaperClipIcon = withSize(PaperClipOutlined);
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({
const emitFiles = useCallback(
(files) => {
if (!isDisabled) {
onFilesChange?.(files);
onFilesChange?.(Array.from(files || []));
}
},
[isDisabled, onFilesChange],
......@@ -35,7 +35,7 @@ export default function FileUploadField({
);
return (
<DropZone className={`rc-file-upload ${className}`.trim()}>
<DropZone className={className}>
<DropZone.Area
aria-label={ariaLabel}
isDisabled={isDisabled}
......@@ -53,7 +53,10 @@ export default function FileUploadField({
<DropZone.Description>
{fileName ? '已选择文件,可重新选择进行替换。' : description}
</DropZone.Description>
<DropZone.Trigger isDisabled={isDisabled}>{triggerLabel}</DropZone.Trigger>
<DropZone.Trigger isDisabled={isDisabled}>
<Upload aria-hidden="true" />
{triggerLabel}
</DropZone.Trigger>
</DropZone.Area>
</DropZone>
);
......
import { Label, ListBox, Select } from '@heroui/react';
import clsx from 'clsx';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import { useMemo } from 'react';
import { ChevronDown } from './AppIcons';
const EMPTY_OPTION_KEY = '__rc_filter_empty__';
......@@ -24,83 +22,44 @@ function normalizeOption(option) {
export default function FilterSelect({
'aria-label': ariaLabel,
className,
isDisabled = false,
disabled = false,
emptyText = '暂无可选项',
onChange,
options = [],
placeholder = '请选择',
value = '',
variant,
}) {
const normalizedOptions = useMemo(
() => options.map(normalizeOption),
() =>
options.map(normalizeOption).map((option) => ({
...option,
id: option.value || EMPTY_OPTION_KEY,
isDisabled: option.disabled,
})),
[options],
);
const selectedValue = String(value ?? '');
const selectedOption = normalizedOptions.find(
(option) => option.value === selectedValue,
);
const hasValue = selectedValue !== '';
const displayLabel = selectedOption?.label || (hasValue ? selectedValue : placeholder);
const selectedKey = selectedValue === '' ? EMPTY_OPTION_KEY : selectedValue;
return (
<Select
<XinyuanAutocomplete
aria-label={ariaLabel || placeholder}
className={clsx('rc-filter-select', className)}
fullWidth
isDisabled={isDisabled}
className={className}
isDisabled={disabled}
emptyText={emptyText}
options={normalizedOptions}
placeholder={placeholder}
selectedKey={selectedKey}
variant="secondary"
variant={variant}
onSelectionChange={(key) => {
const nextValue = key === EMPTY_OPTION_KEY ? '' : String(key ?? '');
onChange?.(nextValue);
}}
>
<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>
valueText={selectedOption?.label || placeholder}
/>
);
}
import { Back, Check, Clock, Close, Delete, Edit, Eye, More, Send } from '@/components/AppIcons';
import { Button, Dropdown, Tooltip } from '@heroui/react';
import {
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 { Children, Fragment, isValidElement } from 'react';
const ACTION_TONE_CLASSES = {
approve: 'pm-table-action--approve',
delete: 'pm-table-action--danger',
edit: 'pm-table-action--default',
history: 'pm-table-action--default',
reject: 'pm-table-action--danger',
revoke: 'pm-table-action--warning',
submit: 'pm-table-action--primary',
view: 'pm-table-action--default',
};
const ACTION_ICON_CLASS =
'grid size-4 shrink-0 place-items-center [line-height:0] [&>svg]:!m-0 [&>svg]:block [&>svg]:size-4';
const ACTION_BUTTON_LAYOUT_CLASS = 'grid place-items-center rounded-full p-0';
const IMPORTANT_ACTION_TONES = new Set(['approve', 'submit']);
function getActionVariant(tone) {
if (['delete', 'reject'].includes(tone)) return 'danger-soft';
if (IMPORTANT_ACTION_TONES.has(tone)) return 'secondary';
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 = {
approve: Check,
......@@ -28,7 +60,9 @@ export const TABLE_ACTION_ICONS = {
function flattenActions(children) {
const actions = [];
Children.forEach(children, (child) => {
if (!child) return;
if (!child) {
return;
}
if (isValidElement(child) && child.type === Fragment) {
actions.push(...flattenActions(child.props.children));
return;
......@@ -38,29 +72,92 @@ function flattenActions(children) {
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 }) {
const actions = flattenActions(children);
const hasOverflow = actions.length >= 4;
const visibleActions = hasOverflow ? actions.slice(0, 2) : actions;
const overflowActions = hasOverflow ? actions.slice(2) : [];
const actions = sortTableActions(flattenActions(children));
const shouldCollapseOverflow = actions.length >= 4;
const visibleActions = shouldCollapseOverflow ? actions.slice(0, 2) : actions;
const overflowActions = shouldCollapseOverflow ? actions.slice(2) : [];
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}
{overflowActions.length > 0 ? (
<Dropdown>
<Tooltip delay={0}>
<Dropdown.Trigger aria-label="更多操作" className="pm-table-action pm-table-action--default"><More size={16} /></Dropdown.Trigger>
<Tooltip.Content>更多操作</Tooltip.Content>
</Tooltip>
<Dropdown.Popover className="rc-dropdown-popover min-w-36" placement="bottom end">
<Dropdown.Menu aria-label="更多操作" onAction={(key) => overflowActions[Number(key)]?.props?.onPress?.()}>
<Dropdown.Trigger
aria-label="更多操作"
className={clsx(
buttonVariants({
isIconOnly: true,
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) => {
const label = action?.props?.label || '操作 ' + (index + 3);
const label = action?.props?.label;
if (!label) {
return null;
}
const Icon = action?.props?.icon;
const actionTone = resolveActionTone({
icon: Icon,
tone: action?.props?.tone,
});
const isDanger = ['delete', 'reject'].includes(actionTone);
return (
<Dropdown.Item id={String(index)} isDisabled={action?.props?.isDisabled} key={label + '-' + index} className="rc-dropdown-option" textValue={label}>
<span className="flex items-center gap-2">{Icon ? <Icon size={16} aria-hidden="true" /> : null}<span>{label}</span></span>
<Dropdown.Item
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>
);
})}
......@@ -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 (
<Tooltip delay={0}>
<Button
{...props}
isIconOnly
aria-label={label}
className={clsx('pm-table-action', ACTION_TONE_CLASSES[tone] || ACTION_TONE_CLASSES.view, className)}
title={label}
isDisabled={isDisabled}
size="sm"
variant="ghost"
variant={getActionVariant(resolvedTone)}
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>
<Tooltip.Content>{label}</Tooltip.Content>
</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_ROUTE_NAME = 'audit_manager_new';
export const APP_BASE_PATH = '/audit_manager_new/';
export const APP_LOGIN_SUBTITLE = '内部控制与报告归档';
export const THEME_STORAGE_KEY = 'xinyuan_color_theme';
export const APP_NAV_ITEMS = Object.freeze([
{ label: '稽核项目', path: '/audit_process', icon: 'project' },
......
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 dayjs from 'dayjs';
import { useCallback, useEffect, useState } from 'react';
......@@ -46,29 +47,40 @@ export default function AuditReportDetail() {
if (error || !report) return <ErrorState message={error || '报告不存在'} onRetry={loadData} />;
const stats = getReportStats(report);
const summaryItems = [
['全部', stats.total, Project, 'teal'],
['未填写', stats.empty, ClipboardDocumentListIcon, 'danger'],
['已完成', stats.completed, CheckCircleIcon, 'success'],
];
return (
<div className="audit-page audit-report-detail">
<div className="audit-detail-header">
<div className="audit-detail-heading">
<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>
<div className="rc-page-stack">
<div className="rc-page-toolbar">
<div className="flex min-w-0 items-center gap-3">
<Tooltip delay={0}><Button isIconOnly aria-label="返回历史报告" size="sm" variant="tertiary" onPress={() => history.back()}><Back size={16} /></Button><Tooltip.Content>返回</Tooltip.Content></Tooltip>
<div>
<div className="audit-detail-title-row">
<div className="flex flex-wrap items-center gap-2">
<h2>{report.title}</h2>
<Chip className="rc-status-chip--success" size="sm">已归档</Chip>
<Chip color="success" size="sm" variant="soft">已归档</Chip>
</div>
<p>
提交时间:{report.metadata?.submitted_time ? dayjs(report.metadata.submitted_time).format('YYYY-MM-DD HH:mm') : '-'}
</p>
</div>
</div>
<Button size="sm" variant="outline" onPress={() => exportAuditReport(report)}>导出</Button>
<Button size="sm" variant="secondary" onPress={() => exportAuditReport(report)}>导出</Button>
</div>
<div className="audit-detail-summary">
<span>全部 <strong>{stats.total}</strong></span>
<span>未填写 <strong>{stats.empty}</strong></span>
<span>已完成 <strong className="is-success">{stats.completed}</strong></span>
<div className="rc-kpi-grid" data-count="3">
{summaryItems.map(([label, value, Icon, tone]) => (
<KPI className="rc-card-surface rc-dashboard-card" key={label}>
<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>
<AuditReportContent report={report} mode="readonly" issueOptions={issueOptions} />
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
......@@ -6,6 +6,7 @@ import {
loadSubject,
queryRecords,
queryTags,
removeRecord,
updateRecord,
userLogin,
} from '@/services/DataService';
......@@ -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) {
return updateRecord(normalizeRecordId(processRecord) || processRecord?.name, {
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