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

feat: align audit manager with xinyuan ui baseline

parent 2a30208c
......@@ -54,7 +54,7 @@
### 颜色、图标与暗色主题
- 源码中存在蓝、sky、rose、pink、渐变和页面私有品牌色;主色未统一为 `#9E1B1B`。
- 源码中存在蓝、sky、rose、pink、渐变和页面私有品牌色;专项优化后主色统一为 `#DA241A`,历史问题记录保留用于追踪。
- 同时存在 Heroicons 和局部自绘图标,缺少 Ant Design Icons 适配层。
- 没有根级暗色主题,Portal 中的 Dropdown、Popover、Modal、Tooltip 和上传面板无法保证一致暗色表面。
- 当前活动路由未渲染数据图表;图表规范作为后续功能的强制合同写入设计文档。
......
......@@ -5,7 +5,7 @@
## 1. 品牌与技术基线
- 应用显示名:`监察稽核管理`。登录、Sidebar、Navbar、应用切换当前项和浏览器标题必须一致。
- 主色:`#9E1B1B`。选中、焦点、Checkbox、日期、Pagination 和信息态统一使用品牌 Token。
- 主色:`#DA241A`。选中、焦点、Checkbox、日期、Pagination 和信息态统一使用品牌 Token;HeroUI 的 `--accent`、显式 `--primary-*` 色阶和 hover/pressed 派生均不得切换到默认蓝紫主题。
- 浅色 Logo 使用 `src/assets/identity/xy-logo-red.png`;暗色 Logo 使用 `xy-logo-white.png`。不得滤镜、反色、加底板或替换为 RSM 标识。
- 基础组件使用 `@heroui/react`,高级导航、DataGrid、DropZone、ActionBar 和 EmptyState 使用本仓独立正式安装的 `@heroui-pro/react`。
- 图标只从 `src/components/AppIcons.jsx` 导出,底层统一使用 Ant Design Icons。
......@@ -13,7 +13,7 @@
## 2. Token 与尺寸
- 页面画布:浅色 `#F5F5F5`,暗色 `#18181B`。
- 页面画布:使用 HeroUI `background` Token,暗色仅由固定 Shell 规则控制。
- 页面左右 inset:桌面 68px,移动端 72px;真实滚动容器 `.rc-app-page` 直接持有该边距。
- 工作台模块和 KPI Card:24px 圆角;DataGrid:20px;普通控件:8px;Popover:12px;Modal:24px。
- 页面标题:20px/600/28px;模块标题:16px/600;普通正文:14px/400。
......@@ -31,7 +31,7 @@
- 登录采用居中认证 Card。花形 Logo 约 36px,标题约 32px/600;不重复显示“登录”标题。
- 账号、密码、验证码可见高度统一为 40px,字段组可见间距 24px,提交按钮上方保留 24px。
- 登录字段有值或自动填充时使用 `#fbf1f1`,Hover 使用 `#faeaea`,Focus 使用鑫元红描边和浅红焦点环。
- 应用切换器固定为居中的紧凑两列 HeroUI Modal,移动端一列;标题“应用切换”、副标题“选择你可访问的应用”。分组与成员顺序读取站点 `site.group`,组标题显示真实应用数量,空组隐藏,未分组可访问项归入“其他应用”,无配置时回退“全部应用”;最多一个“当前”标签,不显示权限标签或外链装饰。
- 应用切换器固定为居中的紧凑两列 HeroUI Modal,移动端一列;保留 `aria-label="应用切换"` 作为无障碍名称,不显示可见标题、副标题或关闭图标,仅保留搜索字段。面板背景跟随页面画布;分组与成员顺序读取站点 `site.group`,组标题显示真实应用数量,空组隐藏,未分组可访问项归入“其他应用”,无配置时回退“全部应用”;最多一个“当前”标签,不显示权限标签或外链装饰。
## 4. 页面、筛选与 Tab
......@@ -52,6 +52,7 @@
- 多选动作仅显示在当前视口底部居中的 ActionBar,右侧使用关闭图标清除选择。
- 业务列表沿用各模块既有分页口径;通知列表固定每页 10 条且不显示页容量选择器。Pagination 距离 DataGrid 约 8px。
- 成功反馈使用顶部居中的 3-4 秒 Toast,不得用页面流 Alert 推挤筛选或表格。
- 图表和 KPI 使用独立的低饱和色板:品牌红、暖棕、灰紫、蓝灰依次承载系列,不使用绿色、荧光色或彩虹式相邻颜色;单系列固定使用 `#DA241A`。
## 6. 表单、上传与弹窗
......
......@@ -17,6 +17,8 @@
-> theme.css(不可变)+ tokens.css(别名/布局扩展)
```
共享 Navbar 支持可选的 breadcrumb header 状态。详情页面通过 `useAppShellHeader` 在数据加载完成后提供父级 HeroUI `Breadcrumbs` 与通用详情标题;真实记录标题留在内容区并与上下文操作同行。Shell 在路由标题变化时清理该覆盖状态,避免跨路由残留。该通道只改变标题呈现,不改变数据请求、路由或业务回调。
允许共享 AppShell、DataGrid composition、Autocomplete reference field、DropZone field、Application switcher 和确认弹窗,但它们必须暴露当前 HeroUI selection/value/event 合同。禁止兼容 Catalyst props、DOM event、class hook 或旧 DOM 结构。
## 组件决策
......@@ -24,12 +26,13 @@
- Shell:Pro Sidebar.Provider/Sidebar/Main/Mobile + Pro Navbar。
- 表单:HeroUI TextField/Input/TextArea/Checkbox/Autocomplete/DatePicker。
- 多选:共享 Autocomplete composition 在 `Autocomplete.Value` 内使用 HeroUI `TagGroup + Tag + Tag.RemoveButton`;根 `selectedKeys` 是唯一选择状态,移除操作回写同一个 `onSelectionChange`,业务页面只负责值协议映射。
- 单选和多选选择器都在 `Autocomplete.Trigger` 内使用官方 `Autocomplete.ClearButton`;单选清除回写原字段空值,多选既可逐项移除 Tag 也可清除全部,禁止仅清理内部 ListBox 状态。
- 列表:Pro DataGrid 原生 selection/sort/fixed columns + HeroUI Pagination + Pro ActionBar/EmptyState。
- 弹层:HeroUI Modal/AlertDialog/Dropdown/Tooltip,根主题覆盖 body Portal。
- 通知:页面直接组合 HeroUI Tabs/SearchField/Card/Chip/Badge/Tooltip 与 Pro EmptyState;业务 service 复用 `DataService` 的 `/notice/customer/*` 接口,Shell 只持有真实未读计数和 60 秒刷新事件,不在 Sidebar 建立第二入口。
- 应用切换:共享 `ApplicationSwitcherDialog` 直接组合 HeroUI Modal/Card/Surface/Avatar/Chip 与 Pro EmptyState;`SiteContext.siteInfo.group` 只负责动态分组与顺序,`UserContext.applications` 和 `/application/info` 继续负责访问集合与详情。授权域 `audit_manager` 规范化为唯一当前项,并导航到部署基路径 `/audit_manager_new/`。
- 应用切换:共享 `ApplicationSwitcherDialog` 直接组合 HeroUI Modal、Pro ItemCardGroup/ItemCard、Surface/Avatar/Chip 与 Pro EmptyState;Modal 仅保留无障碍名称和搜索字段,不显示重复标题/关闭图标,面板使用页面 `background`。`SiteContext.siteInfo.group` 只负责动态分组与顺序并在跨组时去重,`UserContext.applications` 和 `/application/info` 继续负责访问集合与详情。授权域 `audit_manager` 规范化为唯一当前项,并导航到部署基路径 `/audit_manager_new/`。
- 上传:Pro DropZone,页面边界接收真实 File。
- KPI/图表:仅业务存在时使用 Pro KPI/PieChart/BarChart/ComposedChart/ChartTooltip。
- KPI/图表:仅业务存在时使用 Pro KPI/PieChart/BarChart/ComposedChart/ChartTooltip;当前活动路由无真实图表,后续图表必须使用 `--xinyuan-chart-*` 低饱和 token。
- 报告视图:一级 HeroUI Tabs 切换 current/history,二级内容宽度 Tabs 以稳定项目 ID 切换报告快照;当前项目只挂载工具栏和 DataGrid,不重复 Tabs 已表达的项目标题,整体进度由 KPI 承担。项目与控制点仍携带原数组索引进入现有更新/确认回调。
- 当前报告搜索由 `AuditReportContent` 放在二级 Tabs 下方,并与当前项目确认、备注保存组成标准响应式列表工具栏;它继续使用父页面原受控 keyword 与保存回调。
- 报告附件单元格由 HeroUI `Chip/Link` 与 Pro `DropZone` 直接组合,不创建 Card/Surface 包装层;上传、追加和删除仍进入原 `onUpdate(processIndex, pointIndex, patch)` 协议。
......@@ -53,3 +56,6 @@ UI 迁移不得改变 `record-api.md` 和 `subjectsDefinition.md` 的 URL、meth
## 完成定义
活动路由旧 UI 引用与私有视觉 CSS 为零;依赖树单一;所有 Pro 子路径可用;构建、diff check、技能审计脚本通过;双应用四视口双主题的打开态和 console 均有证据。任何一项未完成时不声明迁移完成。当前代码与登录页门禁已通过,但登录后浏览器证据因缺少有效真实登录态尚未完成。
# 2026-09-02 架构同步
共享层固定为 `AppShell -> Sidebar.Provider -> Sidebar + Sidebar.Main/Navbar -> page scroll`。业务列表使用 `DataTable`(HeroUI Pro DataGrid)和 `DataTablePagination`,弹窗使用 HeroUI Modal/AlertDialog,引用/多选使用 `XinyuanAutocomplete`。流程监控业务仅替换呈现层,保留 `processMonitorService` 的 Subject、请求参数、保存回调和删除时机。
......@@ -35,7 +35,7 @@
- [x] 并列报告项目整合为内容宽度二级 HeroUI Tabs,单项目时隐藏轨道;搜索只过滤当前项目列表。
- [x] 当前报告搜索位于二级 Tabs 下方的标准工具栏,移动端独占一行;项目区不重复 Tabs 标题和 KPI 进度。
- [x] 报告附件内容使用紧凑 HeroUI Chip/Link,DataGrid 单元格内无 Card/Surface 或页面私有外观。
- [x] 应用切换按站点配置动态分组,移动一列/桌面两列,使用 HeroUI 原生 Card/Surface/Avatar 并保持唯一当前态。
- [x] 应用切换按站点配置动态分组并去重,移动一列/桌面两列,使用 HeroUI Pro ItemCardGroup/ItemCard + Surface/Avatar,并保持唯一当前态。
- [ ] 四视口、浅色/暗色、目标/基准同时打开完成(登录完成,鉴权页面待有效登录态)。
- [ ] 所有可达 popup、Modal、Tooltip、Dropdown、Sidebar 状态检查完成。
- [x] `npm run build` 与 `git diff --check` 通过;`audit_project.sh` 退出码 0,并报告非活动遗留。
......@@ -48,3 +48,11 @@
## 非活动遗留
`src/pages/process_monitor`、`src/pages/RecordDetailDrawer`、`src/components/record-workbench` 不在当前路由树;排除 Catalyst Kit 自身目录后,共有 7 个存量源码文件仍含 Catalyst/Headless 引用。不能把这些目录误报为已迁移。
# 本轮迁移清单(2026-09-02)
- [x] 权威 theme.css / tokens.css / shell.css 与审计项目管理同步
- [x] 固定深色 Sidebar、浅色内容区、HeroUI Pro Navbar.Trigger、应用切换 Modal
- [x] 共享 DataTable 增强固定列、sticky/横向滚动、EmptyState、ActionBar、20/50/100 页容量接口
- [x] `process_monitor` 页面移除 Catalyst、Headless UI、Heroicons 和私有渐变卡片表格
- [x] API、Subject、路由、权限、校验、保存回调和请求时机未改动
- [ ] `RecordDetailDrawer`、`record-workbench` 仍为未挂载历史存量,恢复路由前继续迁移
This diff is collapsed.
......@@ -2,7 +2,7 @@
审计日期:2026-08-26
当前分支:`codex/ui-ux-xinyuan`
唯一实现基准:`/Users/liuchenlong/Desktop/job/problem_rectification`
唯一实现基准:`/Users/liuchenlong/Desktop/job/audit_project_manager`
本次只迁移 UI、交互表现和前端组件结构。API、参数、Record/metadata 数据结构、路由、权限、校验、分页口径、保存回调、登录退出协议、请求时机和业务状态流均属于保护范围。
......@@ -35,7 +35,7 @@
| `/audit_report_fill` | 外部填报 | Site、ticket/report/owner 参数 |
| `/notifications` | 消息通知 | Site + Auth、平台通知查询/已读/动态动作 |
`process_monitor`、`RecordDetailDrawer`、`record-workbench` 未进入 `.umirc.ts`,属于非活动存量范围,不计入活动页面完成声明;它们仍有 Catalyst/Headless 引用,恢复路由前必须迁移。
`process_monitor`、`RecordDetailDrawer`、`record-workbench` 未进入 `.umirc.ts`。本轮已将 `process_monitor` 页面代码同步到 HeroUI/HeroUI Pro 结构,但它仍是非活动路由;`RecordDetailDrawer` 与 `record-workbench` 保留为未挂载历史存量,恢复路由前需继续迁移。
## 业务保护清单
......@@ -50,8 +50,8 @@
- 登录已同步基准的居中 Card、双主题 Logo、字段节奏、验证码 Button、filled/autofill 与响应式几何。
- 鉴权 Shell 使用共享 `AppShell`、Pro Navbar、Pro Sidebar offcanvas、用户 Dropdown、真实退出回调和应用切换 Modal。
- 应用切换已按 `model_analysis` 基准改为动态分组结构:读取 `SiteContext.siteInfo.group` 保留后端分组及成员顺序,隐藏空组,将未配置的可访问应用归入“其他应用”,并显示真实组内数量。
- 应用项使用严格一列移动/两列桌面结构、L2 HeroUI Card 与 L3 Surface + Avatar identity well;弹窗保持 46rem/42rem、28px padding、Body 单独滚动及唯一“当前” Chip。
- 应用切换已按审计项目管理基准改为动态分组结构:读取 `SiteContext.siteInfo.group` 保留后端分组及成员顺序,隐藏空组,将未配置的可访问应用归入“其他应用”,并在跨分组重复时去重、显示真实组内数量。
- 应用项使用 Pro `ItemCardGroup + ItemCard`,严格一列移动/两列桌面结构,统一红色鑫元 Logo identity well;弹窗保持 46rem/42rem、28px padding、Body 单独滚动及唯一“当前” Chip。
- 应用数据仍来自既有 `/application/info` 与 `userInfo.applications` 回退。仅将授权域 `audit_manager` 规范化为当前应用并导航至既有 `/audit_manager_new/` 部署基路径,未修改接口、权限或登录状态流。
- 活动列表使用 Pro DataGrid、Pagination、ActionBar、EmptyState;选择、排序、固定列及分页业务回调保留。
- 活动 DataGrid 操作列统一将 `tertiary` Eye 查看按钮放在首位:项目控制点与报告控制点打开当前行的只读 HeroUI Modal,历史报告进入原详情路由;编辑、确认、删除权限和回调不变。
......@@ -62,9 +62,9 @@
- 报告附件内容已移除 DataGrid 单元格内逐分组 Card;空附件使用 HeroUI Chip,已有附件使用 HeroUI Link/Chip,编辑上传继续使用 Pro DropZone。原附件数组、上传请求、追加和删除回调保持不变,未新增页面视觉 CSS。
- 活动选择/引用字段使用 HeroUI Autocomplete compound API;上传使用 Pro DropZone。
- 活动路由仅有“相关标签”“相关制度”两个多值引用入口,均经过共享 Autocomplete composition;已选值在触发框内以 HeroUI TagGroup 展示并逐项移除,选择框外无第二套 Chip 列表。单值引用、状态 Chip、DataGrid 摘要和文件列表保持各自语义。
- 活动弹窗使用 HeroUI Modal/AlertDialog;普通辅助按钮使用 `secondary`,删除确认使用 danger。
- 全应用弹窗已统一到鑫元尺寸阶梯:确认弹窗 `md/480px`,单列表单与填报链接 `lg/640px`,控制点双列表单 `3xl/960px`;应用切换保持基准 `46rem/736px`。大尺寸面板通过共享 Token 控制,不再把无效的 HeroUI Container `3xl` 当作官方尺寸。
- 报告页已有真实业务统计改为 Pro KPI compound API;活动路由没有图表,不新增图表或演示数据。
- 活动弹窗使用 HeroUI Modal/AlertDialog;普通辅助按钮使用 `secondary`,删除确认使用 danger。控制点查看态采用无框单列字段布局,字段标签使用 muted、字段值使用 foreground,避免在 Overlay 上叠加同层级 Surface;控制点查看和编辑在同一 Modal 内切换,编辑入口保留权限与原保存回调。
- 全应用弹窗已统一到鑫元尺寸阶梯:确认弹窗 `md/480px`,单列表单与填报链接 `lg/640px`,控制点详情/表单使用 `4xl/1088px`;应用切换保持基准 `46rem/736px`。大尺寸面板通过 `bg-background p-7 sm:max-w-[68rem]` 与 HeroUI `Modal.Container size="lg"` 组合实现,正文保持独立滚动。
- 报告页已有真实业务统计改为 Pro KPI compound API;活动路由没有图表,不新增图表或演示数据。主题专项已固定 HeroUI `--accent`/`--focus` 为 `#DA241A`,并提供完整 `--primary-50..950` 与 `--color-primary-*` 色阶;success 映射为低饱和蓝灰,图表 token 不含绿色。
- 全仓库无 ECharts 源码引用,未使用的 `echarts` 与 `echarts-for-react` 根依赖已移除。
- 权威 `theme.css` 逐字节一致;活动页不存在页面级 CSS,私有外观 utility 扫描为零。
......@@ -108,3 +108,11 @@
- 已填写态三个字段均为 40px,目标与基准的 filled Surface 计算值一致,未提交测试登录请求。
- `audit_project.sh` 退出码为 0;其启发式警告与同一脚本扫描“问题整改”的结果基本一致。目标额外审计确认 7 个非活动存量文件仍有 Catalyst/Headless 引用;脚本因 Node 不能直接 `import` CSS 误报 Pro 子路径,独立解析/动态导入检查已通过全部要求子路径。
- 2026-08-17 保存的真实浏览器会话已过期,服务端鉴权校验后清除了登录态。未伪造 token、用户或业务记录,因此 Sidebar、Navbar、退出菜单、应用切换、Tabs、DataGrid、Modal、KPI 等登录后浏览器验收仍未完成,不能声明全量迁移完成。
### 2026-09-02 本轮真实浏览器复验
- 开发服务:`http://localhost:8003/audit_manager_new/`;使用真实账号 Andy 与后端验证码完成登录。
- 在 1440x900、1280x720、768x1024、390x844 逐一检查登录后 Shell、固定深色 Sidebar、offcanvas 折叠、移动端导航 Sheet、Navbar Bell、用户菜单、应用切换 Modal、项目 Tabs、搜索、DataGrid、行查看详情 Modal 和移动端横向列表。
- 应用切换打开态确认 Pro `ItemCardGroup + ItemCard`、统一鑫元 Logo、分组数量、跨分组去重、搜索过滤和唯一当前态;详情弹窗宽度符合 4xl 级别。
- 重新加载后的当前页面控制台为 0 error / 0 warning;登录过程中两次错误验证码仅产生后端拒绝日志,不涉及代码或业务数据变更。
- 本轮截图保存在 `/tmp/audit-manager-1440.png`、`/tmp/audit-switcher-fixed.png`、`/tmp/controlpoint-modal-1440.png`、`/tmp/audit-1280x720.png`、`/tmp/audit-768x1024.png`、`/tmp/audit-390x844.png` 和 `/tmp/audit-mobile-nav.png`。
......@@ -31,6 +31,8 @@
"clsx": "^2.1.1",
"crypto-js": "^4.2.0",
"dayjs": "^1.11.20",
"echarts": "^6.1.0",
"echarts-for-react": "^3.0.6",
"embla-carousel": "^8.6.0",
"embla-carousel-react": "^8.6.0",
"maplibre-gl": "^6.4.1",
......
......@@ -77,6 +77,12 @@ importers:
dayjs:
specifier: ^1.11.20
version: 1.11.21
echarts:
specifier: ^6.1.0
version: 6.1.0
echarts-for-react:
specifier: ^3.0.6
version: 3.0.6(echarts@6.1.0)(react@19.2.8)
embla-carousel:
specifier: ^8.6.0
version: 8.6.0
......@@ -3730,6 +3736,15 @@ packages:
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
echarts-for-react@3.0.6:
resolution: {integrity: sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==}
peerDependencies:
echarts: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0
react: ^15.0.0 || >=16.0.0
echarts@6.1.0:
resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==}
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
......@@ -7228,6 +7243,9 @@ packages:
sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
size-sensor@1.0.3:
resolution: {integrity: sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==}
slash@3.0.0:
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
engines: {node: '>=8'}
......@@ -7632,6 +7650,9 @@ packages:
tslib@1.14.1:
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
tslib@2.3.0:
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
......@@ -8047,6 +8068,9 @@ packages:
resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==}
engines: {node: '>=12.20'}
zrender@6.1.0:
resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==}
zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
......@@ -9517,12 +9541,12 @@ snapshots:
'@react-aria/i18n@3.13.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@internationalized/date': 3.12.2
'@internationalized/date': 3.12.3
'@internationalized/message': 3.1.10
'@internationalized/string': 3.2.9
'@internationalized/string': 3.2.10
'@swc/helpers': 0.5.23
react: 19.2.8
react-aria: 3.50.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
react-aria: 3.51.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
react-dom: 19.2.8(react@19.2.8)
'@react-aria/interactions@3.28.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
......@@ -9537,7 +9561,7 @@ snapshots:
dependencies:
'@swc/helpers': 0.5.23
react: 19.2.8
react-aria: 3.50.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
react-aria: 3.51.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
react-dom: 19.2.8(react@19.2.8)
'@react-aria/utils@3.34.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
......@@ -12404,6 +12428,18 @@ snapshots:
eastasianwidth@0.2.0: {}
echarts-for-react@3.0.6(echarts@6.1.0)(react@19.2.8):
dependencies:
echarts: 6.1.0
fast-deep-equal: 3.1.3
react: 19.2.8
size-sensor: 1.0.3
echarts@6.1.0:
dependencies:
tslib: 2.3.0
zrender: 6.1.0
ee-first@1.1.1: {}
electron-to-chromium@1.5.398: {}
......@@ -16746,6 +16782,8 @@ snapshots:
sisteransi@1.0.5: {}
size-sensor@1.0.3: {}
slash@3.0.0: {}
slash@4.0.0: {}
......@@ -17225,6 +17263,8 @@ snapshots:
tslib@1.14.1: {}
tslib@2.3.0: {}
tslib@2.8.1: {}
tsutils@3.21.0(typescript@5.9.3):
......@@ -17720,4 +17760,8 @@ snapshots:
yocto-queue@1.2.2: {}
zrender@6.1.0:
dependencies:
tslib: 2.3.0
zwitch@2.0.4: {}
import { THEME_STORAGE_KEY } from '@/constants/app';
import { Sidebar } from '@heroui-pro/react/sidebar';
import { Toast } from '@heroui/react';
import { useLayoutEffect, useState } from 'react';
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { history } from 'umi';
import DashboardNavbar from './DashboardNavbar';
import DashboardSidebar from './DashboardSidebar';
function getInitialTheme() {
if (typeof window === 'undefined') return 'light';
try {
return window.localStorage.getItem(THEME_STORAGE_KEY) === 'dark' ? 'dark' : 'light';
} catch {
return 'light';
}
const AppShellHeaderContext = createContext(null);
export function useAppShellHeader() {
return useContext(AppShellHeaderContext);
}
export default function AppShell({
......@@ -24,24 +20,22 @@ export default function AppShell({
title,
...sidebarProps
}) {
const [theme, setTheme] = useState(getInitialTheme);
const [headerOverride, setHeaderOverrideState] = useState(null);
const setHeaderOverride = useCallback((nextHeader) => {
setHeaderOverrideState(nextHeader || null);
}, []);
useEffect(() => {
setHeaderOverrideState(null);
}, [title]);
useLayoutEffect(() => {
const root = document.documentElement;
root.classList.toggle('dark', theme === 'dark');
root.classList.toggle('light', theme === 'light');
root.dataset.theme = theme;
root.style.colorScheme = theme;
try {
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
} catch {
// Keep the in-memory theme when storage is unavailable.
}
}, [theme]);
const header = headerOverride || { title };
const headerContext = useMemo(() => ({ setHeaderOverride }), [setHeaderOverride]);
return (
<AppShellHeaderContext.Provider value={headerContext}>
<Sidebar.Provider
className={`template-dashboard-shell rc-app-shell ${theme}`}
className="template-dashboard-shell rc-app-shell light"
collapsible="offcanvas"
navigate={(href) => history.push(href)}
open={open}
......@@ -50,13 +44,10 @@ export default function AppShell({
<DashboardSidebar {...sidebarProps} />
<Sidebar.Main className="app-main-glass rc-app-main">
<DashboardNavbar
breadcrumbs={header.breadcrumbs}
notificationCount={notificationCount}
onOpenNotifications={
notificationPath ? () => history.push(notificationPath) : undefined
}
theme={theme}
title={title}
onThemeChange={setTheme}
onOpenNotifications={notificationPath ? () => history.push(notificationPath) : undefined}
title={header.title || title}
/>
<div className="app-shell-content-scroll min-h-0 flex-1 overflow-y-auto">
<div className="rc-app-page">{children}</div>
......@@ -64,5 +55,6 @@ export default function AppShell({
</Sidebar.Main>
<Toast.Provider maxVisibleToasts={3} placement="top" />
</Sidebar.Provider>
</AppShellHeaderContext.Provider>
);
}
import { Bell, Moon, SidebarPanel, Sun } from '@/components/AppIcons';
import { Bell, SidebarPanel } from '@/components/AppIcons';
import { Navbar } from '@heroui-pro/react/navbar';
import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar';
import { Badge, Button, Tooltip } from '@heroui/react';
import { Badge, Breadcrumbs, Button, Tooltip } from '@heroui/react';
import { history } from 'umi';
export default function DashboardNavbar({
breadcrumbs = [],
notificationCount = 0,
onOpenNotifications,
onThemeChange,
theme,
title,
}) {
const { isMobile, isOpen } = useSidebar();
const isDark = theme === 'dark';
const themeLabel = isDark ? '切换至浅色模式' : '切换至深色模式';
const normalizedNotificationCount = Math.max(0, Number(notificationCount) || 0);
const hasBreadcrumbs = breadcrumbs.length > 0;
return (
<Navbar className="app-navbar rc-navbar" maxWidth="full">
......@@ -24,7 +23,37 @@ export default function DashboardNavbar({
>
<SidebarPanel />
</Sidebar.Trigger>
<h1 className="app-navbar-page-title rc-navbar-title">{title}</h1>
{hasBreadcrumbs ? (
<div className="flex min-w-0 flex-1 flex-col justify-center gap-0.5">
<div className="flex min-w-0 items-center gap-2">
<Breadcrumbs aria-label="页面层级" className="rc-navbar-breadcrumbs min-w-0">
{breadcrumbs.map((item) => (
<Breadcrumbs.Item
key={`${item.href || 'current'}-${item.label}`}
href={item.href}
isCurrent={!item.href}
onPress={
item.href
? (event) => {
event?.preventDefault?.();
if (item.onPress) {
item.onPress(event);
} else {
history.push(item.href);
}
}
: undefined
}
>
<span className="truncate">{item.label}</span>
</Breadcrumbs.Item>
))}
</Breadcrumbs>
</div>
</div>
) : (
<h1 className="app-navbar-page-title rc-navbar-title min-w-0 truncate">{title}</h1>
)}
<Navbar.Spacer />
<div className="rc-navbar-actions">
{onOpenNotifications ? (
......@@ -55,20 +84,6 @@ export default function DashboardNavbar({
<Tooltip.Content>通知消息</Tooltip.Content>
</Tooltip>
) : null}
<Tooltip delay={0}>
<Button
isIconOnly
aria-label={themeLabel}
aria-pressed={isDark}
className="app-navbar-icon-button rc-navbar-icon-button"
size="sm"
variant="ghost"
onPress={() => onThemeChange(isDark ? 'light' : 'dark')}
>
{isDark ? <Sun size={16} /> : <Moon size={16} />}
</Button>
<Tooltip.Content>{themeLabel}</Tooltip.Content>
</Tooltip>
</div>
</Navbar.Header>
</Navbar>
......
import xyLogoRed from '@/assets/identity/xy-logo-red.png';
import xyLogoWhite from '@/assets/identity/xy-logo-white.png';
import { AppGrid, LogOut } from '@/components/AppIcons';
import { AppGrid, ChevronDown, LogOut } from '@/components/AppIcons';
import { APP_DISPLAY_NAME } from '@/constants/app';
import { Sidebar } from '@heroui-pro/react/sidebar';
import { Avatar, Button, Dropdown, Label, Tooltip } from '@heroui/react';
import { Sheet } from '@heroui-pro/react/sheet';
import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar';
import { Avatar, Button, Disclosure, Dropdown, Label } from '@heroui/react';
function getUserDisplay(userInfo) {
const displayName = userInfo?.display_name || userInfo?.username || '当前用户';
......@@ -44,19 +44,65 @@ function SidebarUserMenu({ userInfo, onLogout }) {
}
function SidebarContents({ menuGroups, onLogout, onOpenApplicationSwitcher, pathname, userInfo }) {
const renderMenu = (group) => (
<Sidebar.Menu aria-label={group.title}>
{group.items.map((item) => {
const Icon = item.icon;
const isCurrent = pathname === item.path || pathname.startsWith(`${item.path}/`);
return (
<Sidebar.MenuItem
href={item.path}
isCurrent={isCurrent}
key={item.path}
textValue={item.name}
tooltip={item.name}
>
<Sidebar.MenuIcon><Icon size={16} /></Sidebar.MenuIcon>
<Sidebar.MenuLabel>{item.name}</Sidebar.MenuLabel>
</Sidebar.MenuItem>
);
})}
</Sidebar.Menu>
);
const renderGroup = (group) => {
const hasCurrentItem = group.items.some(
(item) => pathname === item.path || pathname.startsWith(`${item.path}/`),
);
const GroupIcon = group.icon;
return (
<Sidebar.Group key={group.title}>
<Disclosure defaultExpanded={hasCurrentItem}>
<Disclosure.Heading>
<Sidebar.GroupLabel>
<Disclosure.Trigger className="flex w-full min-w-0 items-center gap-2 text-left">
{GroupIcon ? <GroupIcon size={16} /> : null}
<span className="min-w-0 flex-1 truncate">{group.title}</span>
<Disclosure.Indicator><ChevronDown size={14} /></Disclosure.Indicator>
</Disclosure.Trigger>
</Sidebar.GroupLabel>
</Disclosure.Heading>
<Disclosure.Content>
<Disclosure.Body>{renderMenu(group)}</Disclosure.Body>
</Disclosure.Content>
</Disclosure>
</Sidebar.Group>
);
};
return (
<>
<Sidebar.Header>
<div className="app-sidebar-brand rc-sidebar-brand">
<div className="app-sidebar-brand-mark rc-sidebar-brand-mark">
<img alt="鑫元" className="rc-sidebar-logo rc-sidebar-logo--light" src={xyLogoRed} />
<img alt="鑫元" className="rc-sidebar-logo rc-sidebar-logo--dark" src={xyLogoWhite} />
<img alt="鑫元" className="rc-sidebar-logo" src={xyLogoWhite} />
</div>
<div className="app-sidebar-brand-main rc-sidebar-brand-main" data-sidebar="label">
<span className="app-sidebar-title rc-sidebar-title">{APP_DISPLAY_NAME}</span>
</div>
<div className="app-sidebar-app-switcher rc-sidebar-app-switcher" data-sidebar="label">
<Tooltip delay={0}>
<Button
isIconOnly
aria-label="切换应用"
......@@ -67,34 +113,14 @@ function SidebarContents({ menuGroups, onLogout, onOpenApplicationSwitcher, path
>
<AppGrid size={16} />
</Button>
<Tooltip.Content>切换应用</Tooltip.Content>
</Tooltip>
</div>
</div>
</Sidebar.Header>
<Sidebar.Content>
{menuGroups.map((group) => (
<Sidebar.Group key={group.title}>
{group.showLabel !== false ? <Sidebar.GroupLabel>{group.title}</Sidebar.GroupLabel> : null}
<Sidebar.Menu aria-label={group.title}>
{group.items.map((item) => {
const Icon = item.icon;
const isCurrent = pathname === item.path || pathname.startsWith(`${item.path}/`);
return (
<Sidebar.MenuItem
href={item.path}
isCurrent={isCurrent}
key={item.path}
textValue={item.name}
tooltip={item.name}
>
<Sidebar.MenuIcon><Icon size={16} /></Sidebar.MenuIcon>
<Sidebar.MenuLabel>{item.name}</Sidebar.MenuLabel>
</Sidebar.MenuItem>
);
})}
</Sidebar.Menu>
</Sidebar.Group>
group.showLabel === false ? (
<Sidebar.Group key={group.title}>{renderMenu(group)}</Sidebar.Group>
) : renderGroup(group)
))}
</Sidebar.Content>
<Sidebar.Footer><SidebarUserMenu userInfo={userInfo} onLogout={onLogout} /></Sidebar.Footer>
......@@ -102,14 +128,31 @@ function SidebarContents({ menuGroups, onLogout, onOpenApplicationSwitcher, path
);
}
function AccessibleMobileSidebar(props) {
const { isMobile, isMobileOpen, setMobileOpen, side } = useSidebar();
if (!isMobile) return null;
return (
<Sheet.Root isOpen={isMobileOpen} placement={side} onOpenChange={setMobileOpen}>
<Sheet.Backdrop variant="blur">
<Sheet.Content className="sidebar__mobile-sheet">
<Sheet.Dialog aria-label={`${APP_DISPLAY_NAME}导航`} className="sidebar__mobile-dialog">
<div className="sidebar__mobile app-sidebar-glass rc-mobile-sidebar" data-slot="sidebar-mobile">
<SidebarContents {...props} />
</div>
</Sheet.Dialog>
</Sheet.Content>
</Sheet.Backdrop>
</Sheet.Root>
);
}
export default function DashboardSidebar(props) {
return (
<>
<Sidebar className="app-sidebar-glass rc-sidebar"><SidebarContents {...props} /></Sidebar>
<Sidebar.Mobile className="app-sidebar-glass rc-mobile-sidebar">
<h2 slot="title" className="sr-only">{APP_DISPLAY_NAME}导航</h2>
<SidebarContents {...props} />
</Sidebar.Mobile>
<AccessibleMobileSidebar {...props} />
</>
);
}
This diff is collapsed.
......@@ -59,6 +59,7 @@ export default function FilterSelect({
const nextValue = key === EMPTY_OPTION_KEY ? '' : String(key ?? '');
onChange?.(nextValue);
}}
onClear={() => onChange?.('')}
valueText={selectedOption?.label || placeholder}
/>
);
......
......@@ -21,7 +21,7 @@ import { Children, Fragment, isValidElement } from 'react';
const ACTION_ICON_CLASS =
'grid size-4 shrink-0 place-items-center [line-height:0] [&>svg]:!m-0 [&>svg]:block [&>svg]:size-4';
const ACTION_BUTTON_LAYOUT_CLASS = 'grid place-items-center rounded-full p-0';
const ACTION_BUTTON_LAYOUT_CLASS = 'grid place-items-center p-0';
const IMPORTANT_ACTION_TONES = new Set(['approve', 'submit']);
function getActionVariant(tone) {
......
......@@ -22,10 +22,14 @@ function defaultFilter(textValue, inputValue) {
function getSelectedKeySet(selectionMode, selectedKey, selectedKeys) {
if (selectionMode === 'multiple') {
return new Set(selectedKeys || []);
return new Set(Array.from(selectedKeys || []).map(toSelectionKey).filter(Boolean));
}
return new Set(selectedKey === null || selectedKey === undefined ? [] : [selectedKey]);
return new Set(
selectedKey === null || selectedKey === undefined
? []
: [toSelectionKey(selectedKey)].filter(Boolean),
);
}
function toSelectionKey(value) {
......@@ -46,6 +50,7 @@ export default function XinyuanAutocomplete({
isRequired = false,
name,
onInputChange,
onClear,
onOpenChange,
onSelectionChange,
options = [],
......@@ -81,13 +86,13 @@ export default function XinyuanAutocomplete({
: placeholder);
const rootSelectionProps =
selectionMode === 'multiple'
? { selectedKeys }
: { selectedKey };
? { selectedKeys: selectedKeyStringSet }
: { selectedKey: selectedKey === null || selectedKey === undefined ? null : toSelectionKey(selectedKey) };
const listBoxSelectionProps =
selectionMode === 'multiple'
? {
selectionMode,
selectedKeys,
selectedKeys: selectedKeyStringSet,
}
: {};
const visibleOptions = isLoading || errorText ? [] : options;
......@@ -165,6 +170,7 @@ export default function XinyuanAutocomplete({
name={name}
selectionMode={selectionMode}
variant={variant}
onClear={onClear || (() => onSelectionChange?.(selectionMode === 'multiple' ? new Set() : null))}
onOpenChange={onOpenChange}
onSelectionChange={onSelectionChange}
>
......@@ -172,6 +178,9 @@ export default function XinyuanAutocomplete({
<Autocomplete.Value className="min-w-0">
{renderValue ? renderValue(selectedOptions) : defaultValue}
</Autocomplete.Value>
<Autocomplete.ClearButton aria-label="清除已选内容">
<Close size={14} />
</Autocomplete.ClearButton>
<Autocomplete.Indicator>
<ChevronDown size={16} style={FIELD_SUFFIX_ICON_STYLE} />
</Autocomplete.Indicator>
......
......@@ -2,7 +2,6 @@ export const APP_DISPLAY_NAME = '监察稽核管理';
export const APP_ROUTE_NAME = 'audit_manager_new';
export const APP_BASE_PATH = '/audit_manager_new/';
export const APP_LOGIN_SUBTITLE = '内部控制与报告归档';
export const THEME_STORAGE_KEY = 'xinyuan_color_theme';
export const APP_NAV_ITEMS = Object.freeze([
{ label: '稽核项目', path: '/audit_process', icon: 'project' },
......
This diff is collapsed.
......@@ -11,38 +11,66 @@
[data-theme="light"],
[data-theme="default"] {
/* Theme Colors (Light Mode) */
--accent: oklch(45.32% 0.1661 27.03);
/* Fixed Xinyuan brand scale. HeroUI v3 consumes --accent for primary
* controls; these aliases keep every integration on the same red family. */
--primary-50: #FDF0EF;
--primary-100: #FBE1DF;
--primary-200: #F7C4C0;
--primary-300: #F29A93;
--primary-400: #EA5C52;
--primary-500: #DA241A;
--primary-600: #C91F17;
--primary-700: #B51B14;
--primary-800: #951710;
--primary-900: #74110D;
--primary-950: #4B0B08;
--primary: var(--primary-500);
--primary-foreground: #FFFFFF;
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-primary-50: var(--primary-50);
--color-primary-100: var(--primary-100);
--color-primary-200: var(--primary-200);
--color-primary-300: var(--primary-300);
--color-primary-400: var(--primary-400);
--color-primary-500: var(--primary-500);
--color-primary-600: var(--primary-600);
--color-primary-700: var(--primary-700);
--color-primary-800: var(--primary-800);
--color-primary-900: var(--primary-900);
--color-primary-950: var(--primary-950);
--accent: #DA241A;
--accent-hover: #C91F17;
--accent-foreground: oklch(99.11% 0 0);
--background: oklch(97.02% 0.0045 27.03);
--border: oklch(90.00% 0.0045 27.03);
--background: oklch(97.02% 0.0000 29.20);
--border: oklch(90.00% 0.0000 29.20);
--danger: oklch(45.32% 0.1661 27.03);
--danger-foreground: oklch(99.11% 0 0);
/* HeroUI control aliases: primary = L1, secondary = L2. */
--default: var(--surface-secondary);
--default-foreground: var(--surface-secondary-foreground);
--field-background: var(--surface);
--default: oklch(94.00% 0.0000 29.20);
--default-foreground: oklch(21.03% 0.0059 29.20);
--field-background: oklch(100.00% 0.0000 29.20);
--field-border: transparent;
--field-foreground: var(--surface-foreground);
--field-placeholder: oklch(55.17% 0.0090 27.03);
--focus: oklch(45.32% 0.1661 27.03);
--foreground: oklch(21.03% 0.0045 27.03);
--muted: oklch(55.17% 0.0090 27.03);
--overlay: var(--surface);
--overlay-foreground: var(--surface-foreground);
--scrollbar: oklch(87.10% 0.0045 27.03);
--segment: oklch(100.00% 0.0045 27.03);
--segment-foreground: oklch(21.03% 0.0045 27.03);
--separator: oklch(92.00% 0.0045 27.03);
--success: oklch(73.29% 0.1952 123.59);
--success-foreground: oklch(21.03% 0.0059 123.59);
--surface: oklch(100.00% 0.0022 27.03);
--surface-foreground: oklch(21.03% 0.0045 27.03);
--surface-secondary: oklch(95.24% 0.0036 27.03);
--surface-secondary-foreground: oklch(21.03% 0.0045 27.03);
--surface-tertiary: oklch(93.73% 0.0036 27.03);
--surface-tertiary-foreground: oklch(21.03% 0.0045 27.03);
--warning: oklch(78.19% 0.1599 45.11);
--warning-foreground: oklch(21.03% 0.0059 45.11);
--field-foreground: oklch(21.03% 0.0000 29.20);
--field-placeholder: oklch(55.17% 0.0000 29.20);
--focus: #DA241A;
--foreground: oklch(21.03% 0.0000 29.20);
--muted: oklch(55.17% 0.0000 29.20);
--overlay: oklch(100.00% 0.0000 29.20);
--overlay-foreground: oklch(21.03% 0.0000 29.20);
--scrollbar: oklch(87.10% 0.0000 29.20);
--segment: oklch(100.00% 0.0000 29.20);
--segment-foreground: oklch(21.03% 0.0000 29.20);
--separator: oklch(92.00% 0.0000 29.20);
--success: #61758F;
--success-foreground: #FFFFFF;
--surface: oklch(100.00% 0.0000 29.20);
--surface-foreground: oklch(21.03% 0.0000 29.20);
--surface-secondary: oklch(95.24% 0.0000 29.20);
--surface-secondary-foreground: oklch(21.03% 0.0000 29.20);
--surface-tertiary: oklch(93.73% 0.0000 29.20);
--surface-tertiary-foreground: oklch(21.03% 0.0000 29.20);
--warning: oklch(78.19% 0.1585 45.37);
--warning-foreground: oklch(21.03% 0.0059 45.37);
/* Border Radius */
--radius: 0.5rem;
......@@ -51,42 +79,117 @@
/* Font Family */
/* Make sure to load Inter font in your app */
--font-sans: var(--font-inter);
/*
* Xinyuan fixed split-shell contract.
* The application canvas always uses this light theme. The Sidebar is the
* only persistent dark plane and must consume these semantic tokens.
*/
--xinyuan-sidebar-background: oklch(16% 0.020 255);
--xinyuan-sidebar-border: oklch(26% 0.022 255);
--xinyuan-sidebar-foreground: oklch(97% 0.004 255);
--xinyuan-sidebar-muted: oklch(76% 0.018 255);
--xinyuan-sidebar-item-hover: oklch(22% 0.026 255);
--xinyuan-sidebar-item-selected: oklch(28% 0.016 255);
--xinyuan-sidebar-item-selected-foreground: oklch(99% 0.002 255);
--xinyuan-sidebar-focus: #DA241A;
/*
* ECharts is the required chart renderer for Xinyuan applications.
* Ant Design remains the icon source. Resolve these CSS variables to
* concrete colors before assigning them to an ECharts option object.
*/
/* Muted categorical palette: brand reds -> warm neutrals -> cool blue-grays. */
--xinyuan-chart-1: #DA241A;
--xinyuan-chart-2: #B83A32;
--xinyuan-chart-3: #C95A52;
--xinyuan-chart-4: #D47A70;
--xinyuan-chart-5: #A65A45;
--xinyuan-chart-6: #B87958;
--xinyuan-chart-7: #C99772;
--xinyuan-chart-8: #6F6674;
--xinyuan-chart-9: #807783;
--xinyuan-chart-10: #938B96;
--xinyuan-chart-11: #4F6380;
--xinyuan-chart-12: #61758F;
--xinyuan-chart-13: #74879A;
--xinyuan-chart-14: #8A6F63;
--xinyuan-chart-15: #9A8174;
--xinyuan-chart-16: #5B5964;
--xinyuan-chart-sector-gap: var(--surface);
--xinyuan-chart-sector-radius: 10px;
--xinyuan-chart-grid: #e8e8e8;
--xinyuan-chart-axis: #737373;
--xinyuan-chart-label: #737373;
--xinyuan-chart-tooltip-background: #ffffff;
--xinyuan-chart-tooltip-border: #dedede;
--xinyuan-chart-tooltip-foreground: #222222;
--xinyuan-chart-tooltip-shadow: 0 16px 40px rgb(15 23 42 / 14%);
--xinyuan-chart-legend-font-size: 0.75rem;
--xinyuan-chart-legend-line-height: 1rem;
}
.dark,
[data-theme="dark"] {
color-scheme: dark;
/* Theme Colors (Dark Mode) */
--accent: oklch(45.32% 0.1661 27.03);
/* Keep the brand scale stable when a dark portal or integration is active. */
--primary-50: #FDF0EF;
--primary-100: #FBE1DF;
--primary-200: #F7C4C0;
--primary-300: #F29A93;
--primary-400: #EA5C52;
--primary-500: #DA241A;
--primary-600: #C91F17;
--primary-700: #B51B14;
--primary-800: #951710;
--primary-900: #74110D;
--primary-950: #4B0B08;
--primary: var(--primary-500);
--primary-foreground: #FFFFFF;
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-primary-50: var(--primary-50);
--color-primary-100: var(--primary-100);
--color-primary-200: var(--primary-200);
--color-primary-300: var(--primary-300);
--color-primary-400: var(--primary-400);
--color-primary-500: var(--primary-500);
--color-primary-600: var(--primary-600);
--color-primary-700: var(--primary-700);
--color-primary-800: var(--primary-800);
--color-primary-900: var(--primary-900);
--color-primary-950: var(--primary-950);
--accent: #DA241A;
--accent-hover: #C91F17;
--accent-foreground: oklch(99.11% 0 0);
--background: oklch(12.00% 0.0045 27.03);
--border: oklch(28.00% 0.0045 27.03);
--danger: oklch(62.00% 0.1600 27.03);
--background: oklch(12.00% 0.0000 29.20);
--border: oklch(28.00% 0.0000 29.20);
--danger: oklch(62% 0.16 27.03);
--danger-foreground: oklch(99.11% 0 0);
/* HeroUI control aliases: primary = L1, secondary = L2. */
--default: var(--surface-secondary);
--default-foreground: var(--surface-secondary-foreground);
--field-background: var(--surface);
--default: oklch(27.40% 0.0000 29.20);
--default-foreground: oklch(99.11% 0 0);
--field-background: oklch(21.03% 0.0000 29.20);
--field-border: transparent;
--field-foreground: var(--surface-foreground);
--field-placeholder: oklch(70.50% 0.0090 27.03);
--focus: oklch(45.32% 0.1661 27.03);
--foreground: oklch(99.11% 0.0045 27.03);
--muted: oklch(70.50% 0.0090 27.03);
--overlay: var(--surface);
--overlay-foreground: var(--surface-foreground);
--scrollbar: oklch(70.50% 0.0045 27.03);
--segment: oklch(39.64% 0.0045 27.03);
--segment-foreground: oklch(99.11% 0.0045 27.03);
--separator: oklch(25.00% 0.0045 27.03);
--success: oklch(73.29% 0.1952 123.59);
--success-foreground: oklch(21.03% 0.0059 123.59);
--surface: oklch(21.03% 0.0090 27.03);
--surface-foreground: oklch(99.11% 0.0045 27.03);
--surface-secondary: oklch(25.70% 0.0067 27.03);
--surface-secondary-foreground: oklch(99.11% 0.0045 27.03);
--surface-tertiary: oklch(27.21% 0.0067 27.03);
--surface-tertiary-foreground: oklch(99.11% 0.0045 27.03);
--warning: oklch(82.03% 0.1400 49.12);
--warning-foreground: oklch(21.03% 0.0059 49.12);
--field-foreground: oklch(99.11% 0.0000 29.20);
--field-placeholder: oklch(70.50% 0.0000 29.20);
--focus: #DA241A;
--foreground: oklch(99.11% 0.0000 29.20);
--muted: oklch(70.50% 0.0000 29.20);
--overlay: oklch(21.03% 0.0000 29.20);
--overlay-foreground: oklch(99.11% 0.0000 29.20);
--scrollbar: oklch(70.50% 0.0000 29.20);
--segment: oklch(39.64% 0.0000 29.20);
--segment-foreground: oklch(99.11% 0.0000 29.20);
--separator: oklch(25.00% 0.0000 29.20);
--success: #74879A;
--success-foreground: #FFFFFF;
--surface: oklch(21.03% 0.0000 29.20);
--surface-foreground: oklch(99.11% 0.0000 29.20);
--surface-secondary: oklch(25.70% 0.0000 29.20);
--surface-secondary-foreground: oklch(99.11% 0.0000 29.20);
--surface-tertiary: oklch(27.21% 0.0000 29.20);
--surface-tertiary-foreground: oklch(99.11% 0.0000 29.20);
--warning: oklch(82.03% 0.1388 49.38);
--warning-foreground: oklch(21.03% 0.0059 49.38);
}
......@@ -2,11 +2,41 @@
.light,
.default,
[data-theme='light'],
[data-theme='default'],
.dark,
[data-theme='dark'] {
[data-theme='default'] {
/* Xinyuan aliases and extension tokens. HeroUI core tokens are owned by theme.css. */
--rc-brand-primary: var(--accent);
/* HeroUI v3 calls the brand channel accent; keep an explicit primary scale
for Tailwind integrations and chart/card consumers. */
--primary: #DA241A;
--primary-foreground: #FFFFFF;
--primary-hover: #C91F17;
--primary-pressed: #B51B14;
--primary-50: #FDF0EF;
--primary-100: #FBE1DF;
--primary-200: #F7C4C0;
--primary-300: #F29A93;
--primary-400: #EA5C52;
--primary-500: #DA241A;
--primary-600: #C91F17;
--primary-700: #B51B14;
--primary-800: #951710;
--primary-900: #74110D;
--primary-950: #4B0B08;
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-primary-hover: var(--primary-hover);
--color-primary-pressed: var(--primary-pressed);
--color-primary-50: var(--primary-50);
--color-primary-100: var(--primary-100);
--color-primary-200: var(--primary-200);
--color-primary-300: var(--primary-300);
--color-primary-400: var(--primary-400);
--color-primary-500: var(--primary-500);
--color-primary-600: var(--primary-600);
--color-primary-700: var(--primary-700);
--color-primary-800: var(--primary-800);
--color-primary-900: var(--primary-900);
--color-primary-950: var(--primary-950);
--rc-brand-light: color-mix(in oklab, var(--accent) 84%, white);
--rc-brand-dark: color-mix(in oklab, var(--accent) 82%, black);
......@@ -27,6 +57,17 @@
var(--background)
);
--rc-text-muted: var(--muted);
--rc-text-empty: color-mix(in oklab, var(--muted) 72%, var(--background));
/* Fixed dark Sidebar aliases. The content canvas remains light. */
--rc-sidebar-background: var(--xinyuan-sidebar-background);
--rc-sidebar-border: var(--xinyuan-sidebar-border);
--rc-sidebar-foreground: var(--xinyuan-sidebar-foreground);
--rc-sidebar-muted: var(--xinyuan-sidebar-muted);
--rc-sidebar-item-hover: var(--xinyuan-sidebar-item-hover);
--rc-sidebar-item-selected: var(--xinyuan-sidebar-item-selected);
--rc-sidebar-item-selected-foreground: var(--xinyuan-sidebar-item-selected-foreground);
--rc-sidebar-focus: var(--xinyuan-sidebar-focus);
--rc-font-sans: var(--font-sans);
--rc-font-medium: 500;
......@@ -45,6 +86,7 @@
/* Page geometry only. Component appearance remains owned by HeroUI. */
--rc-shell-inline-gutter: 3rem;
--rc-sidebar-width: 15rem;
--rc-navbar-inline-padding: var(--rc-shell-inline-gutter);
--rc-page-inline-start: var(--rc-shell-inline-gutter);
--rc-page-inline-end: var(--rc-shell-inline-gutter);
......@@ -105,15 +147,15 @@
0 4px 12px rgb(15 23 42 / 2%);
/* KPI icon surfaces. Card bodies remain neutral; only the icon well carries tone. */
--rc-metric-tone-info: oklch(55% 0.14 255);
--rc-metric-tone-success: oklch(54% 0.13 145);
--rc-metric-tone-teal: oklch(55% 0.1 205);
--rc-metric-tone-danger: oklch(52% 0.17 25);
--rc-metric-tone-warning: oklch(62% 0.12 75);
--rc-metric-tone-violet: oklch(56% 0.12 300);
--rc-metric-tone-indigo: oklch(53% 0.13 275);
--rc-metric-tone-coral: oklch(60% 0.12 48);
--rc-metric-tone-neutral: var(--muted);
--rc-metric-tone-info: var(--rc-chart-11);
--rc-metric-tone-success: var(--rc-chart-6);
--rc-metric-tone-teal: var(--rc-chart-12);
--rc-metric-tone-danger: var(--rc-chart-primary);
--rc-metric-tone-warning: var(--rc-chart-5);
--rc-metric-tone-violet: var(--rc-chart-8);
--rc-metric-tone-indigo: var(--rc-chart-13);
--rc-metric-tone-coral: var(--rc-chart-3);
--rc-metric-tone-neutral: var(--rc-chart-10);
--rc-metric-tone-info-soft: color-mix(
in oklab,
var(--rc-metric-tone-info) 9%,
......@@ -161,40 +203,28 @@
);
/* Data visualization only. Do not reuse UI status colors as a series palette. */
--rc-chart-1: oklch(45.32% 0.1661 27.03);
--rc-chart-2: oklch(56% 0.135 255);
--rc-chart-3: oklch(64% 0.11 220);
--rc-chart-4: oklch(56% 0.115 280);
--rc-chart-5: oklch(60% 0.095 200);
--rc-chart-6: oklch(70% 0.12 80);
--rc-chart-7: oklch(60% 0.1 295);
--rc-chart-8: oklch(57% 0.035 250);
--rc-chart-1: var(--xinyuan-chart-1);
--rc-chart-2: var(--xinyuan-chart-2);
--rc-chart-3: var(--xinyuan-chart-3);
--rc-chart-4: var(--xinyuan-chart-4);
--rc-chart-5: var(--xinyuan-chart-5);
--rc-chart-6: var(--xinyuan-chart-6);
--rc-chart-7: var(--xinyuan-chart-7);
--rc-chart-8: var(--xinyuan-chart-8);
--rc-chart-9: var(--xinyuan-chart-9);
--rc-chart-10: var(--xinyuan-chart-10);
--rc-chart-11: var(--xinyuan-chart-11);
--rc-chart-12: var(--xinyuan-chart-12);
--rc-chart-13: var(--xinyuan-chart-13);
--rc-chart-14: var(--xinyuan-chart-14);
--rc-chart-15: var(--xinyuan-chart-15);
--rc-chart-16: var(--xinyuan-chart-16);
--rc-chart-primary: var(--rc-chart-1);
--rc-chart-risk: oklch(47% 0.145 27.03);
--rc-chart-warning: var(--rc-chart-6);
--rc-chart-positive: var(--rc-chart-2);
--rc-chart-comparison: var(--rc-chart-4);
--rc-chart-neutral: var(--rc-chart-8);
--rc-chart-legend-font-size: 0.75rem;
--rc-chart-legend-line-height: 1rem;
}
.dark,
[data-theme='dark'] {
color-scheme: dark;
--rc-text-secondary: color-mix(
in oklab,
var(--foreground) 76%,
var(--background)
);
--rc-button-secondary-border: color-mix(
in oklab,
var(--foreground) 18%,
transparent
);
--rc-shadow-raised: 0 1px 2px rgb(0 0 0 / 24%),
0 8px 24px rgb(0 0 0 / 24%);
--rc-shadow-overlay: 0 16px 40px rgb(0 0 0 / 38%);
--rc-shadow-card: 0 1px 2px rgb(0 0 0 / 20%),
0 4px 12px rgb(0 0 0 / 18%);
--rc-chart-risk: var(--rc-chart-1);
--rc-chart-warning: var(--rc-chart-3);
--rc-chart-positive: var(--rc-chart-12);
--rc-chart-comparison: var(--rc-chart-11);
--rc-chart-neutral: var(--rc-chart-10);
--rc-chart-legend-font-size: var(--xinyuan-chart-legend-font-size);
--rc-chart-legend-line-height: var(--xinyuan-chart-legend-line-height);
}
......@@ -4,12 +4,14 @@ import { Checkbox } from '@/catalyst-ui-kit/javascript/checkbox';
import { Input } from '@/catalyst-ui-kit/javascript/input';
import { Select } from '@/catalyst-ui-kit/javascript/select';
import { Textarea } from '@/catalyst-ui-kit/javascript/textarea';
import { Chip } from '@heroui/react';
import { queryUsers, uploadFile } from '@/services/DataService';
import ReferenceSearchSelect from './ReferenceSearchSelect';
import ReferenceRecordsTable from './ReferenceRecordsTable';
import {
dateInputValue,
formatFieldValue,
getFormValueSummaries,
getSelectOptions,
isDateFieldType,
isReferenceFieldType,
......@@ -103,6 +105,20 @@ function FieldValue({ value, fieldDef }) {
}
if (fieldDef?.type === 'object' || fieldDef?.type === 'form' || fieldDef?.type === 'subject') {
if (fieldDef?.type === 'form') {
const summaries = getFormValueSummaries(value, fieldDef?.label || '表单');
return (
<div className="flex min-w-0 flex-col gap-2">
{summaries.map((summary) => (
<div className="flex min-w-0 items-center justify-between gap-3" key={summary.key}>
<span className="min-w-0 truncate" title={summary.label}>{summary.label}</span>
<Chip size="sm" variant="secondary">{summary.rowCount} 条记录</Chip>
</div>
))}
</div>
);
}
return (
<pre className="max-h-48 overflow-auto rounded-2xl bg-zinc-950 px-4 py-3 text-xs leading-5 text-zinc-100">
{JSON.stringify(value, null, 2)}
......
This diff is collapsed.
import { Back, CheckCircleIcon, ClipboardDocumentListIcon, Project } from '@/components/AppIcons';
import { CheckCircleIcon, ClipboardDocumentListIcon, Project } from '@/components/AppIcons';
import { useAppShellHeader } from '@/components/AppShell';
import { KPI } from '@heroui-pro/react/kpi';
import { Button, Chip, Tooltip } from '@heroui/react';
import dayjs from 'dayjs';
import { Button } from '@heroui/react';
import { useCallback, useEffect, useState } from 'react';
import { history, useParams } from 'umi';
import AuditReportContent from './AuditReportContent';
......@@ -15,6 +15,7 @@ import { exportAuditReport, getReportStats } from './auditReportModel';
export default function AuditReportDetail() {
const { reportId } = useParams();
const shellHeader = useAppShellHeader();
const [report, setReport] = useState(null);
const [issueOptions, setIssueOptions] = useState([]);
const [loading, setLoading] = useState(true);
......@@ -43,6 +44,23 @@ export default function AuditReportDetail() {
loadData();
}, [loadData]);
useEffect(() => {
if (!report || !shellHeader?.setHeaderOverride) {
shellHeader?.setHeaderOverride(null);
return undefined;
}
shellHeader.setHeaderOverride({
breadcrumbs: [
{ label: '稽核报告', href: '/audit_report', onPress: () => history.back() },
{ label: '报告详情' },
],
title: '报告详情',
});
return () => shellHeader.setHeaderOverride(null);
}, [report, shellHeader]);
if (loading) return <LoadingState label="正在加载历史报告..." />;
if (error || !report) return <ErrorState message={error || '报告不存在'} onRetry={loadData} />;
......@@ -56,18 +74,7 @@ export default function AuditReportDetail() {
return (
<div className="rc-page-stack">
<div className="rc-page-toolbar">
<div className="flex min-w-0 items-center gap-3">
<Tooltip delay={0}><Button isIconOnly aria-label="返回历史报告" size="sm" variant="tertiary" onPress={() => history.back()}><Back size={16} /></Button><Tooltip.Content>返回</Tooltip.Content></Tooltip>
<div>
<div className="flex flex-wrap items-center gap-2">
<h2>{report.title}</h2>
<Chip color="success" size="sm" variant="soft">已归档</Chip>
</div>
<p>
提交时间:{report.metadata?.submitted_time ? dayjs(report.metadata.submitted_time).format('YYYY-MM-DD HH:mm') : '-'}
</p>
</div>
</div>
<h2 className="rc-section-title min-w-0 truncate">{report.title || '报告详情'}</h2>
<Button size="sm" variant="secondary" onPress={() => exportAuditReport(report)}>导出</Button>
</div>
......
......@@ -2,7 +2,7 @@ import xyLogo from '@/assets/identity/xy-logo-red.png';
import xyLogoWhite from '@/assets/identity/xy-logo-white.png';
import { APP_DISPLAY_NAME } from '@/constants/app';
import { AlertDialog, Button } from '@heroui/react';
import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useLocation } from 'umi';
import AuditReportContent from './AuditReportContent';
import { ErrorState, InlineNotice, LoadingState } from './PageState';
......@@ -60,15 +60,7 @@ export default function AuditReportFill() {
const [notice, setNotice] = useState(null);
const [resetOpen, setResetOpen] = useState(false);
useLayoutEffect(() => {
let theme = 'light';
try {
theme = window.localStorage.getItem('xinyuan_color_theme') === 'dark' ? 'dark' : 'light';
} catch {}
document.documentElement.classList.toggle('dark', theme === 'dark');
document.documentElement.classList.toggle('light', theme === 'light');
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
useEffect(() => {
document.title = `填报任务 - ${APP_DISPLAY_NAME}`;
}, []);
......
import { Close, Delete } from '@/components/AppIcons';
import FilterSelect from '@/components/FilterSelect';
import { Button, FieldError, Input, Label, Modal, TextArea, TextField, Tooltip } from '@heroui/react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import ReferenceSearchSelect from './ReferenceSearchSelect';
import { ControlPointPreviewFields } from './ControlPointPreviewDialog';
import {
AUDIT_SUBJECTS,
normalizeRecordId,
searchReferenceRecords,
searchTags,
} from './auditManagementService';
const EMPTY_POINT_FORM = {
name: '',
description: '',
type: '',
owner: null,
related_tags: [],
related_regulations: [],
form_label: '',
attachment_label: [''],
indicator: null,
computed_interval: '',
};
function compactLabels(labels) {
return (labels || []).map((label) => label.trim()).filter(Boolean);
}
function requiredLabel(label, required) {
return <>{label}{required ? <span aria-hidden="true"> *</span> : null}</>;
}
export default function UnifiedControlPointDialog({
canEdit = true,
definition,
initialMode = 'edit',
onClose,
onSaved,
open,
orderLabel,
point,
typeLabel,
}) {
const typeField = (definition?.fields || []).find((field) => field.name === 'type');
const typeOptions = useMemo(() => typeField?.settings?.options || [], [typeField]);
const [form, setForm] = useState(EMPTY_POINT_FORM);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [isEditing, setIsEditing] = useState(initialMode === 'edit' || !point);
const loadDepartmentOptions = useCallback((keyword) => searchReferenceRecords('department', keyword), []);
const loadRegulationOptions = useCallback((keyword) => searchReferenceRecords('internal_regulation', keyword), []);
const loadIndicatorOptions = useCallback((keyword) => searchReferenceRecords(AUDIT_SUBJECTS.INDICATOR, keyword), []);
useEffect(() => {
if (!open) return;
setForm({
...EMPTY_POINT_FORM,
...(point || {}),
type: point?.type || typeOptions[0]?.value || '',
attachment_label: point?.attachment_label?.length ? [...point.attachment_label] : [''],
});
setError('');
setIsEditing(initialMode === 'edit' || !point);
}, [initialMode, open, point, typeOptions]);
const change = (name, value) => setForm((current) => ({ ...current, [name]: value }));
const handleSave = async () => {
if (!form.name.trim()) {
setError('请输入控制点名称');
return;
}
if (!normalizeRecordId(form.owner)) {
setError('请选择责任人');
return;
}
setSaving(true);
setError('');
try {
await onSaved({
...form,
name: form.name.trim(),
description: form.description.trim(),
owner: normalizeRecordId(form.owner),
related_tags: (form.related_tags || []).map(normalizeRecordId).filter(Boolean),
related_regulations: (form.related_regulations || []).map(normalizeRecordId).filter(Boolean),
indicator: normalizeRecordId(form.indicator) || null,
computed_interval: form.computed_interval === '' ? null : Number(form.computed_interval),
attachment_label: compactLabels(form.attachment_label),
});
} catch (requestError) {
setError(requestError?.message || '保存控制点失败');
} finally {
setSaving(false);
}
};
const handleCancel = () => {
if (initialMode === 'view' && point) {
setForm({
...EMPTY_POINT_FORM,
...point,
type: point.type || typeOptions[0]?.value || '',
attachment_label: point.attachment_label?.length ? [...point.attachment_label] : [''],
});
setError('');
setIsEditing(false);
return;
}
onClose();
};
const title = isEditing ? (point ? '编辑控制点' : '添加控制点') : '查看控制点';
const canShowEdit = Boolean(point && canEdit && !isEditing);
return (
<Modal.Root isOpen={open} onOpenChange={(isOpen) => { if (!isOpen && !saving) onClose(); }}>
<Modal.Trigger aria-hidden="true" aria-label={`打开${title}弹窗`} className="sr-only" tabIndex={-1} />
<Modal.Backdrop isDismissable={!saving} variant="blur">
<Modal.Container placement="center" scroll="inside" size="lg">
<Modal.Dialog className="bg-background p-7 sm:max-w-[68rem]">
<Modal.CloseTrigger aria-label="关闭">
<Close size={16} />
</Modal.CloseTrigger>
<Modal.Header><Modal.Heading>{title}</Modal.Heading></Modal.Header>
{isEditing ? (
<Modal.Body className="grid gap-5 sm:grid-cols-2">
<TextField className="rc-form-field sm:col-span-2" isRequired value={form.name} onChange={(value) => change('name', value)}><Label>名称</Label><Input placeholder="请输入控制点名称" variant="secondary" /></TextField>
<TextField className="rc-form-field sm:col-span-2" value={form.description} onChange={(value) => change('description', value)}><Label>描述信息</Label><TextArea placeholder="请输入控制点描述" rows={4} variant="secondary" /></TextField>
<div className="rc-form-field"><Label>{requiredLabel('责任人', true)}</Label><ReferenceSearchSelect value={form.owner} onChange={(value) => change('owner', value)} loadOptions={loadDepartmentOptions} placeholder="搜索责任部门" /></div>
<div className="rc-form-field"><Label>相关标签</Label><ReferenceSearchSelect multiple value={form.related_tags} onChange={(value) => change('related_tags', value)} loadOptions={searchTags} placeholder="搜索并添加标签" /></div>
<div className="rc-form-field sm:col-span-2"><Label>相关制度</Label><ReferenceSearchSelect multiple value={form.related_regulations} onChange={(value) => change('related_regulations', value)} loadOptions={loadRegulationOptions} placeholder="搜索并添加制度" /></div>
<div className="rc-form-field"><Label>{requiredLabel('控制措施', Boolean(typeField?.required))}</Label><FilterSelect aria-label="控制措施" options={typeOptions} value={form.type} variant="secondary" onChange={(value) => change('type', value)} /></div>
{form.type === 'form' ? <TextField className="rc-form-field" value={form.form_label} onChange={(value) => change('form_label', value)}><Label>文本描述</Label><Input placeholder="请输入文本描述" variant="secondary" /></TextField> : null}
{form.type === 'attachment' ? <div className="rc-form-field sm:col-span-2"><Label>文件描述</Label><div className="space-y-2">{form.attachment_label.map((label, index) => <div key={index} className="flex items-center gap-2"><TextField aria-label={`文件描述 ${index + 1}`} className="min-w-0 flex-1" value={label} onChange={(value) => { const labels = [...form.attachment_label]; labels[index] = value; change('attachment_label', labels); }}><Input placeholder="请输入文件类别" variant="secondary" /></TextField><Tooltip delay={0}><Button isIconOnly type="button" aria-label="删除文件描述" size="sm" variant="ghost" onPress={() => change('attachment_label', form.attachment_label.filter((_, itemIndex) => itemIndex !== index))}><Delete size={16} /></Button><Tooltip.Content>删除文件描述</Tooltip.Content></Tooltip></div>)}<Button variant="secondary" type="button" onPress={() => change('attachment_label', [...form.attachment_label, ''])}>添加文件描述</Button></div></div> : null}
{form.type === 'computed' ? <><div className="rc-form-field"><Label>量化指标</Label><ReferenceSearchSelect value={form.indicator} onChange={(value) => change('indicator', value)} loadOptions={loadIndicatorOptions} placeholder="搜索量化指标" /></div><TextField className="rc-form-field" value={String(form.computed_interval)} onChange={(value) => change('computed_interval', value)}><Label>执行频率(天)</Label><Input type="number" min="1" variant="secondary" /></TextField></> : null}
{error ? <FieldError className="sm:col-span-2">{error}</FieldError> : null}
</Modal.Body>
) : (
<Modal.Body><ControlPointPreviewFields orderLabel={orderLabel} point={point} typeLabel={typeLabel} /></Modal.Body>
)}
<Modal.Footer>
{isEditing ? <><Button variant="secondary" isDisabled={saving} onPress={handleCancel}>取消</Button><Button variant="primary" isDisabled={saving} onPress={handleSave}>{saving ? '保存中...' : '保存'}</Button></> : canShowEdit ? <Button variant="primary" onPress={() => setIsEditing(true)}>编辑</Button> : <Button size="sm" variant="secondary" onPress={onClose}>关闭</Button>}
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal.Root>
);
}
import { OpenExternal } from '@/components/AppIcons';
import { Button, Chip, Description, Modal, Surface } from '@heroui/react';
import { Close, OpenExternal } from '@/components/AppIcons';
import { Button, Chip, Description, Modal } from '@heroui/react';
import dayjs from 'dayjs';
import {
CONTROL_POINT_STATUS,
......@@ -40,9 +40,9 @@ function displayValue(value) {
function PreviewField({ children, className, label }) {
return (
<div className={`grid min-w-0 gap-1 ${className || ''}`}>
<Description>{label}</Description>
<div className="min-w-0 whitespace-pre-wrap break-words">
<div className={`grid min-w-0 gap-1 py-3 ${className || ''}`}>
<Description className="text-sm font-medium text-muted">{label}</Description>
<div className="min-w-0 whitespace-pre-wrap break-words text-foreground">
{children === null || children === undefined || children === '' ? '-' : children}
</div>
</div>
......@@ -128,13 +128,7 @@ function PointContentPreview({ point }) {
return displayValue(point.value);
}
export default function ControlPointPreviewDialog({
open,
onClose,
orderLabel,
point,
typeLabel,
}) {
export function ControlPointPreviewFields({ orderLabel, point, typeLabel }) {
const hasRuntimeState = point && (
Object.prototype.hasOwnProperty.call(point, 'value')
|| Object.prototype.hasOwnProperty.call(point, 'issue')
......@@ -143,29 +137,19 @@ export default function ControlPointPreviewDialog({
);
return (
<Modal.Root isOpen={open} onOpenChange={(isOpen) => { if (!isOpen) onClose(); }}>
<Modal.Trigger aria-hidden="true" aria-label="打开控制点查看弹窗" className="sr-only" tabIndex={-1} />
<Modal.Backdrop className="rc-dialog-backdrop" variant="blur">
<Modal.Container placement="center" scroll="inside" size="lg">
<Modal.Dialog className="rc-dialog-panel rc-dialog-panel--lg">
<Modal.CloseTrigger aria-label="关闭" className="rc-dialog-close" />
<Modal.Header>
<Modal.Heading className="rc-dialog-title">查看控制点</Modal.Heading>
</Modal.Header>
<Modal.Body className="rc-dialog-body">
<Surface className="grid gap-5 p-5 sm:grid-cols-2" variant="secondary">
<section className="grid min-w-0 grid-cols-1 gap-y-0">
<PreviewField label="序号">{orderLabel || '-'}</PreviewField>
<PreviewField label="控制措施">{typeLabel || point?.type || '-'}</PreviewField>
<PreviewField className="sm:col-span-2" label="名称">{point?.name || '-'}</PreviewField>
<PreviewField className="sm:col-span-2" label="描述信息">{point?.description || '-'}</PreviewField>
<PreviewField label="名称">{point?.name || '-'}</PreviewField>
<PreviewField label="描述信息">{point?.description || '-'}</PreviewField>
<PreviewField label="责任人">{recordLabel(point?.owner)}</PreviewField>
<PreviewField label="相关标签">{listLabel(point?.related_tags)}</PreviewField>
<PreviewField className="sm:col-span-2" label="相关制度">{listLabel(point?.related_regulations)}</PreviewField>
<PreviewField label="相关制度">{listLabel(point?.related_regulations)}</PreviewField>
{point?.type === 'form' ? (
<PreviewField className="sm:col-span-2" label="文本描述">{point.form_label || '-'}</PreviewField>
<PreviewField label="文本描述">{point.form_label || '-'}</PreviewField>
) : null}
{point?.type === 'attachment' ? (
<PreviewField className="sm:col-span-2" label="文件描述">{listLabel(point.attachment_label)}</PreviewField>
<PreviewField label="文件描述">{listLabel(point.attachment_label)}</PreviewField>
) : null}
{point?.type === 'computed' ? (
<>
......@@ -175,15 +159,39 @@ export default function ControlPointPreviewDialog({
) : null}
{hasRuntimeState ? (
<>
<PreviewField className="sm:col-span-2" label="内容"><PointContentPreview point={point} /></PreviewField>
<PreviewField label="内容"><PointContentPreview point={point} /></PreviewField>
<PreviewField label="异常">{point.issue || '-'}</PreviewField>
<PreviewField label="状态"><PointStatus point={point} /></PreviewField>
<PreviewField className="sm:col-span-2" label="备注">{point.remark || '-'}</PreviewField>
<PreviewField label="备注">{point.remark || '-'}</PreviewField>
</>
) : null}
</Surface>
</section>
);
}
export default function ControlPointPreviewDialog({
open,
onClose,
orderLabel,
point,
typeLabel,
}) {
return (
<Modal.Root isOpen={open} onOpenChange={(isOpen) => { if (!isOpen) onClose(); }}>
<Modal.Trigger aria-hidden="true" aria-label="打开控制点查看弹窗" className="sr-only" tabIndex={-1} />
<Modal.Backdrop variant="blur">
<Modal.Container placement="center" scroll="inside" size="lg">
<Modal.Dialog className="bg-background p-7 sm:max-w-[68rem]">
<Modal.CloseTrigger aria-label="关闭">
<Close size={16} />
</Modal.CloseTrigger>
<Modal.Header>
<Modal.Heading>查看控制点</Modal.Heading>
</Modal.Header>
<Modal.Body>
<ControlPointPreviewFields orderLabel={orderLabel} point={point} typeLabel={typeLabel} />
</Modal.Body>
<Modal.Footer className="rc-dialog-actions">
<Modal.Footer>
<Button size="sm" variant="secondary" onPress={onClose}>关闭</Button>
</Modal.Footer>
</Modal.Dialog>
......
......@@ -96,6 +96,10 @@ export default function ReferenceSearchSelect({
}
setQuery('');
}}
onClear={() => {
onChange(multiple ? [] : '');
setQuery('');
}}
/>
);
}
This diff is collapsed.
This diff is collapsed.
......@@ -418,6 +418,50 @@ export function formatDateText(value) {
return /^\d{4}-\d{2}-\d{2}/.test(text) ? text.slice(0, 10) : text;
}
function parseFormValue(value) {
if (typeof value !== 'string') return value;
const text = value.trim();
if (!text) return value;
try {
return JSON.parse(text);
} catch {
return value;
}
}
const FORM_PLACEHOLDER_LABELS = new Set(['(无标题)', '(无标题)', '无标题', '未命名表单']);
function getFormDisplayLabel(form, fallbackLabel) {
const safeFallback = FORM_PLACEHOLDER_LABELS.has(normalizeText(fallbackLabel))
? '表单'
: normalizeText(fallbackLabel) || '表单';
const candidate = form && typeof form === 'object'
? form.title || form.label || form.name
: '';
const normalizedCandidate = normalizeText(candidate);
return normalizedCandidate && !FORM_PLACEHOLDER_LABELS.has(normalizedCandidate)
? normalizedCandidate
: safeFallback;
}
export function getFormValueSummaries(value, fallbackLabel = '表单') {
const parsedValue = parseFormValue(value);
return toArray(parsedValue)
.filter(Boolean)
.map((instance, index) => {
const normalizedInstance = instance && typeof instance === 'object' && !Array.isArray(instance)
? instance
: { form: instance, data: [] };
const form = normalizedInstance.form;
const label = getFormDisplayLabel(form, fallbackLabel);
return {
key: `${form?._id || form?.name || label}-${index}`,
label,
rowCount: Array.isArray(normalizedInstance.data) ? normalizedInstance.data.length : 0,
};
});
}
export function formatFieldValue(value, fieldDef) {
if (value === null || value === undefined || value === '') {
return '';
......@@ -464,6 +508,12 @@ export function formatFieldValue(value, fieldDef) {
.join('、');
}
if (fieldDef?.type === 'form') {
return getFormValueSummaries(value, fieldDef?.label || '表单')
.map((item) => `${item.label} · ${item.rowCount} 条记录`)
.join('、');
}
if (typeof value === 'object') {
return JSON.stringify(value);
}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment