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

feat: unify xinyuan heroui ui and chart theme

parent 768c1456
# 鑫元 UI 架构
## 2026-09-02 审计项目管理基准
- 当前 `develop` 分支的共享架构与 `/Users/liuchenlong/Desktop/job/audit_project_manager` 当前实现对齐。
- AppShell 固定浅色内容画布和深色 Sidebar;禁止主题切换、主题持久化及局部暗色容器。
- Sidebar 分组使用 HeroUI `Disclosure` compound API,Navbar 使用 HeroUI Pro `Sidebar.Trigger` 与通知 `Badge.Anchor`;用户菜单使用 `Dropdown.Trigger`。
- DataGrid、Autocomplete、Modal、ECharts 适配器直接使用 HeroUI/HeroUI Pro 官方结构;问题整改图表点击回调通过适配器透传,业务状态和数据协议不变。
## 当前基准补充(2026-08-31)
- 目标项目的活动图表边界为 `src/components/XinyuanEChart.jsx`;它直接组合 `echarts-for-react`,统一读取鑫元图表 Token,并负责 Tooltip、ARIA、轴样式、resize 和 Portal 行为。
- 页面不得直接引用 Recharts 或 HeroUI Pro Charts。Pro 组件仍负责 KPI、DataGrid、Sidebar、Navbar、DropZone、ActionBar 和 EmptyState。
- `src/catalyst-ui-kit` 是历史隔离目录,不属于可用组件体系;活动路由和共享组件不得从中导入。
## 组件边界
- HeroUI 与 HeroUI Pro 是全项目唯一 UI 组件体系;页面、共享组件、示例和测试直接使用 `@heroui/react` 和 `@heroui-pro/react`。
......@@ -14,7 +27,7 @@
- Surface:L0 `background` 是页面画布,L1 `surface` 是第一内容层,L2 `surface-secondary` 是次要内容和次要控件层,L3 `surface-tertiary` 只用于 L2 内确有必要的第三层静态内容。Portal 浮层不参与 L0-L3 编号,统一使用 HeroUI `overlay`。`theme.css` 固定映射 `field-background = surface`、`default = surface-secondary`、`overlay = surface`;页面只选择 HeroUI `primary/secondary`,不得写背景色或按深浅主题切换 variant。
- DataGrid:HeroUI Pro `DataGrid` 原生 `selectedKeys`、`onSelectionChange`、`selectionMode`、`showSelectionCheckboxes`,配合原生固定列、Pagination 和 ActionBar。
- Pagination:直接使用 HeroUI compound API,不增加页容量 Select/Autocomplete。HeroUI 不定义每页条数,项目数据层统一使用 10 条/页并继续传递 `page_size=10`。
- Pagination:直接使用 HeroUI compound API;业务列表在总数信息旁使用 HeroUI Autocomplete 提供 20 / 50 / 100 条/页,默认 20 条/页。切换条数后回到第 1 页并继续传递项目原有 `page_size` 参数,选择器宽度至少 128px。
- DataGrid 宽度:共享层直接使用 HeroUI Pro `ColumnSize`。非宽表将第一业务主列设为 `1fr`,选择/操作列固定并以 `min-w-full` 保证完整宽度链;宽表保留业务最小像素宽度,仅在 DataGrid 内横向滚动。页面不得用背景、伪元素或空列填补右侧空白。
- DataGrid 长文本:`RecordWorkbench` 从动态视图字段定义、字段语义和当前真实记录样本共同推导展示策略,不绑定具体旧表头。普通长文本最小 360px,整改情况最小 420px,最多两行换行并由共享 HeroUI Tooltip 提供全文;`DataTable` 根据列最小宽度总和与 `ResizeObserver` 获得的容器实宽决定内部横向滚动。任何单元格文本必须在自身边界内裁切,不得覆盖相邻列。
- DataGrid 行操作:共享 `TableActionButton` 直接组合 HeroUI `Button`、`Dropdown`、`Tooltip` 与 Ant Design Icons,不兼容旧按钮 API。普通查看/编辑/日期/复制/锁定/更多使用 `tertiary` 中性正圆,明确关键的审批/提交使用 `secondary` 强调正圆,删除/驳回使用 `danger-soft` 危险正圆;32px 按钮、16px 图标、8px 间距并与表头左对齐,列宽按可见按钮数动态计算。外观状态由 HeroUI variant 与 `theme.css` 管理,布局类只保证圆心和 SVG 中心重合。
......@@ -25,13 +38,15 @@
- 表单:HeroUI TextField、Input、TextArea、Autocomplete、SearchField、Checkbox、DatePicker;下拉型枚举、用户、引用、单选、多选、年份与页容量通过 `XinyuanAutocomplete.jsx` 组合官方 Autocomplete compound API,上传使用 HeroUI Pro DropZone。
- 日期字段:共享 `FormDatePicker.jsx` 直接组合 HeroUI `DatePicker + DateField + Calendar + CloseButton`。日历触发器与 Autocomplete 指示器使用同一 16px Ant Design 图标几何、共享 `FIELD_SUFFIX_ICON_STYLE` 和尾部光学中心;该样式只引用 HeroUI `--field-placeholder` / `--muted` Token,并直接作用于 Ant 图标根节点,避免不同图标组件的继承链产生色差。可选字段有值时允许清空,并通过页面原有变更回调传出 `''`;组件不得修改日期协议、请求节奏、校验或业务状态。
- 表单布局:`4xl` 新增/编辑弹窗桌面使用等宽双列,普通输入、日期、单选和多选各占一列,只有长文本 TextArea 跨两列;移动端回退单列。跨列只由内容密度决定,不由 `multiple` 数据属性决定。
- Autocomplete:选择状态只使用 HeroUI 当前 `selectedKey` / `selectedKeys`、`selectionMode` 和 `onSelectionChange`,搜索状态只使用 `inputValue` / `onInputChange`。远程字段继续在业务层拥有防抖、请求和 ID-only 映射;共享组件不合成旧 DOM 事件、不复刻旧 Select API。
- Autocomplete:选择状态只使用 HeroUI 当前 `selectedKey` / `selectedKeys`、`selectionMode` 和 `onSelectionChange`,搜索状态只使用 `inputValue` / `onInputChange`。允许清空的已选字段在触发器内使用官方 `Autocomplete.ClearButton`;`onClear` 必须沿用原有受控回调写回单值空值或多值空集合,不能只清内部状态,也不能仅在弹层里提供清空按钮。远程字段继续在业务层拥有防抖、请求和 ID-only 映射;共享组件不合成旧 DOM 事件、不复刻旧 Select API。
- Tabs:直接使用 HeroUI Tabs compound API。页面级、模块级和详情级都按文字内容确定各项宽度,只在 compound slot 的 `className` 上设置 `Tabs/ListContainer: w-fit max-w-full min-w-0`、`List: w-max min-w-max`、`Tab: w-auto min-w-max flex-none whitespace-nowrap`。标签始终单行完整展示,项目不得均分或固定 Tab 宽度,溢出只由 `Tabs.ListContainer` 的官方 `ScrollShadow` 处理。选中态、尺寸、圆角、颜色、阴影和动效采用官方默认,仅由 `theme.css` 提供主题值;禁止全局选择 `.tabs__*` 重画外观。
- 图表:HeroUI Pro PieChart、BarChart、ComposedChart、KPI 和 ChartTooltip;共享 `chartPalette.js` 导出 `tokens.css` 中的 8 个分类色、语义图表色引用及同图唯一分配器。分类图表必须一次传入完整系列 key,在 8 个系列内确定性消除哈希撞色;页面不得直接用 `success/danger/warning/foreground` 组装色板。图表 Card 主标题在 HeroUI `Card.Header` 内统一左对齐并与正文左边缘一致;分类层级、范围和统计口径参数以紧凑 `secondary` Autocomplete 位于 Header 右侧同一行,真正的并列视图切换才使用 HeroUI Tabs。禁止按图表类型切换为居中标题。图例文字通过公开 label slot 使用 `--rc-chart-legend-font-size` / `--rc-chart-legend-line-height`,不得覆盖第三方图表内部类。保留原统计数据与业务回调,不保留 ECharts 私有主题。
- 字段来源切换(例如“材料中心 / 本地上传”)使用 HeroUI `primary` Tabs;Tabs 根、Panel 和 Panel 内容容器为 `w-full min-w-0`,Panel 仅使用 `mt-3 p-0` 调整布局间距,列表仍保持文字自适应宽度,Panel 内选择框和上传控件统一 `fullWidth`,确保控件与弹窗字段列通栏对齐。
- 图表:HeroUI Pro PieChart、BarChart、ComposedChart、KPI 和 ChartTooltip;共享 `chartPalette.js` 导出 `tokens.css` 中的 8 个分类色、语义图表色引用及同图唯一分配器。分类图表必须一次传入完整系列 key,在 8 个系列内确定性消除哈希撞色;页面不得直接用 `success/danger/warning/foreground` 组装色板。图表 Card 主标题在 HeroUI `Card.Header` 内统一左对齐并与正文左边缘一致;分类层级、范围和统计口径参数以紧凑 `secondary` Autocomplete 位于 Header 右侧同一行,真正的并列视图切换才使用 HeroUI Tabs。禁止按图表类型切换为居中标题。图例文字通过公开 label slot 使用 `--rc-chart-legend-font-size` / `--rc-chart-legend-line-height`,不得覆盖第三方图表内部类。交叉维度数据(如问题来源 × 检查单位)优先使用带单元格数量的 ECharts Heatmap,非零单元使用统一语义主色、零值保持当前 Surface,避免多系列柱形在窄卡片内重叠;保留原统计数据与业务回调,不保留 ECharts 私有主题。
- 图表 Tooltip:共享 `colorizeXinyuanChartTooltipPayload` 在进入 HeroUI Pro `ChartTooltipContent` 前把 Tooltip indicator 颜色归一化为当前图形 mark/图例的同一 Token,并排除 `none`/`transparent`。环图、单系列柱图和多系列组合图均显示官方 8px 圆点;页面不复制 Tooltip DOM 或样式。
- 图表焦点:HeroUI Pro 图表根统一使用 `rc-chart-interaction`。共享层通过 `:focus:not(:focus-visible)` 隐藏鼠标点击产生的浏览器原生蓝框,通过 `:focus-visible` 和主题 `--focus` 保留键盘焦点;不得禁用 Recharts `accessibilityLayer`,也不得修改图表点击回调。
- KPI:页面直接组合 HeroUI Pro `KPI` compound API,Card 根使用 `rc-card-surface rc-dashboard-card`,`KPI.Icon` 使用共享 `rc-metric-icon-surface`。共享九色 Token 解决 Pro 原生 `status` 只有三种时的无背景回退,浅底统一为 9% tone(neutral 8%)。`.rc-kpi-grid data-count` 负责 1–9 项栅格和无显式 `data-tone` 时的顺序唯一配色;业务可覆盖语义 tone,但同组不得重复图标或颜色。超过九项拆分模块,页面不得自定义色值、尺寸和背景。
- 图表栅格:共享 `.rc-chart-grid` 在 1280px 及以上使用等宽 `1fr / 1fr`,以下单列。计数边界是同一当前可见业务 Section/Tab Panel 内的同级图表,不是整个路由页面;可配对图表保持双列,`:only-child` 自动跨 `1 / -1` 通铺。隐藏区域和其他 Section 的图表不得让当前单图模块退化为左半宽。
- 类目柱状图布局:当图例位于底部时,必须通过 `grid.bottom >= 92px` 把 X 轴标签和 Legend 分到不同区域;类目标签使用 `interval: 0`、`hideOverlap: true`、固定可读宽度与 `overflow: break` 自动换行,长标签图表高度至少 360px,禁止文字与图例共用基线或通过缩小字体解决重叠。
- 页面布局:`rc-app-page` 统一承担响应式页边距;页面根、模块、网格、工具栏和列表分别使用 `rc-page-stack`、`rc-page-section`、`rc-page-grid`、`rc-page-toolbar`、`rc-list-stack`。这些类只管理布局几何,不选择或覆盖 HeroUI 内部 DOM。
- Navbar 通知入口:通知中心作为全局 Shell 动作,不进入 Sidebar 菜单。使用 HeroUI `Badge.Anchor + Badge + Button + Tooltip` 直接组合,复用原通知路由和未读数量服务;通知按钮在主题按钮左侧,0 条不显示 Badge,超过 99 条显示 `99+`。禁止实现通知 Popover、伪造未读数或改变通知页、请求与已读状态流。
- 列表工具栏:数据集标题单独位于透明列表区首行,下一行才是命令工具栏。搜索与列分组并存时,左侧顺序固定为 HeroUI `SearchField primary` 在前、HeroUI `ToggleButton` 分组控件在后,右侧为业务操作;主操作使用 `Button primary` 并最右,次操作使用 `Button secondary`。移动端搜索先独占整行,分组控件在下一行自然换行。列分组 ToggleButton 直接使用官方状态和 Ant Design 16px 图标,不重画胶囊背景:列正常展示时是默认灰色未选中态,列被收起时才是主题选中态;Tooltip/aria-label 根据当前状态只描述下一步“显示”或“隐藏”动作。只有 Surface/overlay 内的嵌套字段使用 `secondary` SearchField;`outline` 不作为次按钮默认映射。
......@@ -41,6 +56,12 @@
`src/design-system/theme.css` 是以 HeroUI 官方主题工具导出值为基础并加入已批准表面映射的鑫元权威主题,必须与 `/Users/liuchenlong/Desktop/theme.css` 及 `xinyuan-design-system` 基线逐字节一致。加载顺序为 Tailwind、HeroUI、HeroUI Pro、`theme.css`、`tokens.css`、Shell/Login 样式。`tokens.css` 只提供 Shell/Login、页面几何和批准的数据可视化扩展 Token,不覆盖 HeroUI 核心或组件 Token。图表系列 Token 在浅色/深色中固定不变;HeroUI Pro 继续拥有坐标轴、网格、文字、Tooltip 和表面的主题适配。内容页面禁止用 CSS 选择 HeroUI 内部 BEM 类、slot 状态或选中属性。
品牌专项约定:HeroUI `--accent` / `--focus` 固定为鑫元主色 `#DA241A`,`--accent-hover` 为 `#C91F17`;图表使用与 UI 状态分离的 16 色低饱和品牌色板(品牌红、暖棕、灰紫、灰蓝),系列色在深浅模式间不变且禁止绿色、荧光色和彩虹式组合。单系列图表必须使用品牌红,多系列通过稳定业务 key 分配 `--rc-chart-*`,不得在页面写入颜色常量。
环图 Legend 采用 ECharts 官方公开配置:可用宽度足够时保持单行,检测到条目总宽度不足时按数量均分为上下两行;图例圆点始终与对应扇区颜色一致。
应用切换卡片中的鑫元 Logo 直接使用透明 HeroUI `Avatar soft`,固定 36×36px;不得在 Logo 后叠加灰色 `Surface tertiary` 或其它页面私有背景。
页面几何 Token 是允许的扩展:Navbar Header、HeroUI Pro 折叠按钮、主题按钮和页面内容共用 `--rc-shell-inline-gutter`,桌面为 48px、平板为 32px、手机为 16px,左右完全一致。页面一级模块、模块网格和列表内部节奏为 32/24/16px;手机端一级模块和网格收敛为 24/16px。页面不得新增另一套私有 gutter 或顶层 `space-y-*`。
## HeroUI Pro 交付
......
# 鑫元 UI 迁移清单
## 2026-09-02 基准同步状态
- [x] 当前 `develop` 分支与审计项目管理共享 Shell、主题 Tokens、Tailwind 顺序、Sidebar、Navbar、DataGrid、Autocomplete、Modal 和 ECharts 适配器同步
- [x] 移除运行时主题切换与主题本地持久化,固定浅色内容区 + 深色 Sidebar
- [x] 保留问题整改图表既有点击回调,仅同步适配器与视觉实现
- [x] `npm run build`、`git diff --check` 通过
## 2026-08-31 本轮执行状态
- [x] 当前工作区、分支和未提交修改已核验
- [x] HeroUI Pro `1.0.0-beta.8` 已在当前项目内修复并通过根包/CSS/六个子路径解析
- [x] 活动旧组件引用初审完成;历史 `src/catalyst-ui-kit` 未进入活动导入图
- [ ] 同步基准 `theme.css`/Tokens
- [ ] 驾驶舱/整改分析迁移为共享 ECharts `XinyuanEChart`
- [ ] 移除活动 Recharts/Pro Charts 依赖与残余命名
- [ ] 构建、依赖树、旧引用扫描和四视口浏览器验收
> 当前规范:HeroUI 与 HeroUI Pro 已成为项目唯一 UI 组件体系。下表“当前来源”列仅记录迁移前状态,不代表仍可使用;新功能、存量维护、重构、示例和测试均不得引用或复制 `src/catalyst-ui-kit`,也不得重新引入 Headless UI 或旧适配器。
状态:`待迁移`、`迁移中`、`待浏览器验收`、`完成`。
......@@ -48,6 +65,7 @@
- 日期/月份字段的日历触发器与 Autocomplete 箭头已统一为 16px Ant Design Icons、HeroUI 字段占位前景和同一右侧光学中心;可选日期类字段有值时显示 HeroUI `CloseButton`,通过既有 `onChange` 回写 `''`。检查报告出具月份的清空、自动刷新和深浅色真实像素对齐仍待浏览器验收。
- 页面布局已统一到流式 HeroUI 示例节奏:Navbar 操作与页面内容共用对称 gutter,桌面左右 48px、平板左右 32px、手机左右 16px;一级模块 32px、模块网格 24px、列表内部 16px。驾驶舱、整改分析、问题清单、归档、整改处理、整改流程和空状态页已接入共享语义布局类。
- Tabs 已统一为 HeroUI compound API 的内容宽度模式:每项宽度由自身文字和 HeroUI 默认内边距决定,不均分、不固定;所有标签 `flex-none + min-w-max + whitespace-nowrap`,不足时仅由 `Tabs.ListContainer` 内部滚动。没有覆盖 HeroUI 的颜色、圆角、指示器、阴影或选中态,选中 key 与切换回调保持原样。
- 附件字段“材料中心 / 本地上传”已使用 HeroUI `primary` Tabs;Tabs 根、Panel 与内容容器全宽,材料 Autocomplete 与上传控件通栏,业务选材、上传和保存回调不变。
- DataGrid 行操作已按语义分级为 HeroUI `tertiary` / `secondary` / `danger-soft` 正圆按钮:查看、编辑、日期、复制、锁定和更多使用中性 `tertiary`,审批/提交等关键操作使用强调 `secondary`,删除/驳回使用 `danger-soft`。Ant Design 16px 图标采用三层网格居中,操作组左对齐且间距 8px;操作列宽度随 1/2/3 个可见入口计算为 80/112/144px,4 个及以上折叠为“前两个 + 更多”。业务回调、权限、禁用条件和操作排序未改,真实像素居中与 Tooltip/Dropdown 打开态仍待浏览器验收。
- 危险操作已从 HeroUI 默认洋红系统一为鑫元红系 Token:浅色 `--danger` 为 `#9E1B1B`,深色为同色相增亮红;删除行图标、删除选中、确认删除、驳回与退出登录继续直接使用 HeroUI `danger` / `danger-soft`,不存在页面私有颜色。
- 短列表宽度已改为 HeroUI Pro 原生 `1fr` 弹性列:第一业务主列吸收剩余空间,选择列、操作列和其他字段保留最小宽度,表格内容至少铺满 DataGrid。归档等少列列表不再出现右侧大块空白;真实宽表仍保留内部横向滚动。各视口的实际列分配仍待浏览器验收。
......@@ -56,10 +74,10 @@
- 驾驶舱与归档页的 KPI 组已改用 16px 关联项间距;一级模块仍保持 32px,普通图表/模块网格仍保持 24px。HeroUI Pro KPI 内部 padding 和组件外观未覆盖。
- 驾驶舱、归档和整改流程的数值图标已统一到共享 `.rc-metric-icon-surface`:所有图标都有 48px 浅色语义背景和 20px Ant Design 图标;驾驶舱五项分别使用 `info / success / teal / danger / warning`,组内图标和颜色均不重复。统计值、接口和回调未改,浅色/深色真实像素居中仍待浏览器验收。
- KPI 图标浅底混色已从 16% 降为 9%(neutral 8%),并补齐九组共享 tone;`.rc-kpi-grid data-count` 已覆盖 2、5、6、7、8、9 项布局,1–9 项无显式语义 tone 时自动按顺序使用不同颜色。超过九项须拆分模块,不允许循环撞色。
- 驾驶舱与整改分析图表已从 `accent/warning/success/danger/muted/foreground` 状态色拼接改为鑫元 8 色清爽冷调分类色板,并增加主系列、风险、提醒、完成率、对比和中性语义色。环图、堆叠柱图、重点问题、逾期排行、责任矩阵和质效评级均只引用共享 Token;分类系列通过共享分配器做确定性冲突消解,8 个以内不再出现审计部/风险管理部同色。系列色在浅色/深色下固定不变,HeroUI Pro 仍负责坐标轴、网格、文字、Tooltip 与表面的模式切换;统计数据和交互回调未改。浅色/深色实际对比度、Tooltip 和图例一致性仍待真实浏览器验收。
- 驾驶舱与整改分析图表已从 `accent/warning/success/danger/muted/foreground` 状态色拼接改为鑫元 16 色低饱和品牌色板,并增加主系列、风险、提醒、完成率、对比和中性语义色。环图、问题来源与检查单位矩阵热力图、重点问题、逾期排行、责任矩阵和质效评级均只引用共享 Token;分类系列通过共享分配器做确定性冲突消解,16 个以内不再出现相同颜色。系列色在浅色/深色下固定不变,环图恢复官方 ECharts Legend,空间允许时一行展示,空间不足时按条目数量均分两行;HeroUI Pro 仍负责坐标轴、网格、文字、Tooltip 与表面的模式切换;统计数据和交互回调未改。
- 整改分析页图表按当前可见业务分组接入 `.rc-chart-grid`:业务领域/根本原因、来源对比/重点问题、逾期排行/责任矩阵在各自 Section 内保持宽桌面等宽双列,不再使用 `1.05/0.95` 或 `0.9/1.1`;“审计整改质效”分组当前只有综合整改质效结果一个图表,因此通过共享 `:only-child` 规则通铺,与普通驾驶舱的单图整改进度一致。1280px 以下统一回退单列;真实图例换行和各断点高度仍待浏览器验收。
- 驾驶舱与整改分析的图表 Card 主标题已统一为 HeroUI `Card.Header` 左对齐,与 `Card.Content` 左边缘一致;分类层级等当前图表参数使用紧凑 HeroUI Autocomplete 位于 Header 右侧同一行,真正并列视图才使用 Tabs。不得因图表类型、单双列、空状态或通铺布局切换为居中标题;真实像素边线仍待浏览器验收。
- 图表图例说明文字已统一为扩展 Token 控制的 12px/16px,通过 Recharts 公开 `labelStyle` 传入;8px 圆点、系列颜色、数据顺序和图表交互未改。真实宽屏换行和暗色模式仍待浏览器验收。
- 图表图例说明文字已统一为扩展 Token 控制的 12px/16px,通过 ECharts 公开 `legend.textStyle` 传入;8px 圆点、系列颜色、数据顺序和图表交互未改。环图 Legend 优先单行,窄空间按条目数量均分两行。
- 列表筛选已改为条件数量自适应的 HeroUI 布局:1–2 个条件直接位于列表标题上方,3 个以上使用直接 HeroUI `Surface`,不使用 Card 或 `Card.Content`;问题清单的 9 个结构化条件首行展示 3 个等宽高频项,其余 6 项使用原生 HeroUI Disclosure 展开。筛选组采用 12px 间距、标签与控件 6px 间距,宽屏展开区为 3 列;该页原本已随筛选状态自动请求,已移除重复查询和清空图标入口,筛选值、请求参数与自动刷新时机未改。
- 筛选折叠入口不再使用手写条件渲染和箭头状态,已切换为 `Disclosure.Trigger + Disclosure.Indicator + Disclosure.Content`;真实展开动画、键盘操作、焦点状态、暗色表面和各视口布局仍待浏览器验收。
- 展开筛选区已限制在首行字段区域内并复用同一三列模板,6 个附加条件形成 3×2 对齐网格;右侧“收起”操作列不再参与展开字段的宽度分配。平板回退 2 列、手机回退 1 列。
......@@ -72,7 +90,7 @@
- 问题整改详情已改为弹窗级查看/编辑状态:默认字段只读,单一“编辑”入口进入整窗编辑,Footer 统一“取消 / 保存”;取消恢复原记录,保存沿用原 `updateRecord` 和嵌套 payload 一次提交变更,视图禁用、锁定和只读字段不进入编辑。生产构建与静态状态检查已通过,真实点击与接口请求仍待浏览器验收。
- 新增问题整改记录弹窗已与详情统一为 HeroUI `4xl`(1088px);Input、TextArea、DateField.Group、Autocomplete 和多选控件在浅色、深色中统一使用 `secondary` 并铺满字段列,不再监听主题切换 variant。原“检查项目名称、问题简述至少填写一项”不再常驻,只有点击“确认新增”且原校验失败时才打开 HeroUI `AlertDialog`,关闭后保留草稿且不发送新增请求。此前已验证弹窗宽度、移动端安全边距和校验行为;本次统一后的两种主题字段层级仍需真实浏览器复验。
- 新增表单的短字段已统一为桌面等宽双列,责任部门等多选字段不再单独占满整行,并会与下一项整改进度配对;问题简述、整改情况和备注等长文本仍跨两列,移动端全部回退单列。真实浏览器已验证责任部门/整改进度同排各 508px,390px 视口下各字段依次回退为 302px 单列。
- 弹窗内部层级已统一为 `Backdrop -> Overlay -> secondary -> rare tertiary`:应用切换项与骨架、详情附件编辑器、流程详情摘要对象使用 HeroUI `secondary`;Header/Body/Footer 保持透明,普通详情查看态通过一个 `Surface secondary` 承载整组无框标签/值。已移除需要边界的默认 L1 Card,未新增页面私有背景、描边或阴影;真实深浅色层级仍待浏览器验收。
- 弹窗内部层级已统一为 `Backdrop -> Overlay -> secondary -> rare tertiary`:应用切换项与骨架、详情附件编辑器、流程详情摘要对象使用 HeroUI `secondary`;Header/Body/Footer 保持透明,普通详情查看态通过一个 `Surface secondary` 承载整组无框标签/值。已移除需要边界的默认 L1 Card,编辑态由 `secondary` 控件直接位于 Overlay,未新增页面私有背景、描边或阴影;真实深浅色层级仍待浏览器验收。
- 应用切换 Logo 已统一为 L2 应用 Card 内的 HeroUI `Surface tertiary + Avatar soft`:固定 36px 区域、真实图片等比包含、无图/加载失败使用同尺寸 accent 首字回退,解决透明 Logo 与裸文字缺少容器的问题。问题详情编辑态不保留整组查看 Surface,避免表单控件发生 L2 套 L2;业务字段、权限、编辑入口和保存回调未改。
- 问题清单与归档列表的单一关键词字段已从结构化筛选 Surface 拆出。数据集标题独占透明首行,下一行命令工具栏使用 HeroUI `SearchField primary`;存在列分组时,搜索位于左侧最前,分组 ToggleButton 紧随其后。搜索桌面保持 320–384px,手机先独占整行,原关键词值、自动请求和清空回调保持不变。问题清单和归档页的次工具栏操作已从 `outline` 统一为 HeroUI `secondary`;新增记录继续使用 `primary`,按钮回调与禁用条件未改。
- 问题清单标题已独立为列表区首行,搜索、列分组和业务按钮整体下移到第二行;第二行左侧顺序固定为搜索在前、列分组在后,右侧操作顺序不变。列分组使用带 16px Ant Design 图标的 HeroUI `ToggleButton size="sm"`:默认可见时使用官方灰色未选中态,收起后才使用主题选中态;Hover/读屏文案按当前状态分别为“隐藏{分组名}列”或“显示{分组名}列”。列显隐回调、滚动恢复、搜索请求及播报逻辑未改;真实桌面/移动端对齐仍待浏览器复验。
......
......@@ -69,6 +69,7 @@
该功能只适用于六个完整功能应用中允许编辑的 `file` 字段;若目标应用业务文档明确限定字段范围,以该范围为准。
- UI 使用 HeroUI Tabs 在“材料中心 / 本地上传”之间切换;材料选择使用 HeroUI Autocomplete,本地文件使用 HeroUI Pro `DropZone`。必须运行时读取 Subject 字段 `multiple`。
- 当前附件与来源入口分层展示:附件列表负责呈现已选文件,单值 Autocomplete 保持“请选择审计材料”作为替换入口,不重复回显当前文件名;删除图标与文件行垂直居中并使用 HeroUI `danger-soft`。
- 材料中心通过 `POST /record/query` 查询 `am_material` 最近 20 条,按当前用户 Record 权限返回,远程搜索 `metadata.material_title` 和 `metadata.file_asset.name`,正则关键词必须转义。
- 选择已有材料只把 `{name,type,size,url}` 文件快照写入业务草稿,不保存 `am_material` ID,不重新上传、不创建材料。
- 单值字段替换原值;多值字段追加并按 URL 去重,支持逐项删除。
......
# 鑫元问题整改系统设计规范
## 2026-09-02 基准同步
本项目 `develop` 分支统一以 `/Users/liuchenlong/Desktop/job/audit_project_manager` 当前实现为唯一 UI 基准。右侧内容区固定浅色、Sidebar 固定深色;不得新增主题切换按钮、Sun/Moon 图标、主题存储或局部暗色模式。Shell、Sidebar 分组、Navbar 通知、DataGrid、Autocomplete、Modal、ECharts 和全局 Tailwind 加载顺序以基准项目对应文件为准。问题整改专属路由、Subject/View、接口和业务回调只保留自身实现。
## 2026-08-31 基准同步补充
驾驶舱和整改分析的图表统一使用共享 `XinyuanEChart`(`echarts-for-react` + ECharts)。禁止在活动页面引入 `@heroui-pro/react/*-chart`、Recharts 或页面私有图表主题;页面只提供业务数据、series、坐标轴语义和已有点击回调。图表颜色、Tooltip、图例、轴、ARIA 和响应式由主题 Token 与共享适配器管理。
本文件是本项目新增功能和 UI 维护的执行标准。业务接口、参数、数据结构、路由、权限、校验、回调和状态流转不属于普通视觉迁移范围;下述跨应用五项功能是用户明确授权的窄范围功能扩展例外,例外之外的业务仍不得修改。
跨应用新增能力的范围矩阵、`shortTitle`、Subject View 权限、消息通知和审计材料中心文件事务统一遵循 [xinyuan-cross-app-feature-contract.md](./xinyuan-cross-app-feature-contract.md)。六个指定应用实施全部五项,其它鑫元应用只实施消息通知;不得越过白名单扩展功能。
......@@ -62,7 +70,7 @@ Surface 用于表达页面内的包含关系,Overlay 用于表达浮动关系
- `src/design-system/shell.css` 只负责应用壳布局、导航定位、折叠、Logo 和响应式,不得延伸到列表、筛选、Tabs、Card、DataGrid 或弹窗正文。
- 侧边栏只有一个导航组时,不在菜单列表上方重复显示应用名称;组名继续作为 `Sidebar.Menu` 的可访问性标签。只有存在两个及以上确需区分的业务分组时才显示 `Sidebar.GroupLabel`。
- 通知中心不占用 Sidebar 导航层级。存在真实通知功能时,在 Navbar 右侧操作组中使用 HeroUI `Badge.Anchor + Badge + Button + Tooltip`,通知按钮位于主题切换左侧,主题切换始终最右;按钮沿用 36px 中性圆形 Navbar 动作规范,图标为 16px Ant Design Bell。未读数只能来自真实接口,0 时不显示角标,超过 99 显示 `99+`,点击仍进入应用原通知路由。
- 应用切换使用 HeroUI Modal、Card、Surface、Avatar、Chip、Skeleton、Alert 和 EmptyState。应用 Card 位于 Overlay 内,使用 `secondary`;36px Logo 区域是 Card 内的静态第三层,使用 `Surface tertiary` 承载 HeroUI `Avatar soft`。真实图片使用 `object-contain`,无图或加载失败时使用同尺寸的 `accent` 首字回退,不允许裸文字、拉伸图片或尺寸跳变。
- 应用切换使用 HeroUI Modal、Card、Surface、Avatar、Chip、Skeleton、Alert 和 EmptyState。弹窗按确认基准不显示取消/关闭图标;`Modal.Backdrop` 使用官方 `isDismissable`,遮罩点击与 Escape 通过受控 `onOpenChange` 关闭。应用 Card 位于 Overlay 内,使用 `secondary`;36px Logo 区域是 Card 内的静态第三层,使用 `Surface tertiary` 承载 HeroUI `Avatar soft`。真实图片使用 `object-contain`,无图或加载失败时使用同尺寸的 `accent` 首字回退,不允许裸文字、拉伸图片或尺寸跳变。
- 该 Logo well 是跨应用固定规范:`size-9`、`shrink-0`、`rounded-[var(--rc-radius-control)]`、居中且裁切稳定,内部 Avatar 铺满、使用相同圆角并保持透明,图片使用 `object-contain p-1.5`,Skeleton 也使用 `size-9`。浅色、深色下必须分别验证真实不透明图、透明/emoji-like 图和加载失败回退,三者不得改变标题起点。
### 4.1 页面边距与布局节奏
......@@ -81,6 +89,7 @@ Surface 用于表达页面内的包含关系,Overlay 用于表达浮动关系
- 页面级和模块级切换都使用 HeroUI Tabs compound API:`Tabs.ListContainer`、`Tabs.List`、`Tabs.Tab`、`Tabs.Indicator`。
- 不使用自制滑块、胶囊、下划线、选中背景或记录数字;选中态完全由 HeroUI 和 `theme.css` 决定。
- 所有 Tab 宽度跟随各自文字内容,不均分剩余空间、不设置统一宽度。统一 slot class 为 `Tabs/ListContainer: w-fit max-w-full min-w-0`、`List: w-max min-w-max`、`Tab: w-auto min-w-max flex-none whitespace-nowrap`;`全部`等短标签保持紧凑,`风险管理部`等长标签自然更宽。
- 附件来源等“同一字段的来源方式”切换使用 HeroUI `Tabs variant="primary"`:Tabs 根、各 `Tabs.Panel` 和内容容器保持 `w-full min-w-0`,Panel 使用布局类 `mt-3 p-0` 与字段边线对齐,Tab 列表仍按文字宽度收缩;Panel 内的 Autocomplete/DropZone 等控件必须 `fullWidth` 通栏,不得因 Tabs 根节点 `w-fit` 被压缩。
- Tab 文字必须完整单行显示,不允许中文部门名、分类名或视图名换成两行,也不通过截断隐藏标签。空间不足时只允许 HeroUI `Tabs.ListContainer` 内置 `ScrollShadow` 横向滚动,外层不得再加 `overflow-x-auto` 形成双滚动。
- 上述 class 只约束宽度、flex 收缩和换行;不得覆盖 `.tabs__list-container`、`.tabs__list`、`.tabs__tab`、`.tabs__indicator` 的背景、边框、圆角、颜色、阴影、尺寸、Hover 或选中态,也不得把文档示例 CSS 复制为项目私有 Tabs 皮肤。
- 结构化筛选区直接使用 HeroUI `Surface`、Autocomplete、SearchField 和 Button;不用 Card 代替 Surface,不再套 `Card.Content`。关键词搜索和选择器弹层搜索的图标都放在官方 `SearchField.SearchIcon` slot。
......@@ -98,9 +107,11 @@ Surface 用于表达页面内的包含关系,Overlay 用于表达浮动关系
- 列表工具栏主操作使用 HeroUI `primary` 并置于最右,导出、归档、对比等次操作直接使用安装版本 HeroUI `secondary`。`outline` 是独立的强调边界 variant,不再作为次按钮的默认同义词;不得用页面 CSS 重画 secondary 或 outline。
- 筛选区按条件数量自适应:1–2 个条件直接放在列表模块标题上方,不额外套容器;3 个及以上条件使用独立 HeroUI `Surface`。首行最多保留 3 个高频条件,其余条件使用 HeroUI `Disclosure` 的 `Trigger`、`Indicator` 和 `Content` 展开,不手写高度动画、旋转箭头或折叠状态 DOM。
- 同一筛选组内字段间距统一为 12px,字段标签与控件间距为 6px;展开内容与首行间距为 12px。展开区必须复用首行字段区域的宽度和列模板,不跨入右侧展开操作列;本项目宽屏为 3 列、平板 2 列、手机 1 列,使上下字段左右边线完全对齐。
- 结构化筛选控件的标签已表达字段含义,占位文案保持短而统一:文本/长文本使用“请输入”,数字使用“请输入数字”,枚举选择使用“请选择”,日期使用“请选择日期”,人员与引用搜索使用“输入关键词”。占位文案使用 HeroUI placeholder Token 的弱化颜色,不得作为可选项渲染在下拉列表中;不得重复拼接“筛选 + 字段名”,也不得在占位符中写入业务提示、接口说明或长句。
- 展开入口使用 HeroUI `sm`、`ghost` 按钮与 `Disclosure.Indicator`,静止态透明,文案为“展开”/“收起”。自动筛选页面不显示查询或刷新按钮;只有业务明确采用手动提交时才保留查询按钮。按钮外观、动画、焦点和暗色状态由 HeroUI 与 `theme.css` 管理,页面不得绘制灰色胶囊、分割线或独立箭头。
- 筛选字段、展开/收起、刷新、查询和重置的业务回调保持原样;不得因视觉迁移改变触发时机和请求参数。
- 下拉型单选、多选、远程引用、年份和页容量统一使用 HeroUI `Autocomplete.Trigger`、`Autocomplete.Value`、`Autocomplete.Indicator`、`Autocomplete.Popover`、`Autocomplete.Filter`、`SearchField` 和 `ListBox`;选中值使用 `selectedKey` / `selectedKeys`,搜索值使用 `inputValue` / `onInputChange`。
- 允许清空的已选单选/多选字段在 `Autocomplete.Trigger` 内显示官方 `Autocomplete.ClearButton`;点击后通过 `onClear` 沿用原有受控回调写回单值空值或多值空集合,确保表单草稿、脏状态和再次打开的默认选中同步。不得只清内部 React Aria 状态,也不得把清空能力降级为弹层内的私有文字按钮。
- `src/components/XinyuanAutocomplete.jsx` 是业务无关的 HeroUI-native 复合组件,只暴露当前 HeroUI selection 契约,不兼容旧 Select props 或原生事件。页面不得新增 `Popover + Button + ListBox` 选择器。
- Autocomplete、SearchField、ListBox、Checkbox 遵守 React Aria compound/slot 结构,不用旧 DOM 模拟;箭头与搜索/清除图标使用 `AppIcons.jsx` 的 Ant Design Icons。
- `@heroui/styles` 自带的 `autocomplete.css` 是外观权威,不复制用户文档示例 CSS,不在页面中覆盖 `.autocomplete*` 内部类。
......@@ -117,7 +128,7 @@ Surface 用于表达页面内的包含关系,Overlay 用于表达浮动关系
- 固定列、最小列宽和横向滚动是数据可读性行为,不通过 CSS 改写 DataGrid 表头、行高、边框、背景或 Hover。
- DataGrid 无论列数多少都占满列表可用宽度。非宽表使用 HeroUI Pro 原生列尺寸:选择列和操作列保持固定,第一业务主列使用 `1fr` 吸收剩余空间,表格内容保持 `min-width: 100%`;只有真实最小列宽超过视口时才使用像素列宽和内部横向滚动,不允许出现表面全宽但正文只停在左侧的空白断层。
- 批量选择后使用 HeroUI Pro ActionBar;清除选择和业务操作保留原回调。
- 分页直接使用 HeroUI compound Pagination,只展示总数、当前/总页数、上一页、页码和下一页;不展示页容量选择器。HeroUI 不规定每页条数,本系统在数据层固定为 10 条/页,不得手写页码按钮或 CSS 改写当前页样式。
- 分页直接使用 HeroUI compound Pagination,并在总数信息旁提供 HeroUI Autocomplete 条数选择器。业务列表默认 20 条/页,可选 20 / 50 / 100 条/页;切换后回到第 1 页并沿用原分页请求回调。选择器触发器宽度至少 128px,确保“100 条/页”与箭头保持清晰间距。不得手写页码按钮或 CSS 改写当前页样式。
- 行操作使用 HeroUI `Button isIconOnly size="sm"`、Dropdown、Tooltip 和 Ant Design Icons。查看、编辑、日期、复制、锁定和更多等普通操作使用官方 `tertiary`,保留灰色圆底和默认中性前景;审批通过、提交等明确关键操作才使用 `secondary` 的鑫元强调前景;删除/驳回统一 `danger-soft`。按钮为 HeroUI 自带的 32px 桌面/36px 触屏正圆,图标固定 16px 并在按钮中水平、垂直居中。
- 操作组与“操作”表头左边缘对齐,按钮间距固定 8px。1-3 个操作全部展示,4 个及以上展示前两个和一个同规格的“更多”圆形入口;操作列按实际可见圆形按钮数量计算宽度,不得将图标挤到单元格右侧。
- 不得用页面 CSS 重画操作按钮背景、边框、Hover 或危险色,也不得使用 `ghost` 作为普通行操作静止态;操作顺序、权限、禁用条件及回调保持不变。
......@@ -137,6 +148,7 @@ Surface 用于表达页面内的包含关系,Overlay 用于表达浮动关系
- 详情查看态的空标量或空文本字段在原字段位置显示 14px 弱化文案“暂无内容”,不能留下无文字的灰色块,也不能为单个空字段增加输入框外观。进入编辑态后,HeroUI `Input` / `TextArea` 使用“请输入{可见字段名}”描述预期内容;该提示只属于 `placeholder`,不得写入草稿、校验值或提交 payload。
- 禁止在 Modal 的 Overlay 内使用默认 `Card`(L1)表达需要可见边界的嵌套对象,因为 `overlay` 与 `surface` 同值会形成白底叠白底;也禁止为每个只读字段增加 Card,或用页面私有 `bg-*`、边框和阴影修补层级。
- 同一新增/编辑弹窗内的 Input、TextArea、DateField.Group、Autocomplete 和多选控件在浅色、深色中统一使用 HeroUI `secondary`,由 `theme.css` 解析为 L2,不得用页面私有背景、描边或阴影补色。
- 附件字段的当前值只在上方附件列表展示;单值选择框始终作为“选择/替换来源”的入口,避免同一文件名在附件行和 Autocomplete 触发器中重复显示。删除操作与文件名保持同一行垂直居中,多个附件按相同节奏连续排列。
- `4xl` 新增/编辑表单在桌面使用等宽双列:普通文本、数字、日期、单选和多选字段均各占一列并按数据顺序配对;只有问题描述、整改情况、备注等长文本 TextArea 跨两列。移动端统一回退单列,不得因字段为多选就默认占满整行。
- 提交约束不得作为常驻提示占据表单布局。只有用户点击确认且校验未通过时,才打开受控 HeroUI `AlertDialog` 展示一条简短可执行提示;关闭提示后保留原表单草稿,不触发接口、不修改原校验规则。
- 详情字段保持标签在上、内容在下;长文本、附件和关联表格可跨整行,移动端回退单列。
......@@ -144,19 +156,32 @@ Surface 用于表达页面内的包含关系,Overlay 用于表达浮动关系
## 8. 数据图表
### 8.1 品牌色与数据色板(2026-09-02)
- 鑫元唯一 Primary Brand Color 为 `#DA241A`。HeroUI `--accent`、`--focus` 与 `--rc-brand-primary` 在浅色和暗色上下文保持该值;交互 Hover 使用 `#C91F17`,不得被 HeroUI 默认蓝色主题覆盖。旧版 `primary-50` 至 `primary-950` 不属于 HeroUI v3 运行时 Token,不在页面中新增兼容层。
- 图表系列色与 UI 状态色分离,所有图表通过 `--xinyuan-chart-1` 至 `--xinyuan-chart-16` / `--rc-chart-*` 使用下列低饱和品牌色板。色板在浅色、暗色模式中保持完全一致,不使用绿色、荧光色或彩虹式相邻配色:
`#DA241A`、`#B83A32`、`#C95A52`、`#D47A70`、`#A65A45`、`#B87958`、`#C99772`、`#8A6F63`、`#9A8174`、`#6F6674`、`#807783`、`#938B96`、`#4F6380`、`#61758F`、`#74879A`、`#B0A4A0`
- 单系列统一使用 `--rc-chart-primary`(品牌红);同一指标的状态使用同色相深浅;多系列按稳定业务 key 确定性分配协调色,不按实时排序改色。语义映射为:风险 `--rc-chart-risk`、提醒 `--rc-chart-warning`、正向结果 `--rc-chart-positive`(灰蓝)、对比 `--rc-chart-comparison`、中性 `--rc-chart-neutral`。
- 数字卡片使用 `@heroui-pro/react/kpi`。
- 饼图、柱图和组合图分别使用 `@heroui-pro/react/pie-chart`、`bar-chart`、`composed-chart`。
- 图表 Card 主标题统一在 `Card.Header` 内左对齐,并与 `Card.Content` 的左边缘一致;不得因图表类型、单双列或有无数据而居中。分类层级、时间范围和统计口径等当前内容参数使用紧凑 `secondary` Autocomplete,位于 Header 右侧并与标题同一行;只有真正替换并列内容的视图切换使用 HeroUI Tabs。单位属于绘图区信息,不与主标题混排。
- 所有 HeroUI Pro 图表 Hover Tooltip 都使用官方 `ChartTooltipContent`,每个数据项必须显示 8px 颜色圆点,并与当前扇区、柱、线或图例使用完全相同的共享图表 Token。环图/单系列也不能省略圆点;进入官方 Tooltip 前统一清理 `none`、`transparent` 等不可见 stroke,禁止页面 CSS 手画 Tooltip 或用固定品牌色代替真实系列色。
- HeroUI Pro/Recharts 图表保留默认 `accessibilityLayer` 和键盘导航,但鼠标点击扇区、柱、折线或绘图区不得显示浏览器蓝色 Focus 方框。所有图表根使用共享 `rc-chart-interaction`:仅对 `:focus:not(:focus-visible)` 清除 outline,键盘 `:focus-visible` 使用 `var(--focus)` 两像素主题焦点环。不得用 `accessibilityLayer={false}`、全局 `*:focus` 或页面私有 Recharts 选择器消除焦点。
- ECharts 图表根保留键盘可访问性与 ARIA 文本,但鼠标点击扇区、柱、折线或绘图区不得显示浏览器蓝色 Focus 方框。所有图表根使用共享 `rc-chart-interaction`:仅对 `:focus:not(:focus-visible)` 清除 outline,键盘 `:focus-visible` 使用 `var(--focus)` 两像素主题焦点环。不得用全局 `*:focus` 或页面私有图表选择器消除焦点。
- Tooltip 使用 HeroUI Pro `ChartTooltipContent`,坐标轴、网格、标签和暗色样式由 HeroUI Pro CSS 与 `theme.css` 继承。
- 仅在 HeroUI Pro 未导出的底层图表能力上直接使用 Recharts primitive,例如 `Legend`、`Cell`;不得另建私有图表主题或 Tooltip 表面。
- HeroUI Pro 图表负责容器、坐标轴、网格、文字与 Tooltip 的深浅主题适配,但不提供或自动分配业务分类系列色;`Pie/Bar/Line` 的 `fill/stroke` 仍由应用传入。分类图表统一使用 `--rc-chart-1` 至 `--rc-chart-8`,色板由鑫元红、钴蓝、清透青蓝、靛蓝、深青蓝、清金、紫罗兰和蓝灰组成。
- 图表系列 Token 在浅色与深色模式下保持完全相同;切换主题时只允许 HeroUI 官方的 `background/surface/muted/overlay` 等结构色变化,不能让同一业务类别因主题切换改变身份颜色。禁止把 `success`、`danger`、`warning`、`foreground` 直接拼成分类色板,避免荧光绿、亮粉、纯黑、高饱和橙色以及砖红/棕灰等陈旧组合。
- 图表统一通过共享 `XinyuanEChart` 适配器使用 ECharts;不得在页面直接引入 Recharts 或另建私有图表主题、Tooltip 表面。Pro 内部依赖的 Recharts 不属于页面 API。
- HeroUI/HeroUI Pro 负责图表容器与语义表面,ECharts 通过共享 `XinyuanEChart` 适配器负责坐标轴、网格、文字与 Tooltip;业务系列色只从上述 `--rc-chart-*` Token 解析。图表系列 Token 在浅色与深色模式下保持完全相同;切换主题时不能让同一业务类别改变身份颜色。禁止把 `success`、`danger`、`warning`、`foreground` 直接拼成分类色板。
- 有明确业务语义的图表使用 `--rc-chart-primary`、`--rc-chart-risk`、`--rc-chart-warning`、`--rc-chart-positive`、`--rc-chart-comparison`、`--rc-chart-neutral`;UI 的 Alert/Chip 状态色与数据系列色分开管理。
- 同一指标跨页面、跨图表保持同色;普通分类按稳定数据 key 分配颜色,不按数值排序后的位置重新换色。同一张图必须先收集完整系列 key,通过共享分配器进行确定性冲突消解,8 个以内不得出现两个系列同色;超过 8 个时改用分组或其他视觉编码。单序列柱图默认使用 `--rc-chart-primary`,完成率折线使用 `--rc-chart-positive`,逾期/低质效使用 `--rc-chart-risk`。
- 图例说明文字统一使用扩展 Token `--rc-chart-legend-font-size: 0.75rem` 和 `--rc-chart-legend-line-height: 1rem`。通过 Recharts `Legend.labelStyle` 或目标图表库的公开 label slot 传入,不全局选择图表库内部 DOM;图例圆点保持 8px,文字颜色继续跟随对应系列。
- 图例说明文字统一使用扩展 Token `--rc-chart-legend-font-size: 0.75rem` 和 `--rc-chart-legend-line-height: 1rem`。通过 ECharts 的公开 `legend.textStyle` 配置传入,不全局选择图表库内部 DOM;图例圆点保持 8px,文字颜色继续跟随对应系列。
- 图表布局按“同一当前可见业务分组”内的同级图表数量决定,不按整个路由页、其他 Section、隐藏 Tab 或条件分支中的图表总数决定。宽桌面中,同一分组有两个及以上可配对图表时使用 `.rc-chart-grid` 的 `repeat(2, minmax(0, 1fr))` 等宽双列,不使用 `0.9fr/1.1fr` 等主观比例;分组当前只有一个可见图表时,该 Card 必须 `grid-column: 1 / -1` 通铺,不能因页面其他分组存在图表而留下右侧空白。小于 1280px 自动回退单列。
- 同一 `.rc-chart-grid` 行内的 HeroUI 图表 Card 必须保持等高:网格使用 `align-items: stretch`,卡片根节点及内容区占满行轨道;图表 Canvas 可以因数据类型使用不同的内在高度,但不得让同一行相邻模块出现高低不齐。单图通铺模块仍按自身内容高度展示。
- DataGrid 的长列表允许在表格内部纵向滚动,但不得锁死滚动链:滚动到最后一行后继续向下滚动应自然交给页面,使紧邻其后的 Pagination 可达;横向溢出仍由 DataGrid 内部处理。
- 图表实现统一通过 `XinyuanEChart` 使用 `echarts-for-react` 的 ECharts Canvas;禁止在页面直接引入 Recharts 或 HeroUI Pro 图表。共享适配器从主题 Token 读取扇区间隙色、扇区间隙宽度、扇区圆角和柱形圆角:饼图/环图固定 `padAngle: 1`、`--xinyuan-chart-sector-gap-width: 2px`、`borderRadius: var(--xinyuan-chart-sector-radius)`,扇区边界使用 `var(--xinyuan-chart-sector-gap)`;横向柱形使用 `[0, barRadius, barRadius, 0]`,纵向/堆叠柱形使用 `[barRadius, barRadius, 0, 0]`,其中 `barRadius` 默认 8px、扇区圆角同样为 8px。饼图必须使用截图基准的环外分类标签,标签按“分类名称 / 真实数量”两行展示,中心展示真实总数和指标名称;同时恢复 ECharts 官方 Legend,优先一行,空间不足时按条目数量尽量均分为两行,圆点与扇区颜色保持一致。每条两段式引导线不设置统一灰色,而是继承对应扇区的实际颜色,确保指向关系清晰。图表外层 HeroUI Card 使用 `--xinyuan-chart-card-radius: 24px`,Tooltip 使用 `--xinyuan-chart-tooltip-radius: 12px`;页面不得写入独立圆角常量,适配器会覆盖 series 级圆角以保证规范一致。
- 带底部 Legend 的类目柱状图必须为 X 轴标签和 Legend 预留独立空间:`grid.bottom` 至少 92px,Legend 保持在独立底部区域;类目标签使用 `interval: 0`、`hideOverlap: true`、固定可读宽度和 `overflow: break` 自动换行,不能让标签与 Legend 共用同一基线。长标签图表高度至少 360px,窄屏仍保持完整语义,不通过缩小字体或截断业务名称解决重叠。
- 交叉维度图表(例如问题来源 × 检查单位)使用 ECharts `heatmap` 时必须显式配置隐藏的 `visualMap`,将 0 映射到当前 `surface-secondary`、最大值映射到语义主色,并在单元格内显示真实数量;不得依赖缺省 visualMap 或只设置页面私有颜色,否则可能出现坐标轴存在但热力单元格不可见。
- 统计数据、统计口径、筛选范围和图表点击业务回调不得改变;视觉迁移不制造数据。
## 9. 验收
......
# 问题整改系统 UI/UX 全局审计
## 2026-09-02 审计项目管理基准同步
- 当前工作区为 `/Users/liuchenlong/Desktop/job/problem_rectification`,当前分支为 `develop`;本轮未改动接口、路由、权限、校验、业务数据或保存回调。
- 共享视觉基准为 `/Users/liuchenlong/Desktop/job/audit_project_manager` 当前 `develop` 实现。`AppShell` 固定浅色内容画布与深色 Sidebar,不再提供 Sun/Moon 主题切换、主题持久化或局部暗色入口。
- 已同步基准 `theme.css`、`tokens.css`、`shell.css`、Tailwind 全局加载顺序,以及 HeroUI/HeroUI Pro 的 Shell、Sidebar、Navbar、DataTable、Autocomplete、Modal 和 ECharts 适配器实现。
- ECharts 仍保留问题整改驾驶舱既有点击事件透传;图表业务数据、颜色映射入口、请求和统计口径不变。
- HeroUI Pro 运行时为 `1.0.0-beta.8`,根包和要求的 JS 子路径可导入,CSS 子路径通过 `require.resolve`/文件存在性验证;`echarts` 与 `echarts-for-react` 已安装。
## 2026-08-31 当前工作区复核
- 审计工作区:`/Users/liuchenlong/Desktop/job/problem_rectification`
- 当前分支:`codex/ui-ux-xinyuan-migration`
- 唯一视觉基准:`/Users/liuchenlong/Desktop/job/audit_project_manager` 的真实实现与四份 UI 文档。
- HeroUI Pro 已在当前项目根目录通过官方 setup 修复为 `1.0.0-beta.8`。根包、CSS export、Sidebar、Navbar、DataGrid、DropZone、ActionBar、EmptyState 子路径均可解析;未使用相邻项目 `node_modules`、`file:` 依赖或软链接。
- `npm ls @heroui-pro/react @heroui/react @heroui/styles react-aria-components @react-aria/utils` 显示单套 HeroUI/React Aria 运行链。
- 活动页面中的 Shell、筛选、DataGrid、Modal、Autocomplete、DropZone、通知中心和详情组件直接使用 HeroUI/HeroUI Pro;`src/catalyst-ui-kit` 仅为未引用历史源码,禁止进入运行时。
- 历史高优先级发现:`src/pages/problem_rectification/dashboard/index.jsx` 曾使用 HeroUI Pro `BarChart`/`PieChart`/`ComposedChart` 并直接引入 `recharts`。本轮已迁移为共享 `XinyuanEChart`(ECharts + `echarts-for-react`),页面仅保留业务数据、series 和点击回调。
- 当前主题与基准主题存在差异:目标主题已包含 `--xinyuan-chart-1..16`、ECharts Tooltip/轴/图例 Token 和固定深色 Sidebar Token;本轮需同步基准 `theme.css`/Tokens 后再验收浅深色 Portal。
审计结论:HeroUI Pro 依赖门槛已通过,可以开始 UI 迁移;在 ECharts 迁移、主题同步、构建、旧引用扫描和真实浏览器验收完成前,不宣称迁移完成。
> 当前组件库结论:HeroUI 与 HeroUI Pro 是项目唯一 UI 组件体系。文中 Catalyst/Headless UI 只描述迁移前问题和历史整改记录,不构成可用技术选项。
> 2026-08-19 复核:Modal 根面统一保持 HeroUI `overlay`;应用切换项、附件编辑器、流程详情摘要等需要独立边界的弹窗内对象统一使用 HeroUI `secondary`。普通详情查看态以一个 `Surface secondary` 承载整组无框标签/值,编辑态则由 `secondary` 控件直接位于 Overlay。已清理上述范围内的 L1-on-L1 默认 Card,不改变弹窗数据、回调或状态流转;深浅模式实际层级仍待浏览器验收。
> 2026-08-19 复核:Modal 根面统一保持 HeroUI `overlay`;应用切换项、附件编辑器、流程详情摘要等需要独立边界的弹窗内对象统一使用 HeroUI `secondary`。详情查看态不再增加整组灰色 Surface,字段按单列以无框标签/值展示;编辑态由 `secondary` 控件直接通栏位于 Overlay。已清理上述范围内的 L1-on-L1 默认 Card,不改变弹窗数据、回调或状态流转;深浅模式实际层级仍待浏览器验收。
审计日期:2026-08-17
目标分支:`codex/ui-ux-xinyuan-migration`
......@@ -87,16 +108,32 @@
- 登录验证码暗色表面与暗色填充态已改为 `theme.css`/`tokens.css` 语义变量和 `color-mix()` 派生值,不再形成硬编码白色孤岛。
- `src/catalyst-ui-kit` 作为待清理的未引用历史源码隔离保留,不属于可用组件目录;新功能、存量维护、重构、示例和测试均禁止引用、复制或扩展,且不得进入构建或依赖图。
- `RecordWorkbench`、问题清单、归档、驾驶舱明细、整改处理和整改流程均已改用 HeroUI Pro DataGrid;选择、分页和 ActionBar 由原生能力承载。
- 驾驶舱和整改分析已删除 ECharts 与私有图表主题,直接使用 HeroUI Pro KPI、PieChart、BarChart、ComposedChart 和 ChartTooltip;统计数据、筛选状态与重点问题点击回调保持原样。
- 驾驶舱和整改分析已统一使用共享 `XinyuanEChart` 与 ECharts Token 适配器;HeroUI Pro 继续负责 KPI、DataGrid、Sidebar、Navbar、DropZone、ActionBar 和 EmptyState。统计数据、筛选状态与重点问题点击回调保持原样。
- 应用切换、详情、创建、推送、延期、复制、删除和重点问题详情均已改用 HeroUI Modal compound API。
- 文件上传共享组件已改为 HeroUI-native `onFilesChange(files)`,不再伪造原生 `event.target.files`。
- HeroUI Pro 交付已从仓库内 `file:vendor` 制品迁移到官方 `hpsetup` 授权流程,并精确固定为 `1.0.0-beta.8`。本地与 CI 通过未入库的 `HEROUI_KEY` 获取完整制品,不再依赖开发者绝对路径、相邻项目或私人压缩包。
- 仓库以 npm 与 `package-lock.json` 为唯一包管理基线;已删除与当前依赖集不一致的旧版 `pnpm-lock.yaml`,避免不同开发机解析出两套 HeroUI/React Aria 运行时。
- HeroUI、HeroUI Pro 与 Adobe Spectrum 已统一去重到根目录的 `react-aria-components@1.20.0`;`@heroui/react@3.2.2`、`@heroui/styles@3.2.2` 和 `@react-aria/utils@3.34.1` 同样为单套有效运行链,`npm ls` 正常退出。
- `@headlessui/react`、`@heroicons/react`、`echarts` 和 `echarts-for-react` 已从项目运行依赖移除。
- `@headlessui/react` 与 `@heroicons/react` 不在项目运行依赖;`echarts` 与 `echarts-for-react` 是共享图表适配器的显式运行依赖,Pro 内部所需的 `recharts` 仅作为间接依赖保留,活动页面不直接引用。
- `@heroui-pro/react/css` 可解析,Sidebar、Navbar、DataGrid、DropZone、ActionBar、EmptyState、KPI、PieChart、BarChart 和 ComposedChart 子路径均已通过导出解析与文件存在性检查。
- `npm run build`、`git diff --check`、旧调色板/旧 UI/原生控件扫描均已通过;真实登录数据、Portal、Checkbox、固定列、暗色与响应式仍需浏览器验收。
- 2026-08-18 选择控件复核:活动业务代码中的 HeroUI `Select`、原生 `<select>` 和页面自建 `Popover + Button + ListBox` 选择器已归零。本地枚举、表单单选/多选、远程用户/引用、统计年份与分页条数统一进入 `XinyuanAutocomplete.jsx` 的 HeroUI Autocomplete compound 结构;原 ID、数组、查询、防抖、清空和业务回调协议保持不变。
- 2026-08-31 附件字段复核:材料中心/本地上传切换已对齐审计项目管理基准,使用 HeroUI `primary` Tabs;Tabs 根、Panel 和内容容器保持全宽,审计材料 Autocomplete 显式 `w-full`,不再出现 Tab 轨道收缩导致选择框无法通栏的问题。
- 2026-08-31 详情预览复核:查看态字段采用 HeroUI `Surface secondary` 承载双列标签/值网格,长内容跨两列;编辑态移除查看 Surface,由 `secondary` 表单控件直接位于 Overlay,业务字段、权限和保存回调不变。
- 2026-09-02 图表复核:活动驾驶舱与整改分析的所有图表均通过共享 `XinyuanEChart`(ECharts Canvas)渲染;饼图/环图由适配器统一注入 `padAngle: 1`、2px 主题 Surface 扇区间隙和 8px 扇区圆角,采用环外“分类 / 数量”两行标签、中心真实总数与指标名称,并恢复官方 Legend:能容纳时一行展示,空间不足时按条目数量尽量均分两行,图例圆点与扇区颜色一致;ECharts 引导线继承对应扇区颜色。横向/纵向/堆叠柱形分别使用主题 8px 圆角方向,图表 Card 使用 `--xinyuan-chart-card-radius`(24px),Tooltip 使用 `--xinyuan-chart-tooltip-radius`(12px)。适配器同时覆盖 series 与数据项级圆角,避免页面配置破坏规范。未发现活动页面 Recharts 或 HeroUI Pro 图表直接入口,统计数据与点击回调未改变。
- 2026-09-02 图表表达复核:问题来源对比改为“问题来源 × 检查单位”矩阵热力图,每个单元格直接显示真实数量,使用 ECharts `visualMap` 将零值映射到当前 `surface-secondary`、高值映射到鑫元主色,避免单元格空白或多系列横向柱形挤叠;纵横轴长标签均启用完整换行与移动端旋转,图表高度随来源数量增长且不产生页面横向溢出。部门整改责任矩阵继续为底部类目标签与 Legend 预留空间。
- 2026-09-02 应用切换复核:按最新确认的截图基准移除弹窗可见取消/关闭图标,搜索栏恢复完整宽度;HeroUI `Modal.Backdrop` 使用 `isDismissable`,遮罩点击与 Escape 均可关闭,动态分组、搜索和应用跳转不变。
- 2026-09-02 选择控件复核:允许清空的单选、多选、用户、引用、筛选和新增字段均在 HeroUI `Autocomplete.Trigger` 内启用官方 `ClearButton`,点击后沿原受控回调写回空值/空集合;未改变选项 ID、查询、校验或提交协议。
- 2026-09-02 品牌与图表专项复核:HeroUI `--accent` / `--focus` 在浅色、暗色上下文固定为 `#DA241A`,Hover 使用 `#C91F17`;ECharts 系列与 KPI 图标改用 16 色低饱和品牌红、暖棕、灰紫、灰蓝色板,移除绿色、荧光色和彩虹式相邻配色。图表变量在进入 Canvas 前由共享适配器解析为具体颜色,业务数据、布局和统计口径未改变。
- 2026-09-02 应用切换 Logo 复核:移除卡片内 `Surface tertiary` 灰色 Logo 背景,改为透明 HeroUI `Avatar soft` 直接承载鑫元 Logo,固定 36×36px;应用名称起点、当前状态、分组和跳转行为未改变。
- 项目未复制文档示例中的简化 Autocomplete 全局 CSS;实际外观继续由已加载的 `@heroui/styles/dist/components/autocomplete.css` 与权威 `theme.css` 驱动,避免覆盖官方 Hover、Focus、过滤、空状态、动画和暗色行为。
- 2026-08-18 页面几何复核发现 Shell 仅统一了外层 padding,但活动页根节点混用 `space-y-6/8`,模块网格混用 `gap-4/6`,列表标题到 DataGrid 仅 12px,导致同级内容无法形成稳定对齐和纵向节奏。
- 已建立仅负责布局的响应式契约:Navbar Header、折叠按钮、主题按钮和页面内容共用对称 gutter,桌面左右 48px、平板左右 32px、手机左右 16px。页面模块/模块网格/列表内部间距为 32/24/16px,移动端纵向节奏收敛为 24/16px。活动页已接入共享语义类,未修改 HeroUI 组件外观或业务回调;真实浏览器截图验收仍待可用浏览器连接。
## 2026-08-31 最终复核
- 当前分支 `codex/ui-ux-xinyuan-migration` 的开发服务为 `http://localhost:8001/problem_rectification_new/`;登录页、固定深色 Sidebar、Navbar 通知与主题动作、应用切换、驾驶舱、整改分析和问题清单路由均返回 HTTP 200。
- 使用真实浏览器在 1440x900、1280x720、768x1024、390x844 视口复核登录壳、应用切换弹窗、驾驶舱和整改分析;页面级横向溢出为 0,应用切换桌面双列、移动单列,弹窗宽度和内部滚动符合合同。代表性数据夹具仅用于浏览器验收,未写入源码或业务接口。
- 代表性数据下 ECharts Canvas 已渲染,图表 ARIA 文本、Tooltip 入口和深浅主题切换可用;主题切换通过 `xinyuan_color_theme` 持久化。浏览器记录 0 个运行时错误;HeroUI 在关闭态复合弹层上仍输出 4 条开发环境可访问性警告,生产构建不受影响。
- `npm run build`、`git diff --check`、HeroUI Pro 根包/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` 显示单套兼容运行时。
- 活动源码(排除历史隔离目录)未发现 Catalyst/Headless UI/Recharts 直接引用、原生 `select/date/file` 控件或旧色 Utility 类。审计脚本对 `src/catalyst-ui-kit` 历史源码、官方制品来源和动态 `ItemCard` variant 的提醒不代表活动页面仍在使用旧组件;该目录继续隔离且未进入构建。
......@@ -27,6 +27,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.0.0",
......@@ -37,7 +39,7 @@
"react-dom": "^19.2.7",
"react-markdown": ">=10.1.0",
"react-resizable-panels": ">=4.10.0",
"recharts": ">=2.0.0",
"recharts": "^3.10.1",
"remark-breaks": ">=4.0.0",
"remark-gfm": ">=4.0.0",
"shiki": ">=4.0.0",
......@@ -2453,18 +2455,24 @@
}
},
"node_modules/@floating-ui/core": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-0.6.2.tgz",
"integrity": "sha512-jktYRmZwmau63adUG3GKOAVCofBXkk55S/zQ94XOorAHhwqFIOFAy1rSp2N0Wp6/tGbe9V3u/ExlGZypyY17rg==",
"license": "MIT"
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"@floating-ui/utils": "^0.2.12"
}
},
"node_modules/@floating-ui/dom": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-0.4.5.tgz",
"integrity": "sha512-b+prvQgJt8pieaKYMSJBXHxX/DYwdLsAWxKYqnO5dO2V4oo/TYBZJAUQCVNjTWWsrs6o4VDrNcP9+E70HAhJdw==",
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz",
"integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
"license": "MIT",
"optional": true,
"dependencies": {
"@floating-ui/core": "^0.6.2"
"@floating-ui/core": "^1.8.0",
"@floating-ui/utils": "^0.2.12"
}
},
"node_modules/@floating-ui/react-dom": {
......@@ -2494,6 +2502,21 @@
"use-isomorphic-layout-effect": "^1.1.1"
}
},
"node_modules/@floating-ui/react-dom/node_modules/@floating-ui/core": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-0.6.2.tgz",
"integrity": "sha512-jktYRmZwmau63adUG3GKOAVCofBXkk55S/zQ94XOorAHhwqFIOFAy1rSp2N0Wp6/tGbe9V3u/ExlGZypyY17rg==",
"license": "MIT"
},
"node_modules/@floating-ui/react-dom/node_modules/@floating-ui/dom": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-0.4.5.tgz",
"integrity": "sha512-b+prvQgJt8pieaKYMSJBXHxX/DYwdLsAWxKYqnO5dO2V4oo/TYBZJAUQCVNjTWWsrs6o4VDrNcP9+E70HAhJdw==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^0.6.2"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
......@@ -4117,21 +4140,15 @@
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.1.17",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.17.tgz",
"integrity": "sha512-8Vu44Y0MuMBlTQz/jQ8HEMYNq/bBqk87MnBwYR5mC8AthfhEXidZ5aT/oA/CUqboa8THKltnD9L3xyqhU/Sy1Q==",
"version": "11.1.18",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz",
"integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@reduxjs/toolkit/node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/@shikijs/core": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.4.3.tgz",
......@@ -4976,27 +4993,6 @@
"@tiptap/pm": "3.30.1"
}
},
"node_modules/@tiptap/extension-bubble-menu/node_modules/@floating-ui/core": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"@floating-ui/utils": "^0.2.12"
}
},
"node_modules/@tiptap/extension-bubble-menu/node_modules/@floating-ui/dom": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz",
"integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
"license": "MIT",
"optional": true,
"dependencies": {
"@floating-ui/core": "^1.8.0",
"@floating-ui/utils": "^0.2.12"
}
},
"node_modules/@tiptap/extension-bullet-list": {
"version": "3.30.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.30.1.tgz",
......@@ -6516,6 +6512,34 @@
"node": ">=8"
}
},
"node_modules/@umijs/bundler-utoopack/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
"integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.6",
"picocolors": "^1.0.0",
"source-map-js": "^1.0.2"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/@umijs/bundler-utoopack/node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
......@@ -7855,6 +7879,18 @@
"dva": "^2.5.0-0"
}
},
"node_modules/@umijs/plugins/node_modules/dva-loading": {
"version": "3.0.25",
"resolved": "https://registry.npmjs.org/dva-loading/-/dva-loading-3.0.25.tgz",
"integrity": "sha512-RYbpSjvPj2NF+3YSc9QkDpwM1wO8uRAVfU6VGSgIxA2kA/Xd6yoY+VQM7GDRnvCvT579pESeBZBXk+8WKWOdDA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.0.0"
},
"peerDependencies": {
"dva-core": "^1.1.0 || ^1.5.0-0 || ^1.6.0-0"
}
},
"node_modules/@umijs/plugins/node_modules/path-to-regexp": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz",
......@@ -7887,6 +7923,15 @@
"react": "^16.3.0"
}
},
"node_modules/@umijs/plugins/node_modules/redux": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz",
"integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.9.2"
}
},
"node_modules/@umijs/plugins/node_modules/styled-components": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.1.1.tgz",
......@@ -11870,18 +11915,6 @@
"stream-shift": "^1.0.2"
}
},
"node_modules/dva-loading": {
"version": "3.0.25",
"resolved": "https://registry.npmjs.org/dva-loading/-/dva-loading-3.0.25.tgz",
"integrity": "sha512-RYbpSjvPj2NF+3YSc9QkDpwM1wO8uRAVfU6VGSgIxA2kA/Xd6yoY+VQM7GDRnvCvT579pESeBZBXk+8WKWOdDA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.0.0"
},
"peerDependencies": {
"dva-core": "^1.1.0 || ^1.5.0-0 || ^1.6.0-0"
}
},
"node_modules/earcut": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz",
......@@ -11894,6 +11927,36 @@
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"license": "MIT"
},
"node_modules/echarts": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz",
"integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "6.1.0"
}
},
"node_modules/echarts-for-react": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/echarts-for-react/-/echarts-for-react-3.0.6.tgz",
"integrity": "sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"size-sensor": "^1.0.1"
},
"peerDependencies": {
"echarts": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0",
"react": "^15.0.0 || >=16.0.0"
}
},
"node_modules/echarts/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
......@@ -21508,9 +21571,9 @@
}
},
"node_modules/recharts/node_modules/immer": {
"version": "11.1.17",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.17.tgz",
"integrity": "sha512-8Vu44Y0MuMBlTQz/jQ8HEMYNq/bBqk87MnBwYR5mC8AthfhEXidZ5aT/oA/CUqboa8THKltnD9L3xyqhU/Sy1Q==",
"version": "11.1.18",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz",
"integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
......@@ -21540,13 +21603,10 @@
}
},
"node_modules/redux": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz",
"integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.9.2"
}
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-saga": {
"version": "0.16.2",
......@@ -22613,6 +22673,12 @@
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
"license": "MIT"
},
"node_modules/size-sensor": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/size-sensor/-/size-sensor-1.0.3.tgz",
"integrity": "sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==",
"license": "ISC"
},
"node_modules/slash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
......@@ -23683,9 +23749,9 @@
}
},
"node_modules/terser": {
"version": "5.47.1",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.47.1.tgz",
"integrity": "sha512-tPbLXTI6ohPASb/1YViL428oEHu6/qv1OxqYnfaonVCFHqx4+wCd95pHrQWsL5X4pl90CTyW9piSAsS2L0VoMw==",
"version": "5.51.2",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.51.2.tgz",
"integrity": "sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==",
"license": "BSD-2-Clause",
"dependencies": {
"@jridgewell/source-map": "^0.3.3",
......@@ -25459,6 +25525,21 @@
"zod": "^3.18.0"
}
},
"node_modules/zrender": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz",
"integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==",
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "2.3.0"
}
},
"node_modules/zrender/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/zwitch": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
......
......@@ -29,6 +29,8 @@
"clsx": "^2.1.1",
"crypto-js": "^4.2.0",
"dayjs": "^1.11.20",
"echarts": "^6.1.0",
"echarts-for-react": "^3.0.6",
"embla-carousel": ">=8.6.0",
"embla-carousel-react": ">=8.6.0",
"maplibre-gl": ">=6.0.0",
......@@ -39,7 +41,7 @@
"react-dom": "^19.2.7",
"react-markdown": ">=10.1.0",
"react-resizable-panels": ">=4.10.0",
"recharts": ">=2.0.0",
"recharts": "^3.10.1",
"remark-breaks": ">=4.0.0",
"remark-gfm": ">=4.0.0",
"shiki": ">=4.0.0",
......
import '@/global.less';
import '@/tailwind.css';
import { defineApp } from 'umi';
import { APPLICATION_BASE_PATH, THEME_STORAGE_KEY } from '@/config/application';
import {
APPLICATION_BASE_PATH,
APPLICATION_NAME,
} from '@/config/application';
if (typeof document !== 'undefined') {
const storedTheme = window.localStorage?.getItem?.(THEME_STORAGE_KEY);
const theme = storedTheme === 'dark' ? 'dark' : 'light';
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.documentElement.classList.remove('dark');
document.documentElement.classList.add('light');
document.documentElement.dataset.theme = 'light';
document.documentElement.style.colorScheme = 'light';
}
const LEGACY_FLOW_PATH = '/self_inspection_form';
......@@ -36,7 +36,7 @@ if (typeof window !== 'undefined') {
export async function getInitialState() {
return {
name: 'project_manager_xy',
name: APPLICATION_NAME,
};
}
......
import BankOutlined from '@ant-design/icons/BankOutlined';
import BarChartOutlined from '@ant-design/icons/BarChartOutlined';
import AppstoreOutlined from '@ant-design/icons/AppstoreOutlined';
import BellOutlined from '@ant-design/icons/BellOutlined';
import AppstoreOutlined from '@ant-design/icons/AppstoreOutlined';
import CalendarOutlined from '@ant-design/icons/CalendarOutlined';
import CheckOutlined from '@ant-design/icons/CheckOutlined';
import CheckCircleOutlined from '@ant-design/icons/CheckCircleOutlined';
......@@ -18,6 +18,8 @@ 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 FullscreenExitOutlined from '@ant-design/icons/FullscreenExitOutlined';
import FullscreenOutlined from '@ant-design/icons/FullscreenOutlined';
import GroupOutlined from '@ant-design/icons/GroupOutlined';
import PaperClipOutlined from '@ant-design/icons/PaperClipOutlined';
import LeftOutlined from '@ant-design/icons/LeftOutlined';
......@@ -26,7 +28,6 @@ import LockOutlined from '@ant-design/icons/LockOutlined';
import InboxOutlined from '@ant-design/icons/InboxOutlined';
import InfoCircleOutlined from '@ant-design/icons/InfoCircleOutlined';
import MoreOutlined from '@ant-design/icons/MoreOutlined';
import MoonOutlined from '@ant-design/icons/MoonOutlined';
import OrderedListOutlined from '@ant-design/icons/OrderedListOutlined';
import PieChartOutlined from '@ant-design/icons/PieChartOutlined';
import PlusOutlined from '@ant-design/icons/PlusOutlined';
......@@ -36,10 +37,10 @@ import RightOutlined from '@ant-design/icons/RightOutlined';
import RiseOutlined from '@ant-design/icons/RiseOutlined';
import SearchOutlined from '@ant-design/icons/SearchOutlined';
import SendOutlined from '@ant-design/icons/SendOutlined';
import SunOutlined from '@ant-design/icons/SunOutlined';
import TeamOutlined from '@ant-design/icons/TeamOutlined';
import UserOutlined from '@ant-design/icons/UserOutlined';
import UploadOutlined from '@ant-design/icons/UploadOutlined';
import UpOutlined from '@ant-design/icons/UpOutlined';
import UnlockOutlined from '@ant-design/icons/UnlockOutlined';
import WarningOutlined from '@ant-design/icons/WarningOutlined';
......@@ -80,6 +81,8 @@ export const OpenExternal = withSize(ExportOutlined);
export const FileText = withSize(FileTextOutlined);
export const Info = withSize(InfoCircleOutlined);
export const Folder = withSize(FolderOpenOutlined);
export const Fullscreen = withSize(FullscreenOutlined);
export const FullscreenExit = withSize(FullscreenExitOutlined);
export const Group = withSize(GroupOutlined);
export const LogOut = withSize(LogoutOutlined);
export const Lock = withSize(LockOutlined);
......@@ -112,11 +115,9 @@ export function SidebarPanel({ size = 20, style, ...props }) {
);
}
export const More = withSize(MoreOutlined);
export const Moon = withSize(MoonOutlined);
export const Project = withSize(ProjectOutlined);
export const Search = withSize(SearchOutlined);
export const Send = withSize(SendOutlined);
export const Sun = withSize(SunOutlined);
export const Team = withSize(TeamOutlined);
export const User = withSize(UserOutlined);
export const Upload = withSize(UploadOutlined);
......@@ -124,6 +125,8 @@ export const Unlock = withSize(UnlockOutlined);
export const Back = withSize(LeftOutlined);
export const ArrowDownTrayIcon = withSize(DownloadOutlined);
export const ArrowDownIcon = ChevronDown;
export const ArrowUpIcon = withSize(UpOutlined);
export const ArrowPathIcon = withSize(ReloadOutlined);
export const ArrowTrendingUpIcon = withSize(RiseOutlined);
export const BanknotesIcon = withSize(DollarOutlined);
......@@ -144,4 +147,5 @@ export const LockOpenIcon = Unlock;
export const MagnifyingGlassIcon = Search;
export const PaperClipIcon = withSize(PaperClipOutlined);
export const PlusIcon = withSize(PlusOutlined);
export const TableCellsIcon = withSize(OrderedListOutlined);
export const UserGroupIcon = Team;
import { useLayoutEffect, useState } from 'react';
import { Sidebar } from '@heroui-pro/react/sidebar';
import { Toast } from '@heroui/react';
import { THEME_STORAGE_KEY } from '@/config/application';
import { history } from 'umi';
import DashboardNavbar from './DashboardNavbar';
import DashboardSidebar from './DashboardSidebar';
function getInitialTheme() {
if (typeof window === 'undefined') return 'light';
try {
return window.localStorage.getItem(THEME_STORAGE_KEY) === 'dark'
? 'dark'
: 'light';
} catch {
return 'light';
}
}
export default function AppShell({
children,
notificationCount,
......@@ -27,25 +13,9 @@ export default function AppShell({
title,
...sidebarProps
}) {
const [theme, setTheme] = useState(getInitialTheme);
useLayoutEffect(() => {
const root = document.documentElement;
root.classList.toggle('dark', theme === 'dark');
root.classList.toggle('light', theme === 'light');
root.dataset.theme = theme;
root.style.colorScheme = theme;
try {
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
} catch {
// The selected theme still applies for this session when storage is unavailable.
}
}, [theme]);
return (
<Sidebar.Provider
className={`template-dashboard-shell rc-app-shell ${theme}`}
className="template-dashboard-shell rc-app-shell light"
collapsible="offcanvas"
navigate={(href) => history.push(href)}
open={open}
......@@ -55,12 +25,8 @@ export default function AppShell({
<Sidebar.Main className="app-main-glass rc-app-main">
<DashboardNavbar
notificationCount={notificationCount}
onOpenNotifications={
notificationPath ? () => history.push(notificationPath) : undefined
}
theme={theme}
onOpenNotifications={notificationPath ? () => history.push(notificationPath) : undefined}
title={title}
onThemeChange={setTheme}
/>
<div className="app-shell-content-scroll min-h-0 flex-1 overflow-y-auto">
<div className="rc-app-page">{children}</div>
......
import { Navbar } from '@heroui-pro/react/navbar';
import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar';
import { Badge, Button, Tooltip } from '@heroui/react';
import { Bell, Moon, SidebarPanel, Sun } from './AppIcons';
import { Bell, SidebarPanel } from './AppIcons';
export default function DashboardNavbar({
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);
return (
......@@ -46,29 +42,13 @@ export default function DashboardNavbar({
color="danger"
size="sm"
>
{normalizedNotificationCount > 99
? '99+'
: normalizedNotificationCount}
{normalizedNotificationCount > 99 ? '99+' : normalizedNotificationCount}
</Badge>
) : null}
</Badge.Anchor>
<Tooltip.Content>通知消息</Tooltip.Content>
</Tooltip>
) : null}
<Tooltip delay={0}>
<Button
isIconOnly
aria-label={themeLabel}
aria-pressed={isDark}
className="app-navbar-icon-button rc-navbar-icon-button"
size="sm"
variant="ghost"
onPress={() => onThemeChange(isDark ? 'light' : 'dark')}
>
{isDark ? <Sun size={16} /> : <Moon size={16} />}
</Button>
<Tooltip.Content>{themeLabel}</Tooltip.Content>
</Tooltip>
</div>
</Navbar.Header>
</Navbar>
......
import xyLogoRed from '@/assets/xy-logo-red.png';
import xyLogoWhite from '@/assets/xy-logo-white.png';
import { APPLICATION_DISPLAY_NAME } from '@/config/application';
import { Sidebar } from '@heroui-pro/react/sidebar';
import { Avatar, Button, Dropdown, Label, Tooltip } from '@heroui/react';
import { Heading } from 'react-aria-components';
import { AppGrid, LogOut } from './AppIcons';
import { Sheet } from '@heroui-pro/react';
import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar';
import { Avatar, Button, Disclosure, Dropdown, Label } from '@heroui/react';
import { AppGrid, ChevronDown, LogOut } from './AppIcons';
function getUserDisplay(userInfo) {
const displayName = userInfo?.display_name || userInfo?.username || '当前用户';
......@@ -64,9 +63,56 @@ function SidebarContents({
onLogout,
onOpenApplicationSwitcher,
pathname,
theme,
userInfo,
}) {
const renderMenu = (group) => (
<Sidebar.Menu aria-label={group.title}>
{group.items.map((item) => {
const Icon = item.icon;
return (
<Sidebar.MenuItem
href={item.path}
isCurrent={pathname === item.path}
key={item.path}
textValue={item.name}
tooltip={item.name}
>
<Sidebar.MenuIcon>
<Icon size={16} />
</Sidebar.MenuIcon>
<Sidebar.MenuLabel>{item.name}</Sidebar.MenuLabel>
</Sidebar.MenuItem>
);
})}
</Sidebar.Menu>
);
const renderGroup = (group) => {
const hasCurrentItem = group.items.some((item) => pathname === item.path);
const GroupIcon = group.icon;
return (
<Sidebar.Group key={group.title}>
<Disclosure defaultExpanded={hasCurrentItem}>
<Disclosure.Heading>
<Sidebar.GroupLabel>
<Disclosure.Trigger className="flex w-full min-w-0 items-center gap-2 text-left">
{GroupIcon ? <GroupIcon size={16} /> : null}
<span className="min-w-0 flex-1 truncate">{group.title}</span>
<Disclosure.Indicator>
<ChevronDown size={14} />
</Disclosure.Indicator>
</Disclosure.Trigger>
</Sidebar.GroupLabel>
</Disclosure.Heading>
<Disclosure.Content>
<Disclosure.Body>{renderMenu(group)}</Disclosure.Body>
</Disclosure.Content>
</Disclosure>
</Sidebar.Group>
);
};
return (
<>
<Sidebar.Header>
......@@ -74,12 +120,7 @@ function SidebarContents({
<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"
className="rc-sidebar-logo"
src={xyLogoWhite}
/>
</div>
......@@ -95,7 +136,6 @@ function SidebarContents({
className="app-sidebar-app-switcher rc-sidebar-app-switcher"
data-sidebar="label"
>
<Tooltip delay={0}>
<Button
isIconOnly
aria-label="切换应用"
......@@ -106,43 +146,15 @@ function SidebarContents({
>
<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;
return (
<Sidebar.MenuItem
href={item.path}
isCurrent={pathname === item.path}
key={item.path}
textValue={item.name}
tooltip={item.name}
>
<Sidebar.MenuIcon>
<Icon size={16} />
</Sidebar.MenuIcon>
<Sidebar.MenuLabel>{item.name}</Sidebar.MenuLabel>
{Number(item.count) > 0 ? (
<Sidebar.MenuChip>
{Number(item.count) > 99 ? '99+' : Number(item.count)}
</Sidebar.MenuChip>
) : null}
</Sidebar.MenuItem>
);
})}
</Sidebar.Menu>
</Sidebar.Group>
group.showLabel === false ? (
<Sidebar.Group key={group.title}>{renderMenu(group)}</Sidebar.Group>
) : renderGroup(group)
))}
</Sidebar.Content>
......@@ -153,18 +165,45 @@ function SidebarContents({
);
}
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={`${APPLICATION_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">
<Heading slot="title" className="sr-only">
{APPLICATION_DISPLAY_NAME}导航
</Heading>
<SidebarContents {...props} />
</Sidebar.Mobile>
<AccessibleMobileSidebar {...props} />
</>
);
}
import { Close } from '@/components/AppIcons';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import { ActionBar } from '@heroui-pro/react/action-bar';
import { DataGrid } from '@heroui-pro/react/data-grid';
import { EmptyState } from '@heroui-pro/react/empty-state';
......@@ -8,10 +9,29 @@ import {
Pagination,
Separator,
Spinner,
Surface,
} from '@heroui/react';
import { Tooltip } from '@heroui/react';
import { useLayoutEffect, useRef, useState } from 'react';
const DATA_TABLE_LONG_TEXT_TYPES = new Set([
'long_text',
'rich_text',
'object',
'form',
'subject',
]);
const DATA_TABLE_LONG_TEXT_LABEL_PATTERN =
/需求明细|需求内容|材料清单|工作内容|检查内容|审计范围|整改情况|问题简述|整改措施|问题描述|具体问题|整改要求|描述|措施|原因|意见|备注|说明/;
export function isDataTableLongTextField(fieldDef, label = '') {
return DATA_TABLE_LONG_TEXT_TYPES.has(
String(fieldDef?.type || '').toLowerCase(),
) || DATA_TABLE_LONG_TEXT_LABEL_PATTERN.test(
String(label || fieldDef?.label || ''),
);
}
function normalizeDataTableEmptyTitle(title) {
const text = String(title || '').trim();
if (!text) {
......@@ -25,15 +45,30 @@ function normalizeDataTableEmptyTitle(title) {
return title;
}
export function DataTableEmptyState({ title = '暂无数据' }) {
export function EmptyStateSurface({ children, className = '' }) {
return (
<Surface
className={['w-full min-w-0 rounded-2xl p-4', className].filter(Boolean).join(' ')}
variant="secondary"
>
{children}
</Surface>
);
}
export function DataTableEmptyState({ title = '暂无数据', size = 'md' }) {
const resolvedTitle = normalizeDataTableEmptyTitle(title);
return (
<EmptyState size="md">
<EmptyStateSurface>
<EmptyState size={size}>
<EmptyState.Header>
<EmptyState.Title>{resolvedTitle}</EmptyState.Title>
<EmptyState.Description className="rc-empty-state-message">
{resolvedTitle}
</EmptyState.Description>
</EmptyState.Header>
</EmptyState>
</EmptyStateSurface>
);
}
......@@ -77,15 +112,6 @@ function resolveColumnWidth(width) {
return undefined;
}
function resolveColumnMinWidth(column) {
const isActions = column.isActions || column.key === 'actions';
const configuredWidth = resolveColumnWidth(column.width);
const numericWidth =
typeof configuredWidth === 'number' ? configuredWidth : undefined;
return Number(column.minWidth) || numericWidth || (isActions ? 104 : 128);
}
export function DataTable({
ariaLabel = '数据表格',
columns,
......@@ -95,6 +121,7 @@ export function DataTable({
selectedRowKeys,
onSelectedRowKeysChange,
emptyTitle = '暂无数据',
emptyStateSize = 'md',
className = '',
isWide,
disabledRowKeys,
......@@ -102,53 +129,69 @@ export function DataTable({
sortDescriptor,
onSortChange,
renderSelectionActions,
allowsColumnResize = true,
}) {
const containerRef = useRef(null);
const [containerWidth, setContainerWidth] = useState(0);
const selectionEnabled = Boolean(selectedRowKeys && onSelectedRowKeysChange);
const selectedKeys = selectionEnabled ? new Set(selectedRowKeys) : undefined;
const minimumContentWidth = columns.reduce(
(total, column) => total + resolveColumnMinWidth(column),
selectionEnabled ? 40 : 0,
const gridViewportRef = useRef(null);
const [hasHorizontalOverflow, setHasHorizontalOverflow] = useState(false);
const firstBusinessColumnIndex = columns.findIndex(
(column) =>
!column.isSelection &&
!column.isActions &&
column.key !== 'actions',
);
const measuredWide =
containerWidth > 0
? minimumContentWidth > containerWidth + 1
: columns.length > 6;
const wide = isWide === true || measuredWide;
useLayoutEffect(() => {
const container = containerRef.current;
if (!container) {
if (typeof isWide === 'boolean') {
setHasHorizontalOverflow(isWide);
return undefined;
}
const updateWidth = () => setContainerWidth(container.clientWidth);
updateWidth();
const viewport = gridViewportRef.current;
if (!viewport) {
return undefined;
}
const updateOverflow = () => {
const scrollContainer =
viewport.querySelector('.table__resizable-container') ||
viewport.querySelector('.data-table-scroll');
if (!scrollContainer) return;
setHasHorizontalOverflow(
scrollContainer.scrollWidth > scrollContainer.clientWidth + 1,
);
};
updateOverflow();
if (typeof ResizeObserver === 'undefined') {
return undefined;
}
const observer = new ResizeObserver(updateWidth);
observer.observe(container);
return () => observer.disconnect();
}, []);
const firstBusinessColumnIndex = columns.findIndex(
(column) =>
!column.isSelection &&
!column.isActions &&
column.key !== 'actions',
);
const resizeObserver = new ResizeObserver(updateOverflow);
resizeObserver.observe(viewport);
const scrollContainer =
viewport.querySelector('.table__resizable-container') ||
viewport.querySelector('.data-table-scroll');
if (scrollContainer) resizeObserver.observe(scrollContainer);
return () => resizeObserver.disconnect();
}, [columns, isWide, rows]);
const wide = typeof isWide === 'boolean' ? isWide : hasHorizontalOverflow;
const gridColumns = columns.map((column, index) => {
const isActions = column.isActions || column.key === 'actions';
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' : undefined)
? column.pin || (isActions ? 'end' : isPrimary ? 'start' : undefined)
: undefined;
const minWidth = resolveColumnMinWidth(column);
const minWidth =
column.minWidth ||
numericWidth ||
(isActions ? 104 : pinned ? 220 : 128);
const width =
!wide && isPrimary
? typeof configuredWidth === 'string' && configuredWidth.endsWith('fr')
......@@ -170,20 +213,27 @@ export function DataTable({
return (
<DataTableText
text={value}
showTooltip={false}
className={
isPrimary ? 'font-medium' : ''
}
/>
);
}
return value;
return isActions ? value : (
<div className="min-w-0 max-w-full">
{value}
</div>
);
},
isRowHeader: index === 0,
isRowHeader: index === firstBusinessColumnIndex,
allowsSorting: column.sortable === true,
headerClassName: column.headerClassName,
cellClassName: column.cellClassName,
cellClassName: [
'min-w-0',
column.cellClassName,
].filter(Boolean).join(' '),
allowsResizing: true,
maxWidth: resolveColumnWidth(column.maxWidth),
minWidth,
width,
pinned,
......@@ -210,11 +260,8 @@ export function DataTable({
};
return (
<div
ref={containerRef}
className={['relative min-w-0 w-full', className].filter(Boolean).join(' ')}
>
<div className="relative min-w-0 w-full">
<div className={['relative min-w-0 w-full', className].filter(Boolean).join(' ')}>
<div ref={gridViewportRef} className="relative min-w-0 w-full">
{isLoading ? (
<div
className="absolute inset-0 z-10 grid place-items-center"
......@@ -225,7 +272,7 @@ export function DataTable({
) : null}
<DataGrid
showSelectionCheckboxes={selectionEnabled}
allowsColumnResize
allowsColumnResize={allowsColumnResize}
aria-label={ariaLabel}
columns={gridColumns}
contentClassName={wide ? 'min-w-max' : 'min-w-full'}
......@@ -233,7 +280,11 @@ export function DataTable({
disabledKeys={disabledRowKeys}
getRowId={(row) => resolveRowKey(row, rows.indexOf(row), getRowKey)}
renderEmptyState={() =>
isLoading ? <DataTableLoadingState /> : <DataTableEmptyState title={emptyTitle} />
isLoading ? (
<DataTableLoadingState />
) : (
<DataTableEmptyState title={emptyTitle} size={emptyStateSize} />
)
}
scrollContainerClassName="data-table-scroll"
selectedKeys={selectedKeys}
......@@ -274,6 +325,7 @@ export function DataTableActionBar({
return (
<ActionBar
aria-label={ariaLabel}
className="rc-data-grid-action-bar"
isOpen={selectedCount > 0}
>
<ActionBar.Prefix>
......@@ -307,37 +359,39 @@ export function DataTableText({
maxLines = 1,
}) {
const fullText = String(text ?? children ?? '').trim();
const textClassName =
maxLines === 2
? 'w-full min-w-0 max-w-full overflow-hidden whitespace-normal break-words [display:-webkit-box] [-webkit-box-orient:vertical] [-webkit-line-clamp:2] [overflow-wrap:anywhere]'
: 'block w-full min-w-0 max-w-full overflow-hidden truncate';
const multilineClassName =
maxLines > 1
? `overflow-hidden whitespace-normal break-words [display:-webkit-box] [-webkit-box-orient:vertical] ${
maxLines === 3 ? '[-webkit-line-clamp:3]' : '[-webkit-line-clamp:2]'
} [overflow-wrap:anywhere]`
: '';
const trigger = showTooltip ? (
<DataTableTextTooltipTrigger
className={[textClassName, className].filter(Boolean).join(' ')}
className={[multilineClassName, className].filter(Boolean).join(' ')}
text={fullText}
truncate={false}
truncate={maxLines === 1}
>
{children ?? (fullText || '-')}
</DataTableTextTooltipTrigger>
) : (
<span className={[textClassName, className].filter(Boolean).join(' ')}>
<span className={[
maxLines > 1 ? multilineClassName : 'truncate',
'block min-w-0 max-w-full overflow-hidden',
className,
].filter(Boolean).join(' ')}>
{children ?? (fullText || '-')}
</span>
);
if (
!fullText ||
fullText === '-' ||
(!showTooltip && fullText.length <= 18)
) {
if (!showTooltip || !fullText || fullText === '-') {
return trigger;
}
return (
<DataTableTextTooltipTrigger
className={[textClassName, className].filter(Boolean).join(' ')}
className={[multilineClassName, className].filter(Boolean).join(' ')}
text={fullText}
truncate={false}
truncate={maxLines === 1}
>
{children ?? (fullText || '-')}
</DataTableTextTooltipTrigger>
......@@ -354,13 +408,18 @@ function DataTableTextTooltipTrigger({
<Tooltip>
<Tooltip.Trigger
className={[
'block min-w-0 max-w-full overflow-hidden',
truncate ? 'min-w-0 truncate' : '',
className,
].filter(Boolean).join(' ')}
>
{children}
</Tooltip.Trigger>
<Tooltip.Content>{text}</Tooltip.Content>
<Tooltip.Content>
<span className="block max-w-[min(32rem,calc(100vw-2rem))] whitespace-pre-wrap break-words">
{text}
</span>
</Tooltip.Content>
</Tooltip>
);
}
......@@ -396,7 +455,6 @@ export function DataTablePrimaryAction({
const handlePress = onPress || onClick;
return (
<Tooltip>
<Button
aria-label={ariaLabel || fullText}
className={className}
......@@ -409,8 +467,6 @@ export function DataTablePrimaryAction({
{children ?? fullText}
</span>
</Button>
<Tooltip.Content>{fullText}</Tooltip.Content>
</Tooltip>
);
}
......@@ -509,8 +565,10 @@ export function DataTableChip({ children, text, className = '' }) {
export function DataTablePagination({
page,
pageSize,
pageSizeOptions = [],
total,
onPageChange,
onPageSizeChange,
isDisabled = false,
itemLabel = '条',
}) {
......@@ -519,13 +577,43 @@ export function DataTablePagination({
const pageWindowEnd = total
? Math.min(totalPages, pageWindowStart + 5)
: pageWindowStart;
const normalizedPageSizeOptions = pageSizeOptions
.map((value) => Number(value))
.filter((value, index, values) => Number.isFinite(value) && value > 0 && values.indexOf(value) === index);
const pageSizeOptionsForPicker = normalizedPageSizeOptions.map((value) => ({
id: String(value),
label: `${value} 条/页`,
}));
const hasPageSizePicker =
pageSizeOptionsForPicker.length > 0 && typeof onPageSizeChange === 'function';
return (
<div className="flex flex-wrap items-center justify-between gap-3">
<Pagination size="sm">
<Pagination.Summary>
共 {total} {itemLabel},第{' '}
{total ? page + 1 : 0} / {total ? totalPages : 0} 页
</Pagination.Summary>
<div className="flex min-w-0 flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 shrink-0 items-center gap-3">
<span className="min-w-0 text-sm text-muted">
共 {total} {itemLabel},第 {total ? page + 1 : 0} / {total ? totalPages : 0} 页
</span>
{hasPageSizePicker ? (
<XinyuanAutocomplete
aria-label="每页条数"
className="w-32 shrink-0"
isDisabled={isDisabled}
options={pageSizeOptionsForPicker}
placeholder="每页条数"
selectedKey={String(pageSize)}
showSearch={false}
valueText={`${pageSize} 条/页`}
variant="secondary"
onSelectionChange={(key) => {
const nextPageSize = Number(key);
if (normalizedPageSizeOptions.includes(nextPageSize)) {
onPageSizeChange(nextPageSize);
}
}}
/>
) : null}
</div>
<Pagination className="ml-auto w-auto shrink-0" size={hasPageSizePicker ? 'lg' : 'sm'}>
<Pagination.Content>
<Pagination.Item>
<Pagination.Previous
......
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import { useMemo } from 'react';
const EMPTY_OPTION_KEY = '__rc_filter_empty__';
function normalizeOption(option) {
if (option && typeof option === 'object') {
return {
......@@ -32,9 +30,9 @@ export default function FilterSelect({
}) {
const normalizedOptions = useMemo(
() =>
options.map(normalizeOption).map((option) => ({
options.map(normalizeOption).filter((option) => option.value !== '').map((option) => ({
...option,
id: option.value || EMPTY_OPTION_KEY,
id: option.value,
isDisabled: option.disabled,
})),
[options],
......@@ -43,21 +41,22 @@ export default function FilterSelect({
const selectedOption = normalizedOptions.find(
(option) => option.value === selectedValue,
);
const selectedKey = selectedValue === '' ? EMPTY_OPTION_KEY : selectedValue;
const selectedKey = selectedValue || null;
return (
<XinyuanAutocomplete
aria-label={ariaLabel || placeholder}
className={className}
isDisabled={disabled}
isClearable={Boolean(selectedValue)}
emptyText={emptyText}
options={normalizedOptions}
onClear={() => onChange?.('')}
placeholder={placeholder}
selectedKey={selectedKey}
variant={variant}
onSelectionChange={(key) => {
const nextValue = key === EMPTY_OPTION_KEY ? '' : String(key ?? '');
onChange?.(nextValue);
onChange?.(String(key ?? ''));
}}
valueText={selectedOption?.label || placeholder}
/>
......
......@@ -127,9 +127,11 @@ export default function FormSelect({
aria-label={ariaLabel || resolvedPlaceholder}
className={className}
isDisabled={disabled}
isClearable={selectedOptions.length > 0}
isRequired={required}
name={name}
options={options}
onClear={() => emitChange(multiple ? [] : [''])}
placeholder={resolvedPlaceholder}
selectionMode={multiple ? 'multiple' : 'single'}
valueText={displayText}
......
......@@ -605,9 +605,11 @@ export default function ReferenceSearchSelect({
errorText={error}
inputValue={query}
isDisabled={disabled}
isClearable={allowClear}
isLoading={loading}
isOpen={active}
options={menuOptions}
onClear={clearSelection}
placement={optionsAnchor || 'bottom start'}
placeholder={placeholder}
popoverHeader={allowClear ? (
......
......@@ -70,8 +70,10 @@ export default function ResourceFilterMultiSelect({
emptyText="没有匹配选项"
inputValue={searchKeyword}
isDisabled={disabled}
isClearable={selectedValues.length > 0}
isOpen={isOpen}
options={autocompleteOptions}
onClear={handleClearSelection}
placeholder={placeholder}
popoverHeader={(
<div className="flex items-center justify-between gap-3 px-3 pb-1">
......
......@@ -83,8 +83,10 @@ export default function UserSearchSelect({
aria-label={ariaLabel}
inputValue={keyword}
isDisabled={disabled}
isClearable={selectedIds.length > 0}
isLoading={loading}
options={options}
onClear={() => onChange?.(multiple ? [] : null)}
placeholder={placeholder}
selectionMode={multiple ? 'multiple' : 'single'}
selectedKey={multiple ? undefined : selectedIds[0] || null}
......
......@@ -52,11 +52,13 @@ export default function XinyuanAutocomplete({
fullWidth = true,
inputValue,
isDisabled = false,
isClearable = false,
isLoading = false,
isOpen,
isRequired = false,
name,
onInputChange,
onClear,
onOpenChange,
onSelectionChange,
options = [],
......@@ -65,6 +67,7 @@ export default function XinyuanAutocomplete({
popoverHeader,
renderOption,
searchPlaceholder = '输入关键词搜索',
showSearch = true,
selectedKey,
selectedKeys,
selectionMode = 'single',
......@@ -81,7 +84,6 @@ export default function XinyuanAutocomplete({
Array.from(selectedKeySet).map(toSelectionKey),
);
const selectedOptions = options.filter((option) =>
selectedKeySet.has(option.id) ||
selectedKeyStringSet.has(toSelectionKey(option.id)),
);
const resolvedValueText =
......@@ -90,8 +92,10 @@ export default function XinyuanAutocomplete({
? selectedOptions.map((option) => option.label).join('、')
: placeholder);
const isMultiple = selectionMode === 'multiple';
const hasSelection = selectedKeySet.size > 0;
const normalizedSelectedKeys = selectedKeySet;
const rootSelectionProps = isMultiple
const rootSelectionProps =
isMultiple
? { selectedKey: null }
: { selectedKey: normalizedSelectedKeys.values().next().value || null };
const handleMultipleOptionAction = (optionKey) => {
......@@ -99,12 +103,12 @@ export default function XinyuanAutocomplete({
return;
}
const nextKeys = new Set(normalizedSelectedKeys);
const normalizedOptionKey = toSelectionKey(optionKey);
if (!normalizedOptionKey) {
return;
}
const nextKeys = new Set(normalizedSelectedKeys);
if (nextKeys.has(normalizedOptionKey)) {
nextKeys.delete(normalizedOptionKey);
} else {
......@@ -114,6 +118,8 @@ export default function XinyuanAutocomplete({
};
const listBoxSelectionProps = isMultiple
? {
// Autocomplete is single-selection under the hood. Use item actions
// for multi-select so the list cannot replace the controlled values.
selectionMode: 'single',
onAction: handleMultipleOptionAction,
}
......@@ -140,6 +146,7 @@ export default function XinyuanAutocomplete({
isOpen={isOpen}
isRequired={isRequired}
name={name}
onClear={onClear}
selectionMode={isMultiple ? 'single' : selectionMode}
variant={variant}
onOpenChange={onOpenChange}
......@@ -148,12 +155,15 @@ export default function XinyuanAutocomplete({
<Autocomplete.Trigger>
<Autocomplete.Value>
<span
className="block min-w-0 truncate"
className={`block min-w-0 truncate ${hasSelection ? '' : 'text-field-placeholder'}`.trim()}
title={resolvedValueText}
>
{resolvedValueText}
</span>
</Autocomplete.Value>
{isClearable ? (
<Autocomplete.ClearButton aria-label={`清除${ariaLabel || placeholder}`} />
) : null}
<Autocomplete.Indicator>
<ChevronDown size={16} style={FIELD_SUFFIX_ICON_STYLE} />
</Autocomplete.Indicator>
......@@ -165,6 +175,7 @@ export default function XinyuanAutocomplete({
onInputChange={onInputChange}
>
{popoverHeader}
{showSearch ? (
<SearchField
aria-label={searchPlaceholder}
fullWidth
......@@ -180,6 +191,7 @@ export default function XinyuanAutocomplete({
</SearchField.ClearButton>
</SearchField.Group>
</SearchField>
) : null}
<ListBox
aria-label={ariaLabel || placeholder}
items={visibleOptions}
......
import {
getXinyuanEChartsTheme,
} from '@/design-system/chartPalette';
import ReactECharts from 'echarts-for-react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
function mergeAxisTheme(axis, theme) {
if (!axis) return axis;
const applyTheme = (item) => ({
...item,
axisLabel: {
color: theme.axis,
fontFamily: theme.fontFamily,
fontSize: 12,
...item?.axisLabel,
},
axisLine: {
...item?.axisLine,
lineStyle: {
color: theme.grid,
...item?.axisLine?.lineStyle,
},
},
axisTick: {
...item?.axisTick,
lineStyle: {
color: theme.grid,
...item?.axisTick?.lineStyle,
},
},
splitLine: {
...item?.splitLine,
lineStyle: {
color: theme.grid,
...item?.splitLine?.lineStyle,
},
},
});
return Array.isArray(axis) ? axis.map(applyTheme) : applyTheme(axis);
}
function readAxisType(axis) {
if (Array.isArray(axis)) {
return axis[0]?.type;
}
return axis?.type;
}
function getBarRadius(option, theme) {
const isHorizontal = readAxisType(option?.xAxis) === 'value'
&& readAxisType(option?.yAxis) === 'category';
return isHorizontal
? [0, theme.barRadius, theme.barRadius, 0]
: [theme.barRadius, theme.barRadius, 0, 0];
}
function forceItemStyle(data, itemStyle) {
if (!Array.isArray(data)) return data;
return data.map((item) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) return item;
return {
...item,
itemStyle: {
...item.itemStyle,
...itemStyle,
},
};
});
}
export default function XinyuanEChart({
ariaLabel,
className = '',
height = 320,
onEvents,
option,
}) {
const chartRef = useRef(null);
const hideTooltip = useCallback(() => {
chartRef.current?.getEchartsInstance?.().dispatchAction({ type: 'hideTip' });
}, []);
useEffect(() => {
window.addEventListener('resize', hideTooltip);
return () => window.removeEventListener('resize', hideTooltip);
}, [hideTooltip]);
const themedOption = useMemo(() => {
const theme = getXinyuanEChartsTheme();
const applyPieTheme = (series) => {
if (series?.type !== 'pie') return series;
return {
...series,
itemStyle: {
...series.itemStyle,
borderColor: theme.sectorGap,
borderRadius: theme.sectorRadius,
borderWidth: theme.sectorGapWidth,
},
data: forceItemStyle(series.data, {
borderColor: theme.sectorGap,
borderRadius: theme.sectorRadius,
borderWidth: theme.sectorGapWidth,
}),
padAngle: 1,
};
};
const applySeriesTheme = (series) => {
const themedSeries = applyPieTheme(series);
if (themedSeries?.type !== 'bar') return themedSeries;
return {
...themedSeries,
itemStyle: {
...themedSeries.itemStyle,
borderRadius: getBarRadius(option, theme),
},
data: forceItemStyle(themedSeries.data, {
borderRadius: getBarRadius(option, theme),
}),
};
};
const themedSeries = Array.isArray(option?.series)
? option.series.map(applySeriesTheme)
: applySeriesTheme(option?.series);
return {
animationDuration: 320,
animationDurationUpdate: 240,
animationEasing: 'cubicOut',
aria: {
enabled: true,
decal: { show: false },
...option?.aria,
},
color: option?.color || theme.palette,
textStyle: {
color: theme.label,
fontFamily: theme.fontFamily,
...option?.textStyle,
},
...option,
series: themedSeries,
legend: Array.isArray(option?.legend)
? option.legend.map((legend) => ({
icon: 'circle',
itemHeight: 8,
itemWidth: 8,
...legend,
textStyle: {
color: theme.label,
fontFamily: theme.fontFamily,
fontSize: 12,
lineHeight: 16,
...legend?.textStyle,
},
}))
: option?.legend ? {
icon: 'circle',
itemHeight: 8,
itemWidth: 8,
...option.legend,
textStyle: {
color: theme.label,
fontFamily: theme.fontFamily,
fontSize: 12,
lineHeight: 16,
...option.legend?.textStyle,
},
} : undefined,
tooltip: {
appendToBody: true,
backgroundColor: theme.tooltipBackground,
borderColor: theme.tooltipBorder,
borderWidth: 1,
className: 'rc-echarts-tooltip',
confine: true,
enterable: false,
extraCssText: `border-radius: ${theme.tooltipRadius}; box-shadow: ${theme.tooltipShadow};`,
padding: [10, 12],
renderMode: 'html',
...option?.tooltip,
textStyle: {
color: theme.foreground,
fontFamily: theme.fontFamily,
fontSize: 12,
...option?.tooltip?.textStyle,
},
},
xAxis: mergeAxisTheme(option?.xAxis, theme),
yAxis: mergeAxisTheme(option?.yAxis, theme),
};
}, [option]);
return (
<div
aria-label={ariaLabel}
className={`rc-chart-interaction w-full min-w-0 ${className}`.trim()}
role="img"
tabIndex={0}
>
<ReactECharts
lazyUpdate
notMerge
onEvents={{ globalout: hideTooltip, ...onEvents }}
option={themedOption}
opts={{ renderer: 'canvas' }}
ref={chartRef}
style={{ height, width: '100%' }}
/>
</div>
);
}
......@@ -3,12 +3,14 @@ import { Modal } from '@heroui/react';
import clsx from 'clsx';
const DIALOG_WIDTH_CLASS = {
md: 'sm:max-w-[30rem]',
xl: 'sm:max-w-[46rem]',
'2xl': 'sm:max-w-[52.5rem]',
'3xl': 'sm:max-w-[60rem]',
'4xl': 'sm:max-w-[68rem]',
'5xl': 'sm:max-w-[76rem]',
};
const STANDARD_PANEL_PADDING_SIZES = new Set(['xl', '2xl', '3xl', '4xl', '5xl']);
export default function XinyuanModal({
isOpen,
......@@ -30,7 +32,8 @@ export default function XinyuanModal({
<Modal.Container placement="center" scroll="inside" size={modalSize}>
<Modal.Dialog
className={clsx(
DIALOG_WIDTH_CLASS[size] && 'p-7',
STANDARD_PANEL_PADDING_SIZES.has(size) && 'p-7',
'bg-background',
DIALOG_WIDTH_CLASS[size],
dialogClassName,
)}
......@@ -40,11 +43,11 @@ export default function XinyuanModal({
</Modal.CloseTrigger>
<Modal.Header>
<Modal.Heading>{title}</Modal.Heading>
</Modal.Header>
<Modal.Body className={bodyClassName}>
{description ? (
<div className="text-sm text-muted">{description}</div>
) : null}
</Modal.Header>
<Modal.Body className={bodyClassName}>
{children}
</Modal.Body>
{footer ? (
......
......@@ -509,8 +509,10 @@ function WorkbenchPagination({ pagination }) {
isDisabled={Boolean(pagination.disabled)}
page={Math.max(0, Number(pagination.page || 0))}
pageSize={pageSize}
pageSizeOptions={pagination.pageSizeOptions || []}
total={totalRecords}
onPageChange={pagination.onPageChange}
onPageSizeChange={pagination.onPageSizeChange}
/>
);
}
......@@ -992,7 +994,6 @@ export default function RecordWorkbench({
value={filters[field.key] || ''}
onChange={(value) => onFiltersChange?.(field.key, value)}
options={[
{ value: '', label: field.placeholder || `全部${field.label}` },
...(field.options || []),
]}
placeholder={field.placeholder || `全部${field.label}`}
......@@ -1036,7 +1037,10 @@ export default function RecordWorkbench({
</FilterField>
);
const tableSectionClass = 'rc-list-stack';
const tableContentClass = 'rc-list-data-stack';
const tableContentClass = [
'rc-list-data-stack',
stickyTable ? 'rc-data-table-sticky' : '',
].filter(Boolean).join(' ');
return (
<div className="rc-page-stack">
......@@ -1204,10 +1208,6 @@ export default function RecordWorkbench({
</form>
) : null}
<div className="flex min-w-0 items-center">
<h2 className="text-base font-medium text-foreground">{title}</h2>
</div>
<div className="rc-page-toolbar">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
{toolbarSearchField ? (
......
......@@ -2,7 +2,6 @@ export const APPLICATION_DISPLAY_NAME = '问题整改';
export const APPLICATION_NAME = 'problem_rectification_new';
export const APPLICATION_BASE_PATH = `/${APPLICATION_NAME}/`;
export const AUTHORIZATION_APPLICATION_NAME = APPLICATION_NAME;
export const THEME_STORAGE_KEY = 'xinyuan_color_theme';
export const ROUTE_TITLES = Object.freeze({
'/integrity_risk_table/dashboard': '驾驶舱',
......
......@@ -7,6 +7,14 @@ export const XINYUAN_CHART_PALETTE = Object.freeze([
'var(--rc-chart-6)',
'var(--rc-chart-7)',
'var(--rc-chart-8)',
'var(--rc-chart-9)',
'var(--rc-chart-10)',
'var(--rc-chart-11)',
'var(--rc-chart-12)',
'var(--rc-chart-13)',
'var(--rc-chart-14)',
'var(--rc-chart-15)',
'var(--rc-chart-16)',
]);
export const XINYUAN_CHART_SEMANTIC = Object.freeze({
......@@ -65,46 +73,121 @@ export function createXinyuanChartColorMap(keys = []) {
return colorMap;
}
function isVisibleChartColor(color) {
if (typeof color !== 'string') {
return false;
function readRootToken(tokenName, fallback = '') {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return fallback;
}
const normalizedColor = color.trim().toLowerCase();
return Boolean(normalizedColor)
&& normalizedColor !== 'none'
&& normalizedColor !== 'transparent';
return window.getComputedStyle(document.documentElement)
.getPropertyValue(tokenName)
.trim() || fallback;
}
export function colorizeXinyuanChartTooltipPayload(
payload = [],
resolveColor,
) {
return payload.map((entry, index) => {
const rechartsPayload = entry?.payload || {};
const sourceDatum = rechartsPayload?.payload || rechartsPayload;
const key = sourceDatum?.name
?? rechartsPayload?.name
?? entry?.name
?? entry?.dataKey
?? '';
const candidateColors = [
resolveColor?.({ entry, index, key, sourceDatum }),
rechartsPayload?.fill,
sourceDatum?.fill,
entry?.fill,
entry?.color,
entry?.stroke,
getXinyuanChartColor(key, index),
];
const color = candidateColors.find(isVisibleChartColor);
const concreteColorCache = new Map();
function convertOklchToRgb(value) {
const match = String(value).trim().match(
/^oklch\(\s*([\d.]+)%?\s+([\d.]+)%?\s+([\d.-]+)(?:deg)?\s*\)$/i,
);
if (!match) return value;
const lightnessValue = Number(match[1]);
const chromaValue = Number(match[2]);
const lightness = lightnessValue > 1 ? lightnessValue / 100 : lightnessValue;
const chroma = chromaValue > 1 ? chromaValue / 100 : chromaValue;
const hue = (Number(match[3]) * Math.PI) / 180;
const a = chroma * Math.cos(hue);
const b = chroma * Math.sin(hue);
const l = (lightness + 0.3963377774 * a + 0.2158037573 * b) ** 3;
const m = (lightness - 0.1055613458 * a - 0.0638541728 * b) ** 3;
const s = (lightness - 0.0894841775 * a - 1.291485548 * b) ** 3;
const channels = [
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
].map((channel) => {
const clamped = Math.max(0, Math.min(1, channel));
const encoded = clamped <= 0.0031308
? 12.92 * clamped
: 1.055 * (clamped ** (1 / 2.4)) - 0.055;
return Math.round(encoded * 255);
});
return `rgb(${channels.join(', ')})`;
}
function readConcreteColorToken(tokenName, fallback = '') {
const raw = readRootToken(tokenName, fallback);
const cached = concreteColorCache.get(tokenName);
if (cached?.raw === raw) return cached.value;
if (typeof document === 'undefined' || !raw) {
return raw || fallback;
}
const probe = document.createElement('span');
probe.style.color = raw;
if (!probe.style.color) {
return fallback;
}
probe.style.position = 'absolute';
probe.style.pointerEvents = 'none';
document.body?.appendChild(probe);
const resolved = convertOklchToRgb(window.getComputedStyle(probe).color || fallback);
probe.remove();
concreteColorCache.set(tokenName, { raw, value: resolved });
return resolved;
}
export function resolveXinyuanChartColor(color, fallback = '') {
const match = String(color || '').trim().match(/^var\((--[^)]+)\)$/);
return match
? readConcreteColorToken(match[1], fallback)
: String(color || fallback);
}
export function createResolvedXinyuanChartColorMap(keys = []) {
return new Map(
Array.from(createXinyuanChartColorMap(keys)).map(([key, color]) => [
key,
resolveXinyuanChartColor(color),
]),
);
}
export function getXinyuanEChartsTheme() {
const documentFont = typeof document === 'undefined'
? 'Inter, sans-serif'
: window.getComputedStyle(document.body).fontFamily;
return {
...entry,
color,
fill: color,
name: entry?.name ?? sourceDatum?.name ?? entry?.dataKey,
stroke: color,
axis: readRootToken('--xinyuan-chart-axis', '#737373'),
accentForeground: readConcreteColorToken('--accent-foreground', '#ffffff'),
fontFamily: documentFont || 'Inter, sans-serif',
foreground: readConcreteColorToken('--foreground', readRootToken('--xinyuan-chart-tooltip-foreground', '#222222')),
grid: readRootToken('--xinyuan-chart-grid', '#e8e8e8'),
label: readRootToken('--xinyuan-chart-label', '#737373'),
palette: Array.from({ length: 16 }, (_, index) => (
readRootToken(`--xinyuan-chart-${index + 1}`, '#667085')
)),
barRadius: Number.parseFloat(
readRootToken('--xinyuan-chart-bar-radius', '8'),
) || 8,
sectorGap: readConcreteColorToken('--xinyuan-chart-sector-gap', '#ffffff'),
surfaceSecondary: readConcreteColorToken('--surface-secondary', '#f5f5f5'),
surfaceTertiary: readConcreteColorToken('--surface-tertiary', '#eeeeee'),
sectorGapWidth: Number.parseFloat(
readRootToken('--xinyuan-chart-sector-gap-width', '2'),
) || 2,
sectorRadius: Number.parseFloat(
readRootToken('--xinyuan-chart-sector-radius', '10'),
) || 10,
cardRadius: readRootToken('--xinyuan-chart-card-radius', '1.5rem'),
tooltipRadius: readRootToken('--xinyuan-chart-tooltip-radius', '0.75rem'),
tooltipBackground: readRootToken('--xinyuan-chart-tooltip-background', '#ffffff'),
tooltipBorder: readRootToken('--xinyuan-chart-tooltip-border', '#dedede'),
tooltipShadow: readRootToken(
'--xinyuan-chart-tooltip-shadow',
'0 16px 40px rgb(15 23 42 / 14%)',
),
};
});
}
......@@ -435,6 +435,41 @@
gap: var(--rc-page-module-gap);
}
.rc-section-title {
margin: 0;
color: var(--rc-text-primary);
font-size: 1.125rem;
font-weight: var(--rc-font-bold);
line-height: 1.5rem;
letter-spacing: 0;
}
.rc-module-title {
margin: 0;
color: var(--rc-text-primary);
font-size: 1rem;
font-weight: var(--rc-font-semibold);
line-height: 1.5rem;
letter-spacing: 0;
}
.rc-modal-header-actions {
align-self: flex-start;
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.5rem;
margin-inline-start: auto;
max-width: 100%;
}
@media (max-width: 40rem) {
.rc-modal-header-actions {
flex-basis: 100%;
}
}
.rc-page-grid {
min-width: 0;
gap: var(--rc-page-module-gap);
......@@ -442,18 +477,32 @@
.rc-chart-grid {
display: grid;
align-items: stretch;
grid-template-columns: minmax(0, 1fr);
}
.rc-chart-grid > * {
min-width: 0;
height: 100%;
}
/* Keep every HeroUI chart Card in a row on the same vertical track, even
when its ECharts canvas uses a different intrinsic height. */
.rc-chart-grid > .card,
.rc-chart-grid > [data-slot='card'] {
min-height: 0;
}
.rc-chart-grid > .card > .card__content,
.rc-chart-grid > [data-slot='card'] > [data-slot='content'] {
min-height: 0;
}
.rc-chart-grid > :only-child {
grid-column: 1 / -1;
}
/* Recharts keeps keyboard navigation while pointer focus stays visually quiet. */
/* Charts keep keyboard navigation while pointer focus stays visually quiet. */
.rc-chart-interaction:focus:not(:focus-visible),
.rc-chart-interaction :focus:not(:focus-visible) {
outline: none;
......@@ -465,6 +514,10 @@
outline-offset: 2px;
}
.rc-echarts-tooltip {
position: fixed !important;
}
.rc-kpi-grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
......@@ -509,8 +562,31 @@
box-shadow: var(--rc-shadow-card);
}
.rc-filter-surface {
border-radius: var(--rc-radius-overlay);
}
.rc-dashboard-card {
border-radius: var(--rc-chart-card-radius);
}
.rc-workspace-module {
border-radius: 1.5rem;
border: 1px solid var(--border);
background: transparent;
box-shadow: none;
}
.rc-empty-state-message {
color: var(--rc-text-empty);
font-size: var(--text-sm);
font-weight: var(--font-weight-normal);
}
.rc-empty-field-text {
color: var(--rc-text-empty);
font-size: var(--text-sm);
font-weight: var(--font-weight-normal);
}
/* HeroUI Pro KPI.Icon keeps its compound role; this class supplies Xinyuan tones. */
......@@ -655,6 +731,121 @@
gap: var(--rc-list-stack-gap);
}
/* Keep long data sets inside the table while the column headings remain visible. */
.rc-data-table-sticky .data-table-scroll {
position: relative;
max-height: min(60dvh, 42rem);
overflow-x: auto;
overflow-y: auto;
/* Keep horizontal overflow inside the grid, but let vertical wheel/touch
scrolling continue into the page once the last row is reached. This
keeps the pagination area reachable without requiring a second gesture
surface or a nested scroll lock. */
overscroll-behavior-x: contain;
overscroll-behavior-y: auto;
}
/* DataGrid adds a non-scrolling resize wrapper. Let sticky headers use the
outer scroll container as their containing block instead of this wrapper. */
.rc-data-table-sticky .table__resizable-container {
overflow: visible;
}
.rc-data-table-sticky [data-slot='table-header'] .table__column {
position: sticky;
top: 0;
inset-block-start: 0;
z-index: 4;
display: table-cell;
color: var(--muted);
background: var(--surface-secondary);
box-shadow: inset 0 -1px 0 var(--separator);
}
.rc-data-table-sticky [data-slot='table-header'] .table__column.bg-accent-soft {
background: var(--accent-soft);
color: var(--accent-soft-foreground);
}
/* The leading pinned column must sit above scrolling body cells. Keep its
surface opaque so text from adjacent columns cannot bleed through while
horizontal or vertical scrolling. */
.rc-data-table-sticky [data-pinned='start'] {
position: sticky;
z-index: 6;
background: var(--surface);
}
.rc-data-table-sticky [data-slot='table-header'] [data-pinned='start'] {
z-index: 8;
background: var(--surface-secondary);
}
/* Mirror HeroUI's row states on the opaque pinned surface so the leading
column still responds to hover and selection instead of looking detached. */
@media (hover: hover) {
.rc-data-table-sticky [data-slot='data-grid']
.table__row:hover
[data-pinned='start'].table__cell,
.rc-data-table-sticky [data-slot='data-grid']
.table__row[data-hovered='true']
[data-pinned='start'].table__cell {
/* Keep the pinned cell opaque; transparent hover fills let scrolled
sibling-cell text show through the fixed column. */
background-color: var(--color-surface-secondary);
}
@supports (color: color-mix(in oklab, white, black)) {
.rc-data-table-sticky [data-slot='data-grid']
.table__row:hover
[data-pinned='start'].table__cell,
.rc-data-table-sticky [data-slot='data-grid']
.table__row[data-hovered='true']
[data-pinned='start'].table__cell {
background-color: color-mix(
in oklab,
var(--color-surface) 40%,
var(--color-surface-secondary)
);
}
}
}
.rc-data-table-sticky [data-slot='data-grid']
.table__row[data-selected='true']
[data-pinned='start'].table__cell {
background-color: var(--color-surface-secondary);
}
@supports (color: color-mix(in oklab, white, black)) {
.rc-data-table-sticky [data-slot='data-grid']
.table__row[data-selected='true']
[data-pinned='start'].table__cell {
background-color: color-mix(
in oklab,
var(--color-surface) 10%,
var(--color-surface-secondary)
);
}
}
@media (max-width: 48rem) {
.rc-data-table-sticky .data-table-scroll {
max-height: min(54dvh, 32rem);
}
}
@media (min-width: 769px) {
.action-bar:has(.rc-data-grid-action-bar) {
inset-inline: 0;
}
body:has(.rc-app-shell[data-state='expanded'])
.action-bar:has(.rc-data-grid-action-bar) {
inset-inline-start: var(--rc-sidebar-width);
}
}
@media (min-width: 80rem) {
.rc-chart-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
......@@ -862,6 +1053,14 @@
font-weight: var(--rc-font-semibold);
}
.rc-application-identity-avatar {
width: 2.25rem;
height: 2.25rem;
flex: none;
border-radius: var(--rc-radius-control);
background: transparent;
}
.rc-app-shell .sidebar__content,
.rc-mobile-sidebar .sidebar__content {
min-height: 0;
......@@ -1185,12 +1384,12 @@
place-items: center;
padding: 0;
border-radius: 50%;
color: var(--foreground);
color: var(--rc-sidebar-foreground);
background-color: transparent;
line-height: 1;
--button-bg-hover: var(--accent-soft);
--button-bg-pressed: var(--accent-soft);
--button-fg: var(--foreground);
--button-bg-hover: var(--rc-sidebar-item-selected);
--button-bg-pressed: var(--rc-sidebar-item-selected);
--button-fg: var(--rc-sidebar-item-selected-foreground);
}
.app-sidebar-app-trigger .anticon {
......@@ -1210,16 +1409,16 @@
@media (hover: hover) {
.app-sidebar-app-trigger.button:hover,
.app-sidebar-app-trigger.button[data-hovered='true'] {
color: var(--foreground);
background-color: var(--accent-soft);
color: var(--rc-sidebar-item-selected-foreground);
background-color: var(--rc-sidebar-item-selected);
}
}
.app-sidebar-app-trigger.button:active,
.app-sidebar-app-trigger.button[data-pressed='true'],
.app-sidebar-app-trigger.button[aria-expanded='true'] {
color: var(--foreground);
background-color: var(--accent-soft);
color: var(--rc-sidebar-item-selected-foreground);
background-color: var(--rc-sidebar-item-selected);
}
.app-sidebar-app-dialog {
......@@ -1403,3 +1602,89 @@
border-right: 0;
}
}
/* Fixed split-shell theme: dark navigation, permanently light content canvas. */
.template-dashboard-shell .app-sidebar-glass,
.rc-mobile-sidebar.app-sidebar-glass {
color: var(--rc-sidebar-foreground);
border-color: var(--rc-sidebar-border) !important;
background-color: var(--rc-sidebar-background) !important;
}
.template-dashboard-shell
.sidebar__menu-item[data-current='true']
.sidebar__menu-item-content,
.rc-mobile-sidebar
.sidebar__menu-item[data-current='true']
.sidebar__menu-item-content {
background: var(--rc-sidebar-item-selected);
}
.template-dashboard-shell
.sidebar__menu-item[data-current='true']
.sidebar__menu-item-content,
.template-dashboard-shell
.sidebar__menu-item[data-current='true']
.sidebar__menu-item-content *,
.rc-mobile-sidebar
.sidebar__menu-item[data-current='true']
.sidebar__menu-item-content,
.rc-mobile-sidebar
.sidebar__menu-item[data-current='true']
.sidebar__menu-item-content * {
color: var(--rc-sidebar-item-selected-foreground) !important;
}
.template-dashboard-shell .sidebar__menu-item:hover .sidebar__menu-item-content,
.template-dashboard-shell
.sidebar__menu-item[data-hovered='true']
.sidebar__menu-item-content,
.rc-mobile-sidebar .sidebar__menu-item:hover .sidebar__menu-item-content,
.rc-mobile-sidebar
.sidebar__menu-item[data-hovered='true']
.sidebar__menu-item-content,
.app-sidebar-user-trigger.dropdown__trigger:hover,
.app-sidebar-user-trigger.dropdown__trigger[data-hovered='true'] {
background: var(--rc-sidebar-item-hover);
}
.app-sidebar-user-trigger.dropdown__trigger[aria-expanded='true'],
.app-sidebar-app-trigger.button[data-pressed='true'],
.app-sidebar-app-trigger.button[aria-expanded='true'] {
background: var(--rc-sidebar-item-selected);
}
.app-sidebar-user-avatar,
.app-sidebar-app-trigger.button,
.app-sidebar-user-trigger.dropdown__trigger {
color: var(--rc-sidebar-foreground);
background: transparent;
}
.app-sidebar-user-avatar {
background: var(--rc-sidebar-item-hover);
}
.template-dashboard-shell .sidebar__group-label,
.rc-mobile-sidebar .sidebar__group-label,
.template-dashboard-shell .sidebar__menu-icon,
.rc-mobile-sidebar .sidebar__menu-icon,
.app-sidebar-user-description {
color: var(--rc-sidebar-muted);
}
.template-dashboard-shell .sidebar__menu-label,
.rc-mobile-sidebar .sidebar__menu-label,
.app-sidebar-title,
.app-sidebar-user-name,
.app-sidebar-app-trigger.button {
color: var(--rc-sidebar-foreground);
}
.app-sidebar-user-trigger.dropdown__trigger:focus-visible,
.app-sidebar-app-trigger.button:focus-visible,
.template-dashboard-shell .sidebar__menu-item:focus-visible,
.rc-mobile-sidebar .sidebar__menu-item:focus-visible {
outline: 2px solid var(--rc-sidebar-focus);
outline-offset: 2px;
}
......@@ -11,38 +11,39 @@
[data-theme="light"],
[data-theme="default"] {
/* Theme Colors (Light Mode) */
--accent: oklch(45.32% 0.1661 27.03);
/* Xinyuan brand primary. Keep the same value in every theme mode. */
--accent: #DA241A;
--accent-foreground: oklch(99.11% 0 0);
--background: oklch(97.02% 0.0045 27.03);
--border: oklch(90.00% 0.0045 27.03);
--accent-hover: #C91F17;
--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: oklch(73.29% 0.1935 123.85);
--success-foreground: oklch(21.03% 0.0059 123.85);
--surface: oklch(100.00% 0.0000 29.20);
--surface-foreground: oklch(21.03% 0.0000 29.20);
--surface-secondary: oklch(95.24% 0.0000 29.20);
--surface-secondary-foreground: oklch(21.03% 0.0000 29.20);
--surface-tertiary: oklch(93.73% 0.0000 29.20);
--surface-tertiary-foreground: oklch(21.03% 0.0000 29.20);
--warning: oklch(78.19% 0.1585 45.37);
--warning-foreground: oklch(21.03% 0.0059 45.37);
/* Border Radius */
--radius: 0.5rem;
......@@ -51,42 +52,99 @@
/* Font Family */
/* Make sure to load Inter font in your app */
--font-sans: var(--font-inter);
/*
* Xinyuan fixed split-shell contract.
* The application canvas always uses this light theme. The Sidebar is the
* only persistent dark plane and must consume these semantic tokens.
*/
--xinyuan-sidebar-background: oklch(16% 0.020 255);
--xinyuan-sidebar-border: oklch(26% 0.022 255);
--xinyuan-sidebar-foreground: oklch(97% 0.004 255);
--xinyuan-sidebar-muted: oklch(76% 0.018 255);
--xinyuan-sidebar-item-hover: oklch(22% 0.026 255);
--xinyuan-sidebar-item-selected: oklch(28% 0.016 255);
--xinyuan-sidebar-item-selected-foreground: oklch(99% 0.002 255);
--xinyuan-sidebar-focus: oklch(78% 0.105 29.20);
/*
* ECharts is the required chart renderer for Xinyuan applications.
* Ant Design remains the icon source. Resolve these CSS variables to
* concrete colors before assigning them to an ECharts option object.
*/
/*
* Calm Xinyuan data palette: brand reds, muted warm neutrals, then
* restrained cool blue-grays. These tokens are data-only and do not
* change between light and dark mode.
*/
--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: #8A6F63;
--xinyuan-chart-9: #9A8174;
--xinyuan-chart-10: #6F6674;
--xinyuan-chart-11: #807783;
--xinyuan-chart-12: #938B96;
--xinyuan-chart-13: #4F6380;
--xinyuan-chart-14: #61758F;
--xinyuan-chart-15: #74879A;
--xinyuan-chart-16: #B0A4A0;
--xinyuan-chart-bar-radius: 8px;
--xinyuan-chart-sector-gap: var(--surface);
--xinyuan-chart-sector-gap-width: 2px;
--xinyuan-chart-sector-radius: 8px;
--xinyuan-chart-card-radius: 1.5rem;
--xinyuan-chart-tooltip-radius: 0.75rem;
--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 brand and focus stable when the Sidebar uses its dark plane. */
--accent: #DA241A;
--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);
--accent-hover: #C91F17;
--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: oklch(73.29% 0.1935 123.85);
--success-foreground: oklch(21.03% 0.0059 123.85);
--surface: oklch(21.03% 0.0000 29.20);
--surface-foreground: oklch(99.11% 0.0000 29.20);
--surface-secondary: oklch(25.70% 0.0000 29.20);
--surface-secondary-foreground: oklch(99.11% 0.0000 29.20);
--surface-tertiary: oklch(27.21% 0.0000 29.20);
--surface-tertiary-foreground: oklch(99.11% 0.0000 29.20);
--warning: oklch(82.03% 0.1388 49.38);
--warning-foreground: oklch(21.03% 0.0059 49.38);
}
......@@ -2,9 +2,7 @@
.light,
.default,
[data-theme='light'],
[data-theme='default'],
.dark,
[data-theme='dark'] {
[data-theme='default'] {
/* Xinyuan aliases and extension tokens. HeroUI core tokens are owned by theme.css. */
--rc-brand-primary: var(--accent);
--rc-brand-light: color-mix(in oklab, var(--accent) 84%, white);
......@@ -27,6 +25,17 @@
var(--background)
);
--rc-text-muted: var(--muted);
--rc-text-empty: color-mix(in oklab, var(--muted) 72%, var(--background));
/* Fixed dark Sidebar aliases. The content canvas remains light. */
--rc-sidebar-background: var(--xinyuan-sidebar-background);
--rc-sidebar-border: var(--xinyuan-sidebar-border);
--rc-sidebar-foreground: var(--xinyuan-sidebar-foreground);
--rc-sidebar-muted: var(--xinyuan-sidebar-muted);
--rc-sidebar-item-hover: var(--xinyuan-sidebar-item-hover);
--rc-sidebar-item-selected: var(--xinyuan-sidebar-item-selected);
--rc-sidebar-item-selected-foreground: var(--xinyuan-sidebar-item-selected-foreground);
--rc-sidebar-focus: var(--xinyuan-sidebar-focus);
--rc-font-sans: var(--font-sans);
--rc-font-medium: 500;
......@@ -45,6 +54,7 @@
/* Page geometry only. Component appearance remains owned by HeroUI. */
--rc-shell-inline-gutter: 3rem;
--rc-sidebar-width: 15rem;
--rc-navbar-inline-padding: var(--rc-shell-inline-gutter);
--rc-page-inline-start: var(--rc-shell-inline-gutter);
--rc-page-inline-end: var(--rc-shell-inline-gutter);
......@@ -82,14 +92,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);
/* KPI icon accents reuse the restrained chart family; no green/fluorescent tones. */
--rc-metric-tone-info: var(--rc-chart-13);
--rc-metric-tone-success: var(--rc-chart-14);
--rc-metric-tone-teal: var(--rc-chart-15);
--rc-metric-tone-danger: var(--rc-chart-2);
--rc-metric-tone-warning: var(--rc-chart-6);
--rc-metric-tone-violet: var(--rc-chart-10);
--rc-metric-tone-indigo: var(--rc-chart-11);
--rc-metric-tone-coral: var(--rc-chart-3);
--rc-metric-tone-neutral: var(--muted);
--rc-metric-tone-info-soft: color-mix(
in oklab,
......@@ -138,40 +149,30 @@
);
/* 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-risk: var(--rc-chart-2);
--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-positive: var(--rc-chart-13);
--rc-chart-comparison: var(--rc-chart-14);
--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);
--rc-chart-card-radius: var(--xinyuan-chart-card-radius);
--rc-chart-tooltip-radius: var(--xinyuan-chart-tooltip-radius);
}
:root {
--pm-brand: var(--rc-brand-primary, #9e1b1b);
--pm-brand: var(--rc-brand-primary, #DA241A);
--pm-brand-strong: var(--rc-brand-dark, #7e1515);
--pm-surface: var(--rc-bg-surface, #ffffff);
--pm-bg: var(--rc-bg-canvas, #f5f5f5);
......
import clsx from 'clsx';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { Surface } from '@heroui/react';
import TailwindEditableField from './TailwindEditableField';
import {
buildViewColumns,
......@@ -133,13 +132,5 @@ export default function SubjectViewContent({
</section>
);
if (mode === 'view' && !minimal) {
return (
<Surface className="rounded-2xl px-5 py-2 sm:px-6 sm:py-3" variant="secondary">
{fields}
</Surface>
);
}
return fields;
}
......@@ -234,6 +234,7 @@ function UserSelector({ value, onChange, multiple, variant }) {
aria-label="用户"
emptyText="暂无用户"
inputValue={keyword}
isClearable={selectedIds.length > 0}
options={autocompleteOptions}
placeholder="请选择用户"
searchPlaceholder="搜索用户名或显示名"
......@@ -242,6 +243,7 @@ function UserSelector({ value, onChange, multiple, variant }) {
valueText={valueText || '请选择用户'}
variant={variant}
onInputChange={setKeyword}
onClear={() => onChange?.(multiple ? [] : null)}
onSelectionChange={handleSelectionChange}
renderOption={(option) => (
<span className="min-w-0">
......@@ -276,6 +278,7 @@ function FieldOptionSelect({ multiple, onChange, options, value, variant }) {
<XinyuanAutocomplete
{...selectProps}
aria-label="选择字段值"
isClearable={selectedOptions.length > 0}
options={options.map((option) => ({
id: String(option.value),
label: option.label,
......@@ -284,6 +287,7 @@ function FieldOptionSelect({ multiple, onChange, options, value, variant }) {
selectionMode={multiple ? 'multiple' : 'single'}
valueText={displayValue}
variant={variant}
onClear={() => onChange?.(multiple ? [] : '')}
/>
);
}
......
......@@ -4,17 +4,19 @@ import {
APPLICATION_NAME,
} from '@/config/application';
import { getApplications } from '@/services/DataService';
import { ExclamationTriangleIcon } from '@/components/AppIcons';
import { EmptyStateSurface } from '@/components/DataTable';
import { ExclamationTriangleIcon, Search as SearchIcon } from '@/components/AppIcons';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { ItemCard } from '@heroui-pro/react/item-card';
import { ItemCardGroup } from '@heroui-pro/react/item-card-group';
import {
Alert,
Avatar,
Card,
Chip,
Description,
Modal,
ScrollShadow,
SearchField,
Skeleton,
Surface,
} from '@heroui/react';
import { useEffect, useMemo, useState } from 'react';
......@@ -74,7 +76,7 @@ function normalizeApplication(application, fallback = {}) {
fallback?.title ||
application?.title ||
'进入对应应用继续处理业务',
logo: current ? xinyuanLogo : application?.logo || fallback?.logo || null,
logo: xinyuanLogo,
url: resolveApplicationUrl({
name,
}),
......@@ -151,10 +153,11 @@ function buildApplicationSections(applications, applicationGroups) {
.map((name) => {
const application = applicationMap.get(name);
if (application) {
groupedApplicationNames.add(name);
if (!application || groupedApplicationNames.has(name)) {
return null;
}
groupedApplicationNames.add(name);
return application;
})
.filter(Boolean)
......@@ -184,6 +187,10 @@ function buildApplicationSections(applications, applicationGroups) {
}
function isCurrentApplication(application) {
if (application?.name === CURRENT_APPLICATION_NAME) {
return true;
}
if (typeof window === 'undefined') {
return false;
}
......@@ -210,64 +217,44 @@ function isCurrentApplication(application) {
function ApplicationCard({ application, current }) {
const target = application?.url?.startsWith('http') ? '_blank' : undefined;
const initial = application.title.trim().slice(0, 1).toUpperCase() || 'A';
const description = application.description?.trim();
const hasDistinctDescription = description && description !== application.title.trim();
return (
<Card className="h-full" variant="secondary">
<Card.Content>
<a
<ItemCard
aria-current={current ? 'page' : undefined}
className="h-full min-w-0 no-underline"
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"
render={(props) => <a {...props} />}
target={target}
variant={current ? 'outline' : 'secondary'}
>
{application.logo ? (
<Avatar.Image className="object-contain p-1.5" src={application.logo} alt="" />
) : null}
<Avatar.Fallback>{initial}</Avatar.Fallback>
<ItemCard.Icon aria-hidden="true">
<Avatar className="rc-application-identity-avatar" color="accent" variant="soft">
<Avatar.Image alt="" className="object-contain p-1.5" src={application.logo || xinyuanLogo} />
<Avatar.Fallback><img alt="" className="object-contain p-1.5" src={xinyuanLogo} /></Avatar.Fallback>
</Avatar>
</Surface>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium leading-5 text-foreground" title={application.title}>
{application.title}
</span>
<Description className="mt-0.5 block truncate leading-4" title={application.description}>
{application.description || application.name || '应用'}
</Description>
</span>
{current ? <Chip color="accent" size="sm">当前</Chip> : null}
</a>
</Card.Content>
</Card>
</ItemCard.Icon>
<ItemCard.Content>
<ItemCard.Title title={application.title}>{application.title}</ItemCard.Title>
{hasDistinctDescription ? <ItemCard.Description title={description}>{description}</ItemCard.Description> : null}
</ItemCard.Content>
{current ? <ItemCard.Action><Chip color="accent" size="sm">当前</Chip></ItemCard.Action> : null}
</ItemCard>
);
}
function LoadingSkeleton() {
return (
<div className="grid gap-3 sm:grid-cols-2" aria-label="正在加载应用">
<ItemCardGroup aria-label="正在加载应用" className="grid-cols-1 gap-3 sm:grid-cols-2" columns={2} layout="grid" variant="transparent">
{Array.from({ length: 6 }).map((_, index) => (
<Card key={index} variant="secondary">
<Card.Content className="flex items-center gap-3">
<Skeleton className="size-9" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-3 w-full" />
</div>
</Card.Content>
</Card>
<ItemCard key={index} variant="default">
<ItemCard.Icon><Skeleton className="size-9" /></ItemCard.Icon>
<ItemCard.Content className="gap-2"><Skeleton className="h-4 w-2/3" /><Skeleton className="h-3 w-full" /></ItemCard.Content>
</ItemCard>
))}
</div>
</ItemCardGroup>
);
}
......@@ -299,13 +286,25 @@ export default function ApplicationSwitcherDialog({
fallbackApplications.length > 0 ? 'success' : 'idle',
);
const [errorMessage, setErrorMessage] = useState('');
const [applicationSearch, setApplicationSearch] = useState('');
const currentApplicationName = useMemo(() => {
return applications.find(isCurrentApplication)?.name || '';
return applications.find(isCurrentApplication)?.name || CURRENT_APPLICATION_NAME;
}, [applications]);
const applicationSections = useMemo(
() => buildApplicationSections(applications, siteInfo?.group),
[applications, siteInfo?.group],
);
const filteredApplicationSections = useMemo(() => {
const keyword = applicationSearch.trim().toLocaleLowerCase();
if (!keyword) return applicationSections;
return applicationSections
.map((section) => ({
...section,
applications: section.applications.filter((application) => [application.title, application.description, application.name]
.some((value) => String(value || '').toLocaleLowerCase().includes(keyword))),
}))
.filter((section) => section.applications.length > 0);
}, [applicationSearch, applicationSections]);
useEffect(() => {
if (fallbackApplications.length === 0) {
......@@ -319,6 +318,7 @@ export default function ApplicationSwitcherDialog({
useEffect(() => {
if (!open) {
setApplicationSearch('');
return;
}
......@@ -365,29 +365,39 @@ export default function ApplicationSwitcherDialog({
<Modal.Root>
<Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
<Modal.Backdrop
isDismissable
isOpen={open}
onOpenChange={(isOpen) => {
if (!isOpen) onClose?.();
}}
variant="blur"
>
<Modal.Container className="w-full sm:w-full" placement="center" scroll="inside" size="lg">
<Modal.Container placement="center" scroll="inside" size="lg">
<Modal.Dialog
className="max-h-[min(42rem,calc(100dvh-2rem))] p-7"
aria-label="应用切换"
className="max-h-[min(42rem,calc(100dvh-2rem))] bg-background p-7"
style={{
maxWidth: 'min(46rem, calc(100vw - 2rem))',
width: 'min(46rem, calc(100vw - 2rem))',
}}
>
<Modal.CloseTrigger
aria-label="关闭"
style={{ insetInlineEnd: '1.75rem', top: '1.75rem' }}
/>
<Modal.Header>
<Modal.Heading>应用切换</Modal.Heading>
<div className="text-sm text-muted">选择你可访问的应用</div>
<SearchField
aria-label="搜索应用"
className="w-full min-w-0 max-w-full"
value={applicationSearch}
variant="secondary"
onChange={setApplicationSearch}
>
<SearchField.Group>
<SearchField.SearchIcon><SearchIcon size={16} /></SearchField.SearchIcon>
<SearchField.Input placeholder="搜索应用" />
<SearchField.ClearButton aria-label="清空应用搜索" />
</SearchField.Group>
</SearchField>
</Modal.Header>
<Modal.Body style={{ marginTop: '0.75rem' }}>
<Modal.Body className="flex min-h-0 overflow-hidden" style={{ marginTop: '0.75rem' }}>
<ScrollShadow className="h-full min-h-0 flex-1 overflow-y-auto pe-1 pb-1" hideScrollBar={false} offset={12} size={24}>
{status === 'error' && errorMessage ? (
<Alert status="warning">
<Alert.Indicator><ExclamationTriangleIcon size={20} /></Alert.Indicator>
......@@ -397,19 +407,14 @@ export default function ApplicationSwitcherDialog({
{status === 'loading' && applications.length === 0 ? (
<LoadingSkeleton />
) : applications.length > 0 ? (
) : filteredApplicationSections.length > 0 ? (
<div className="space-y-6">
{applicationSections.map((section) => (
<section key={section.key}>
<div className="mb-3 flex items-center justify-between gap-3">
<h3 className="text-sm font-medium leading-5 text-foreground">
{section.label}
</h3>
<Description>
{section.applications.length} 个应用
</Description>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{filteredApplicationSections.map((section) => (
<ItemCardGroup key={section.key} className="grid-cols-1 gap-3 sm:grid-cols-2" columns={2} layout="grid" variant="transparent">
<ItemCardGroup.Header className="flex items-center justify-between gap-3">
<ItemCardGroup.Title>{section.label}</ItemCardGroup.Title>
<ItemCardGroup.Description className="mt-0">{section.applications.length} 个应用</ItemCardGroup.Description>
</ItemCardGroup.Header>
{section.applications.map((application) => (
<ApplicationCard
key={application.name}
......@@ -417,18 +422,13 @@ export default function ApplicationSwitcherDialog({
current={application.name === currentApplicationName}
/>
))}
</div>
</section>
</ItemCardGroup>
))}
</div>
) : (
<EmptyState size="md">
<EmptyState.Header>
<EmptyState.Title>暂无可切换应用</EmptyState.Title>
<EmptyState.Description>当前账号还没有分配其它应用权限。</EmptyState.Description>
</EmptyState.Header>
</EmptyState>
<EmptyStateSurface><EmptyState size="md"><EmptyState.Header><EmptyState.Title>{applicationSearch.trim() ? '没有找到匹配的应用' : '暂无可切换应用'}</EmptyState.Title><EmptyState.Description>{applicationSearch.trim() ? '请尝试其他关键词。' : '当前账号还没有分配其它应用权限。'}</EmptyState.Description></EmptyState.Header></EmptyState></EmptyStateSurface>
)}
</ScrollShadow>
</Modal.Body>
</Modal.Dialog>
</Modal.Container>
......
......@@ -10,7 +10,8 @@ import {
loadArchiveWorkbenchResources,
} from './archiveWorkbenchService';
const DEFAULT_PAGE_SIZE = 10;
const PAGE_SIZE_OPTIONS = [20, 50, 100];
const DEFAULT_PAGE_SIZE = 20;
export default function ProblemRectificationArchivePage() {
const [records, setRecords] = useState([]);
......@@ -24,7 +25,7 @@ export default function ProblemRectificationArchivePage() {
const [activeViewName, setActiveViewName] = useState('');
const [selectedIds, setSelectedIds] = useState([]);
const [page, setPage] = useState(0);
const pageSize = DEFAULT_PAGE_SIZE;
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
const reloadList = useCallback(async () => {
setLoading(true);
......@@ -147,26 +148,16 @@ export default function ProblemRectificationArchivePage() {
pagination={{
page,
pageSize,
pageSizeOptions: PAGE_SIZE_OPTIONS,
totalRecords: filteredRecords.length,
disabled: loading,
onPageChange: setPage,
}}
toolbarActions={[
{
key: 'archive-export',
label: '导出批次',
variant: 'secondary',
disabled: true,
onClick: () => {},
},
{
key: 'archive-compare',
label: '批次对比',
variant: 'secondary',
disabled: true,
onClick: () => {},
onPageSizeChange: (nextPageSize) => {
setPageSize(nextPageSize);
setPage(0);
setSelectedIds([]);
},
]}
}}
rowActions={[
{
key: 'view-detail',
......@@ -197,7 +188,7 @@ export default function ProblemRectificationArchivePage() {
loadingTitle: '正在加载归档批次',
loadingDescription: '正在读取归档对象、视图配置和批次记录,请稍候。',
emptyTitle: '暂无归档批次',
emptyDescription: '当前还没有归档批次记录,后续完成“一键归档”后会在这里展示。',
emptyDescription: '当前还没有归档批次记录。',
}}
renderCell={({ record, column }) => {
if (column.field === 'metadata.reference_records') {
......
......@@ -59,7 +59,6 @@ export default function ProblemRectificationMaterialField({
const [keyword, setKeyword] = useState('');
const [debouncedKeyword, setDebouncedKeyword] = useState('');
const [materials, setMaterials] = useState([]);
const [selectedMaterialId, setSelectedMaterialId] = useState(null);
const [materialsLoading, setMaterialsLoading] = useState(false);
const [materialsError, setMaterialsError] = useState('');
const [retryKey, setRetryKey] = useState(0);
......@@ -75,7 +74,6 @@ export default function ProblemRectificationMaterialField({
setActiveTab(MATERIAL_CENTER_TAB);
setKeyword('');
setDebouncedKeyword('');
setSelectedMaterialId(null);
setMaterialsError('');
setUploadError('');
setLocalFileKeys(new Set());
......@@ -124,15 +122,11 @@ export default function ProblemRectificationMaterialField({
})),
[materials],
);
const selectedMaterial = useMemo(
() => materials.find((material) => material.id === selectedMaterialId),
[materials, selectedMaterialId],
);
const selectedValueText = multiple
? fileValues.length > 0
? `已选 ${fileValues.length} 个附件,可继续添加`
: '请选择审计材料'
: selectedMaterial?.materialTitle || fileValues[0]?.name || '请选择审计材料';
: '请选择审计材料';
const handleMaterialSelection = (key) => {
const materialId = key ? String(key) : '';
......@@ -149,11 +143,9 @@ export default function ProblemRectificationMaterialField({
setUploadError('');
if (multiple) {
onChange?.(appendUniqueFiles(fileValues, [fileAsset]));
setSelectedMaterialId(null);
return;
}
setSelectedMaterialId(material.id);
setLocalFileKeys(new Set());
onPendingLocalMaterialChange?.(
fieldPath,
......@@ -192,7 +184,6 @@ export default function ProblemRectificationMaterialField({
setLocalFileKeys((currentKeys) => new Set(
multiple ? [...currentKeys, ...uploadedKeys] : uploadedKeys,
));
setSelectedMaterialId(null);
onPendingLocalMaterialChange?.(
fieldPath,
{
......@@ -238,14 +229,13 @@ export default function ProblemRectificationMaterialField({
return;
}
setSelectedMaterialId(null);
onChange?.(null);
};
return (
<div className="space-y-4">
<div className="w-full min-w-0 space-y-3">
{fileValues.length > 0 ? (
<div className="space-y-2">
<div className="space-y-1">
{fileValues.map((file, index) => {
const fileKey = getFileKey(file);
const fileLabel = file?.name || file?.url || `附件 ${index + 1}`;
......@@ -255,9 +245,9 @@ export default function ProblemRectificationMaterialField({
<div className="truncate text-sm font-medium text-foreground" title={fileLabel}>
{fileLabel}
</div>
<div className="mt-1 text-xs text-muted">
{localFileKeys.has(fileKey) ? '本地文件,保存后同步至审计材料中心' : '已选择附件'}
</div>
{localFileKeys.has(fileKey) ? (
<div className="mt-1 text-xs text-muted">本地文件,保存后同步至审计材料中心</div>
) : null}
</div>
<Tooltip>
<Button
......@@ -282,9 +272,9 @@ export default function ProblemRectificationMaterialField({
<Tabs
aria-label="附件来源"
className="w-fit max-w-full min-w-0"
className="w-full min-w-0"
selectedKey={activeTab}
variant="secondary"
variant="primary"
onSelectionChange={(key) => setActiveTab(String(key))}
>
<Tabs.ListContainer className="w-fit max-w-full min-w-0">
......@@ -300,10 +290,11 @@ export default function ProblemRectificationMaterialField({
</Tabs.List>
</Tabs.ListContainer>
<Tabs.Panel className="pt-3" id={MATERIAL_CENTER_TAB}>
<div className="space-y-3">
<Tabs.Panel className="mt-3 w-full min-w-0 p-0" id={MATERIAL_CENTER_TAB}>
<div className="w-full min-w-0 space-y-3">
<XinyuanAutocomplete
aria-label="选择审计材料"
className="w-full min-w-0"
emptyText="没有找到可选择的审计材料"
errorText={materialsError}
inputValue={keyword}
......@@ -312,7 +303,7 @@ export default function ProblemRectificationMaterialField({
options={materialOptions}
placeholder="请选择审计材料"
searchPlaceholder="搜索材料标题或文件名"
selectedKey={selectedMaterialId}
selectedKey={null}
shouldFilter={false}
valueText={selectedValueText}
variant="secondary"
......@@ -344,8 +335,8 @@ export default function ProblemRectificationMaterialField({
</div>
</Tabs.Panel>
<Tabs.Panel className="pt-3" id={LOCAL_UPLOAD_TAB}>
<div className="space-y-3">
<Tabs.Panel className="mt-3 w-full min-w-0 p-0" id={LOCAL_UPLOAD_TAB}>
<div className="w-full min-w-0 space-y-3">
<FileUploadField
description={multiple
? '可一次选择多个文件;上传后先保留为详情草稿,保存详情时逐个同步写入审计材料中心。'
......
import { DataTable } from '@/components/DataTable';
import {
colorizeXinyuanChartTooltipPayload,
createXinyuanChartColorMap,
createResolvedXinyuanChartColorMap,
getXinyuanChartColor,
getXinyuanEChartsTheme,
resolveXinyuanChartColor,
XINYUAN_CHART_SEMANTIC,
} from '@/design-system/chartPalette';
import XinyuanEChart from '@/components/XinyuanEChart';
import XinyuanModal from '@/components/XinyuanModal';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import {
......@@ -22,13 +24,8 @@ import {
Spinner,
Tabs,
} from '@heroui/react';
import { BarChart } from '@heroui-pro/react/bar-chart';
import { ComposedChart } from '@heroui-pro/react/composed-chart';
import { ChartTooltipContent } from '@heroui-pro/react/chart-tooltip';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { KPI } from '@heroui-pro/react/kpi';
import { PieChart } from '@heroui-pro/react/pie-chart';
import { Cell, Legend } from 'recharts';
import { useEffect, useMemo, useState } from 'react';
import { loadProblemRectificationDashboardData } from '../problemRectificationDashboardService';
......@@ -61,11 +58,6 @@ const FOCUS_ISSUE_TYPES = [
tone: 'accent',
},
];
const CHART_LEGEND_LABEL_STYLE = {
fontSize: 'var(--rc-chart-legend-font-size)',
lineHeight: 'var(--rc-chart-legend-line-height)',
};
function formatPercent(value) {
const numericValue = Number(value || 0);
if (!Number.isFinite(numericValue)) {
......@@ -112,30 +104,11 @@ function StatCard({ label, value, icon: Icon, tone }) {
);
}
function XinyuanChartTooltipContent({
active,
colorForEntry,
hideHeader,
label,
payload,
valueFormatter,
}) {
return (
<ChartTooltipContent
active={active}
hideHeader={hideHeader}
label={label}
payload={colorizeXinyuanChartTooltipPayload(payload, colorForEntry)}
valueFormatter={valueFormatter}
/>
);
}
function Panel({ title, actions, children, className }) {
return (
<Card className={className}>
<Card className={`rc-dashboard-card ${className || ''}`.trim()}>
<Card.Header className="w-full flex-row items-center gap-3 text-left">
<Card.Title className="min-w-0 flex-1 text-left">{title}</Card.Title>
<Card.Title className="rc-module-title min-w-0 flex-1 text-left">{title}</Card.Title>
{actions ? <div className="shrink-0">{actions}</div> : null}
</Card.Header>
<Card.Content>{children}</Card.Content>
......@@ -193,203 +166,283 @@ function FocusIssueDetailDialog({ issueType, items = [], onClose }) {
function DonutChart({ data, centerLabel, colorForItem }) {
const total = data.reduce((sum, item) => sum + Number(item.value || 0), 0);
const colorMap = createXinyuanChartColorMap(data.map((item) => item.name));
return (
<PieChart className="rc-chart-interaction" height={320}>
<PieChart.Pie
data={data}
dataKey="value"
innerRadius="52%"
nameKey="name"
outerRadius="72%"
paddingAngle={2}
>
{data.map((item, index) => (
<PieChart.Cell
key={`${item.name}-${index}`}
fill={colorForItem?.(item, index)
|| colorMap.get(String(item.name ?? '').trim())
|| getXinyuanChartColor(item.name, index)}
/>
))}
<PieChart.Label
content={({ viewBox }) => {
const { cx = 0, cy = 0 } = viewBox || {};
return (
<text textAnchor="middle" x={cx} y={cy}>
<tspan fill="var(--foreground)" fontSize="24" fontWeight="600" x={cx} dy="-0.1em">
{formatCount(total)}
</tspan>
<tspan fill="var(--muted)" fontSize="12" x={cx} dy="1.6em">
{centerLabel}
</tspan>
</text>
const [viewportWidth, setViewportWidth] = useState(
() => (typeof window === 'undefined' ? 1440 : window.innerWidth),
);
}}
position="center"
/>
</PieChart.Pie>
<PieChart.Tooltip
content={(
<XinyuanChartTooltipContent
colorForEntry={({ index, key, sourceDatum }) => (
colorForItem?.(sourceDatum, index)
|| colorMap.get(String(key ?? '').trim())
|| getXinyuanChartColor(key, index)
)}
valueFormatter={(value) => `${formatCount(value)} 条`}
/>
)}
/>
</PieChart>
useEffect(() => {
const handleResize = () => setViewportWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
const option = useMemo(() => {
const theme = getXinyuanEChartsTheme();
const colorMap = createResolvedXinyuanChartColorMap(data.map((item) => item.name));
const legendData = data.map((item) => String(item.name ?? '').trim()).filter(Boolean);
const estimatedLegendWidth = legendData.reduce(
(width, label) => width + (Array.from(label).length * 13) + 28,
0,
);
const chartWidth = viewportWidth >= 1280
? Math.max(320, (viewportWidth - 120) / 2)
: Math.max(320, viewportWidth - 32);
const legendWidth = Math.max(260, chartWidth * 0.9);
// Keep short legends on one line; split long legends into two balanced rows
// before ECharts performs any additional overflow handling.
const shouldSplitLegend = legendData.length > 1 && estimatedLegendWidth > legendWidth;
const splitAt = Math.ceil(legendData.length / 2);
const legend = shouldSplitLegend
? [
{ bottom: 20, data: legendData.slice(0, splitAt), left: 'center', width: '92%' },
{ bottom: 0, data: legendData.slice(splitAt), left: 'center', width: '92%' },
]
: { bottom: 0, data: legendData, left: 'center', width: '92%' };
return {
graphic: [{
type: 'text',
left: 'center',
top: '40%',
style: {
text: `{value|${formatCount(total)}}\n{label|${centerLabel}}`,
textAlign: 'center',
rich: {
value: { color: theme.foreground, fontSize: 24, fontWeight: 600, lineHeight: 32 },
label: { color: theme.label, fontSize: 12, lineHeight: 18 },
},
},
}],
legend,
series: [{
avoidLabelOverlap: true,
center: ['50%', '43%'],
data: data.map((item, index) => ({
...item,
itemStyle: {
color: resolveXinyuanChartColor(
colorForItem?.(item, index)
|| colorMap.get(String(item.name ?? '').trim())
|| getXinyuanChartColor(item.name, index),
),
},
})),
label: {
alignTo: 'labelLine',
distanceToLabelLine: 6,
formatter: ({ name, value }) => `{name|${name}}\n{value|${formatCount(value)} 条}`,
lineHeight: 18,
minMargin: 10,
rich: {
name: { color: theme.label, fontSize: 13, fontWeight: 500, lineHeight: 18 },
value: { color: theme.label, fontSize: 14, fontWeight: 600, lineHeight: 20 },
},
show: true,
},
labelLine: {
length: 20,
length2: 22,
// ECharts uses each pie item's visual color when no explicit color
// is provided, keeping the leader line tied to its sector.
lineStyle: { width: 1.5 },
show: true,
},
labelLayout: (params) => {
const points = params?.labelLinePoints;
if (!Array.isArray(points) || points.length < 3) return null;
const endpoint = points[2];
const isRight = endpoint[0] >= points[0][0];
return {
align: isRight ? 'left' : 'right',
hideOverlap: true,
moveOverlap: 'none',
verticalAlign: 'middle',
x: endpoint[0] + (isRight ? 8 : -8),
y: endpoint[1],
};
},
name: centerLabel,
radius: ['52%', '72%'],
type: 'pie',
}],
tooltip: { trigger: 'item', valueFormatter: (value) => `${formatCount(value)} 条` },
};
}, [centerLabel, colorForItem, data, total, viewportWidth]);
return <XinyuanEChart ariaLabel={`${centerLabel}分布图`} option={option} />;
}
function HorizontalBarChart({ data, color = XINYUAN_CHART_SEMANTIC.primary, onSelect }) {
function HorizontalBarChart({
data,
color = XINYUAN_CHART_SEMANTIC.primary,
height = 320,
onSelect,
}) {
const option = useMemo(() => {
return {
grid: { containLabel: true, left: 16, right: 24, top: 12, bottom: 12 },
tooltip: {
axisPointer: { type: 'shadow' },
trigger: 'axis',
valueFormatter: (value) => `${formatCount(value)} 条`,
},
xAxis: { allowDecimals: false, splitLine: { show: false }, type: 'value' },
yAxis: { axisLabel: { width: 112, overflow: 'truncate' }, data: data.map((item) => item.name), inverse: true, type: 'category' },
series: [{
barMaxWidth: 28,
data: data.map((item) => ({
...item,
itemStyle: { color: resolveXinyuanChartColor(item.fill || color) },
})),
name: '数量',
type: 'bar',
}],
};
}, [color, data]);
return (
<BarChart
className="rc-chart-interaction"
data={data}
height={320}
layout="vertical"
margin={{ left: 16, right: 24 }}
>
<BarChart.Grid horizontal={false} />
<BarChart.XAxis allowDecimals={false} type="number" />
<BarChart.YAxis dataKey="name" type="category" width={112} />
<BarChart.Tooltip
content={(
<XinyuanChartTooltipContent
colorForEntry={({ sourceDatum }) => sourceDatum?.fill || color}
valueFormatter={(value) => `${formatCount(value)} 条`}
<XinyuanEChart
ariaLabel="数量横向柱状图"
height={height}
onEvents={onSelect ? {
click: (params) => onSelect({
...params,
key: params?.data?.key,
payload: params?.data,
}),
} : undefined}
option={option}
/>
)}
/>
<BarChart.Bar
dataKey="value"
fill={color}
maxBarSize={28}
name="数量"
radius={[0, 4, 4, 0]}
onClick={onSelect}
>
{data.map((item, index) => (
<Cell key={`${item.name}-${index}`} fill={item.fill || color} />
))}
</BarChart.Bar>
</BarChart>
);
}
function StackedSourceChart({ sourceComparison }) {
const seriesColorMap = createXinyuanChartColorMap(
sourceComparison.series.map((series) => series.name),
);
const chartData = sourceComparison.categories.map((name, index) => ({
name,
...Object.fromEntries(
sourceComparison.series.map((series) => [series.name, Number(series.data[index] || 0)]),
),
}));
return (
<BarChart
className="rc-chart-interaction"
data={chartData}
height={320}
margin={{ left: 8, right: 16, top: 24 }}
>
<BarChart.Grid vertical={false} />
<BarChart.XAxis dataKey="name" />
<BarChart.YAxis allowDecimals={false} />
<BarChart.Tooltip
content={(
<XinyuanChartTooltipContent
colorForEntry={({ entry }) => (
seriesColorMap.get(String(entry?.name ?? entry?.dataKey ?? '').trim())
)}
valueFormatter={(value) => `${formatCount(value)} 条`}
/>
)}
/>
<Legend
iconSize={8}
iconType="circle"
labelStyle={CHART_LEGEND_LABEL_STYLE}
/>
{sourceComparison.series.map((series, index) => (
<BarChart.Bar
key={series.name}
dataKey={series.name}
fill={seriesColorMap.get(String(series.name ?? '').trim())
|| getXinyuanChartColor(series.name, index)}
maxBarSize={34}
stackId="total"
/>
))}
</BarChart>
function SourceComparisonChart({ sourceComparison }) {
const option = useMemo(() => {
const theme = getXinyuanEChartsTheme();
const units = sourceComparison.series.map((series) => series.name);
const categories = sourceComparison.categories;
const maxValue = Math.max(
0,
...sourceComparison.series.flatMap((series) => (
series.data.map((value) => Number(value || 0))
)),
);
const cells = sourceComparison.series.flatMap((series, unitIndex) => (
categories.map((category, categoryIndex) => {
const count = Number(series.data[categoryIndex] || 0);
return {
label: {
color: count > 0 ? theme.accentForeground : theme.label,
},
value: [unitIndex, categoryIndex, count],
};
})
));
return {
grid: { containLabel: true, left: 8, right: 16, top: 12, bottom: 68 },
visualMap: {
dimension: 2,
inRange: {
color: [theme.surfaceSecondary, theme.palette[0]],
},
max: maxValue || 1,
min: 0,
show: false,
},
tooltip: {
formatter: (params) => {
const value = Array.isArray(params?.value) ? params.value : [];
const unit = units[value[0]] || '未分类单位';
const source = categories[value[1]] || '未分类来源';
return `${source}<br/>${unit}:${formatCount(value[2])} 条`;
},
trigger: 'item',
},
xAxis: {
axisLabel: {
hideOverlap: true,
interval: 0,
lineHeight: 16,
margin: 12,
overflow: 'break',
rotate: units.length > 2 || units.some((unit) => String(unit).length > 6) ? 28 : 0,
width: 96,
},
data: units,
type: 'category',
},
yAxis: {
axisLabel: {
hideOverlap: true,
interval: 0,
lineHeight: 16,
margin: 12,
overflow: 'break',
width: 112,
},
data: categories,
inverse: true,
type: 'category',
},
series: [{
data: cells,
emphasis: {
itemStyle: {
borderColor: theme.accentForeground,
borderWidth: 2,
},
},
itemStyle: {
borderColor: theme.sectorGap,
borderWidth: 3,
},
label: {
fontSize: 13,
fontWeight: 600,
formatter: ({ value }) => formatCount(value?.[2]),
show: true,
},
progressive: 0,
type: 'heatmap',
}],
};
}, [sourceComparison]);
// Keep the matrix readable while avoiding the oversized empty area created
// by a fixed 360px canvas for the usual six-row dataset.
const chartHeight = Math.max(300, sourceComparison.categories.length * 36 + 56);
return <XinyuanEChart ariaLabel="问题来源与检查单位矩阵图" height={chartHeight} option={option} />;
}
function DepartmentMatrixChart({ data }) {
return (
<ComposedChart
className="rc-chart-interaction"
data={data}
height={320}
margin={{ left: 8, right: 8, top: 24 }}
>
<ComposedChart.Grid vertical={false} />
<ComposedChart.XAxis dataKey="name" />
<ComposedChart.YAxis allowDecimals={false} yAxisId="count" />
<ComposedChart.YAxis
axisLine={false}
domain={[0, 100]}
orientation="right"
tickFormatter={(value) => `${value}%`}
yAxisId="rate"
/>
<ComposedChart.Tooltip
content={(
<XinyuanChartTooltipContent
colorForEntry={({ entry }) => ({
completionRate: XINYUAN_CHART_SEMANTIC.positive,
overdueCount: XINYUAN_CHART_SEMANTIC.warning,
totalCount: XINYUAN_CHART_SEMANTIC.primary,
})[entry?.dataKey]}
/>
)}
/>
<Legend
iconSize={8}
iconType="circle"
labelStyle={CHART_LEGEND_LABEL_STYLE}
/>
<ComposedChart.Bar
dataKey="totalCount"
fill={XINYUAN_CHART_SEMANTIC.primary}
maxBarSize={28}
name="问题总数"
yAxisId="count"
/>
<ComposedChart.Bar
dataKey="overdueCount"
fill={XINYUAN_CHART_SEMANTIC.warning}
maxBarSize={28}
name="逾期未完成"
yAxisId="count"
/>
<ComposedChart.Line
dataKey="completionRate"
dot={false}
name="整改完成率"
stroke={XINYUAN_CHART_SEMANTIC.positive}
strokeWidth={2}
type="monotone"
yAxisId="rate"
/>
</ComposedChart>
);
const option = useMemo(() => {
return {
grid: { containLabel: true, left: 8, right: 8, top: 24, bottom: 92 },
legend: { bottom: 8, left: 'center', width: '92%', itemGap: 12 },
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
xAxis: {
axisLabel: {
hideOverlap: true,
interval: 0,
lineHeight: 16,
margin: 12,
overflow: 'break',
width: 112,
},
data: data.map((item) => item.name),
type: 'category',
},
yAxis: [
{ allowDecimals: false, type: 'value' },
{ axisLabel: { formatter: (value) => `${value}%` }, max: 100, min: 0, position: 'right', type: 'value' },
],
series: [
{ barMaxWidth: 28, data: data.map((item) => Number(item.totalCount || 0)), itemStyle: { color: resolveXinyuanChartColor(XINYUAN_CHART_SEMANTIC.primary) }, name: '问题总数', type: 'bar', yAxisIndex: 0 },
{ barMaxWidth: 28, data: data.map((item) => Number(item.overdueCount || 0)), itemStyle: { color: resolveXinyuanChartColor(XINYUAN_CHART_SEMANTIC.warning) }, name: '逾期未完成', type: 'bar', yAxisIndex: 0 },
{ data: data.map((item) => Number(item.completionRate || 0)), lineStyle: { width: 2 }, name: '整改完成率', smooth: true, symbol: 'none', type: 'line', yAxisIndex: 1, itemStyle: { color: resolveXinyuanChartColor(XINYUAN_CHART_SEMANTIC.positive) } },
],
};
}, [data]);
return <XinyuanEChart ariaLabel="部门整改责任矩阵图" height={360} option={option} />;
}
export function ProblemRectificationDashboardPage({ dashboardMode = 'overview' } = {}) {
......@@ -489,6 +542,7 @@ export function ProblemRectificationDashboardPage({ dashboardMode = 'overview' }
<Tabs
className="w-fit max-w-full min-w-0"
selectedKey={activeGroupKey}
variant="primary"
onSelectionChange={(key) => setActiveGroupKey(String(key))}
>
<Tabs.ListContainer className="w-fit max-w-full min-w-0">
......@@ -651,7 +705,7 @@ export function ProblemRectificationDashboardPage({ dashboardMode = 'overview' }
<div className="rc-page-grid rc-chart-grid">
<Panel title="问题来源对比">
{summary.sourceComparison.categories.length > 0 ? (
<StackedSourceChart sourceComparison={summary.sourceComparison} />
<SourceComparisonChart sourceComparison={summary.sourceComparison} />
) : (
<EmptyBlock title="暂无问题来源对比数据" />
)}
......@@ -661,6 +715,7 @@ export function ProblemRectificationDashboardPage({ dashboardMode = 'overview' }
{focusIssueChartData.some((item) => item.value > 0) ? (
<HorizontalBarChart
data={focusIssueChartData}
height={300}
onSelect={(entry) => {
const issueTypeKey = entry?.payload?.key || entry?.key;
if (issueTypeKey) setActiveFocusIssueTypeKey(issueTypeKey);
......
......@@ -58,6 +58,7 @@ import {
} from '@/utils/subjectView';
import {
DEFAULT_PROBLEM_RECTIFICATION_PAGE_SIZE,
PROBLEM_RECTIFICATION_PAGE_SIZE_OPTIONS,
PROBLEM_RECTIFICATION_SUBJECT,
AUDIT_STATUS_APPROVED,
AUDIT_STATUS_PENDING,
......@@ -419,6 +420,7 @@ function CreateFieldSelect({ value, options, onChange, placeholder = '请选择'
return (
<XinyuanAutocomplete
aria-label={placeholder}
isClearable={Boolean(value)}
options={options.map((option) => ({
id: String(option.value),
label: option.label,
......@@ -427,6 +429,7 @@ function CreateFieldSelect({ value, options, onChange, placeholder = '请选择'
selectedKey={value || null}
variant={variant}
isDisabled={disabled}
onClear={() => onChange('')}
onSelectionChange={(key) => onChange(key ? String(key) : '')}
/>
);
......@@ -805,7 +808,7 @@ export default function ProblemRectificationListPage() {
const [delayDepartmentAssignments, setDelayDepartmentAssignments] = useState([]);
const [successMessage, setSuccessMessage] = useState('');
const [page, setPage] = useState(0);
const pageSize = DEFAULT_PROBLEM_RECTIFICATION_PAGE_SIZE;
const [pageSize, setPageSize] = useState(DEFAULT_PROBLEM_RECTIFICATION_PAGE_SIZE);
const [pagination, setPagination] = useState({
page: 0,
pageSize: DEFAULT_PROBLEM_RECTIFICATION_PAGE_SIZE,
......@@ -1857,20 +1860,6 @@ export default function ProblemRectificationListPage() {
)
)}
toolbarActions={activeGridView ? [
{
key: 'export',
label: '导出',
variant: 'secondary',
disabled: true,
onClick: () => {},
},
{
key: 'archive',
label: '一键归档',
variant: 'secondary',
disabled: true,
onClick: () => {},
},
{
key: 'create',
label: '新增记录',
......@@ -2015,12 +2004,18 @@ export default function ProblemRectificationListPage() {
pagination={{
page,
pageSize,
pageSizeOptions: PROBLEM_RECTIFICATION_PAGE_SIZE_OPTIONS,
totalRecords: totalMatchingRecords,
totalPages: pagination.totalPages,
onPageChange: (nextPage) => {
setPage(nextPage);
setSelectedIds([]);
},
onPageSizeChange: (nextPageSize) => {
setPageSize(nextPageSize);
setPage(0);
setSelectedIds([]);
},
}}
stickyTable
renderCell={({ record, column, expandable, expanded, toggleExpanded }) => {
......
......@@ -31,8 +31,8 @@ import {
} from '@/utils/subjectView';
export const PROBLEM_RECTIFICATION_SUBJECT = 'problem_rectification';
export const PROBLEM_RECTIFICATION_PAGE_SIZE_OPTIONS = [10, 20, 50];
export const DEFAULT_PROBLEM_RECTIFICATION_PAGE_SIZE = 10;
export const PROBLEM_RECTIFICATION_PAGE_SIZE_OPTIONS = [20, 50, 100];
export const DEFAULT_PROBLEM_RECTIFICATION_PAGE_SIZE = 20;
export const DEPARTMENT_RECTIFICATION_FIELD = 'department_rectification_items';
export const DELAY_REQUEST_FIELD = 'problem_rectification_delay_request';
export const DELAY_REQUEST_ITEMS_FIELD = 'problem_rectification_delay_request_items';
......@@ -854,6 +854,15 @@ export function buildProblemRectificationFilterSchema(columns = []) {
} else if (!['text', 'long_text', 'rich_text'].includes(type)) {
return fields;
}
const placeholder = controlType === 'select'
? '请选择'
: controlType === 'date'
? '请选择日期'
: controlType === 'number'
? '请输入数字'
: controlType === 'user' || controlType === 'reference'
? '输入关键词'
: '请输入';
fields.push({
key: `field:${field}`,
field,
......@@ -861,7 +870,7 @@ export function buildProblemRectificationFilterSchema(columns = []) {
label: column.label || field,
type: controlType,
options,
placeholder: `筛选${column.label || field}`,
placeholder,
});
return fields;
}, []);
......
@import "@fontsource/inter/400.css";
@import "@fontsource/inter/500.css";
@import "@fontsource/inter/600.css";
@import "tailwindcss";
@import "tailwindcss" source(none);
@import "@heroui/styles";
@import "@heroui-pro/react/css";
@import "./design-system/theme.css";
@import "./design-system/tokens.css";
@import "./design-system/shell.css";
@source "./*.{js,jsx,ts,tsx}";
@source "./components/**/*.{js,jsx,ts,tsx}";
@source "./pages/**/*.{js,jsx,ts,tsx}";
@source "./wrapper/**/*.{js,jsx,ts,tsx}";
@theme {
--font-inter:
"Inter", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
......@@ -17,19 +19,12 @@
--font-inter:
"Inter", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
color-scheme: light !important;
--pm-brand: var(--rc-brand-primary);
--pm-brand-strong: var(--rc-brand-dark);
}
html {
color-scheme: light !important;
}
html.dark,
html[data-theme="dark"] {
color-scheme: dark !important;
}
body {
margin: 0;
font-family: var(--rc-font-sans);
......@@ -38,6 +33,43 @@ body {
color-scheme: inherit;
}
:is(.checkbox[data-selected="true"], .checkbox[data-indeterminate="true"])
.checkbox__control,
:is(
.checkbox__content[data-selected="true"],
.checkbox__content[data-indeterminate="true"]
)
.checkbox__control {
border-color: var(--rc-brand-primary) !important;
background: var(--rc-brand-primary) !important;
box-shadow: none !important;
filter: none !important;
}
:is(.checkbox[data-selected="true"], .checkbox[data-indeterminate="true"])
.checkbox__control::before,
:is(
.checkbox__content[data-selected="true"],
.checkbox__content[data-indeterminate="true"]
)
.checkbox__control::before {
border-color: var(--rc-brand-primary) !important;
background: var(--rc-brand-primary) !important;
box-shadow: none !important;
filter: none !important;
}
:is(.checkbox[data-selected="true"], .checkbox[data-indeterminate="true"])
.checkbox__indicator,
:is(
.checkbox__content[data-selected="true"],
.checkbox__content[data-indeterminate="true"]
)
.checkbox__indicator {
color: var(--accent-foreground) !important;
filter: none !important;
}
body .login-page {
--rc-login-brand-logo-size: 2.25rem;
--rc-login-card-brand-gap: 0.875rem;
......
import authService from '@/services/authService';
import { getUserInfo, userLogout } from '@/services/DataService';
import { Spinner } from '@heroui/react';
import { createContext, useEffect, useMemo, useState } from 'react';
import { history, Navigate, Outlet, useLocation } from 'umi';
......@@ -84,8 +85,9 @@ export default function AuthWrapper() {
if (!loaded) {
return (
<div className="flex min-h-screen items-center justify-center text-sm text-slate-500">
正在验证登录状态...
<div className="flex min-h-screen items-center justify-center gap-2 text-sm text-muted" role="status">
<Spinner size="sm" />
<span>正在验证登录状态...</span>
</div>
);
}
......
......@@ -6,6 +6,7 @@ import weekday from 'dayjs/plugin/weekday';
import { createContext, useEffect, useState } from 'react';
import { Outlet } from 'umi';
import { APPLICATION_DISPLAY_NAME } from '@/config/application';
import { Spinner } from '@heroui/react';
dayjs.extend(weekday);
dayjs.extend(localeData);
......@@ -59,8 +60,9 @@ export default function SiteWrapper() {
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center text-sm text-slate-500">
加载中...
<div className="flex min-h-screen items-center justify-center gap-2 text-sm text-muted" role="status">
<Spinner size="sm" />
<span>加载中...</span>
</div>
);
}
......
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