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

feat: finalize Xinyuan HeroUI design standards

parent 837b6947
......@@ -10,21 +10,32 @@
## 标准实现
- 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`。
- DataGrid 宽度:共享层直接使用 HeroUI Pro `ColumnSize`。非宽表将第一业务主列设为 `1fr`,选择/操作列固定并以 `min-w-full` 保证完整宽度链;宽表保留业务最小像素宽度,仅在 DataGrid 内横向滚动。页面不得用背景、伪元素或空列填补右侧空白。
- DataGrid 行操作:共享 `TableActionButton` 直接组合 HeroUI `Button`、`Dropdown`、`Tooltip` 与 Ant Design Icons,不兼容旧按钮 API。普通操作使用 `secondary` 正圆,危险操作使用 `danger-soft` 正圆;32px 按钮、16px 图标、8px 间距并与表头左对齐,列宽按可见按钮数动态计算。外观状态由 HeroUI variant 与 `theme.css` 管理,布局类只保证圆心和 SVG 中心重合。
- Modal:HeroUI `Modal`/`AlertDialog` compound API,标题、正文和页脚上下结构,Portal 继承根主题。内容型新增表单与详情统一使用 `4xl`(1088px);同一表单的 Input、TextArea、DateField.Group、Autocomplete 和多选控件统一使用浅色 `primary`、深色 `secondary` 的 HeroUI field variant。提交约束仅在用户点击确认且校验失败后通过受控 `AlertDialog` 展示,不在表单中常驻。可编辑详情默认是查看态,Footer 右下的单一“编辑”主操作切换到整窗编辑,查看态仅通过右上关闭图标退出;草稿由弹窗统一持有,Footer 原位切换取消/保存并通过原更新服务一次提交,不允许字段级即时保存。
- DataGrid 行操作:共享 `TableActionButton` 直接组合 HeroUI `Button`、`Dropdown`、`Tooltip` 与 Ant Design Icons,不兼容旧按钮 API。普通查看/编辑/日期/复制/锁定/更多使用 `tertiary` 中性正圆,明确关键的审批/提交使用 `secondary` 强调正圆,删除/驳回使用 `danger-soft` 危险正圆;32px 按钮、16px 图标、8px 间距并与表头左对齐,列宽按可见按钮数动态计算。外观状态由 HeroUI variant 与 `theme.css` 管理,布局类只保证圆心和 SVG 中心重合。
- HeroUI 危险语义统一由 `theme.css` 的鑫元红系 `--danger` 驱动:浅色为主红 `#9E1B1B`,深色为同色相增亮红;`danger-soft` 、Hover 和前景色保留 HeroUI 官方派生公式。删除、驳回、退出和失败状态不得在页面层写粉色、洋红或私有红色。
- Modal:HeroUI `Modal`/`AlertDialog` compound API,标题、正文和页脚上下结构,Portal 继承根主题。层级固定为 `Backdrop -> Modal.Dialog overlay -> nested secondary -> rare static tertiary`:Header/Body/Footer 透明承载;普通详情查看态用一个 `Surface secondary` 承载整组只读标签/值,字段本身不重复套 Card;对象预览或 Logo well 等真实第三层静态内容才使用 `tertiary`。进入编辑态后不保留整组查看 Surface,由 `secondary` 表单控件直接位于 Overlay。禁止用默认 L1 Card 在 Overlay 内表达嵌套边界,也禁止给每个只读字段套 Card 或用页面 CSS 补背景。内容型新增表单与详情统一使用 `4xl`(1088px);Modal 内 Input、TextArea、DateField.Group、Autocomplete 和多选控件在两种主题下统一使用 HeroUI `secondary`。提交约束仅在用户点击确认且校验失败后通过受控 `AlertDialog` 展示,不在表单中常驻。可编辑详情默认是查看态,Footer 右下的单一“编辑”主操作切换到整窗编辑,查看态仅通过右上关闭图标退出;草稿由弹窗统一持有,Footer 原位切换取消/保存并通过原更新服务一次提交,不允许字段级即时保存。
- 详情查看 Surface 的跨应用固定几何为 `rounded-2xl px-5 py-2 sm:px-6 sm:py-3`;内部字段网格为单列到 `md` 双列、`gap-x-8 gap-y-2`,普通字段 `py-3`,标签与值间距 6px,长内容跨两列。此结构只控制视觉包含关系,不能拥有或重置业务状态。
- 详情字段的空值展示属于纯表现层:`null`、空字符串、仅空格字符串和空数组在查看态统一显示弱化的“暂无内容”,字段本身保持无框;编辑态的 HeroUI `Input` / `TextArea` 使用“请输入{可见字段名}”作为 `placeholder`。任何提示文案都不得进入受控 `value`、业务校验或保存 payload。
- 表单: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。
- 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;保留原统计数据与业务回调,不保留 ECharts 私有主题。
- 图表: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 私有主题。
- 图表 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 的图表不得让当前单图模块退化为左半宽。
- 页面布局:`rc-app-page` 统一承担响应式页边距;页面根、模块、网格、工具栏和列表分别使用 `rc-page-stack`、`rc-page-section`、`rc-page-grid`、`rc-page-toolbar`、`rc-list-stack`。这些类只管理布局几何,不选择或覆盖 HeroUI 内部 DOM。
- 列表工具栏:数据集标题单独位于透明列表区首行,下一行才是命令工具栏。搜索与列分组并存时,左侧顺序固定为 HeroUI `SearchField primary` 在前、HeroUI `ToggleButton` 分组控件在后,右侧为业务操作;主操作使用 `Button primary` 并最右,次操作使用 `Button secondary`。移动端搜索先独占整行,分组控件在下一行自然换行。列分组 ToggleButton 直接使用官方状态和 Ant Design 16px 图标,不重画胶囊背景:列正常展示时是默认灰色未选中态,列被收起时才是主题选中态;Tooltip/aria-label 根据当前状态只描述下一步“显示”或“隐藏”动作。只有 Surface/overlay 内的嵌套字段使用 `secondary` SearchField;`outline` 不作为次按钮默认映射。
- 模块 Card 控件:Card Header 使用单行“左侧标题 + 右侧控件”。并列内容视图用 HeroUI Tabs;分类层级、日期范围、统计口径等同一内容的参数用直接 HeroUI Autocomplete 或 HeroUI-native `XinyuanAutocomplete`,使用 `secondary` 并按内容紧凑定宽。参数变化只连接原状态和回调,不改变统计口径、请求或数据结构。
## 主题
`src/design-system/theme.css` 是权威主题,必须与 `xinyuan-design-system` 基线逐字节一致。加载顺序为 Tailwind、HeroUI、HeroUI Pro、`theme.css`、`tokens.css`、Shell/Login 样式。`tokens.css` 只提供 Shell/Login 所需的 `--rc-*` 别名,不覆盖 HeroUI 核心或组件 Token。内容页面禁止用 CSS 选择 HeroUI 内部 BEM 类、slot 状态或选中属性。
`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 状态或选中属性。
页面几何 Token 是允许的扩展:Navbar Header、HeroUI Pro 折叠按钮、主题按钮和页面内容共用 `--rc-shell-inline-gutter`,桌面为 48px、平板为 32px、手机为 16px,左右完全一致。页面一级模块、模块网格和列表内部节奏为 32/24/16px;手机端一级模块和网格收敛为 24/16px。页面不得新增另一套私有 gutter 或顶层 `space-y-*`。
......
......@@ -36,22 +36,37 @@
- 当前桌面会话没有可用浏览器连接,因此表格勾选、Portal、暗色切换和响应式仍保持“待浏览器验收”,不以构建结果替代。
- 本地枚举、表单单选/多选、远程用户/引用、统计年份和分页条数已统一为 HeroUI Autocomplete;活动业务代码的 Select、原生 select 与页面自建选择 Popover 扫描为零。
- Autocomplete 编译和静态 selection 契约检查已通过;打开态搜索、单选关闭、多选保留、清空、键盘焦点及深浅色 Portal 仍待真实浏览器验收。
- 日期/月份字段的日历触发器与 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 与切换回调保持原样。
- DataGrid 行操作已统一为 HeroUI `secondary` / `danger-soft` 正圆按钮,Ant Design 16px 图标采用三层网格居中,操作组左对齐且间距 8px;操作列宽度随 1/2/3 个可见入口计算为 80/112/144px,4 个及以上折叠为“前两个 + 更多”。业务回调、权限、禁用条件和操作排序未改,真实像素居中与 Tooltip/Dropdown 打开态仍待浏览器验收。
- 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。归档等少列列表不再出现右侧大块空白;真实宽表仍保留内部横向滚动。各视口的实际列分配仍待浏览器验收。
- 页容量选择器已从共享分页、问题清单和归档列表移除。分页栏只保留 HeroUI compound Pagination 内容,数据层默认并固定为 10 条/页;翻页与服务端 `page_size` 逻辑未改。
- 归档页的数字指标卡已从页面私有 `Card + Chip` 排版替换为 HeroUI Pro `KPI` compound API;标题在 Header,数值和语义图标由 Content 原生两列布局承载,不再显示右上角说明图标。统计口径与数值未改。
- 驾驶舱与归档页的 KPI 组已改用 16px 关联项间距;一级模块仍保持 32px,普通图表/模块网格仍保持 24px。HeroUI Pro KPI 内部 padding 和组件外观未覆盖。
- 列表筛选已改为条件数量自适应的 HeroUI 布局:1–2 个条件直接位于列表标题上方,3 个以上保留独立 Card;问题清单的 10 个条件首行展示 3 个等宽高频项,其余 7 项使用原生 HeroUI Disclosure 展开。筛选组采用 12px 间距、标签与控件 6px 间距,宽屏展开区为 4 列;该页原本已随筛选状态自动请求,已移除重复查询和清空图标入口,筛选值、请求参数与自动刷新时机未改。
- 驾驶舱、归档和整改流程的数值图标已统一到共享 `.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 和图例一致性仍待真实浏览器验收。
- 整改分析页图表按当前可见业务分组接入 `.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 圆点、系列颜色、数据顺序和图表交互未改。真实宽屏换行和暗色模式仍待浏览器验收。
- 列表筛选已改为条件数量自适应的 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 列。
- 筛选 Card 内的 Autocomplete、DateField.Group 和普通 Input 已改为根主题驱动的 HeroUI 官方 variant:浅色 `primary` 避免 secondary 灰度过深,暗色 `secondary` 避免字段表面与 Card 融为一体;未增加私有颜色、描边或主题覆盖。
- 筛选页面内层级已统一为页面 `background` → HeroUI `Surface default` → HeroUI `secondary` 筛选框。权威主题已将 `--default` 映射到 `--surface-secondary`,修正原值与 `surface-tertiary` 过近造成的深灰错觉;外层 Surface 直接使用 `rounded-3xl p-6`,不叠加描边或 Card 阴影,内部 Autocomplete、DateField.Group 和普通 Input 在浅色/暗色中都使用 `secondary`。筛选 Autocomplete 和日期选择的真实 Popover 已取消 `surface-tertiary` 覆盖,与 Modal、Dropdown、Tooltip 等全部浮层统一使用 HeroUI 默认 `overlay`;Popover 内 SearchField 继续使用 `secondary` 作为浮层内控件。
- Surface 层级已固化到项目设计规范、UI 架构和共享 `xinyuan-design-system` skill:`background -> surface/default -> surface-secondary -> surface-tertiary` 只描述页面内嵌套,`overlay` 是独立的浮动/阻断维度,不属于第四层 Surface。旧“白色筛选 Card + 描边/阴影”和筛选弹层私有表面规则已移除。
- 问题清单“整改情况”列已提高到 420px(固定时 360px),使用两行换行和 HeroUI Tooltip 展示全文,避免内容越过单元格;所有 user 类型列表字段已改为 HeroUI `Avatar` + 姓名的紧凑身份展示,人员列至少 220px。
- 驾驶舱与整改分析的年度范围控件已去除重复“统计年份”可见标签,保留 `aria-label`,使用 144px 宽的 HeroUI Autocomplete `primary` 触发器;年份来源、默认值和切换回调未改变。
- 问题整改详情已调整为 HeroUI Modal 的 1088px 内容型宽度,28px inset、内部滚动和右上角全屏/关闭操作组;全屏图标不再落到标题下方,字段内容移除逐字段阴影 Card,改为紧凑两列标签/值网格。
- 问题整改详情已改为弹窗级查看/编辑状态:默认字段只读,单一“编辑”入口进入整窗编辑,Footer 统一“取消 / 保存”;取消恢复原记录,保存沿用原 `updateRecord` 和嵌套 payload 一次提交变更,视图禁用、锁定和只读字段不进入编辑。生产构建与静态状态检查已通过,真实点击与接口请求仍待浏览器验收。
- 新增问题整改记录弹窗已与详情统一为 HeroUI `4xl`(1088px);Input、TextArea、DateField.Group、Autocomplete 和多选控件共用浅色 `primary` / 深色 `secondary` variant 并铺满字段列。原“检查项目名称、问题简述至少填写一项”不再常驻,只有点击“确认新增”且原校验失败时才打开 HeroUI `AlertDialog`,关闭后保留草稿且不发送新增请求。已在真实浏览器验证 1280x720 浅色/深色和 390x844:桌面弹窗宽 1088px,移动端宽 358px且保留 16px安全边距;三类字段均为 36px高、同列等宽、同主题背景,校验失败期间未出现新增接口请求。
- 新增问题整改记录弹窗已与详情统一为 HeroUI `4xl`(1088px);Input、TextArea、DateField.Group、Autocomplete 和多选控件在浅色、深色中统一使用 `secondary` 并铺满字段列,不再监听主题切换 variant。原“检查项目名称、问题简述至少填写一项”不再常驻,只有点击“确认新增”且原校验失败时才打开 HeroUI `AlertDialog`,关闭后保留草稿且不发送新增请求。此前已验证弹窗宽度、移动端安全边距和校验行为;本次统一后的两种主题字段层级仍需真实浏览器复验。
- 新增表单的短字段已统一为桌面等宽双列,责任部门等多选字段不再单独占满整行,并会与下一项整改进度配对;问题简述、整改情况和备注等长文本仍跨两列,移动端全部回退单列。真实浏览器已验证责任部门/整改进度同排各 508px,390px 视口下各字段依次回退为 302px 单列。
- 问题清单与归档列表的单一关键词字段已从筛选 Card 拆出,改为列表标题右侧的 HeroUI `SearchField secondary`;桌面保持 320–384px,手机换行全宽,原关键词值、自动请求和清空回调保持不变。
- 弹窗内部层级已统一为 `Backdrop -> Overlay -> secondary -> rare tertiary`:应用切换项与骨架、详情附件编辑器、流程详情摘要对象使用 HeroUI `secondary`;Header/Body/Footer 保持透明,普通详情查看态通过一个 `Surface secondary` 承载整组无框标签/值。已移除需要边界的默认 L1 Card,未新增页面私有背景、描边或阴影;真实深浅色层级仍待浏览器验收。
- 应用切换 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/读屏文案按当前状态分别为“隐藏{分组名}列”或“显示{分组名}列”。列显隐回调、滚动恢复、搜索请求及播报逻辑未改;真实桌面/移动端对齐仍待浏览器复验。
- 整改分析“问题类型 / 业务领域分布”的分类层级已从 Header 下方整行 Tabs 改为标题右侧 HeroUI-native `XinyuanAutocomplete secondary`;一级至四级选项、`activeCategoryLevel` 状态和原数据切换回调保持不变。该控件按模块参数而非并列视图处理,其他真实视图切换 Tabs 未受影响。
- 驾驶舱与整改分析的 Pie/Bar/Composed Tooltip 已统一通过共享 payload 颜色归一化后进入 HeroUI Pro `ChartTooltipContent`;环图和单系列 Tooltip 不再因不可见 stroke 丢失颜色圆点,多系列圆点继续与柱/线/图例同色。统计值、Hover 触发和数据口径未改,真实浏览器仍需复验全部图表。
- 驾驶舱与整改分析的 Pie/Bar/Composed 根已统一挂载 `rc-chart-interaction`;鼠标点击图形区域不再显示浏览器蓝色 Focus 方框,键盘 `focus-visible` 仍使用鑫元主题焦点环。Recharts `accessibilityLayer`、图表点击与 Tooltip 逻辑均未关闭。
- 仍需在 1440x900、1024x768、768x1024 与 390x844 浏览器视口核验对齐线、换行、DataGrid 内部横向滚动和移动端无页面级横向溢出。
......@@ -13,9 +13,10 @@
## 2. 主题权威
- `src/design-system/theme.css` 是浅色、暗色、品牌色、语义色、圆角和字体的唯一主题权威,必须与批准的鑫元 `theme.css` 逐字节一致。
- `src/design-system/tokens.css` 仅保留 Login/AppShell 的 `--rc-*` 别名,不得覆盖 HeroUI 的组件 Token、阴影、控件高度、DataGrid、Tabs、Modal、Card、Checkbox 或 Pagination 外观。
- `src/design-system/theme.css` 是浅色、暗色、品牌色、语义色、圆角和字体的唯一主题权威,以 HeroUI 官方主题工具导出值为基础,并在文件内固化批准的 L1/L2/Overlay 映射。它必须与 `/Users/liuchenlong/Desktop/theme.css` 及共享 Skill 基线逐字节一致;升级 HeroUI 时重新导出基础值、复核三项语义映射并整体批准新基线。
- `src/design-system/tokens.css` 仅保留 Login/AppShell 别名、页面几何和批准的数据可视化扩展 Token,不得覆盖 HeroUI 的组件 Token、控件高度、DataGrid、Tabs、Modal、Card、Checkbox 或 Pagination 外观。
- 内容页面使用 HeroUI 的 `background`、`surface`、`foreground`、`muted`、`accent`、`success`、`warning`、`danger` 等语义 Token,不新增 hex、zinc/slate/sky/rose 等私有调色板。
- HeroUI `danger` 固定为鑫元红系:浅色 `oklch(45.32% 0.1661 27.03)`(鑫元主红 `#9E1B1B`),深色 `oklch(62% 0.16 27.03)`(同色相增亮)。删除、驳回、退出和失败状态继续使用 HeroUI `danger` / `danger-soft`,软色由 HeroUI 公式自动生成;禁止恢复洋红、亮粉或页面私有危险色。
- 主题挂载在 `document.documentElement`,Portal、Popover、Tooltip、Dropdown 和 Modal 必须继承同一主题。
- 禁止在页面 CSS 中选择 HeroUI 内部类或状态属性,例如 `.data-grid*`、`.tabs*`、`.modal*`、`.checkbox*`、`[data-selected]`、`[data-state]`。
......@@ -27,6 +28,28 @@
- 页面不得用普通 `div` 模拟 Card、Alert、Chip、EmptyState、ListBox、Table 或 Modal。
- 不嵌套 Card;同层内容优先使用间距和 HeroUI 原生分隔结构。
### 3.1 Surface 层级硬规范
Surface 用于表达页面内的包含关系,Overlay 用于表达浮动关系。页面只判断使用场景,不直接写颜色。以下十六进制仅便于人工识别,代码以 `theme.css` 中的 OKLCH 值为准。
| 层级 | Token 与固定值 | 什么时候使用 | 页面写法 |
| --- | --- | --- | --- |
| L0 页面画布 | `--background`:浅色 `oklch(97.02% 0.0045 27.03)`,约 `#F8F4F3`;深色 `oklch(12% 0.0045 27.03)`,约 `#070505` | AppShell、页面底色、无框工具栏所在画布 | 不新建表面;使用页面容器 |
| L1 主内容 | `--surface`:浅色 `oklch(100% 0.0022 27.03)`,约 `#FFFFFE`;深色 `oklch(21.03% 0.009 27.03)`,约 `#1C1716` | 第一层内容面板、筛选 Surface、Card、直接位于页面画布上的搜索框 | `Surface default`、Card 默认、字段 `primary` |
| L2 次要层 | `--surface-secondary`:浅色 `oklch(95.24% 0.0036 27.03)`,约 `#F2EEEE`;深色 `oklch(25.70% 0.0067 27.03)`,约 `#262222` | L1 内的筛选框、表单控件、次按钮、真正的次级内容区 | 字段或按钮 `secondary`;结构区使用 `surface-secondary` |
| L3 第三层 | `--surface-tertiary`:浅色 `oklch(93.73% 0.0036 27.03)`,约 `#EDE9E9`;深色 `oklch(27.21% 0.0067 27.03)`,约 `#2A2625` | 仅限 L2 内仍需嵌套的静态内容区;极少使用 | 只使用语义 Surface Token,不作为字段 variant |
| Overlay 浮层 | `--overlay = --surface`:浅色约 `#FFFFFE`;深色约 `#1C1716`,通过官方 overlay shadow 提升 | Modal、AlertDialog、Autocomplete/DatePicker Popover、Dropdown、Tooltip | 保留 HeroUI 默认 Overlay,不传背景类 |
`theme.css` 的唯一组件映射为:HeroUI `primary` 字段读取 `--field-background = --surface`(L1);HeroUI `secondary` 字段和次按钮读取 `--default = --surface-secondary`(L2);所有 Portal 浮层读取 `--overlay = --surface`。因此页面不得设置 `bg-*`、hex、OKLCH 或组件内部 CSS。
选择规则只有三条:直接位于页面画布上的搜索或输入使用 `primary`;位于 Surface、Card、Modal 或 Overlay 内容中的输入、日期和 Autocomplete 使用 `secondary`;所有弹出面板保持组件默认 Overlay。L3 绝不用于 Input、Button、Popover 或 Modal。
- 3 个及以上结构化筛选固定使用 `<Surface variant="default" className="rounded-3xl p-6">`;不加描边、不加 Card 阴影、不再套 `Card` / `Card.Content`。1–2 个筛选条件保持无框。
- 筛选、Modal 和 Overlay 内容内的 Input、Autocomplete 和真实 `DateField.Group` 在浅色、暗色中都使用 `secondary`,不得根据主题切换 variant。
- 页面 Surface 层级不延伸到 Portal。筛选 Autocomplete/DatePicker 的真实 Popover 与普通表单、详情、Dropdown、Tooltip、Modal 一样,统一使用 HeroUI 官方 `overlay` 表面、前景色、圆角和阴影,不传入背景类覆盖默认浮层。
- Popover 内 SearchField 可继续使用 `secondary` 作为 overlay 内部控件层,但不得改变 Popover 根表面,也不得给筛选浮层建立私有 light/dark 分支。
- 父子层级需要区分时不得解析成相同背景;没有嵌套语义时也不得为制造色差凭空增加一层 Surface。
- 禁止以 `bg-white`、zinc/slate/gray、透明叠色、渐变、模糊、页面私有 light/dark selector 代替主题 Surface。透明仅用于已由父层提供边界的工具栏、列表标题、行或纯布局组。
## 4. Login 与 AppShell
- 应用名称统一为“问题整改”,供 Login、Sidebar、Navbar、应用切换和浏览器标题共用。
......@@ -34,7 +57,8 @@
- Sidebar、Navbar 使用 HeroUI Pro,Logo 浅色使用 `xy-logo-red.png`、暗色使用 `xy-logo-white.png`。
- `src/design-system/shell.css` 只负责应用壳布局、导航定位、折叠、Logo 和响应式,不得延伸到列表、筛选、Tabs、Card、DataGrid 或弹窗正文。
- 侧边栏只有一个导航组时,不在菜单列表上方重复显示应用名称;组名继续作为 `Sidebar.Menu` 的可访问性标签。只有存在两个及以上确需区分的业务分组时才显示 `Sidebar.GroupLabel`。
- 应用切换使用 HeroUI Modal、Card、Avatar、Chip、Skeleton、Alert 和 EmptyState 的默认外观。
- 应用切换使用 HeroUI Modal、Card、Surface、Avatar、Chip、Skeleton、Alert 和 EmptyState。应用 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 页面边距与布局节奏
......@@ -54,16 +78,19 @@
- 所有 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`;`全部`等短标签保持紧凑,`风险管理部`等长标签自然更宽。
- Tab 文字必须完整单行显示,不允许中文部门名、分类名或视图名换成两行,也不通过截断隐藏标签。空间不足时只允许 HeroUI `Tabs.ListContainer` 内置 `ScrollShadow` 横向滚动,外层不得再加 `overflow-x-auto` 形成双滚动。
- 上述 class 只约束宽度、flex 收缩和换行;不得覆盖 `.tabs__list-container`、`.tabs__list`、`.tabs__tab`、`.tabs__indicator` 的背景、边框、圆角、颜色、阴影、尺寸、Hover 或选中态,也不得把文档示例 CSS 复制为项目私有 Tabs 皮肤。
- 筛选区使用 HeroUI Card、Autocomplete、SearchField、Button;关键词搜索和选择器弹层搜索的图标都放在官方 `SearchField.SearchIcon` slot。
- Card 内的结构化筛选字段按根主题切换 HeroUI 官方 variant:浅色使用 `primary` 的 `--field-background` 白色表面,暗色使用 `secondary` 的 `--default` 表面与 Card 拉开层级。Autocomplete、普通 Input 使用同一 variant,日期将 variant 传给真实的 `DateField.Group`;不修改 `theme.css`,不新增页面私有背景、描边或阴影。
- 结构化筛选区直接使用 HeroUI `Surface`、Autocomplete、SearchField 和 Button;不用 Card 代替 Surface,不再套 `Card.Content`。关键词搜索和选择器弹层搜索的图标都放在官方 `SearchField.SearchIcon` slot。
- 筛选页面内层级固定为页面 `background` → `Surface default / bg-surface` → `secondary / --default = --surface-secondary` 筛选框。外层 Surface 使用官方基础示例的 `rounded-3xl p-6`,不额外添加描边、Card 阴影或页面私有背景;Surface 内的 Autocomplete、普通 Input 和真实 `DateField.Group` 在浅色/暗色中统一使用 HeroUI `secondary`。筛选 Autocomplete 与 DatePicker 的真实 Popover 脱离页面层级,直接使用 HeroUI 默认 `overlay`;其内部官方 SearchField 继续继承 `secondary` variant。不得为筛选弹层覆盖背景、前景色、圆角或阴影。
- DataGrid 长文本先按业务重要性扩宽,再允许最多两行自然换行,超出内容通过 HeroUI Tooltip 展示全文;“整改情况”基准宽度为 420px,作为左侧固定列时限制为 360px,任何文字都不得跨越单元格遮挡相邻字段。
- DataGrid 的用户类型字段直接使用 HeroUI `Avatar`:列表使用 `size="sm"`、`variant="soft"`,有头像资源时显示图片,无资源时显示姓名首字回退;头像与姓名水平居中,人员列基准宽度不小于 220px,不使用页面私有渐变、边框或旧 Avatar 组件。
- 驾驶舱、分析页的单一年度范围使用紧凑 HeroUI Autocomplete,选择值本身表达“全部年份”或具体年份时不再重复显示外置“统计年份”标签;触发器使用官方 `primary` 外观和内容宽度,不添加页面私有胶囊、阴影或边框。
- 内容型详情弹窗使用 HeroUI Modal `4xl` 基准宽度 1088px、内部滚动和 28px 内容 inset;全屏与关闭图标在标题栏右上组成同一 24px 操作组并垂直居中。详情字段使用无框标签/值网格,不为每个字段重复创建带阴影 Card。
- 可编辑详情弹窗默认进入查看态,字段值不可点击编辑;Footer 右下只提供一个“编辑”主操作,不再重复显示“关闭”文字按钮,查看态通过右上角关闭图标退出。进入编辑态后一次性展示全部有权限字段的 HeroUI 控件,Footer 原位切换为“取消 / 保存”;取消丢弃整次草稿,保存按原接口和嵌套 payload 一次提交全部变更。禁止每个字段各自显示编辑、清空、取消或保存按钮,锁定、只读和视图禁用字段在编辑态仍保持查看表现。
- 详情编辑态的 Input、TextArea、DatePicker、Autocomplete 和引用选择必须共用同一 HeroUI field variant:浅色 `primary`、深色 `secondary`。不得让选择框呈白底而文本字段呈深灰底,也不得用页面私有背景或描边强行对齐。
- 列表的单一关键词搜索不进入结构化筛选 Card,也不显示字段标签;使用 HeroUI `SearchField` 的 `secondary`、`fullWidth` compound API,放在列表标题右侧。桌面宽度为 320–384px,手机端换到标题下一行并占满可用宽度;搜索和清空图标使用 Ant Design Icons。
- 筛选区按条件数量自适应:1–2 个条件直接放在列表模块标题上方,不额外套 Card;3 个及以上条件使用独立 HeroUI Card。独立筛选卡首行最多保留 3 个高频条件,其余条件使用 HeroUI `Disclosure` 的 `Trigger`、`Indicator` 和 `Content` 展开,不手写高度动画、旋转箭头或折叠状态 DOM。
- 详情编辑态位于 Modal 内,Input、TextArea、DatePicker、Autocomplete 和引用选择在浅色、深色中统一使用 HeroUI `secondary`。不得根据主题切换 variant,也不得用页面私有背景或描边强行对齐。
- 列表的单一关键词搜索不进入结构化筛选 Surface,也不显示字段标签;透明列表区先单独显示数据集标题,再在标题下方放命令工具栏。搜索与列分组并存时,工具栏左侧先放 HeroUI `SearchField` 的默认 `primary`、`fullWidth` compound API,再放 HeroUI `ToggleButton` 列分组控件,业务操作位于最右。桌面端搜索与分组同排水平居中;手机端搜索先独占整行,分组控件在下一行自然换行。只有位于 `Surface` 或 `overlay` 内的嵌套搜索控件才使用 `secondary`。搜索桌面宽度为 320–384px,手机端占满可用宽度;搜索、清空和分组图标使用 Ant Design Icons。所有列表关键词搜索统一使用占位文案“请输入关键词”,不按数据对象变化、不枚举可搜索字段、不加省略号;精确的人员、引用或下拉搜索仍使用明确的目标对象文案。
- 列分组是显示/隐藏字段的 UI 状态,不是数据筛选。每个分组直接使用 HeroUI `ToggleButton size="sm"`,采用 16px Ant Design 语义图标与文字;列正常展示时保持 HeroUI 默认灰色未选中态,列被收起时才使用官方主题选中态,不得继续使用 Switch、私有 segmented control、页面背景色或手写 selected 样式。Tooltip 与 `aria-label` 必须说明点击后的唯一动作:当前展示时写“隐藏{分组名}列”,当前收起时写“显示{分组名}列”,不得使用含糊的“显示或隐藏”。列显隐、滚动恢复和可访问播报必须保持原回调。
- 模块 Card 内先区分“并列视图”和“当前内容参数”:真正替换模块内容的视图、模式或指标切换使用内容宽度 HeroUI Tabs;分类层级、时间范围、统计口径等只改变同一图表/内容呈现方式的参数使用 HeroUI Autocomplete。模块参数选择器位于 Card Header 右侧并与左侧标题同一行水平居中,使用 `secondary` 表面和紧凑内容宽度;不得在标题下方再占一整行 Tabs,也不得使用页面私有 Select。窄屏可让标题换行,但选择器不压缩且不能造成 Card 横向溢出。
- 列表工具栏主操作使用 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 `sm`、`ghost` 按钮与 `Disclosure.Indicator`,静止态透明,文案为“展开”/“收起”。自动筛选页面不显示查询或刷新按钮;只有业务明确采用手动提交时才保留查询按钮。按钮外观、动画、焦点和暗色状态由 HeroUI 与 `theme.css` 管理,页面不得绘制灰色胶囊、分割线或独立箭头。
- 筛选字段、展开/收起、刷新、查询和重置的业务回调保持原样;不得因视觉迁移改变触发时机和请求参数。
......@@ -72,6 +99,7 @@
- Autocomplete、SearchField、ListBox、Checkbox 遵守 React Aria compound/slot 结构,不用旧 DOM 模拟;箭头与搜索/清除图标使用 `AppIcons.jsx` 的 Ant Design Icons。
- `@heroui/styles` 自带的 `autocomplete.css` 是外观权威,不复制用户文档示例 CSS,不在页面中覆盖 `.autocomplete*` 内部类。
- 日期使用共享 HeroUI DatePicker,上传使用 HeroUI Pro DropZone;禁止可见原生 date、select、file 控件。
- 日期、月份和日期时间字段的日历图标与 Autocomplete 下拉箭头统一使用 16px Ant Design Icons,并通过共享 `FIELD_SUFFIX_ICON_STYLE` 让 Ant 图标根节点直接解析 HeroUI `var(--field-placeholder, var(--muted))`;不能只依赖跨组件继承,也不得写黑色、灰色常量或页面私有颜色。两者尾部图标的右侧光学中心必须一致,不允许用页面私有偏移修正。可选日期类字段在有值时显示 HeroUI `CloseButton` 清空入口,清空必须沿用原 `onChange` 通道写回业务既有空值 `''`,不得改变日期格式、查询参数或自动请求时机;必填、只读和禁用字段不显示清空入口。
## 6. DataGrid 与分页
......@@ -81,19 +109,25 @@
- DataGrid 无论列数多少都占满列表可用宽度。非宽表使用 HeroUI Pro 原生列尺寸:选择列和操作列保持固定,第一业务主列使用 `1fr` 吸收剩余空间,表格内容保持 `min-width: 100%`;只有真实最小列宽超过视口时才使用像素列宽和内部横向滚动,不允许出现表面全宽但正文只停在左侧的空白断层。
- 批量选择后使用 HeroUI Pro ActionBar;清除选择和业务操作保留原回调。
- 分页直接使用 HeroUI compound Pagination,只展示总数、当前/总页数、上一页、页码和下一页;不展示页容量选择器。HeroUI 不规定每页条数,本系统在数据层固定为 10 条/页,不得手写页码按钮或 CSS 改写当前页样式。
- 行操作使用 HeroUI `Button isIconOnly size="sm"`、Dropdown、Tooltip 和 Ant Design Icons。普通操作统一 `variant="secondary"`,删除/驳回统一 `variant="danger-soft"`;按钮为 HeroUI 自带的 32px 桌面/36px 触屏正圆,图标固定 16px 并在按钮中水平、垂直居中。
- 行操作使用 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` 作为普通行操作静止态;操作顺序、权限、禁用条件及回调保持不变。
## 7. Card、KPI、Modal 与状态
- 数字指标卡直接使用 HeroUI Pro `KPI` compound API:`KPI.Header + KPI.Title`、可选 `KPI.Actions`、`KPI.Content + KPI.Value + KPI.Icon`。保留 HeroUI 默认的左侧数值/右侧 32px 图标布局,不得用私有 Card/Chip 结构将标题、数字和图标纵向居中或把图标沉底。
- 数字指标卡直接使用 HeroUI Pro `KPI` compound API:`KPI.Header + KPI.Title`、可选 `KPI.Actions`、`KPI.Content + KPI.Value + KPI.Icon`。卡体组合 `rc-card-surface rc-dashboard-card`;右侧 `KPI.Icon` 组合共享 `.rc-metric-icon-surface`,固定 48px、16px 圆角和 20px Ant Design 图标,不得用私有 Card/Chip 结构替代。
- HeroUI Pro 当前 `KPI.Icon status` 只覆盖 `success / warning / danger`,鑫元共享扩展统一提供 `info / success / teal / danger / warning / violet / indigo / coral / neutral` 九组图标表面 Token。浅底只混入 9% tone(neutral 为 8%),保持轻盈,不得在页面另行加深。
- `.rc-kpi-grid` 必须带实际 `data-count`:2 项为两列,5/6/9 项为三列,7/8 项为四列;移动端一列、平板两列。1–9 项未指定业务 tone 时按顺序自动获得九种不重复色;有明确成功、逾期等语义时可指定 tone,但同组业务图标、前景色和浅底组合仍不得重复。超过 9 项应拆分业务模块,不循环使用色板。
- 间距按信息关系分层:同一组 KPI/紧密重复项使用 `.rc-kpi-grid` 和 16px 组内间距,普通模块/图表网格使用 24px,一级模块之间使用 32px。不通过改写 `.kpi__*` 缩放 HeroUI 内部外观。
- 普通信息面使用 HeroUI Card;数字指标使用 HeroUI Pro KPI,不手写数字卡片外观。
- 状态使用 HeroUI Chip 的官方语义色;反馈使用 Alert,加载使用 Spinner/Skeleton,空状态使用 HeroUI Pro EmptyState。
- 弹窗使用 HeroUI Modal compound API,宽度使用统一尺寸梯度;内容较多的新增表单与详情弹窗统一使用 `4xl`(1088px),正文采用响应式布局,但不覆盖 Modal 圆角、阴影、Backdrop 和选中态。
- 同一新增/编辑弹窗内的 Input、TextArea、DateField.Group、Autocomplete 和多选控件必须共用根主题解析出的 HeroUI field variant:浅色 `primary`、深色 `secondary`,不得用页面私有背景、描边或阴影补色。
- 弹窗层级固定为 `Backdrop -> Modal.Dialog overlay -> secondary -> rare tertiary`:`Modal.Dialog` 本身保持 HeroUI `overlay`,Header、Body、Footer 透明承载。普通详情查看态将整组只读标签/值放入一个 HeroUI `Surface secondary`,字段本身不重复套 Card;对象预览、Logo well 等确有第三层边界的静态内容可使用 `tertiary`。进入编辑态后移除整组查看 Surface,由 HeroUI `secondary` 表单控件直接位于 Overlay,避免 L2 套 L2。输入框、按钮、弹层和 Modal 永不使用 L3。
- 查看态字段组统一使用 `rounded-2xl px-5 py-2 sm:px-6 sm:py-3`:16px 圆角、移动/桌面 20/24px 水平内边距;字段网格从 `md` 起双列,列间距 32px、行间距 8px,字段上下 12px,标签与值间距 6px。长文本、附件、引用和对象预览跨两列。加载/错误/空状态替换该 Surface,锁定等上下文 Alert 位于 Surface 之前。
- 详情查看态的空标量或空文本字段在原字段位置显示 14px 弱化文案“暂无内容”,不能留下无文字的灰色块,也不能为单个空字段增加输入框外观。进入编辑态后,HeroUI `Input` / `TextArea` 使用“请输入{可见字段名}”描述预期内容;该提示只属于 `placeholder`,不得写入草稿、校验值或提交 payload。
- 禁止在 Modal 的 Overlay 内使用默认 `Card`(L1)表达需要可见边界的嵌套对象,因为 `overlay` 与 `surface` 同值会形成白底叠白底;也禁止为每个只读字段增加 Card,或用页面私有 `bg-*`、边框和阴影修补层级。
- 同一新增/编辑弹窗内的 Input、TextArea、DateField.Group、Autocomplete 和多选控件在浅色、深色中统一使用 HeroUI `secondary`,由 `theme.css` 解析为 L2,不得用页面私有背景、描边或阴影补色。
- `4xl` 新增/编辑表单在桌面使用等宽双列:普通文本、数字、日期、单选和多选字段均各占一列并按数据顺序配对;只有问题描述、整改情况、备注等长文本 TextArea 跨两列。移动端统一回退单列,不得因字段为多选就默认占满整行。
- 提交约束不得作为常驻提示占据表单布局。只有用户点击确认且校验未通过时,才打开受控 HeroUI `AlertDialog` 展示一条简短可执行提示;关闭提示后保留原表单草稿,不触发接口、不修改原校验规则。
- 详情字段保持标签在上、内容在下;长文本、附件和关联表格可跨整行,移动端回退单列。
......@@ -103,9 +137,17 @@
- 数字卡片使用 `@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 选择器消除焦点。
- Tooltip 使用 HeroUI Pro `ChartTooltipContent`,坐标轴、网格、标签和暗色样式由 HeroUI Pro CSS 与 `theme.css` 继承。
- 仅在 HeroUI Pro 未导出的底层图表能力上直接使用 Recharts primitive,例如 `Legend`、`Cell`;不得另建私有图表主题或 Tooltip 表面。
- 图表颜色只引用 `--accent`、`--success`、`--warning`、`--danger`、`--muted`、`--foreground`。
- HeroUI Pro 图表负责容器、坐标轴、网格、文字与 Tooltip 的深浅主题适配,但不提供或自动分配业务分类系列色;`Pie/Bar/Line` 的 `fill/stroke` 仍由应用传入。分类图表统一使用 `--rc-chart-1` 至 `--rc-chart-8`,色板由鑫元红、钴蓝、清透青蓝、靛蓝、深青蓝、清金、紫罗兰和蓝灰组成。
- 图表系列 Token 在浅色与深色模式下保持完全相同;切换主题时只允许 HeroUI 官方的 `background/surface/muted/overlay` 等结构色变化,不能让同一业务类别因主题切换改变身份颜色。禁止把 `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,文字颜色继续跟随对应系列。
- 图表布局按“同一当前可见业务分组”内的同级图表数量决定,不按整个路由页、其他 Section、隐藏 Tab 或条件分支中的图表总数决定。宽桌面中,同一分组有两个及以上可配对图表时使用 `.rc-chart-grid` 的 `repeat(2, minmax(0, 1fr))` 等宽双列,不使用 `0.9fr/1.1fr` 等主观比例;分组当前只有一个可见图表时,该 Card 必须 `grid-column: 1 / -1` 通铺,不能因页面其他分组存在图表而留下右侧空白。小于 1280px 自动回退单列。
- 统计数据、统计口径、筛选范围和图表点击业务回调不得改变;视觉迁移不制造数据。
## 9. 验收
......
# 问题整改系统 UI/UX 全局审计
> 2026-08-19 复核:Modal 根面统一保持 HeroUI `overlay`;应用切换项、附件编辑器、流程详情摘要等需要独立边界的弹窗内对象统一使用 HeroUI `secondary`。普通详情查看态以一个 `Surface secondary` 承载整组无框标签/值,编辑态则由 `secondary` 控件直接位于 Overlay。已清理上述范围内的 L1-on-L1 默认 Card,不改变弹窗数据、回调或状态流转;深浅模式实际层级仍待浏览器验收。
审计日期:2026-08-17
目标分支:`codex/ui-ux-xinyuan-migration`
......@@ -31,7 +33,7 @@
- 迁移前 `package.json` 声明 `1.0.0-beta.8`,锁文件和实际镜像为 `1.0.0-beta.7`,`npm ls` 报 invalid。
- Pro 包通过跨项目符号链接加载;相邻工程与当前工程各有一套物理 HeroUI/React Aria,存在 Context 双实例风险。
- 本地镜像包含入口、CSS、Sidebar、Navbar、DataGrid、DropZone、ActionBar 和 EmptyState。
- 本地 `file:` 安装仅用于迁移启动,团队交付仍需授权私有 npm 或统一 `.tgz`。
- 迁移启动阶段曾使用本地 `file:` 镜像;该方式已退出当前交付链。当前团队统一通过 HeroUI 官方 `hpsetup` 授权流程取得精确固定的 `1.0.0-beta.8` 完整制品,仓库与开发机不再依赖相邻项目路径。
### 主题
......@@ -71,8 +73,8 @@
## 迁移后复核
- `src/design-system/theme.css` 已与 2026-08-18 skill 权威文件逐字节一致,SHA-256 为 `8f98b720884ded1163cfb355a235cf050f203fec85e7ded2faec87a8cf29673a`。
- `tokens.css` 已收敛为 Shell/Login 别名层,不再覆盖 HeroUI 的 Card、Tabs、DataGrid、Modal、Checkbox、Pagination、控件高度、圆角或阴影;内容组件外观由 HeroUI/HeroUI Pro CSS 与权威 `theme.css` 决定。
- `src/design-system/theme.css` 已同步 2026-08-19 从 HeroUI 官方主题工具下载并批准的基础值,并在同一权威主题文件内固化三项鑫元语义映射:`field-background = surface`、`default = surface-secondary`、`overlay = surface`。该映射属于共享主题,不是页面私有覆盖。`/Users/liuchenlong/Desktop/theme.css`、项目文件与 skill 基线逐字节一致,SHA-256 为 `3ba654ba04b040316eb3f6fcf8bde18bf5d5698281477b95e860bc5e2eeb6ddf`。
- `tokens.css` 已收敛为 Shell/Login 别名、页面几何与图表数据色扩展层,不再覆盖 HeroUI 的 Card、Tabs、DataGrid、Modal、Checkbox、Pagination、控件高度、圆角或阴影;内容组件外观由 HeroUI/HeroUI Pro CSS 与权威 `theme.css` 决定。图表系列色在深浅模式中固定,HeroUI Pro 负责坐标轴、网格、文字、Tooltip 和表面的主题适配。
- Tabs 复核确认当前 `@heroui/styles@3.2.2` 提供 `Tabs.ListContainer` 内置 `ScrollShadow`,但默认 Tab 的 `w-full` 会压缩或均分中文标签。活动 Tabs 已仅在官方 compound slot 的 `className` 设置 `w-fit/w-max/w-auto`、`min-w-max`、`flex-none` 和 `whitespace-nowrap`,使每项宽度跟随自身文字;未改选中逻辑,未新增外层滚动,也未覆盖官方外观。
- 行操作复核发现共享按钮的普通状态使用 `ghost`,导致静止态没有正圆背景;操作组同时存在右对齐、6px 间距和 128px 固定列宽,三个操作会被挤向右侧。现已改为 HeroUI 官方 `secondary` / `danger-soft`,Ant Design 包装层与 SVG 固定 16px 网格居中,组间距 8px并与表头左对齐,操作列按实际入口数量动态计算。两个推送弹窗内的重复删除按钮也已接入同一共享实现,业务回调与禁用条件未变。
- 短列表复核发现共享 `DataTable` 将所有业务列转换为固定像素宽度,HeroUI DataGrid 外层虽为全宽,实际单元格宽度总和不足时仍会在右侧留下大块次表面空白。共享层现已保留 HeroUI Pro 的 `%`/`fr` `ColumnSize`,非宽表第一业务主列使用 `1fr`,内容层使用 `min-w-full`;宽表路径、数据、排序、选择和行操作均未改变。
......
......@@ -17,6 +17,7 @@ import EyeOutlined from '@ant-design/icons/EyeOutlined';
import ExportOutlined from '@ant-design/icons/ExportOutlined';
import FileTextOutlined from '@ant-design/icons/FileTextOutlined';
import FolderOpenOutlined from '@ant-design/icons/FolderOpenOutlined';
import GroupOutlined from '@ant-design/icons/GroupOutlined';
import PaperClipOutlined from '@ant-design/icons/PaperClipOutlined';
import LeftOutlined from '@ant-design/icons/LeftOutlined';
import LogoutOutlined from '@ant-design/icons/LogoutOutlined';
......@@ -45,6 +46,10 @@ function iconStyle(size, style) {
return size === undefined ? style : { fontSize: size, ...style };
}
export const FIELD_SUFFIX_ICON_STYLE = Object.freeze({
color: 'var(--field-placeholder, var(--muted))',
});
function withSize(Icon) {
return function AppIcon({ size, style, ...props }) {
return (
......@@ -73,6 +78,7 @@ export const OpenExternal = withSize(ExportOutlined);
export const FileText = withSize(FileTextOutlined);
export const Info = withSize(InfoCircleOutlined);
export const Folder = withSize(FolderOpenOutlined);
export const Group = withSize(GroupOutlined);
export const LogOut = withSize(LogoutOutlined);
export const Lock = withSize(LockOutlined);
export function SidebarPanel({ size = 20, style, ...props }) {
......
......@@ -21,12 +21,9 @@ function SidebarUserMenu({ userInfo, onLogout }) {
return (
<Dropdown>
<Button
fullWidth
<Dropdown.Trigger
aria-label="打开用户菜单"
className="app-sidebar-user-trigger rc-sidebar-user-trigger"
size="sm"
variant="ghost"
>
<Avatar className="app-sidebar-user-avatar rc-sidebar-avatar">
{userInfo?.avatar ? (
......@@ -41,7 +38,7 @@ function SidebarUserMenu({ userInfo, onLogout }) {
<span className="app-sidebar-user-name">{user.displayName}</span>
<span className="app-sidebar-user-description">{user.username}</span>
</span>
</Button>
</Dropdown.Trigger>
<Dropdown.Popover placement="top start">
<Dropdown.Menu
aria-label="用户菜单"
......
import {
Calendar as HeroCalendar,
CloseButton,
DateField,
DatePicker,
I18nProvider,
......@@ -7,7 +8,11 @@ import {
} from '@heroui/react';
import { parseDate, parseDateTime, parseTime } from '@internationalized/date';
import { useMemo } from 'react';
import { Calendar as CalendarIcon } from './AppIcons';
import {
Calendar as CalendarIcon,
Close,
FIELD_SUFFIX_ICON_STYLE,
} from './AppIcons';
function parseValue(value, mode) {
if (!value) {
......@@ -91,6 +96,7 @@ export default function FormDatePicker({
className,
disabled = false,
granularity,
isClearable = true,
max,
min,
mode,
......@@ -123,6 +129,8 @@ export default function FormDatePicker({
const minValue = useMemo(() => parseValue(min, resolvedMode), [min, resolvedMode]);
const maxValue = useMemo(() => parseValue(max, resolvedMode), [max, resolvedMode]);
const timeValue = useMemo(() => parseTimeValue(value), [value]);
const canClear =
isClearable && Boolean(value) && !disabled && !readOnly && !required;
return (
<I18nProvider locale="zh-CN">
......@@ -141,16 +149,35 @@ export default function FormDatePicker({
emitDateChange(onChange, name, formatValue(nextValue, resolvedMode))
}
>
<DateField.Group fullWidth variant={variant}>
<DateField.Input>
<DateField.Group className="relative" fullWidth variant={variant}>
<DateField.Input className={canClear ? 'pe-14' : 'pe-8'}>
{(segment) => (
<DateField.Segment segment={segment} />
)}
</DateField.Input>
<DateField.Suffix>
<DatePicker.Trigger aria-label="打开日期选择">
<DateField.Suffix className="absolute inset-y-0 end-1 mr-0 gap-0">
{canClear ? (
<CloseButton
aria-label={`清空${ariaLabel || '日期'}`}
onPress={() => emitDateChange(onChange, name, '')}
>
<Close
className="grid size-4 place-items-center leading-none [&>svg]:block [&>svg]:size-4"
size={16}
style={FIELD_SUFFIX_ICON_STYLE}
/>
</CloseButton>
) : null}
<DatePicker.Trigger
aria-label="打开日期选择"
className="size-6 w-6 shrink-0 justify-center p-1 text-field-placeholder"
>
<DatePicker.TriggerIndicator>
<CalendarIcon />
<CalendarIcon
className="grid size-4 place-items-center leading-none [&>svg]:block [&>svg]:size-4"
size={16}
style={FIELD_SUFFIX_ICON_STYLE}
/>
</DatePicker.TriggerIndicator>
</DatePicker.Trigger>
</DateField.Suffix>
......
......@@ -306,7 +306,6 @@ export default function ReferenceSearchSelect({
pageSize = 20,
queryFilter,
searchFields = DEFAULT_SEARCH_FIELDS,
optionsPanelClassName = '',
optionsAnchor,
showClearAction = true,
selectedSummaryMode = 'labels',
......@@ -602,7 +601,6 @@ export default function ReferenceSearchSelect({
options={menuOptions}
placement={optionsAnchor || 'bottom start'}
placeholder={placeholder}
popoverClassName={optionsPanelClassName}
popoverHeader={allowClear ? (
<div className="flex justify-end px-3 pb-1">
<Button size="sm" variant="tertiary" onPress={clearSelection}>
......
......@@ -22,6 +22,13 @@ import { Children, Fragment, isValidElement } from 'react';
const ACTION_ICON_CLASS =
'grid size-4 shrink-0 place-items-center [line-height:0] [&>svg]:!m-0 [&>svg]:block [&>svg]:size-4';
const ACTION_BUTTON_LAYOUT_CLASS = 'grid place-items-center rounded-full p-0';
const IMPORTANT_ACTION_TONES = new Set(['approve', 'submit']);
function getActionVariant(tone) {
if (['delete', 'reject'].includes(tone)) return 'danger-soft';
if (IMPORTANT_ACTION_TONES.has(tone)) return 'secondary';
return 'tertiary';
}
function resolveActionTone({ icon, tone }) {
if (tone) {
......@@ -108,9 +115,10 @@ export function TableActionGroup({ className, children, ...props }) {
buttonVariants({
isIconOnly: true,
size: 'sm',
variant: 'secondary',
variant: 'tertiary',
}),
ACTION_BUTTON_LAYOUT_CLASS,
'pm-table-action',
)}
title="更多操作"
>
......@@ -184,13 +192,15 @@ export function TableActionButton({
title={label}
isDisabled={isDisabled}
size="sm"
variant={
['delete', 'reject'].includes(resolvedTone)
? 'danger-soft'
: 'secondary'
}
variant={getActionVariant(resolvedTone)}
onPress={onPress}
className={clsx(ACTION_BUTTON_LAYOUT_CLASS, className)}
className={clsx(
ACTION_BUTTON_LAYOUT_CLASS,
'pm-table-action',
IMPORTANT_ACTION_TONES.has(resolvedTone) && 'pm-table-action--important',
['delete', 'reject'].includes(resolvedTone) && 'pm-table-action--danger',
className,
)}
>
<Icon
className={ACTION_ICON_CLASS}
......
import { ChevronDown, Close, Search } from '@/components/AppIcons';
import {
ChevronDown,
Close,
FIELD_SUFFIX_ICON_STYLE,
Search,
} from '@/components/AppIcons';
import {
Autocomplete,
Label,
......@@ -39,7 +44,6 @@ export default function XinyuanAutocomplete({
options = [],
placeholder = '请选择',
placement = 'bottom start',
popoverClassName,
popoverHeader,
renderOption,
searchPlaceholder = '输入关键词搜索',
......@@ -94,17 +98,21 @@ export default function XinyuanAutocomplete({
</span>
</Autocomplete.Value>
<Autocomplete.Indicator>
<ChevronDown size={14} />
<ChevronDown size={16} style={FIELD_SUFFIX_ICON_STYLE} />
</Autocomplete.Indicator>
</Autocomplete.Trigger>
<Autocomplete.Popover className={popoverClassName} placement={placement}>
<Autocomplete.Popover placement={placement}>
<Autocomplete.Filter
filter={shouldFilter ? filter || defaultFilter : () => true}
inputValue={inputValue}
onInputChange={onInputChange}
>
{popoverHeader}
<SearchField aria-label={searchPlaceholder} fullWidth>
<SearchField
aria-label={searchPlaceholder}
fullWidth
variant={variant}
>
<SearchField.Group>
<SearchField.SearchIcon>
<Search size={16} />
......
......@@ -13,8 +13,10 @@ import {
Delete,
Eye,
ArrowPathIcon,
ChartBarSquareIcon,
ChevronDownIcon,
ChevronRightIcon,
Group,
MagnifyingGlassIcon,
} from '@/components/AppIcons';
import FilterSelect from '@/components/FilterSelect';
......@@ -23,12 +25,12 @@ import { formatFieldValue, getFieldValue } from '@/utils/subjectView';
import {
Alert,
Button,
Card,
Disclosure,
Input,
SearchField,
Switch,
Surface,
Tabs,
ToggleButton,
Tooltip,
} from '@heroui/react';
import clsx from 'clsx';
......@@ -65,35 +67,6 @@ function FilterField({ label, children }) {
);
}
function isRootDarkTheme() {
if (typeof document === 'undefined') {
return false;
}
const root = document.documentElement;
return root.classList.contains('dark') || root.dataset.theme === 'dark';
}
function useRootDarkTheme() {
const [isDark, setIsDark] = useState(isRootDarkTheme);
useEffect(() => {
const root = document.documentElement;
const syncTheme = () => setIsDark(isRootDarkTheme());
const observer = new MutationObserver(syncTheme);
syncTheme();
observer.observe(root, {
attributes: true,
attributeFilter: ['class', 'data-theme'],
});
return () => observer.disconnect();
}, []);
return isDark;
}
function getPinnedSide(column) {
const pinnedValue = column?.lockPosition || column?.pinned;
if (pinnedValue === 'left' || pinnedValue === 'right') {
......@@ -247,22 +220,23 @@ function orderColumnsByGroups(columns = [], fieldToGroup = {}, groupById = {}) {
return orderedColumns;
}
function SummaryCard({ label, value, tone = 'neutral', icon: Icon }) {
const iconStatus = ['danger', 'success', 'warning'].includes(tone)
? tone
: undefined;
function SummaryCard({ label, value, tone, icon: Icon }) {
const numericValue = Number(value);
return (
<KPI>
<KPI className="rc-card-surface rc-dashboard-card">
<KPI.Header>
<KPI.Title>{label}</KPI.Title>
</KPI.Header>
<KPI.Content>
<KPI.Value value={Number.isFinite(numericValue) ? numericValue : 0} />
{Icon ? (
<KPI.Icon status={iconStatus} aria-label={label}>
<Icon />
<KPI.Icon
aria-label={label}
className="rc-metric-icon-surface"
data-tone={tone || undefined}
>
<Icon size={20} />
</KPI.Icon>
) : null}
</KPI.Content>
......@@ -289,7 +263,7 @@ function getActionButtonVariant(action) {
return action?.variant === 'danger' ? 'danger' : 'danger-soft';
}
return action?.variant || (action?.tone === 'primary' ? 'primary' : 'outline');
return action?.variant || (action?.tone === 'primary' ? 'primary' : 'secondary');
}
function defaultGetRecordId(record) {
......@@ -357,26 +331,23 @@ function getWorkbenchColumnWidth(column, stickyMeta) {
return Math.min(readableWidth, 280);
}
function ColumnGroupControl({ group, collapsed, onToggle }) {
const visible = !collapsed;
function ColumnGroupControl({ group, collapsed, index, onToggle }) {
const Icon = index % 2 === 0 ? Group : ChartBarSquareIcon;
const actionLabel = `${collapsed ? '显示' : '隐藏'}${group.name}列`;
return (
<Tooltip delay={300}>
<Switch
<ToggleButton
size="sm"
isSelected={visible}
isSelected={collapsed}
onChange={onToggle}
aria-label={`显示${group.name}列`}
aria-label={actionLabel}
>
<Switch.Content>
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
{group.name}
</Switch.Content>
</Switch>
<Icon size={16} />
<span>{group.name}</span>
</ToggleButton>
<Tooltip.Content>
显示或隐藏“{group.name}”的 {group.children.length} 个列表字段,不会筛选或修改数据
{actionLabel}(共 {group.children.length} 个字段)
</Tooltip.Content>
</Tooltip>
);
......@@ -473,8 +444,7 @@ export default function RecordWorkbench({
const [collapsedColumnGroups, setCollapsedColumnGroups] = useState({});
const [columnGroupAnnouncement, setColumnGroupAnnouncement] = useState('');
const [expandedRowIds, setExpandedRowIds] = useState({});
const isDarkTheme = useRootDarkTheme();
const filterFieldVariant = isDarkTheme ? 'secondary' : 'primary';
const filterFieldVariant = 'secondary';
const loading = Boolean(states?.loading);
const error = states?.error || '';
const totalRecords = pagination?.totalRecords ?? records.length;
......@@ -841,16 +811,6 @@ export default function RecordWorkbench({
const disabledDataGridRowKeys = dataGridRows
.filter((row) => row.isExpandedRecord || row.isExpandedPanel)
.map((row) => String(row.key));
const summaryGridClass =
summaryItems.length === 1
? 'md:grid-cols-1'
: summaryItems.length === 2
? 'md:grid-cols-2'
: summaryItems.length === 3
? 'md:grid-cols-3'
: summaryItems.length === 5 || summaryItems.length === 6
? 'md:grid-cols-2 xl:grid-cols-3'
: 'md:grid-cols-2 xl:grid-cols-4';
const toolbarSearchField = filterSchema.find((field) => field.icon === 'search');
const structuredFilterFields = filterSchema.filter(
(field) => field !== toolbarSearchField,
......@@ -906,7 +866,7 @@ export default function RecordWorkbench({
return (
<div className="rc-page-stack">
{summaryItems.length > 0 ? (
<section className={clsx('rc-kpi-grid grid', summaryGridClass)}>
<section className="rc-kpi-grid" data-count={summaryItems.length}>
{summaryItems.map((item) => (
<SummaryCard
key={item.label}
......@@ -945,76 +905,77 @@ export default function RecordWorkbench({
) : null}
{structuredFilterFields.length > 0 && !useInlineFilters ? (
<Card>
<Card.Content>
<Disclosure
isExpanded={filtersExpanded}
onExpandedChange={setFiltersExpanded}
<Surface
className="rounded-3xl p-6"
variant="default"
>
<Disclosure
isExpanded={filtersExpanded}
onExpandedChange={setFiltersExpanded}
>
<form
onSubmit={(event) => {
event.preventDefault();
onQuery?.();
}}
>
<form
onSubmit={(event) => {
event.preventDefault();
onQuery?.();
}}
>
<div className="grid min-w-0 gap-3 xl:grid-cols-[minmax(0,1fr)_auto] xl:items-end">
<div
className={clsx(
'grid min-w-0 gap-3',
primaryFilterGridClass,
)}
>
{primaryFilterFields.map(renderFilterField)}
</div>
<div className="flex min-w-0 flex-wrap items-end gap-2 md:justify-end xl:flex-nowrap">
{onClearFilters ? (
<Tooltip delay={0}>
<Button
aria-label="重置筛选"
isDisabled={loading}
isIconOnly
size="sm"
variant="secondary"
type="button"
onPress={onClearFilters}
>
<ArrowPathIcon size={16} />
</Button>
<Tooltip.Content>重置筛选</Tooltip.Content>
</Tooltip>
) : null}
{onQuery ? (
<div className="grid min-w-0 gap-3 xl:grid-cols-[minmax(0,1fr)_auto] xl:items-end">
<div
className={clsx(
'grid min-w-0 gap-3',
primaryFilterGridClass,
)}
>
{primaryFilterFields.map(renderFilterField)}
</div>
<div className="flex min-w-0 flex-wrap items-end gap-2 md:justify-end xl:flex-nowrap">
{onClearFilters ? (
<Tooltip delay={0}>
<Button
aria-label="重置筛选"
isDisabled={loading}
isIconOnly
size="sm"
variant="primary"
type="submit"
variant="secondary"
type="button"
onPress={onClearFilters}
>
查询
<ArrowPathIcon size={16} />
</Button>
) : null}
{expandedFilterFields.length > 0 ? (
<Disclosure.Trigger className="button button--sm button--ghost">
<span>{filtersExpanded ? '收起' : '展开'}</span>
<Disclosure.Indicator>
<ChevronDownIcon aria-hidden="true" size={14} />
</Disclosure.Indicator>
</Disclosure.Trigger>
) : null}
</div>
<Tooltip.Content>重置筛选</Tooltip.Content>
</Tooltip>
) : null}
{onQuery ? (
<Button
isDisabled={loading}
size="sm"
variant="primary"
type="submit"
>
查询
</Button>
) : null}
{expandedFilterFields.length > 0 ? (
<Disclosure.Content className="min-w-0 xl:col-start-1 xl:col-end-2">
<div className="grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{expandedFilterFields.map(renderFilterField)}
</div>
</Disclosure.Content>
<Disclosure.Trigger className="button button--sm button--ghost">
<span>{filtersExpanded ? '收起' : '展开'}</span>
<Disclosure.Indicator>
<ChevronDownIcon aria-hidden="true" size={14} />
</Disclosure.Indicator>
</Disclosure.Trigger>
) : null}
</div>
</form>
</Disclosure>
</Card.Content>
</Card>
{expandedFilterFields.length > 0 ? (
<Disclosure.Content className="min-w-0 xl:col-start-1 xl:col-end-2">
<div className="grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{expandedFilterFields.map(renderFilterField)}
</div>
</Disclosure.Content>
) : null}
</div>
</form>
</Disclosure>
</Surface>
) : null}
<section className={tableSectionClass}>
......@@ -1053,16 +1014,19 @@ 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-3">
<h2 className="text-base font-medium text-foreground">{title}</h2>
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
{toolbarSearchField ? (
<div className="w-full min-w-0 sm:w-80 lg:w-96">
<div className="w-full min-w-0 flex-none sm:w-80 lg:w-96">
<SearchField
aria-label={toolbarSearchField.label || '关键词搜索'}
fullWidth
value={filters[toolbarSearchField.key] || ''}
variant="secondary"
variant="primary"
onChange={(value) =>
onFiltersChange?.(toolbarSearchField.key, value)
}
......@@ -1080,12 +1044,13 @@ export default function RecordWorkbench({
</div>
) : null}
{hasColumnGroups ? (
<div className="flex flex-wrap items-center gap-3" aria-label="列分组显示控制">
{columnGroupMeta.groups.map((group) => (
<div className="flex flex-wrap items-center gap-2" aria-label="列分组显示控制">
{columnGroupMeta.groups.map((group, index) => (
<ColumnGroupControl
key={group.id}
group={group}
collapsed={Boolean(collapsedColumnGroups[group.id])}
index={index}
onToggle={() => toggleColumnGroup(group.id)}
/>
))}
......
export const XINYUAN_CHART_PALETTE = Object.freeze([
'var(--rc-chart-1)',
'var(--rc-chart-2)',
'var(--rc-chart-3)',
'var(--rc-chart-4)',
'var(--rc-chart-5)',
'var(--rc-chart-6)',
'var(--rc-chart-7)',
'var(--rc-chart-8)',
]);
export const XINYUAN_CHART_SEMANTIC = Object.freeze({
primary: 'var(--rc-chart-primary)',
risk: 'var(--rc-chart-risk)',
warning: 'var(--rc-chart-warning)',
positive: 'var(--rc-chart-positive)',
comparison: 'var(--rc-chart-comparison)',
neutral: 'var(--rc-chart-neutral)',
});
function normalizeChartKey(key) {
return String(key ?? '').trim();
}
function hashChartKey(key) {
return Array.from(key).reduce(
(value, character) => ((value * 31) + character.codePointAt(0)) >>> 0,
0,
);
}
export function getXinyuanChartColor(key, fallbackIndex = 0) {
const stableKey = normalizeChartKey(key);
if (!stableKey) {
return XINYUAN_CHART_PALETTE[fallbackIndex % XINYUAN_CHART_PALETTE.length];
}
return XINYUAN_CHART_PALETTE[hashChartKey(stableKey) % XINYUAN_CHART_PALETTE.length];
}
export function createXinyuanChartColorMap(keys = []) {
const stableKeys = [...new Set(keys.map(normalizeChartKey).filter(Boolean))]
.sort();
const colorMap = new Map();
const usedIndexes = new Set();
stableKeys.forEach((key, keyIndex) => {
const preferredIndex = hashChartKey(key) % XINYUAN_CHART_PALETTE.length;
let paletteIndex = preferredIndex;
if (keyIndex < XINYUAN_CHART_PALETTE.length) {
for (let offset = 0; offset < XINYUAN_CHART_PALETTE.length; offset += 1) {
const candidateIndex = (preferredIndex + offset) % XINYUAN_CHART_PALETTE.length;
if (!usedIndexes.has(candidateIndex)) {
paletteIndex = candidateIndex;
usedIndexes.add(candidateIndex);
break;
}
}
}
colorMap.set(key, XINYUAN_CHART_PALETTE[paletteIndex]);
});
return colorMap;
}
function isVisibleChartColor(color) {
if (typeof color !== 'string') {
return false;
}
const normalizedColor = color.trim().toLowerCase();
return Boolean(normalizedColor)
&& normalizedColor !== 'none'
&& normalizedColor !== 'transparent';
}
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);
return {
...entry,
color,
fill: color,
name: entry?.name ?? sourceDatum?.name ?? entry?.dataKey,
stroke: color,
};
});
}
......@@ -440,11 +440,202 @@
gap: var(--rc-page-module-gap);
}
.rc-chart-grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
}
.rc-chart-grid > * {
min-width: 0;
}
.rc-chart-grid > :only-child {
grid-column: 1 / -1;
}
/* Recharts keeps keyboard navigation while pointer focus stays visually quiet. */
.rc-chart-interaction:focus:not(:focus-visible),
.rc-chart-interaction :focus:not(:focus-visible) {
outline: none;
}
.rc-chart-interaction:focus-visible,
.rc-chart-interaction :focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
}
.rc-kpi-grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
min-width: 0;
gap: var(--rc-related-item-gap);
}
@media (min-width: 48rem) {
.rc-kpi-grid:not([data-count='1']) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (min-width: 80rem) {
.rc-kpi-grid[data-count='1'] {
grid-template-columns: minmax(0, 1fr);
}
.rc-kpi-grid[data-count='2'] {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.rc-kpi-grid[data-count='3'],
.rc-kpi-grid[data-count='5'],
.rc-kpi-grid[data-count='6'],
.rc-kpi-grid[data-count='9'] {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.rc-kpi-grid[data-count='4'],
.rc-kpi-grid[data-count='7'],
.rc-kpi-grid[data-count='8'] {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
/* Shared HeroUI/HeroUI Pro business-card contract. */
.rc-card-surface {
border: 1px solid var(--rc-border-default);
background-color: var(--rc-bg-surface);
color: var(--rc-text-primary);
box-shadow: var(--rc-shadow-card);
}
.rc-dashboard-card {
border-radius: 1.5rem;
}
/* HeroUI Pro KPI.Icon keeps its compound role; this class supplies Xinyuan tones. */
.rc-metric-icon-surface {
--rc-metric-icon-color: var(--rc-metric-tone-neutral);
--rc-metric-icon-background: var(--rc-metric-tone-neutral-soft);
display: grid;
width: 3rem;
min-width: 3rem;
max-width: 3rem;
height: 3rem;
min-height: 3rem;
max-height: 3rem;
flex: 0 0 3rem;
place-items: center;
border-radius: 1rem;
color: var(--rc-metric-icon-color);
background-color: var(--rc-metric-icon-background);
line-height: 1;
}
.rc-metric-icon-surface[data-tone='info'] {
--rc-metric-icon-color: var(--rc-metric-tone-info);
--rc-metric-icon-background: var(--rc-metric-tone-info-soft);
}
.rc-metric-icon-surface[data-tone='success'] {
--rc-metric-icon-color: var(--rc-metric-tone-success);
--rc-metric-icon-background: var(--rc-metric-tone-success-soft);
}
.rc-metric-icon-surface[data-tone='teal'] {
--rc-metric-icon-color: var(--rc-metric-tone-teal);
--rc-metric-icon-background: var(--rc-metric-tone-teal-soft);
}
.rc-metric-icon-surface[data-tone='danger'] {
--rc-metric-icon-color: var(--rc-metric-tone-danger);
--rc-metric-icon-background: var(--rc-metric-tone-danger-soft);
}
.rc-metric-icon-surface[data-tone='warning'] {
--rc-metric-icon-color: var(--rc-metric-tone-warning);
--rc-metric-icon-background: var(--rc-metric-tone-warning-soft);
}
.rc-metric-icon-surface[data-tone='violet'] {
--rc-metric-icon-color: var(--rc-metric-tone-violet);
--rc-metric-icon-background: var(--rc-metric-tone-violet-soft);
}
.rc-metric-icon-surface[data-tone='indigo'] {
--rc-metric-icon-color: var(--rc-metric-tone-indigo);
--rc-metric-icon-background: var(--rc-metric-tone-indigo-soft);
}
.rc-metric-icon-surface[data-tone='coral'] {
--rc-metric-icon-color: var(--rc-metric-tone-coral);
--rc-metric-icon-background: var(--rc-metric-tone-coral-soft);
}
/* Untoned KPI items receive a unique ordered tone for boards of up to nine. */
.rc-kpi-grid > :nth-child(1) .rc-metric-icon-surface:not([data-tone]) {
--rc-metric-icon-color: var(--rc-metric-tone-info);
--rc-metric-icon-background: var(--rc-metric-tone-info-soft);
}
.rc-kpi-grid > :nth-child(2) .rc-metric-icon-surface:not([data-tone]) {
--rc-metric-icon-color: var(--rc-metric-tone-success);
--rc-metric-icon-background: var(--rc-metric-tone-success-soft);
}
.rc-kpi-grid > :nth-child(3) .rc-metric-icon-surface:not([data-tone]) {
--rc-metric-icon-color: var(--rc-metric-tone-warning);
--rc-metric-icon-background: var(--rc-metric-tone-warning-soft);
}
.rc-kpi-grid > :nth-child(4) .rc-metric-icon-surface:not([data-tone]) {
--rc-metric-icon-color: var(--rc-metric-tone-violet);
--rc-metric-icon-background: var(--rc-metric-tone-violet-soft);
}
.rc-kpi-grid > :nth-child(5) .rc-metric-icon-surface:not([data-tone]) {
--rc-metric-icon-color: var(--rc-metric-tone-danger);
--rc-metric-icon-background: var(--rc-metric-tone-danger-soft);
}
.rc-kpi-grid > :nth-child(6) .rc-metric-icon-surface:not([data-tone]) {
--rc-metric-icon-color: var(--rc-metric-tone-teal);
--rc-metric-icon-background: var(--rc-metric-tone-teal-soft);
}
.rc-kpi-grid > :nth-child(7) .rc-metric-icon-surface:not([data-tone]) {
--rc-metric-icon-color: var(--rc-metric-tone-indigo);
--rc-metric-icon-background: var(--rc-metric-tone-indigo-soft);
}
.rc-kpi-grid > :nth-child(8) .rc-metric-icon-surface:not([data-tone]) {
--rc-metric-icon-color: var(--rc-metric-tone-coral);
--rc-metric-icon-background: var(--rc-metric-tone-coral-soft);
}
.rc-kpi-grid > :nth-child(9) .rc-metric-icon-surface:not([data-tone]) {
--rc-metric-icon-color: var(--rc-metric-tone-neutral);
--rc-metric-icon-background: var(--rc-metric-tone-neutral-soft);
}
.rc-metric-icon-surface > .anticon,
.rc-metric-icon-surface > svg {
display: block;
width: 1.25rem;
height: 1.25rem;
margin: 0;
color: inherit;
font-size: 1.25rem;
line-height: 1;
}
.rc-metric-icon-surface > .anticon > svg {
display: block;
width: 1em;
height: 1em;
margin: 0;
}
.rc-page-toolbar {
display: flex;
min-width: 0;
......@@ -464,6 +655,12 @@
gap: var(--rc-list-stack-gap);
}
@media (min-width: 80rem) {
.rc-chart-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.rc-mobile-sidebar-backdrop {
position: fixed;
inset: 0;
......@@ -728,7 +925,7 @@
padding: var(--rc-space-2) var(--rc-space-3) var(--rc-space-4);
}
.rc-sidebar-user-trigger.button {
.rc-sidebar-user-trigger.dropdown__trigger {
display: flex;
width: 100%;
min-width: 0;
......@@ -743,9 +940,9 @@
text-align: left;
}
.rc-sidebar-user-trigger.button:hover,
.rc-sidebar-user-trigger.button[data-hovered='true'],
.rc-sidebar-user-trigger.button[aria-expanded='true'] {
.rc-sidebar-user-trigger.dropdown__trigger:hover,
.rc-sidebar-user-trigger.dropdown__trigger[data-hovered='true'],
.rc-sidebar-user-trigger.dropdown__trigger[aria-expanded='true'] {
background: var(--default);
}
......@@ -1134,7 +1331,7 @@
padding: 0.5rem 0.75rem 1rem;
}
.app-sidebar-user-trigger.button {
.app-sidebar-user-trigger.dropdown__trigger {
display: flex;
width: 100%;
min-width: 0;
......@@ -1150,13 +1347,13 @@
}
@media (hover: hover) {
.app-sidebar-user-trigger.button:hover,
.app-sidebar-user-trigger.button[data-hovered='true'] {
.app-sidebar-user-trigger.dropdown__trigger:hover,
.app-sidebar-user-trigger.dropdown__trigger[data-hovered='true'] {
background: var(--default);
}
}
.app-sidebar-user-trigger.button[aria-expanded='true'] {
.app-sidebar-user-trigger.dropdown__trigger[aria-expanded='true'] {
background: var(--default);
}
......
......@@ -13,34 +13,35 @@
/* Theme Colors (Light Mode) */
--accent: oklch(45.32% 0.1661 27.03);
--accent-foreground: oklch(99.11% 0 0);
--background: oklch(97.02% 0.0019 27.03);
--border: oklch(90.00% 0.0019 27.03);
--danger: oklch(65.32% 0.2337 358.52);
--background: oklch(97.02% 0.0045 27.03);
--border: oklch(90.00% 0.0045 27.03);
--danger: oklch(45.32% 0.1661 27.03);
--danger-foreground: oklch(99.11% 0 0);
--default: oklch(94.00% 0.0019 27.03);
--default-foreground: oklch(21.03% 0.0059 27.03);
--field-background: oklch(100.00% 0.0009 27.03);
/* HeroUI control aliases: primary = L1, secondary = L2. */
--default: var(--surface-secondary);
--default-foreground: var(--surface-secondary-foreground);
--field-background: var(--surface);
--field-border: transparent;
--field-foreground: oklch(21.03% 0.0019 27.03);
--field-placeholder: oklch(55.17% 0.0038 27.03);
--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.0019 27.03);
--muted: oklch(55.17% 0.0038 27.03);
--overlay: oklch(100.00% 0.0006 27.03);
--overlay-foreground: oklch(21.03% 0.0019 27.03);
--scrollbar: oklch(87.10% 0.0019 27.03);
--segment: oklch(100.00% 0.0019 27.03);
--segment-foreground: oklch(21.03% 0.0019 27.03);
--separator: oklch(92.00% 0.0019 27.03);
--success: oklch(73.29% 0.1942 123.59);
--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.0009 27.03);
--surface-foreground: oklch(21.03% 0.0019 27.03);
--surface-secondary: oklch(95.24% 0.0015 27.03);
--surface-secondary-foreground: oklch(21.03% 0.0019 27.03);
--surface-tertiary: oklch(93.73% 0.0015 27.03);
--surface-tertiary-foreground: oklch(21.03% 0.0019 27.03);
--warning: oklch(78.19% 0.1591 45.11);
--surface: oklch(100.00% 0.0022 27.03);
--surface-foreground: oklch(21.03% 0.0045 27.03);
--surface-secondary: oklch(95.24% 0.0036 27.03);
--surface-secondary-foreground: oklch(21.03% 0.0045 27.03);
--surface-tertiary: oklch(93.73% 0.0036 27.03);
--surface-tertiary-foreground: oklch(21.03% 0.0045 27.03);
--warning: oklch(78.19% 0.1599 45.11);
--warning-foreground: oklch(21.03% 0.0059 45.11);
/* Border Radius */
......@@ -58,33 +59,34 @@
/* Theme Colors (Dark Mode) */
--accent: oklch(45.32% 0.1661 27.03);
--accent-foreground: oklch(99.11% 0 0);
--background: oklch(12.00% 0.0019 27.03);
--border: oklch(28.00% 0.0019 27.03);
--danger: oklch(59.40% 0.1974 357.41);
--background: oklch(12.00% 0.0045 27.03);
--border: oklch(28.00% 0.0045 27.03);
--danger: oklch(62.00% 0.1600 27.03);
--danger-foreground: oklch(99.11% 0 0);
--default: oklch(27.40% 0.0019 27.03);
--default-foreground: oklch(99.11% 0 0);
--field-background: oklch(21.03% 0.0038 27.03);
/* HeroUI control aliases: primary = L1, secondary = L2. */
--default: var(--surface-secondary);
--default-foreground: var(--surface-secondary-foreground);
--field-background: var(--surface);
--field-border: transparent;
--field-foreground: oklch(99.11% 0.0019 27.03);
--field-placeholder: oklch(70.50% 0.0038 27.03);
--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.0019 27.03);
--muted: oklch(70.50% 0.0038 27.03);
--overlay: oklch(21.03% 0.0038 27.03);
--overlay-foreground: oklch(99.11% 0.0019 27.03);
--scrollbar: oklch(70.50% 0.0019 27.03);
--segment: oklch(39.64% 0.0019 27.03);
--segment-foreground: oklch(99.11% 0.0019 27.03);
--separator: oklch(25.00% 0.0019 27.03);
--success: oklch(73.29% 0.1942 123.59);
--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.0038 27.03);
--surface-foreground: oklch(99.11% 0.0019 27.03);
--surface-secondary: oklch(25.70% 0.0029 27.03);
--surface-secondary-foreground: oklch(99.11% 0.0019 27.03);
--surface-tertiary: oklch(27.21% 0.0029 27.03);
--surface-tertiary-foreground: oklch(99.11% 0.0019 27.03);
--warning: oklch(82.03% 0.1393 49.12);
--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);
}
\ No newline at end of file
}
......@@ -5,7 +5,7 @@
[data-theme='default'],
.dark,
[data-theme='dark'] {
/* Shell aliases only. HeroUI component tokens are owned by theme.css. */
/* Xinyuan aliases and extension tokens. HeroUI core tokens are owned by theme.css. */
--rc-brand-primary: var(--accent);
--rc-brand-light: color-mix(in oklab, var(--accent) 84%, white);
--rc-brand-dark: color-mix(in oklab, var(--accent) 82%, black);
......@@ -78,6 +78,82 @@
--rc-shadow-raised: 0 1px 2px rgb(15 23 42 / 6%),
0 8px 24px rgb(15 23 42 / 8%);
--rc-shadow-overlay: 0 16px 40px rgb(15 23 42 / 14%);
--rc-shadow-card: 0 1px 2px rgb(15 23 42 / 2%),
0 4px 12px rgb(15 23 42 / 2%);
/* KPI icon surfaces. Card bodies remain neutral; only the icon well carries tone. */
--rc-metric-tone-info: oklch(55% 0.14 255);
--rc-metric-tone-success: oklch(54% 0.13 145);
--rc-metric-tone-teal: oklch(55% 0.1 205);
--rc-metric-tone-danger: oklch(52% 0.17 25);
--rc-metric-tone-warning: oklch(62% 0.12 75);
--rc-metric-tone-violet: oklch(56% 0.12 300);
--rc-metric-tone-indigo: oklch(53% 0.13 275);
--rc-metric-tone-coral: oklch(60% 0.12 48);
--rc-metric-tone-neutral: var(--muted);
--rc-metric-tone-info-soft: color-mix(
in oklab,
var(--rc-metric-tone-info) 9%,
var(--surface)
);
--rc-metric-tone-success-soft: color-mix(
in oklab,
var(--rc-metric-tone-success) 9%,
var(--surface)
);
--rc-metric-tone-teal-soft: color-mix(
in oklab,
var(--rc-metric-tone-teal) 9%,
var(--surface)
);
--rc-metric-tone-danger-soft: color-mix(
in oklab,
var(--rc-metric-tone-danger) 9%,
var(--surface)
);
--rc-metric-tone-warning-soft: color-mix(
in oklab,
var(--rc-metric-tone-warning) 9%,
var(--surface)
);
--rc-metric-tone-violet-soft: color-mix(
in oklab,
var(--rc-metric-tone-violet) 9%,
var(--surface)
);
--rc-metric-tone-indigo-soft: color-mix(
in oklab,
var(--rc-metric-tone-indigo) 9%,
var(--surface)
);
--rc-metric-tone-coral-soft: color-mix(
in oklab,
var(--rc-metric-tone-coral) 9%,
var(--surface)
);
--rc-metric-tone-neutral-soft: color-mix(
in oklab,
var(--rc-metric-tone-neutral) 8%,
var(--surface)
);
/* Data visualization only. Do not reuse UI status colors as a series palette. */
--rc-chart-1: oklch(45.32% 0.1661 27.03);
--rc-chart-2: oklch(56% 0.135 255);
--rc-chart-3: oklch(64% 0.11 220);
--rc-chart-4: oklch(56% 0.115 280);
--rc-chart-5: oklch(60% 0.095 200);
--rc-chart-6: oklch(70% 0.12 80);
--rc-chart-7: oklch(60% 0.1 295);
--rc-chart-8: oklch(57% 0.035 250);
--rc-chart-primary: var(--rc-chart-1);
--rc-chart-risk: oklch(47% 0.145 27.03);
--rc-chart-warning: var(--rc-chart-6);
--rc-chart-positive: var(--rc-chart-2);
--rc-chart-comparison: var(--rc-chart-4);
--rc-chart-neutral: var(--rc-chart-8);
--rc-chart-legend-font-size: 0.75rem;
--rc-chart-legend-line-height: 1rem;
}
.dark,
......@@ -96,4 +172,6 @@
--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%);
}
import clsx from 'clsx';
import { EmptyState } from '@heroui-pro/react/empty-state';
import useRootDarkTheme from '@/hooks/useRootDarkTheme';
import { Surface } from '@heroui/react';
import TailwindEditableField from './TailwindEditableField';
import {
buildViewColumns,
......@@ -62,7 +62,7 @@ export default function SubjectViewContent({
const contentFields = minimal
? (displayFields || []).filter((field) => field.field !== 'title')
: displayFields;
const fieldVariant = useRootDarkTheme() ? 'secondary' : 'primary';
const fieldVariant = 'secondary';
if (!contentFields || contentFields.length === 0) {
if (minimal) {
......@@ -76,13 +76,14 @@ export default function SubjectViewContent({
);
}
return (
const fields = (
<section
className="grid min-w-0 grid-cols-1 gap-x-8 gap-y-2 md:grid-cols-2"
>
{contentFields.map((field) => {
const value = getFieldValue(editableRecord, field.field);
const fieldType = field.fieldDef?.type || 'text';
const fieldLabel = getFieldLabel(field);
const isFullWidth = FULL_WIDTH_FIELD_TYPES.has(fieldType);
const fieldDisabled =
isFieldDisabled?.(field.field, view) ||
......@@ -98,7 +99,7 @@ export default function SubjectViewContent({
)}
>
<div className={minimal ? 'sr-only' : 'text-sm font-medium text-muted'}>
{getFieldLabel(field)}
{fieldLabel}
</div>
<div className={minimal ? undefined : 'mt-1.5 min-w-0'}>
<TailwindEditableField
......@@ -107,6 +108,7 @@ export default function SubjectViewContent({
fieldPath={field.field}
editSessionId={editSessionId}
fieldVariant={fieldVariant}
placeholder={`请输入${fieldLabel}`}
mode={mode}
onChange={(newValue) =>
onFieldChange?.(
......@@ -126,4 +128,14 @@ 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;
}
......@@ -74,9 +74,26 @@ function FieldError({ children, className }) {
);
}
function isEmptyFieldValue(value) {
if (value === null || value === undefined) return true;
if (typeof value === 'string') return value.trim() === '';
if (Array.isArray(value)) {
return value.length === 0 || value.every((item) => isEmptyFieldValue(item));
}
return false;
}
function getTextPlaceholder(placeholder, fieldDef) {
const explicitPlaceholder = String(placeholder || '').trim();
if (explicitPlaceholder) return explicitPlaceholder;
const fieldLabel = String(fieldDef?.label || '').trim();
return fieldLabel ? `请输入${fieldLabel}` : '请输入内容';
}
function FieldValue({ value, fieldDef }) {
if (value === null || value === undefined || value === '' || (Array.isArray(value) && value.length === 0)) {
return <span className="text-muted">未填写</span>;
if (isEmptyFieldValue(value)) {
return <span className="text-sm text-muted">暂无内容</span>;
}
if (fieldDef?.type === 'select') {
......@@ -128,7 +145,7 @@ function FieldValue({ value, fieldDef }) {
if (fieldDef?.type === 'object' || fieldDef?.type === 'form' || fieldDef?.type === 'subject') {
return (
<Card variant="secondary">
<Card variant="tertiary">
<Card.Content>
<pre className="max-h-48 overflow-auto text-xs leading-5">
{JSON.stringify(value, null, 2)}
......@@ -139,7 +156,11 @@ function FieldValue({ value, fieldDef }) {
}
const text = formatFieldValue(value, fieldDef);
return <span className="whitespace-pre-wrap text-foreground">{text || '-'}</span>;
return (
<span className={clsx('whitespace-pre-wrap', isEmptyFieldValue(text) ? 'text-sm text-muted' : 'text-foreground')}>
{isEmptyFieldValue(text) ? '暂无内容' : text}
</span>
);
}
function UserSelector({ value, onChange, multiple, variant }) {
......@@ -280,6 +301,7 @@ function ControlledFieldEditor({
fieldPath,
editSessionId,
fieldVariant,
placeholder,
onChange,
onValidationChange,
}) {
......@@ -288,6 +310,7 @@ function ControlledFieldEditor({
const fieldType = fieldDef?.type || 'text';
const isDateField = isDateFieldType(fieldType);
const isLongText = ['long_text', 'rich_text'].includes(fieldType);
const textPlaceholder = getTextPlaceholder(placeholder, fieldDef);
const handleFileUpload = async (files) => {
const file = files?.[0];
......@@ -366,7 +389,7 @@ function ControlledFieldEditor({
if (fieldType === 'file') {
return (
<Card>
<Card variant="secondary">
<Card.Content className="space-y-3">
{toArray(value).length === 0 ? (
<span className="text-sm text-muted">未上传</span>
......@@ -450,6 +473,7 @@ function ControlledFieldEditor({
return (
<TextArea
fullWidth
placeholder={textPlaceholder}
rows={5}
value={value || ''}
variant={fieldVariant}
......@@ -471,6 +495,7 @@ function ControlledFieldEditor({
return (
<Input
fullWidth
placeholder={textPlaceholder}
type={fieldType === 'number' ? 'number' : 'text'}
value={value ?? ''}
variant={fieldVariant}
......@@ -489,7 +514,8 @@ export default function TailwindEditableField({
fieldDef,
fieldPath,
editSessionId,
fieldVariant = 'primary',
fieldVariant = 'secondary',
placeholder,
onSave,
onChange,
onValidationChange,
......@@ -517,6 +543,7 @@ export default function TailwindEditableField({
const isDateField = isDateFieldType(fieldType);
const isLongText = ['long_text', 'rich_text'].includes(fieldType);
const isJsonLike = ['object', 'form', 'subject'].includes(fieldType);
const textPlaceholder = getTextPlaceholder(placeholder, fieldDef);
if (mode === 'view' || (mode === 'edit' && (readonly || disabled))) {
return <ReadonlyFieldValue value={value} fieldDef={fieldDef} />;
......@@ -530,6 +557,7 @@ export default function TailwindEditableField({
fieldPath={fieldPath}
editSessionId={editSessionId}
fieldVariant={fieldVariant}
placeholder={textPlaceholder}
onChange={onChange}
onValidationChange={onValidationChange}
/>
......@@ -734,7 +762,7 @@ export default function TailwindEditableField({
if (fieldType === 'file') {
return (
<Card>
<Card variant="secondary">
<Card.Content className="space-y-3">
<div className="space-y-2">
{toArray(draft).length === 0 ? (
......@@ -822,6 +850,7 @@ export default function TailwindEditableField({
<TextArea
autoFocus
fullWidth
placeholder={textPlaceholder}
rows={5}
value={draft || ''}
variant="secondary"
......@@ -848,6 +877,7 @@ export default function TailwindEditableField({
<Input
autoFocus
fullWidth
placeholder={textPlaceholder}
type={fieldType === 'number' ? 'number' : 'text'}
value={draft ?? ''}
variant="secondary"
......
......@@ -9,6 +9,7 @@ import {
Description,
Modal,
Skeleton,
Surface,
} from '@heroui/react';
import { useEffect, useMemo, useState } from 'react';
......@@ -124,7 +125,7 @@ function ApplicationCard({ application, current }) {
const initial = application.title.trim().slice(0, 1).toUpperCase() || 'A';
return (
<Card className="h-full" variant={current ? 'secondary' : 'default'}>
<Card className="h-full" variant="secondary">
<Card.Content>
<a
href={application.url}
......@@ -133,10 +134,22 @@ function ApplicationCard({ application, current }) {
aria-current={current ? 'page' : undefined}
className="flex min-w-0 items-center gap-3 no-underline"
>
<Avatar size="md">
{application.logo ? <Avatar.Image src={application.logo} alt={application.title} /> : null}
<Avatar.Fallback>{initial}</Avatar.Fallback>
</Avatar>
<Surface
aria-hidden="true"
className="grid size-9 shrink-0 place-items-center overflow-hidden rounded-[var(--rc-radius-control)]"
variant="tertiary"
>
<Avatar
className="size-full rounded-[var(--rc-radius-control)] bg-transparent"
color="accent"
variant="soft"
>
{application.logo ? (
<Avatar.Image className="object-contain p-1.5" src={application.logo} alt="" />
) : null}
<Avatar.Fallback>{initial}</Avatar.Fallback>
</Avatar>
</Surface>
<span className="min-w-0 flex-1">
<span className="block break-words font-medium leading-5 text-foreground" title={application.title}>
{application.title}
......@@ -156,9 +169,9 @@ function LoadingSkeleton() {
return (
<div className="grid gap-3 sm:grid-cols-2" aria-label="正在加载应用">
{Array.from({ length: 6 }).map((_, index) => (
<Card key={index}>
<Card key={index} variant="secondary">
<Card.Content className="flex items-center gap-3">
<Skeleton className="size-10" />
<Skeleton className="size-9" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-3 w-full" />
......
......@@ -131,7 +131,7 @@ export default function ProblemRectificationArchivePage() {
label: '关键词搜索',
type: 'text',
icon: 'search',
placeholder: '搜索归档批次标题、编码或关联字段...',
placeholder: '请输入关键词',
},
]}
onFiltersChange={(key, value) => {
......@@ -155,14 +155,14 @@ export default function ProblemRectificationArchivePage() {
{
key: 'archive-export',
label: '导出批次',
variant: 'outline',
variant: 'secondary',
disabled: true,
onClick: () => {},
},
{
key: 'archive-compare',
label: '批次对比',
variant: 'outline',
variant: 'secondary',
disabled: true,
onClick: () => {},
},
......
import { DataTable } from '@/components/DataTable';
import {
colorizeXinyuanChartTooltipPayload,
createXinyuanChartColorMap,
getXinyuanChartColor,
XINYUAN_CHART_SEMANTIC,
} from '@/design-system/chartPalette';
import XinyuanModal from '@/components/XinyuanModal';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import {
......@@ -18,6 +24,7 @@ import {
} 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';
......@@ -25,11 +32,11 @@ import { Cell, Legend } from 'recharts';
import { useEffect, useMemo, useState } from 'react';
import { loadProblemRectificationDashboardData } from '../problemRectificationDashboardService';
const CATEGORY_LEVEL_TABS = [
{ key: 'level1', label: '一级分类' },
{ key: 'level2', label: '二级分类' },
{ key: 'level3', label: '三级分类' },
{ key: 'level4', label: '四级分类' },
const CATEGORY_LEVEL_OPTIONS = [
{ id: 'level1', label: '一级分类' },
{ id: 'level2', label: '二级分类' },
{ id: 'level3', label: '三级分类' },
{ id: 'level4', label: '四级分类' },
];
const FOCUS_ISSUE_TYPES = [
{
......@@ -54,14 +61,10 @@ const FOCUS_ISSUE_TYPES = [
tone: 'accent',
},
];
const CHART_PALETTE = [
'var(--accent)',
'var(--warning)',
'var(--success)',
'var(--danger)',
'var(--muted)',
'var(--foreground)',
];
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);
......@@ -83,14 +86,11 @@ function SectionHeading({ title }) {
return <h2 className="text-base font-medium text-foreground">{title}</h2>;
}
function StatCard({ label, value, icon: Icon, tone = 'neutral' }) {
const iconStatus = ['danger', 'success', 'warning'].includes(tone)
? tone
: undefined;
function StatCard({ label, value, icon: Icon, tone }) {
const numericValue = Number.parseFloat(String(value).replace(/,/g, ''));
return (
<KPI>
<KPI className="rc-card-surface rc-dashboard-card">
<KPI.Header>
<KPI.Title>{label}</KPI.Title>
</KPI.Header>
......@@ -99,7 +99,11 @@ function StatCard({ label, value, icon: Icon, tone = 'neutral' }) {
{() => value}
</KPI.Value>
{Icon ? (
<KPI.Icon status={iconStatus} aria-label={label}>
<KPI.Icon
aria-label={label}
className="rc-metric-icon-surface"
data-tone={tone || undefined}
>
<Icon size={20} />
</KPI.Icon>
) : null}
......@@ -108,12 +112,31 @@ function StatCard({ label, value, icon: Icon, tone = 'neutral' }) {
);
}
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.Header className="flex flex-wrap items-center justify-between gap-3">
<Card.Title>{title}</Card.Title>
{actions}
<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>
{actions ? <div className="shrink-0">{actions}</div> : null}
</Card.Header>
<Card.Content>{children}</Card.Content>
</Card>
......@@ -170,8 +193,9 @@ 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 height={320}>
<PieChart className="rc-chart-interaction" height={320}>
<PieChart.Pie
data={data}
dataKey="value"
......@@ -183,7 +207,9 @@ function DonutChart({ data, centerLabel, colorForItem }) {
{data.map((item, index) => (
<PieChart.Cell
key={`${item.name}-${index}`}
fill={colorForItem?.(item, index) || CHART_PALETTE[index % CHART_PALETTE.length]}
fill={colorForItem?.(item, index)
|| colorMap.get(String(item.name ?? '').trim())
|| getXinyuanChartColor(item.name, index)}
/>
))}
<PieChart.Label
......@@ -204,20 +230,40 @@ function DonutChart({ data, centerLabel, colorForItem }) {
/>
</PieChart.Pie>
<PieChart.Tooltip
content={<PieChart.TooltipContent valueFormatter={(value) => `${formatCount(value)} 条`} />}
content={(
<XinyuanChartTooltipContent
colorForEntry={({ index, key, sourceDatum }) => (
colorForItem?.(sourceDatum, index)
|| colorMap.get(String(key ?? '').trim())
|| getXinyuanChartColor(key, index)
)}
valueFormatter={(value) => `${formatCount(value)} 条`}
/>
)}
/>
</PieChart>
);
}
function HorizontalBarChart({ data, color = 'var(--accent)', onSelect }) {
function HorizontalBarChart({ data, color = XINYUAN_CHART_SEMANTIC.primary, onSelect }) {
return (
<BarChart data={data} height={320} layout="vertical" margin={{ left: 16, right: 24 }}>
<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={<BarChart.TooltipContent valueFormatter={(value) => `${formatCount(value)} 条`} />}
content={(
<XinyuanChartTooltipContent
colorForEntry={({ sourceDatum }) => sourceDatum?.fill || color}
valueFormatter={(value) => `${formatCount(value)} 条`}
/>
)}
/>
<BarChart.Bar
dataKey="value"
......@@ -236,6 +282,9 @@ function HorizontalBarChart({ data, color = 'var(--accent)', onSelect }) {
}
function StackedSourceChart({ sourceComparison }) {
const seriesColorMap = createXinyuanChartColorMap(
sourceComparison.series.map((series) => series.name),
);
const chartData = sourceComparison.categories.map((name, index) => ({
name,
...Object.fromEntries(
......@@ -244,19 +293,36 @@ function StackedSourceChart({ sourceComparison }) {
}));
return (
<BarChart data={chartData} height={320} margin={{ left: 8, right: 16, top: 24 }}>
<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={<BarChart.TooltipContent valueFormatter={(value) => `${formatCount(value)} 条`} />}
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}
/>
<Legend iconSize={8} iconType="circle" />
{sourceComparison.series.map((series, index) => (
<BarChart.Bar
key={series.name}
dataKey={series.name}
fill={CHART_PALETTE[index % CHART_PALETTE.length]}
fill={seriesColorMap.get(String(series.name ?? '').trim())
|| getXinyuanChartColor(series.name, index)}
maxBarSize={34}
stackId="total"
/>
......@@ -267,7 +333,12 @@ function StackedSourceChart({ sourceComparison }) {
function DepartmentMatrixChart({ data }) {
return (
<ComposedChart data={data} height={320} margin={{ left: 8, right: 8, top: 24 }}>
<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" />
......@@ -278,18 +349,32 @@ function DepartmentMatrixChart({ data }) {
tickFormatter={(value) => `${value}%`}
yAxisId="rate"
/>
<ComposedChart.Tooltip content={<ComposedChart.TooltipContent />} />
<Legend iconSize={8} iconType="circle" />
<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="var(--accent)"
fill={XINYUAN_CHART_SEMANTIC.primary}
maxBarSize={28}
name="问题总数"
yAxisId="count"
/>
<ComposedChart.Bar
dataKey="overdueCount"
fill="var(--danger)"
fill={XINYUAN_CHART_SEMANTIC.warning}
maxBarSize={28}
name="逾期未完成"
yAxisId="count"
......@@ -298,7 +383,7 @@ function DepartmentMatrixChart({ data }) {
dataKey="completionRate"
dot={false}
name="整改完成率"
stroke="var(--success)"
stroke={XINYUAN_CHART_SEMANTIC.positive}
strokeWidth={2}
type="monotone"
yAxisId="rate"
......@@ -381,10 +466,10 @@ export function ProblemRectificationDashboardPage({ dashboardMode = 'overview' }
name: type.label,
value: summary.focusIssueGroups?.[type.key]?.length || 0,
fill: type.tone === 'danger'
? 'var(--danger)'
? XINYUAN_CHART_SEMANTIC.risk
: type.tone === 'warning'
? 'var(--warning)'
: 'var(--accent)',
? XINYUAN_CHART_SEMANTIC.warning
: XINYUAN_CHART_SEMANTIC.comparison,
})),
[summary.focusIssueGroups],
);
......@@ -477,7 +562,7 @@ export function ProblemRectificationDashboardPage({ dashboardMode = 'overview' }
{!isBusinessDashboard ? (
<>
<section className="rc-page-section">
<div className="rc-kpi-grid grid md:grid-cols-2 xl:grid-cols-3">
<div className="rc-kpi-grid" data-count="5">
<StatCard
label="问题总数"
value={formatCount(summary.totalCount)}
......@@ -527,31 +612,25 @@ export function ProblemRectificationDashboardPage({ dashboardMode = 'overview' }
<>
<section className="rc-page-section">
<SectionHeading title="问题分析与风险定位" />
<div className="rc-page-grid grid xl:grid-cols-[1.05fr_0.95fr]">
<div className="rc-page-grid rc-chart-grid">
<Panel
title="问题类型 / 业务领域分布"
actions={(
<Tabs
<XinyuanAutocomplete
aria-label="分类层级"
className="w-fit max-w-full min-w-0"
className="w-36"
fullWidth={false}
options={CATEGORY_LEVEL_OPTIONS}
placement="bottom end"
placeholder="选择分类层级"
selectedKey={activeCategoryLevel}
shouldFilter={false}
valueText={CATEGORY_LEVEL_OPTIONS.find(
(option) => option.id === activeCategoryLevel,
)?.label}
variant="secondary"
onSelectionChange={(key) => setActiveCategoryLevel(String(key))}
>
<Tabs.ListContainer className="w-fit max-w-full min-w-0">
<Tabs.List className="w-max min-w-max">
{CATEGORY_LEVEL_TABS.map((tab) => (
<Tabs.Tab
className="w-auto min-w-max flex-none whitespace-nowrap"
id={tab.key}
key={tab.key}
>
{tab.label}
<Tabs.Indicator />
</Tabs.Tab>
))}
</Tabs.List>
</Tabs.ListContainer>
</Tabs>
/>
)}
>
{activeCategoryData.length > 0 ? (
......@@ -569,35 +648,40 @@ export function ProblemRectificationDashboardPage({ dashboardMode = 'overview' }
</Panel>
</div>
<Panel title="问题来源对比">
{summary.sourceComparison.categories.length > 0 ? (
<StackedSourceChart sourceComparison={summary.sourceComparison} />
) : (
<EmptyBlock title="暂无问题来源对比数据" />
)}
</Panel>
<div className="rc-page-grid rc-chart-grid">
<Panel title="问题来源对比">
{summary.sourceComparison.categories.length > 0 ? (
<StackedSourceChart sourceComparison={summary.sourceComparison} />
) : (
<EmptyBlock title="暂无问题来源对比数据" />
)}
</Panel>
<Panel title="重点问题分布">
{focusIssueChartData.some((item) => item.value > 0) ? (
<HorizontalBarChart
data={focusIssueChartData}
onSelect={(entry) => {
const issueTypeKey = entry?.payload?.key || entry?.key;
if (issueTypeKey) setActiveFocusIssueTypeKey(issueTypeKey);
}}
/>
) : (
<EmptyBlock title="当前没有相关重点问题" />
)}
</Panel>
<Panel title="重点问题分布">
{focusIssueChartData.some((item) => item.value > 0) ? (
<HorizontalBarChart
data={focusIssueChartData}
onSelect={(entry) => {
const issueTypeKey = entry?.payload?.key || entry?.key;
if (issueTypeKey) setActiveFocusIssueTypeKey(issueTypeKey);
}}
/>
) : (
<EmptyBlock title="当前没有相关重点问题" />
)}
</Panel>
</div>
</section>
<section className="rc-page-section">
<SectionHeading title="问题责任追踪与执行" />
<div className="rc-page-grid grid xl:grid-cols-[0.9fr_1.1fr]">
<div className="rc-page-grid rc-chart-grid">
<Panel title="逾期问题排行">
{summary.overdueRanking.length > 0 ? (
<HorizontalBarChart data={summary.overdueRanking} color="var(--danger)" />
<HorizontalBarChart
data={summary.overdueRanking}
color={XINYUAN_CHART_SEMANTIC.risk}
/>
) : (
<EmptyBlock title="当前没有逾期问题排行数据" />
)}
......@@ -614,23 +698,25 @@ export function ProblemRectificationDashboardPage({ dashboardMode = 'overview' }
<section className="rc-page-section">
<SectionHeading title="审计整改质效" />
<Panel title="综合整改质效结果">
{summary.qualityRatingDistribution.length > 0 ? (
<DonutChart
data={summary.qualityRatingDistribution}
centerLabel="评级记录"
colorForItem={(item) => (
item.name === '高'
? 'var(--danger)'
: item.name === '中'
? 'var(--warning)'
: 'var(--success)'
)}
/>
) : (
<EmptyBlock title="暂无综合整改质效结果数据" />
)}
</Panel>
<div className="rc-page-grid rc-chart-grid">
<Panel title="综合整改质效结果">
{summary.qualityRatingDistribution.length > 0 ? (
<DonutChart
data={summary.qualityRatingDistribution}
centerLabel="评级记录"
colorForItem={(item) => (
item.name === '高'
? XINYUAN_CHART_SEMANTIC.positive
: item.name === '中'
? XINYUAN_CHART_SEMANTIC.warning
: XINYUAN_CHART_SEMANTIC.risk
)}
/>
) : (
<EmptyBlock title="暂无综合整改质效结果数据" />
)}
</Panel>
</div>
</section>
</>
) : null}
......
......@@ -1288,7 +1288,7 @@ export default function ProblemRectificationFlowTablePage() {
{isDelayFlow ? (
<>
{editable ? (
<Button isDisabled={submitting} size="sm" variant="outline" onPress={() => handleDelaySave(row)}>
<Button isDisabled={submitting} size="sm" variant="secondary" onPress={() => handleDelaySave(row)}>
保存申请
</Button>
) : null}
......@@ -1324,7 +1324,7 @@ export default function ProblemRectificationFlowTablePage() {
) : (
<>
{editable ? (
<Button isDisabled={submitting} size="sm" variant="outline" onPress={() => handleSave(row)}>
<Button isDisabled={submitting} size="sm" variant="secondary" onPress={() => handleSave(row)}>
保存
</Button>
) : null}
......@@ -1573,7 +1573,7 @@ export default function ProblemRectificationFlowTablePage() {
aria-label="刷新流程"
isDisabled={loading || Boolean(submittingKey)}
size="sm"
variant="outline"
variant="secondary"
onPress={loadFlow}
>
<ArrowPathIcon size={16} />
......
......@@ -12,6 +12,7 @@ import { normalizeText } from '@/utils/subjectView';
import dayjs from 'dayjs';
import { Alert, Button, Card, Chip, Spinner } from '@heroui/react';
import { EmptyState as ProEmptyState } from '@heroui-pro/react/empty-state';
import { KPI } from '@heroui-pro/react/kpi';
import { useContext, useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'umi';
import { UserContext } from '@/wrapper/Auth';
......@@ -73,22 +74,42 @@ function StepBadge({ title, status, active }) {
);
}
function SummaryCard({ label, value, hint, icon: Icon, tone = 'neutral' }) {
function SummaryCard({ label, value, hint, icon: Icon, tone, variant = 'default' }) {
if (Icon) {
const numericValue = Number.parseFloat(String(value).replace(/,/g, ''));
return (
<KPI className="rc-card-surface rc-dashboard-card">
<KPI.Header>
<KPI.Title>{label}</KPI.Title>
</KPI.Header>
<KPI.Content>
<KPI.Value value={Number.isFinite(numericValue) ? numericValue : 0}>
{() => value}
</KPI.Value>
<KPI.Icon
aria-label={label}
className="rc-metric-icon-surface"
data-tone={tone || undefined}
>
<Icon size={20} />
</KPI.Icon>
</KPI.Content>
{hint ? <KPI.Footer className="text-xs text-muted">{hint}</KPI.Footer> : null}
</KPI>
);
}
return (
<Card>
<Card variant={variant}>
<Card.Content className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="text-sm font-medium text-muted">{label}</div>
<div className={Icon ? 'mt-2 text-2xl font-medium text-foreground' : 'mt-2 text-lg font-medium text-foreground'}>
<div className="mt-2 text-lg font-medium text-foreground">
{value}
</div>
{hint ? <div className="mt-1 text-xs text-muted">{hint}</div> : null}
</div>
{Icon ? (
<Chip color={['danger', 'success', 'warning'].includes(tone) ? tone : undefined} size="lg" variant="soft" aria-label={label}>
<Icon size={20} />
</Chip>
) : null}
</Card.Content>
</Card>
);
......@@ -387,7 +408,7 @@ export default function ProblemRectificationFormPage() {
/>
</section>
<section className="rc-page-grid grid md:grid-cols-3">
<section className="rc-kpi-grid" data-count="3">
<SummaryCard label="关联记录" value={summary.total} icon={Database} tone="info" />
<SummaryCard
label="已整改"
......@@ -451,7 +472,7 @@ export default function ProblemRectificationFormPage() {
render: (record) => (
<Button
size="sm"
variant="outline"
variant="secondary"
onPress={() => setSelectedRecord(record)}
>
{canEditRecords && !record.isLocked ? '查看/填写' : '查看详情'}
......@@ -491,7 +512,7 @@ export default function ProblemRectificationFormPage() {
</Card.Header>
<Card.Content>
<div className="flex flex-wrap gap-3">
<Button isDisabled={submitting} size="sm" variant="outline" onPress={reload}>
<Button isDisabled={submitting} size="sm" variant="secondary" onPress={reload}>
刷新流程
</Button>
......@@ -553,10 +574,10 @@ export default function ProblemRectificationFormPage() {
</Alert>
) : null}
<div className="grid gap-4 md:grid-cols-2">
<SummaryCard label="问题标题" value={selectedRecord.title || '-'} />
<SummaryCard label="反馈单位" value={selectedRecord.feedbackUnit || '-'} />
<SummaryCard label="整改进度" value={selectedRecord.progress || '-'} />
<SummaryCard label="预计完成时间" value={formatDateText(selectedRecord.raw?.metadata?.the_time_of_rectification)} />
<SummaryCard label="问题标题" value={selectedRecord.title || '-'} variant="secondary" />
<SummaryCard label="反馈单位" value={selectedRecord.feedbackUnit || '-'} variant="secondary" />
<SummaryCard label="整改进度" value={selectedRecord.progress || '-'} variant="secondary" />
<SummaryCard label="预计完成时间" value={formatDateText(selectedRecord.raw?.metadata?.the_time_of_rectification)} variant="secondary" />
</div>
<div className="grid gap-4 lg:grid-cols-2">
......
......@@ -14,7 +14,6 @@ import {
TableActionButton,
} from '@/components/TableActionButton';
import XinyuanModal from '@/components/XinyuanModal';
import useRootDarkTheme from '@/hooks/useRootDarkTheme';
import RecordWorkbench from '@/components/record-workbench';
import RecordDetailDrawer from '@/pages/RecordDetailDrawer';
import {
......@@ -796,8 +795,7 @@ export default function ProblemRectificationListPage() {
});
const [gridViewCounts, setGridViewCounts] = useState({});
const [workflowUserLabels, setWorkflowUserLabels] = useState({});
const isDarkTheme = useRootDarkTheme();
const createFieldVariant = isDarkTheme ? 'secondary' : 'primary';
const createFieldVariant = 'secondary';
const reloadResources = useCallback(async () => {
setLoading(true);
......@@ -1660,7 +1658,7 @@ export default function ProblemRectificationListPage() {
label: '关键词搜索',
type: 'text',
icon: 'search',
placeholder: '搜索项目名称、严重程度、分类、责任部门、问题原因...',
placeholder: '请输入关键词',
},
{
key: 'inspectionReportMonth',
......@@ -1750,14 +1748,14 @@ export default function ProblemRectificationListPage() {
{
key: 'export',
label: '导出',
variant: 'outline',
variant: 'secondary',
disabled: true,
onClick: () => {},
},
{
key: 'archive',
label: '一键归档',
variant: 'outline',
variant: 'secondary',
disabled: true,
onClick: () => {},
},
......@@ -1808,7 +1806,7 @@ export default function ProblemRectificationListPage() {
key: 'view-delay-request',
label: '查看延期申请',
icon: CalendarDaysIcon,
variant: 'outline',
variant: 'secondary',
hidden: ({ record }) => !resolveDelayRequestDisplay(record),
onClick: ({ record }) => setDelayDetailRecord(record),
},
......@@ -1816,7 +1814,7 @@ export default function ProblemRectificationListPage() {
key: 'lock',
label: '锁定',
icon: LockClosedIcon,
variant: 'outline',
variant: 'secondary',
hidden: ({ record }) => record.isLocked || record.progress !== '已整改',
disabled: () => actionLoading,
onClick: ({ record }) => handleLockToggle(record, true),
......@@ -1825,7 +1823,7 @@ export default function ProblemRectificationListPage() {
key: 'unlock',
label: '解锁',
icon: LockOpenIcon,
variant: 'outline',
variant: 'secondary',
hidden: ({ record }) => !record.isLocked,
disabled: () => actionLoading,
onClick: ({ record }) => handleLockToggle(record, false),
......@@ -1833,14 +1831,14 @@ export default function ProblemRectificationListPage() {
{
key: 'clone',
label: '复制',
variant: 'outline',
variant: 'secondary',
disabled: ({ record }) => actionLoading || record.isLocked,
onClick: ({ record }) => setCloneTargetRecord(record),
},
{
key: 'delete',
label: '删除',
variant: 'outline',
variant: 'danger-soft',
tone: 'danger',
disabled: ({ record }) => actionLoading || record.isLocked,
onClick: ({ record }) => handleDelete([record]),
......
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