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 引用。不能把这些目录误报为已迁移。
# 监察稽核管理鑫元设计规范
本文件是后续新增页面与功能的强制 UI 合同,视觉基线为鑫元设计系统 1.2。
## 1. 品牌与基础
- 应用显示名统一为“监察稽核管理”,用于登录、Sidebar、Navbar、应用切换当前项和浏览器标题。
- 主色为 `#9E1B1B`。选中、焦点、Checkbox、日期、Pagination 和信息状态通过 Token 使用品牌红。
- 浅色 Logo 使用 `src/assets/identity/xy-logo-red.png`,暗色使用 `xy-logo-white.png`;不使用滤镜、反色、色块或 RSM 标识。
- 样式顺序为 Tailwind、HeroUI、HeroUI Pro、鑫元 Token、项目语义样式。
- 字距固定为 0,不使用渐变、营销式大标题、装饰性多卡片或随视口缩放的字号。
## 2. 尺寸与层级
- 画布:浅色 `#F5F5F5`,暗色 `#18181B`;表面分别使用语义 surface Token。
- 页面左右 inset:桌面 68px、移动 72px;页面级横向溢出隐藏,宽表只在 DataGrid 内滚动。
- 工作台/KPI 模块 24px 圆角,DataGrid 20px,控件 8px,Popover 12px,Modal 24px。
- 页面标题 20/600/28;模块标题 16/600;正文 14/400;字距均为 0。
- DataGrid 表头 12/500,正文 14/400,主字段 14/550,次字段 13/400,Chip 12/500。
- 常规按钮桌面高 32px、触屏 36px、24px 圆角、14/500;主次按钮同尺寸,主操作最右。
## 3. 登录、Shell 与应用切换
- 登录使用居中认证 Card;36px 花形 Logo 与约 32/600 系统标题横向排列,并作为一个品牌组在 Card 内容区水平居中;表单标签和控件仍保持左对齐,不重复“登录”标题。
- 账号、密码、验证码可见高度统一 40px;字段组可见间距 24px,提交按钮上方 24px。
- filled/autofill 使用不透明浅红 `#fbf1f1`,Hover `#faeaea`,Focus 用品牌红;必须覆盖 Chrome autofill inset fill。
- HeroUI Pro Sidebar 宽 240px,`collapsible="offcanvas"`,收起后宽度为 0,不保留图标轨道。
- Sidebar 底部整行用户入口使用 HeroUI Dropdown compound API,显示头像、姓名、登录名,菜单内提供现有业务退出。
- “退出登录”统一使用 HeroUI `Dropdown.Item variant="danger"`:文字与退出图标在默认、Hover、Focus 状态均使用 `var(--danger)`,反馈背景使用主题语义表面,不得在不同应用间混用黑色与红色。
- Navbar 高 64px、透明无边框;Sidebar.Trigger 使用 Pro 默认 filled 图形。全局主题切换在最右并持久化。
- 应用切换使用居中 HeroUI Modal:约 46rem 宽、42rem 最大高、三列紧凑应用卡;最多一个“当前”,无统计、权限或装饰信息。
## 4. 页面、筛选和 Tabs
- Navbar 是唯一可见页面标题;内容区仅保留无障碍标题。
- 标准列表顺序:页面级 Tabs、筛选 Card、透明列表标题/操作行、DataGrid、Pagination。
- 实时筛选首行最多三个字段,不显示查询按钮;右侧使用刷新 icon button 和 text-only 展开/收起。
- Input、Select/ListBox、远程引用、DatePicker 的关闭态和打开态统一为 8px 控件、单边框、单阴影。
- 选择控件仅在空值时显示占位符。已有值时关闭态必须显示实际名称;远程名称尚在加载时显示“已选择 N 项”,不得退回搜索占位符。已选值使用主文本色与 500 字重,打开后再进入搜索状态。
- 主数据集切换使用 HeroUI Tabs;只有一个可访问视图时隐藏轨道。模块局部模式使用紧凑 segmented control。
- 普通业务按钮为纯文本;icon-only 只用于真实图标操作,必须有 Tooltip 与 `aria-label`。
## 5. DataGrid 与分页
- 所有业务列表使用 HeroUI Pro DataGrid 原生排序、选择、固定列和空状态;不手写 Checkbox 列。
- 表格始终填满内容宽度。仅在真实列最小宽度超过 viewport 时固定首个业务列和操作列并内部横向滚动。
- 操作列仅在当前可见数据至少有一行能够渲染真实操作时展示;无可用操作时隐藏整列并回收宽度,不使用空白单元格或 `-` 占位。
- 操作按钮为 32px 正圆、16px Ant Design icon。1-3 个直接显示,4 个以上显示两个高频项,其余进入更多菜单。
- 多选后在当前可视区底部显示 HeroUI Pro ActionBar,清除选择是最右 icon-only 关闭操作。
- Pagination 提供 10/20/50,切换页容量回到第一页并保留筛选、排序、权限范围。
- 空状态只显示一次“暂无数据”,表头和列宽在 loading/empty 时不能跳动。
## 6. 表单、上传、弹窗和反馈
- 页面直接使用 HeroUI TextField/Input/TextArea/Checkbox/Select/DatePicker compound API;禁止可见原生 select/date/file 控件。
- Checkbox 16px、4px 圆角;选中/半选的背景、边框和内填充均为 `#9E1B1B`,指标为白色。
- 上传使用 HeroUI Pro DropZone,保留 accept、disabled、单/多文件和原业务回调。
- Modal 所有视口居中,24px 圆角;标准 padding 28px、确认 24px,遮罩约 50% 黑和 12px blur。
- Footer 桌面右对齐,取消在前、主操作最右;移动端全宽反向堆叠。最终删除确认才使用 solid danger。
- 成功保存/删除/导入/导出用顶部居中 3-4 秒 Toast,不使用推挤页面布局的 Alert。
## 7. KPI 与图表
- KPI 网格:移动 1 列、中屏 2 列;桌面 4 张 4 列、5 张 3+2、6 张 3+3、8 张 4+4。
- KPI 使用不透明表面、24px 圆角、细边框和轻阴影,不使用每卡异色背景。
- 图标面 48px、16px 圆角、20px Ant icon,容器与 SVG 中心偏差不超过 0.5px。
- 同一 KPI 组的图标按业务语义使用不同 Token 前景/浅色表面,不能全部使用品牌红;卡片主体仍保持统一中性表面。监察稽核报告固定映射为:全部控制点 teal、未填写 danger、待确认 warning、已完成 success。
- ECharts 从 Token 读取系列、文字、网格、图例、Tooltip 和暗色表面;不修改业务统计口径。
## 8. 暗色、响应式与验收
- 根节点统一控制 light/dark,所有 Dropdown、Popover、Tooltip、Modal、DatePicker、DropZone、ActionBar 与图表 Portal 继承主题。
- 页面不得出现 white island、文字遮挡、页面级横向滚动或移动端按钮溢出。
- 必测视口:1440x900、1280x720、768x1024、390x844。
- 每个模块必须在真实浏览器验证打开态、键盘焦点、业务回调和控制台;构建成功不能替代页面验收。
# 监察稽核管理鑫元设计系统
唯一实现基准为“问题整改”应用。以下合同适用于活动路由及后续新增页面,不允许页面私有外观覆盖 HeroUI/HeroUI Pro。
## 主题与品牌
- 应用显示名统一为“监察稽核管理”。
- 浅色/暗色 Logo 分别使用 `src/assets/identity/xy-logo-red.png` 与 `xy-logo-white.png`。
- 权威 `src/design-system/theme.css` 必须与技能资产逐字节一致;加载顺序为 Tailwind、HeroUI、HeroUI Pro、权威主题、鑫元 Tokens。
- 品牌主色 `#9E1B1B` 由主题 Token 提供。页面禁止硬编码品牌/表面/状态颜色、渐变、阴影和组件状态。
- Surface 层级固定为 L0 background、L1 surface、L2 surface-secondary、稀有 L3 surface-tertiary;Portal 使用 overlay,不把 Card 当 Surface。
## Shell
- HeroUI Pro Sidebar 宽 240px,`collapsible="offcanvas"`,关闭后宽度为零且无图标轨道。
- HeroUI Pro Navbar 高 64px;`Sidebar.Trigger` 使用 Pro 原生图形。
- Navbar 与页面内容采用对称 48px/32px/16px gutter;主题操作在最右。
- 用户行由 `Dropdown.Trigger` 打开真实菜单,退出调用目标应用现有服务。
- 应用切换使用 HeroUI Modal,桌面宽约 46rem、高不超过 42rem,一列移动/两列桌面;最多一个“当前”。
## 登录
- 居中认证 Card,桌面约 28rem;36px Logo 与应用名居中,不重复“登录”标题。
- 账号、密码、验证码控件高度一致;label 到控件 12px,字段组真实可见间距 24px,提交按钮上方 24px。
- 验证码刷新为 HeroUI Button,保留后端 captcha 图片/token 行为。
- 空字段保持 HeroUI 默认;filled/autofill 使用批准的浅红表面并覆盖 Chrome autofill。
## 页面与控件
- 页面 className 仅负责 flex/grid/宽度/间距/对齐/响应式。
- Button/Input/TextArea/Checkbox/Autocomplete/DatePicker/Tabs/Modal/Pagination 直接使用 HeroUI;Sidebar/Navbar/DataGrid/DropZone/ActionBar/EmptyState/KPI/Charts 使用 Pro 官方 compound API。
- 普通业务 Button 纯文本;真正的 icon-only 操作使用 Ant Design icon、Tooltip 和 accessible name。
- 三个及以上结构化筛选使用 `Surface variant="default"`,内部控件 `secondary`;前 3 项在首行,其余用 Disclosure。
- 所有可见单选/远程引用使用 Autocomplete;不使用 native select、可见 date/file 控件或 OS popup。
- 枚举多选和远程引用多选统一使用 Autocomplete 的 `selectionMode="multiple"`、`selectedKeys` 与 `onSelectionChange`。已选项必须在 `Autocomplete.Value` 内使用 HeroUI `TagGroup > TagGroup.List > Tag + Tag.RemoveButton` 展示,Tag 在触发框内换行并支持键盘访问和逐项移除;禁止在选择框下方或外部另起 Chip/Badge/Tag 列表。
- 多选触发框、选项选中态、Tag 与移除动作必须由同一个受控数组派生。引用字段查询时机不变,移除和提交仍只传 `_id[]`,不得传完整对象。单选字段继续显示普通 `Autocomplete.Value` 文本;状态 Chip、只读 DataGrid 标签和文件列表不套用多选字段规则。
- 页面级 Tabs 的根节点、ListContainer 和 List 必须按自身文本内容收缩,不得使用 `flex-1`、`w-full` 或剩余空间填充轨道;同行业务操作通过外层布局靠右。只有一个可选视图时隐藏轨道。
- 同一业务视图内存在结构与操作一致的并列数据集时,使用第二级 HeroUI Tabs 切换并一次只展示当前数据集,不顺序堆叠重复表头。监察稽核报告固定采用“进行中/历史报告”一级 Tabs 和“稽核项目”二级 Tabs;两级轨道均为内容宽度,不使用等分或整行拉伸样式。
- 二级项目 Tabs 只控制可见快照,不改变整体 KPI、保存、提交、权限或数据索引;列表搜索作用于当前项目,Tabs 保持完整稳定,切换项目后继续使用该项目原始 `processIndex / pointIndex` 回调。
- 项目名称已经出现在二级 Tabs 时,当前项目列表区不重复项目标题或完成数;整体完成数由页面 KPI 承担。二级 Tabs 后使用 16px 间距进入列表工具栏,再以 16px 间距进入 DataGrid。
- 报告项目工具栏使用 HeroUI SearchField:默认宽 384px、移动端全宽并独占一行、placeholder 统一为“请输入关键词”;项目确认和保存操作在右侧,禁止把搜索留在项目 Tabs 上方。
## 列表、弹窗和反馈
- 透明列表区顺序:数据集标题、搜索/列分组/业务工具栏、DataGrid、Pagination。
- DataGrid 使用原生 selection、sorting、fixed columns、horizontal scrolling、EmptyState;选择动作使用可视区浮动 ActionBar。
- 业务列表存在“操作”列时,查看 Eye 图标必须作为首个常驻操作,使用 `TableActionButton` 的 `tertiary`/`view` 语义和 Tooltip;编辑、审批、删除按权限与原业务条件排列其后。只有真实详情路由或只读 HeroUI Modal 可以承接查看操作,禁止空按钮和无反馈图标。
- DataGrid 附件单元格禁止嵌套 Card、Surface 或自制背景块。附件分组使用紧凑无框行:分组名为普通文本,空值用 HeroUI `Chip size="sm"`,已有地址用 HeroUI `Link + Link.Icon`,无地址的文件名用 Chip;编辑态的 Pro DropZone 位于对应分组下一行。只允许 flex、换行、间距和截断布局,保持同行数据可比较且避免单个附件撑高整行。
- Pagination 固定业务默认每页 10 条,不显示页容量选择器。
- 查看/表单 Modal 使用 4xl 适用宽度;详情查看态用单一 L2 Surface,编辑态控件直接在 Overlay 上使用 secondary。
- 最终删除确认使用 AlertDialog/Modal + solid danger;例行成功提示使用浮动 Toast。
## KPI 与图表
- 业务存在数值 KPI 时必须使用 Pro KPI compound API;模块 24px radius、16px grid gap、业务图标和唯一 tone。
- 业务存在图表时使用 Pro PieChart、BarChart、ComposedChart 与 ChartTooltipContent,并遵循共享 chart Tokens。
- 稽核报告的现有业务统计使用 Pro KPI compound API;活动路由没有图表,不新增演示数字或伪造业务数据。
## 验收
每个活动模块与“问题整改”同时打开,在 1440x900、1280x720、768x1024、390x844 的浅色/暗色模式比较。必须覆盖登录、Sidebar、Navbar、退出菜单、应用切换、Tabs、筛选、打开态 popup、DataGrid、Pagination、ActionBar、Modal、Tooltip 和 console。
当前登录页已完成上述四视口双主题与 filled 状态对照。登录后页面必须在获得有效真实登录态后补齐,不允许使用伪造 token 或业务数据绕过验收。
# 监察稽核管理 UI 全局审计
# 监察稽核管理 UI 审计
审计日期:2026-08-14
审计分支:`codex/ui-ux-xinyuan`
审计基线:鑫元设计系统 1.2、HeroUI 3、HeroUI Pro 1.0.0-beta.7
审计日期:2026-08-21
当前分支:`codex/ui-ux-xinyuan`
唯一实现基准:`/Users/liuchenlong/Desktop/job/problem_rectification`
> 本文记录 UI 修改前的全局事实。迁移只替换展示与交互组件,不修改接口、请求参数、权限、状态机、表单提交、统计口径或路由。
本次只迁移 UI、交互表现和前端组件结构。API、参数、Record/metadata 数据结构、路由、权限、校验、分页口径、保存回调、登录退出协议、请求时机和业务状态流均属于保护范围。
## 1. 框架与构建
## 安装硬门槛
- Umi Max 4,构建命令 `npm run build`,开发命令 `npm run dev`。
- React 应用,Tailwind CSS 4,项目样式入口为 `src/tailwind.css` 与 `src/global.less`。
- `base` 与 `publicPath` 均为 `/audit_manager_new/`;登录、401/403 与应用内跳转必须保持 base-aware。
- 代理保持不变:`/api` 指向 `https://xy.streams7.com/api`,`/parser` 指向 `https://xy.streams7.com`。
- 当前活动路由:
- 实际目录:`/Users/liuchenlong/Desktop/job/audit_manager_new`
- 实际命令:`pnpm dlx hpsetup@latest <license>`,2026-08-20 成功;授权值未写入项目、文档或环境文件。
- HeroUI Pro:`1.0.0-beta.8`,解析到目标项目 `node_modules/.pnpm`,不是相邻项目链接。
- 运行时:`@heroui/react 3.2.4`、`@heroui/styles 3.2.4`、`react-aria-components 1.20.0`、`react/react-dom 19.2.8`。
- 已验证 `css`、`sidebar`、`navbar`、`data-grid`、`drop-zone`、`action-bar`、`empty-state`、`kpi` 导出。
- 安装修改 `package.json`、`pnpm-lock.yaml`、`pnpm-workspace.yaml`。历史跨项目 `file:` 依赖已从根依赖和锁文件移除。
- 已删除包含相邻项目 `file:` 解析记录的过期 `package-lock.json`;当前仅保留 `pnpm-lock.yaml`。
| 页面 | 路由 | 鉴权 |
| --- | --- | --- |
| 登录 | `/login` | Site |
| 稽核项目 | `/audit_process` | Site + Auth |
| 稽核报告 | `/audit_report` | Site + Auth |
| 历史报告详情 | `/audit_report/:reportId` | Site + Auth |
| 外部填报 | `/audit_report_fill` | Site + ticket |
## 2. 登录、鉴权与退出
- 登录调用 `userLogin`,请求体字段为 `identity`、加密后的 `password`、`captcha`、验证码 `token` 与 `type: password`。
- 登录成功后由 `authService` 保存 `current_user` 与 `auth_token`,并跳回查询参数 `redirect` 或 `/`。
- `AuthWrapper` 使用 `getUserInfo` 刷新用户资料;失败时清理本地登录信息。
- 退出调用现有 `userLogout(username)`,无论接口结果如何均清理本地登录信息并跳转登录页。
- 审计发现的 Shell 问题:左侧用户行虽然显示用户信息,但 Dropdown 触发结构未按 HeroUI v3 的 `Dropdown.Trigger` 复合结构实现;退出菜单可能不能稳定打开。退出跳转当前硬编码 `/login`,必须改为保留 Umi base 的应用路由行为,但不能改变退出 API 或清理逻辑。
## 3. 页面、弹窗与业务入口
| 功能面 | 主要入口 | 业务保护点 |
| --- | --- | --- |
| 稽核项目 | 项目切换、搜索、刷新、新增/编辑/删除控制点 | 项目查询、控制点增删改、负责人引用、分页/筛选条件、确认回调不变 |
| 稽核报告 | 当前报告、历史报告、导入、生成、提交、删除 | 报告快照、状态流转、上传解析、保存与提交顺序不变 |
| 报告内容 | 文本、附件、异常、备注、确认控制点/流程 | 字段值结构、附件数组、owner 限制、确认条件不变 |
| 历史详情 | 加载、导出、返回 | reportId、统计口径和导出回调不变 |
| 外部填报 | ticket 鉴权、保存、重置、导出 | report/owner/ticket 参数、草稿合并、并发读取和未保存提醒不变 |
| 应用切换 | 应用查询、当前应用识别、导航 | 应用响应归一化、可访问列表和目标 URL 不变 |
活动页面包含创建/编辑、删除确认、提交确认、重置确认和详情内容。未发现活动路由中的驾驶舱图表;依赖中保留 ECharts,图表主题合同仍写入设计规范供后续功能使用。
## 4. 旧组件与引用关系
活动范围的硬性违规:
- `AuditProcess.jsx`:Catalyst Alert、Badge、Button、Dialog、Field、Input、Select、Table、Textarea。
- `AuditReport.jsx`:Catalyst Alert、Badge、Button、Dialog、Input、Table。
- `AuditReportContent.jsx`:Catalyst Badge、Button、Select、Textarea,并通过旧式共享 API 使用表格/上传/行操作。
- `AuditReportDetail.jsx`:Catalyst Badge、Button。
- `AuditReportFill.jsx`:Catalyst Alert、Button。
- `PageState.jsx`:Catalyst Button。
- `ApplicationSwitcherDialog.jsx`:Headless UI Dialog。
- `DataTable.jsx`:内部虽使用 HeroUI Pro DataGrid,但对页面暴露旧 `Table/TableHead/TableRow/TableCell` 兼容 API,不符合目标架构。
- `TableActionButton.jsx`、`FileUploadField.jsx` 等需要确认只暴露 HeroUI 当前事件与选择模型,不能复刻旧 props/event API。
`src/catalyst-ui-kit` 是旧组件库目录。本次禁止进入、扩展或把 HeroUI 嵌入其中;已被先前兼容尝试修改的文件必须恢复为仓库原状。
未进入 `.umirc.ts` 的存量代码仍引用 Catalyst/Headless UI:`src/pages/process_monitor`、`src/pages/RecordDetailDrawer`、`src/components/record-workbench`。它们不在本次运行时验收范围,不能误报为已迁移;最终扫描必须单独列出剩余数量。若后续恢复这些路由,必须先按同一架构迁移。
## 原始运行状态
## 5. 控件、表格与 Portal
- Umi Max 4,Tailwind CSS 4;`base/publicPath` 均为 `/audit_manager_new/`。
- 原始页面未改动时启动地址:`http://localhost:8001/audit_manager_new/`。
- 登录页标题“监察稽核管理”,字段为账号、密码、验证码;1440x900 控制台 0 error / 0 warning。
- 原始截图:`.playwright-cli/original-login-1440x900.png`。
- 代理保持 `/api -> https://xy.streams7.com/api`、`/parser -> https://xy.streams7.com`。
- Button、Input、Checkbox、Select、Textarea、Dialog、Table 的活动页面来源主要是 Catalyst。
- 活动页面存在旧 `<option>` Select 结构;需要直接替换为 HeroUI `Select + ListBox` compound API。
- 文件上传业务通过本地 FileUploadField 传递旧 DOM event,需要改为 HeroUI Pro DropZone 的 `File[]`/选择回调边界,页面保留上传函数与 accept/disabled 规则。
- 旧表格包含页面级列定义、手写分页和旧选择语义;目标必须使用 HeroUI Pro DataGrid 原生 `selectedKeys`、`onSelectionChange`、排序、固定列、Pagination 与 ActionBar。
- Application switcher 使用 Headless Portal;其他 Dropdown/Popover/Tooltip/Modal 需验证 body Portal 能继承根节点 `.dark` 与 `data-theme`。
- 当前应用没有活动图表页面;后续 ECharts 必须使用 Token 驱动的 Tooltip、图例、网格与暗色表面。
## 活动路由
## 6. 依赖解析审计
当前本机解析:
| 包 | 版本 | 来源 |
| 路由 | 页面 | 鉴权/业务入口 |
| --- | --- | --- |
| `@heroui-pro/react` | 1.0.0-beta.7 | sibling `project_manager_new` 本地镜像 |
| `@heroui/react` | 3.2.2 | sibling file dependency |
| `@heroui/styles` | 3.2.2 | sibling file dependency |
| `react-aria-components` | 1.19.0 | sibling file dependency |
| `react` / `react-dom` | 19.2.7 | sibling file dependency |
迁移后,`npm ls @heroui-pro/react @heroui/react @heroui/styles react-aria-components @react-aria/utils` 显示一套 HeroUI/React Aria 版本并通过。加入 `react react-dom` 的完整运行时树仍失败:Umi 依赖链包含 React 18.3.1 及只声明兼容 React 16-18 的 peer,而顶层 HeroUI 3 使用 React 19;本地 `file:` 依赖又解析到相邻项目。浏览器运行时已通过 alias 固定到顶层 React 19,但完整依赖树和跨机器交付仍不干净。
本机镜像仅允许用于启动开发。团队交付必须由私有 npm 或统一授权的版本化 `.tgz` 提供 `@heroui-pro/react@1.0.0-beta.7`;未获得授权制品前,不得把机器相关 `file:../project_manager_new/...` 当作最终安装合同。
## 7. 业务与 UI 耦合风险
- 旧 Select/Input 使用 DOM event;HeroUI 使用值/selection/onChange,需要在页面展示层改签名,不能改服务参数。
- 旧 Table JSX 同时承载列展示和业务按钮;迁移时必须先抽出列配置,保持回调闭包和权限条件。
- 报告附件上传依赖 `event.target.files`;迁移 DropZone 时必须显式传 `File`,不能伪造 DOM event。
- 删除、提交、重置弹窗同时控制异步状态;必须保留 disabled/loading、关闭顺序和错误展示。
- DataGrid 选择、分页、筛选变化会影响批量动作;必须在浏览器中验证受控 selection 的清理时机。
## 8. 审计结论
现状不满足原生 HeroUI 架构门槛,不能通过修改 Catalyst 内部或保留兼容表格接口完成验收。迁移顺序固定为依赖与 Token、Login、Shell/用户退出、应用切换、活动页面表单与 Tabs、DataGrid/Pagination/ActionBar、Modal/详情/上传、暗色与响应式,最后执行旧引用和依赖树扫描。
## 9. 迁移后复核
- 活动路由和本次新建共享组件已经直接使用 `@heroui/react`、`@heroui-pro/react` 或已验证 Pro 子路径;扫描结果没有 Catalyst、Headless UI 或 Heroicons 引用。
- `src/catalyst-ui-kit` 未被本次修改。非活动存量目录 `src/pages/process_monitor`、`src/pages/RecordDetailDrawer`、`src/components/record-workbench` 仍有旧引用,已从“已迁移”声明中排除。
- 登录、退出、鉴权路由、应用切换、稽核项目、当前/历史报告、历史详情和外部填报已用真实账号及真实记录完成浏览器验收。API 地址、参数、权限条件、禁用条件和提交回调没有改动。
- 四个目标视口均无页面级横向滚动;宽表由 DataGrid 自身滚动。浅色/暗色 Portal、Modal、Select/ListBox、DropZone、桌面 Sidebar 收起和移动 offcanvas 已验收。
- 真实数据没有触发 Pagination 或选择批量 ActionBar;当前报告提交因 8 个控制点未完成而禁用;DropZone 选文件会真实上传。上述交互没有通过伪造数据或绕过业务条件补测。
- 本机依赖版本为 Pro `1.0.0-beta.7`、HeroUI/Styles `3.2.2`、React Aria Components `1.19.0`、React/React DOM `19.2.7`。指定 HeroUI/React Aria 树和 HeroUI Pro 子路径可在本机验证;包含 React 的完整树仍受 Umi peer 与嵌套 React 18 影响,团队包源也未确定,因此跨机器可复现性继续是明确风险。
## 10. 选择控件回显复核
- 当前活动路由共有三类选择控件:`ReferenceSearchSelect` 远程引用、`FilterSelect` 枚举选择,以及 `DataTable` 的分页页容量选择。
- `AuditProcess` 中责任部门、相关标签、相关制度和量化指标共用远程引用控件。原实现始终以搜索词控制输入值,导致已选 ID 存在、选项行也带选中标记,但关闭态仍显示搜索占位符。
- 远程引用现已分离关闭态选中摘要与打开态搜索词:名称解析后显示真实名称,解析期间显示明确选择数量;多选标签继续保留原移除回调。
- 枚举 Select 对非空值统一使用主文本色和 500 字重;即使后端返回的值暂未出现在选项定义中,也显示原值而不是错误退回占位符。
- 分页页容量原本已直接显示 `${pageSize} 条/页`,保持现有值协议和视觉层级,不额外改造。
- 浏览器已验证浅色/暗色、桌面/`390x844`:关闭态显示“审计部”“审计、测试”“文件上传”,打开态责任部门保留当前项标记并支持搜索;临时改选后取消可恢复原值,页面与弹窗均无横向溢出。
| `/login` | 登录 | Site、验证码、AES 密码、`userLogin` |
| `/audit_process` | 稽核项目 | Site + Auth、项目与控制点管理 |
| `/audit_report` | 稽核报告 | Site + Auth、当前/历史报告 |
| `/audit_report/:reportId` | 历史报告详情 | Site + Auth、详情与导出 |
| `/audit_report_fill` | 外部填报 | Site、ticket/report/owner 参数 |
`process_monitor`、`RecordDetailDrawer`、`record-workbench` 未进入 `.umirc.ts`,属于非活动存量范围,不计入活动页面完成声明;它们仍有 Catalyst/Headless 引用,恢复路由前必须迁移。
## 业务保护清单
- 登录:保留 `identity/password/captcha/token/type`、AES 加密、登录态存储和 redirect。
- 退出:保留 `userLogout`、本地信息清理和 Umi 登录跳转。
- 稽核项目:保留 `audit_process` 查询、排序、权限、控制点嵌套结构、引用仅存 ID、定义选项来源。
- 稽核报告:保留 current/history 口径、报告快照、控制点完成约束、提交时间和状态流转。
- 外部填报:保留 ticket 校验、草稿合并、附件上传、未保存提醒、保存/重置/导出回调。
- 所有业务接口以 `src/services/record-api.md` 为准;对象定义以 `src/services/subjectsDefinition.md` 为准。
## UI/架构迁移结果
- 登录已同步基准的居中 Card、双主题 Logo、字段节奏、验证码 Button、filled/autofill 与响应式几何。
- 鉴权 Shell 使用共享 `AppShell`、Pro Navbar、Pro Sidebar offcanvas、用户 Dropdown、真实退出回调和应用切换 Modal。
- 应用切换使用基准两列结构、L2 Card 与 L3 Surface + Avatar identity well。
- 活动列表使用 Pro DataGrid、Pagination、ActionBar、EmptyState;选择、排序、固定列及分页业务回调保留。
- 活动 DataGrid 操作列统一将 `tertiary` Eye 查看按钮放在首位:项目控制点与报告控制点打开当前行的只读 HeroUI Modal,历史报告进入原详情路由;编辑、确认、删除权限和回调不变。
- 报告视图“进行中 / 历史报告”Tabs 已移除 `flex-1` 伸展,根节点和 ListContainer 按基准使用内容宽度;同行导出、填报链接和提交操作继续由外层布局靠右。
- 进行中、历史详情和责任部门填报复用的报告内容已将并列项目由纵向重复 DataGrid 整合为第二级 HeroUI Tabs;一次只显示当前项目,一级/二级 Tabs 都按内容宽度收缩,单项目时隐藏二级轨道。
- 项目 Tabs 使用快照记录 ID(无 ID 时回落原索引)维持选择。搜索仅过滤当前项目控制点,责任部门仍只看有其控制点的项目;原 `processIndex / pointIndex`、确认、备注、保存和提交回调未改变。
- 当前项目区已移除与 Tabs/KPI 重复的项目标题和完成数。搜索下移到二级 Tabs 后的标准响应式工具栏,桌面 384px、移动端独占一行;确认和保存沿同一工具栏靠右,相关模块间距统一为 16px。
- 报告附件内容已移除 DataGrid 单元格内逐分组 Card;空附件使用 HeroUI Chip,已有附件使用 HeroUI Link/Chip,编辑上传继续使用 Pro DropZone。原附件数组、上传请求、追加和删除回调保持不变,未新增页面视觉 CSS。
- 活动选择/引用字段使用 HeroUI Autocomplete compound API;上传使用 Pro DropZone。
- 活动路由仅有“相关标签”“相关制度”两个多值引用入口,均经过共享 Autocomplete composition;已选值在触发框内以 HeroUI TagGroup 展示并逐项移除,选择框外无第二套 Chip 列表。单值引用、状态 Chip、DataGrid 摘要和文件列表保持各自语义。
- 活动弹窗使用 HeroUI Modal/AlertDialog;普通辅助按钮使用 `secondary`,删除确认使用 danger。
- 全应用弹窗已统一到鑫元尺寸阶梯:确认弹窗 `md/480px`,单列表单与填报链接 `lg/640px`,控制点双列表单 `3xl/960px`;应用切换保持基准 `46rem/736px`。大尺寸面板通过共享 Token 控制,不再把无效的 HeroUI Container `3xl` 当作官方尺寸。
- 报告页已有真实业务统计改为 Pro KPI compound API;活动路由没有图表,不新增图表或演示数据。
- 全仓库无 ECharts 源码引用,未使用的 `echarts` 与 `echarts-for-react` 根依赖已移除。
- 权威 `theme.css` 逐字节一致;活动页不存在页面级 CSS,私有外观 utility 扫描为零。
## 迁移风险
- HeroUI Pro beta.8 要求 React 19;Umi 的旧间接依赖仍声明较老 React peer,最终以实际构建、浏览器 console 和完整依赖树检查作为门槛。
- DataGrid 列/选择/排序适配必须保持页面现有回调和分页从 0 开始的口径。
- DropZone 必须传真实 `File`,不在共享组件伪造 DOM event。
- 引用控件必须保持已选对象回显和写入 ID/ID[],远程查询时机不变。
- 多值引用的 `selectedKeys`、Tag 和移除动作必须来自同一个 `_id[]`;不得维护第二套选择状态或提交完整对象。
- 无真实登录凭据时不能伪造业务数据或声称已完成登录后浏览器验收。
## 验收证据与剩余边界
- `npm run build` 与 `git diff --check` 通过。
- 2026-08-21 重新打开 `http://localhost:8001/audit_manager_new/`,应用正常跳转真实登录页,当前开发预览 console 为 0 error / 0 warning。
- 目标与“问题整改”登录页在 1440x900、1280x720、768x1024、390x844 的浅色/暗色几何逐项一致,所有视口横向溢出为 0,console 为 0 error / 0 warning。
- 已填写态三个字段均为 40px,目标与基准的 filled Surface 计算值一致,未提交测试登录请求。
- `audit_project.sh` 退出码为 0;其启发式警告与同一脚本扫描“问题整改”的结果基本一致。目标额外审计确认 7 个非活动存量文件仍有 Catalyst/Headless 引用;脚本因 Node 不能直接 `import` CSS 误报 Pro 子路径,独立解析/动态导入检查已通过全部要求子路径。
- 2026-08-17 保存的真实浏览器会话已过期,服务端鉴权校验后清除了登录态。未伪造 token、用户或业务记录,因此 Sidebar、Navbar、退出菜单、应用切换、Tabs、DataGrid、Modal、KPI 等登录后浏览器验收仍未完成,不能声明全量迁移完成。
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -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 source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -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 xyLogo from '@/assets/identity/xy-logo-red.png';
import { Close } from '@/components/AppIcons';
import { APP_DISPLAY_NAME, APP_LOGIN_SUBTITLE, APP_ROUTE_NAME } from '@/constants/app';
import { getApplications } from '@/services/DataService';
import { Modal, Spinner } from '@heroui/react';
import { Close, ExclamationTriangleIcon } from '@/components/AppIcons';
import { EmptyState } from '@heroui-pro/react/empty-state';
import {
Alert,
Avatar,
Card,
Chip,
Description,
Modal,
Skeleton,
Surface,
} from '@heroui/react';
import { useEffect, useMemo, useState } from 'react';
function unwrapApplications(response) {
if (Array.isArray(response)) return response;
return response?.data || response?.applications || response?.result || [];
function normalizeLabelList(items) {
if (!Array.isArray(items)) {
return [];
}
return items
.map((item) => {
if (typeof item === 'string') {
return item;
}
return item?.display_name || item?.title || item?.name || '';
})
.filter(Boolean);
}
function resolveApplicationUrl(application) {
if (application?.name) {
return `/${application.name}`;
}
return '#';
}
function normalizeApplication(application, fallback = {}) {
const name = application?.name || fallback?.name || '';
const current = name === APP_ROUTE_NAME;
return {
name,
title: current ? APP_DISPLAY_NAME : application?.title || fallback?.title || name || '未命名应用',
description: current ? APP_LOGIN_SUBTITLE : application?.description || fallback?.description || application?.title || fallback?.title || name || '应用',
logo: current ? xyLogo : application?.logo || fallback?.logo || null,
url: application?.url || (name ? `/${name}` : '#'),
current,
title: application?.title || fallback?.title || application?.name || fallback?.name || '未命名应用',
description:
application?.description ||
fallback?.description ||
fallback?.title ||
application?.title ||
'进入对应应用继续处理业务',
logo: application?.logo || fallback?.logo || null,
url: resolveApplicationUrl({
name,
}),
access: application?.access || fallback?.access || '',
status: application?.status || fallback?.status || '',
type: application?.type || fallback?.type || '',
order: application?.order ?? fallback?.order ?? 0,
roles: normalizeLabelList(application?.roles?.length ? application.roles : fallback?.roles),
groups: normalizeLabelList(application?.groups?.length ? application.groups : fallback?.groups),
};
}
function mergeApplications(fetched, fallback) {
const fallbackMap = new Map(fallback.map((item) => [item.name, item]));
const merged = new Map();
fetched.forEach((item) => {
const normalized = normalizeApplication(item, fallbackMap.get(item?.name));
if (normalized.name) merged.set(normalized.name, normalized);
function mergeApplications(fetchedApplications, fallbackApplications) {
const fallbackMap = new Map(
fallbackApplications.map((application) => [application.name, application]),
);
const normalizedFetched = fetchedApplications
.map((application) =>
normalizeApplication(application, fallbackMap.get(application?.name)),
)
.filter((application) => application.name);
const mergedMap = new Map(
normalizedFetched.map((application) => [application.name, application]),
);
fallbackApplications.forEach((application) => {
if (!application?.name || mergedMap.has(application.name)) {
return;
}
mergedMap.set(application.name, normalizeApplication(application, application));
});
return Array.from(mergedMap.values()).sort((left, right) => {
const orderDifference = Number(left.order || 0) - Number(right.order || 0);
if (orderDifference !== 0) {
return orderDifference;
}
return left.title.localeCompare(right.title, 'zh-CN');
});
fallback.forEach((item) => { if (item.name && !merged.has(item.name)) merged.set(item.name, item); });
return Array.from(merged.values());
}
function ApplicationCard({ application }) {
const target = application.url?.startsWith('http') ? '_blank' : undefined;
function isCurrentApplication(application) {
if (typeof window === 'undefined') {
return false;
}
const currentPath = window.location.pathname;
const applicationRoot = application?.name ? `/${application.name}` : '';
if (application?.url?.startsWith('http')) {
try {
const url = new URL(application.url);
return url.pathname === currentPath;
} catch (error) {
return false;
}
}
return (
<a className="app-switcher-card group flex min-w-0 items-center gap-3 rounded-xl border border-[var(--rc-border-default)] bg-[var(--rc-bg-subtle)] px-3 py-3 text-left transition hover:bg-[var(--accent-soft)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--focus)]" data-current={application.current ? 'true' : undefined} href={application.url} rel={target ? 'noreferrer' : undefined} target={target}>
{application.logo ? <img alt={application.title} className="app-switcher-card__logo" src={application.logo} /> : <span className="app-switcher-card__initial">{application.title.slice(0, 1)}</span>}
<span className="min-w-0 flex-1"><span className="block truncate text-sm font-medium leading-5 text-[var(--rc-text-primary)]">{application.title}</span><span className="mt-0.5 block truncate text-xs leading-4 text-[var(--rc-text-muted)]">{application.description || application.name || '应用'}</span></span>
{application.current ? <span className="shrink-0 rounded-full bg-[var(--rc-brand-primary-soft)] px-2 py-0.5 text-xs font-medium text-[var(--rc-brand-primary)]">当前</span> : null}
application?.url === currentPath ||
(!!applicationRoot &&
(currentPath === applicationRoot ||
currentPath.startsWith(`${applicationRoot}/`)))
);
}
function ApplicationCard({ application, current }) {
const target = application?.url?.startsWith('http') ? '_blank' : undefined;
const initial = application.title.trim().slice(0, 1).toUpperCase() || 'A';
return (
<Card className="h-full" variant="secondary">
<Card.Content>
<a
href={application.url}
target={target}
rel={target ? 'noreferrer' : undefined}
aria-current={current ? 'page' : undefined}
className="flex min-w-0 items-center gap-3 no-underline"
>
<Surface
aria-hidden="true"
className="grid size-9 shrink-0 place-items-center overflow-hidden rounded-[var(--rc-radius-control)]"
variant="tertiary"
>
<Avatar
className="size-full rounded-[var(--rc-radius-control)] bg-transparent"
color="accent"
variant="soft"
>
{application.logo ? (
<Avatar.Image className="object-contain p-1.5" src={application.logo} alt="" />
) : null}
<Avatar.Fallback>{initial}</Avatar.Fallback>
</Avatar>
</Surface>
<span className="min-w-0 flex-1">
<span className="block break-words font-medium leading-5 text-foreground" title={application.title}>
{application.title}
</span>
<span className="block truncate text-sm text-muted" title={application.description}>
{application.description || application.name || '应用'}
</span>
</span>
{current ? <Chip color="accent" size="sm">当前</Chip> : null}
</a>
</Card.Content>
</Card>
);
}
function LoadingSkeleton() {
return (
<div className="grid gap-3 sm:grid-cols-2" aria-label="正在加载应用">
{Array.from({ length: 6 }).map((_, index) => (
<Card key={index} variant="secondary">
<Card.Content className="flex items-center gap-3">
<Skeleton className="size-9" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-3 w-full" />
</div>
</Card.Content>
</Card>
))}
</div>
);
}
export default function ApplicationSwitcherDialog({ open, onClose, userInfo }) {
const fallback = useMemo(() => (userInfo?.applications || []).map((item) => normalizeApplication(item, item)).filter((item) => item.name), [userInfo]);
const [applications, setApplications] = useState(fallback);
const [loading, setLoading] = useState(false);
export default function ApplicationSwitcherDialog({
open,
onClose,
userInfo,
}) {
const fallbackApplications = useMemo(() => {
if (!Array.isArray(userInfo?.applications)) {
return [];
}
return userInfo.applications
.map((application) =>
normalizeApplication(application, {
...application,
description: application?.title,
}),
)
.filter((application) => application.name);
}, [userInfo]);
const [applications, setApplications] = useState(fallbackApplications);
const [status, setStatus] = useState(
fallbackApplications.length > 0 ? 'success' : 'idle',
);
const [errorMessage, setErrorMessage] = useState('');
const currentApplicationName = useMemo(() => {
return applications.find(isCurrentApplication)?.name || '';
}, [applications]);
useEffect(() => {
if (!open) return undefined;
if (fallbackApplications.length === 0) {
return;
}
setApplications((previous) => {
if (previous.length > 0) {
return previous;
}
return fallbackApplications;
});
}, [fallbackApplications]);
useEffect(() => {
if (!open) {
return;
}
let cancelled = false;
setLoading(true);
getApplications().then((response) => { if (!cancelled) setApplications(mergeApplications(unwrapApplications(response), fallback)); }).catch(() => { if (!cancelled) setApplications(fallback); }).finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [fallback, open]);
const loadApplications = async () => {
setStatus((previous) => (previous === 'success' ? previous : 'loading'));
setErrorMessage('');
try {
const response = await getApplications();
const fetchedApplications = Array.isArray(response) ? response : [];
if (cancelled) {
return;
}
const mergedApplications = mergeApplications(
fetchedApplications,
fallbackApplications,
);
setApplications(mergedApplications);
setStatus('success');
} catch (error) {
if (cancelled) {
return;
}
setApplications(fallbackApplications);
setStatus('error');
setErrorMessage('应用列表加载失败,已回退展示当前账号上的应用信息。');
}
};
loadApplications();
return () => {
cancelled = true;
};
}, [fallbackApplications, open]);
return (
<Modal.Root isOpen={open} onOpenChange={(isOpen) => { if (!isOpen) onClose?.(); }}>
<Modal.Trigger aria-hidden="true" aria-label="打开应用切换" className="sr-only" tabIndex={-1} />
<Modal.Backdrop className="rc-dialog-backdrop" variant="blur">
<Modal.Container placement="center" scroll="inside" size="xl">
<Modal.Dialog className="app-switcher-dialog rc-dialog-panel rc-dialog-panel--xl">
<Modal.CloseTrigger aria-label="关闭应用切换" className="rc-dialog-close"><Close size={16} /></Modal.CloseTrigger>
<Modal.Header className="app-switcher-dialog__header">
<Modal.Root>
<Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
<Modal.Backdrop
isOpen={open}
onOpenChange={(isOpen) => {
if (!isOpen) onClose?.();
}}
variant="blur"
>
<Modal.Container className="w-full sm:w-full" placement="center" scroll="inside" size="lg">
<Modal.Dialog
className="max-h-[min(42rem,calc(100dvh-2rem))]"
style={{ maxWidth: 'min(46rem, calc(100vw - 2rem))' }}
>
<Modal.CloseTrigger aria-label="关闭">
<Close size={16} />
</Modal.CloseTrigger>
<Modal.Header>
<div className="min-w-0">
<Modal.Heading className="rc-dialog-title">应用切换</Modal.Heading>
<p className="app-switcher-dialog__description">选择你可访问的应用</p>
<Modal.Heading>应用切换</Modal.Heading>
<Description>
选择你可访问的应用
</Description>
</div>
</Modal.Header>
<Modal.Body className="app-switcher-dialog__body">
{loading && applications.length === 0 ? <div className="app-switcher-state"><Spinner size="sm" /><span>应用加载中...</span></div> : null}
{!loading && applications.length === 0 ? <p className="app-switcher-state">暂无可切换应用</p> : null}
<div className="app-switcher-grid">{applications.map((application) => <ApplicationCard application={application} key={application.name} />)}</div>
<Modal.Body>
{status === 'error' && errorMessage ? (
<Alert status="warning">
<Alert.Indicator><ExclamationTriangleIcon size={20} /></Alert.Indicator>
<Alert.Content><Alert.Description>{errorMessage}</Alert.Description></Alert.Content>
</Alert>
) : null}
{status === 'loading' && applications.length === 0 ? (
<LoadingSkeleton />
) : applications.length > 0 ? (
<div className="grid gap-3 sm:grid-cols-2">
{applications.map((application) => (
<ApplicationCard
key={application.name}
application={application}
current={application.name === currentApplicationName}
/>
))}
</div>
) : (
<EmptyState size="md">
<EmptyState.Header>
<EmptyState.Title>暂无可切换应用</EmptyState.Title>
<EmptyState.Description>当前账号还没有分配其它应用权限。</EmptyState.Description>
</EmptyState.Header>
</EmptyState>
)}
</Modal.Body>
</Modal.Dialog>
</Modal.Container>
......
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>
</>
);
}
import {
Archive,
ChevronDown,
Close,
Database,
FileText,
} from '@/components/AppIcons';
import { Close } from '@/components/AppIcons';
import { ActionBar } from '@heroui-pro/react/action-bar';
import { DataGrid } from '@heroui-pro/react/data-grid';
import { EmptyState } from '@heroui-pro/react/empty-state';
import {
Button,
Chip,
Label,
ListBox,
Pagination,
Select,
Separator,
Spinner,
} from '@heroui/react';
import { Tooltip } from '@heroui/react/tooltip';
import { useEffect, useRef, useState } from 'react';
export const DATA_TABLE_PAGE_SIZE_OPTIONS = Object.freeze([10, 20, 50]);
export function normalizeDataTablePageSize(
value,
fallback = DATA_TABLE_PAGE_SIZE_OPTIONS[0],
) {
const nextValue = Number(value);
return DATA_TABLE_PAGE_SIZE_OPTIONS.includes(nextValue)
? nextValue
: fallback;
}
export function DataGridEmptyIllustration() {
return (
<div className="data-grid-empty-illustration" aria-hidden="true">
<span className="data-grid-empty-illustration-back">
<Database size={28} />
</span>
<span className="data-grid-empty-illustration-front">
<FileText size={32} />
<span className="data-grid-empty-illustration-lines">
<i />
<i />
<i />
</span>
</span>
<span className="data-grid-empty-illustration-badge">
<Archive size={18} />
</span>
</div>
);
}
import { Tooltip } from '@heroui/react';
function normalizeDataTableEmptyTitle(title) {
const text = String(title || '').trim();
......@@ -71,11 +28,8 @@ export function DataTableEmptyState({ title = '暂无数据' }) {
const resolvedTitle = normalizeDataTableEmptyTitle(title);
return (
<EmptyState className="data-grid-empty-state" size="md">
<EmptyState size="md">
<EmptyState.Header>
<EmptyState.Media>
<DataGridEmptyIllustration />
</EmptyState.Media>
<EmptyState.Title>{resolvedTitle}</EmptyState.Title>
</EmptyState.Header>
</EmptyState>
......@@ -84,7 +38,7 @@ export function DataTableEmptyState({ title = '暂无数据' }) {
function DataTableLoadingState() {
return (
<div className="data-grid-empty-loading" aria-label="数据加载中">
<div className="flex min-h-48 items-center justify-center" aria-label="数据加载中">
<Spinner size="sm" />
</div>
);
......@@ -111,8 +65,17 @@ function resolveColumnWidth(width) {
return width;
}
const match = String(width || '').match(/(\d+)px/);
return match ? Number(match[1]) : undefined;
const normalizedWidth = String(width || '').trim();
const pixelMatch = normalizedWidth.match(/^(\d+(?:\.\d+)?)px$/);
if (pixelMatch) {
return Number(pixelMatch[1]);
}
if (/^\d+(?:\.\d+)?(?:%|fr)$/.test(normalizedWidth)) {
return normalizedWidth;
}
return undefined;
}
export function DataTable({
......@@ -124,52 +87,43 @@ export function DataTable({
selectedRowKeys,
onSelectedRowKeysChange,
emptyTitle = '暂无数据',
emptyDescription = '当前筛选条件下没有可显示的记录。',
className = '',
isWide,
contentMinWidth,
disabledRowKeys,
onRowAction,
sortDescriptor,
onSortChange,
renderSelectionActions,
}) {
const gridViewportRef = useRef(null);
const [gridViewportWidth, setGridViewportWidth] = useState(null);
const selectionEnabled = Boolean(selectedRowKeys && onSelectedRowKeysChange);
const selectedKeys = selectionEnabled ? new Set(selectedRowKeys) : undefined;
const naturalMinWidth = columns.reduce((total, column) => {
const isActions = column.isActions || column.key === 'actions';
const width = resolveColumnWidth(column.width);
return total + (column.minWidth || width || (isActions ? 104 : 128));
}, selectionEnabled ? 40 : 0);
const wide =
isWide ??
(gridViewportWidth !== null && naturalMinWidth > gridViewportWidth + 1);
useEffect(() => {
const element = gridViewportRef.current;
if (!element) return undefined;
const updateWidth = () => {
setGridViewportWidth(element.getBoundingClientRect().width);
};
updateWidth();
const observer = new ResizeObserver(updateWidth);
observer.observe(element);
return () => observer.disconnect();
}, []);
const wide = isWide ?? columns.length > 6;
const firstBusinessColumnIndex = columns.findIndex(
(column) =>
!column.isSelection &&
!column.isActions &&
column.key !== 'actions',
);
const gridColumns = columns.map((column, index) => {
const isActions = column.isActions || column.key === 'actions';
const isPrimary = column.isPrimary ?? (!isActions && index === 0);
const width = resolveColumnWidth(column.width);
const isPrimary =
column.isPrimary ?? (!isActions && index === firstBusinessColumnIndex);
const configuredWidth = resolveColumnWidth(column.width);
const numericWidth =
typeof configuredWidth === 'number' ? configuredWidth : undefined;
const pinned = wide
? column.pin || (isActions ? 'end' : index === 0 ? 'start' : undefined)
? column.pin || (isActions ? 'end' : undefined)
: undefined;
const minWidth =
column.minWidth || width || (isActions ? 104 : pinned ? 220 : 128);
column.minWidth ||
numericWidth ||
(isActions ? 104 : pinned ? 220 : 128);
const width =
!wide && isPrimary
? typeof configuredWidth === 'string' && configuredWidth.endsWith('fr')
? configuredWidth
: '1fr'
: configuredWidth || (pinned ? minWidth : undefined);
return {
id: String(column.key),
......@@ -186,7 +140,7 @@ export function DataTable({
<DataTableText
text={value}
className={
isPrimary ? 'pm-data-grid-primary' : 'pm-data-grid-text'
isPrimary ? 'font-medium' : ''
}
/>
);
......@@ -196,37 +150,26 @@ export function DataTable({
isRowHeader: index === 0,
allowsSorting: column.sortable === true,
headerClassName: column.headerClassName,
cellClassName: [
'pm-data-grid-cell',
isPrimary ? 'pm-data-grid-cell--primary' : '',
column.cellClassName || '',
]
.filter(Boolean)
.join(' '),
cellClassName: column.cellClassName,
allowsResizing: true,
minWidth,
width: width || (pinned ? minWidth : undefined),
width,
pinned,
};
});
const resolvedContentMinWidth = wide
? Math.max(
Number(contentMinWidth) || 0,
gridColumns.reduce(
(total, column) => total + (column.width || column.minWidth || 128),
selectionEnabled ? 40 : 0,
),
)
: undefined;
const handleSelectionChange = (selection) => {
if (!onSelectedRowKeysChange) {
return;
}
if (selection === 'all') {
const disabledKeySet = new Set(
Array.from(disabledRowKeys || []).map(String),
);
onSelectedRowKeysChange(
rows.map((row, index) => resolveRowKey(row, index, getRowKey)),
rows
.map((row, index) => resolveRowKey(row, index, getRowKey))
.filter((key) => !disabledKeySet.has(key)),
);
return;
}
......@@ -234,33 +177,14 @@ export function DataTable({
onSelectedRowKeysChange(Array.from(selection).map(String));
};
const emptyState = <DataTableEmptyState title={emptyTitle} />;
return (
<div className={['relative min-w-0 w-full', className].filter(Boolean).join(' ')}>
<div className="relative min-w-0 w-full">
{isLoading && rows.length > 0 ? (
<div
className={['pm-data-table-selection-scope', className]
.filter(Boolean)
.join(' ')}
className="absolute inset-0 z-10 grid place-items-center"
aria-label="数据加载中"
>
<div
ref={gridViewportRef}
className={[
'pm-data-grid-wrap',
rows.length > 0 ? 'pm-data-grid-wrap--populated' : '',
!isLoading && rows.length === 0 ? 'pm-data-grid-wrap--empty' : '',
]
.filter(Boolean)
.join(' ')}
style={
resolvedContentMinWidth
? {
'--pm-data-grid-content-min-width': `${resolvedContentMinWidth}px`,
}
: undefined
}
>
{isLoading && rows.length > 0 ? (
<div className="pm-data-grid-loading" aria-label="数据加载中">
<Spinner size="sm" />
</div>
) : null}
......@@ -268,27 +192,15 @@ export function DataTable({
showSelectionCheckboxes={selectionEnabled}
allowsColumnResize
aria-label={ariaLabel}
className="pm-data-grid"
columns={gridColumns}
contentClassName={
wide
? 'pm-data-grid-content pm-data-grid-content--wide'
: 'pm-data-grid-content'
}
contentClassName={wide ? 'min-w-max' : 'min-w-full'}
data={rows}
disabledKeys={disabledRowKeys}
getRowId={(row) => resolveRowKey(row, rows.indexOf(row), getRowKey)}
renderEmptyState={() =>
isLoading ? (
<DataTableLoadingState />
) : (
<span
className="pm-data-grid-native-empty-fallback"
aria-hidden="true"
/>
)
isLoading ? <DataTableLoadingState /> : <DataTableEmptyState title={emptyTitle} />
}
scrollContainerClassName="pm-data-grid-scroll"
scrollContainerClassName="data-table-scroll"
selectedKeys={selectedKeys}
selectionMode={selectionEnabled ? 'multiple' : 'none'}
variant="primary"
......@@ -300,9 +212,6 @@ export function DataTable({
selectionEnabled ? handleSelectionChange : undefined
}
/>
{!isLoading && rows.length === 0 ? (
<div className="pm-data-grid-empty-overlay">{emptyState}</div>
) : null}
</div>
{selectionEnabled ? (
......@@ -329,7 +238,6 @@ export function DataTableActionBar({
}) {
return (
<ActionBar
className="pm-data-table-action-bar"
aria-label={ariaLabel}
isOpen={selectedCount > 0}
>
......@@ -361,26 +269,63 @@ export function DataTableText({
text,
className = '',
showTooltip = false,
maxLines = 1,
}) {
const fullText = String(text ?? children ?? '').trim();
const trigger = (
<span
className={['pm-data-grid-truncate', className]
.filter(Boolean)
.join(' ')}
const multilineClassName =
maxLines === 2
? 'overflow-hidden whitespace-normal break-words [display:-webkit-box] [-webkit-box-orient:vertical] [-webkit-line-clamp:2] [overflow-wrap:anywhere]'
: '';
const trigger = showTooltip ? (
<DataTableTextTooltipTrigger
className={[multilineClassName, className].filter(Boolean).join(' ')}
text={fullText}
truncate={maxLines === 1}
>
{children ?? (fullText || '-')}
</DataTableTextTooltipTrigger>
) : (
<span className={['block min-w-0 truncate', className].filter(Boolean).join(' ')}>
{children ?? (fullText || '-')}
</span>
);
if (!fullText || fullText === '-' || (!showTooltip && fullText.length <= 18)) {
if (
!fullText ||
fullText === '-' ||
(!showTooltip && fullText.length <= 18)
) {
return trigger;
}
return (
<Tooltip delay={0}>
{trigger}
<Tooltip.Content className="pm-data-grid-text-tooltip">{fullText}</Tooltip.Content>
<DataTableTextTooltipTrigger
className={[multilineClassName, className].filter(Boolean).join(' ')}
text={fullText}
truncate={maxLines === 1}
>
{children ?? (fullText || '-')}
</DataTableTextTooltipTrigger>
);
}
function DataTableTextTooltipTrigger({
children,
className = '',
text,
truncate = true,
}) {
return (
<Tooltip>
<Tooltip.Trigger
className={[
truncate ? 'min-w-0 truncate' : '',
className,
].filter(Boolean).join(' ')}
>
{children}
</Tooltip.Trigger>
<Tooltip.Content>{text}</Tooltip.Content>
</Tooltip>
);
}
......@@ -395,7 +340,7 @@ export function DataTablePrimaryText({
<DataTableText
text={text}
showTooltip={showTooltip}
className={['pm-data-grid-primary', className].filter(Boolean).join(' ')}
className={['font-medium', className].filter(Boolean).join(' ')}
>
{children}
</DataTableText>
......@@ -407,24 +352,25 @@ export function DataTablePrimaryAction({
text,
className = '',
onPress,
onClick,
isDisabled = false,
disabled = false,
ariaLabel,
}) {
const fullText = String(text ?? children ?? '').trim() || '-';
const handlePress = onPress || onClick;
return (
<Tooltip>
<Button
aria-label={ariaLabel || fullText}
className={['pm-data-grid-primary-action', className]
.filter(Boolean)
.join(' ')}
isDisabled={isDisabled}
className={className}
isDisabled={isDisabled || disabled}
size="sm"
variant="ghost"
onPress={onPress}
onPress={handlePress}
>
<span className="pm-data-grid-primary pm-data-grid-truncate">
<span className="min-w-0 truncate">
{children ?? fullText}
</span>
</Button>
......@@ -443,7 +389,7 @@ export function DataTableSecondaryText({
<DataTableText
text={text}
showTooltip={showTooltip}
className={['pm-data-grid-secondary', className]
className={['text-sm text-muted', className]
.filter(Boolean)
.join(' ')}
>
......@@ -465,18 +411,34 @@ export function DataTableTextPair({
const secondaryText = hasSecondary ? String(secondary).trim() : '';
const tooltipText = [primaryText, secondaryText].filter(Boolean).join('\n');
const content = (
<span className={['pm-data-grid-text-pair pm-data-grid-tooltip-trigger', className].filter(Boolean).join(' ')} tabIndex={0}>
<span className={['pm-data-grid-truncate pm-data-grid-primary', primaryClassName].filter(Boolean).join(' ')}>{primaryText}</span>
{hasSecondary ? <span className={['pm-data-grid-truncate pm-data-grid-secondary', secondaryClassName].filter(Boolean).join(' ')}>{secondaryText}</span> : null}
</span>
);
return (
<Tooltip delay={0}>
{content}
<Tooltip.Content className="pm-data-grid-text-tooltip whitespace-pre-line">{tooltipText}</Tooltip.Content>
</Tooltip>
<DataTableTextTooltipTrigger
className={['flex min-w-0 flex-col', className]
.filter(Boolean)
.join(' ')}
text={tooltipText}
truncate={false}
>
<span
className={['min-w-0 truncate font-medium', primaryClassName]
.filter(Boolean)
.join(' ')}
>
{primaryText}
</span>
{hasSecondary ? (
<span
className={[
'min-w-0 truncate text-sm text-muted',
secondaryClassName,
]
.filter(Boolean)
.join(' ')}
>
{secondaryText}
</span>
) : null}
</DataTableTextTooltipTrigger>
);
}
......@@ -488,24 +450,14 @@ export function DataTablePersonIdentity({ name, account, className = '' }) {
<DataTableTextPair
primary={primaryText}
secondary={secondaryText}
className={['pm-data-grid-person-identity', className]
.filter(Boolean)
.join(' ')}
className={className}
/>
);
}
export function DataTableChip({ children, text, className = '' }) {
const fullText = String(text ?? children ?? '').trim();
const trigger = (
<span
className={['pm-data-grid-chip', className].filter(Boolean).join(' ')}
>
<span className="pm-data-grid-chip__label">
{children ?? (fullText || '-')}
</span>
</span>
);
const trigger = <Chip className={className} size="sm">{children ?? (fullText || '-')}</Chip>;
if (!fullText || fullText === '-' || fullText.length <= 18) {
return trigger;
......@@ -524,8 +476,6 @@ export function DataTablePagination({
pageSize,
total,
onPageChange,
pageSizeOptions = DATA_TABLE_PAGE_SIZE_OPTIONS,
onPageSizeChange,
isDisabled = false,
itemLabel = '条',
}) {
......@@ -534,15 +484,11 @@ export function DataTablePagination({
const pageWindowEnd = total
? Math.min(totalPages, pageWindowStart + 5)
: pageWindowStart;
const hasPageSizeControl =
pageSizeOptions.length > 0 && Boolean(onPageSizeChange);
return (
<div className="pm-pagination-bar">
<Pagination size="sm" className="w-full">
<div className="flex flex-wrap items-center justify-between gap-3">
<Pagination size="sm">
<Pagination.Summary>
共 {total} {itemLabel},
{hasPageSizeControl ? null : <>每页 {pageSize} 条,</>}第{' '}
共 {total} {itemLabel},第{' '}
{total ? page + 1 : 0} / {total ? totalPages : 0} 页
</Pagination.Summary>
<Pagination.Content>
......@@ -583,46 +529,6 @@ export function DataTablePagination({
</Pagination.Item>
</Pagination.Content>
</Pagination>
{hasPageSizeControl ? (
<div className="pm-pagination-controls">
<Select
aria-label="每页条数"
className="pm-pagination-page-size-select"
isDisabled={isDisabled}
selectedKey={String(pageSize)}
variant="secondary"
onSelectionChange={(key) => onPageSizeChange(Number(key))}
>
<Select.Trigger>
<span className="pm-pagination-page-size-copy">
<span className="pm-pagination-page-size-label">每页</span>
<Select.Value className="pm-pagination-page-size-value">
{pageSize} 条/页
</Select.Value>
</span>
<ChevronDown className="pm-pagination-page-size-indicator" />
</Select.Trigger>
<Select.Popover
className="rc-form-select__popover rc-filter-select__popover"
placement="bottom start"
>
<ListBox aria-label="每页条数">
{pageSizeOptions.map((option) => (
<ListBox.Item
key={option}
id={String(option)}
className="rc-form-select__option rc-filter-select__option"
textValue={`${option} 条/页`}
>
<Label>{option} 条/页</Label>
<ListBox.ItemIndicator />
</ListBox.Item>
))}
</ListBox>
</Select.Popover>
</Select>
</div>
) : null}
</div>
);
}
......@@ -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' },
......
......@@ -272,9 +272,6 @@
}
.rc-app-main {
--rc-page-title-inset: calc(
var(--rc-space-6) + var(--rc-navbar-trigger-size) + var(--rc-space-3)
);
display: flex;
min-width: 0;
min-height: 0;
......@@ -300,7 +297,7 @@
min-height: 4rem;
max-height: 4rem;
gap: var(--rc-space-3);
padding: 0 var(--rc-space-6);
padding: 0 var(--rc-navbar-inline-padding);
}
.rc-navbar [data-slot='sidebar-trigger'] {
......@@ -309,6 +306,7 @@
max-width: var(--rc-navbar-trigger-size);
height: var(--rc-navbar-trigger-size);
flex: 0 0 var(--rc-navbar-trigger-size);
margin-inline-start: 0;
}
.rc-navbar-icon-button.button,
......@@ -412,10 +410,257 @@
min-width: 0;
min-height: 100%;
margin: 0;
padding: var(--rc-space-4) var(--rc-page-title-inset) var(--rc-space-5);
padding-block: var(--rc-page-padding-block-start)
var(--rc-page-padding-block-end);
padding-inline-start: var(--rc-page-inline-start);
padding-inline-end: var(--rc-page-inline-end);
overflow-x: clip;
}
/* Shared page rhythm. These classes control layout only, never HeroUI internals. */
.rc-page-stack,
.rc-page-section,
.rc-list-stack,
.rc-list-data-stack {
display: flex;
min-width: 0;
flex-direction: column;
}
.rc-page-stack {
gap: var(--rc-page-section-gap);
}
.rc-page-section {
gap: var(--rc-page-module-gap);
}
.rc-page-grid {
min-width: 0;
gap: var(--rc-page-module-gap);
}
.rc-chart-grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
}
.rc-chart-grid > * {
min-width: 0;
}
.rc-chart-grid > :only-child {
grid-column: 1 / -1;
}
/* Recharts keeps keyboard navigation while pointer focus stays visually quiet. */
.rc-chart-interaction:focus:not(:focus-visible),
.rc-chart-interaction :focus:not(:focus-visible) {
outline: none;
}
.rc-chart-interaction:focus-visible,
.rc-chart-interaction :focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
}
.rc-kpi-grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
min-width: 0;
gap: var(--rc-related-item-gap);
}
@media (min-width: 48rem) {
.rc-kpi-grid:not([data-count='1']) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (min-width: 80rem) {
.rc-kpi-grid[data-count='1'] {
grid-template-columns: minmax(0, 1fr);
}
.rc-kpi-grid[data-count='2'] {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.rc-kpi-grid[data-count='3'],
.rc-kpi-grid[data-count='5'],
.rc-kpi-grid[data-count='6'],
.rc-kpi-grid[data-count='9'] {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.rc-kpi-grid[data-count='4'],
.rc-kpi-grid[data-count='7'],
.rc-kpi-grid[data-count='8'] {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
/* Shared HeroUI/HeroUI Pro business-card contract. */
.rc-card-surface {
border: 1px solid var(--rc-border-default);
background-color: var(--rc-bg-surface);
color: var(--rc-text-primary);
box-shadow: var(--rc-shadow-card);
}
.rc-dashboard-card {
border-radius: 1.5rem;
}
/* HeroUI Pro KPI.Icon keeps its compound role; this class supplies Xinyuan tones. */
.rc-metric-icon-surface {
--rc-metric-icon-color: var(--rc-metric-tone-neutral);
--rc-metric-icon-background: var(--rc-metric-tone-neutral-soft);
display: grid;
width: 3rem;
min-width: 3rem;
max-width: 3rem;
height: 3rem;
min-height: 3rem;
max-height: 3rem;
flex: 0 0 3rem;
place-items: center;
border-radius: 1rem;
color: var(--rc-metric-icon-color);
background-color: var(--rc-metric-icon-background);
line-height: 1;
}
.rc-metric-icon-surface[data-tone='info'] {
--rc-metric-icon-color: var(--rc-metric-tone-info);
--rc-metric-icon-background: var(--rc-metric-tone-info-soft);
}
.rc-metric-icon-surface[data-tone='success'] {
--rc-metric-icon-color: var(--rc-metric-tone-success);
--rc-metric-icon-background: var(--rc-metric-tone-success-soft);
}
.rc-metric-icon-surface[data-tone='teal'] {
--rc-metric-icon-color: var(--rc-metric-tone-teal);
--rc-metric-icon-background: var(--rc-metric-tone-teal-soft);
}
.rc-metric-icon-surface[data-tone='danger'] {
--rc-metric-icon-color: var(--rc-metric-tone-danger);
--rc-metric-icon-background: var(--rc-metric-tone-danger-soft);
}
.rc-metric-icon-surface[data-tone='warning'] {
--rc-metric-icon-color: var(--rc-metric-tone-warning);
--rc-metric-icon-background: var(--rc-metric-tone-warning-soft);
}
.rc-metric-icon-surface[data-tone='violet'] {
--rc-metric-icon-color: var(--rc-metric-tone-violet);
--rc-metric-icon-background: var(--rc-metric-tone-violet-soft);
}
.rc-metric-icon-surface[data-tone='indigo'] {
--rc-metric-icon-color: var(--rc-metric-tone-indigo);
--rc-metric-icon-background: var(--rc-metric-tone-indigo-soft);
}
.rc-metric-icon-surface[data-tone='coral'] {
--rc-metric-icon-color: var(--rc-metric-tone-coral);
--rc-metric-icon-background: var(--rc-metric-tone-coral-soft);
}
/* Untoned KPI items receive a unique ordered tone for boards of up to nine. */
.rc-kpi-grid > :nth-child(1) .rc-metric-icon-surface:not([data-tone]) {
--rc-metric-icon-color: var(--rc-metric-tone-info);
--rc-metric-icon-background: var(--rc-metric-tone-info-soft);
}
.rc-kpi-grid > :nth-child(2) .rc-metric-icon-surface:not([data-tone]) {
--rc-metric-icon-color: var(--rc-metric-tone-success);
--rc-metric-icon-background: var(--rc-metric-tone-success-soft);
}
.rc-kpi-grid > :nth-child(3) .rc-metric-icon-surface:not([data-tone]) {
--rc-metric-icon-color: var(--rc-metric-tone-warning);
--rc-metric-icon-background: var(--rc-metric-tone-warning-soft);
}
.rc-kpi-grid > :nth-child(4) .rc-metric-icon-surface:not([data-tone]) {
--rc-metric-icon-color: var(--rc-metric-tone-violet);
--rc-metric-icon-background: var(--rc-metric-tone-violet-soft);
}
.rc-kpi-grid > :nth-child(5) .rc-metric-icon-surface:not([data-tone]) {
--rc-metric-icon-color: var(--rc-metric-tone-danger);
--rc-metric-icon-background: var(--rc-metric-tone-danger-soft);
}
.rc-kpi-grid > :nth-child(6) .rc-metric-icon-surface:not([data-tone]) {
--rc-metric-icon-color: var(--rc-metric-tone-teal);
--rc-metric-icon-background: var(--rc-metric-tone-teal-soft);
}
.rc-kpi-grid > :nth-child(7) .rc-metric-icon-surface:not([data-tone]) {
--rc-metric-icon-color: var(--rc-metric-tone-indigo);
--rc-metric-icon-background: var(--rc-metric-tone-indigo-soft);
}
.rc-kpi-grid > :nth-child(8) .rc-metric-icon-surface:not([data-tone]) {
--rc-metric-icon-color: var(--rc-metric-tone-coral);
--rc-metric-icon-background: var(--rc-metric-tone-coral-soft);
}
.rc-kpi-grid > :nth-child(9) .rc-metric-icon-surface:not([data-tone]) {
--rc-metric-icon-color: var(--rc-metric-tone-neutral);
--rc-metric-icon-background: var(--rc-metric-tone-neutral-soft);
}
.rc-metric-icon-surface > .anticon,
.rc-metric-icon-surface > svg {
display: block;
width: 1.25rem;
height: 1.25rem;
margin: 0;
color: inherit;
font-size: 1.25rem;
line-height: 1;
}
.rc-metric-icon-surface > .anticon > svg {
display: block;
width: 1em;
height: 1em;
margin: 0;
}
.rc-page-toolbar {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--rc-page-toolbar-gap);
}
.rc-page-toolbar-responsive {
flex-direction: column;
align-items: stretch;
}
.rc-list-stack,
.rc-list-data-stack {
gap: var(--rc-list-stack-gap);
}
@media (min-width: 80rem) {
.rc-chart-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.rc-mobile-sidebar-backdrop {
position: fixed;
inset: 0;
......@@ -454,6 +699,10 @@
}
@media (max-width: 1023px) {
.rc-app-main {
--rc-shell-inline-gutter: var(--rc-space-8);
}
.rc-sidebar {
display: none;
}
......@@ -463,15 +712,25 @@
}
}
@media (min-width: 768px) {
.rc-page-toolbar-responsive {
flex-direction: row;
align-items: center;
}
}
@media (max-width: 640px) {
.rc-app-main {
--rc-page-title-inset: calc(
var(--rc-space-6) + var(--rc-navbar-icon-button-size) + var(--rc-space-3)
);
--rc-shell-inline-gutter: var(--rc-space-4);
--rc-page-padding-block-start: var(--rc-space-4);
--rc-page-padding-block-end: var(--rc-space-6);
--rc-page-section-gap: var(--rc-space-6);
--rc-page-module-gap: var(--rc-space-4);
}
.rc-app-page {
padding: var(--rc-space-4) var(--rc-page-title-inset) var(--rc-space-5);
padding-block: var(--rc-page-padding-block-start)
var(--rc-page-padding-block-end);
}
.rc-navbar [data-slot='sidebar-trigger'] {
......@@ -666,7 +925,7 @@
padding: var(--rc-space-2) var(--rc-space-3) var(--rc-space-4);
}
.rc-sidebar-user-trigger.button {
.rc-sidebar-user-trigger.dropdown__trigger {
display: flex;
width: 100%;
min-width: 0;
......@@ -681,9 +940,9 @@
text-align: left;
}
.rc-sidebar-user-trigger.button:hover,
.rc-sidebar-user-trigger.button[data-hovered='true'],
.rc-sidebar-user-trigger.button[aria-expanded='true'] {
.rc-sidebar-user-trigger.dropdown__trigger:hover,
.rc-sidebar-user-trigger.dropdown__trigger[data-hovered='true'],
.rc-sidebar-user-trigger.dropdown__trigger[aria-expanded='true'] {
background: var(--default);
}
......@@ -719,6 +978,13 @@
background: var(--rc-bg-canvas);
}
.dark .rc-app-main.sidebar__main,
[data-theme='dark'] .rc-app-main.sidebar__main,
.dark .app-shell-content-scroll,
[data-theme='dark'] .app-shell-content-scroll {
background: var(--rc-bg-canvas);
}
.rc-navbar {
flex: none;
height: 4rem;
......@@ -798,8 +1064,7 @@
width: auto;
max-width: 8rem;
height: 1.5rem;
filter: brightness(0) saturate(100%) invert(13%) sepia(17%) saturate(1318%)
hue-rotate(181deg) brightness(91%) contrast(92%);
filter: none;
object-fit: contain;
}
......@@ -809,6 +1074,7 @@
}
.rc-login-card.card {
width: 100%;
border-radius: var(--rc-radius-overlay);
background: var(--rc-bg-surface);
box-shadow: var(--rc-shadow-raised);
......@@ -861,7 +1127,7 @@
min-width: 0;
min-height: 3rem;
align-items: center;
gap: 0.75rem;
gap: 0.625rem;
padding: 0.5rem 0.25rem;
}
......@@ -874,19 +1140,19 @@
}
.app-sidebar-brand-mark {
display: flex;
width: 2.75rem;
height: 1.5rem;
display: grid;
width: 1.75rem;
height: 1.75rem;
flex: none;
align-items: center;
justify-content: center;
place-items: center;
overflow: hidden;
}
.app-sidebar-brand-mark img {
grid-area: 1 / 1;
display: block;
width: 100%;
height: 100%;
width: 1.75rem;
height: 1.75rem;
object-fit: contain;
}
......@@ -1065,13 +1331,13 @@
padding: 0.5rem 0.75rem 1rem;
}
.app-sidebar-user-trigger.button {
.app-sidebar-user-trigger.dropdown__trigger {
display: flex;
width: 100%;
min-width: 0;
min-height: 3rem;
align-items: center;
justify-content: center;
justify-content: flex-start;
gap: 0.625rem;
padding: 0.375rem 0.5rem !important;
border-radius: calc(var(--radius) * 2) !important;
......@@ -1081,13 +1347,13 @@
}
@media (hover: hover) {
.app-sidebar-user-trigger.button:hover,
.app-sidebar-user-trigger.button[data-hovered='true'] {
.app-sidebar-user-trigger.dropdown__trigger:hover,
.app-sidebar-user-trigger.dropdown__trigger[data-hovered='true'] {
background: var(--default);
}
}
.app-sidebar-user-trigger.button[aria-expanded='true'] {
.app-sidebar-user-trigger.dropdown__trigger[aria-expanded='true'] {
background: var(--default);
}
......
/*
* 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);
}
......@@ -2,76 +2,39 @@
.light,
.default,
[data-theme='light'],
[data-theme='default'] {
/* Xinyuan brand and semantic colors */
--rc-brand-primary: #9e1b1b;
--rc-brand-light: #b83a3a;
--rc-brand-dark: #7e1515;
--rc-brand-primary-hover: var(--rc-brand-light);
--rc-brand-primary-pressed: var(--rc-brand-dark);
--rc-brand-primary-soft: rgb(158 27 27 / 8%);
--rc-brand-primary-soft-hover: rgb(158 27 27 / 12%);
--rc-brand-glass: rgb(158 27 27 / 4%);
--rc-brand-on-primary: #ffffff;
--rc-color-success: #16845b;
--rc-color-warning: #b46a00;
--rc-color-danger: #c93636;
--rc-color-info: var(--rc-brand-primary);
--rc-color-teal: #0f766e;
--rc-color-favorite: #d68a00;
--rc-color-favorite-soft: rgb(214 138 0 / 10%);
--rc-color-success-soft: rgb(22 132 91 / 9%);
--rc-color-success-border: rgb(22 132 91 / 24%);
--rc-color-warning-soft: rgb(180 106 0 / 10%);
--rc-color-warning-border: rgb(180 106 0 / 25%);
--rc-color-danger-soft: rgb(201 54 54 / 9%);
--rc-color-danger-border: rgb(201 54 54 / 22%);
--rc-color-info-soft: var(--rc-brand-primary-soft);
--rc-color-info-border: color-mix(in srgb, var(--rc-brand-primary) 24%, transparent);
--rc-color-teal-soft: rgb(15 118 110 / 9%);
--rc-color-teal-border: rgb(15 118 110 / 24%);
--rc-color-neutral-soft: #f4f4f5;
--rc-color-neutral-border: #e4e4e7;
[data-theme='default'],
.dark,
[data-theme='dark'] {
/* Xinyuan aliases and extension tokens. HeroUI core tokens are owned by theme.css. */
--rc-brand-primary: var(--accent);
--rc-brand-light: color-mix(in oklab, var(--accent) 84%, white);
--rc-brand-dark: color-mix(in oklab, var(--accent) 82%, black);
/* Surfaces, borders and text */
--rc-bg-canvas: #f5f5f5;
--rc-bg-surface: #ffffff;
--rc-bg-subtle: #f8fafc;
--rc-layout-background: #f4f6f9;
--rc-sidebar-glass: rgb(255 255 255 / 42%);
--rc-main-glass: rgb(255 255 255 / 82%);
--rc-shell-glass: rgb(255 255 255 / 78%);
--rc-glass-border: rgb(255 255 255 / 60%);
--rc-sidebar-border: rgb(226 232 240 / 55%);
--rc-border-default: #e3e8ef;
--rc-border-strong: #cbd3de;
--rc-field-background: #ffffff;
--rc-field-border: #d9e1eb;
--rc-field-border-hover: #b8c4d2;
--rc-field-border-focus: var(--rc-brand-primary);
--rc-button-secondary-border: rgb(9 9 11 / 12%);
--rc-button-secondary-border-hover: rgb(9 9 11 / 20%);
--rc-button-secondary-background-hover: rgb(9 9 11 / 4%);
--rc-text-primary: #172033;
--rc-text-secondary: #4e5d73;
--rc-text-muted: #718096;
--rc-bg-canvas: var(--background);
--rc-bg-surface: var(--surface);
--rc-border-default: var(--border);
--rc-field-background: var(--field-background);
--rc-field-border-focus: var(--focus);
--rc-button-secondary-border: color-mix(
in oklab,
var(--foreground) 12%,
transparent
);
--rc-text-primary: var(--foreground);
--rc-text-secondary: color-mix(
in oklab,
var(--foreground) 72%,
var(--background)
);
--rc-text-muted: var(--muted);
/* Typography */
--rc-font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif;
--rc-font-mono: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
--rc-text-xs: 0.75rem;
--rc-text-sm: 0.875rem;
--rc-text-md: 1rem;
--rc-text-lg: 1.25rem;
--rc-text-xl: 1.5rem;
--rc-font-regular: 400;
--rc-font-sans: var(--font-sans);
--rc-font-medium: 500;
--rc-font-semibold: 600;
--rc-line-compact: 1.25;
--rc-line-normal: 1.5;
--rc-text-xs: 0.75rem;
--rc-text-sm: 0.875rem;
/* Spacing, radius and motion */
--rc-space-1: 0.25rem;
--rc-space-2: 0.5rem;
--rc-space-3: 0.75rem;
......@@ -79,7 +42,19 @@
--rc-space-5: 1.25rem;
--rc-space-6: 1.5rem;
--rc-space-8: 2rem;
--rc-space-10: 2.5rem;
/* Page geometry only. Component appearance remains owned by HeroUI. */
--rc-shell-inline-gutter: 3rem;
--rc-navbar-inline-padding: var(--rc-shell-inline-gutter);
--rc-page-inline-start: var(--rc-shell-inline-gutter);
--rc-page-inline-end: var(--rc-shell-inline-gutter);
--rc-page-padding-block-start: 1.5rem;
--rc-page-padding-block-end: 2rem;
--rc-page-section-gap: 2rem;
--rc-page-module-gap: 1.5rem;
--rc-related-item-gap: 1rem;
--rc-page-toolbar-gap: 1rem;
--rc-list-stack-gap: 1rem;
--rc-radius-control: 0.375rem;
--rc-radius-container: 0.5rem;
--rc-radius-overlay: 0.75rem;
......@@ -87,75 +62,23 @@
--rc-motion-normal: 180ms;
--rc-ease-standard: cubic-bezier(0.2, 0, 0, 1);
/* Component contracts */
--rc-filter-radius: 0.75rem;
--rc-button-radius: 1.5rem;
--rc-button-sm-height: 2rem;
--rc-button-md-height: 2.25rem;
--rc-button-lg-height: 2.5rem;
--rc-table-action-width: 9rem;
--rc-table-action-gap: 0.5rem;
--rc-table-action-padding-inline: 0.75rem;
--rc-table-action-idle-opacity: 0.55;
--rc-navbar-trigger-size: 2rem;
--rc-navbar-icon-button-size: 2.25rem;
--rc-segment-track-padding: 0.125rem;
--rc-segment-track-radius: 0.75rem;
--rc-segment-item-height: 1.75rem;
--rc-segment-item-radius: 0.625rem;
--rc-segment-item-padding-inline: 0.75rem;
--rc-segment-item-gap: 0.5rem;
--rc-segment-font-size: var(--rc-text-sm);
--rc-segment-font-weight: var(--rc-font-medium);
--rc-segment-line-height: 1.25rem;
--rc-sidebar-app-trigger-size: 2.25rem;
--rc-sidebar-app-trigger-icon-size: 1rem;
--rc-navbar-border-width: 0px;
--rc-navbar-title-size: 1.25rem;
--rc-navbar-page-title-size: var(--rc-navbar-title-size);
--rc-navbar-title-weight: 600;
--rc-navbar-title-line-height: 1.75rem;
--rc-login-panel-width: 24rem;
--rc-sidebar-app-trigger-size: 2.25rem;
--rc-sidebar-app-trigger-icon-size: 1rem;
--rc-login-panel-width: 28rem;
--rc-login-brand-size: 1.5rem;
--rc-login-brand-weight: var(--rc-font-semibold);
--rc-login-brand-gap: 1.5rem;
--rc-toolbar-filter-background: var(--default);
--rc-toolbar-filter-background-hover: var(--default-hover);
--rc-toolbar-filter-foreground: var(--foreground);
--rc-toolbar-filter-icon: var(--muted);
--rc-toolbar-filter-radius: 999px;
--rc-toolbar-filter-content-gap: 0.5rem;
--rc-data-grid-radius: calc(var(--radius) * 2.5);
--rc-data-grid-header-background: var(--surface-secondary);
--rc-data-grid-header-height: 2.25rem;
--rc-data-grid-header-font-size: var(--rc-text-xs);
--rc-data-grid-header-font-weight: var(--rc-font-medium);
--rc-data-grid-header-line-height: var(--rc-line-compact);
--rc-data-grid-header-color: var(--rc-text-muted);
--rc-data-grid-body-font-size: var(--rc-text-sm);
--rc-data-grid-body-font-weight: var(--rc-font-regular);
--rc-data-grid-body-line-height: var(--rc-line-normal);
--rc-data-grid-body-color: var(--rc-text-secondary);
--rc-data-grid-primary-font-size: var(--rc-text-sm);
--rc-data-grid-primary-font-weight: 550;
--rc-data-grid-primary-line-height: var(--rc-line-normal);
--rc-data-grid-primary-color: var(--rc-text-primary);
--rc-data-grid-secondary-font-size: 0.8125rem;
--rc-data-grid-secondary-font-weight: var(--rc-font-regular);
--rc-data-grid-secondary-line-height: var(--rc-line-compact);
--rc-data-grid-secondary-color: #7a8596;
--rc-data-grid-text-pair-gap: 0.25rem;
--rc-data-grid-chip-font-size: var(--rc-text-xs);
--rc-data-grid-chip-font-weight: var(--rc-font-medium);
--rc-data-grid-chip-line-height: var(--rc-line-compact);
--rc-data-grid-row-hover-background: color-mix(
in oklab,
var(--default) 35%,
var(--surface)
);
/* Approved Xinyuan Modal size ladder. */
--rc-modal-radius: 1.5rem;
--rc-modal-padding: 1.75rem;
--rc-modal-title-size: var(--rc-text-md);
--rc-modal-title-size: 1rem;
--rc-modal-title-weight: var(--rc-font-medium);
--rc-modal-title-line-height: 1.5rem;
--rc-modal-section-gap: 0.75rem;
......@@ -174,143 +97,104 @@
--rc-modal-width-3xl: 60rem;
--rc-modal-width-4xl: 68rem;
--rc-modal-width-5xl: 76rem;
--rc-data-grid-action-size: 2rem;
--rc-data-grid-action-radius: 999px;
--rc-list-pagination-gap: 0.5rem;
--rc-checkbox-size: 1rem;
--rc-checkbox-radius: 0.25rem;
--rc-checkbox-background: var(--rc-field-background);
--rc-checkbox-border: var(--rc-field-border);
--rc-checkbox-border-hover: var(--rc-field-border-hover);
--rc-checkbox-disabled-background: #f3f5f8;
--rc-checkbox-disabled-border: #d8dee7;
--rc-checkbox-disabled-opacity: 0.56;
--rc-checkbox-checked-background: var(--rc-brand-primary);
--rc-checkbox-checked-border: var(--rc-brand-dark);
--rc-checkbox-check-color: var(--rc-brand-on-primary);
--rc-kbd-background: #f4f6f9;
--rc-content-max-width: 80rem;
--rc-page-gutter: 1.25rem;
/* Elevation */
--rc-shadow-card: 0 1px 2px rgb(15 23 42 / 2%), 0 4px 12px rgb(15 23 42 / 2%);
--rc-shadow-raised: 0 1px 2px rgb(15 23 42 / 6%),
0 8px 24px rgb(15 23 42 / 8%);
--rc-shadow-overlay: 0 16px 40px rgb(15 23 42 / 14%);
--rc-shadow-brand-glow: 0 4px 14px rgb(158 27 27 / 15%);
--rc-shadow-main-glass: 0 8px 28px rgb(148 163 184 / 14%),
inset 0 0 0 1px rgb(255 255 255 / 30%);
--rc-shadow-card: 0 1px 2px rgb(15 23 42 / 2%),
0 4px 12px rgb(15 23 42 / 2%);
/* HeroUI semantic bridge */
--accent: var(--rc-brand-primary);
--accent-foreground: var(--rc-brand-on-primary);
--accent-hover: var(--rc-brand-primary-hover);
--accent-soft: var(--rc-brand-primary-soft);
--accent-soft-hover: var(--rc-brand-primary-soft-hover);
--accent-soft-foreground: color-mix(
/* KPI icon surfaces. Card bodies remain neutral; only the icon well carries tone. */
--rc-metric-tone-info: oklch(55% 0.14 255);
--rc-metric-tone-success: oklch(54% 0.13 145);
--rc-metric-tone-teal: oklch(55% 0.1 205);
--rc-metric-tone-danger: oklch(52% 0.17 25);
--rc-metric-tone-warning: oklch(62% 0.12 75);
--rc-metric-tone-violet: oklch(56% 0.12 300);
--rc-metric-tone-indigo: oklch(53% 0.13 275);
--rc-metric-tone-coral: oklch(60% 0.12 48);
--rc-metric-tone-neutral: var(--muted);
--rc-metric-tone-info-soft: color-mix(
in oklab,
var(--rc-brand-primary) 82%,
var(--rc-text-primary) 18%
var(--rc-metric-tone-info) 9%,
var(--surface)
);
--background: var(--rc-bg-canvas);
--surface: var(--rc-bg-surface);
--surface-shadow: var(--rc-shadow-card);
--foreground: var(--rc-text-primary);
--muted: var(--rc-text-muted);
--muted-foreground: var(--rc-text-secondary);
--separator: var(--rc-border-default);
--focus: var(--rc-brand-primary);
--app-background-gradient: radial-gradient(
circle at 0% 0%,
rgb(158 27 27 / 5%) 0%,
rgb(244 246 249 / 0%) 50%
),
var(--rc-layout-background);
--rc-metric-tone-success-soft: color-mix(
in oklab,
var(--rc-metric-tone-success) 9%,
var(--surface)
);
--rc-metric-tone-teal-soft: color-mix(
in oklab,
var(--rc-metric-tone-teal) 9%,
var(--surface)
);
--rc-metric-tone-danger-soft: color-mix(
in oklab,
var(--rc-metric-tone-danger) 9%,
var(--surface)
);
--rc-metric-tone-warning-soft: color-mix(
in oklab,
var(--rc-metric-tone-warning) 9%,
var(--surface)
);
--rc-metric-tone-violet-soft: color-mix(
in oklab,
var(--rc-metric-tone-violet) 9%,
var(--surface)
);
--rc-metric-tone-indigo-soft: color-mix(
in oklab,
var(--rc-metric-tone-indigo) 9%,
var(--surface)
);
--rc-metric-tone-coral-soft: color-mix(
in oklab,
var(--rc-metric-tone-coral) 9%,
var(--surface)
);
--rc-metric-tone-neutral-soft: color-mix(
in oklab,
var(--rc-metric-tone-neutral) 8%,
var(--surface)
);
/* Data visualization only. Do not reuse UI status colors as a series palette. */
--rc-chart-1: oklch(45.32% 0.1661 27.03);
--rc-chart-2: oklch(56% 0.135 255);
--rc-chart-3: oklch(64% 0.11 220);
--rc-chart-4: oklch(56% 0.115 280);
--rc-chart-5: oklch(60% 0.095 200);
--rc-chart-6: oklch(70% 0.12 80);
--rc-chart-7: oklch(60% 0.1 295);
--rc-chart-8: oklch(57% 0.035 250);
--rc-chart-primary: var(--rc-chart-1);
--rc-chart-risk: oklch(47% 0.145 27.03);
--rc-chart-warning: var(--rc-chart-6);
--rc-chart-positive: var(--rc-chart-2);
--rc-chart-comparison: var(--rc-chart-4);
--rc-chart-neutral: var(--rc-chart-8);
--rc-chart-legend-font-size: 0.75rem;
--rc-chart-legend-line-height: 1rem;
}
.dark,
[data-theme='dark'] {
color-scheme: dark;
--rc-brand-primary-soft: rgb(158 27 27 / 16%);
--rc-brand-primary-soft-hover: rgb(158 27 27 / 22%);
--rc-brand-glass: rgb(158 27 27 / 10%);
--rc-color-success: #4fca92;
--rc-color-warning: #f0ad4e;
--rc-color-danger: #ff7272;
--rc-color-info: #f0a3a3;
--rc-color-teal: #5eead4;
--rc-color-favorite: #f2b84b;
--rc-color-favorite-soft: rgb(242 184 75 / 14%);
--rc-color-success-soft: rgb(79 202 146 / 14%);
--rc-color-success-border: rgb(79 202 146 / 30%);
--rc-color-warning-soft: rgb(240 173 78 / 15%);
--rc-color-warning-border: rgb(240 173 78 / 32%);
--rc-color-danger-soft: rgb(255 114 114 / 14%);
--rc-color-danger-border: rgb(255 114 114 / 30%);
--rc-color-info-soft: rgb(158 27 27 / 18%);
--rc-color-info-border: rgb(184 58 58 / 42%);
--rc-color-teal-soft: rgb(94 234 212 / 14%);
--rc-color-teal-border: rgb(94 234 212 / 30%);
--rc-color-neutral-soft: #27272a;
--rc-color-neutral-border: #3f3f46;
--rc-bg-canvas: #0f0f11;
--rc-bg-surface: #18181b;
--rc-bg-subtle: #202024;
--rc-layout-background: #0f0f11;
--rc-sidebar-glass: #0f0f11;
--rc-main-glass: #0f0f11;
--rc-shell-glass: #0f0f11;
--rc-glass-border: #303036;
--rc-sidebar-border: #303036;
--rc-border-default: #303036;
--rc-border-strong: #45454d;
--rc-field-background: #202024;
--rc-field-border: #3b3b42;
--rc-field-border-hover: #56565f;
--rc-button-secondary-border: rgb(255 255 255 / 18%);
--rc-button-secondary-border-hover: rgb(255 255 255 / 28%);
--rc-button-secondary-background-hover: rgb(255 255 255 / 7%);
--rc-text-primary: #f4f4f5;
--rc-text-secondary: #d4d4d8;
--rc-text-muted: #a1a1aa;
--rc-data-grid-secondary-color: #8b8b95;
--rc-checkbox-background: #202024;
--rc-checkbox-border: #56565f;
--rc-checkbox-border-hover: #6f6f7a;
--rc-checkbox-disabled-background: #1b1b1f;
--rc-checkbox-disabled-border: #36363d;
--rc-checkbox-disabled-opacity: 0.58;
--rc-kbd-background: #202024;
--rc-shadow-card: 0 1px 2px rgb(0 0 0 / 18%), 0 4px 12px rgb(0 0 0 / 12%);
--rc-shadow-raised: 0 1px 2px rgb(0 0 0 / 24%), 0 8px 24px rgb(0 0 0 / 24%);
--rc-text-secondary: color-mix(
in oklab,
var(--foreground) 76%,
var(--background)
);
--rc-button-secondary-border: color-mix(
in oklab,
var(--foreground) 18%,
transparent
);
--rc-shadow-raised: 0 1px 2px rgb(0 0 0 / 24%),
0 8px 24px rgb(0 0 0 / 24%);
--rc-shadow-overlay: 0 16px 40px rgb(0 0 0 / 38%);
--rc-shadow-main-glass: none;
--accent: var(--rc-brand-primary);
--accent-foreground: var(--rc-brand-on-primary);
--accent-hover: var(--rc-brand-light);
--accent-soft: var(--rc-brand-primary-soft);
--accent-soft-hover: var(--rc-brand-primary-soft-hover);
--accent-soft-foreground: #f0a3a3;
--background: var(--rc-bg-canvas);
--surface: var(--rc-bg-surface);
--surface-secondary: var(--rc-bg-subtle);
--surface-shadow: var(--rc-shadow-card);
--overlay: var(--rc-bg-surface);
--overlay-foreground: var(--rc-text-primary);
--foreground: var(--rc-text-primary);
--muted: var(--rc-text-muted);
--muted-foreground: var(--rc-text-secondary);
--default: #27272a;
--default-hover: #3f3f46;
--border: var(--rc-border-default);
--separator: var(--rc-border-default);
--field: var(--rc-field-background);
--field-foreground: var(--rc-text-primary);
--focus: var(--rc-brand-primary);
--backdrop: rgb(0 0 0 / 50%);
--app-background-gradient: var(--rc-bg-canvas);
--rc-shadow-card: 0 1px 2px rgb(0 0 0 / 20%),
0 4px 12px rgb(0 0 0 / 18%);
}
:root {
--pm-brand: var(--rc-brand-primary, #9e1b1b);
--pm-brand-strong: var(--rc-brand-dark, #7e1515);
--pm-surface: var(--rc-bg-surface, #ffffff);
--pm-bg: var(--rc-bg-canvas, #f5f5f5);
--pm-text: var(--rc-text-primary, #172033);
--pm-muted: var(--rc-text-muted, #718096);
}
html,
body,
#root {
......@@ -6,1009 +15,21 @@ body,
body {
margin: 0;
background: var(--rc-bg-canvas);
}
.login-page {
display: grid;
min-height: 100vh;
place-items: center;
padding: 24px 16px;
background: var(--rc-bg-canvas);
}
.login-card {
width: min(28rem, 100%);
border: 1px solid var(--rc-border-default) !important;
border-radius: 24px !important;
background: var(--rc-bg-surface) !important;
box-shadow: var(--rc-shadow-raised) !important;
}
.login-card [data-slot='card-content'] {
padding: 36px !important;
}
.login-brand {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 32px;
}
.login-brand img {
width: 36px;
height: 36px;
object-fit: contain;
}
.login-brand-logo--dark {
display: none;
}
.dark .login-brand-logo--light {
display: none;
}
.dark .login-brand-logo--dark {
display: block;
}
.login-brand strong {
color: var(--rc-text-primary);
font-size: 32px;
font-weight: var(--rc-login-brand-weight);
line-height: 1.15;
letter-spacing: 0;
}
.login-form {
display: flex;
flex-direction: column;
}
.login-field {
display: flex;
flex-direction: column;
gap: 12px;
}
.login-field + .login-field {
margin-top: 24px;
}
.login-field .label {
color: var(--rc-text-secondary);
font-size: 14px;
font-weight: 500;
}
.login-captcha-row {
display: grid;
grid-template-columns: minmax(0, 1.55fr) minmax(0, 1fr);
gap: 12px;
}
.login-captcha {
display: grid;
height: 40px;
place-items: center;
overflow: hidden;
padding: 0 8px;
border: 1px solid var(--rc-field-border);
border-radius: 8px;
color: var(--rc-text-muted);
background: var(--rc-bg-surface);
cursor: pointer;
}
.login-captcha:hover {
border-color: var(--rc-field-border-hover);
color: var(--rc-brand-primary);
}
.login-captcha span {
display: grid;
width: 100%;
height: 100%;
place-items: center;
line-height: 0;
}
.login-captcha svg {
display: block;
width: auto;
height: auto;
max-width: 100%;
max-height: 30px;
margin: auto;
}
.login-error {
margin-top: 16px;
}
.login-input[data-filled='true'],
.login-input[data-filled='true']:hover,
.login-input[data-filled='true']:focus,
.login-input[data-filled='true']:focus-visible,
.login-input.input--secondary:not(:placeholder-shown),
.login-input.input--secondary:not(:placeholder-shown):focus {
--input-bg: #fbf1f1 !important;
--input-bg-hover: #faeaea !important;
--input-bg-focus: #fbf1f1 !important;
background: #fbf1f1 !important;
background-color: #fbf1f1 !important;
}
.login-input[data-filled='true']:focus,
.login-input[data-filled='true']:focus-visible,
.login-input.input--secondary:not(:placeholder-shown):focus {
box-shadow:
inset 0 0 0 1000px #fbf1f1,
0 0 0 2px rgb(158 27 27 / 16%) !important;
-webkit-box-shadow:
inset 0 0 0 1000px #fbf1f1,
0 0 0 2px rgb(158 27 27 / 16%) !important;
}
.login-input:-webkit-autofill,
.login-input:-webkit-autofill:hover,
.login-input:-webkit-autofill:focus {
-webkit-text-fill-color: var(--rc-text-primary) !important;
box-shadow: inset 0 0 0 1000px #fbf1f1 !important;
-webkit-box-shadow: inset 0 0 0 1000px #fbf1f1 !important;
}
.dark .login-input[data-filled='true'],
.dark .login-input.input--secondary:not(:placeholder-shown) {
--input-bg: rgb(158 27 27 / 14%) !important;
--input-bg-hover: rgb(158 27 27 / 18%) !important;
--input-bg-focus: rgb(158 27 27 / 14%) !important;
background: rgb(158 27 27 / 14%) !important;
box-shadow: none !important;
-webkit-box-shadow: none !important;
}
.rc-sidebar-logo {
width: 28px !important;
height: 28px !important;
object-fit: contain;
}
.rc-sidebar-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rc-sidebar-user-trigger {
display: grid !important;
width: 100% !important;
min-width: 0;
min-height: 52px;
grid-template-columns: 32px minmax(0, 1fr);
align-items: center;
justify-content: stretch !important;
gap: 10px;
padding: 8px 10px !important;
border: 0 !important;
border-radius: 12px !important;
background: transparent !important;
color: var(--rc-text-primary) !important;
text-align: left;
box-shadow: none !important;
}
.rc-sidebar-user-trigger:hover,
.rc-sidebar-user-trigger[data-hovered='true'],
.rc-sidebar-user-trigger:focus-visible {
background: var(--default) !important;
}
.rc-sidebar-avatar {
width: 32px !important;
height: 32px !important;
grid-column: 1;
}
.rc-sidebar-user-copy {
display: flex;
min-width: 0;
grid-column: 2;
flex-direction: column;
gap: 2px;
line-height: 1;
}
.rc-sidebar-user-copy strong,
.rc-sidebar-user-copy small {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rc-sidebar-user-copy strong {
font-size: 14px;
font-weight: 550;
line-height: 18px;
}
.rc-sidebar-user-copy small {
color: var(--rc-text-muted);
font-size: 12px;
font-weight: 400;
line-height: 16px;
}
.rc-sidebar-user-menu-item {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.rc-sidebar-user-menu-item--danger,
.rc-sidebar-user-menu-item--danger :is(label, .anticon, svg) {
color: var(--danger) !important;
}
.app-switcher-dialog {
width: min(46rem, calc(100vw - 32px)) !important;
max-width: 46rem !important;
max-height: min(42rem, calc(100dvh - 32px));
padding: 0 !important;
overflow: hidden;
}
.app-switcher-dialog__header {
padding: 16px 20px 12px !important;
}
.app-switcher-dialog__description {
margin: 4px 0 0;
color: var(--rc-text-muted);
font-size: 14px;
line-height: 20px;
}
.app-switcher-dialog__body {
min-height: 0;
padding: 4px 20px 20px !important;
overflow-y: auto;
scrollbar-gutter: stable;
}
.app-switcher-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.app-switcher-state {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.app-switcher-card[data-current='true'] {
border-color: color-mix(in srgb, var(--rc-brand-primary) 34%, var(--rc-border-default));
background: var(--rc-brand-primary-soft);
}
.app-switcher-state {
padding: 40px 16px;
color: var(--rc-text-muted);
text-align: center;
font-size: 14px;
}
.audit-page {
display: flex;
min-width: 0;
flex-direction: column;
gap: 20px;
}
.audit-page-intro {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.audit-page-intro:has(> :only-child) {
justify-content: flex-end;
}
.audit-page-intro p {
margin: 0;
color: var(--rc-text-muted);
font-size: 14px;
line-height: 20px;
background: var(--rc-bg-canvas, #f5f5f5);
color: var(--pm-text);
font-family:
"PingFang SC",
"Microsoft YaHei",
"Helvetica Neue",
Arial,
sans-serif;
}
.audit-filter-card {
display: flex;
min-width: 0;
align-items: end;
justify-content: space-between;
gap: 16px;
padding: 18px;
border: 1px solid var(--rc-border-default);
border-radius: 24px;
background: var(--rc-bg-surface);
box-shadow: var(--rc-shadow-card);
* {
box-sizing: border-box;
}
.audit-filter-search {
width: min(360px, 100%);
min-width: 0;
}
.audit-filter-search__control {
display: flex;
width: 100%;
min-width: 0;
height: 40px;
align-items: center;
gap: 8px;
padding-inline: 12px;
border: 1px solid var(--rc-field-border);
border-radius: 8px;
background: var(--rc-bg-surface);
box-shadow: var(--field-shadow);
}
.audit-filter-search__control:focus-within {
border-color: var(--rc-brand-primary);
box-shadow: 0 0 0 2px color-mix(in oklab, var(--rc-brand-primary) 16%, transparent);
}
.audit-filter-search__control .input {
min-width: 0;
padding: 0 !important;
border: 0 !important;
background: transparent !important;
box-shadow: none !important;
}
@media (max-width: 767px) {
.app-switcher-grid {
grid-template-columns: 1fr;
}
.audit-filter-card {
align-items: stretch;
flex-direction: column;
}
.audit-filter-search {
width: 100%;
}
}
.audit-filter-card .rc-input-group,
.audit-filter-card .rc-form-input {
width: min(360px, 100%);
}
.audit-list-header,
.audit-section-header {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.audit-section-header h2,
.audit-list-header h2 {
margin: 0;
color: var(--rc-text-primary);
font-size: 16px;
font-weight: 600;
line-height: 24px;
}
.audit-section-meta {
color: var(--rc-text-muted);
font-size: 13px;
font-weight: 400;
}
.audit-project-tabs {
width: max-content;
max-width: 100%;
overflow-x: auto;
}
.audit-project-tabs .tabs__list-container,
.audit-project-tabs .tabs__list {
width: max-content !important;
min-width: max-content !important;
}
.audit-project-tabs .tabs__tab {
width: auto !important;
flex: 0 0 auto !important;
}
.audit-report-section .audit-filter-card > .rc-button:last-child {
margin-left: auto;
}
.audit-stat-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.audit-stat {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px;
border: 1px solid var(--rc-border-default);
border-radius: 24px;
background: var(--rc-bg-surface);
box-shadow: var(--rc-shadow-card);
}
.audit-stat__copy {
min-width: 0;
}
.audit-stat__label {
display: block;
color: var(--rc-text-muted);
font-size: 13px;
line-height: 18px;
}
.audit-stat__value {
display: block;
margin-top: 6px;
color: var(--rc-text-primary);
font-size: 24px;
font-weight: 600;
line-height: 28px;
}
.audit-stat__icon {
display: grid;
width: 48px;
height: 48px;
flex: none;
place-items: center;
border-radius: 16px;
color: var(--rc-brand-primary);
background: var(--rc-brand-primary-soft);
}
.audit-stat__icon .anticon,
.audit-stat__icon svg {
display: block;
width: 20px;
height: 20px;
margin: 0;
}
.audit-report-sections {
display: grid;
gap: 20px;
}
.audit-report-section {
display: grid;
min-width: 0;
gap: 12px;
}
.rc-form-field {
display: flex;
min-width: 0;
flex-direction: column;
gap: 8px;
}
.rc-form-field > .label,
.rc-form-field [data-slot='label'] {
color: var(--rc-text-secondary);
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
.rc-user-select__chips {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin-top: 8px;
}
.rc-user-select__chip {
display: inline-flex !important;
width: auto !important;
max-width: 100%;
min-height: 24px !important;
height: 24px !important;
flex: none;
align-items: center !important;
gap: 4px !important;
padding: 0 4px 0 8px !important;
border-width: 1px !important;
border-radius: 999px !important;
box-shadow: none !important;
font-size: 12px !important;
font-weight: 500 !important;
line-height: 16px !important;
letter-spacing: 0;
vertical-align: middle;
}
.rc-user-select__chip-label {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font: inherit !important;
line-height: 16px !important;
}
.rc-user-select__chip-remove {
display: inline-grid !important;
width: 18px !important;
height: 18px !important;
min-width: 18px !important;
min-height: 18px !important;
flex: none;
place-items: center !important;
padding: 0 !important;
border: 0 !important;
border-radius: 50% !important;
background: transparent !important;
color: currentColor !important;
box-shadow: none !important;
line-height: 0 !important;
}
.rc-user-select__chip-remove:hover,
.rc-user-select__chip-remove[data-hovered='true'] {
background: var(--rc-brand-primary-soft-hover) !important;
}
.rc-user-select__chip-remove :is(.anticon, svg),
.rc-user-select__chip-remove .anticon svg {
display: block !important;
width: 12px !important;
height: 12px !important;
margin: 0 !important;
line-height: 0 !important;
}
.rc-form-input,
.rc-form-textarea {
width: 100%;
min-width: 0;
border-radius: 8px !important;
}
.rc-form-textarea {
min-height: 88px;
}
.rc-toast {
position: fixed;
top: 5rem;
left: 50%;
z-index: 160;
display: flex;
width: min(42rem, calc(100vw - 2rem));
align-items: center;
justify-content: center;
gap: 10px;
padding: 12px 14px;
border: 1px solid var(--rc-color-success-border);
border-radius: 14px;
color: var(--rc-color-success);
background: color-mix(in oklab, var(--rc-color-success-soft) 28%, var(--rc-bg-surface));
box-shadow: var(--rc-shadow-overlay);
font-size: 14px;
font-weight: 500;
transform: translateX(-50%);
}
.rc-toast--error {
border-color: var(--rc-color-danger-border);
color: var(--rc-color-danger);
background: color-mix(in oklab, var(--rc-color-danger-soft) 28%, var(--rc-bg-surface));
}
.rc-toast__icon,
.rc-toast__close {
display: grid;
width: 20px;
height: 20px;
flex: none;
place-items: center;
}
.rc-toast__close {
padding: 0;
border: 0;
border-radius: 999px;
color: currentColor;
background: transparent;
}
.audit-state {
display: flex;
min-height: 16rem;
align-items: center;
justify-content: center;
gap: 10px;
color: var(--rc-text-muted);
font-size: 14px;
}
.audit-state--error {
flex-direction: column;
color: var(--rc-color-danger);
text-align: center;
}
.audit-empty-state {
min-height: 15rem;
border: 1px solid var(--rc-border-default);
border-radius: 20px;
background: var(--rc-bg-surface);
}
.audit-control-section {
display: grid;
min-width: 0;
gap: 12px;
padding-top: 20px;
border-top: 1px solid var(--rc-border-default);
}
.audit-control-section:first-child {
padding-top: 0;
border-top: 0;
}
.audit-control-points-table {
min-width: 0;
}
.audit-control-points-table .pm-data-grid-cell-content {
width: 100%;
}
.audit-control-points-table .rc-form-textarea {
min-width: 220px;
}
.audit-control-points-table .rc-form-select {
min-width: 128px;
}
.audit-attachment-group {
display: grid;
min-width: 260px;
gap: 8px;
}
.audit-attachment-group + .audit-attachment-group {
margin-top: 16px;
}
.audit-attachment-label {
color: var(--rc-text-secondary);
font-size: 12px;
font-weight: 500;
line-height: 18px;
}
.audit-attachment-list {
display: grid;
gap: 4px;
}
.audit-attachment-item {
display: flex;
min-width: 0;
min-height: 32px;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.audit-attachment-link {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 6px;
color: var(--rc-brand-primary);
font-size: 13px;
font-weight: 500;
a {
color: inherit;
text-decoration: none;
}
.audit-attachment-link:hover {
color: var(--rc-brand-primary-hover);
text-decoration: underline;
text-underline-offset: 3px;
}
.audit-attachment-empty {
color: var(--rc-text-muted);
font-size: 12px;
}
.audit-attachment-upload .drop-zone__area {
min-height: 116px;
padding: 12px;
}
.audit-attachment-upload .drop-zone__label,
.audit-attachment-upload .drop-zone__description {
font-size: 12px;
}
.audit-attachment-error {
color: var(--rc-color-danger);
font-size: 12px;
line-height: 18px;
}
.audit-detail-header,
.audit-detail-heading,
.audit-detail-title-row {
display: flex;
min-width: 0;
align-items: center;
}
.audit-detail-header {
flex-wrap: wrap;
justify-content: space-between;
gap: 16px;
}
.audit-detail-heading {
gap: 12px;
}
.audit-detail-title-row {
flex-wrap: wrap;
gap: 8px;
}
.audit-detail-title-row h2 {
margin: 0;
color: var(--rc-text-primary);
font-size: 18px;
font-weight: 600;
line-height: 26px;
}
.audit-detail-heading p {
margin: 4px 0 0;
color: var(--rc-text-muted);
font-size: 13px;
line-height: 20px;
}
.audit-back-button {
width: 32px !important;
min-width: 32px !important;
height: 32px !important;
padding: 0 !important;
border-radius: 999px !important;
}
.audit-detail-summary {
display: flex;
flex-wrap: wrap;
gap: 8px 32px;
padding: 14px 0;
border-block: 1px solid var(--rc-border-default);
color: var(--rc-text-muted);
font-size: 14px;
}
.audit-detail-summary strong {
margin-left: 4px;
color: var(--rc-text-primary);
font-weight: 600;
}
.audit-detail-summary strong.is-success {
color: var(--rc-color-success);
}
.audit-fill-page {
min-height: 100vh;
padding: 24px;
color: var(--rc-text-primary);
background: var(--rc-bg-canvas);
}
.audit-fill-shell {
width: min(100%, 1600px);
min-width: 0;
margin: 0 auto;
padding: 28px;
border: 1px solid var(--rc-border-default);
border-radius: 24px;
background: var(--rc-bg-surface);
box-shadow: var(--rc-shadow-card);
}
.audit-fill-header {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
padding-bottom: 24px;
border-bottom: 1px solid var(--rc-border-default);
}
.audit-fill-identity {
display: flex;
min-width: 0;
align-items: flex-start;
gap: 12px;
}
.audit-fill-logo {
width: 36px;
height: 36px;
flex: none;
object-fit: contain;
}
.audit-fill-logo--dark {
display: none;
}
.dark .audit-fill-logo--light {
display: none;
}
.dark .audit-fill-logo--dark {
display: block;
}
.audit-fill-identity span {
display: block;
margin-bottom: 4px;
color: var(--rc-brand-primary);
font-size: 12px;
font-weight: 600;
line-height: 18px;
}
.dark .audit-fill-identity span {
color: var(--rc-brand-light);
}
.audit-fill-identity h1 {
margin: 0;
color: var(--rc-text-primary);
font-size: 20px;
font-weight: 600;
line-height: 28px;
}
.audit-fill-identity p {
margin: 4px 0 0;
color: var(--rc-text-muted);
font-size: 14px;
line-height: 20px;
}
.audit-fill-content {
display: grid;
gap: 20px;
margin-top: 24px;
}
.audit-fill-actions {
position: sticky;
bottom: 0;
z-index: 20;
display: flex;
justify-content: flex-end;
gap: 8px;
margin: 24px -8px -8px;
padding: 16px 8px 8px;
border-top: 1px solid var(--rc-border-default);
background: color-mix(in srgb, var(--rc-bg-surface) 94%, transparent);
-webkit-backdrop-filter: blur(12px);
backdrop-filter: blur(12px);
}
@media (max-width: 900px) {
.audit-stat-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 767px) {
.login-card [data-slot='card-content'] {
padding: 28px 24px !important;
}
.login-brand strong {
font-size: 28px;
}
.audit-page-intro,
.audit-list-header,
.audit-section-header,
.audit-filter-card {
align-items: stretch;
flex-direction: column;
}
.audit-filter-card .rc-input-group,
.audit-filter-card .rc-form-input {
width: 100%;
}
.audit-report-section .audit-filter-card > .rc-button:last-child {
width: 100%;
margin-left: 0;
}
.audit-stat-grid {
grid-template-columns: 1fr;
}
.audit-detail-header,
.audit-section-header {
align-items: stretch;
}
.audit-detail-header > .rc-button,
.audit-section-header > .rc-button {
width: 100%;
}
.audit-fill-page {
padding: 0;
}
.audit-fill-shell {
padding: 20px 16px;
border: 0;
border-radius: 0;
box-shadow: none;
}
.audit-fill-header > .rc-button {
width: 100%;
}
.audit-fill-actions {
margin-inline: -8px;
}
.audit-fill-actions .rc-button {
flex: 1;
}
}
import xyLogo from '@/assets/identity/xy-logo-red.png';
import xyLogoRed from '@/assets/identity/xy-logo-red.png';
import xyLogoWhite from '@/assets/identity/xy-logo-white.png';
import { APP_DISPLAY_NAME } from '@/constants/app';
import {
APP_BASE_PATH,
APP_DISPLAY_NAME,
} from '@/constants/app';
import authService from '@/services/authService';
import { getCaptcha, userLogin } from '@/services/DataService';
import { SiteContext } from '@/wrapper/Site';
import { Alert, Button, Card, Input, Label, Spinner, TextField } from '@heroui/react';
import { Alert, Button, Card, Input, Label, TextField } from '@heroui/react';
import CryptoJS from 'crypto-js';
import { useContext, useEffect, useState } from 'react';
import { history, useSearchParams } from 'umi';
......@@ -14,6 +17,35 @@ function buildPassword(password, siteKey) {
return siteKey ? CryptoJS.AES.encrypt(password, siteKey).toString() : password;
}
function normalizeRedirect(value) {
if (!value) {
return '/';
}
try {
const target = new URL(value, window.location.origin);
if (target.origin !== window.location.origin) {
return '/';
}
const applicationBasePath = APP_BASE_PATH.replace(/\/+$/, '');
const targetPath = `${target.pathname}${target.search}${target.hash}`;
if (
applicationBasePath &&
(target.pathname === applicationBasePath ||
target.pathname.startsWith(`${applicationBasePath}/`))
) {
return targetPath.slice(applicationBasePath.length) || '/';
}
return targetPath;
} catch {
return '/';
}
}
export default function LoginPage() {
const site = useContext(SiteContext);
const [searchParams] = useSearchParams();
......@@ -24,12 +56,14 @@ export default function LoginPage() {
const [captchaLoading, setCaptchaLoading] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const redirect = searchParams.get('redirect') || '/';
const redirect = normalizeRedirect(searchParams.get('redirect'));
const refreshCaptcha = async () => {
setCaptchaLoading(true);
try {
setCaptchaData((await getCaptcha()) || null);
const nextCaptcha = await getCaptcha();
setCaptchaData(nextCaptcha || null);
} catch (requestError) {
setCaptchaData(null);
setError(requestError?.message || '验证码加载失败,请稍后重试');
......@@ -39,7 +73,7 @@ export default function LoginPage() {
};
useEffect(() => {
document.title = APP_DISPLAY_NAME;
document.title = `${APP_DISPLAY_NAME} - 登录`;
refreshCaptcha();
}, []);
......@@ -47,6 +81,7 @@ export default function LoginPage() {
event.preventDefault();
setLoading(true);
setError('');
try {
const loginInfo = await userLogin({
identity,
......@@ -55,6 +90,7 @@ export default function LoginPage() {
token: captchaData?.token,
type: 'password',
});
authService.setCurrentUser(loginInfo);
if (loginInfo?.token) authService.setToken(loginInfo.token);
history.push(redirect);
......@@ -68,45 +104,131 @@ export default function LoginPage() {
};
return (
<main className="login-page">
<Card className="login-card">
<main className="login-page rc-login-page">
<section className="login-shell rc-login-shell">
<Card className="login-card rc-login-card">
<Card.Content>
<div className="login-brand login-card-brand">
<img alt="鑫元" className="login-brand-logo login-brand-logo--light" src={xyLogo} />
<img alt="" aria-hidden="true" className="login-brand-logo login-brand-logo--dark" src={xyLogoWhite} />
<div className="login-brand login-card-brand rc-login-brand">
<span className="login-brand-logo">
<img
alt="鑫元"
className="rc-brand-logo rc-brand-logo--light"
src={xyLogoRed}
/>
<img
alt="鑫元"
className="rc-brand-logo rc-brand-logo--dark"
src={xyLogoWhite}
/>
</span>
<strong>{APP_DISPLAY_NAME}</strong>
</div>
<form className="login-form" onSubmit={handleSubmit}>
<TextField className="login-field" isDisabled={loading} isRequired value={identity} onChange={setIdentity}>
<form className="login-form rc-login-form" onSubmit={handleSubmit}>
<TextField
className="login-field"
data-has-value={identity ? 'true' : undefined}
fullWidth
isDisabled={loading}
isRequired
value={identity}
onChange={setIdentity}
>
<Label>账号</Label>
<Input autoComplete="username" className="login-input" data-filled={identity ? 'true' : undefined} placeholder="请输入账号" variant="secondary" />
<Input
autoComplete="username"
autoFocus
className="login-input"
data-filled={identity ? 'true' : undefined}
placeholder="请输入账号"
variant="secondary"
/>
</TextField>
<TextField className="login-field" isDisabled={loading} isRequired value={password} onChange={setPassword}>
<TextField
className="login-field"
data-has-value={password ? 'true' : undefined}
fullWidth
isDisabled={loading}
isRequired
type="password"
value={password}
onChange={setPassword}
>
<Label>密码</Label>
<Input autoComplete="current-password" className="login-input" data-filled={password ? 'true' : undefined} placeholder="请输入密码" type="password" variant="secondary" />
<Input
autoComplete="current-password"
className="login-input"
data-filled={password ? 'true' : undefined}
placeholder="请输入密码"
variant="secondary"
/>
</TextField>
<TextField className="login-field" isDisabled={loading} isRequired value={captchaCode} onChange={setCaptchaCode}>
<div
className="login-field"
data-has-value={captchaCode ? 'true' : undefined}
>
<Label>验证码</Label>
<div className="login-captcha-row">
<Input className="login-input" data-filled={captchaCode ? 'true' : undefined} placeholder="请输入验证码" variant="secondary" />
<button
<Input
aria-label="验证码"
autoComplete="off"
className="login-input"
data-filled={captchaCode ? 'true' : undefined}
disabled={loading}
placeholder="请输入验证码"
required
value={captchaCode}
variant="secondary"
onChange={(event) => setCaptchaCode(event.target.value)}
/>
<Button
aria-label="刷新验证码"
className="login-captcha"
disabled={loading || captchaLoading}
onClick={() => { setError(''); refreshCaptcha(); }}
type="button"
className="login-captcha-button"
isDisabled={loading || captchaLoading}
variant="secondary"
onPress={() => {
setError('');
refreshCaptcha();
}}
>
{captchaData?.image ? <span dangerouslySetInnerHTML={{ __html: captchaData.image }} /> : captchaLoading ? <Spinner size="sm" /> : '刷新验证码'}
</button>
{captchaData?.image ? (
<span
className="captcha-image"
dangerouslySetInnerHTML={{ __html: captchaData.image }}
/>
) : (
<span>{captchaLoading ? '加载中...' : '刷新验证码'}</span>
)}
</Button>
</div>
</TextField>
{error ? <Alert className="login-error" color="danger" role="alert">{error}</Alert> : null}
<Button className="login-submit-button" fullWidth isDisabled={captchaLoading} isPending={loading} type="submit" variant="primary">
{loading ? '正在登录...' : '登录'}
</div>
{error ? (
<Alert status="danger">
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
<Button
fullWidth
className="login-submit-button"
isDisabled={
loading || captchaLoading || !identity || !password || !captchaCode
}
isPending={loading}
type="submit"
variant="primary"
>
{loading ? '登录中...' : '登录'}
</Button>
</form>
</Card.Content>
</Card>
</section>
</main>
);
}
import { DataTable, DataTableChip, DataTableTextPair } from '@/components/DataTable';
import FilterSelect from '@/components/FilterSelect';
import { Edit, Search, Delete } from '@/components/AppIcons';
import { Delete, Edit, Eye, Search } from '@/components/AppIcons';
import { TableActionButton, TableActionGroup } from '@/components/TableActionButton';
import { useAuthorization } from '@/hooks/user';
import { AlertDialog, Button, FieldError, Input, Label, Modal, Tabs, TextArea, TextField, Tooltip } from '@heroui/react';
import { AlertDialog, Button, FieldError, Input, Label, Modal, SearchField, Tabs, TextArea, TextField, Tooltip } from '@heroui/react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { EmptyState, ErrorState, InlineNotice, LoadingState } from './PageState';
import ReferenceSearchSelect from './ReferenceSearchSelect';
import ControlPointPreviewDialog from './ControlPointPreviewDialog';
import {
AUDIT_SUBJECTS,
createAuditProcess,
deleteAuditProcess,
loadAuditDefinitions,
normalizeRecordId,
queryAuditProcesses,
......@@ -45,7 +47,7 @@ function requiredLabel(label, required) {
return (
<>
{label}
{required ? <span className="ml-1 text-red-600">*</span> : null}
{required ? <span aria-hidden="true"> *</span> : null}
</>
);
}
......@@ -93,20 +95,20 @@ function AuditProjectDialog({ open, onClose, onSaved }) {
<Modal.Body className="rc-dialog-body space-y-5">
<TextField className="rc-form-field" isRequired value={form.title} onChange={(value) => setForm((current) => ({ ...current, title: value }))}>
<Label>项目名称</Label>
<Input placeholder="请输入项目名称" autoFocus />
<Input placeholder="请输入项目名称" autoFocus variant="secondary" />
</TextField>
<TextField className="rc-form-field" isRequired value={String(form.order)} onChange={(value) => setForm((current) => ({ ...current, order: value }))}>
<Label>项目序号</Label>
<Input type="number" min="1" max="500" />
<Input type="number" min="1" max="500" variant="secondary" />
</TextField>
<TextField className="rc-form-field" value={form.description} onChange={(value) => setForm((current) => ({ ...current, description: value }))}>
<Label>项目描述</Label>
<TextArea rows={4} />
<TextArea rows={4} variant="secondary" />
</TextField>
{error ? <FieldError>{error}</FieldError> : null}
</Modal.Body>
<Modal.Footer className="rc-dialog-actions">
<Button variant="outline" onPress={onClose} isDisabled={saving}>取消</Button>
<Button variant="secondary" onPress={onClose} isDisabled={saving}>取消</Button>
<Button variant="primary" onPress={handleSave} isDisabled={saving}>
{saving ? '保存中...' : '保存'}
</Button>
......@@ -185,18 +187,18 @@ function ControlPointDialog({ open, onClose, onSaved, point, definition }) {
<Modal.Root isOpen={open} onOpenChange={(isOpen) => { if (!isOpen && !saving) onClose(); }}>
<Modal.Trigger aria-hidden="true" aria-label={`打开${point ? '编辑' : '添加'}控制点弹窗`} className="sr-only" tabIndex={-1} />
<Modal.Backdrop className="rc-dialog-backdrop" isDismissable={!saving} variant="blur">
<Modal.Container placement="center" scroll="inside" size="3xl">
<Modal.Container placement="center" scroll="inside" size="lg">
<Modal.Dialog className="rc-dialog-panel rc-dialog-panel--3xl">
<Modal.CloseTrigger aria-label="关闭" className="rc-dialog-close" />
<Modal.Header><Modal.Heading className="rc-dialog-title">{point ? '编辑控制点' : '添加控制点'}</Modal.Heading></Modal.Header>
<Modal.Body className="rc-dialog-body grid gap-5 sm:grid-cols-2">
<TextField className="rc-form-field sm:col-span-2" isRequired value={form.name} onChange={(value) => change('name', value)}>
<Label>名称</Label>
<Input />
<Input placeholder="请输入控制点名称" variant="secondary" />
</TextField>
<TextField className="rc-form-field sm:col-span-2" value={form.description} onChange={(value) => change('description', value)}>
<Label>描述信息</Label>
<TextArea rows={4} />
<TextArea placeholder="请输入控制点描述" rows={4} variant="secondary" />
</TextField>
<div className="rc-form-field">
<Label>{requiredLabel('责任人', true)}</Label>
......@@ -229,13 +231,13 @@ function ControlPointDialog({ open, onClose, onSaved, point, definition }) {
</div>
<div className="rc-form-field">
<Label>{requiredLabel('控制措施', Boolean(typeField?.required))}</Label>
<FilterSelect aria-label="控制措施" options={typeOptions} value={form.type} onChange={(value) => change('type', value)} />
<FilterSelect aria-label="控制措施" options={typeOptions} value={form.type} variant="secondary" onChange={(value) => change('type', value)} />
</div>
{form.type === 'form' ? (
<TextField className="rc-form-field" value={form.form_label} onChange={(value) => change('form_label', value)}>
<Label>文本描述</Label>
<Input />
<Input placeholder="请输入文本描述" variant="secondary" />
</TextField>
) : null}
......@@ -249,11 +251,11 @@ function ControlPointDialog({ open, onClose, onSaved, point, definition }) {
const labels = [...form.attachment_label];
labels[index] = value;
change('attachment_label', labels);
}}><Input placeholder="请输入文件类别" /></TextField>
}}><Input placeholder="请输入文件类别" variant="secondary" /></TextField>
<Tooltip delay={0}><Button isIconOnly type="button" aria-label="删除文件描述" size="sm" variant="ghost" onPress={() => change('attachment_label', form.attachment_label.filter((_, itemIndex) => itemIndex !== index))}><Delete size={16} /></Button><Tooltip.Content>删除文件描述</Tooltip.Content></Tooltip>
</div>
))}
<Button variant="outline" type="button" onPress={() => change('attachment_label', [...form.attachment_label, ''])}>
<Button variant="secondary" type="button" onPress={() => change('attachment_label', [...form.attachment_label, ''])}>
添加文件描述
</Button>
</div>
......@@ -273,7 +275,7 @@ function ControlPointDialog({ open, onClose, onSaved, point, definition }) {
</div>
<TextField className="rc-form-field" value={String(form.computed_interval)} onChange={(value) => change('computed_interval', value)}>
<Label>执行频率(天)</Label>
<Input type="number" min="1" />
<Input type="number" min="1" variant="secondary" />
</TextField>
</>
) : null}
......@@ -281,7 +283,7 @@ function ControlPointDialog({ open, onClose, onSaved, point, definition }) {
{error ? <FieldError className="sm:col-span-2">{error}</FieldError> : null}
</Modal.Body>
<Modal.Footer className="rc-dialog-actions">
<Button variant="outline" onPress={onClose} isDisabled={saving}>取消</Button>
<Button variant="secondary" onPress={onClose} isDisabled={saving}>取消</Button>
<Button variant="primary" onPress={handleSave} isDisabled={saving}>
{saving ? '保存中...' : '保存'}
</Button>
......@@ -304,7 +306,10 @@ export default function AuditProcess() {
const [error, setError] = useState('');
const [notice, setNotice] = useState(null);
const [projectDialogOpen, setProjectDialogOpen] = useState(false);
const [projectToDelete, setProjectToDelete] = useState(null);
const [deletingProject, setDeletingProject] = useState(false);
const [pointDialog, setPointDialog] = useState(null);
const [pointToPreview, setPointToPreview] = useState(null);
const [deletePointIndex, setDeletePointIndex] = useState(null);
const loadData = useCallback(async (preferredId) => {
......@@ -377,18 +382,19 @@ export default function AuditProcess() {
minWidth: 240,
render: ({ point }) => (point.related_regulations || []).length ? point.related_regulations.map((regulation) => <div key={normalizeRecordId(regulation)}>{regulation.title || regulation.name}</div>) : '-',
},
...(canManage ? [{
{
key: 'actions',
title: '操作',
width: 112,
width: canManage ? 136 : 88,
isActions: true,
render: ({ point, index }) => (
<TableActionGroup className="justify-start">
<TableActionButton icon={Edit} label="编辑控制点" onPress={() => setPointDialog({ point, index })} tone="edit" />
<TableActionButton icon={Delete} label="删除控制点" onPress={() => setDeletePointIndex(index)} tone="delete" />
<TableActionButton icon={Eye} label="查看控制点" onPress={() => setPointToPreview({ point, index })} tone="view" />
{canManage ? <TableActionButton icon={Edit} label="编辑控制点" onPress={() => setPointDialog({ point, index })} tone="edit" /> : null}
{canManage ? <TableActionButton icon={Delete} label="删除控制点" onPress={() => setDeletePointIndex(index)} tone="delete" /> : null}
</TableActionGroup>
),
}] : []),
},
];
const savePoint = async (nextPoint) => {
......@@ -417,44 +423,73 @@ export default function AuditProcess() {
}
};
const removeProject = async () => {
if (!projectToDelete) return;
setDeletingProject(true);
try {
await deleteAuditProcess(projectToDelete);
setProjectToDelete(null);
setKeyword('');
setNotice({ tone: 'success', text: '稽核项目已删除' });
await loadData();
} catch (requestError) {
setProjectToDelete(null);
setNotice({ tone: 'error', text: requestError?.message || '删除稽核项目失败' });
} finally {
setDeletingProject(false);
}
};
if (loading && processes.length === 0) return <LoadingState label="正在加载稽核项目..." />;
if (error && processes.length === 0) return <ErrorState message={error} onRetry={loadData} />;
return (
<div className="audit-page">
<div className="audit-page-intro">
{canManage ? (
<Button size="sm" variant="primary" onPress={() => setProjectDialogOpen(true)}>添加项目</Button>
) : null}
</div>
{notice ? (
<InlineNotice tone={notice.tone} onDismiss={() => setNotice(null)}>{notice.text}</InlineNotice>
) : null}
<div className="rc-page-stack">
{processes.length > 1 || canManage ? (
<div className="flex min-w-0 items-center gap-4">
{processes.length > 1 ? (
<Tabs className="filter-tabs-wrap audit-project-tabs" selectedKey={activeId} onSelectionChange={(key) => setActiveId(String(key))}>
<Tabs.ListContainer>
<Tabs.List aria-label="稽核项目">
<Tabs className="min-w-0 flex-1" selectedKey={activeId} onSelectionChange={(key) => setActiveId(String(key))}>
<Tabs.ListContainer className="w-fit max-w-full min-w-0">
<Tabs.List aria-label="稽核项目" className="w-max min-w-max">
{processes.map((process) => {
const id = normalizeRecordId(process);
return <Tabs.Tab id={id} key={id}>{process.order}.{process.title}<Tabs.Indicator /></Tabs.Tab>;
return <Tabs.Tab className="w-auto min-w-max flex-none whitespace-nowrap" id={id} key={id}>{process.order}.{process.title}<Tabs.Indicator /></Tabs.Tab>;
})}
</Tabs.List>
</Tabs.ListContainer>
</Tabs>
) : null}
{activeProcess ? (
<section className="audit-report-section">
<div className="audit-filter-card">
<TextField aria-label="搜索控制点" className="audit-filter-search" value={keyword} onChange={setKeyword}>
<div className="audit-filter-search__control"><Search size={16} /><Input placeholder="搜索控制点、描述或责任人" /></div>
</TextField>
{canManage ? (
<Button size="sm" variant="primary" onPress={() => setPointDialog({ point: null, index: null })}>添加控制点</Button>
<div className="ml-auto flex shrink-0 items-center gap-2">
{activeProcess ? (
<>
<Button size="sm" variant="secondary" onPress={() => setPointDialog({ point: null, index: null })}>添加控制点</Button>
<Button size="sm" variant="danger-soft" onPress={() => setProjectToDelete(activeProcess)}>删除项目</Button>
</>
) : null}
<Button size="sm" variant="primary" onPress={() => setProjectDialogOpen(true)}>添加项目</Button>
</div>
) : null}
</div>
) : null}
{notice ? (
<InlineNotice tone={notice.tone} onDismiss={() => setNotice(null)}>{notice.text}</InlineNotice>
) : null}
{activeProcess ? (
<section className="rc-list-stack">
<h2>控制点列表</h2>
<div className="rc-page-toolbar rc-page-toolbar-responsive">
<SearchField className="w-full sm:w-96" value={keyword} onChange={setKeyword}>
<SearchField.Group>
<SearchField.SearchIcon><Search size={16} /></SearchField.SearchIcon>
<SearchField.Input aria-label="搜索控制点" placeholder="搜索控制点、描述或责任人" />
<SearchField.ClearButton />
</SearchField.Group>
</SearchField>
</div>
<DataTable
ariaLabel="稽核控制点"
......@@ -488,6 +523,39 @@ export default function AuditProcess() {
point={pointDialog?.point}
definition={definition}
/>
<ControlPointPreviewDialog
open={Boolean(pointToPreview)}
onClose={() => setPointToPreview(null)}
orderLabel={pointToPreview ? `${activeProcess?.order || ''}.${pointToPreview.index + 1}` : ''}
point={pointToPreview?.point}
typeLabel={pointToPreview
? fieldByName(definition, 'type')?.settings?.options?.find(
(option) => option.value === pointToPreview.point?.type,
)?.label
: ''}
/>
<AlertDialog.Root
isOpen={Boolean(projectToDelete)}
onOpenChange={(isOpen) => {
if (!isOpen && !deletingProject) setProjectToDelete(null);
}}
>
<AlertDialog.Trigger aria-hidden="true" aria-label="打开删除项目确认弹窗" className="sr-only" tabIndex={-1} />
<AlertDialog.Backdrop className="rc-dialog-backdrop" isDismissable={!deletingProject} variant="blur">
<AlertDialog.Container placement="center" size="md">
<AlertDialog.Dialog className="rc-dialog-panel rc-dialog-panel--md">
<AlertDialog.Header><AlertDialog.Heading className="rc-dialog-title">删除稽核项目</AlertDialog.Heading></AlertDialog.Header>
<AlertDialog.Body className="rc-dialog-body">
删除“{projectToDelete?.title || '当前项目'}”后,后续新生成的报告将不再包含该项目;已生成报告的快照不受影响。
</AlertDialog.Body>
<AlertDialog.Footer className="rc-dialog-actions">
<Button variant="secondary" isDisabled={deletingProject} onPress={() => setProjectToDelete(null)}>取消</Button>
<Button variant="danger" isPending={deletingProject} onPress={removeProject}>确认删除</Button>
</AlertDialog.Footer>
</AlertDialog.Dialog>
</AlertDialog.Container>
</AlertDialog.Backdrop>
</AlertDialog.Root>
<AlertDialog.Root isOpen={deletePointIndex !== null} onOpenChange={(isOpen) => { if (!isOpen) setDeletePointIndex(null); }}>
<AlertDialog.Trigger aria-hidden="true" aria-label="打开删除控制点确认弹窗" className="sr-only" tabIndex={-1} />
<AlertDialog.Backdrop className="rc-dialog-backdrop" variant="blur">
......@@ -496,7 +564,7 @@ export default function AuditProcess() {
<AlertDialog.Header><AlertDialog.Heading className="rc-dialog-title">删除控制点</AlertDialog.Heading></AlertDialog.Header>
<AlertDialog.Body className="rc-dialog-body">删除后,后续新生成的报告将不再包含该控制点;已生成报告的快照不受影响。</AlertDialog.Body>
<AlertDialog.Footer className="rc-dialog-actions">
<Button variant="outline" onPress={() => setDeletePointIndex(null)}>取消</Button>
<Button variant="secondary" onPress={() => setDeletePointIndex(null)}>取消</Button>
<Button variant="danger" onPress={removePoint}>确认删除</Button>
</AlertDialog.Footer>
</AlertDialog.Dialog>
......
import { DataTable, DataTableChip, DataTablePrimaryText } from '@/components/DataTable';
import { CheckCircleIcon, ClipboardDocumentListIcon, ClockIcon, Project } from '@/components/AppIcons';
import { DataTable, DataTablePrimaryText } from '@/components/DataTable';
import { CheckCircleIcon, ClipboardDocumentListIcon, ClockIcon, Eye, Project } from '@/components/AppIcons';
import { TableActionButton, TableActionGroup } from '@/components/TableActionButton';
import { KPI } from '@heroui-pro/react/kpi';
import { useAuthorization } from '@/hooks/user';
import { Search } from '@/components/AppIcons';
import { AlertDialog, Button, Input, Modal, Tabs, TextField } from '@heroui/react';
import { AlertDialog, Button, Chip, Modal, Surface, Tabs } from '@heroui/react';
import dayjs from 'dayjs';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { history } from 'umi';
......@@ -29,6 +30,20 @@ import {
updateReportPoint,
} from './auditReportModel';
function StatCard({ label, value, icon: Icon, tone }) {
return (
<KPI className="rc-card-surface rc-dashboard-card">
<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>
);
}
function FillLinksDialog({ open, onClose, report, onNotice }) {
const owners = useMemo(() => {
const ownerMap = new Map();
......@@ -63,16 +78,16 @@ function FillLinksDialog({ open, onClose, report, onNotice }) {
<Modal.CloseTrigger aria-label="关闭" className="rc-dialog-close" />
<Modal.Header><Modal.Heading className="rc-dialog-title">责任部门填报链接</Modal.Heading></Modal.Header>
<Modal.Body className="rc-dialog-body">
<div className="divide-y divide-[var(--rc-border-default)]">
<div className="flex flex-col gap-3">
{owners.map((owner) => (
<div key={owner.id} className="flex items-center justify-between gap-3 py-3">
<span className="text-sm font-medium text-[var(--rc-text-primary)]">{owner.label}</span>
<Button size="sm" variant="outline" onPress={() => copyLink(owner)}>复制链接</Button>
</div>
<Surface key={owner.id} className="flex items-center justify-between gap-3 px-4 py-3" variant="secondary">
<span>{owner.label}</span>
<Button size="sm" variant="secondary" onPress={() => copyLink(owner)}>复制链接</Button>
</Surface>
))}
</div>
</Modal.Body>
<Modal.Footer className="rc-dialog-actions"><Button size="sm" variant="outline" onPress={onClose}>关闭</Button></Modal.Footer>
<Modal.Footer className="rc-dialog-actions"><Button size="sm" variant="secondary" onPress={onClose}>关闭</Button></Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
......@@ -128,7 +143,24 @@ export default function AuditReport() {
{ key: 'title', title: '报告名称', minWidth: 300, render: (item) => <DataTablePrimaryText text={item.title} showTooltip /> },
{ key: 'created', title: '创建时间', width: 180, render: (item) => dayjs(item.created_at).format('YYYY-MM-DD HH:mm') },
{ key: 'submitted', title: '提交时间', width: 180, render: (item) => item.metadata?.submitted_time ? dayjs(item.metadata.submitted_time).format('YYYY-MM-DD HH:mm') : '-' },
{ key: 'status', title: '状态', width: 120, render: () => <DataTableChip className="rc-status-chip--success">已归档</DataTableChip> },
{ key: 'status', title: '状态', width: 120, render: () => <Chip color="success" size="sm" variant="soft">已归档</Chip> },
{
key: 'actions',
title: '操作',
width: 88,
isActions: true,
align: 'end',
render: (item) => (
<TableActionGroup className="justify-end">
<TableActionButton
icon={Eye}
label="查看历史报告"
tone="view"
onPress={() => history.push(`/audit_report/${normalizeRecordId(item)}`)}
/>
</TableActionGroup>
),
},
];
const updatePoint = (processIndex, pointIndex, patch) => {
setReport((current) => updateReportPoint(current, processIndex, pointIndex, patch));
......@@ -213,12 +245,19 @@ export default function AuditReport() {
if (error && !report && historyReports.length === 0) return <ErrorState message={error} onRetry={loadData} />;
return (
<div className="audit-page">
<div className="audit-page-intro">
<div className="rc-page-stack">
<div className="flex min-w-0 items-center gap-4">
<Tabs className="w-fit max-w-full min-w-0" selectedKey={tab} onSelectionChange={(key) => { setTab(String(key)); setKeyword(''); }}>
<Tabs.ListContainer className="w-fit max-w-full min-w-0"><Tabs.List aria-label="报告视图" className="w-max min-w-max">
<Tabs.Tab className="w-auto min-w-max flex-none whitespace-nowrap" id="current">进行中<Tabs.Indicator /></Tabs.Tab>
<Tabs.Tab className="w-auto min-w-max flex-none whitespace-nowrap" id="history">历史报告<Tabs.Indicator /></Tabs.Tab>
</Tabs.List></Tabs.ListContainer>
</Tabs>
{tab === 'current' && report ? (
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" onPress={() => exportAuditReport(report)}>导出</Button>
{canManage ? <Button size="sm" variant="outline" onPress={() => setLinksOpen(true)}>填报链接</Button> : null}
<div className="ml-auto flex shrink-0 items-center gap-2">
<Button size="sm" variant="secondary" onPress={() => exportAuditReport(report)}>导出</Button>
{canManage ? <Button size="sm" variant="secondary" onPress={() => setLinksOpen(true)}>填报链接</Button> : null}
{canManage ? (
<Button size="sm" variant="primary" isDisabled={!canSubmitReport(report) || saving} onPress={() => setSubmitOpen(true)}>
提交报告
......@@ -230,46 +269,31 @@ export default function AuditReport() {
{notice ? <InlineNotice tone={notice.tone} onDismiss={() => setNotice(null)}>{notice.text}</InlineNotice> : null}
<div className="audit-stat-grid" data-count="4">
<div className="rc-kpi-grid" data-count="4">
{[
['全部控制点', stats.total, Project, 'teal'],
['未填写', stats.empty, ClipboardDocumentListIcon, 'danger'],
['待确认', stats.pending, ClockIcon, 'warning'],
['已完成', stats.completed, CheckCircleIcon, 'success'],
].map(([label, value, Icon, tone]) => (
<div key={label} className="audit-stat">
<div className="audit-stat__copy"><span className="audit-stat__label">{label}</span><strong className="audit-stat__value">{value}</strong></div>
<span className={`audit-stat__icon rc-metric-icon-surface rc-tone-surface--${tone}`}><Icon size={20} /></span>
</div>
<StatCard key={label} icon={Icon} label={label} tone={tone} value={value} />
))}
</div>
<Tabs className="filter-tabs-wrap audit-project-tabs" selectedKey={tab} onSelectionChange={(key) => { setTab(String(key)); setKeyword(''); }}>
<Tabs.ListContainer><Tabs.List aria-label="报告视图">
<Tabs.Tab id="current">进行中<Tabs.Indicator /></Tabs.Tab>
<Tabs.Tab id="history">历史报告<Tabs.Indicator /></Tabs.Tab>
</Tabs.List></Tabs.ListContainer>
</Tabs>
{tab === 'current' ? (
report ? (
<div className="audit-report-section">
<div className="audit-filter-card">
<TextField aria-label="搜索报告控制点" className="audit-filter-search" value={keyword} onChange={setKeyword}>
<div className="audit-filter-search__control"><Search size={16} /><Input placeholder="搜索控制点、描述或责任人" /></div>
</TextField>
{canManage ? <Button size="sm" variant="outline" onPress={() => persistReport(report)} isDisabled={saving}>{saving ? '保存中...' : '保存备注修改'}</Button> : null}
</div>
<AuditReportContent
report={report}
mode={canManage ? 'review' : 'readonly'}
keyword={keyword}
issueOptions={issueOptions}
isSaving={saving}
onKeywordChange={setKeyword}
onSave={canManage ? () => persistReport(report) : undefined}
onUpdate={updatePoint}
onConfirmPoint={confirmPoint}
onConfirmProcess={confirmProcess}
/>
</div>
) : (
<EmptyState
title="当前没有进行中的报告"
......@@ -297,7 +321,7 @@ export default function AuditReport() {
<AlertDialog.Header><AlertDialog.Heading className="rc-dialog-title">提交并归档报告</AlertDialog.Heading></AlertDialog.Header>
<AlertDialog.Body className="rc-dialog-body">提交后报告将转为只读历史记录,不能继续填报或修改。</AlertDialog.Body>
<AlertDialog.Footer className="rc-dialog-actions">
<Button variant="outline" onPress={() => setSubmitOpen(false)}>取消</Button>
<Button variant="secondary" onPress={() => setSubmitOpen(false)}>取消</Button>
<Button variant="primary" onPress={submit} isDisabled={saving}>确认提交</Button>
</AlertDialog.Footer>
</AlertDialog.Dialog>
......
import { DataTable, DataTableChip, DataTableTextPair } from '@/components/DataTable';
import { DataTable, DataTableTextPair } from '@/components/DataTable';
import FileUploadField from '@/components/FileUploadField';
import FilterSelect from '@/components/FilterSelect';
import { Check, Delete, OpenExternal } from '@/components/AppIcons';
import { TableActionButton } from '@/components/TableActionButton';
import { Button, TextArea, TextField } from '@heroui/react';
import { Check, Delete, Eye, OpenExternal, Search } from '@/components/AppIcons';
import { TableActionButton, TableActionGroup } from '@/components/TableActionButton';
import { Button, Chip, Link, SearchField, Tabs, TextArea, TextField } from '@heroui/react';
import dayjs from 'dayjs';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import {
CONTROL_POINT_STATUS,
hasControlPointValue,
normalizeRecordId,
uploadAuditDocument,
} from './auditManagementService';
import ControlPointPreviewDialog from './ControlPointPreviewDialog';
function normalizeDocuments(value) {
if (!value) return [];
......@@ -19,9 +20,9 @@ function normalizeDocuments(value) {
}
export function ReportStatusBadge({ point }) {
if (!hasControlPointValue(point)) return <DataTableChip className="rc-status-chip--neutral">未填写</DataTableChip>;
if (point.status === CONTROL_POINT_STATUS.DONE) return <DataTableChip className="rc-status-chip--success">已完成</DataTableChip>;
return <DataTableChip className="rc-status-chip--warning">待确认</DataTableChip>;
if (!hasControlPointValue(point)) return <Chip size="sm">未填写</Chip>;
if (point.status === CONTROL_POINT_STATUS.DONE) return <Chip color="success" size="sm" variant="soft">已完成</Chip>;
return <Chip color="warning" size="sm" variant="soft">待确认</Chip>;
}
function PointContent({
......@@ -36,40 +37,42 @@ function PointContent({
if (point.type === 'form') {
if (!editable) {
return <div className="max-w-80 whitespace-pre-wrap text-sm text-zinc-700">{point.value || '-'}</div>;
return <div className="max-w-80 whitespace-pre-wrap">{point.value || '-'}</div>;
}
return (
<TextField aria-label={point.form_label || '检查结果'} value={point.value || ''} onChange={(value) => onUpdate(processIndex, pointIndex, { value })}>
<TextArea rows={4} placeholder={point.form_label || '请输入检查结果'} />
<TextArea rows={4} placeholder={point.form_label || '请输入检查结果'} variant="secondary" />
</TextField>
);
}
if (point.type === 'attachment') {
return (
<div className="min-w-64 space-y-3">
<div className="flex min-w-64 flex-col gap-3">
{(point.attachment_label || []).map((label, labelIndex) => {
const documents = normalizeDocuments(point.value?.[label]);
return (
<div key={`${label}-${labelIndex}`} className="audit-attachment-group">
<div className="audit-attachment-label">{label}</div>
<div key={`${label}-${labelIndex}`} className="flex min-w-0 flex-col gap-2">
<div className="flex min-w-0 items-center justify-between gap-3">
<span className="min-w-0 truncate">{label}</span>
{documents.length > 0 ? (
<div className="audit-attachment-list">
{documents.map((document, documentIndex) => (
<div key={normalizeRecordId(document) || documentIndex} className="audit-attachment-item">
<a
href={document.url}
target="_blank"
rel="noreferrer"
className="audit-attachment-link"
>
<span className="truncate">{document.title || document.name || '附件'}</span>
<OpenExternal size={13} />
</a>
<div className="flex min-w-0 flex-wrap items-center justify-end gap-2">
{documents.map((document, documentIndex) => {
const documentLabel = document.title || document.name || '附件';
return (
<div key={normalizeRecordId(document) || documentIndex} className="flex min-w-0 items-center gap-1">
{document.url ? (
<Link href={document.url} target="_blank" rel="noreferrer">
<span className="max-w-48 truncate">{documentLabel}</span>
<Link.Icon><OpenExternal size={13} /></Link.Icon>
</Link>
) : (
<Chip size="sm">{documentLabel}</Chip>
)}
{editable ? (
<TableActionButton
icon={Delete}
label={`删除${document.title || '附件'}`}
label={`删除${documentLabel}`}
tone="delete"
onPress={() => {
const nextDocuments = documents.filter((_, index) => index !== documentIndex);
......@@ -80,15 +83,16 @@ function PointContent({
/>
) : null}
</div>
))}
);
})}
</div>
) : (
<div className="audit-attachment-empty">未上传</div>
<Chip size="sm">未上传</Chip>
)}
</div>
{editable ? (
<FileUploadField
ariaLabel={`上传${label}`}
className="audit-attachment-upload"
description="支持拖拽或选择单个文件,上传后可继续追加。"
isDisabled={Boolean(uploadingLabel)}
triggerLabel={uploadingLabel === label ? '上传中...' : '选择文件'}
......@@ -116,14 +120,14 @@ function PointContent({
</div>
);
})}
{uploadError ? <div className="audit-attachment-error">{uploadError}</div> : null}
{uploadError ? <div role="alert">{uploadError}</div> : null}
</div>
);
}
if (point.type === 'computed') {
const executions = Array.isArray(point.value) ? point.value : [];
if (executions.length === 0) return <span className="text-zinc-400">暂无执行结果</span>;
if (executions.length === 0) return <span>暂无执行结果</span>;
return (
<div className="space-y-1 text-sm">
{executions.map((execution, index) => (
......@@ -135,7 +139,7 @@ function PointContent({
);
}
return <span className="text-zinc-400">-</span>;
return <span>-</span>;
}
export default function AuditReportContent({
......@@ -144,10 +148,15 @@ export default function AuditReportContent({
ownerId = '',
keyword = '',
issueOptions = [],
isSaving = false,
onKeywordChange,
onSave,
onUpdate,
onConfirmPoint,
onConfirmProcess,
}) {
const [pointToPreview, setPointToPreview] = useState(null);
const [activeProcessKey, setActiveProcessKey] = useState('');
const editableFill = mode === 'fill';
const reviewer = mode === 'review';
const normalizedKeyword = keyword.trim().toLowerCase();
......@@ -155,19 +164,26 @@ export default function AuditReportContent({
.map((process, processIndex) => ({ process, processIndex }))
.filter(({ process }) => {
const points = process.metadata?.control_points || [];
return points.some((point) => {
if (ownerId && normalizeRecordId(point.owner) !== ownerId) return false;
if (ownerId && point.type === 'computed') return false;
if (!normalizedKeyword) return true;
return [point.name, point.description, point.owner?.title]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(normalizedKeyword));
});
if (!ownerId) return true;
return points.some(
(point) => normalizeRecordId(point.owner) === ownerId && point.type !== 'computed',
);
});
const getProcessKey = ({ process, processIndex }) => normalizeRecordId(process) || String(processIndex);
const fallbackProcessKey = processes.length > 0 ? getProcessKey(processes[0]) : '';
const selectedProcessKey = processes.some((entry) => getProcessKey(entry) === activeProcessKey)
? activeProcessKey
: fallbackProcessKey;
const selectedProcessEntry = processes.find((entry) => getProcessKey(entry) === selectedProcessKey);
return (
<div className="space-y-5">
{processes.map(({ process, processIndex }) => {
useEffect(() => {
if (activeProcessKey !== selectedProcessKey) setActiveProcessKey(selectedProcessKey);
}, [activeProcessKey, selectedProcessKey]);
const renderSelectedProcess = () => {
if (!selectedProcessEntry) return null;
const { process, processIndex } = selectedProcessEntry;
const visiblePoints = (process.metadata?.control_points || [])
.map((point, pointIndex) => ({ point, pointIndex }))
.filter(({ point }) => {
......@@ -179,7 +195,6 @@ export default function AuditReportContent({
.some((value) => String(value).toLowerCase().includes(normalizedKeyword));
});
const allPoints = process.metadata?.control_points || [];
const completed = allPoints.filter((point) => point.status === CONTROL_POINT_STATUS.DONE).length;
const confirmable = allPoints.some(
(point) => hasControlPointValue(point) && point.status === CONTROL_POINT_STATUS.UNDO,
);
......@@ -232,7 +247,7 @@ export default function AuditReportContent({
render: ({ point, pointIndex }) => {
const canEditPoint = editableFill && point.status !== CONTROL_POINT_STATUS.DONE;
return canEditPoint ? (
<FilterSelect aria-label="是否存在异常" options={issueOptions} placeholder="请选择" value={point.issue || ''} onChange={(value) => onUpdate(processIndex, pointIndex, { issue: value })} />
<FilterSelect aria-label="是否存在异常" options={issueOptions} placeholder="请选择" value={point.issue || ''} variant="secondary" onChange={(value) => onUpdate(processIndex, pointIndex, { issue: value })} />
) : point.issue || '-';
},
},
......@@ -244,7 +259,7 @@ export default function AuditReportContent({
const canEditPoint = editableFill && point.status !== CONTROL_POINT_STATUS.DONE;
return canEditPoint || reviewer ? (
<TextField aria-label="备注" isDisabled={reviewer && point.status === CONTROL_POINT_STATUS.DONE} value={point.remark || ''} onChange={(value) => onUpdate(processIndex, pointIndex, { remark: value })}>
<TextArea rows={3} placeholder="填写备注" />
<TextArea rows={3} placeholder="填写备注" variant="secondary" />
</TextField>
) : point.remark || '-';
},
......@@ -255,49 +270,99 @@ export default function AuditReportContent({
minWidth: 112,
render: ({ point }) => <ReportStatusBadge point={point} />,
},
...(reviewer && hasVisibleConfirmAction
? [{
{
key: 'actions',
title: '操作',
width: 88,
width: reviewer && hasVisibleConfirmAction ? 120 : 88,
isActions: true,
align: 'end',
render: ({ point, pointIndex }) => (
hasControlPointValue(point) && point.status === CONTROL_POINT_STATUS.UNDO ? (
<div className="icon-actions flex justify-end">
<TableActionGroup className="justify-end">
<TableActionButton
icon={Eye}
label="查看控制点"
tone="view"
onPress={() => setPointToPreview({
orderLabel: `${process.order}.${pointIndex + 1}`,
point,
})}
/>
{reviewer && hasControlPointValue(point) && point.status === CONTROL_POINT_STATUS.UNDO ? (
<TableActionButton icon={Check} label="确认控制点" tone="approve" onPress={() => onConfirmPoint(processIndex, pointIndex)} />
</div>
) : null
) : null}
</TableActionGroup>
),
}]
: []),
},
];
return (
<section key={normalizeRecordId(process) || processIndex} className="audit-control-section">
<div className="audit-section-header">
<div>
<h2>{process.order}.{process.title || process.metadata?.process_name}</h2>
<div className="audit-section-meta">已完成 {completed} / {allPoints.length}</div>
</div>
<section className="rc-list-stack">
{onKeywordChange || onSave || (reviewer && confirmable) ? (
<div className="rc-page-toolbar rc-page-toolbar-responsive">
{onKeywordChange ? (
<SearchField className="w-full md:w-96" value={keyword} onChange={onKeywordChange}>
<SearchField.Group>
<SearchField.SearchIcon><Search size={16} /></SearchField.SearchIcon>
<SearchField.Input aria-label="搜索报告控制点" placeholder="请输入关键词" />
<SearchField.ClearButton />
</SearchField.Group>
</SearchField>
) : null}
{onSave || (reviewer && confirmable) ? (
<div className="flex flex-wrap justify-end gap-2 md:ml-auto">
{reviewer && confirmable ? (
<Button size="sm" variant="outline" onPress={() => onConfirmProcess(processIndex)}>
<Button size="sm" variant="secondary" onPress={() => onConfirmProcess(processIndex)}>
确认本项目已填内容
</Button>
) : null}
{onSave ? (
<Button size="sm" variant="secondary" onPress={onSave} isDisabled={isSaving}>
{isSaving ? '保存中...' : '保存备注修改'}
</Button>
) : null}
</div>
) : null}
</div>
) : null}
<DataTable
ariaLabel={`${process.title || process.metadata?.process_name || '稽核项目'}控制点`}
className="audit-control-points-table"
columns={columns}
contentMinWidth={reviewer && hasVisibleConfirmAction ? 1458 : 1370}
contentMinWidth={reviewer && hasVisibleConfirmAction ? 1490 : 1458}
getRowKey={({ point, pointIndex }) => `${point.name}-${pointIndex}`}
isWide
rows={rows}
/>
</section>
);
};
return (
<div className="rc-list-stack">
{processes.length > 1 ? (
<Tabs className="w-fit max-w-full min-w-0" selectedKey={selectedProcessKey} onSelectionChange={(key) => setActiveProcessKey(String(key))}>
<Tabs.ListContainer className="w-fit max-w-full min-w-0">
<Tabs.List aria-label="稽核报告项目" className="w-max min-w-max">
{processes.map((entry) => {
const { process } = entry;
const processKey = getProcessKey(entry);
return (
<Tabs.Tab className="w-auto min-w-max flex-none whitespace-nowrap" id={processKey} key={processKey}>
{process.order}.{process.title || process.metadata?.process_name}
<Tabs.Indicator />
</Tabs.Tab>
);
})}
</Tabs.List>
</Tabs.ListContainer>
</Tabs>
) : null}
{renderSelectedProcess()}
<ControlPointPreviewDialog
open={Boolean(pointToPreview)}
onClose={() => setPointToPreview(null)}
orderLabel={pointToPreview?.orderLabel}
point={pointToPreview?.point}
/>
</div>
);
}
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} />
......
......@@ -151,26 +151,26 @@ export default function AuditReportFill() {
}
};
if (loading) return <div className="audit-fill-page"><LoadingState label="正在加载填报任务..." /></div>;
if (error || !report) return <div className="audit-fill-page"><ErrorState message={error || '报告不存在'} onRetry={loadData} /></div>;
if (loading) return <div className="min-h-screen"><LoadingState label="正在加载填报任务..." /></div>;
if (error || !report) return <div className="min-h-screen"><ErrorState message={error || '报告不存在'} onRetry={loadData} /></div>;
return (
<div className="audit-fill-page">
<main className="audit-fill-shell">
<header className="audit-fill-header">
<div className="audit-fill-identity">
<img className="audit-fill-logo audit-fill-logo--light" src={xyLogo} alt="鑫元" />
<img className="audit-fill-logo audit-fill-logo--dark" src={xyLogoWhite} alt="" aria-hidden="true" />
<div className="min-h-screen">
<main className="mx-auto flex min-h-screen w-full max-w-[90rem] flex-col gap-8 px-4 py-6 md:px-8 lg:px-12">
<header className="flex flex-wrap items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-3">
<img className="size-9 object-contain dark:hidden" src={xyLogo} alt="鑫元" />
<img className="hidden size-9 object-contain dark:block" src={xyLogoWhite} alt="" aria-hidden="true" />
<div>
<span>{APP_DISPLAY_NAME}</span>
<h1>{report.title}</h1>
<p>{ownerLabel}填报任务</p>
</div>
</div>
<Button size="sm" variant="outline" onPress={() => exportAuditReport(report)}>导出</Button>
<Button size="sm" variant="secondary" onPress={() => exportAuditReport(report)}>导出</Button>
</header>
<div className="audit-fill-content">
<div className="flex flex-col gap-6">
{notice ? <InlineNotice tone={notice.tone} onDismiss={() => setNotice(null)}>{notice.text}</InlineNotice> : null}
<AuditReportContent
report={report}
......@@ -181,8 +181,8 @@ export default function AuditReportFill() {
/>
</div>
<footer className="audit-fill-actions">
<Button size="sm" variant="outline" onPress={() => setResetOpen(true)} isDisabled={saving || !dirty}>重置</Button>
<footer className="flex flex-wrap justify-end gap-2">
<Button size="sm" variant="secondary" onPress={() => setResetOpen(true)} isDisabled={saving || !dirty}>重置</Button>
<Button size="sm" variant="primary" onPress={save} isDisabled={saving || !dirty}>
{saving ? '保存中...' : '保存填报'}
</Button>
......@@ -197,7 +197,7 @@ export default function AuditReportFill() {
<AlertDialog.Header><AlertDialog.Heading className="rc-dialog-title">重置未保存修改</AlertDialog.Heading></AlertDialog.Header>
<AlertDialog.Body className="rc-dialog-body">将重新加载服务端最新内容,本次尚未保存的编辑会丢失。</AlertDialog.Body>
<AlertDialog.Footer className="rc-dialog-actions">
<Button variant="outline" onPress={() => setResetOpen(false)}>取消</Button>
<Button variant="secondary" onPress={() => setResetOpen(false)}>取消</Button>
<Button variant="danger" onPress={async () => { setResetOpen(false); await loadData(); }}>确认重置</Button>
</AlertDialog.Footer>
</AlertDialog.Dialog>
......
import { OpenExternal } from '@/components/AppIcons';
import { Button, Chip, Description, Modal, Surface } from '@heroui/react';
import dayjs from 'dayjs';
import {
CONTROL_POINT_STATUS,
hasControlPointValue,
normalizeRecordId,
} from './auditManagementService';
function recordLabel(value) {
if (value === null || value === undefined || value === '') return '-';
if (typeof value !== 'object') return String(value);
return value.title
|| value.label
|| value.name
|| value.filename
|| normalizeRecordId(value)
|| '';
}
function listLabel(values) {
const labels = (Array.isArray(values) ? values : values ? [values] : [])
.map(recordLabel)
.filter(Boolean);
return labels.length > 0 ? labels.join('、') : '-';
}
function displayValue(value) {
if (value === null || value === undefined || value === '') return '-';
if (Array.isArray(value)) {
const labels = value.map(recordLabel).filter(Boolean);
if (labels.length === value.length) return labels.join('、');
}
if (typeof value === 'object') {
const label = recordLabel(value);
return label || JSON.stringify(value, null, 2);
}
return String(value);
}
function PreviewField({ children, className, label }) {
return (
<div className={`grid min-w-0 gap-1 ${className || ''}`}>
<Description>{label}</Description>
<div className="min-w-0 whitespace-pre-wrap break-words">
{children === null || children === undefined || children === '' ? '-' : children}
</div>
</div>
);
}
function PointStatus({ point }) {
if (!hasControlPointValue(point)) return <Chip size="sm">未填写</Chip>;
if (point.status === CONTROL_POINT_STATUS.DONE) {
return <Chip color="success" size="sm" variant="soft">已完成</Chip>;
}
return <Chip color="warning" size="sm" variant="soft">待确认</Chip>;
}
function AttachmentPreview({ point }) {
const labels = point.attachment_label?.length
? point.attachment_label
: Object.keys(point.value || {});
if (labels.length === 0) return '-';
return (
<div className="grid gap-3">
{labels.map((label, labelIndex) => {
const documents = Array.isArray(point.value?.[label])
? point.value[label].filter(Boolean)
: point.value?.[label]
? [point.value[label]]
: [];
return (
<div className="grid gap-1" key={`${label}-${labelIndex}`}>
<Description>{label}</Description>
{documents.length > 0 ? documents.map((document, documentIndex) => (
document.url ? (
<a
className="flex min-w-0 items-center gap-2"
href={document.url}
key={normalizeRecordId(document) || documentIndex}
rel="noreferrer"
target="_blank"
>
<span className="truncate">{recordLabel(document) || '附件'}</span>
<OpenExternal size={13} />
</a>
) : (
<span key={normalizeRecordId(document) || documentIndex}>
{recordLabel(document) || '附件'}
</span>
)
)) : <span>未上传</span>}
</div>
);
})}
</div>
);
}
function ComputedPreview({ value }) {
const executions = Array.isArray(value) ? value : [];
if (executions.length === 0) return '暂无执行结果';
return (
<div className="grid gap-3">
{executions.map((execution, index) => (
<div className="grid gap-1" key={execution.execute_time || index}>
<Description>
{execution.execute_time
? dayjs(execution.execute_time).format('YYYY-MM-DD HH:mm:ss')
: `执行结果 ${index + 1}`}
</Description>
<span className="whitespace-pre-wrap break-words">
{displayValue(execution.execution_data ?? execution)}
</span>
</div>
))}
</div>
);
}
function PointContentPreview({ point }) {
if (point.type === 'attachment') return <AttachmentPreview point={point} />;
if (point.type === 'computed') return <ComputedPreview value={point.value} />;
return displayValue(point.value);
}
export default function ControlPointPreviewDialog({
open,
onClose,
orderLabel,
point,
typeLabel,
}) {
const hasRuntimeState = point && (
Object.prototype.hasOwnProperty.call(point, 'value')
|| Object.prototype.hasOwnProperty.call(point, 'issue')
|| Object.prototype.hasOwnProperty.call(point, 'remark')
|| Object.prototype.hasOwnProperty.call(point, 'status')
);
return (
<Modal.Root isOpen={open} onOpenChange={(isOpen) => { if (!isOpen) onClose(); }}>
<Modal.Trigger aria-hidden="true" aria-label="打开控制点查看弹窗" className="sr-only" tabIndex={-1} />
<Modal.Backdrop className="rc-dialog-backdrop" variant="blur">
<Modal.Container placement="center" scroll="inside" size="lg">
<Modal.Dialog className="rc-dialog-panel rc-dialog-panel--lg">
<Modal.CloseTrigger aria-label="关闭" className="rc-dialog-close" />
<Modal.Header>
<Modal.Heading className="rc-dialog-title">查看控制点</Modal.Heading>
</Modal.Header>
<Modal.Body className="rc-dialog-body">
<Surface className="grid gap-5 p-5 sm:grid-cols-2" variant="secondary">
<PreviewField label="序号">{orderLabel || '-'}</PreviewField>
<PreviewField label="控制措施">{typeLabel || point?.type || '-'}</PreviewField>
<PreviewField className="sm:col-span-2" label="名称">{point?.name || '-'}</PreviewField>
<PreviewField className="sm:col-span-2" label="描述信息">{point?.description || '-'}</PreviewField>
<PreviewField label="责任人">{recordLabel(point?.owner)}</PreviewField>
<PreviewField label="相关标签">{listLabel(point?.related_tags)}</PreviewField>
<PreviewField className="sm:col-span-2" label="相关制度">{listLabel(point?.related_regulations)}</PreviewField>
{point?.type === 'form' ? (
<PreviewField className="sm:col-span-2" label="文本描述">{point.form_label || '-'}</PreviewField>
) : null}
{point?.type === 'attachment' ? (
<PreviewField className="sm:col-span-2" label="文件描述">{listLabel(point.attachment_label)}</PreviewField>
) : null}
{point?.type === 'computed' ? (
<>
<PreviewField label="量化指标">{recordLabel(point.indicator)}</PreviewField>
<PreviewField label="执行频率(天)">{point.computed_interval ?? '-'}</PreviewField>
</>
) : null}
{hasRuntimeState ? (
<>
<PreviewField className="sm:col-span-2" label="内容"><PointContentPreview point={point} /></PreviewField>
<PreviewField label="异常">{point.issue || '-'}</PreviewField>
<PreviewField label="状态"><PointStatus point={point} /></PreviewField>
<PreviewField className="sm:col-span-2" label="备注">{point.remark || '-'}</PreviewField>
</>
) : null}
</Surface>
</Modal.Body>
<Modal.Footer className="rc-dialog-actions">
<Button size="sm" variant="secondary" onPress={onClose}>关闭</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal.Root>
);
}
import xyLogoRed from '@/assets/identity/xy-logo-red.png';
import xyLogoWhite from '@/assets/identity/xy-logo-white.png';
import { AppGrid, FileText, LogOut, Moon, Project, Sun } from '@/components/AppIcons';
import AppShell from '@/components/AppShell';
import { FileText, Project } from '@/components/AppIcons';
import ApplicationSwitcherDialog from '@/components/ApplicationSwitcherDialog';
import { APP_DISPLAY_NAME, APP_NAV_ITEMS, getAppRouteTitle } from '@/constants/app';
import { APP_DISPLAY_NAME, getAppRouteTitle } from '@/constants/app';
import { UserContext } from '@/wrapper/Auth';
import { Navbar } from '@heroui-pro/react/navbar';
import { Sidebar } from '@heroui-pro/react/sidebar';
import { Avatar, Button, Dropdown, Label, Tooltip } from '@heroui/react';
import { useContext, useLayoutEffect, useMemo, useState } from 'react';
import { Heading } from 'react-aria-components/Dialog';
import { history, Outlet, useLocation } from 'umi';
const THEME_STORAGE_KEY = 'xinyuan_color_theme';
const NAV_ICONS = { project: Project, report: FileText };
function getInitialTheme() {
if (typeof window === 'undefined') return 'light';
try { return window.localStorage.getItem(THEME_STORAGE_KEY) === 'dark' ? 'dark' : 'light'; } catch { return 'light'; }
}
function SidebarUserMenu({ userInfo }) {
const displayName = userInfo?.display_name || userInfo?.username || '当前用户';
return (
<Dropdown>
<Dropdown.Trigger className="rc-sidebar-user-trigger" aria-label={`打开${displayName}的用户菜单`}>
<Avatar className="rc-sidebar-avatar">
{userInfo?.avatar ? <Avatar.Image alt={displayName} src={userInfo.avatar} /> : null}
<Avatar.Fallback>{displayName.slice(0, 1).toUpperCase()}</Avatar.Fallback>
</Avatar>
<span className="rc-sidebar-user-copy" data-sidebar="label">
<strong>{displayName}</strong>
<small>{userInfo?.username || '已登录'}</small>
</span>
</Dropdown.Trigger>
<Dropdown.Popover placement="top start">
<Dropdown.Menu aria-label="用户菜单" onAction={(key) => { if (key === 'logout') userInfo?.logout?.(); }}>
<Dropdown.Item id="logout" textValue="退出登录" variant="danger">
<div className="rc-sidebar-user-menu-item rc-sidebar-user-menu-item--danger">
<Label>退出登录</Label>
<LogOut size={14} />
</div>
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown.Popover>
</Dropdown>
);
}
function SidebarContents({ pathname, theme, userInfo, onSwitchApplication }) {
return (
<>
<Sidebar.Header>
<div className="rc-sidebar-brand">
<img alt="鑫元" className="rc-sidebar-logo" src={theme === 'dark' ? xyLogoWhite : xyLogoRed} />
<span className="rc-sidebar-title" data-sidebar="label">{APP_DISPLAY_NAME}</span>
<span data-sidebar="label">
<Tooltip delay={0}>
<Button isIconOnly aria-label="切换应用" className="rc-sidebar-app-trigger" size="sm" variant="ghost" onPress={onSwitchApplication}><AppGrid size={16} /></Button>
<Tooltip.Content>切换应用</Tooltip.Content>
</Tooltip>
</span>
</div>
</Sidebar.Header>
<Sidebar.Content>
<Sidebar.Group>
<Sidebar.GroupLabel>稽核工作台</Sidebar.GroupLabel>
<Sidebar.Menu aria-label="稽核工作台" showGuideLines={false}>
{APP_NAV_ITEMS.map((item) => {
const Icon = NAV_ICONS[item.icon] || FileText;
return (
<Sidebar.MenuItem href={item.path} isCurrent={pathname === item.path || pathname.startsWith(`${item.path}/`)} key={item.path} textValue={item.label} tooltip={item.label}>
<Sidebar.MenuIcon><Icon size={16} /></Sidebar.MenuIcon>
<Sidebar.MenuLabel>{item.label}</Sidebar.MenuLabel>
</Sidebar.MenuItem>
);
})}
</Sidebar.Menu>
</Sidebar.Group>
</Sidebar.Content>
<Sidebar.Footer><SidebarUserMenu userInfo={userInfo} /></Sidebar.Footer>
</>
);
}
import { useContext, useEffect, useMemo, useState } from 'react';
import { Outlet, useLocation } from 'umi';
const MENU_GROUPS = [
{
title: APP_DISPLAY_NAME,
showLabel: false,
items: [
{ name: '稽核项目', path: '/audit_process', icon: Project },
{ name: '稽核报告', path: '/audit_report', icon: FileText },
],
},
];
export default function AuditManagementLayout() {
const location = useLocation();
const userInfo = useContext(UserContext);
const [theme, setTheme] = useState(getInitialTheme);
const [switcherOpen, setSwitcherOpen] = useState(false);
const pageTitle = useMemo(() => getAppRouteTitle(location.pathname), [location.pathname]);
useLayoutEffect(() => {
document.documentElement.classList.toggle('dark', theme === 'dark');
document.documentElement.classList.toggle('light', theme === 'light');
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
document.title = `${pageTitle} - ${APP_DISPLAY_NAME}`;
try { window.localStorage.setItem(THEME_STORAGE_KEY, theme); } catch {}
}, [pageTitle, theme]);
const [sidebarOpen, setSidebarOpen] = useState(true);
const [isApplicationDialogOpen, setIsApplicationDialogOpen] = useState(false);
const currentTitle = useMemo(
() => getAppRouteTitle(location.pathname),
[location.pathname],
);
const sidebarProps = { pathname: location.pathname, theme, userInfo, onSwitchApplication: () => setSwitcherOpen(true) };
const themeLabel = theme === 'dark' ? '切换至浅色模式' : '切换至深色模式';
useEffect(() => {
document.title = `${currentTitle} - ${APP_DISPLAY_NAME}`;
}, [currentTitle]);
return (
<Sidebar.Provider className={`rc-app-shell ${theme}`} collapsible="offcanvas" navigate={(href) => history.push(href)}>
<Sidebar className="rc-sidebar"><SidebarContents {...sidebarProps} /></Sidebar>
<Sidebar.Mobile className="rc-mobile-sidebar"><Heading className="sr-only" slot="title">{APP_DISPLAY_NAME}导航</Heading><SidebarContents {...sidebarProps} /></Sidebar.Mobile>
<Sidebar.Main className="rc-app-main">
<Navbar className="rc-navbar" maxWidth="full">
<Navbar.Header>
<Sidebar.Trigger aria-label="打开或收起导航" className="rc-navbar-trigger" />
<h1 className="rc-navbar-title">{pageTitle}</h1>
<Navbar.Spacer />
<Tooltip delay={0}>
<Button isIconOnly aria-label={themeLabel} className="rc-navbar-icon-button" size="sm" variant="ghost" onPress={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}</Button>
<Tooltip.Content>{themeLabel}</Tooltip.Content>
</Tooltip>
</Navbar.Header>
</Navbar>
<div className="min-h-0 flex-1 overflow-y-auto"><div className="rc-app-page"><h1 className="sr-only">{pageTitle}</h1><Outlet /></div></div>
</Sidebar.Main>
<ApplicationSwitcherDialog open={switcherOpen} onClose={() => setSwitcherOpen(false)} userInfo={userInfo} />
</Sidebar.Provider>
<>
<AppShell
menuGroups={MENU_GROUPS}
onLogout={userInfo?.logout}
onOpenApplicationSwitcher={() => setIsApplicationDialogOpen(true)}
open={sidebarOpen}
pathname={location.pathname}
title={currentTitle}
userInfo={userInfo}
onOpenChange={setSidebarOpen}
>
<Outlet />
</AppShell>
<ApplicationSwitcherDialog
open={isApplicationDialogOpen}
onClose={() => setIsApplicationDialogOpen(false)}
userInfo={userInfo}
/>
</>
);
}
import { CheckCircleIcon, Close, ExclamationTriangleIcon } from '@/components/AppIcons';
import { EmptyState as HeroEmptyState } from '@heroui-pro/react/empty-state';
import { Button, Spinner } from '@heroui/react';
import { Alert, Button, Spinner, toast } from '@heroui/react';
import { useEffect } from 'react';
export function LoadingState({ label = '正在加载...' }) {
return <div className="audit-state"><Spinner size="sm" /><span>{label}</span></div>;
return <div className="flex min-h-56 items-center justify-center gap-2"><Spinner size="sm" /><span className="text-sm text-muted">{label}</span></div>;
}
export function ErrorState({ message, onRetry }) {
return (
<div className="audit-state audit-state--error">
<div className="flex min-h-56 flex-col items-center justify-center gap-3 text-center">
<ExclamationTriangleIcon size={24} />
<span>{message || '数据加载失败'}</span>
{onRetry ? <Button size="sm" variant="outline" onPress={onRetry}>重新加载</Button> : null}
<span className="text-sm text-muted">{message || '数据加载失败'}</span>
{onRetry ? <Button size="sm" variant="secondary" onPress={onRetry}>重新加载</Button> : null}
</div>
);
}
export function EmptyState({ title, description, action }) {
return (
<HeroEmptyState className="audit-empty-state" size="md">
<HeroEmptyState className="min-h-56" size="md">
<HeroEmptyState.Header>
<HeroEmptyState.Title>{title}</HeroEmptyState.Title>
{description ? <HeroEmptyState.Description>{description}</HeroEmptyState.Description> : null}
......@@ -31,16 +31,23 @@ export function EmptyState({ title, description, action }) {
export function InlineNotice({ tone = 'success', children, onDismiss }) {
useEffect(() => {
if (!onDismiss) return undefined;
const timeout = window.setTimeout(onDismiss, 3600);
return () => window.clearTimeout(timeout);
}, [children, onDismiss]);
if (tone !== 'success') return undefined;
toast.success(String(children || ''), { timeout: 4000 });
onDismiss?.();
return undefined;
}, [children, onDismiss, tone]);
if (tone === 'success') return null;
return (
<div className={`rc-toast rc-toast--${tone}`} role={tone === 'error' ? 'alert' : 'status'} aria-live={tone === 'error' ? 'assertive' : 'polite'}>
<span className="rc-toast__icon">{tone === 'error' ? <ExclamationTriangleIcon size={16} /> : <CheckCircleIcon size={16} />}</span>
<span>{children}</span>
{onDismiss ? <button aria-label="关闭提示" className="rc-toast__close" onClick={onDismiss} type="button"><Close size={14} /></button> : null}
</div>
<Alert status="danger">
<Alert.Indicator><ExclamationTriangleIcon size={16} /></Alert.Indicator>
<Alert.Content><Alert.Description>{children}</Alert.Description></Alert.Content>
{onDismiss ? (
<Button isIconOnly aria-label="关闭提示" size="sm" variant="ghost" onPress={onDismiss}>
<Close size={14} />
</Button>
) : null}
</Alert>
);
}
import { Close } from '@/components/AppIcons';
import { Button, Chip, ComboBox, Input, ListBox, Spinner, Tooltip } from '@heroui/react';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import { useEffect, useMemo, useState } from 'react';
import { normalizeRecordId, normalizeRecordLabel } from './auditManagementService';
......@@ -20,30 +19,24 @@ export default function ReferenceSearchSelect({
placeholder = '搜索并选择',
}) {
const valueRecords = useMemo(
() => (Array.isArray(value) ? value : value ? [value] : []).filter((item) => typeof item !== 'string'),
() => (Array.isArray(value) ? value : value ? [value] : [])
.filter((item) => typeof item !== 'string'),
[value],
);
const [query, setQuery] = useState('');
const [options, setOptions] = useState(valueRecords);
const [loading, setLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const selectedIds = useMemo(
() => (Array.isArray(value) ? value : value ? [value] : []).map(normalizeRecordId).filter(Boolean),
() => (Array.isArray(value) ? value : value ? [value] : [])
.map(normalizeRecordId)
.filter(Boolean),
[value],
);
const selectedRecords = selectedIds
.map((id) => options.find((option) => normalizeRecordId(option) === id)
|| valueRecords.find((record) => normalizeRecordId(record) === id))
.filter(Boolean);
const selectedLabels = selectedRecords.map(normalizeRecordLabel).filter(Boolean);
const selectionSummary = selectedIds.length === 0
? ''
: selectedLabels.length === selectedIds.length
? selectedLabels.join('、')
: `已选择 ${selectedIds.length} 项`;
useEffect(() => {
if (valueRecords.length > 0) setOptions((current) => uniqueRecords([...current, ...valueRecords]));
if (valueRecords.length > 0) {
setOptions((current) => uniqueRecords([...current, ...valueRecords]));
}
}, [valueRecords]);
useEffect(() => {
......@@ -52,80 +45,57 @@ export default function ReferenceSearchSelect({
setLoading(true);
try {
const records = await loadOptions(query);
if (!cancelled) setOptions((current) => uniqueRecords([...current, ...(records || [])]));
if (!cancelled) {
setOptions((current) => uniqueRecords([...current, ...(records || [])]));
}
} finally {
if (!cancelled) setLoading(false);
}
}, 180);
return () => { cancelled = true; window.clearTimeout(timer); };
}, [loadOptions, query]);
const selectedSet = new Set(selectedIds);
const availableOptions = multiple ? options.filter((record) => !selectedSet.has(normalizeRecordId(record))) : options;
const handleSelection = (key) => {
const id = String(key || '');
if (!id) return;
onChange(multiple ? Array.from(new Set([...selectedIds, id])) : id);
setQuery('');
setIsOpen(false);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [loadOptions, query]);
const normalizedOptions = options.map((record) => ({
id: normalizeRecordId(record),
label: normalizeRecordLabel(record) || normalizeRecordId(record),
record,
}));
const selectedRecords = selectedIds
.map((id) => normalizedOptions.find((option) => option.id === id))
.filter(Boolean);
const valueText = selectedIds.length === 0
? placeholder
: selectedRecords.length === selectedIds.length
? selectedRecords.map((option) => option.label).join('、')
: `已选择 ${selectedIds.length} 项`;
return (
<div className="reference-search-select">
<ComboBox
<XinyuanAutocomplete
aria-label={placeholder}
fullWidth
inputValue={isOpen ? query : selectionSummary}
isOpen={isOpen}
menuTrigger="focus"
selectedKey={multiple ? null : selectedIds[0] || null}
inputValue={query}
isLoading={loading}
options={normalizedOptions}
placeholder={placeholder}
searchPlaceholder={placeholder}
selectedKey={multiple ? undefined : selectedIds[0] || null}
selectedKeys={multiple ? new Set(selectedIds) : undefined}
selectionMode={multiple ? 'multiple' : 'single'}
shouldFilter={false}
valueText={valueText}
variant="secondary"
onInputChange={setQuery}
onOpenChange={(open) => {
setIsOpen(open);
onSelectionChange={(selection) => {
if (multiple) {
onChange(Array.from(selection || []).map(String));
} else {
onChange(selection ? String(selection) : '');
}
setQuery('');
}}
onSelectionChange={handleSelection}
>
<ComboBox.InputGroup className="reference-search-select__surface rc-user-combobox__group" data-has-value={selectedIds.length > 0 ? 'true' : undefined}>
<Input
aria-label={selectionSummary && !isOpen ? `${placeholder},当前已选${selectionSummary}` : placeholder}
className="reference-search-select__input"
data-has-value={selectionSummary && !isOpen ? 'true' : undefined}
placeholder={loading && isOpen ? '正在搜索...' : placeholder}
title={selectionSummary && !isOpen ? selectionSummary : undefined}
/>
<ComboBox.Trigger aria-label={`打开${placeholder}列表`} />
</ComboBox.InputGroup>
<ComboBox.Popover className="rc-user-combobox__popover rc-filter-options-popover" placement="bottom start">
<ListBox aria-label={placeholder} items={availableOptions} renderEmptyState={() => <div className="rc-user-select__state">{loading ? '正在搜索...' : '没有匹配结果'}</div>}>
{(record) => (
<ListBox.Item id={normalizeRecordId(record)} textValue={normalizeRecordLabel(record)}>
<span className="rc-user-select__name">{normalizeRecordLabel(record) || normalizeRecordId(record)}</span>
<ListBox.ItemIndicator />
</ListBox.Item>
)}
</ListBox>
{loading ? <div className="rc-user-combobox__loading"><Spinner size="sm" /></div> : null}
</ComboBox.Popover>
</ComboBox>
{multiple && selectedRecords.length > 0 ? (
<div className="rc-user-select__chips">
{selectedRecords.map((record) => {
const id = normalizeRecordId(record);
const label = normalizeRecordLabel(record);
return (
<Chip className="rc-user-select__chip rc-status-chip rc-status-chip--info" key={id} size="sm" variant="soft">
<Chip.Label className="rc-user-select__chip-label">{label}</Chip.Label>
<Tooltip delay={0}>
<Button aria-label={`移除${label}`} className="rc-user-select__chip-remove" isIconOnly variant="ghost" onPress={() => onChange(selectedIds.filter((selectedId) => selectedId !== id))}><Close size={12} /></Button>
<Tooltip.Content>移除</Tooltip.Content>
</Tooltip>
</Chip>
);
})}
</div>
) : null}
</div>
);
}
......@@ -7,7 +7,7 @@
- `/audit_report/:reportId`:已归档报告只读详情
- `/audit_report_fill?owner=:departmentId&report=:reportId&ticket=:ticket`:责任部门填报
应用部署基路径为 `/audit_manager_new/`。主应用导航使用 Catalyst Sidebar,移动端导航由 Headless UI Dialog 承载。
应用部署基路径为 `/audit_manager_new/`。主应用导航使用 HeroUI Pro Sidebar,桌面端采用 offcanvas 折叠,移动端使用 `Sidebar.Mobile`。
## 2. 权限
......@@ -21,15 +21,20 @@
1. 进入“稽核项目”,页面查询 `audit_process`,按 `order` 排序并以项目切换条展示。
2. 页面同时读取 `audit_process` Subject,再根据 `control_points.settings.subject` 加载 `audit_process_control_point`。
3. 管理员可添加项目,业务字段写入 `metadata.process_name`、`metadata.process_description`、`metadata.control_points`。
4. 管理员可添加或编辑控制点;类型选项来自 Subject `settings.options`。
5. `owner`、`related_regulations`、`indicator` 等引用字段通过搜索选择,保存时只提交 `_id`;多值引用提交 `_id[]`。
6. 删除控制点只影响项目当前定义,已经生成的报告快照不变。
3. 管理员可通过项目切换 Tabs 同行最右侧的“添加项目”入口新增项目,业务字段写入 `metadata.process_name`、`metadata.process_description`、`metadata.control_points`。
4. 存在当前项目时,“添加控制点”以次按钮显示在“添加项目”主按钮左侧;管理员可添加或编辑控制点,类型选项来自 Subject `settings.options`。
5. 管理员可通过“添加项目”左侧的“删除项目”入口删除当前项目;操作必须经二次确认,并调用 `DELETE /record/:name` 执行软删除。
6. `owner`、`related_regulations`、`indicator` 等引用字段通过搜索选择,保存时只提交 `_id`;多值引用提交 `_id[]`。
7. 删除项目或控制点只影响当前项目配置及后续新生成的报告,已经生成的报告快照不变。
8. 控制点列表“操作”列始终以查看图标为第一项;所有成员可打开只读控制点详情,管理员在其后继续使用原编辑、删除入口。
多值引用的已选项以内嵌 HeroUI Tag 显示在搜索选择框内,可在框内逐项移除;该展示变化不改变搜索请求和 `_id[]` 提交协议。
校验与限制:
- 项目名称、项目序号必填,序号范围 1-500
- 控制点名称和责任人按旧版业务规则必填
- 删除项目失败时保留其他项目数据并显示错误提示,不执行物理删除
- `attachment_label` 会去除空白项
- Subject 定义加载失败时禁止打开不完整的动态表单,并显示可重试错误
......@@ -42,6 +47,8 @@
5. 文本控制点填写 `value`;附件控制点按 `attachment_label` 上传文档;异常选项从 `audit_report.issue.settings.options` 读取;备注写入 `remark`。
6. 保存时先重新读取最新报告。服务端已经 `done` 的控制点保持不变,只合并当前责任部门且仍为 `undo` 的控制点,避免覆盖复核结果。
附件在控制点 DataGrid 中按分组紧凑展示:未上传显示状态 Chip,已上传显示可打开的文件 Link;填报态继续在对应分组使用 DropZone 上传。该展示不改变按 `attachment_label` 分组的 `value` 对象、上传请求、追加或删除规则。
异常处理:
- 缺少 `owner` 或 `report` 参数时停止加载并提示链接不完整
......@@ -51,13 +58,16 @@
## 5. 复核与归档
1. 稽核人员在“进行中”查看全部项目、控制点、填报内容、异常与备注。
2. 只有存在有效 `value` 且状态为 `undo` 的控制点可以确认。
3. 可逐项确认,也可确认某项目内全部已填写控制点;确认后状态变为 `done`。
4. 管理员修改复核备注后点击“保存备注修改”持久化。
5. 仅当报告至少包含一个控制点且所有控制点均为 `done` 时允许提交。
6. 提交写入 `metadata.status=history` 和 ISO 8601 格式的 `metadata.submitted_time`,随后进入历史报告列表。
7. 历史详情只读,可导出 Excel,不允许继续填报或修改。
1. 页面最上方左侧通过“进行中 / 历史报告”Tabs 切换报告视图;当前报告存在时,“导出 / 填报链接 / 提交报告”位于同一行最右侧,并沿用既有权限与报告状态限制。
2. “进行中”内的并列稽核项目使用第二级内容宽度 Tabs,一次展示当前项目的控制点 DataGrid;项目名称由 Tabs 表达,项目区不重复标题和完成数,整体完成情况继续由 KPI 表达。项目切换只改变当前快照的可见项目,不改变整体 KPI、保存或提交口径。
3. 关键词搜索位于项目 Tabs 下方、DataGrid 上方,桌面端在工具栏左侧,移动端独占一行;搜索只筛选当前项目列表,项目 Tabs 保持完整。当前项目确认与备注保存位于同一工具栏右侧;责任部门填报仍只展示该部门实际拥有控制点的项目。
4. 只有存在有效 `value` 且状态为 `undo` 的控制点可以确认。
5. 可逐项确认,也可确认当前项目内全部已填写控制点;确认后状态变为 `done`。
6. 管理员修改复核备注后点击“保存备注修改”持久化。
7. 仅当报告至少包含一个控制点且所有控制点均为 `done` 时允许提交。
8. 提交写入 `metadata.status=history` 和 ISO 8601 格式的 `metadata.submitted_time`,随后进入历史报告列表。
9. 历史详情只读,可导出 Excel,不允许继续填报或修改。
10. 进行中、填报和历史详情中的控制点列表均提供查看图标,打开当前快照的只读详情;历史报告列表的查看图标继续进入原 `/audit_report/:reportId` 详情路由。
## 6. 状态说明
......
......@@ -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: {
......
@import 'tailwindcss';
@import '@heroui/styles';
@import '@heroui-pro/react/css';
@import './design-system/tokens.css';
@import './design-system/shell.css';
@import "@fontsource/inter/400.css";
@import "@fontsource/inter/500.css";
@import "@fontsource/inter/600.css";
@import "tailwindcss";
@import "@heroui/styles";
@import "@heroui-pro/react/css";
@import "./design-system/theme.css";
@import "./design-system/tokens.css";
@import "./design-system/shell.css";
@theme {
--font-sans: var(--rc-font-sans);
--font-inter:
"Inter", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
}
:root {
--font-inter:
"Inter", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
color-scheme: light !important;
--pm-brand: var(--rc-brand-primary);
--pm-brand-strong: var(--rc-brand-dark);
......@@ -19,7 +26,7 @@ html {
}
html.dark,
html[data-theme='dark'] {
html[data-theme="dark"] {
color-scheme: dark !important;
}
......@@ -31,491 +38,6 @@ body {
color-scheme: inherit;
}
/* Compatibility bridge for legacy page utilities while modules move to semantic tokens. */
.dark
body
:is(
.bg-white,
[class~='bg-white/95'],
[class~='bg-white/90'],
[class~='bg-white/80'],
[class~='bg-white/70'],
[class~='bg-white/60'],
[class~='bg-white/50'],
[class~='bg-white/10'],
[class~='bg-white/5']
) {
background-color: var(--rc-bg-surface) !important;
}
.dark
body
:is(
.bg-zinc-50,
.bg-zinc-100,
.bg-zinc-200,
.bg-slate-50,
.bg-slate-100,
.bg-slate-200,
[class*='bg-zinc-50/'],
[class*='bg-zinc-100/'],
[class*='bg-slate-50/'],
[class*='bg-slate-100/'],
[class~='bg-zinc-50/95'],
[class~='bg-zinc-50/80'],
[class~='bg-zinc-50/70'],
[class~='bg-zinc-50/60']
) {
background-color: var(--rc-bg-subtle) !important;
}
.dark
body
:is(
.border-zinc-100,
.border-zinc-200,
.border-zinc-300,
[class*='border-zinc-100/'],
[class*='border-zinc-200/'],
[class*='border-slate-100/'],
[class*='border-slate-200/'],
[class~='border-zinc-100/80'],
[class~='border-zinc-200/80'],
[class~='border-zinc-200/70'],
[class~='border-zinc-950/10']
) {
border-color: var(--rc-border-default) !important;
}
.dark
body
:is(
.text-zinc-950,
.text-zinc-900,
.text-zinc-800,
.text-slate-950,
.text-slate-900,
.text-slate-800
) {
color: var(--rc-text-primary) !important;
}
.dark
body
:is(
.text-zinc-700,
.text-zinc-600,
.text-zinc-500,
.text-slate-700,
.text-slate-600,
.text-slate-500
) {
color: var(--rc-text-secondary) !important;
}
.dark body :is(.text-zinc-400, .text-zinc-300) {
color: var(--rc-text-muted) !important;
}
.dark body :is([class~='ring-zinc-950/5'], [class~='ring-zinc-950/10']) {
--tw-ring-color: var(--rc-border-default) !important;
}
.dark body :is(.bg-red-50, .bg-red-100, .bg-rose-50, .bg-rose-100) {
background-color: var(--rc-color-danger-soft) !important;
}
.dark body :is(.bg-amber-50, .bg-amber-100, .bg-yellow-50, .bg-yellow-100) {
background-color: var(--rc-color-warning-soft) !important;
}
.dark body :is(.bg-emerald-50, .bg-emerald-100, .bg-green-50, .bg-green-100) {
background-color: var(--rc-color-success-soft) !important;
}
.dark body :is(.bg-sky-50, .bg-sky-100, .bg-blue-50, .bg-blue-100) {
background-color: var(--rc-color-info-soft) !important;
}
.dark
body
:is(
.bg-cyan-50,
.bg-cyan-100,
.bg-indigo-50,
.bg-indigo-100,
.bg-violet-50,
.bg-violet-100,
.bg-purple-50,
.bg-purple-100
) {
background-color: var(--rc-color-info-soft) !important;
}
.dark body :is(.bg-teal-50, .bg-teal-100) {
background-color: var(--rc-color-teal-soft) !important;
}
.dark body :is(.border-red-200, .border-rose-200) {
border-color: var(--rc-color-danger-border) !important;
}
.dark body :is(.border-amber-200, .border-yellow-200) {
border-color: var(--rc-color-warning-border) !important;
}
.dark body :is(.border-emerald-200, .border-green-200) {
border-color: var(--rc-color-success-border) !important;
}
.dark body :is(.border-sky-200, .border-blue-100, .border-blue-200) {
border-color: var(--rc-color-info-border) !important;
}
.dark
body
:is(
.border-cyan-200,
.border-indigo-200,
.border-violet-200,
.border-purple-200
) {
border-color: var(--rc-color-info-border) !important;
}
.dark body :is(.border-teal-200) {
border-color: var(--rc-color-teal-border) !important;
}
.dark
body
:is(
.text-red-600,
.text-red-700,
.text-red-800,
.text-rose-600,
.text-rose-700
) {
color: var(--rc-color-danger) !important;
}
.dark
body
:is(
.text-amber-600,
.text-amber-700,
.text-amber-800,
.text-amber-900,
.text-yellow-700
) {
color: var(--rc-color-warning) !important;
}
.dark
body
:is(
.text-emerald-600,
.text-emerald-700,
.text-emerald-800,
.text-green-600,
.text-green-700
) {
color: var(--rc-color-success) !important;
}
.dark
body
:is(
.text-sky-600,
.text-sky-700,
.text-sky-800,
.text-blue-600,
.text-blue-700
) {
color: var(--rc-color-info) !important;
}
.dark
body
:is(
.text-cyan-600,
.text-cyan-700,
.text-indigo-600,
.text-indigo-700,
.text-violet-600,
.text-violet-700,
.text-purple-600,
.text-purple-700
) {
color: var(--rc-color-info) !important;
}
.dark body :is(.text-teal-600, .text-teal-700) {
color: var(--rc-color-teal) !important;
}
.dark body [class*='bg-gradient-to-'] {
background-color: var(--rc-bg-subtle) !important;
background-image: none !important;
}
/* Portal surfaces must inherit the root theme exactly like the application Shell. */
.dark
:is(.rc-dialog-viewport, [data-headlessui-portal], [data-overlay-container]) {
color: var(--rc-text-primary);
}
.dark
:is(
.rc-dialog-panel,
.rc-filter-options-popover,
[role='listbox'],
[role='menu']
) {
border-color: var(--rc-border-default);
background-color: var(--overlay);
color: var(--rc-text-primary);
}
.dark .rc-dialog-panel :is(hr, [role='separator']) {
border-color: var(--rc-border-default);
}
.dark
.rc-dialog-panel
:is(input, select, textarea):not([type='checkbox']):not([type='radio']) {
color: var(--rc-text-primary);
caret-color: var(--rc-text-primary);
}
.dark .rc-dialog-panel :is(input, textarea)::placeholder {
color: var(--rc-text-muted);
}
.dark .rc-dialog-panel :is(input, select, textarea):disabled {
color: var(--rc-text-muted);
-webkit-text-fill-color: var(--rc-text-muted);
}
.rc-status-chip {
border-style: solid;
border-width: 1px;
white-space: nowrap;
}
.rc-status-chip--neutral,
.rc-tone-surface--neutral {
border-color: var(--rc-color-neutral-border) !important;
background: var(--rc-color-neutral-soft) !important;
color: var(--rc-text-secondary) !important;
}
.rc-status-chip--info,
.rc-tone-surface--info {
border-color: var(--rc-color-info-border) !important;
background: var(--rc-color-info-soft) !important;
color: var(--rc-color-info) !important;
}
.rc-status-chip--success,
.rc-tone-surface--success {
border-color: var(--rc-color-success-border) !important;
background: var(--rc-color-success-soft) !important;
color: var(--rc-color-success) !important;
}
.rc-status-chip--warning,
.rc-tone-surface--warning {
border-color: var(--rc-color-warning-border) !important;
background: var(--rc-color-warning-soft) !important;
color: var(--rc-color-warning) !important;
}
.rc-status-chip--danger,
.rc-tone-surface--danger {
border-color: var(--rc-color-danger-border) !important;
background: var(--rc-color-danger-soft) !important;
color: var(--rc-color-danger) !important;
}
.rc-status-chip--teal,
.rc-tone-surface--teal {
border-color: var(--rc-color-teal-border) !important;
background: var(--rc-color-teal-soft) !important;
color: var(--rc-color-teal) !important;
}
.rc-metric-icon-surface {
display: grid !important;
width: 3rem !important;
height: 3rem !important;
min-width: 3rem !important;
min-height: 3rem !important;
flex: none;
place-items: center !important;
padding: 0 !important;
border: 0 !important;
border-radius: 1rem !important;
line-height: 1 !important;
}
.rc-metric-icon-surface .anticon,
.rc-metric-icon-surface > svg {
display: grid !important;
width: 1.25rem !important;
height: 1.25rem !important;
place-items: center !important;
margin: 0 !important;
color: inherit !important;
font-size: 1.25rem !important;
line-height: 1 !important;
}
.rc-metric-icon-surface .anticon > svg,
.rc-metric-icon-surface > svg {
display: block !important;
width: 1em !important;
height: 1em !important;
margin: 0 !important;
}
:where(
.button--icon-only,
.rc-filter-reset-button.button,
.rc-form-date-picker__trigger,
.date-picker__trigger,
.app-navbar-icon-button,
.rc-navbar-icon-button,
.app-navbar-trigger,
.rc-navbar-trigger,
.app-sidebar-app-trigger,
.rc-sidebar-app-trigger,
.rc-title-info-trigger,
.pm-table-action,
.rc-tone-action--neutral,
.rc-tone-action--info,
.rc-tone-action--success,
.rc-tone-action--danger
) {
display: inline-grid !important;
place-items: center !important;
align-items: center !important;
justify-content: center !important;
justify-items: center !important;
align-content: center !important;
padding: 0 !important;
line-height: 1 !important;
}
:where(
.button--icon-only,
.rc-filter-reset-button.button,
.rc-form-date-picker__trigger,
.date-picker__trigger,
.app-navbar-icon-button,
.rc-navbar-icon-button,
.app-navbar-trigger,
.rc-navbar-trigger,
.app-sidebar-app-trigger,
.rc-sidebar-app-trigger,
.rc-title-info-trigger,
.pm-table-action,
.rc-tone-action--neutral,
.rc-tone-action--info,
.rc-tone-action--success,
.rc-tone-action--danger
) :where(.anticon, svg) {
display: grid !important;
width: 1rem !important;
height: 1rem !important;
place-items: center !important;
flex: none !important;
margin: 0 !important;
line-height: 0 !important;
vertical-align: middle !important;
transform: none !important;
}
:where(
.button--icon-only,
.rc-filter-reset-button.button,
.rc-form-date-picker__trigger,
.date-picker__trigger,
.app-navbar-icon-button,
.rc-navbar-icon-button,
.app-navbar-trigger,
.rc-navbar-trigger,
.app-sidebar-app-trigger,
.rc-sidebar-app-trigger,
.rc-title-info-trigger,
.pm-table-action,
.rc-tone-action--neutral,
.rc-tone-action--info,
.rc-tone-action--success,
.rc-tone-action--danger
) :where(.anticon > svg, svg > svg) {
display: block !important;
width: 1rem !important;
height: 1rem !important;
margin: 0 !important;
vertical-align: middle !important;
transform: none !important;
}
.rc-tone-action--neutral,
.rc-tone-action--info,
.rc-tone-action--success,
.rc-tone-action--danger {
border: 1px solid transparent !important;
}
.rc-tone-action--neutral {
border-color: var(--rc-color-neutral-border) !important;
background: var(--rc-color-neutral-soft) !important;
color: var(--rc-text-muted) !important;
}
.rc-tone-action--info {
background: var(--rc-color-info-soft) !important;
color: var(--rc-color-info) !important;
}
.rc-tone-action--success {
background: var(--rc-color-success-soft) !important;
color: var(--rc-color-success) !important;
}
.rc-tone-action--danger {
background: var(--rc-color-danger-soft) !important;
color: var(--rc-color-danger) !important;
}
.rc-tone-action--neutral:hover,
.rc-tone-action--info:hover,
.rc-tone-action--success:hover,
.rc-tone-action--danger:hover {
border-color: currentColor !important;
filter: brightness(0.96);
}
.dark
:is(
.rc-tone-action--neutral,
.rc-tone-action--info,
.rc-tone-action--success,
.rc-tone-action--danger
):hover {
filter: brightness(1.16);
}
/* Static business cards use borders for definition and only a light resting lift. */
.rc-card-surface {
border-color: var(--rc-border-default) !important;
background-color: var(--rc-bg-surface) !important;
color: var(--rc-text-primary);
box-shadow: var(--rc-shadow-card) !important;
}
body .login-page {
--rc-login-brand-logo-size: 2.25rem;
--rc-login-card-brand-gap: 0.875rem;
......@@ -535,6 +57,7 @@ body .login-page {
body .login-brand img {
width: var(--rc-login-brand-logo-size) !important;
height: var(--rc-login-brand-logo-size) !important;
filter: none !important;
}
body .login-card-brand {
......@@ -553,11 +76,11 @@ body .login-card-brand strong {
white-space: nowrap;
}
body .login-card [data-slot='card-header'] {
body .login-card [data-slot="card-header"] {
display: none !important;
}
body .login-card [data-slot='card-content'] {
body .login-card [data-slot="card-content"] {
padding: 2.25rem !important;
}
......@@ -568,9 +91,9 @@ body
.input,
.input--secondary,
.login-input,
[data-slot='input'],
[data-slot='input-wrapper'],
[data-slot='control']
[data-slot="input"],
[data-slot="input-wrapper"],
[data-slot="control"]
) {
height: var(--rc-login-control-height) !important;
min-height: var(--rc-login-control-height) !important;
......@@ -579,14 +102,20 @@ body
body
.login-form
.login-field
:is(input, .login-input, input.input--secondary, input[data-slot='input']) {
:is(input, .login-input, input.input--secondary, input[data-slot="input"]) {
line-height: var(--rc-login-control-height) !important;
}
body
.login-form
.login-field[data-has-value='true']
:is(.input, .input--secondary, [data-slot='input'], [data-slot='input-wrapper'], [data-slot='control']) {
.login-field[data-has-value="true"]
:is(
.input,
.input--secondary,
[data-slot="input"],
[data-slot="input-wrapper"],
[data-slot="control"]
) {
--input-bg: var(--rc-login-field-filled-background) !important;
--input-bg-hover: var(--rc-login-field-filled-background-hover) !important;
--input-bg-focus: var(--rc-login-field-filled-background) !important;
......@@ -596,8 +125,12 @@ body
body
.login-form
.login-field[data-has-value='true']
:is(.input input, [data-slot='input-wrapper'] input, [data-slot='control'] input) {
.login-field[data-has-value="true"]
:is(
.input input,
[data-slot="input-wrapper"] input,
[data-slot="control"] input
) {
background: transparent !important;
background-color: transparent !important;
}
......@@ -605,7 +138,9 @@ body
body
.login-form
.login-field
:is(input.input--secondary, input[data-slot='input']):not(:placeholder-shown) {
:is(input.input--secondary, input[data-slot="input"]):not(
:placeholder-shown
) {
--input-bg: var(--rc-login-field-filled-background) !important;
--input-bg-hover: var(--rc-login-field-filled-background-hover) !important;
--input-bg-focus: var(--rc-login-field-filled-background) !important;
......@@ -615,10 +150,16 @@ body
body
.login-form
.login-input:is(.input, .input--secondary, [data-slot='input'])[data-filled='true'],
.login-input:is(
.input,
.input--secondary,
[data-slot="input"]
)[data-filled="true"],
body
.login-form
.login-input:is(.input, .input--secondary, [data-slot='input']):not(:placeholder-shown) {
.login-input:is(.input, .input--secondary, [data-slot="input"]):not(
:placeholder-shown
) {
--input-bg: var(--rc-login-field-filled-background) !important;
--input-bg-hover: var(--rc-login-field-filled-background-hover) !important;
--input-bg-focus: var(--rc-login-field-filled-background) !important;
......@@ -630,18 +171,24 @@ body
body
.login-form
.login-field[data-has-value='true']
:is(.input, .input--secondary, [data-slot='input'], [data-slot='input-wrapper'], [data-slot='control']):hover,
.login-field[data-has-value="true"]
:is(
.input,
.input--secondary,
[data-slot="input"],
[data-slot="input-wrapper"],
[data-slot="control"]
):hover,
body
.login-form
.login-field[data-has-value='true']
.login-field[data-has-value="true"]
:is(
.input,
.input--secondary,
[data-slot='input'],
[data-slot='input-wrapper'],
[data-slot='control']
)[data-hovered='true'] {
[data-slot="input"],
[data-slot="input-wrapper"],
[data-slot="control"]
)[data-hovered="true"] {
border-color: var(--rc-login-field-border-hover) !important;
background: var(--rc-login-field-filled-background-hover) !important;
background-color: var(--rc-login-field-filled-background-hover) !important;
......@@ -649,16 +196,34 @@ body
body
.login-form
.login-field[data-has-value='true']
:is(.input, .input--secondary, [data-slot='input'], [data-slot='input-wrapper'], [data-slot='control']):focus,
.login-field[data-has-value="true"]
:is(
.input,
.input--secondary,
[data-slot="input"],
[data-slot="input-wrapper"],
[data-slot="control"]
):focus,
body
.login-form
.login-field[data-has-value='true']
:is(.input, .input--secondary, [data-slot='input'], [data-slot='input-wrapper'], [data-slot='control']):focus-within,
.login-field[data-has-value="true"]
:is(
.input,
.input--secondary,
[data-slot="input"],
[data-slot="input-wrapper"],
[data-slot="control"]
):focus-within,
body
.login-form
.login-field[data-has-value='true']
:is(.input, .input--secondary, [data-slot='input'], [data-slot='input-wrapper'], [data-slot='control'])[data-focused='true'] {
.login-field[data-has-value="true"]
:is(
.input,
.input--secondary,
[data-slot="input"],
[data-slot="input-wrapper"],
[data-slot="control"]
)[data-focused="true"] {
border-color: var(--rc-field-border-focus) !important;
background: var(--rc-login-field-filled-background) !important;
background-color: var(--rc-login-field-filled-background) !important;
......@@ -670,11 +235,15 @@ body
body
.login-form
.login-field
:is(input.input--secondary, input[data-slot='input']):not(:placeholder-shown):focus,
:is(input.input--secondary, input[data-slot="input"]):not(
:placeholder-shown
):focus,
body
.login-form
.login-field
:is(input.input--secondary, input[data-slot='input']):not(:placeholder-shown)[data-focused='true'] {
:is(input.input--secondary, input[data-slot="input"]):not(
:placeholder-shown
)[data-focused="true"] {
border-color: var(--rc-field-border-focus) !important;
background: var(--rc-login-field-filled-background) !important;
background-color: var(--rc-login-field-filled-background) !important;
......@@ -685,16 +254,28 @@ body
body
.login-form
.login-input:is(.input, .input--secondary, [data-slot='input'])[data-filled='true']:focus,
.login-input:is(
.input,
.input--secondary,
[data-slot="input"]
)[data-filled="true"]:focus,
body
.login-form
.login-input:is(.input, .input--secondary, [data-slot='input'])[data-filled='true'][data-focused='true'],
.login-input:is(
.input,
.input--secondary,
[data-slot="input"]
)[data-filled="true"][data-focused="true"],
body
.login-form
.login-input:is(.input, .input--secondary, [data-slot='input']):not(:placeholder-shown):focus,
.login-input:is(.input, .input--secondary, [data-slot="input"]):not(
:placeholder-shown
):focus,
body
.login-form
.login-input:is(.input, .input--secondary, [data-slot='input']):not(:placeholder-shown)[data-focused='true'] {
.login-input:is(.input, .input--secondary, [data-slot="input"]):not(
:placeholder-shown
)[data-focused="true"] {
--input-bg: var(--rc-login-field-filled-background) !important;
--input-bg-hover: var(--rc-login-field-filled-background-hover) !important;
--input-bg-focus: var(--rc-login-field-filled-background) !important;
......@@ -708,12 +289,12 @@ body
body
.login-form
:is(.input, [data-slot='input-wrapper'], [data-slot='control']):has(
:is(.input, [data-slot="input-wrapper"], [data-slot="control"]):has(
input:-webkit-autofill
),
body
.login-form
:is(.input, [data-slot='input-wrapper'], [data-slot='control']):has(
:is(.input, [data-slot="input-wrapper"], [data-slot="control"]):has(
input:autofill
) {
--input-bg: var(--rc-login-field-autofill-background) !important;
......@@ -732,42 +313,66 @@ body .login-form input:-webkit-autofill {
body
.login-form
:is(input.login-input, input.input--secondary, input[data-slot='input'])[data-filled='true'],
:is(
input.login-input,
input.input--secondary,
input[data-slot="input"]
)[data-filled="true"],
body
.login-form
:is(input.login-input, input.input--secondary, input[data-slot='input']):not(
:is(input.login-input, input.input--secondary, input[data-slot="input"]):not(
:placeholder-shown
),
body
.login-form
:is(input.login-input, input.input--secondary, input[data-slot='input']):not(
:is(input.login-input, input.input--secondary, input[data-slot="input"]):not(
:placeholder-shown
):focus,
body
.login-form
:is(input.login-input, input.input--secondary, input[data-slot='input']):not(
:is(input.login-input, input.input--secondary, input[data-slot="input"]):not(
:placeholder-shown
):focus-visible,
body
.login-form
:is(input.login-input, input.input--secondary, input[data-slot='input']):not(
:is(input.login-input, input.input--secondary, input[data-slot="input"]):not(
:placeholder-shown
)[data-focused='true'],
)[data-focused="true"],
body
.login-form
:is(input.login-input, input.input--secondary, input[data-slot='input']):-webkit-autofill,
:is(
input.login-input,
input.input--secondary,
input[data-slot="input"]
):-webkit-autofill,
body
.login-form
:is(input.login-input, input.input--secondary, input[data-slot='input']):-webkit-autofill:hover,
:is(
input.login-input,
input.input--secondary,
input[data-slot="input"]
):-webkit-autofill:hover,
body
.login-form
:is(input.login-input, input.input--secondary, input[data-slot='input']):-webkit-autofill:focus,
:is(
input.login-input,
input.input--secondary,
input[data-slot="input"]
):-webkit-autofill:focus,
body
.login-form
:is(input.login-input, input.input--secondary, input[data-slot='input']):autofill,
:is(
input.login-input,
input.input--secondary,
input[data-slot="input"]
):autofill,
body
.login-form
:is(input.login-input, input.input--secondary, input[data-slot='input']):autofill:focus {
:is(
input.login-input,
input.input--secondary,
input[data-slot="input"]
):autofill:focus {
--input-bg: var(--rc-login-field-filled-background) !important;
--input-bg-hover: var(--rc-login-field-filled-background-hover) !important;
--input-bg-focus: var(--rc-login-field-filled-background) !important;
......@@ -786,9 +391,8 @@ body
0 0 0 2px var(--rc-login-field-focus-ring) !important;
-webkit-text-fill-color: var(--rc-text-primary) !important;
transition:
background-color 150ms ease,
border-color 150ms ease,
box-shadow 150ms ease;
background-color 9999s ease-out,
color 9999s ease-out;
}
body .login-submit-button {
......@@ -798,2028 +402,114 @@ body .login-submit-button {
border-radius: var(--rc-button-radius, 999px) !important;
}
.dark .login-page,
body .dark .login-page,
body.dark .login-page,
body [data-theme='dark'] .login-page {
--rc-login-field-filled-background: rgb(158 27 27 / 14%);
--rc-login-field-filled-background-hover: rgb(158 27 27 / 18%);
--rc-login-field-autofill-background: rgb(158 27 27 / 16%);
--rc-login-field-border-hover: rgb(184 58 58 / 42%);
--rc-login-field-focus-ring: rgb(184 58 58 / 22%);
}
.rc-dashboard-card,
.rc-workspace-module {
border-radius: 24px !important;
}
/* HeroUI Pro file selection must use semantic surfaces instead of native file controls. */
.rc-file-upload .drop-zone__area {
min-height: 10rem;
border-color: var(--rc-border-default);
background: var(--rc-field-background);
color: var(--rc-text-primary);
}
.rc-file-upload .drop-zone__area:hover:not([data-disabled]) {
border-color: var(--rc-border-strong);
background: var(--rc-bg-subtle);
}
.rc-file-upload .drop-zone__area[data-drop-target] {
border-color: var(--rc-brand-primary);
background: var(--rc-brand-primary-soft);
.rc-dialog-backdrop {
width: 100%;
height: 100%;
background: var(--backdrop);
-webkit-backdrop-filter: blur(var(--rc-modal-backdrop-blur));
backdrop-filter: blur(var(--rc-modal-backdrop-blur));
}
.rc-file-upload .drop-zone__trigger {
height: var(--rc-button-sm-height);
border-color: var(--rc-button-secondary-border);
border-radius: var(--rc-button-radius);
background: transparent;
color: var(--rc-text-primary);
box-shadow: none;
.rc-dialog-panel {
--rc-modal-current-width: var(--rc-modal-width-lg);
position: relative;
display: flex;
width: min(calc(100vw - 5rem), var(--rc-modal-current-width)) !important;
max-width: calc(100vw - 5rem) !important;
max-height: calc(100dvh - 2rem);
flex-direction: column;
padding: var(--rc-modal-padding);
overflow: hidden;
border: 0 !important;
border-radius: var(--rc-modal-radius) !important;
background: var(--overlay);
box-shadow: var(--overlay-shadow);
outline: none;
}
.rc-file-upload .drop-zone__trigger:hover,
.rc-file-upload .drop-zone__trigger[data-hovered='true'] {
border-color: var(--rc-button-secondary-border-hover);
background: var(--rc-button-secondary-background-hover);
.rc-dialog-panel--xs {
--rc-modal-current-width: var(--rc-modal-width-xs);
}
.rc-file-upload .drop-zone__icon {
color: var(--rc-brand-primary);
.rc-dialog-panel--sm {
--rc-modal-current-width: var(--rc-modal-width-sm);
}
.rc-file-upload .drop-zone__label {
max-width: 100%;
overflow: hidden;
color: var(--rc-text-primary);
text-overflow: ellipsis;
white-space: nowrap;
.rc-dialog-panel--md {
--rc-modal-current-width: var(--rc-modal-width-md);
--rc-modal-padding: 1.5rem;
}
.rc-file-upload .drop-zone__description {
color: var(--rc-text-muted);
.rc-dialog-panel--lg {
--rc-modal-current-width: var(--rc-modal-width-lg);
}
@media (max-width: 767px) {
.rc-file-upload .drop-zone__trigger {
height: var(--rc-button-md-height);
}
.rc-dialog-panel--xl {
--rc-modal-current-width: var(--rc-modal-width-xl);
}
/* Keep form and DataGrid selection controls on Xinyuan checkbox tokens. */
.checkbox .checkbox__control {
border-width: 1px !important;
border-color: var(--rc-checkbox-border) !important;
background-color: var(--rc-checkbox-background) !important;
box-shadow: none !important;
color: var(--rc-text-primary) !important;
.rc-dialog-panel--2xl {
--rc-modal-current-width: var(--rc-modal-width-2xl);
}
.checkbox .checkbox__control::before {
background-color: var(--rc-checkbox-checked-background) !important;
.rc-dialog-panel--3xl {
--rc-modal-current-width: var(--rc-modal-width-3xl);
}
.checkbox:hover:not([data-disabled='true']):not([data-selected='true']):not(
[aria-checked='true']
):not([data-indeterminate='true'])
.checkbox__control,
.checkbox[data-hovered='true']:not([data-disabled='true']):not(
[data-selected='true']
):not([aria-checked='true']):not([data-indeterminate='true'])
.checkbox__control,
.checkbox:has([data-slot='checkbox-content'][data-hovered='true']):not(
[data-disabled='true']
):not([data-selected='true']):not([aria-checked='true']):not(
[data-indeterminate='true']
)
.checkbox__control {
border-color: var(--rc-checkbox-border-hover) !important;
background-color: color-mix(
in oklab,
var(--rc-checkbox-background) 82%,
var(--rc-text-primary) 18%
) !important;
.rc-dialog-panel--4xl {
--rc-modal-current-width: var(--rc-modal-width-4xl);
}
:is(
.checkbox[data-selected='true'],
.checkbox[aria-checked='true'],
.checkbox[data-indeterminate='true']
)
.checkbox__control::before,
.checkbox[data-indeterminate='true'] .checkbox__control {
background-color: var(--rc-checkbox-checked-background) !important;
.rc-dialog-panel--5xl {
--rc-modal-current-width: var(--rc-modal-width-5xl);
}
:is(
.checkbox[data-selected='true'],
.checkbox[aria-checked='true'],
.checkbox[data-indeterminate='true']
)
.checkbox__control {
border-color: var(--rc-checkbox-checked-border) !important;
color: var(--rc-checkbox-check-color) !important;
.rc-dialog-title {
margin: 0;
padding-right: 2.5rem;
color: var(--rc-text-primary);
font-size: var(--rc-modal-title-size);
font-weight: var(--rc-modal-title-weight);
line-height: var(--rc-modal-title-line-height);
letter-spacing: 0;
text-wrap: balance;
}
.pm-data-table-selection-scope {
position: relative;
min-width: 0;
}
.pm-data-table-selection-scope .action-bar {
position: absolute;
inset-inline: 1rem;
bottom: 1rem;
z-index: 20;
}
.pm-data-table-selection-scope .action-bar__wrapper.pm-data-table-action-bar {
max-width: min(100% - 2rem, 32rem);
}
.rc-button--solid,
.rc-filter-query-button {
border: 0 !important;
background-image: none !important;
box-shadow: none !important;
}
.rc-button--solid::before,
.rc-button--solid::after {
content: none !important;
background: transparent !important;
box-shadow: none !important;
}
.rc-button--primary {
background-color: var(--rc-brand-primary) !important;
}
.rc-button--primary[data-hover],
.rc-button--primary[data-active],
.rc-button--primary[data-hover='true'],
.rc-button--primary[data-active='true'] {
background-color: var(--rc-brand-primary-hover) !important;
}
.rc-button--outline {
border: 1px solid var(--rc-button-secondary-border) !important;
background-color: transparent !important;
color: var(--rc-text-primary) !important;
box-shadow: none !important;
}
.rc-button--outline:hover,
.rc-button--outline:active,
.rc-button--outline[data-hover],
.rc-button--outline[data-active],
.rc-button--outline[data-hover='true'],
.rc-button--outline[data-active='true'] {
border: 1px solid var(--rc-button-secondary-border-hover) !important;
background-color: var(--rc-button-secondary-background-hover) !important;
}
.rc-button--plain {
color: var(--rc-text-primary) !important;
}
.rc-button--danger.rc-button--plain {
border: 1px solid transparent !important;
background-color: transparent !important;
color: var(--rc-color-danger) !important;
box-shadow: none !important;
}
.rc-button--plain:hover,
.rc-button--plain:active,
.rc-button--plain[data-hover],
.rc-button--plain[data-active],
.rc-button--plain[data-hover='true'],
.rc-button--plain[data-active='true'] {
background-color: var(--rc-button-secondary-background-hover) !important;
}
.rc-button--danger.rc-button--plain:hover,
.rc-button--danger.rc-button--plain:active,
.rc-button--danger.rc-button--plain[data-hover],
.rc-button--danger.rc-button--plain[data-active],
.rc-button--danger.rc-button--plain[data-hover='true'],
.rc-button--danger.rc-button--plain[data-active='true'] {
border-color: var(--rc-color-danger-border) !important;
background-color: var(--rc-color-danger-soft) !important;
color: var(--rc-color-danger) !important;
}
.rc-filter-card.card {
gap: 0;
overflow: visible;
border-color: var(--rc-border-default);
border-radius: 1rem;
background: var(--rc-bg-surface);
box-shadow: var(--rc-shadow-card);
}
.rc-filter-card form {
min-width: 0;
}
.rc-filter-label,
.rc-filter-primary-fields > .min-w-0 > :first-child:not(.sr-only),
.rc-filter-expanded-grid > div > :first-child:not(.sr-only) {
color: var(--rc-text-secondary) !important;
font-size: var(--rc-text-sm) !important;
font-weight: var(--rc-font-medium) !important;
line-height: var(--rc-line-normal) !important;
letter-spacing: 0;
}
.rc-filter-card {
--rc-filter-control-border: var(--rc-button-secondary-border);
}
.rc-filter-card
span[data-slot='control']:has(> input, > select, > textarea)::before,
.rc-filter-card .reference-search-select__shell::before {
border-radius: calc(0.5rem - 1px);
background: var(--rc-field-background);
box-shadow: var(--field-shadow);
}
.rc-filter-card :is(input, select, textarea),
.rc-filter-card .reference-search-select__surface {
border: 1px solid var(--rc-filter-control-border) !important;
border-radius: 0.5rem !important;
background: transparent !important;
box-shadow: none !important;
}
.rc-filter-card .rc-form-input {
width: 100%;
min-width: 0;
}
.rc-filter-card
.rc-form-input
:is(.input, [data-slot='input-wrapper'], [data-slot='control']) {
border: 1px solid var(--rc-filter-control-border) !important;
border-radius: 0.5rem !important;
background: var(--rc-field-background) !important;
box-shadow: var(--field-shadow) !important;
}
.rc-filter-card
.rc-form-input
:is(input, [data-slot='input']) {
min-width: 0;
height: 2.25rem !important;
border-color: transparent !important;
background: transparent !important;
box-shadow: none !important;
color: var(--rc-text-secondary) !important;
font-size: var(--rc-text-sm) !important;
font-weight: var(--rc-font-regular) !important;
line-height: 1.25rem !important;
}
.rc-filter-card input.rc-form-input {
width: 100%;
min-width: 0;
height: 2.25rem !important;
border: 1px solid var(--rc-filter-control-border) !important;
border-radius: 0.5rem !important;
background: var(--rc-field-background) !important;
box-shadow: var(--field-shadow) !important;
color: var(--rc-text-secondary) !important;
font-size: var(--rc-text-sm) !important;
font-weight: var(--rc-font-regular) !important;
line-height: 1.25rem !important;
}
.rc-filter-card
.rc-filter-control
:is(label.relative, div.relative):has(> .anticon, > svg)
.rc-form-input
:is(input, [data-slot='input']) {
padding-left: 2.25rem !important;
}
.rc-filter-card
.rc-filter-control
:is(label.relative, div.relative):has(> .anticon, > svg)
input.rc-form-input {
padding-left: 2.25rem !important;
}
.rc-filter-card
.rc-filter-control
:is(label.relative, div.relative):has(> .anticon, > svg)
> :is(.anticon, svg) {
left: 0.75rem !important;
color: var(--rc-text-muted) !important;
}
.rc-filter-card input[type='number'] {
appearance: textfield;
}
.rc-filter-card input[type='number']::-webkit-outer-spin-button,
.rc-filter-card input[type='number']::-webkit-inner-spin-button {
margin: 0;
appearance: none;
}
.rc-filter-card
.reference-search-select__surface
.reference-search-select__input,
.rc-filter-card
.reference-search-select__surface
.reference-search-select__input:hover,
.rc-filter-card
.reference-search-select__surface
.reference-search-select__input:focus {
border: 0 !important;
background: transparent !important;
box-shadow: none !important;
outline: none;
}
.rc-filter-card
:is(
.input,
.select__trigger,
.combo-box__input-group,
.resource-filter-multi-select__trigger
) {
border: 1px solid var(--rc-filter-control-border) !important;
border-radius: 0.5rem !important;
background: var(--rc-field-background) !important;
box-shadow: var(--field-shadow);
}
.rc-filter-card :is(input, select, textarea):hover,
.rc-filter-card .reference-search-select__surface:hover,
.rc-filter-card
:is(
.input,
.select__trigger,
.combo-box__input-group,
.resource-filter-multi-select__trigger
):hover {
border-color: var(--rc-field-border-hover) !important;
}
.rc-filter-card
span[data-slot='control']:has(> input, > select, > textarea):focus-within
> :is(input, select, textarea),
.rc-filter-card
.reference-search-select__shell:focus-within
.reference-search-select__surface {
border-color: var(--rc-field-border-focus) !important;
}
.rc-filter-card
span[data-slot='control']:has(
> input,
> select,
> textarea
):focus-within::after,
.rc-filter-card .reference-search-select__shell:focus-within::after {
--tw-ring-color: var(--rc-brand-primary) !important;
}
.rc-filter-card
:is(.input, .select__trigger, .combo-box__input-group):focus-within,
.rc-filter-card .resource-filter-multi-select__trigger:focus-visible {
border-color: var(--rc-field-border-focus) !important;
box-shadow: var(--field-shadow), 0 0 0 2px var(--rc-brand-primary-soft);
outline: none;
}
.rc-textarea-single-surface::before,
.rc-textarea-single-surface::after {
display: none;
}
.rc-textarea-single-surface textarea {
min-height: inherit;
background: var(--rc-field-background);
box-shadow: var(--field-shadow, 0 1px 2px rgb(15 23 42 / 4%));
}
.rc-textarea-single-surface textarea:focus {
border-color: var(--rc-field-border-focus);
}
.rc-filter-select {
min-width: 0;
width: 100%;
}
.rc-filter-card .rc-filter-select__trigger,
.rc-filter-select__trigger {
display: flex;
width: 100%;
min-width: 0;
height: 2.25rem;
min-height: 2.25rem;
align-items: center;
gap: 0.5rem;
border: 1px solid
var(--rc-filter-control-border, var(--rc-field-border)) !important;
border-radius: 0.5rem !important;
background: var(--rc-field-background) !important;
padding: 0 0.625rem 0 0.75rem;
color: var(--rc-text-secondary);
box-shadow: var(--field-shadow) !important;
font-size: var(--rc-text-sm);
font-weight: var(--rc-font-regular);
line-height: 1.25rem;
text-align: left;
transition: border-color var(--rc-motion-fast) var(--rc-ease-standard),
box-shadow var(--rc-motion-fast) var(--rc-ease-standard);
}
.rc-filter-card .rc-filter-select__trigger:hover,
.rc-filter-select__trigger:hover,
.rc-filter-select__trigger[data-hovered='true'] {
border-color: var(--rc-field-border-hover) !important;
}
.rc-filter-card .rc-filter-select__trigger[data-focus-visible='true'],
.rc-filter-select__trigger[data-focus-visible='true'],
.rc-filter-select__trigger:focus-visible {
border-color: var(--rc-field-border-focus) !important;
box-shadow: var(--field-shadow), 0 0 0 2px var(--rc-brand-primary-soft) !important;
outline: none;
}
.rc-filter-select__trigger[data-disabled='true'] {
cursor: not-allowed;
opacity: 0.5;
}
.rc-filter-select__value {
min-width: 0;
flex: 1;
overflow: hidden;
color: inherit;
}
.rc-filter-select__trigger[data-has-value='true'] {
color: var(--rc-text-primary);
}
.rc-filter-select__trigger[data-has-value='true'] .rc-filter-select__value {
font-weight: var(--rc-font-medium);
}
.rc-filter-select__placeholder {
color: var(--rc-text-muted);
}
.reference-search-select__input[data-has-value='true'] {
color: var(--rc-text-primary) !important;
font-weight: var(--rc-font-medium) !important;
}
.rc-filter-select__indicator {
display: inline-grid !important;
width: 1rem;
height: 1rem;
flex: none;
place-items: center;
color: var(--rc-text-muted);
font-size: 0.875rem;
line-height: 1;
transform-origin: center;
transition:
color var(--rc-motion-fast) var(--rc-ease-standard),
transform var(--rc-motion-fast) var(--rc-ease-standard);
}
.rc-filter-select__indicator :is(svg, .anticon),
.rc-filter-select__indicator svg,
.rc-filter-select__trigger .select__indicator,
.rc-filter-select__trigger .select__indicator svg {
display: block !important;
width: 1em !important;
height: 1em !important;
margin: 0 !important;
color: currentColor !important;
fill: currentColor !important;
line-height: 1 !important;
}
.rc-filter-select__trigger:hover .rc-filter-select__indicator,
.rc-filter-select__trigger[data-hovered='true'] .rc-filter-select__indicator,
.rc-filter-select__trigger[aria-expanded='true'] .rc-filter-select__indicator {
color: var(--rc-text-secondary);
}
.rc-filter-select__indicator[data-open='true'],
.rc-filter-select__trigger[aria-expanded='true'] .rc-filter-select__indicator {
transform: rotate(180deg);
}
.rc-filter-options-popover,
.rc-filter-select__popover {
max-width: min(24rem, calc(100vw - 1.5rem));
border: 1px solid var(--rc-border-default) !important;
border-radius: var(--rc-radius-overlay) !important;
background: var(--rc-bg-surface) !important;
padding: 0.25rem !important;
box-shadow: var(--rc-shadow-raised) !important;
color: var(--rc-text-secondary);
overflow: hidden;
z-index: 1200;
}
.reference-search-select__options {
width: max(var(--trigger-width, 16rem), 16rem);
min-width: min(var(--trigger-width, 16rem), calc(100vw - 1.5rem));
}
.rc-filter-select__popover {
width: max(var(--trigger-width), 11.25rem);
min-width: var(--trigger-width);
}
.rc-form-select__popover {
width: max(var(--trigger-width), 12rem);
}
.rc-form-date-picker {
width: 100%;
min-width: 0;
}
.rc-form-date-picker__group {
position: relative !important;
display: flex !important;
width: 100% !important;
min-height: 2.25rem;
align-items: center !important;
border: 1px solid var(--rc-field-border) !important;
border-radius: 0.5rem !important;
background: var(--rc-field-background) !important;
box-shadow: var(--field-shadow) !important;
color: var(--rc-text-primary) !important;
}
.rc-form-date-picker__group:hover {
border-color: var(--rc-field-border-hover) !important;
}
.rc-form-date-picker[data-focus-within='true'] .rc-form-date-picker__group {
border-color: var(--rc-field-border-focus) !important;
box-shadow: 0 0 0 2px
color-mix(in srgb, var(--rc-brand-primary) 22%, transparent),
var(--field-shadow) !important;
}
.rc-form-date-picker__input {
flex: 1 1 auto !important;
min-width: 0;
padding-inline: 0.75rem 2.75rem !important;
}
.rc-form-date-picker__segment {
color: var(--rc-text-primary) !important;
font-size: var(--rc-text-sm);
font-weight: var(--rc-font-regular);
}
.rc-form-date-picker__segment[data-placeholder='true'] {
color: var(--rc-text-muted) !important;
}
.rc-form-date-picker__suffix {
position: absolute !important;
top: 50% !important;
right: 0.125rem !important;
display: flex !important;
flex: none !important;
align-items: center !important;
justify-content: center !important;
margin-left: auto !important;
padding-right: 0 !important;
transform: translateY(-50%) !important;
}
.rc-form-date-picker__trigger {
display: inline-grid !important;
place-items: center !important;
width: 2rem !important;
height: 2rem !important;
min-width: 2rem !important;
min-height: 2rem !important;
border-radius: 999px !important;
color: var(--rc-text-muted) !important;
padding: 0 !important;
line-height: 1 !important;
}
.rc-form-date-picker__trigger:hover,
.rc-form-date-picker__trigger[data-hovered='true'] {
background: var(--default) !important;
color: var(--rc-text-primary) !important;
}
.rc-form-date-picker__trigger :where(.anticon, svg),
.rc-form-date-picker__trigger [data-slot='date-picker-trigger-indicator'] {
display: grid !important;
width: 1rem !important;
height: 1rem !important;
place-items: center !important;
margin: 0 !important;
color: currentColor !important;
line-height: 1 !important;
}
.rc-form-date-picker__trigger :where(.anticon > svg, svg > svg) {
display: block !important;
width: 100% !important;
height: 100% !important;
margin: 0 !important;
}
.rc-form-date-picker__popover,
.rc-dropdown-popover {
max-width: calc(100vw - 1.5rem);
max-height: min(24rem, calc(100vh - 1.5rem));
border: 1px solid var(--rc-border-default) !important;
border-radius: var(--rc-radius-overlay) !important;
background: var(--rc-bg-surface) !important;
padding: 0.25rem !important;
box-shadow: var(--rc-shadow-raised) !important;
color: var(--rc-text-secondary) !important;
overflow: auto;
z-index: 1400;
}
.rc-form-date-picker__calendar {
background: transparent !important;
color: var(--rc-text-primary) !important;
}
.rc-form-date-picker__popover [data-slot='calendar-nav-button'],
.rc-form-date-picker__popover [data-slot='calendar-cell'] {
color: var(--rc-text-secondary) !important;
}
.rc-form-date-picker__popover
[data-slot='calendar-cell'][data-today='true'] {
color: var(--rc-brand-primary) !important;
font-weight: var(--rc-font-medium) !important;
}
.rc-form-date-picker__popover [data-slot='calendar-nav-button']:hover,
.rc-form-date-picker__popover [data-slot='calendar-cell']:hover,
.rc-form-date-picker__popover [data-slot='calendar-cell'][data-hovered='true'] {
background: var(--default) !important;
color: var(--rc-text-primary) !important;
}
.rc-form-date-picker__popover
[data-slot='calendar-cell'][data-selected='true'],
.rc-form-date-picker__popover
[data-slot='calendar-cell'][aria-selected='true'] {
background: var(--rc-brand-primary-soft) !important;
color: var(--accent-soft-foreground) !important;
}
.rc-form-date-picker__popover [data-slot='calendar-cell'][data-focused='true'],
.rc-form-date-picker__popover [data-slot='calendar-cell'][data-focus-visible='true'] {
outline: 2px solid var(--rc-brand-primary) !important;
outline-offset: -2px;
}
.rc-form-date-picker__popover [data-slot='calendar-header-cell'] {
color: var(--rc-text-muted) !important;
}
.rc-form-date-picker__time-panel {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-top: 0.25rem;
padding: 0.75rem 0.75rem 0.625rem;
border-top: 1px solid var(--rc-border-default);
}
.rc-form-date-picker__time-label {
color: var(--rc-text-secondary);
font-size: var(--rc-text-sm);
font-weight: var(--rc-font-medium);
}
.rc-form-date-picker__time-field {
width: 8.5rem;
min-width: 0;
}
.rc-form-date-picker__time-group {
display: flex !important;
min-height: 2.25rem;
align-items: center !important;
border: 1px solid var(--rc-field-border) !important;
border-radius: 0.5rem !important;
background: var(--rc-field-background) !important;
box-shadow: var(--field-shadow) !important;
color: var(--rc-text-primary) !important;
}
.rc-form-date-picker__time-group:hover {
border-color: var(--rc-field-border-hover) !important;
}
.rc-form-date-picker__time-field[data-focus-within='true']
.rc-form-date-picker__time-group {
border-color: var(--rc-field-border-focus) !important;
box-shadow: 0 0 0 2px
color-mix(in srgb, var(--rc-brand-primary) 22%, transparent),
var(--field-shadow) !important;
}
.rc-form-date-picker__time-input {
flex: 1 1 auto !important;
min-width: 0;
padding-inline: 0.75rem !important;
}
.rc-form-date-picker__segment[data-focused='true'],
.rc-form-date-picker__segment[data-focus-visible='true'] {
border-radius: 0.25rem !important;
background: var(--rc-brand-primary-soft) !important;
color: var(--rc-brand-primary) !important;
outline: none !important;
}
.rc-dropdown-option {
min-height: 2.25rem;
border-radius: 0.5rem !important;
color: var(--rc-text-secondary) !important;
font-size: var(--rc-text-sm);
font-weight: var(--rc-font-regular);
}
.rc-dropdown-option:hover,
.rc-dropdown-option[data-focus='true'],
.rc-dropdown-option[data-focused='true'] {
background: var(--default) !important;
color: var(--rc-text-primary) !important;
}
.rc-filter-select__listbox,
.rc-filter-options-popover {
max-height: 18rem;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.rc-filter-options-popover__status {
display: flex;
min-height: 2.25rem;
align-items: center;
justify-content: center;
padding: 0.5rem 0.75rem;
color: var(--rc-text-muted);
font-size: var(--rc-text-sm);
line-height: 1.25rem;
text-align: center;
}
.rc-filter-options-popover__status--error {
color: var(--rc-color-danger);
}
.rc-filter-option,
.rc-filter-select__option {
display: flex;
width: 100%;
min-width: 0;
min-height: 2.25rem;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
border: 0 !important;
border-radius: 0.5rem !important;
background: transparent !important;
padding: 0.5rem 0.625rem !important;
color: var(--rc-text-secondary) !important;
box-shadow: none !important;
font-size: var(--rc-text-sm);
font-weight: var(--rc-font-regular);
line-height: 1.25rem;
outline: none;
cursor: default;
}
.rc-filter-option:hover,
.rc-filter-option--focused,
.rc-filter-select__option[data-focused='true'],
.rc-filter-select__option[data-hovered='true'] {
background: var(--default) !important;
color: var(--rc-text-primary) !important;
}
.rc-filter-option--selected,
.rc-filter-option[data-selected='true'],
.rc-filter-select__option[data-selected='true'] {
background: var(--rc-brand-primary-soft) !important;
color: var(--accent-soft-foreground) !important;
}
.rc-filter-option--selected.rc-filter-option--focused,
.rc-filter-option[data-selected='true']:hover,
.rc-filter-select__option[data-selected='true'][data-focused='true'] {
background: var(--rc-brand-primary-soft-hover) !important;
color: var(--accent-soft-foreground) !important;
}
.rc-filter-select__option-label {
display: block;
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: inherit;
font: inherit;
}
.rc-filter-select__option-indicator {
width: 1rem;
height: 1rem;
flex: none;
color: var(--rc-brand-primary);
}
.rc-filter-select__empty {
padding: 1.25rem 0.75rem;
color: var(--rc-text-muted);
font-size: var(--rc-text-sm);
text-align: center;
}
.rc-filter-option__description {
color: var(--rc-text-muted) !important;
}
.rc-filter-option__description--selected,
.rc-filter-option__selected-label {
color: var(--rc-brand-primary) !important;
}
.rc-filter-option__selected-label {
background: var(--rc-brand-primary-soft);
}
.resource-filter-multi-select__popover {
width: max(var(--trigger-width, var(--button-width, 16rem)), 16rem);
min-width: min(
var(--trigger-width, var(--button-width, 16rem)),
calc(100vw - 1.5rem)
);
}
.rc-filter-reset-button.button {
border-color: var(--rc-border-default);
color: var(--rc-text-muted);
}
.rc-filter-reset-button.button:hover,
.rc-filter-reset-button.button[data-hovered='true'] {
border-color: var(--rc-border-strong);
color: var(--rc-text-primary);
}
.rc-filter-query-button.button {
border: 0 !important;
background: var(--rc-brand-primary) !important;
color: white;
box-shadow: none !important;
}
.rc-filter-query-button.button:hover,
.rc-filter-query-button.button:active,
.rc-filter-query-button.button[data-hovered='true'],
.rc-filter-query-button.button[data-pressed='true'] {
background: var(--rc-brand-primary-hover) !important;
}
.rc-filter-toggle-button.button {
color: var(--rc-text-secondary);
}
.rc-filter-toggle-button.button:hover,
.rc-filter-toggle-button.button[data-hovered='true'] {
color: var(--rc-text-primary);
}
@media (max-width: 767px) {
.rc-filter-card.card {
padding: var(--rc-space-4);
}
.rc-filter-actions {
width: 100%;
}
.rc-filter-query-button.button {
flex: 1 1 auto;
}
}
.pm-data-grid-wrap {
position: relative;
min-width: 0;
min-height: 15rem;
overflow: hidden;
border: 0;
border-radius: var(--rc-data-grid-radius);
background: transparent;
clip-path: inset(0 round var(--rc-data-grid-radius));
}
.pm-data-grid-loading {
position: absolute;
inset: 0;
z-index: 12;
display: grid;
place-items: center;
background: color-mix(in oklab, var(--rc-bg-surface) 72%, transparent);
}
/* FullCalendar ships light-only defaults, so bridge them to HeroUI semantics. */
.rc-app-shell .fc {
--fc-page-bg-color: var(--rc-bg-surface);
--fc-neutral-bg-color: var(--rc-bg-subtle);
--fc-neutral-text-color: var(--rc-text-muted);
--fc-border-color: var(--rc-border-default);
--fc-list-event-hover-bg-color: var(--default);
--fc-more-link-bg-color: var(--default);
--fc-more-link-text-color: var(--rc-text-secondary);
--fc-non-business-color: color-mix(in oklab, var(--default) 55%, transparent);
--fc-highlight-color: var(--rc-brand-primary-soft);
--fc-today-bg-color: color-mix(
in oklab,
var(--rc-color-warning) 12%,
transparent
);
color: var(--rc-text-secondary);
}
.rc-app-shell .fc :is(.fc-col-header-cell, .fc-list-day-cushion) {
background: var(--rc-bg-subtle);
color: var(--rc-text-muted);
}
.rc-app-shell .fc :is(.fc-daygrid-day, .fc-list, .fc-list-table td) {
background-color: var(--rc-bg-surface);
}
.rc-app-shell .fc .fc-daygrid-day.fc-day-today {
background-color: var(--fc-today-bg-color);
}
.rc-app-shell .fc .fc-popover {
background: var(--overlay);
border-color: var(--rc-border-default);
color: var(--rc-text-secondary);
box-shadow: var(--rc-shadow-overlay);
}
.pm-data-grid.table-root,
.pm-data-grid .table-root,
.pm-data-grid-scroll,
.pm-data-grid .table__resizable-container {
border: 0 !important;
outline: 0 !important;
border-radius: var(--rc-data-grid-radius);
background: transparent !important;
box-shadow: none !important;
}
.pm-data-grid.table-root,
.pm-data-grid .table-root {
min-height: 15rem;
padding: 0 !important;
overflow: hidden;
}
.pm-data-grid-scroll {
width: 100%;
min-height: 15rem;
overflow: auto;
}
.pm-data-grid .table__resizable-container {
width: 100%;
min-width: 0;
min-height: 15rem;
overflow: visible;
}
.pm-data-grid-wrap--populated,
.pm-data-grid-wrap--populated .pm-data-grid.table-root,
.pm-data-grid-wrap--populated .pm-data-grid .table-root,
.pm-data-grid-wrap--populated .pm-data-grid-scroll,
.pm-data-grid-wrap--populated .pm-data-grid .table__resizable-container {
min-height: 0;
}
.pm-data-grid-content,
.pm-data-grid .table__content {
width: 100%;
background: var(--rc-data-grid-header-background) !important;
}
.pm-data-grid .table__content,
.pm-data-grid .table__header {
background: transparent !important;
}
.pm-data-grid .table__content {
background: var(--rc-data-grid-header-background) !important;
}
.pm-data-grid .table__header {
position: sticky;
top: 0;
z-index: 7;
isolation: isolate;
}
.pm-data-grid .table__header > tr > :first-child {
border-start-start-radius: var(--rc-data-grid-radius);
}
.pm-data-grid .table__header > tr > :last-child {
border-start-end-radius: var(--rc-data-grid-radius);
}
.pm-data-grid .table__body > .table__row:first-child > :first-child,
.pm-data-grid .table__body > .table__row:first-child > :last-child {
border-start-start-radius: 0 !important;
border-start-end-radius: 0 !important;
}
.pm-data-grid .table__body > .table__row:last-child > :first-child {
border-end-start-radius: var(--rc-data-grid-radius) !important;
}
.pm-data-grid .table__body > .table__row:last-child > :last-child {
border-end-end-radius: var(--rc-data-grid-radius) !important;
}
.pm-data-grid .table__body > .table__row:last-child > * {
border-bottom: 0 !important;
}
.pm-data-grid-content--wide {
min-width: max(100%, var(--pm-data-grid-content-min-width, 1080px));
}
.pm-data-grid .table__column,
.pm-data-grid .data-grid__selection-column,
.pm-data-grid .data-grid__drag-handle-column {
position: sticky;
top: 0;
z-index: 6;
height: var(--rc-data-grid-header-height);
background: var(--rc-data-grid-header-background);
color: var(--rc-data-grid-header-color);
font-size: var(--rc-data-grid-header-font-size);
font-weight: var(--rc-data-grid-header-font-weight);
line-height: var(--rc-data-grid-header-line-height);
letter-spacing: 0px;
}
.pm-data-grid .table__cell,
.pm-data-grid .data-grid__selection-cell,
.pm-data-grid .data-grid__drag-handle-cell {
padding-block: 0.625rem;
border-bottom: 1px solid
color-mix(in oklab, var(--separator) 50%, transparent) !important;
background: var(--rc-bg-surface);
color: var(--rc-data-grid-body-color);
font-size: var(--rc-data-grid-body-font-size);
font-weight: var(--rc-data-grid-body-font-weight);
line-height: var(--rc-data-grid-body-line-height);
letter-spacing: 0px;
font-synthesis: none;
white-space: nowrap;
}
.pm-data-grid .table__cell :where(.font-semibold, .font-bold, .font-extrabold),
.pm-data-grid
.data-grid__selection-cell
:where(.font-semibold, .font-bold, .font-extrabold) {
font-weight: var(--rc-font-medium) !important;
}
.pm-data-grid
.table__cell
:where(.text-zinc-950, .text-zinc-900, .text-zinc-800, .text-zinc-700),
.pm-data-grid
.data-grid__selection-cell
:where(.text-zinc-950, .text-zinc-900, .text-zinc-800, .text-zinc-700) {
color: var(--rc-data-grid-body-color) !important;
}
.pm-data-grid
.table__cell
:where(.text-zinc-600, .text-zinc-500, .text-zinc-400),
.pm-data-grid
.data-grid__selection-cell
:where(.text-zinc-600, .text-zinc-500, .text-zinc-400) {
color: var(--rc-data-grid-secondary-color) !important;
}
.pm-data-grid .pm-data-grid-primary {
color: var(--rc-data-grid-primary-color);
font-size: var(--rc-data-grid-primary-font-size);
font-weight: var(--rc-data-grid-primary-font-weight) !important;
line-height: var(--rc-data-grid-primary-line-height);
}
.pm-data-grid .pm-data-grid-primary-action.button {
width: 100%;
min-width: 0;
max-width: 100%;
height: auto;
min-height: 1.35rem;
justify-content: flex-start;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
color: var(--rc-data-grid-primary-color);
box-shadow: none;
line-height: 1.35rem;
text-align: left;
text-decoration: none;
transition: color var(--rc-motion-fast) var(--rc-ease-standard);
}
.pm-data-grid .pm-data-grid-primary-action.button:hover,
.pm-data-grid .pm-data-grid-primary-action.button[data-hovered='true'],
.pm-data-grid .pm-data-grid-primary-action.button[data-focus-visible='true'] {
background: transparent;
color: var(--rc-brand-primary);
text-decoration: none;
}
.pm-data-grid .pm-data-grid-primary-action .pm-data-grid-primary {
color: currentColor;
}
.pm-data-grid
.pm-data-grid-primary
:where(.text-zinc-950, .text-zinc-900, .text-zinc-800, .text-zinc-700) {
color: var(--rc-data-grid-primary-color) !important;
}
.pm-data-grid .pm-data-grid-text {
color: var(--rc-data-grid-body-color);
font-size: var(--rc-data-grid-body-font-size);
font-weight: var(--rc-data-grid-body-font-weight);
line-height: var(--rc-data-grid-body-line-height);
}
.pm-data-grid .pm-data-grid-secondary {
color: var(--rc-data-grid-secondary-color) !important;
font-size: var(--rc-data-grid-secondary-font-size);
font-weight: var(--rc-data-grid-secondary-font-weight) !important;
line-height: var(--rc-data-grid-secondary-line-height);
}
.pm-data-grid .pm-data-grid-text-pair {
display: flex;
min-width: 0;
max-width: 100%;
flex-direction: column;
gap: var(--rc-data-grid-text-pair-gap);
}
.pm-data-grid .pm-data-grid-number {
font-variant-numeric: tabular-nums;
font-weight: var(--rc-font-regular);
}
.pm-data-grid .pm-data-grid-number--emphasis {
color: var(--rc-data-grid-primary-color);
font-variant-numeric: tabular-nums;
font-weight: var(--rc-font-medium);
}
.pm-data-grid .table__row:hover .table__cell,
.pm-data-grid .table__row[data-hovered='true'] .table__cell,
.pm-data-grid .table__row:hover [data-pinned],
.pm-data-grid .table__row[data-hovered='true'] [data-pinned] {
background: var(--rc-data-grid-row-hover-background) !important;
}
.pm-data-grid [data-pinned] {
background: var(--rc-bg-surface) !important;
}
.pm-data-grid [data-pinned].table__column,
.pm-data-grid [data-pinned].data-grid__selection-column,
.pm-data-grid [data-pinned].data-grid__drag-handle-column {
z-index: 8;
background: var(--rc-data-grid-header-background) !important;
}
.pm-data-grid .table__cell > *,
.pm-data-grid .data-grid__selection-cell > * {
min-width: 0;
max-width: 100%;
}
.pm-data-grid .table__cell > .sticky {
position: static !important;
width: 100% !important;
min-width: 0 !important;
max-width: 100%;
background: transparent !important;
box-shadow: none !important;
}
.pm-data-grid .icon-actions {
justify-content: flex-start;
gap: 0.125rem;
padding-inline: 0;
}
.pm-data-grid .icon-actions > button,
.pm-data-grid .icon-actions > .group > button,
.pm-data-grid .icon-actions > .relative > button {
width: var(--rc-data-grid-action-size);
height: var(--rc-data-grid-action-size);
min-width: var(--rc-data-grid-action-size);
max-width: var(--rc-data-grid-action-size);
flex: 0 0 var(--rc-data-grid-action-size);
border-radius: var(--rc-data-grid-action-radius) !important;
}
.pm-table-action {
width: var(--rc-data-grid-action-size);
height: var(--rc-data-grid-action-size);
min-width: var(--rc-data-grid-action-size);
max-width: var(--rc-data-grid-action-size);
flex: 0 0 var(--rc-data-grid-action-size);
border: 0 !important;
border-radius: var(--rc-data-grid-action-radius) !important;
box-shadow: none !important;
}
.pm-table-action--default {
color: var(--rc-text-primary);
background: var(--default);
}
.pm-table-action--default:hover {
background: var(--default-hover);
}
.pm-table-action--primary {
color: var(--rc-brand-primary);
background: var(--rc-brand-primary-soft);
}
.pm-table-action--approve {
color: var(--rc-color-success);
background: color-mix(in srgb, var(--rc-color-success) 10%, transparent);
}
.pm-table-action--warning {
color: var(--rc-color-warning);
background: color-mix(in srgb, var(--rc-color-warning) 10%, transparent);
}
.pm-table-action--danger {
color: var(--rc-color-danger);
background: color-mix(in srgb, var(--rc-color-danger) 10%, transparent);
}
.pm-data-grid-truncate {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pm-data-grid-chip {
display: inline-flex;
max-width: 100%;
min-width: 0;
flex: 0 0 auto;
align-items: center;
vertical-align: middle;
white-space: nowrap;
font-size: var(--rc-data-grid-chip-font-size) !important;
font-weight: var(--rc-data-grid-chip-font-weight) !important;
line-height: var(--rc-data-grid-chip-line-height);
letter-spacing: 0px;
}
.pm-data-grid-chip__label {
min-width: 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rc-dialog-backdrop {
width: 100%;
height: 100%;
background: var(--backdrop);
-webkit-backdrop-filter: blur(var(--rc-modal-backdrop-blur));
backdrop-filter: blur(var(--rc-modal-backdrop-blur));
}
.rc-dialog-panel {
--rc-modal-current-width: var(--rc-modal-width-lg);
position: relative;
display: flex;
width: min(calc(100vw - 5rem), var(--rc-modal-current-width)) !important;
max-width: calc(100vw - 5rem) !important;
max-height: calc(100dvh - 2rem);
flex-direction: column;
padding: var(--rc-modal-padding);
overflow: hidden;
border: 0 !important;
border-radius: var(--rc-modal-radius) !important;
background: var(--overlay);
box-shadow: var(--overlay-shadow);
outline: none;
}
.rc-dialog-panel--xs {
--rc-modal-current-width: var(--rc-modal-width-xs);
}
.rc-dialog-panel--sm {
--rc-modal-current-width: var(--rc-modal-width-sm);
}
.rc-dialog-panel--md {
--rc-modal-current-width: var(--rc-modal-width-md);
}
.rc-dialog-panel--lg {
--rc-modal-current-width: var(--rc-modal-width-lg);
}
.rc-dialog-panel--xl {
--rc-modal-current-width: var(--rc-modal-width-xl);
}
.rc-dialog-panel--2xl {
--rc-modal-current-width: var(--rc-modal-width-2xl);
}
.rc-dialog-panel--3xl {
--rc-modal-current-width: var(--rc-modal-width-3xl);
}
.rc-dialog-panel--4xl {
--rc-modal-current-width: var(--rc-modal-width-4xl);
}
.rc-dialog-panel--5xl {
--rc-modal-current-width: var(--rc-modal-width-5xl);
}
.rc-dialog-panel > form {
display: flex;
min-height: 0;
max-height: 100%;
flex-direction: column;
}
.rc-dialog-title {
margin: 0;
padding-right: 2.5rem;
color: var(--rc-text-primary);
font-size: var(--rc-modal-title-size);
font-weight: var(--rc-modal-title-weight);
line-height: var(--rc-modal-title-line-height);
letter-spacing: 0;
text-wrap: balance;
}
.rc-dialog-description {
margin-top: var(--rc-modal-section-gap);
padding-right: 2.5rem;
color: var(--rc-text-muted);
font-size: var(--rc-text-sm);
font-weight: var(--rc-font-regular);
line-height: var(--rc-line-normal);
text-wrap: pretty;
}
.rc-dialog-body {
min-height: 0;
min-width: 0;
flex: 1;
margin-top: var(--rc-modal-section-gap);
margin-right: -0.1875rem;
margin-left: -0.1875rem;
padding: 0.1875rem;
overflow-y: auto;
overscroll-behavior: contain;
color: var(--rc-text-secondary);
font-size: var(--rc-text-sm);
line-height: 1.43;
}
.rc-dialog-actions {
display: flex;
flex: none;
flex-direction: row;
align-items: center;
justify-content: flex-end;
gap: var(--rc-modal-footer-gap);
margin-top: var(--rc-modal-footer-margin);
}
.rc-dialog-actions .rc-button--secondary:not(.rc-button--danger) {
border: 1px solid var(--rc-border-default) !important;
background: var(--rc-bg-surface);
color: var(--rc-text-primary);
box-shadow: none;
}
.rc-dialog-actions .rc-button--secondary:not(.rc-button--danger):hover,
.rc-dialog-actions .rc-button--secondary:not(.rc-button--danger)[data-hover],
.rc-dialog-actions
.rc-button--secondary:not(.rc-button--danger)[data-hover='true'] {
border-color: color-mix(
in oklab,
var(--rc-border-default) 72%,
var(--rc-text-muted)
);
background: var(--default);
}
.rc-dialog-close {
top: var(--rc-modal-close-inset);
right: var(--rc-modal-close-inset);
z-index: 2;
width: var(--rc-modal-close-size);
height: var(--rc-modal-close-size);
min-width: var(--rc-modal-close-size);
padding: 0.25rem;
border: 0;
background: var(--default);
color: var(--rc-text-muted);
box-shadow: none;
transition: color var(--rc-motion-fast) var(--rc-ease-standard),
background-color var(--rc-motion-fast) var(--rc-ease-standard);
}
/* Explicit close controls already live inside the inset title wrapper. */
.rc-dialog-close--explicit {
top: 0;
right: 0;
}
.rc-dialog-panel > .rc-dialog-close--explicit {
top: var(--rc-modal-close-inset);
right: var(--rc-modal-close-inset);
}
.rc-dialog-panel:has(.rc-dialog-close--explicit) > .rc-dialog-close--automatic {
display: none;
}
.rc-dialog-close:hover,
.rc-dialog-close:focus-visible {
background: var(--default-hover);
color: var(--rc-text-primary);
outline: none;
}
.rc-dialog-close .anticon,
.rc-dialog-close .anticon > svg {
display: block;
width: var(--rc-modal-close-icon-size);
height: var(--rc-modal-close-icon-size);
margin: 0;
}
.rc-dialog-close:focus-visible {
box-shadow: 0 0 0 2px var(--rc-brand-primary-soft-hover);
}
.data-grid-empty-state {
min-height: 12rem;
padding: var(--rc-space-8) var(--rc-space-4);
}
.data-grid-empty-loading {
display: flex;
min-height: 18rem;
align-items: center;
justify-content: center;
}
.pm-data-grid-native-empty-fallback {
display: none !important;
}
.pm-data-grid-wrap--empty .data-grid__empty-state,
.pm-data-grid-wrap--empty [data-slot='data-grid-empty-state'] {
display: none !important;
visibility: hidden !important;
}
.pm-data-grid-empty-overlay {
position: absolute;
inset: var(--rc-data-grid-header-height) 0 0;
z-index: 10;
display: grid;
place-items: center;
min-height: 12rem;
padding: var(--rc-space-4);
overflow: hidden;
border-radius: var(--rc-data-grid-radius);
background: var(--surface);
pointer-events: none;
}
.pm-data-grid-empty-overlay .empty-state {
min-height: 0;
padding: 0;
pointer-events: auto;
}
.data-grid-empty-illustration {
position: relative;
width: 7.5rem;
height: 5.75rem;
color: var(--rc-text-muted);
}
.data-grid-empty-illustration-back,
.data-grid-empty-illustration-front,
.data-grid-empty-illustration-badge {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
}
.data-grid-empty-illustration-back {
inset: 0.25rem 1.375rem 1.5rem 0.75rem;
justify-content: flex-start;
padding-left: 1rem;
border: 1px solid
color-mix(in oklab, var(--rc-brand-primary) 14%, var(--border));
border-radius: 0.875rem;
background: color-mix(in oklab, var(--rc-brand-primary) 6%, var(--surface));
color: color-mix(in oklab, var(--rc-brand-primary) 48%, var(--muted));
transform: rotate(-5deg);
}
.data-grid-empty-illustration-front {
inset: 1rem 0.5rem 0.25rem 1.5rem;
justify-content: flex-start;
gap: 0.625rem;
padding-inline: 0.875rem;
border: 1px solid var(--border);
border-radius: 1rem;
background: var(--surface);
color: var(--rc-brand-primary);
box-shadow: var(--surface-shadow);
}
.data-grid-empty-illustration-lines {
display: flex;
width: 2.5rem;
flex-direction: column;
gap: 0.375rem;
}
.data-grid-empty-illustration-lines i {
display: block;
height: 0.25rem;
border-radius: 999px;
background: var(--default);
}
.data-grid-empty-illustration-lines i:nth-child(2) {
width: 75%;
}
.data-grid-empty-illustration-lines i:nth-child(3) {
width: 50%;
}
.data-grid-empty-illustration-badge {
right: 0;
bottom: 0;
width: 2rem;
height: 2rem;
border: 2px solid var(--surface);
border-radius: 50%;
background: var(--default);
color: var(--foreground);
box-shadow: var(--surface-shadow);
}
.pm-pagination-bar {
flex: none;
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-top: var(--rc-list-pagination-gap) !important;
padding-top: 0;
border-top: 0;
}
.space-y-6 > :has(+ .pm-pagination-bar),
.space-y-5 > :has(+ .pm-pagination-bar),
.space-y-4 > :has(+ .pm-pagination-bar),
.space-y-3 > :has(+ .pm-pagination-bar),
.space-y-2 > :has(+ .pm-pagination-bar) {
margin-bottom: 0 !important;
}
.rc-page-heading {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--rc-space-3);
}
.rc-page-tabs,
.rc-page-controls {
min-width: 0;
}
.rc-page-controls {
display: flex;
margin-left: auto;
align-items: center;
gap: var(--rc-space-3);
}
.rc-page-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: var(--rc-space-2);
}
.rc-page-toolbar {
min-width: 0;
}
.filter-tabs-wrap {
max-width: 100%;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
}
.filter-tabs-wrap::-webkit-scrollbar {
display: none;
}
.filter-tabs-wrap .tabs__list-container {
border: 0 !important;
border-radius: calc(var(--radius) * 3) !important;
background: var(--default) !important;
}
.filter-tabs-wrap .tabs__list {
gap: 0;
padding: 0.25rem !important;
}
.filter-tabs-wrap .tabs__tab {
height: 2rem;
padding-inline: 1rem;
border-radius: calc(var(--radius) * 3) !important;
color: var(--muted);
white-space: nowrap;
}
.filter-tabs-wrap .tabs__tab[data-selected='true'] {
color: var(--foreground) !important;
}
.filter-tabs-wrap .tabs__indicator {
inset: 0 !important;
width: 100%;
height: 100% !important;
border-radius: calc(var(--radius) * 3) !important;
background: var(--segment) !important;
box-shadow: var(--surface-shadow) !important;
}
.rc-segmented-control {
display: inline-flex;
min-width: max-content;
align-items: center;
padding: var(--rc-segment-track-padding);
border: 1px solid var(--rc-border-default);
border-radius: var(--rc-segment-track-radius);
background: color-mix(in oklab, var(--default) 78%, var(--rc-bg-surface));
box-shadow: inset 0 0 0 1px
color-mix(in oklab, var(--rc-bg-surface) 82%, transparent),
0 1px 2px rgb(15 23 42 / 4%);
}
.rc-segmented-control > .tabs__list-container,
.rc-segmented-control .tabs__list-container {
border: 0 !important;
border-radius: calc(var(--rc-segment-track-radius) - var(--rc-segment-track-padding)) !important;
background: transparent !important;
box-shadow: none !important;
}
.rc-segmented-control .tabs__list {
min-width: max-content !important;
padding: 0 !important;
gap: 0 !important;
}
.rc-segmented-control .tabs__separator {
display: none !important;
}
.rc-segmented-control__item {
display: inline-flex;
height: var(--rc-segment-item-height);
flex: none;
align-items: center;
justify-content: center;
gap: var(--rc-segment-item-gap);
padding-inline: var(--rc-segment-item-padding-inline);
border: 0 !important;
border-radius: var(--rc-segment-item-radius);
outline: 0 !important;
background: transparent !important;
color: var(--rc-text-secondary) !important;
font-size: var(--rc-segment-font-size);
font-weight: var(--rc-segment-font-weight);
line-height: var(--rc-segment-line-height);
letter-spacing: 0;
box-shadow: none !important;
white-space: nowrap;
transition: color var(--rc-motion-fast) var(--rc-ease-standard),
background-color var(--rc-motion-fast) var(--rc-ease-standard),
box-shadow var(--rc-motion-fast) var(--rc-ease-standard);
}
.rc-segmented-control__item:not(.is-active):not([data-selected='true']) {
border: 0 !important;
outline: 0 !important;
background: transparent !important;
box-shadow: none !important;
}
.rc-segmented-control__item:hover:not(:disabled) {
background: transparent !important;
color: var(--rc-text-primary) !important;
box-shadow: none !important;
}
.rc-segmented-control__item.is-active,
.rc-segmented-control__item[data-selected='true'] {
background: var(--rc-bg-surface) !important;
color: var(--rc-text-primary) !important;
font-weight: var(--rc-font-medium) !important;
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--rc-border-default) 76%, transparent),
0 1px 2px rgb(15 23 42 / 10%) !important;
}
.rc-segmented-control__item[data-selected='true'] .tabs__indicator {
inset: 0 !important;
width: 100% !important;
height: 100% !important;
border-radius: var(--rc-segment-item-radius) !important;
background: var(--rc-bg-surface) !important;
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--rc-border-default) 76%, transparent),
0 1px 2px rgb(15 23 42 / 10%) !important;
}
.rc-segmented-control__item:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.rc-segmented-control__count {
padding: 0.125rem 0.375rem;
border-radius: 999px;
background: color-mix(in oklab, var(--default) 78%, transparent);
color: inherit;
font-size: var(--rc-text-xs);
font-weight: var(--rc-font-medium);
line-height: 1rem;
}
.rc-title-info-trigger,
.rc-title-info-trigger.button {
display: inline-flex !important;
width: 1rem !important;
min-width: 1rem !important;
height: 1rem !important;
min-height: 1rem !important;
flex: none;
align-items: center !important;
justify-content: center !important;
padding: 0 !important;
border: 0 !important;
border-radius: 999px !important;
background: transparent !important;
box-shadow: none !important;
color: var(--rc-text-muted) !important;
line-height: 1 !important;
transform: none !important;
}
.rc-title-info-trigger:hover,
.rc-title-info-trigger:focus-visible,
.rc-title-info-trigger[data-hovered='true'],
.rc-title-info-trigger.button:hover,
.rc-title-info-trigger.button:focus-visible,
.rc-title-info-trigger.button[data-hovered='true'] {
background: transparent !important;
color: var(--rc-text-secondary) !important;
}
.rc-title-info-trigger .anticon,
.rc-title-info-trigger .anticon > svg,
.rc-title-info-trigger > svg {
display: block;
width: 0.875rem !important;
height: 0.875rem !important;
margin: 0 !important;
font-size: 0.875rem !important;
}
.rc-tab-count {
position: relative;
z-index: 1;
min-width: 1.25rem;
padding-inline: 0.375rem;
border-radius: 999px;
background: var(--rc-brand-primary-soft);
color: var(--rc-brand-primary);
font-size: var(--rc-text-xs);
line-height: 1.25rem;
text-align: center;
}
.pm-pagination-bar > .pagination {
.rc-dialog-body {
min-width: 0;
min-height: 0;
flex: 1;
width: 100%;
}
.pm-pagination-bar .pagination__summary {
margin-top: var(--rc-modal-section-gap);
margin-right: -0.1875rem;
margin-left: -0.1875rem;
padding: 0.1875rem;
overflow-y: auto;
overscroll-behavior: contain;
color: var(--rc-text-secondary);
font-size: var(--rc-text-xs);
white-space: nowrap;
}
.pm-pagination-bar .pagination__content {
justify-content: flex-end;
font-size: var(--rc-text-sm);
line-height: 1.43;
}
.pm-pagination-controls {
.rc-dialog-actions {
display: flex;
flex: none;
align-items: center;
gap: 0.5rem;
}
.pm-pagination-page-size-select {
width: auto;
min-width: 10rem;
max-width: 100%;
}
.pm-pagination-page-size-select .select__trigger {
height: 2.25rem;
min-height: 2.25rem;
padding-inline: 1rem;
border: 0 !important;
border-radius: var(--rc-toolbar-filter-radius) !important;
background: var(--rc-toolbar-filter-background) !important;
box-shadow: none !important;
}
.pm-pagination-page-size-select .select__trigger:hover,
.pm-pagination-page-size-select .select__trigger[data-hovered='true'] {
background: var(--rc-toolbar-filter-background-hover) !important;
}
.pm-pagination-page-size-copy {
display: flex;
min-width: 0;
flex: 1;
align-items: center;
gap: 0.375rem;
}
.pm-pagination-page-size-label,
.pm-pagination-page-size-value {
color: var(--foreground) !important;
font-size: 0.8125rem;
font-weight: 500;
line-height: 1rem;
white-space: nowrap;
}
.pm-pagination-page-size-label::after {
content: ':';
}
.pm-pagination-page-size-select .select__indicator,
.pm-pagination-page-size-indicator {
display: inline-grid !important;
width: 1rem;
height: 1rem;
flex: none;
place-items: center;
color: var(--rc-toolbar-filter-icon);
font-size: 0.875rem;
line-height: 1;
}
.pm-pagination-page-size-indicator :is(svg, .anticon),
.pm-pagination-page-size-indicator svg,
.pm-pagination-page-size-select .select__indicator svg {
display: block !important;
width: 1em !important;
height: 1em !important;
margin: 0 !important;
color: currentColor !important;
fill: currentColor !important;
justify-content: flex-end;
gap: var(--rc-modal-footer-gap);
margin-top: var(--rc-modal-footer-margin);
}
@media (max-width: 768px) {
.rc-page-heading,
.rc-page-controls,
.rc-page-actions {
width: 100%;
}
.rc-page-actions {
justify-content: flex-start;
}
.pm-pagination-bar {
align-items: stretch;
flex-direction: column;
}
.pm-pagination-bar .pagination__content {
max-width: 100%;
justify-content: flex-start;
overflow-x: auto;
scrollbar-width: none;
}
.pm-pagination-bar .pagination__content::-webkit-scrollbar {
display: none;
}
.pm-pagination-bar .pagination__summary {
width: 100%;
white-space: normal;
}
.pm-pagination-controls {
justify-content: flex-start;
}
.rc-dialog-close {
top: var(--rc-modal-close-inset);
right: var(--rc-modal-close-inset);
z-index: 2;
width: var(--rc-modal-close-size);
height: var(--rc-modal-close-size);
min-width: var(--rc-modal-close-size);
padding: 0.25rem;
}
@media (max-width: 767px) {
.external-project-referral-grid .pm-data-grid [data-pinned='start'] {
position: static !important;
left: auto !important;
z-index: auto !important;
}
.rc-dialog-panel {
width: calc(100vw - 2rem) !important;
max-width: calc(100vw - 2rem) !important;
......@@ -2836,188 +526,105 @@ body [data-theme='dark'] .login-page {
}
}
/* Xinyuan legacy brand-color bridge: copied sky/blue/cyan/rose/red/pink utilities render as Xinyuan red. */
body
:is(
.bg-sky-50,
.bg-sky-100,
.bg-blue-50,
.bg-blue-100,
.bg-cyan-50,
.bg-cyan-100,
.bg-rose-50,
.bg-rose-100,
.bg-red-50,
.bg-red-100,
.bg-pink-50,
.bg-pink-100,
[class*='bg-sky-50/'],
[class*='bg-sky-100/'],
[class*='bg-blue-50/'],
[class*='bg-blue-100/'],
[class*='bg-cyan-50/'],
[class*='bg-cyan-100/'],
[class*='bg-rose-50/'],
[class*='bg-rose-100/'],
[class*='bg-red-50/'],
[class*='bg-red-100/'],
[class*='bg-pink-50/'],
[class*='bg-pink-100/']
) {
background-color: var(--rc-brand-primary-soft) !important;
.login-brand-logo {
display: grid;
width: var(--rc-login-brand-logo-size);
height: var(--rc-login-brand-logo-size);
place-items: center;
}
body
:is(
.border-sky-100,
.border-sky-200,
.border-sky-300,
.border-sky-400,
.border-blue-100,
.border-blue-200,
.border-blue-300,
.border-blue-400,
.border-cyan-100,
.border-cyan-200,
.border-cyan-300,
.border-cyan-400,
.border-rose-100,
.border-rose-200,
.border-rose-300,
.border-rose-400,
.border-red-100,
.border-red-200,
.border-red-300,
.border-red-400,
.border-pink-100,
.border-pink-200,
.border-pink-300,
.border-pink-400
) {
border-color: color-mix(in srgb, var(--rc-brand-primary) 28%, transparent) !important;
.login-brand-logo .rc-brand-logo,
.rc-sidebar-brand-mark .rc-sidebar-logo {
grid-area: 1 / 1;
object-fit: contain;
}
body
:is(
.text-sky-500,
.text-sky-600,
.text-sky-700,
.text-sky-800,
.text-blue-500,
.text-blue-600,
.text-blue-700,
.text-blue-800,
.text-cyan-500,
.text-cyan-600,
.text-cyan-700,
.text-cyan-800,
.text-rose-500,
.text-rose-600,
.text-rose-700,
.text-rose-800,
.text-red-500,
.text-red-600,
.text-red-700,
.text-red-800,
.text-pink-500,
.text-pink-600,
.text-pink-700,
.text-pink-800
) {
color: var(--rc-brand-primary) !important;
.rc-brand-logo--dark,
.rc-sidebar-brand-mark .rc-sidebar-logo--dark {
display: none;
}
body
:is(
.rc-status-chip--info,
.rc-tone-surface--info,
.pm-data-grid-chip.rc-status-chip--info
) {
border-color: var(--rc-color-info-border) !important;
background: var(--rc-color-info-soft) !important;
color: var(--rc-color-info) !important;
.dark .rc-brand-logo--light,
.dark .rc-sidebar-brand-mark .rc-sidebar-logo--light {
display: none;
}
body
:is(
.focus\:border-sky-400:focus,
.focus\:border-blue-400:focus,
.focus\:border-cyan-400:focus,
.focus\:border-rose-400:focus,
.focus\:border-red-400:focus,
.focus\:border-pink-400:focus
) {
border-color: var(--rc-brand-primary) !important;
.dark .rc-brand-logo--dark,
.dark .rc-sidebar-brand-mark .rc-sidebar-logo--dark {
display: block;
}
body
:is(
.ring-sky-300,
.ring-blue-300,
.ring-cyan-300,
.ring-rose-300,
.ring-red-300,
.ring-pink-300
) {
--tw-ring-color: color-mix(in srgb, var(--rc-brand-primary) 28%, transparent) !important;
body .login-captcha-row {
display: grid;
grid-template-columns: minmax(0, 1.55fr) minmax(0, 1fr);
gap: 0.75rem;
width: 100%;
}
.app-switcher-card[data-current='true'] {
border-color: color-mix(
in oklab,
var(--rc-brand-primary) 34%,
var(--rc-border-default)
) !important;
background: var(--rc-brand-primary-soft) !important;
body .login-captcha-button {
width: 100%;
height: var(--rc-login-control-height) !important;
min-height: var(--rc-login-control-height) !important;
border: 1px solid var(--rc-button-secondary-border) !important;
border-radius: var(--rc-radius-control) !important;
background: var(--rc-bg-surface) !important;
color: var(--rc-text-secondary) !important;
box-shadow: none !important;
}
.app-switcher-card__logo,
.app-switcher-card__initial {
width: 2.25rem;
height: 2.25rem;
flex: 0 0 2.25rem;
border-radius: var(--rc-radius-control);
body .login-captcha-button .captcha-image {
display: grid;
width: 100%;
height: 100%;
min-width: 0;
place-items: center;
overflow: hidden;
}
.app-switcher-card__logo {
body .login-captcha-button .captcha-image :is(svg, img) {
display: block;
object-fit: contain;
width: auto;
max-width: 100%;
height: auto;
max-height: 1.875rem;
margin: 0;
}
.app-switcher-card__initial {
display: inline-grid;
place-items: center;
background: var(--rc-brand-primary);
color: var(--rc-brand-on-primary);
font-size: var(--rc-text-sm);
font-weight: var(--rc-font-semibold);
line-height: 1;
.dark body .login-captcha-button,
body .dark .login-captcha-button,
body [data-theme="dark"] .login-captcha-button {
border-color: var(--rc-button-secondary-border) !important;
background: var(--rc-bg-surface) !important;
color: var(--rc-text-secondary) !important;
}
/* Keep populated login fields on the opaque Xinyuan tint through HeroUI focus states. */
body
.login-page
.login-card
.login-form
.login-field
input.login-input.input--secondary[data-filled='true']:focus,
body
.login-page
.login-card
.login-form
.login-field
input.login-input.input--secondary[data-filled='true']:focus-visible {
--input-bg: var(--rc-login-field-filled-background) !important;
--input-bg-focus: var(--rc-login-field-filled-background) !important;
background: var(--rc-login-field-filled-background) !important;
background-color: var(--rc-login-field-filled-background) !important;
box-shadow:
inset 0 0 0 1000px var(--rc-login-field-filled-background),
0 0 0 2px var(--rc-login-field-focus-ring) !important;
-webkit-box-shadow:
inset 0 0 0 1000px var(--rc-login-field-filled-background),
0 0 0 2px var(--rc-login-field-focus-ring) !important;
transition:
background-color 150ms ease,
border-color 150ms ease,
box-shadow 150ms ease !important;
.dark .login-page,
body .dark .login-page,
body.dark .login-page,
body [data-theme="dark"] .login-page {
--rc-login-field-filled-background: color-mix(
in oklab,
var(--rc-brand-primary) 14%,
var(--rc-field-background)
);
--rc-login-field-filled-background-hover: color-mix(
in oklab,
var(--rc-brand-primary) 18%,
var(--rc-field-background)
);
--rc-login-field-autofill-background: color-mix(
in oklab,
var(--rc-brand-primary) 16%,
var(--rc-field-background)
);
--rc-login-field-border-hover: color-mix(
in oklab,
var(--rc-brand-primary) 42%,
var(--rc-border-default)
);
--rc-login-field-focus-ring: color-mix(
in oklab,
var(--rc-brand-primary) 22%,
transparent
);
}
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