Commit 98b07c71 authored by Andy-bubu's avatar Andy-bubu

feat: sync Xinyuan UI and list interactions

parent 2da8483a
...@@ -11,6 +11,7 @@ ...@@ -11,6 +11,7 @@
/.umi-test /.umi-test
/dist /dist
/.playwright-cli /.playwright-cli
/artifacts
/.mfsu /.mfsu
.swc .swc
.pnpm-store .pnpm-store
...@@ -16,9 +16,11 @@ ...@@ -16,9 +16,11 @@
- 不引入 Catalyst、Headless UI、兼容旧 props 的 HeroUI 包装层或可见原生选择/日期/文件控件。 - 不引入 Catalyst、Headless UI、兼容旧 props 的 HeroUI 包装层或可见原生选择/日期/文件控件。
- 列表使用 HeroUI Pro DataGrid、ActionBar、EmptyState 和 HeroUI Pagination。 - 列表使用 HeroUI Pro DataGrid、ActionBar、EmptyState 和 HeroUI Pagination。
- 筛选使用 HeroUI Surface、SearchField、Autocomplete、DatePicker 和 Disclosure。 - 筛选使用 HeroUI Surface、SearchField、Autocomplete、DatePicker 和 Disclosure。
- 受控 Autocomplete 的已选值必须可逆操作:单选使用官方 `Autocomplete.ClearButton` 清除当前值;多选在 Trigger 内使用官方 `TagGroup + Tag + Tag.RemoveButton` 将每个值显示为独立 Tag 并支持单项删除,同时保留官方清除入口清空集合;下拉中的官方 `ListBox.Item` 再次点选也可移除单项。Tag、清除按钮和选中态都从同一受控 `selectedKeys` 派生,不得合并为逗号文本、使用页面自制标签或维护第二套选择状态。 - 受控 Autocomplete 的已选值必须可逆操作:单选使用官方 `Autocomplete.ClearButton` 清除当前值;多选在 Trigger 内使用官方 `TagGroup + Tag + Tag.RemoveButton` 将每个值显示为独立 Tag 并支持单项删除,同时保留官方清除入口清空集合;下拉中的官方 `ListBox.Item` 再次点选也可移除单项。Tag、清除按钮和选中态都从同一受控 `selectedKeys` 派生,不得合并为逗号文本、使用页面自制标签或维护第二套选择状态。已选 TagGroup 使用 HeroUI `default` variant,Tag 使用官方 `accent-soft-hover` / `accent-soft-foreground` 语义 Token,继承鑫元品牌辅助色并与字段背景拉开层级,避免纯白 `surface`。
- `isOpen` 只在调用方明确提供布尔值时作为受控属性传给 Autocomplete;未提供时必须让 HeroUI 管理打开态,禁止用 `isOpen={undefined}` 锁死 Trigger。 - `isOpen` 只在调用方明确提供布尔值时作为受控属性传给 Autocomplete;未提供时必须让 HeroUI 管理打开态,禁止用 `isOpen={undefined}` 锁死 Trigger。
- KPI 使用 HeroUI Pro KPI;图表使用 HeroUI Pro 图表与 ChartTooltip。 - KPI 使用 HeroUI Pro KPI;图表使用 HeroUI Pro 图表与 ChartTooltip。
- 鑫元唯一 Primary Brand Color 为 `#DA241A`;HeroUI `--accent`、`--primary`、focus、hover、pressed 和按钮 disabled 状态均从品牌红阶派生。
- `--xinyuan-chart-1` 至 `--xinyuan-chart-16` 是独立于 UI 状态色的可视化 Token,采用品牌红、暖中性色、灰紫和灰蓝的低饱和顺序,不包含绿色或荧光色。单系列使用 `--rc-chart-primary`,多系列通过 `src/utils/chartColors.js` 按稳定业务 key 分配颜色。
- Modal/Dropdown/Popover/Tooltip 等 Portal 必须继承根级深色主题。 - Modal/Dropdown/Popover/Tooltip 等 Portal 必须继承根级深色主题。
- Modal 面板保持 HeroUI Overlay 的 24px 圆角;弹窗内结构性 Surface 只声明官方 `secondary/tertiary` variant 和布局间距,不再通过业务 JSX 覆盖圆角、背景、描边或阴影。详情中的关联记录属于 L2 详情 Surface 内的静态 L3 内容,使用官方 `Surface variant="tertiary"` 承载,并在其内部使用共享 `DataTable` 的 HeroUI Pro DataGrid `secondary` variant;表头保持 `surface-secondary`、表体保持透明并继承 L3 Surface。禁止页面自定义颜色、描边、阴影或 HeroUI 内部 BEM/slot 外观。 - Modal 面板保持 HeroUI Overlay 的 24px 圆角;弹窗内结构性 Surface 只声明官方 `secondary/tertiary` variant 和布局间距,不再通过业务 JSX 覆盖圆角、背景、描边或阴影。详情中的关联记录属于 L2 详情 Surface 内的静态 L3 内容,使用官方 `Surface variant="tertiary"` 承载,并在其内部使用共享 `DataTable` 的 HeroUI Pro DataGrid `secondary` variant;表头保持 `surface-secondary`、表体保持透明并继承 L3 Surface。禁止页面自定义颜色、描边、阴影或 HeroUI 内部 BEM/slot 外观。
- Modal 统一带有 `rc-dialog-panel rc-dialog-panel--{size}` 宽度阶梯;宽度只由共享布局类负责,背景、圆角、阴影、关闭和状态由 HeroUI Modal compound API 提供。 - Modal 统一带有 `rc-dialog-panel rc-dialog-panel--{size}` 宽度阶梯;宽度只由共享布局类负责,背景、圆角、阴影、关闭和状态由 HeroUI Modal compound API 提供。
...@@ -26,9 +28,15 @@ ...@@ -26,9 +28,15 @@
- 字段标签必须使用共享 `getFieldDisplayLabel` 将字段定义别名映射为中文;`metadata.*` 仅是数据路径,禁止直接显示在标签、placeholder、ARIA 文案或列表表头中。未知技术字段统一显示“其他信息”,不得把英文下划线别名暴露给用户。 - 字段标签必须使用共享 `getFieldDisplayLabel` 将字段定义别名映射为中文;`metadata.*` 仅是数据路径,禁止直接显示在标签、placeholder、ARIA 文案或列表表头中。未知技术字段统一显示“其他信息”,不得把英文下划线别名暴露给用户。
- 输入提示只出现在控件内部:文本/长文本使用中文 placeholder,选择、日期、人员/引用和附件使用各自 HeroUI 控件的内部提示。禁止在控件下方再渲染同一句自动生成的 `Description`;真实错误、加载、空状态仍分别由 Alert、Spinner、EmptyState 承担。 - 输入提示只出现在控件内部:文本/长文本使用中文 placeholder,选择、日期、人员/引用和附件使用各自 HeroUI 控件的内部提示。禁止在控件下方再渲染同一句自动生成的 `Description`;真实错误、加载、空状态仍分别由 Alert、Spinner、EmptyState 承担。
- 弹窗内编辑字段统一使用 HeroUI `secondary` variant:文本、TextArea、DatePicker、Autocomplete(含普通选项、人员、引用字段)必须共享 L2 字段背景与 disabled 状态,不得因组合组件漏传 variant 产生白灰混杂层级。 - 弹窗内编辑字段统一使用 HeroUI `secondary` variant:文本、TextArea、DatePicker、Autocomplete(含普通选项、人员、引用字段)必须共享 L2 字段背景与 disabled 状态,不得因组合组件漏传 variant 产生白灰混杂层级。
- 同一业务弹窗的查看态与编辑态必须切换结构层级,而不是只切换 disabled:查看态只用一个 `Surface variant="secondary"` 包住完整只读字段组,字段本身保持无框;编辑态移除该组 Surface,让所有 `secondary` 控件直接位于 Overlay。只读或锁定字段在编辑态保持无框文字,不得渲染成禁用灰色输入框;弹窗中的独立 DataGrid 使用官方 `secondary` variant。 - 同一业务弹窗的查看态与编辑态必须保持相同字段顺序和内容结构:预览态普通 Detail 字段直接使用 Modal 页面背景,不包裹灰色大块 Surface;编辑态让可编辑字段使用 HeroUI `secondary` 控件通铺整行,禁用字段保持无框只读文字。动态表单、关联记录和独立 DataGrid 等完整模块才使用官方 `secondary/tertiary` Surface。
- 列表页标题只由 HeroUI Pro Navbar 承担;内容区不重复渲染同名二级标题,搜索栏和业务工具栏直接位于页面内容起始位置。 - 列表页标题只由 HeroUI Pro Navbar 承担;内容区不重复渲染同名二级标题,搜索栏和业务工具栏直接位于页面内容起始位置。
- 筛选操作组在查询按钮左侧提供 HeroUI `Button isIconOnly variant="secondary"` 的重置筛选入口,使用 Ant Design `ArrowPathIcon` 16px 图标和 Tooltip;重置只调用现有 `onClearFilters` 回调。
- 筛选字段占位文案由共享类型规则统一为简短的“请输入”“请选择”或“请选择日期”,不重复拼接完整字段标签;未选择值不挂载 Autocomplete 清除图标,真实选中后才显示清除入口。
- 筛选区的引用字段必须与普通下拉、人员和日期字段一样使用 HeroUI `secondary` variant,`RecordWorkbench` 使用的共享引用选择器默认并下传该 variant,避免部分引用选择器回退为白色 primary 字段层。
- 详情弹窗预览态使用页面背景作为 Modal 内容背景;普通 Detail 字段不包裹灰色大块 Surface,按 Detail View 配置顺序单列排列,标签为 16px/400 muted,值为 16px/400,空值显示“暂无内容”。动态表单、关联记录等完整模块才使用 HeroUI 模块 Surface。
- 分页页大小使用 HeroUI 原生 Autocomplete,控件宽度固定为 128px;页大小是必选当前值,不显示清除/删除图标,仍通过下拉选项切换。
- 全局 `DataTable` 必须向 HeroUI Pro `DataGrid` 传递 populated/loading/empty 状态类;populated 状态的 root、scroll、resizable、content 链使用 `min-height: 0` 并按真实表格高度收缩,不能保留空状态的最小高度。 - 全局 `DataTable` 必须向 HeroUI Pro `DataGrid` 传递 populated/loading/empty 状态类;populated 状态的 root、scroll、resizable、content 链使用 `min-height: 0` 并按真实表格高度收缩,不能保留空状态的最小高度。
- DataGrid 的纵向滚动必须允许滚动链:列表到达第一行或最后一行后继续滚轮/触控,页面内容容器应继续滚动以露出分页区域;仅横向滚动保持在列表内。共享 `DataTable` 在滚轮到达边界且页面仍可滚动时转发当前增量,避免浏览器或触控板吞掉边界手势;普通列表滚动、Modal 内部表格和页面已到边界时不转发。
- DataGrid 普通字段单行截断,详情关联表的长文本最多两行;完整值通过共享 HeroUI `Tooltip` 提供。页面不得使用 `whitespace-normal`、`font-mono` 或原生 `title` 来替代表格文本契约。 - DataGrid 普通字段单行截断,详情关联表的长文本最多两行;完整值通过共享 HeroUI `Tooltip` 提供。页面不得使用 `whitespace-normal`、`font-mono` 或原生 `title` 来替代表格文本契约。
- DataGrid 表头、普通字段、主字段、次字段和 Chip 统一使用 `--rc-data-grid-*` Token 与 `pm-data-grid-*` 共享排版类,页面不得单独声明表格字重或颜色。 - DataGrid 表头、普通字段、主字段、次字段和 Chip 统一使用 `--rc-data-grid-*` Token 与 `pm-data-grid-*` 共享排版类,页面不得单独声明表格字重或颜色。
- View `settings.groups` 驱动列分组:工具栏分组按钮显示真实分组名与列数,Tooltip 列出所含字段;Hover/Focus 时仅通过语义 Token 联动高亮该组表头,点击切换整组列显隐。分组字段的 DataGrid 表头采用“分组名 + 字段名”两层结构,分组名称按 `label`、`title`、`name` 的顺序解析,不得硬编码业务组名或另建页面状态。 - View `settings.groups` 驱动列分组:工具栏分组按钮显示真实分组名与列数,Tooltip 列出所含字段;Hover/Focus 时仅通过语义 Token 联动高亮该组表头,点击切换整组列显隐。分组字段的 DataGrid 表头采用“分组名 + 字段名”两层结构,分组名称按 `label`、`title`、`name` 的顺序解析,不得硬编码业务组名或另建页面状态。
...@@ -39,6 +47,7 @@ ...@@ -39,6 +47,7 @@
- 批量动作只通过 `@heroui-pro/react/action-bar` 的浮动 ActionBar 呈现;普通工具栏保留导入、全量导出和新建,主操作在最右侧。 - 批量动作只通过 `@heroui-pro/react/action-bar` 的浮动 ActionBar 呈现;普通工具栏保留导入、全量导出和新建,主操作在最右侧。
- 共享 DataGrid 以真实列最小可读宽度之和与当前 viewport 比较决定内部滚动和固定列,不以列数阈值决定宽表。 - 共享 DataGrid 以真实列最小可读宽度之和与当前 viewport 比较决定内部滚动和固定列,不以列数阈值决定宽表。
- 应用切换严格遵循 `model_analysis` 专项基准:HeroUI Modal 宽度为 `min(46rem, 100vw - 2rem)`、最大高度为 `min(42rem, 100dvh - 2rem)`、28px inset,仅 Body 滚动;移动端一列、`sm` 起固定两列,卡片列表与加载骨架均使用 12px 间距,桌面不扩展为三列。 - 应用切换严格遵循 `model_analysis` 专项基准:HeroUI Modal 宽度为 `min(46rem, 100vw - 2rem)`、最大高度为 `min(42rem, 100dvh - 2rem)`、28px inset,仅 Body 滚动;移动端一列、`sm` 起固定两列,卡片列表与加载骨架均使用 12px 间距,桌面不扩展为三列。
- 应用切换 Header 仅保留一个 HeroUI `SearchField`,不渲染可见标题或关闭图标;Modal 通过 `aria-label="应用切换"` 保留可访问名称,隐藏的官方 `Modal.CloseTrigger`、Esc 和 Backdrop 关闭行为保持可用。
- 应用切换分组必须读取 `GET /info -> site.group` 的 `{ label, applications }` 动态配置,按用户真实可访问应用匹配并保持分组和组内顺序;空组和无权限应用不展示,未分组应用进入 `其他应用`,无分组配置时展示单个 `全部应用`,每组右侧显示真实 `{count} 个应用`。禁止写死真实组名、成员或数量。 - 应用切换分组必须读取 `GET /info -> site.group` 的 `{ label, applications }` 动态配置,按用户真实可访问应用匹配并保持分组和组内顺序;空组和无权限应用不展示,未分组应用进入 `其他应用`,无分组配置时展示单个 `全部应用`,每组右侧显示真实 `{count} 个应用`。禁止写死真实组名、成员或数量。
## 布局约定 ## 布局约定
......
...@@ -94,7 +94,7 @@ ...@@ -94,7 +94,7 @@
- `XinyuanModal`、详情 Modal、应用切换 Modal 均使用 `rc-dialog-panel--{size}` 共享宽度阶梯;HeroUI Overlay 继续拥有弹窗背景、24px 圆角、阴影、焦点和关闭交互。 - `XinyuanModal`、详情 Modal、应用切换 Modal 均使用 `rc-dialog-panel--{size}` 共享宽度阶梯;HeroUI Overlay 继续拥有弹窗背景、24px 圆角、阴影、焦点和关闭交互。
- 新增并应用 `--rc-data-grid-*` 与 `pm-data-grid-*` 共享排版 Token:表头 12/500、普通字段 14/400、主字段 14/550、次字段 13/400、Chip 12/500;页面不再单独声明表格字重或颜色。 - 新增并应用 `--rc-data-grid-*` 与 `pm-data-grid-*` 共享排版 Token:表头 12/500、普通字段 14/400、主字段 14/550、次字段 13/400、Chip 12/500;页面不再单独声明表格字重或颜色。
- ReferenceSearchSelect 与 ResourceFilterMultiSelect 已标记共享 semantic class,仍由 HeroUI Autocomplete compound API 负责 trigger、search、ListBox、选中态和 Portal。 - ReferenceSearchSelect 与 ResourceFilterMultiSelect 已标记共享 semantic class,仍由 HeroUI Autocomplete compound API 负责 trigger、search、ListBox、选中态和 Portal。
- 受控选择器复核(2026-08-24):`XinyuanAutocomplete` 的多选 Trigger 使用 HeroUI `TagGroup + Tag + Tag.RemoveButton` 显示独立可删除 Tag;单选清除、多选清空和下拉单项移除继续使用 HeroUI `Autocomplete.ClearButton`、`ListBox` 与 `ListBox.Item` 官方 API。责任单位、人员、引用和普通 Select 均回传原有空值/ID 集合协议,未增加第二套业务状态。 - 受控选择器复核(2026-08-24):`XinyuanAutocomplete` 的多选 Trigger 使用 HeroUI `TagGroup + Tag + Tag.RemoveButton` 显示独立可删除 Tag;Tag 使用官方 `accent-soft-hover` / `accent-soft-foreground` 语义 Token,采用更深的鑫元品牌辅助层并与输入框背景区分,避免已选 Tag 呈纯白 `surface`。单选清除、多选清空和下拉单项移除继续使用 HeroUI `Autocomplete.ClearButton`、`ListBox` 与 `ListBox.Item` 官方 API。责任单位、人员、引用和普通 Select 均回传原有空值/ID 集合协议,未增加第二套业务状态。
### 全局组件复核(2026-08-21) ### 全局组件复核(2026-08-21)
...@@ -149,6 +149,28 @@ ...@@ -149,6 +149,28 @@
- `subjectView.js` 只读取 `user.applications[]` 中名称为 `integrity_risk_prevention_new` 的一项,不读取用户顶层 roles/groups/permissions,也不合并其他应用授权。系统管理员、系统账号和本应用“管理”角色旁路普通授权,但 `disableColumns` 始终先判定并保持只读。 - `subjectView.js` 只读取 `user.applications[]` 中名称为 `integrity_risk_prevention_new` 的一项,不读取用户顶层 roles/groups/permissions,也不合并其他应用授权。系统管理员、系统账号和本应用“管理”角色旁路普通授权,但 `disableColumns` 始终先判定并保持只读。
- 详情加载通过 `GET /subject/:subjectName/view/:viewName` 获取完整 View;当前 Detail 存在任一有效 `auth` 或 `disableColumns` 时整套使用 Detail 权限,两项均无有效配置或没有实际 Detail 时整套回退活动 Grid。Detail 与 Grid 不合并,也不逐字段回退;`hasEditableFields` 只检查当前激活 Detail 的展示字段。 - 详情加载通过 `GET /subject/:subjectName/view/:viewName` 获取完整 View;当前 Detail 存在任一有效 `auth` 或 `disableColumns` 时整套使用 Detail 权限,两项均无有效配置或没有实际 Detail 时整套回退活动 Grid。Detail 与 Grid 不合并,也不逐字段回退;`hasEditableFields` 只检查当前激活 Detail 的展示字段。
- Playwright 权限矩阵结果:其他应用“管理”和顶层“管理”均为记录查询 0、Grid 0;系统账号与本应用“管理”均可进入 Grid;管理员遇到 Detail `disableColumns` 时编辑按钮为 0;Detail 允许/Grid 拒绝时编辑按钮与输入框均为 1;空 Detail 回退到拒绝 Grid 时为 0、回退到允许 Grid 时为 1;Detail 只配置其他字段 auth 时当前字段不会回退 Grid;切换可编辑/只读 Detail 后编辑按钮从 1 立即变为 0。矩阵控制台 0 error / 0 warning。 - Playwright 权限矩阵结果:其他应用“管理”和顶层“管理”均为记录查询 0、Grid 0;系统账号与本应用“管理”均可进入 Grid;管理员遇到 Detail `disableColumns` 时编辑按钮为 0;Detail 允许/Grid 拒绝时编辑按钮与输入框均为 1;空 Detail 回退到拒绝 Grid 时为 0、回退到允许 Grid 时为 1;Detail 只配置其他字段 auth 时当前字段不会回退 Grid;切换可编辑/只读 Detail 后编辑按钮从 1 立即变为 0。矩阵控制台 0 error / 0 warning。
### 共享弹窗与多选复核(2026-09-02)
- 详情 `Modal.Dialog` 明确使用 HeroUI `bg-background`,并通过共享 `rc-dialog-panel` / `rc-dialog-panel--4xl` 提供宽度阶梯;标题、记录摘要、辅助说明和详情 Tabs 保持在同一 Header 层级,Body 继续作为唯一滚动区域。
- `XinyuanModal` 的辅助说明统一渲染为 HeroUI `Modal.Header` 内的 `Description`,缺省时使用共享兜底文案,不在 Body 追加自定义说明块。
- `XinyuanAutocomplete` 多选只由受控字符串 key 驱动:ListBox 仅使用单一 `Item.onPress` 提交一次变化,勾选标记、Tag 删除、清空和默认回显共享同一 key 集合;未显式传入 `isOpen` 时保持 HeroUI 非受控打开态。
- 应用切换加载态与真实列表均为移动单列、`sm` 起固定两列且 `gap-3`;Header 移除可见标题与关闭图标,仅保留通栏搜索;Modal 可访问名称、隐藏关闭触发器、Esc/Backdrop 关闭、动态分组和应用授权数据流未改变。
- 分页页大小 Autocomplete 已复核为 128px 固定宽度,并关闭不适用于必选页大小的清除图标;页码、页大小变更回调和查询数据流未改变。
- 筛选区已补齐问题整改基准的重置图标按钮:HeroUI secondary icon-only Button、16px ArrowPath 图标、Tooltip,位于查询按钮左侧;仅触发既有 `onClearFilters`,不改变筛选或查询协议。
- 筛选控件已修复空值哨兵导致的误显示:`FilterSelect` 空值不再编码为选中 key,`XinyuanAutocomplete` 仅在真实选中值存在时挂载清除图标;占位文案统一为短格式,不再重复字段长名称。
- 复核发现两个引用筛选字段未传 `variant` 导致白色 primary 层,已在共享 `RecordWorkbench` 调用中补传 `secondary`,与其他筛选字段层级一致。
- 引用筛选控件复核(2026-09-03):`RecordWorkbench` 使用的详情引用选择器已接收并下传默认 `secondary` variant,修复部分引用筛选框回退白色 primary 层的问题;查询值、引用 ID、搜索请求和清空回调均保持不变。
- 详情预览态已移除普通字段外层灰色 `Surface secondary`,字段按 Detail View 顺序单列展示;标签统一 16px/400 muted,值 16px/400,空值统一为“暂无内容”,动态表单和关联记录模块仍保留各自 HeroUI 模块边界。
- 数据列表滚动链已修复:`.data-table-scroll` 的纵向 `overscroll-behavior` 恢复为 `auto`,到达首/末行后继续滚动会传递到页面容器;横向滚动仍保持列表内,不改变分页和数据流。
- 数据列表滚动链增强(2026-09-03):共享 `DataTable` 在 HeroUI DataGrid 到达首/末端且最近页面滚动容器仍可移动时,将当前滚轮增量转发给页面容器;列表中间滚动、页面到达自身边界、Modal 内无页面滚动容器时保持原生行为。分页区域可在列表末端继续滚轮后自然露出,不改变行数据、分页回调或查询时机。
- 本轮 `npm run build`、`git diff --check` 和 HeroUI Pro 子路径导入复核通过;Playwright 登录页四视口截图位于 `artifacts/integrity-login-{1440,1280,768,390}.png`,控制台无 Error/Warning。
### 品牌主色与图表配色专项(2026-09-02)
- 复核 `theme.css`、`tokens.css`、`tailwind.css` 和浏览器计算样式:HeroUI primary 按钮实际背景为 `rgb(218, 36, 26)`,即 `#DA241A`;hover、pressed、focus 和 disabled 均使用同一品牌红阶。
- 图表 Token 固定为 16 个低饱和品牌红、暖色、中性色和灰蓝色阶,没有绿色、荧光色或彩虹式相邻色。`src/utils/chartColors.js` 通过业务 key 哈希和确定性冲突递进映射到 `--rc-chart-*`,过滤、排序不会按当前索引换色;单系列固定品牌红。
- 当前应用没有真实图表业务路由,未新增伪造图表数据;现有 KPI 图标色已统一落在灰蓝/暖中性色和品牌红范围内。
# 五项跨应用能力迁移前审计(2026-08-26) # 五项跨应用能力迁移前审计(2026-08-26)
- 应用范围:统一展示名为“廉洁风险防控”,属于完整功能白名单,需实施 Grid/Detail `shortTitle`、Subject View 权限、通知中心和审计材料中心文件流程。 - 应用范围:统一展示名为“廉洁风险防控”,属于完整功能白名单,需实施 Grid/Detail `shortTitle`、Subject View 权限、通知中心和审计材料中心文件流程。
......
This diff is collapsed.
...@@ -30,6 +30,8 @@ ...@@ -30,6 +30,8 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"dayjs": "^1.11.20", "dayjs": "^1.11.20",
"echarts": "^6.1.0",
"echarts-for-react": "^3.0.6",
"embla-carousel": "^8.6.0", "embla-carousel": "^8.6.0",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"jszip": "^3.10.1", "jszip": "^3.10.1",
......
import '@/global.less'; import '@/global.less';
import '@/tailwind.css'; import '@/tailwind.css';
import { defineApp } from 'umi'; import { defineApp } from 'umi';
import { APPLICATION_DISPLAY_NAME, THEME_STORAGE_KEY } from '@/config/application'; import {
APPLICATION_BASE_PATH,
APPLICATION_NAME,
} from '@/config/application';
if (typeof document !== 'undefined') { if (typeof document !== 'undefined') {
const theme = window.localStorage?.getItem?.(THEME_STORAGE_KEY) === 'dark' ? 'dark' : 'light'; document.documentElement.classList.remove('dark');
document.documentElement.classList.toggle('dark', theme === 'dark'); document.documentElement.classList.add('light');
document.documentElement.classList.toggle('light', theme === 'light'); document.documentElement.dataset.theme = 'light';
document.documentElement.dataset.theme = theme; document.documentElement.style.colorScheme = 'light';
document.documentElement.style.colorScheme = theme; }
const LEGACY_LOGIN_PATH = '/login';
const appBasePath = String(
INTEGRITY_RISK_PREVENTION_APP_BASE_PATH || APPLICATION_BASE_PATH || '/',
).replace(/\/+$/, '');
if (
typeof window !== 'undefined' &&
appBasePath &&
window.location.pathname === LEGACY_LOGIN_PATH
) {
window.location.replace(
`${appBasePath}${LEGACY_LOGIN_PATH}${window.location.search}${window.location.hash}`,
);
} }
export async function getInitialState() { export async function getInitialState() {
return { return {
name: APPLICATION_DISPLAY_NAME, name: APPLICATION_NAME,
}; };
} }
......
import { useLayoutEffect, useState } from 'react';
import { Sidebar } from '@heroui-pro/react/sidebar'; import { Sidebar } from '@heroui-pro/react/sidebar';
import { Toast } from '@heroui/react'; import { Toast } from '@heroui/react';
import { THEME_STORAGE_KEY } from '@/config/application';
import { history } from 'umi'; import { history } from 'umi';
import DashboardNavbar from './DashboardNavbar'; import DashboardNavbar from './DashboardNavbar';
import DashboardSidebar from './DashboardSidebar'; import DashboardSidebar from './DashboardSidebar';
function getInitialTheme() {
if (typeof window === 'undefined') return 'light';
try {
return window.localStorage.getItem(THEME_STORAGE_KEY) === 'dark'
? 'dark'
: 'light';
} catch {
return 'light';
}
}
export default function AppShell({ export default function AppShell({
children, children,
open,
onOpenChange,
notificationCount, notificationCount,
notificationPath, notificationPath,
open,
onOpenChange,
title, title,
...sidebarProps ...sidebarProps
}) { }) {
const [theme, setTheme] = useState(getInitialTheme);
useLayoutEffect(() => {
const root = document.documentElement;
root.classList.toggle('dark', theme === 'dark');
root.classList.toggle('light', theme === 'light');
root.dataset.theme = theme;
root.style.colorScheme = theme;
try {
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
} catch {
// The selected theme still applies for this session when storage is unavailable.
}
}, [theme]);
return ( return (
<Sidebar.Provider <Sidebar.Provider
className={`template-dashboard-shell rc-app-shell ${theme}`} className="template-dashboard-shell rc-app-shell light"
collapsible="offcanvas" collapsible="offcanvas"
navigate={(href) => history.push(href)} navigate={(href) => history.push(href)}
open={open} open={open}
...@@ -56,9 +26,7 @@ export default function AppShell({ ...@@ -56,9 +26,7 @@ export default function AppShell({
<DashboardNavbar <DashboardNavbar
notificationCount={notificationCount} notificationCount={notificationCount}
onOpenNotifications={notificationPath ? () => history.push(notificationPath) : undefined} onOpenNotifications={notificationPath ? () => history.push(notificationPath) : undefined}
theme={theme}
title={title} title={title}
onThemeChange={setTheme}
/> />
<div className="app-shell-content-scroll min-h-0 flex-1 overflow-y-auto"> <div className="app-shell-content-scroll min-h-0 flex-1 overflow-y-auto">
<div className="rc-app-page">{children}</div> <div className="rc-app-page">{children}</div>
......
import { Navbar } from '@heroui-pro/react/navbar'; import { Navbar } from '@heroui-pro/react/navbar';
import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar'; import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar';
import { Badge, Button, Tooltip } from '@heroui/react'; import { Badge, Button, Tooltip } from '@heroui/react';
import { Bell, Moon, SidebarPanel, Sun } from './AppIcons'; import { Bell, SidebarPanel } from './AppIcons';
export default function DashboardNavbar({ export default function DashboardNavbar({
notificationCount = 0, notificationCount = 0,
onOpenNotifications, onOpenNotifications,
onThemeChange,
theme,
title, title,
}) { }) {
const { isMobile, isOpen } = useSidebar(); const { isMobile, isOpen } = useSidebar();
const isDark = theme === 'dark';
const themeLabel = isDark ? '切换至浅色模式' : '切换至深色模式';
const normalizedNotificationCount = Math.max(0, Number(notificationCount) || 0); const normalizedNotificationCount = Math.max(0, Number(notificationCount) || 0);
return ( return (
...@@ -53,20 +49,6 @@ export default function DashboardNavbar({ ...@@ -53,20 +49,6 @@ export default function DashboardNavbar({
<Tooltip.Content>通知消息</Tooltip.Content> <Tooltip.Content>通知消息</Tooltip.Content>
</Tooltip> </Tooltip>
) : null} ) : null}
<Tooltip delay={0}>
<Button
isIconOnly
aria-label={themeLabel}
aria-pressed={isDark}
className="app-navbar-icon-button rc-navbar-icon-button"
size="sm"
variant="ghost"
onPress={() => onThemeChange(isDark ? 'light' : 'dark')}
>
{isDark ? <Sun size={16} /> : <Moon size={16} />}
</Button>
<Tooltip.Content>{themeLabel}</Tooltip.Content>
</Tooltip>
</div> </div>
</Navbar.Header> </Navbar.Header>
</Navbar> </Navbar>
......
import xyLogoRed from '@/assets/xy-logo-red.png';
import xyLogoWhite from '@/assets/xy-logo-white.png'; import xyLogoWhite from '@/assets/xy-logo-white.png';
import { APPLICATION_DISPLAY_NAME } from '@/config/application'; import { APPLICATION_DISPLAY_NAME } from '@/config/application';
import { Sheet } from '@heroui-pro/react/sheet'; import { Sheet } from '@heroui-pro/react';
import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar'; import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar';
import { Avatar, Button, Dropdown, Label, Tooltip } from '@heroui/react'; import { Avatar, Button, Disclosure, Dropdown, Label } from '@heroui/react';
import { AppGrid, LogOut } from './AppIcons'; import { AppGrid, ChevronDown, LogOut } from './AppIcons';
function getUserDisplay(userInfo) { function getUserDisplay(userInfo) {
const displayName = userInfo?.display_name || userInfo?.username || '当前用户'; const displayName = userInfo?.display_name || userInfo?.username || '当前用户';
...@@ -64,9 +63,56 @@ function SidebarContents({ ...@@ -64,9 +63,56 @@ function SidebarContents({
onLogout, onLogout,
onOpenApplicationSwitcher, onOpenApplicationSwitcher,
pathname, pathname,
theme,
userInfo, userInfo,
}) { }) {
const renderMenu = (group) => (
<Sidebar.Menu aria-label={group.title}>
{group.items.map((item) => {
const Icon = item.icon;
return (
<Sidebar.MenuItem
href={item.path}
isCurrent={pathname === item.path}
key={item.path}
textValue={item.name}
tooltip={item.name}
>
<Sidebar.MenuIcon>
<Icon size={16} />
</Sidebar.MenuIcon>
<Sidebar.MenuLabel>{item.name}</Sidebar.MenuLabel>
</Sidebar.MenuItem>
);
})}
</Sidebar.Menu>
);
const renderGroup = (group) => {
const hasCurrentItem = group.items.some((item) => pathname === item.path);
const GroupIcon = group.icon;
return (
<Sidebar.Group key={group.title}>
<Disclosure defaultExpanded={hasCurrentItem}>
<Disclosure.Heading>
<Sidebar.GroupLabel>
<Disclosure.Trigger className="flex w-full min-w-0 items-center gap-2 text-left">
{GroupIcon ? <GroupIcon size={16} /> : null}
<span className="min-w-0 flex-1 truncate">{group.title}</span>
<Disclosure.Indicator>
<ChevronDown size={14} />
</Disclosure.Indicator>
</Disclosure.Trigger>
</Sidebar.GroupLabel>
</Disclosure.Heading>
<Disclosure.Content>
<Disclosure.Body>{renderMenu(group)}</Disclosure.Body>
</Disclosure.Content>
</Disclosure>
</Sidebar.Group>
);
};
return ( return (
<> <>
<Sidebar.Header> <Sidebar.Header>
...@@ -74,12 +120,7 @@ function SidebarContents({ ...@@ -74,12 +120,7 @@ function SidebarContents({
<div className="app-sidebar-brand-mark rc-sidebar-brand-mark"> <div className="app-sidebar-brand-mark rc-sidebar-brand-mark">
<img <img
alt="鑫元" alt="鑫元"
className="rc-sidebar-logo rc-sidebar-logo--light" className="rc-sidebar-logo"
src={xyLogoRed}
/>
<img
alt="鑫元"
className="rc-sidebar-logo rc-sidebar-logo--dark"
src={xyLogoWhite} src={xyLogoWhite}
/> />
</div> </div>
...@@ -95,49 +136,25 @@ function SidebarContents({ ...@@ -95,49 +136,25 @@ function SidebarContents({
className="app-sidebar-app-switcher rc-sidebar-app-switcher" className="app-sidebar-app-switcher rc-sidebar-app-switcher"
data-sidebar="label" data-sidebar="label"
> >
<Tooltip delay={0}> <Button
<Button isIconOnly
isIconOnly aria-label="切换应用"
aria-label="切换应用" className="app-sidebar-app-trigger rc-sidebar-app-trigger"
className="app-sidebar-app-trigger rc-sidebar-app-trigger" size="sm"
size="sm" variant="ghost"
variant="ghost" onPress={onOpenApplicationSwitcher}
onPress={onOpenApplicationSwitcher} >
> <AppGrid size={16} />
<AppGrid size={16} /> </Button>
</Button>
<Tooltip.Content>切换应用</Tooltip.Content>
</Tooltip>
</div> </div>
</div> </div>
</Sidebar.Header> </Sidebar.Header>
<Sidebar.Content> <Sidebar.Content>
{menuGroups.map((group) => ( {menuGroups.map((group) => (
<Sidebar.Group key={group.title}> group.showLabel === false ? (
{group.showLabel !== false ? ( <Sidebar.Group key={group.title}>{renderMenu(group)}</Sidebar.Group>
<Sidebar.GroupLabel>{group.title}</Sidebar.GroupLabel> ) : renderGroup(group)
) : null}
<Sidebar.Menu aria-label={group.title}>
{group.items.map((item) => {
const Icon = item.icon;
return (
<Sidebar.MenuItem
href={item.path}
isCurrent={pathname === item.path}
key={item.path}
textValue={item.name}
tooltip={item.name}
>
<Sidebar.MenuIcon>
<Icon size={16} />
</Sidebar.MenuIcon>
<Sidebar.MenuLabel>{item.name}</Sidebar.MenuLabel>
</Sidebar.MenuItem>
);
})}
</Sidebar.Menu>
</Sidebar.Group>
))} ))}
</Sidebar.Content> </Sidebar.Content>
...@@ -148,7 +165,7 @@ function SidebarContents({ ...@@ -148,7 +165,7 @@ function SidebarContents({
); );
} }
function MobileDashboardSidebar(props) { function AccessibleMobileSidebar(props) {
const { isMobile, isMobileOpen, setMobileOpen, side } = useSidebar(); const { isMobile, isMobileOpen, setMobileOpen, side } = useSidebar();
if (!isMobile) { if (!isMobile) {
...@@ -156,32 +173,27 @@ function MobileDashboardSidebar(props) { ...@@ -156,32 +173,27 @@ function MobileDashboardSidebar(props) {
} }
return ( return (
<Sheet isOpen={isMobileOpen} placement={side} onOpenChange={setMobileOpen}> <Sheet.Root
isOpen={isMobileOpen}
placement={side}
onOpenChange={setMobileOpen}
>
<Sheet.Backdrop variant="blur"> <Sheet.Backdrop variant="blur">
<Sheet.Content className="sidebar__mobile-sheet"> <Sheet.Content className="sidebar__mobile-sheet">
<Sheet.Dialog <Sheet.Dialog
aria-label={`${APPLICATION_DISPLAY_NAME}导航`} aria-label={`${APPLICATION_DISPLAY_NAME}导航`}
className="sidebar__mobile-dialog" className="sidebar__mobile-dialog"
> >
<Sheet.Heading className="sr-only">
{APPLICATION_DISPLAY_NAME}导航
</Sheet.Heading>
<div <div
className="sidebar__mobile app-sidebar-glass rc-mobile-sidebar" className="sidebar__mobile app-sidebar-glass rc-mobile-sidebar"
data-slot="sidebar-mobile" data-slot="sidebar-mobile"
> >
<SidebarContents <SidebarContents {...props} />
{...props}
onOpenApplicationSwitcher={() => {
setMobileOpen(false);
props.onOpenApplicationSwitcher?.();
}}
/>
</div> </div>
</Sheet.Dialog> </Sheet.Dialog>
</Sheet.Content> </Sheet.Content>
</Sheet.Backdrop> </Sheet.Backdrop>
</Sheet> </Sheet.Root>
); );
} }
...@@ -191,7 +203,7 @@ export default function DashboardSidebar(props) { ...@@ -191,7 +203,7 @@ export default function DashboardSidebar(props) {
<Sidebar className="app-sidebar-glass rc-sidebar"> <Sidebar className="app-sidebar-glass rc-sidebar">
<SidebarContents {...props} /> <SidebarContents {...props} />
</Sidebar> </Sidebar>
<MobileDashboardSidebar {...props} /> <AccessibleMobileSidebar {...props} />
</> </>
); );
} }
This diff is collapsed.
...@@ -202,7 +202,13 @@ function NestedSubjectFields({ fieldDef, value, onChange, disabled, depth }) { ...@@ -202,7 +202,13 @@ function NestedSubjectFields({ fieldDef, value, onChange, disabled, depth }) {
}, [subjectName, depth]); }, [subjectName, depth]);
if (!subjectName || depth >= MAX_SUBJECT_DEPTH) { if (!subjectName || depth >= MAX_SUBJECT_DEPTH) {
return <JsonInput value={value} onChange={onChange} placeholder={fieldPlaceholder} />; return (
<JsonInput
value={value}
onChange={onChange}
placeholder={getFieldPlaceholder(fieldDef)}
/>
);
} }
if (error) { if (error) {
......
...@@ -43,7 +43,7 @@ export default function FilterSelect({ ...@@ -43,7 +43,7 @@ export default function FilterSelect({
const selectedOption = normalizedOptions.find( const selectedOption = normalizedOptions.find(
(option) => option.value === selectedValue, (option) => option.value === selectedValue,
); );
const selectedKey = selectedValue === '' ? EMPTY_OPTION_KEY : selectedValue; const selectedKey = selectedValue === '' ? null : selectedValue;
return ( return (
<XinyuanAutocomplete <XinyuanAutocomplete
......
...@@ -29,7 +29,8 @@ import { ...@@ -29,7 +29,8 @@ import {
loadSubjectWorkbenchResources, loadSubjectWorkbenchResources,
} from '@/services/SubjectWorkbenchService'; } from '@/services/SubjectWorkbenchService';
const DEFAULT_PAGE_SIZE = 10; const DEFAULT_PAGE_SIZE = 20;
const PAGE_SIZE_OPTIONS = [20, 50, 100];
const BOOLEAN_FILTER_OPTIONS = [{ label: '是', value: 'true' }, { label: '否', value: 'false' }]; const BOOLEAN_FILTER_OPTIONS = [{ label: '是', value: 'true' }, { label: '否', value: 'false' }];
function normalizeFilterFieldPath(field) { function normalizeFilterFieldPath(field) {
...@@ -72,7 +73,7 @@ function buildFilterSchema(columns = []) { ...@@ -72,7 +73,7 @@ function buildFilterSchema(columns = []) {
else if (isReferenceFieldType(fieldType)) type = getReferenceSubjectName(fieldDef) ? 'reference' : 'text'; else if (isReferenceFieldType(fieldType)) type = getReferenceSubjectName(fieldDef) ? 'reference' : 'text';
else if (['text', 'long_text', 'rich_text'].includes(fieldType)) type = 'text'; else if (['text', 'long_text', 'rich_text'].includes(fieldType)) type = 'text';
if (!type) return fields; if (!type) return fields;
fields.push({ key: `field:${field}`, field, fieldDef, label: column.label || field, type, options, placeholder: `筛选${column.label || field}` }); fields.push({ key: `field:${field}`, field, fieldDef, label: column.label || field, type, options });
return fields; return fields;
}, []); }, []);
} }
...@@ -196,7 +197,7 @@ export default function SubjectRecordWorkbench({ ...@@ -196,7 +197,7 @@ export default function SubjectRecordWorkbench({
const [pendingFilters, setPendingFilters] = useState({}); const [pendingFilters, setPendingFilters] = useState({});
const [selectedIds, setSelectedIds] = useState([]); const [selectedIds, setSelectedIds] = useState([]);
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const pageSize = DEFAULT_PAGE_SIZE; const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
const [recordDialogOpen, setRecordDialogOpen] = useState(false); const [recordDialogOpen, setRecordDialogOpen] = useState(false);
const [editingRecord, setEditingRecord] = useState(null); const [editingRecord, setEditingRecord] = useState(null);
const [deleteTargets, setDeleteTargets] = useState([]); const [deleteTargets, setDeleteTargets] = useState([]);
...@@ -393,7 +394,7 @@ export default function SubjectRecordWorkbench({ ...@@ -393,7 +394,7 @@ export default function SubjectRecordWorkbench({
? buildSummaryItems({ records, filteredRecords, selectedIds, activeView }) ? buildSummaryItems({ records, filteredRecords, selectedIds, activeView })
: [ : [
{ label: '记录总数', value: records.length, hint: '当前对象全部记录', tone: 'default' }, { label: '记录总数', value: records.length, hint: '当前对象全部记录', tone: 'default' },
{ label: '筛选结果', value: filteredRecords.length, hint: activeView?.title || '当前视图', tone: 'emerald' }, { label: '筛选结果', value: filteredRecords.length, hint: activeView?.title || '当前视图', tone: 'info' },
{ label: '当前已选', value: selectedIds.length, hint: '可执行批量操作', tone: 'amber' }, { label: '当前已选', value: selectedIds.length, hint: '可执行批量操作', tone: 'amber' },
]; ];
...@@ -565,12 +566,19 @@ export default function SubjectRecordWorkbench({ ...@@ -565,12 +566,19 @@ export default function SubjectRecordWorkbench({
pagination={{ pagination={{
page: safePage, page: safePage,
pageSize, pageSize,
pageSizeOptions: PAGE_SIZE_OPTIONS,
totalRecords: filteredRecords.length, totalRecords: filteredRecords.length,
totalPages, totalPages,
onPageChange: (nextPage) => { onPageChange: (nextPage) => {
setPage(nextPage); setPage(nextPage);
setSelectedIds([]); setSelectedIds([]);
}, },
onPageSizeChange: (nextPageSize) => {
if (!PAGE_SIZE_OPTIONS.includes(nextPageSize)) return;
setPageSize(nextPageSize);
setPage(0);
setSelectedIds([]);
},
}} }}
stickyTable stickyTable
showContentTitle={false} showContentTitle={false}
......
...@@ -45,11 +45,13 @@ export default function XinyuanAutocomplete({ ...@@ -45,11 +45,13 @@ export default function XinyuanAutocomplete({
fullWidth = true, fullWidth = true,
inputValue, inputValue,
isDisabled = false, isDisabled = false,
isClearable,
isLoading = false, isLoading = false,
isOpen, isOpen,
isRequired = false, isRequired = false,
name, name,
onInputChange, onInputChange,
onClear,
onOpenChange, onOpenChange,
onSelectionChange, onSelectionChange,
options = [], options = [],
...@@ -58,6 +60,7 @@ export default function XinyuanAutocomplete({ ...@@ -58,6 +60,7 @@ export default function XinyuanAutocomplete({
popoverHeader, popoverHeader,
renderOption, renderOption,
searchPlaceholder = '输入关键词搜索', searchPlaceholder = '输入关键词搜索',
showSearch = true,
showClearButton = true, showClearButton = true,
selectedKey, selectedKey,
selectedKeys, selectedKeys,
...@@ -67,6 +70,7 @@ export default function XinyuanAutocomplete({ ...@@ -67,6 +70,7 @@ export default function XinyuanAutocomplete({
variant, variant,
}) { }) {
const resolvedInputValue = inputValue ?? ''; const resolvedInputValue = inputValue ?? '';
const resolvedShowClearButton = isClearable ?? showClearButton;
const selectedKeySet = getSelectedKeySet( const selectedKeySet = getSelectedKeySet(
selectionMode, selectionMode,
selectedKey, selectedKey,
...@@ -75,7 +79,12 @@ export default function XinyuanAutocomplete({ ...@@ -75,7 +79,12 @@ export default function XinyuanAutocomplete({
const selectedKeyStringSet = new Set( const selectedKeyStringSet = new Set(
Array.from(selectedKeySet).map(toSelectionKey), Array.from(selectedKeySet).map(toSelectionKey),
); );
const selectedOptions = options.filter((option) => selectedKeyStringSet.has(toSelectionKey(option.id))); const optionMap = new Map(
options.map((option) => [toSelectionKey(option.id), option]),
);
const selectedOptions = [...selectedKeyStringSet].map((key) =>
optionMap.get(key) || { id: key, label: key },
);
const resolvedValueText = const resolvedValueText =
valueText ?? valueText ??
(selectedOptions.length > 0 (selectedOptions.length > 0
...@@ -89,9 +98,13 @@ export default function XinyuanAutocomplete({ ...@@ -89,9 +98,13 @@ export default function XinyuanAutocomplete({
activeFilter(option.textValue || option.label, resolvedInputValue), activeFilter(option.textValue || option.label, resolvedInputValue),
) )
: options; : options;
const hasSelection = showClearButton && selectedKeySet.size > 0; const hasSelection = selectedKeySet.size > 0;
const clearLabel = selectionMode === 'multiple' ? '清空已选项' : '清除已选项'; const clearLabel = selectionMode === 'multiple' ? '清空已选项' : '清除已选项';
const handleClear = () => { const handleClear = () => {
if (onClear) {
onClear();
return;
}
onSelectionChange?.(selectionMode === 'multiple' ? new Set() : null); onSelectionChange?.(selectionMode === 'multiple' ? new Set() : null);
}; };
const handleMultipleOptionPress = (option) => { const handleMultipleOptionPress = (option) => {
...@@ -125,29 +138,23 @@ export default function XinyuanAutocomplete({ ...@@ -125,29 +138,23 @@ export default function XinyuanAutocomplete({
); );
onSelectionChange?.(nextKeys); onSelectionChange?.(nextKeys);
}; };
const handleMultipleOptionAction = (optionKey) => { const rootOpenProps = typeof isOpen === 'boolean' ? { isOpen } : {};
const nextKeys = new Set(selectedKeyStringSet);
const key = toSelectionKey(optionKey);
if (!key) return;
if (nextKeys.has(key)) nextKeys.delete(key); else nextKeys.add(key);
onSelectionChange?.(nextKeys);
};
const normalizedSelectionProps = selectionMode === 'multiple' const normalizedSelectionProps = selectionMode === 'multiple'
? { selectedKey: null } ? { selectedKey: null }
: { selectedKey: selectedKeySet.values().next().value || null }; : { selectedKey: selectedKeySet.values().next().value || null };
const normalizedListProps = selectionMode === 'multiple' const normalizedListProps = selectionMode === 'multiple'
? { selectionMode: 'single', onAction: handleMultipleOptionAction } ? { selectionMode: 'single' }
: { selectionMode: 'single', selectedKeys: selectedKeySet, onSelectionChange }; : { selectionMode: 'single', selectedKeys: selectedKeySet, onSelectionChange };
return ( return (
<Autocomplete <Autocomplete
{...rootOpenProps}
{...normalizedSelectionProps} {...normalizedSelectionProps}
aria-label={ariaLabel || placeholder} aria-label={ariaLabel || placeholder}
allowsEmptyCollection={allowsEmptyCollection} allowsEmptyCollection={allowsEmptyCollection}
className={className} className={className}
fullWidth={fullWidth} fullWidth={fullWidth}
isDisabled={isDisabled} isDisabled={isDisabled}
isOpen={isOpen}
isRequired={isRequired} isRequired={isRequired}
name={name} name={name}
selectionMode={selectionMode === 'multiple' ? 'single' : selectionMode} selectionMode={selectionMode === 'multiple' ? 'single' : selectionMode}
...@@ -170,7 +177,7 @@ export default function XinyuanAutocomplete({ ...@@ -170,7 +177,7 @@ export default function XinyuanAutocomplete({
className="min-w-0 flex-1" className="min-w-0 flex-1"
selectionMode="none" selectionMode="none"
size="sm" size="sm"
variant="surface" variant="default"
onRemove={handleTagRemove} onRemove={handleTagRemove}
> >
<TagGroup.List <TagGroup.List
...@@ -178,7 +185,11 @@ export default function XinyuanAutocomplete({ ...@@ -178,7 +185,11 @@ export default function XinyuanAutocomplete({
items={selectedOptions} items={selectedOptions}
> >
{(option) => ( {(option) => (
<Tag id={option.id} textValue={option.label}> <Tag
id={option.id}
textValue={option.label}
className="bg-accent-soft-hover text-accent-soft-foreground"
>
<span className="min-w-0 truncate">{option.label}</span> <span className="min-w-0 truncate">{option.label}</span>
<Tag.RemoveButton <Tag.RemoveButton
aria-label={`移除${option.label}`} aria-label={`移除${option.label}`}
...@@ -191,11 +202,9 @@ export default function XinyuanAutocomplete({ ...@@ -191,11 +202,9 @@ export default function XinyuanAutocomplete({
) : selectionMode === 'multiple' ? ( ) : selectionMode === 'multiple' ? (
<span className="block min-w-0 flex-1 truncate">{placeholder}</span> <span className="block min-w-0 flex-1 truncate">{placeholder}</span>
) : null} ) : null}
{showClearButton ? ( {resolvedShowClearButton && hasSelection ? (
<Autocomplete.ClearButton <Autocomplete.ClearButton
aria-hidden={hasSelection ? undefined : true}
aria-label={clearLabel} aria-label={clearLabel}
data-empty={hasSelection ? 'false' : 'true'}
/> />
) : null} ) : null}
<Autocomplete.Indicator> <Autocomplete.Indicator>
...@@ -209,22 +218,24 @@ export default function XinyuanAutocomplete({ ...@@ -209,22 +218,24 @@ export default function XinyuanAutocomplete({
onInputChange={onInputChange} onInputChange={onInputChange}
> >
{popoverHeader} {popoverHeader}
<SearchField {showSearch ? (
aria-label={searchPlaceholder} <SearchField
className="reference-search-select__input" aria-label={searchPlaceholder}
fullWidth className="reference-search-select__input"
variant={variant} fullWidth
> variant={variant}
<SearchField.Group> >
<SearchField.SearchIcon> <SearchField.Group>
<Search size={16} /> <SearchField.SearchIcon>
</SearchField.SearchIcon> <Search size={16} />
<SearchField.Input autoFocus placeholder={searchPlaceholder} /> </SearchField.SearchIcon>
<SearchField.ClearButton aria-label="清空搜索"> <SearchField.Input autoFocus placeholder={searchPlaceholder} />
<Close size={14} /> <SearchField.ClearButton aria-label="清空搜索">
</SearchField.ClearButton> <Close size={14} />
</SearchField.Group> </SearchField.ClearButton>
</SearchField> </SearchField.Group>
</SearchField>
) : null}
<ListBox <ListBox
aria-label={ariaLabel || placeholder} aria-label={ariaLabel || placeholder}
dependencies={[ dependencies={[
......
...@@ -3,16 +3,14 @@ import { Description, Modal } from '@heroui/react'; ...@@ -3,16 +3,14 @@ import { Description, Modal } from '@heroui/react';
import clsx from 'clsx'; import clsx from 'clsx';
const DIALOG_WIDTH_CLASS = { const DIALOG_WIDTH_CLASS = {
xs: 'rc-dialog-panel--xs', md: 'sm:max-w-[30rem]',
sm: 'rc-dialog-panel--sm', xl: 'sm:max-w-[46rem]',
md: 'rc-dialog-panel--md', '2xl': 'sm:max-w-[52.5rem]',
lg: 'rc-dialog-panel--lg', '3xl': 'sm:max-w-[60rem]',
xl: 'rc-dialog-panel--xl', '4xl': 'sm:max-w-[68rem]',
'2xl': 'rc-dialog-panel--2xl', '5xl': 'sm:max-w-[76rem]',
'3xl': 'rc-dialog-panel--3xl',
'4xl': 'rc-dialog-panel--4xl',
'5xl': 'rc-dialog-panel--5xl',
}; };
const STANDARD_PANEL_PADDING_SIZES = new Set(['xl', '2xl', '3xl', '4xl', '5xl']);
export default function XinyuanModal({ export default function XinyuanModal({
isOpen, isOpen,
...@@ -26,7 +24,6 @@ export default function XinyuanModal({ ...@@ -26,7 +24,6 @@ export default function XinyuanModal({
footer, footer,
}) { }) {
const modalSize = ['xs', 'sm', 'md', 'lg'].includes(size) ? size : 'lg'; const modalSize = ['xs', 'sm', 'md', 'lg'].includes(size) ? size : 'lg';
const panelPaddingClass = ['xs', 'sm', 'md'].includes(size) ? 'p-6' : 'p-7';
return ( return (
<Modal.Root> <Modal.Root>
...@@ -35,8 +32,8 @@ export default function XinyuanModal({ ...@@ -35,8 +32,8 @@ export default function XinyuanModal({
<Modal.Container placement="center" scroll="inside" size={modalSize}> <Modal.Container placement="center" scroll="inside" size={modalSize}>
<Modal.Dialog <Modal.Dialog
className={clsx( className={clsx(
'rc-dialog-panel', STANDARD_PANEL_PADDING_SIZES.has(size) && 'p-7',
panelPaddingClass, 'bg-background',
DIALOG_WIDTH_CLASS[size], DIALOG_WIDTH_CLASS[size],
dialogClassName, dialogClassName,
)} )}
...@@ -46,7 +43,7 @@ export default function XinyuanModal({ ...@@ -46,7 +43,7 @@ export default function XinyuanModal({
</Modal.CloseTrigger> </Modal.CloseTrigger>
<Modal.Header> <Modal.Header>
<Modal.Heading>{title}</Modal.Heading> <Modal.Heading>{title}</Modal.Heading>
<Description>{description}</Description> {description ? <Description>{description}</Description> : null}
</Modal.Header> </Modal.Header>
<Modal.Body className={bodyClassName}> <Modal.Body className={bodyClassName}>
{children} {children}
......
This diff is collapsed.
...@@ -3,7 +3,6 @@ export const APPLICATION_NAME = 'integrity_risk_prevention_new'; ...@@ -3,7 +3,6 @@ export const APPLICATION_NAME = 'integrity_risk_prevention_new';
export const APPLICATION_BASE_PATH = `/${APPLICATION_NAME}/`; export const APPLICATION_BASE_PATH = `/${APPLICATION_NAME}/`;
export const AUTHORIZATION_APPLICATION_NAME = APPLICATION_NAME; export const AUTHORIZATION_APPLICATION_NAME = APPLICATION_NAME;
export const AUDIT_MATERIAL_SOURCE_APP = 'integrity_risk_prevention'; export const AUDIT_MATERIAL_SOURCE_APP = 'integrity_risk_prevention';
export const THEME_STORAGE_KEY = 'xinyuan_color_theme';
export const ROUTE_TITLES = Object.freeze({ export const ROUTE_TITLES = Object.freeze({
'/risk_prevention_control_measures_table': '廉洁风险防控表 (过程表)', '/risk_prevention_control_measures_table': '廉洁风险防控表 (过程表)',
......
This diff is collapsed.
...@@ -11,38 +11,57 @@ ...@@ -11,38 +11,57 @@
[data-theme="light"], [data-theme="light"],
[data-theme="default"] { [data-theme="default"] {
/* Theme Colors (Light Mode) */ /* Theme Colors (Light Mode) */
--accent: oklch(45.32% 0.1661 27.03); /* Xinyuan Fund brand red. HeroUI's accent is the primary action color. */
--accent: #DA241A;
--accent-hover: #C91F17;
--accent-pressed: #B51B14;
--accent-foreground: oklch(99.11% 0 0); --accent-foreground: oklch(99.11% 0 0);
--background: oklch(97.02% 0.0045 27.03); --primary: #DA241A;
--border: oklch(90.00% 0.0045 27.03); --primary-hover: #C91F17;
--danger: oklch(45.32% 0.1661 27.03); --primary-pressed: #B51B14;
--primary-foreground: #FFFFFF;
--primary-50: #FCE9E7;
--primary-100: #F9D0CC;
--primary-200: #F3A29A;
--primary-300: #EC756D;
--primary-400: #E44A3F;
--primary-500: #DA241A;
--primary-600: #C91F17;
--primary-700: #B51B14;
--primary-800: #941711;
--primary-900: #73120D;
--primary-950: #4A0C08;
--background: oklch(97.02% 0.0000 29.20);
--border: oklch(90.00% 0.0000 29.20);
--danger: #B51B14;
--danger-hover: #941711;
--danger-foreground: oklch(99.11% 0 0); --danger-foreground: oklch(99.11% 0 0);
/* HeroUI control aliases: primary = L1, secondary = L2. */ --default: oklch(94.00% 0.0000 29.20);
--default: var(--surface-secondary); --default-foreground: oklch(21.03% 0.0059 29.20);
--default-foreground: var(--surface-secondary-foreground); --field-background: oklch(100.00% 0.0000 29.20);
--field-background: var(--surface);
--field-border: transparent; --field-border: transparent;
--field-foreground: var(--surface-foreground); --field-foreground: oklch(21.03% 0.0000 29.20);
--field-placeholder: oklch(55.17% 0.0090 27.03); --field-placeholder: oklch(55.17% 0.0000 29.20);
--focus: oklch(45.32% 0.1661 27.03); --focus: #DA241A;
--foreground: oklch(21.03% 0.0045 27.03); --foreground: oklch(21.03% 0.0000 29.20);
--muted: oklch(55.17% 0.0090 27.03); --muted: oklch(55.17% 0.0000 29.20);
--overlay: var(--surface); --overlay: oklch(100.00% 0.0000 29.20);
--overlay-foreground: var(--surface-foreground); --overlay-foreground: oklch(21.03% 0.0000 29.20);
--scrollbar: oklch(87.10% 0.0045 27.03); --scrollbar: oklch(87.10% 0.0000 29.20);
--segment: oklch(100.00% 0.0045 27.03); --segment: oklch(100.00% 0.0000 29.20);
--segment-foreground: oklch(21.03% 0.0045 27.03); --segment-foreground: oklch(21.03% 0.0000 29.20);
--separator: oklch(92.00% 0.0045 27.03); --separator: oklch(92.00% 0.0000 29.20);
--success: oklch(73.29% 0.1952 123.59); /* Success stays readable without introducing a green hue into the brand system. */
--success-foreground: oklch(21.03% 0.0059 123.59); --success: #61758F;
--surface: oklch(100.00% 0.0022 27.03); --success-foreground: #FFFFFF;
--surface-foreground: oklch(21.03% 0.0045 27.03); --surface: oklch(100.00% 0.0000 29.20);
--surface-secondary: oklch(95.24% 0.0036 27.03); --surface-foreground: oklch(21.03% 0.0000 29.20);
--surface-secondary-foreground: oklch(21.03% 0.0045 27.03); --surface-secondary: oklch(95.24% 0.0000 29.20);
--surface-tertiary: oklch(93.73% 0.0036 27.03); --surface-secondary-foreground: oklch(21.03% 0.0000 29.20);
--surface-tertiary-foreground: oklch(21.03% 0.0045 27.03); --surface-tertiary: oklch(93.73% 0.0000 29.20);
--warning: oklch(78.19% 0.1599 45.11); --surface-tertiary-foreground: oklch(21.03% 0.0000 29.20);
--warning-foreground: oklch(21.03% 0.0059 45.11); --warning: oklch(78.19% 0.1585 45.37);
--warning-foreground: oklch(21.03% 0.0059 45.37);
/* Border Radius */ /* Border Radius */
--radius: 0.5rem; --radius: 0.5rem;
...@@ -51,42 +70,107 @@ ...@@ -51,42 +70,107 @@
/* Font Family */ /* Font Family */
/* Make sure to load Inter font in your app */ /* Make sure to load Inter font in your app */
--font-sans: var(--font-inter); --font-sans: var(--font-inter);
/*
* Xinyuan fixed split-shell contract.
* The application canvas always uses this light theme. The Sidebar is the
* only persistent dark plane and must consume these semantic tokens.
*/
--xinyuan-sidebar-background: oklch(16% 0.020 255);
--xinyuan-sidebar-border: oklch(26% 0.022 255);
--xinyuan-sidebar-foreground: oklch(97% 0.004 255);
--xinyuan-sidebar-muted: oklch(76% 0.018 255);
--xinyuan-sidebar-item-hover: oklch(22% 0.026 255);
--xinyuan-sidebar-item-selected: oklch(28% 0.016 255);
--xinyuan-sidebar-item-selected-foreground: oklch(99% 0.002 255);
--xinyuan-sidebar-focus: oklch(78% 0.105 29.20);
/*
* ECharts is the required chart renderer for Xinyuan applications.
* Ant Design remains the icon source. Resolve these CSS variables to
* concrete colors before assigning them to an ECharts option object.
*/
/* Coordinated financial palette: brand reds -> warm neutrals -> gray blues. */
--xinyuan-chart-1: #DA241A;
--xinyuan-chart-2: #B83A32;
--xinyuan-chart-3: #C95A52;
--xinyuan-chart-4: #D47A70;
--xinyuan-chart-5: #A65A45;
--xinyuan-chart-6: #B87958;
--xinyuan-chart-7: #C99772;
--xinyuan-chart-8: #6F6674;
--xinyuan-chart-9: #807783;
--xinyuan-chart-10: #938B96;
--xinyuan-chart-11: #4F6380;
--xinyuan-chart-12: #61758F;
--xinyuan-chart-13: #74879A;
--xinyuan-chart-14: #8A6F63;
--xinyuan-chart-15: #9A8174;
--xinyuan-chart-16: #B0A5AD;
--xinyuan-chart-sector-gap: var(--surface);
--xinyuan-chart-sector-radius: 10px;
--xinyuan-chart-grid: #e8e8e8;
--xinyuan-chart-axis: #737373;
--xinyuan-chart-label: #737373;
--xinyuan-chart-tooltip-background: #ffffff;
--xinyuan-chart-tooltip-border: #dedede;
--xinyuan-chart-tooltip-foreground: #222222;
--xinyuan-chart-tooltip-shadow: 0 16px 40px rgb(15 23 42 / 14%);
--xinyuan-chart-legend-font-size: 0.75rem;
--xinyuan-chart-legend-line-height: 1rem;
} }
.dark, .dark,
[data-theme="dark"] { [data-theme="dark"] {
color-scheme: dark; color-scheme: dark;
/* Theme Colors (Dark Mode) */ /* Theme Colors (Dark Mode) */
--accent: oklch(45.32% 0.1661 27.03); --accent: #DA241A;
--accent-hover: #C91F17;
--accent-pressed: #B51B14;
--accent-foreground: oklch(99.11% 0 0); --accent-foreground: oklch(99.11% 0 0);
--background: oklch(12.00% 0.0045 27.03); --primary: #DA241A;
--border: oklch(28.00% 0.0045 27.03); --primary-hover: #C91F17;
--danger: oklch(62.00% 0.1600 27.03); --primary-pressed: #B51B14;
--primary-foreground: #FFFFFF;
--primary-50: #FCE9E7;
--primary-100: #F9D0CC;
--primary-200: #F3A29A;
--primary-300: #EC756D;
--primary-400: #E44A3F;
--primary-500: #DA241A;
--primary-600: #C91F17;
--primary-700: #B51B14;
--primary-800: #941711;
--primary-900: #73120D;
--primary-950: #4A0C08;
--background: oklch(12.00% 0.0000 29.20);
--border: oklch(28.00% 0.0000 29.20);
--danger: #B51B14;
--danger-hover: #941711;
--danger-foreground: oklch(99.11% 0 0); --danger-foreground: oklch(99.11% 0 0);
/* HeroUI control aliases: primary = L1, secondary = L2. */ --default: oklch(27.40% 0.0000 29.20);
--default: var(--surface-secondary); --default-foreground: oklch(99.11% 0 0);
--default-foreground: var(--surface-secondary-foreground); --field-background: oklch(21.03% 0.0000 29.20);
--field-background: var(--surface);
--field-border: transparent; --field-border: transparent;
--field-foreground: var(--surface-foreground); --field-foreground: oklch(99.11% 0.0000 29.20);
--field-placeholder: oklch(70.50% 0.0090 27.03); --field-placeholder: oklch(70.50% 0.0000 29.20);
--focus: oklch(45.32% 0.1661 27.03); --focus: #DA241A;
--foreground: oklch(99.11% 0.0045 27.03); --foreground: oklch(99.11% 0.0000 29.20);
--muted: oklch(70.50% 0.0090 27.03); --muted: oklch(70.50% 0.0000 29.20);
--overlay: var(--surface); --overlay: oklch(21.03% 0.0000 29.20);
--overlay-foreground: var(--surface-foreground); --overlay-foreground: oklch(99.11% 0.0000 29.20);
--scrollbar: oklch(70.50% 0.0045 27.03); --scrollbar: oklch(70.50% 0.0000 29.20);
--segment: oklch(39.64% 0.0045 27.03); --segment: oklch(39.64% 0.0000 29.20);
--segment-foreground: oklch(99.11% 0.0045 27.03); --segment-foreground: oklch(99.11% 0.0000 29.20);
--separator: oklch(25.00% 0.0045 27.03); --separator: oklch(25.00% 0.0000 29.20);
--success: oklch(73.29% 0.1952 123.59); --success: #8A9AAF;
--success-foreground: oklch(21.03% 0.0059 123.59); --success-foreground: #141820;
--surface: oklch(21.03% 0.0090 27.03); --surface: oklch(21.03% 0.0000 29.20);
--surface-foreground: oklch(99.11% 0.0045 27.03); --surface-foreground: oklch(99.11% 0.0000 29.20);
--surface-secondary: oklch(25.70% 0.0067 27.03); --surface-secondary: oklch(25.70% 0.0000 29.20);
--surface-secondary-foreground: oklch(99.11% 0.0045 27.03); --surface-secondary-foreground: oklch(99.11% 0.0000 29.20);
--surface-tertiary: oklch(27.21% 0.0067 27.03); --surface-tertiary: oklch(27.21% 0.0000 29.20);
--surface-tertiary-foreground: oklch(99.11% 0.0045 27.03); --surface-tertiary-foreground: oklch(99.11% 0.0000 29.20);
--warning: oklch(82.03% 0.1400 49.12); --warning: oklch(82.03% 0.1388 49.38);
--warning-foreground: oklch(21.03% 0.0059 49.12); --warning-foreground: oklch(21.03% 0.0059 49.38);
} }
...@@ -2,11 +2,14 @@ ...@@ -2,11 +2,14 @@
.light, .light,
.default, .default,
[data-theme='light'], [data-theme='light'],
[data-theme='default'], [data-theme='default'] {
.dark,
[data-theme='dark'] {
/* Xinyuan aliases and extension tokens. HeroUI core 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-primary: var(--accent);
--rc-brand-hover: var(--accent-hover);
--rc-brand-pressed: var(--accent-pressed);
--rc-brand-disabled: color-mix(in oklab, var(--accent) 38%, var(--surface-secondary));
--rc-brand-focus: var(--focus);
--rc-brand-on-primary: var(--accent-foreground);
--rc-brand-light: color-mix(in oklab, var(--accent) 84%, white); --rc-brand-light: color-mix(in oklab, var(--accent) 84%, white);
--rc-brand-dark: color-mix(in oklab, var(--accent) 82%, black); --rc-brand-dark: color-mix(in oklab, var(--accent) 82%, black);
...@@ -27,6 +30,17 @@ ...@@ -27,6 +30,17 @@
var(--background) var(--background)
); );
--rc-text-muted: var(--muted); --rc-text-muted: var(--muted);
--rc-text-empty: color-mix(in oklab, var(--muted) 72%, var(--background));
/* Fixed dark Sidebar aliases. The content canvas remains light. */
--rc-sidebar-background: var(--xinyuan-sidebar-background);
--rc-sidebar-border: var(--xinyuan-sidebar-border);
--rc-sidebar-foreground: var(--xinyuan-sidebar-foreground);
--rc-sidebar-muted: var(--xinyuan-sidebar-muted);
--rc-sidebar-item-hover: var(--xinyuan-sidebar-item-hover);
--rc-sidebar-item-selected: var(--xinyuan-sidebar-item-selected);
--rc-sidebar-item-selected-foreground: var(--xinyuan-sidebar-item-selected-foreground);
--rc-sidebar-focus: var(--xinyuan-sidebar-focus);
--rc-font-sans: var(--font-sans); --rc-font-sans: var(--font-sans);
--rc-font-medium: 500; --rc-font-medium: 500;
...@@ -35,23 +49,6 @@ ...@@ -35,23 +49,6 @@
--rc-text-xs: 0.75rem; --rc-text-xs: 0.75rem;
--rc-text-sm: 0.875rem; --rc-text-sm: 0.875rem;
/* Shared DataGrid typography. Pages choose field meaning; the wrapper owns type. */
--rc-data-grid-header-size: 0.75rem;
--rc-data-grid-header-weight: 500;
--rc-data-grid-header-color: var(--rc-text-muted);
--rc-data-grid-body-size: 0.875rem;
--rc-data-grid-body-weight: 400;
--rc-data-grid-body-color: var(--rc-text-secondary);
--rc-data-grid-primary-size: 0.875rem;
--rc-data-grid-primary-weight: 550;
--rc-data-grid-primary-color: var(--rc-text-primary);
--rc-data-grid-secondary-size: 0.8125rem;
--rc-data-grid-secondary-weight: 400;
--rc-data-grid-secondary-color: #7a8596;
--rc-data-grid-chip-size: 0.75rem;
--rc-data-grid-chip-weight: 500;
--rc-data-grid-chip-color: var(--rc-text-secondary);
--rc-space-1: 0.25rem; --rc-space-1: 0.25rem;
--rc-space-2: 0.5rem; --rc-space-2: 0.5rem;
--rc-space-3: 0.75rem; --rc-space-3: 0.75rem;
...@@ -62,6 +59,7 @@ ...@@ -62,6 +59,7 @@
/* Page geometry only. Component appearance remains owned by HeroUI. */ /* Page geometry only. Component appearance remains owned by HeroUI. */
--rc-shell-inline-gutter: 3rem; --rc-shell-inline-gutter: 3rem;
--rc-sidebar-width: 15rem;
--rc-navbar-inline-padding: var(--rc-shell-inline-gutter); --rc-navbar-inline-padding: var(--rc-shell-inline-gutter);
--rc-page-inline-start: var(--rc-shell-inline-gutter); --rc-page-inline-start: var(--rc-shell-inline-gutter);
--rc-page-inline-end: var(--rc-shell-inline-gutter); --rc-page-inline-end: var(--rc-shell-inline-gutter);
...@@ -99,14 +97,14 @@ ...@@ -99,14 +97,14 @@
0 4px 12px 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. */ /* 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-info: #4F6380;
--rc-metric-tone-success: oklch(54% 0.13 145); --rc-metric-tone-success: #61758F;
--rc-metric-tone-teal: oklch(55% 0.1 205); --rc-metric-tone-teal: #74879A;
--rc-metric-tone-danger: oklch(52% 0.17 25); --rc-metric-tone-danger: #DA241A;
--rc-metric-tone-warning: oklch(62% 0.12 75); --rc-metric-tone-warning: #B87958;
--rc-metric-tone-violet: oklch(56% 0.12 300); --rc-metric-tone-violet: #6F6674;
--rc-metric-tone-indigo: oklch(53% 0.13 275); --rc-metric-tone-indigo: #807783;
--rc-metric-tone-coral: oklch(60% 0.12 48); --rc-metric-tone-coral: #C95A52;
--rc-metric-tone-neutral: var(--muted); --rc-metric-tone-neutral: var(--muted);
--rc-metric-tone-info-soft: color-mix( --rc-metric-tone-info-soft: color-mix(
in oklab, in oklab,
...@@ -155,41 +153,29 @@ ...@@ -155,41 +153,29 @@
); );
/* Data visualization only. Do not reuse UI status colors as a series palette. */ /* 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-1: var(--xinyuan-chart-1);
--rc-chart-2: oklch(56% 0.135 255); --rc-chart-2: var(--xinyuan-chart-2);
--rc-chart-3: oklch(64% 0.11 220); --rc-chart-3: var(--xinyuan-chart-3);
--rc-chart-4: oklch(56% 0.115 280); --rc-chart-4: var(--xinyuan-chart-4);
--rc-chart-5: oklch(60% 0.095 200); --rc-chart-5: var(--xinyuan-chart-5);
--rc-chart-6: oklch(70% 0.12 80); --rc-chart-6: var(--xinyuan-chart-6);
--rc-chart-7: oklch(60% 0.1 295); --rc-chart-7: var(--xinyuan-chart-7);
--rc-chart-8: oklch(57% 0.035 250); --rc-chart-8: var(--xinyuan-chart-8);
--rc-chart-9: var(--xinyuan-chart-9);
--rc-chart-10: var(--xinyuan-chart-10);
--rc-chart-11: var(--xinyuan-chart-11);
--rc-chart-12: var(--xinyuan-chart-12);
--rc-chart-13: var(--xinyuan-chart-13);
--rc-chart-14: var(--xinyuan-chart-14);
--rc-chart-15: var(--xinyuan-chart-15);
--rc-chart-16: var(--xinyuan-chart-16);
/* Stable semantic meanings. The palette is intentionally free of green and neon hues. */
--rc-chart-primary: var(--rc-chart-1); --rc-chart-primary: var(--rc-chart-1);
--rc-chart-risk: oklch(47% 0.145 27.03); --rc-chart-risk: var(--rc-chart-1);
--rc-chart-warning: var(--rc-chart-6); --rc-chart-warning: var(--rc-chart-3);
--rc-chart-positive: var(--rc-chart-2); --rc-chart-positive: var(--rc-chart-11);
--rc-chart-comparison: var(--rc-chart-4); --rc-chart-comparison: var(--rc-chart-5);
--rc-chart-neutral: var(--rc-chart-8); --rc-chart-neutral: var(--rc-chart-8);
--rc-chart-legend-font-size: 0.75rem; --rc-chart-legend-font-size: var(--xinyuan-chart-legend-font-size);
--rc-chart-legend-line-height: 1rem; --rc-chart-legend-line-height: var(--xinyuan-chart-legend-line-height);
}
.dark,
[data-theme='dark'] {
color-scheme: dark;
--rc-text-secondary: color-mix(
in oklab,
var(--foreground) 76%,
var(--background)
);
--rc-button-secondary-border: color-mix(
in oklab,
var(--foreground) 18%,
transparent
);
--rc-data-grid-secondary-color: #8b8b95;
--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%);
} }
:root { :root {
--pm-brand: var(--rc-brand-primary); --pm-brand: var(--rc-brand-primary);
--pm-brand-strong: var(--rc-brand-dark, #7e1515); --pm-brand-strong: var(--rc-brand-pressed, #b51b14);
--pm-surface: var(--rc-bg-surface); --pm-surface: var(--rc-bg-surface);
--pm-bg: var(--rc-bg-canvas); --pm-bg: var(--rc-bg-canvas);
--pm-text: var(--rc-text-primary); --pm-text: var(--rc-text-primary);
......
...@@ -14,6 +14,7 @@ export default function ReferenceSearchSelect({ ...@@ -14,6 +14,7 @@ export default function ReferenceSearchSelect({
value, value,
onChange, onChange,
placeholder, placeholder,
variant = 'secondary',
}) { }) {
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [options, setOptions] = useState([]); const [options, setOptions] = useState([]);
...@@ -112,6 +113,7 @@ export default function ReferenceSearchSelect({ ...@@ -112,6 +113,7 @@ export default function ReferenceSearchSelect({
selectionMode={multiple ? 'multiple' : 'single'} selectionMode={multiple ? 'multiple' : 'single'}
shouldFilter={false} shouldFilter={false}
valueText={valueText || placeholder || '搜索引用记录标题'} valueText={valueText || placeholder || '搜索引用记录标题'}
variant={variant}
onInputChange={setKeyword} onInputChange={setKeyword}
onSelectionChange={handleSelectionChange} onSelectionChange={handleSelectionChange}
renderOption={(option) => ( renderOption={(option) => (
......
import clsx from 'clsx'; import clsx from 'clsx';
import { EmptyState } from '@heroui-pro/react/empty-state'; import { EmptyState } from '@heroui-pro/react/empty-state';
import { Surface } from '@heroui/react';
import TailwindEditableField from './TailwindEditableField'; import TailwindEditableField from './TailwindEditableField';
import { import {
buildViewColumns, buildViewColumns,
...@@ -10,20 +9,6 @@ import { ...@@ -10,20 +9,6 @@ import {
isFieldDisabledByView, isFieldDisabledByView,
} from '@/utils/subjectView'; } from '@/utils/subjectView';
const FULL_WIDTH_FIELD_TYPES = new Set([
'long_text',
'rich_text',
'object',
'form',
'subject',
'file',
'reference',
'document',
'view',
'template',
'task',
]);
export default function SubjectViewContent({ export default function SubjectViewContent({
view, view,
subject, subject,
...@@ -57,13 +42,11 @@ export default function SubjectViewContent({ ...@@ -57,13 +42,11 @@ export default function SubjectViewContent({
const fields = ( const fields = (
<section <section
className="grid min-w-0 grid-cols-1 gap-x-8 gap-y-2 md:grid-cols-2" className="grid min-w-0 grid-cols-1 gap-y-2"
> >
{contentFields.map((field) => { {contentFields.map((field) => {
const value = getFieldValue(editableRecord, field.field); const value = getFieldValue(editableRecord, field.field);
const fieldType = field.fieldDef?.type || 'text';
const fieldLabel = getFieldDisplayLabel(field.fieldDef, field.field); const fieldLabel = getFieldDisplayLabel(field.fieldDef, field.field);
const isFullWidth = FULL_WIDTH_FIELD_TYPES.has(fieldType);
const fieldDisabled = isFieldDisabled const fieldDisabled = isFieldDisabled
? isFieldDisabled(field.field, view) ? isFieldDisabled(field.field, view)
: isFieldDisabledByView(field.field, view); : isFieldDisabledByView(field.field, view);
...@@ -74,13 +57,12 @@ export default function SubjectViewContent({ ...@@ -74,13 +57,12 @@ export default function SubjectViewContent({
className={clsx( className={clsx(
'min-w-0', 'min-w-0',
minimal ? 'py-1' : 'py-3', minimal ? 'py-1' : 'py-3',
isFullWidth && 'md:col-span-2',
)} )}
> >
<div className={minimal ? 'sr-only' : 'text-sm font-medium text-muted'}> <div className={minimal ? 'sr-only' : 'text-base font-normal text-muted'}>
{fieldLabel} {fieldLabel}
</div> </div>
<div className={minimal ? undefined : 'mt-1.5 min-w-0'}> <div className={minimal ? undefined : 'mt-1.5 min-w-0 text-base font-normal'}>
<TailwindEditableField <TailwindEditableField
value={value} value={value}
fieldDef={field.fieldDef} fieldDef={field.fieldDef}
...@@ -109,13 +91,5 @@ export default function SubjectViewContent({ ...@@ -109,13 +91,5 @@ export default function SubjectViewContent({
</section> </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; return fields;
} }
...@@ -49,7 +49,6 @@ function getClearedValue(fieldDef) { ...@@ -49,7 +49,6 @@ function getClearedValue(fieldDef) {
function Pill({ children, tone = 'neutral' }) { function Pill({ children, tone = 'neutral' }) {
const semanticTone = { const semanticTone = {
amber: 'warning', amber: 'warning',
emerald: 'success',
rose: 'danger', rose: 'danger',
zinc: 'neutral', zinc: 'neutral',
}[tone] || tone; }[tone] || tone;
...@@ -114,7 +113,7 @@ function FieldValue({ value, fieldDef }) { ...@@ -114,7 +113,7 @@ function FieldValue({ value, fieldDef }) {
return ( return (
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{toArray(value).map((item, index) => ( {toArray(value).map((item, index) => (
<Pill key={`${normalizeUserLabel(item)}-${index}`} tone="success"> <Pill key={`${normalizeUserLabel(item)}-${index}`} tone="info">
{normalizeUserLabel(item) || '-'} {normalizeUserLabel(item) || '-'}
</Pill> </Pill>
))} ))}
......
...@@ -359,7 +359,7 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -359,7 +359,7 @@ const RecordDetailDrawer = (props, ref) => {
variant="blur" variant="blur"
> >
<Modal.Container placement="center" scroll="inside" size={isFullscreen ? 'full' : 'lg'}> <Modal.Container placement="center" scroll="inside" size={isFullscreen ? 'full' : 'lg'}>
<Modal.Dialog className={isFullscreen ? undefined : 'rc-dialog-panel rc-dialog-panel--4xl p-7'}> <Modal.Dialog className={isFullscreen ? 'bg-background' : 'rc-dialog-panel rc-dialog-panel--4xl bg-background p-7'}>
<div className="absolute right-7 top-7 z-10 flex items-center gap-2"> <div className="absolute right-7 top-7 z-10 flex items-center gap-2">
{!minimalContent ? ( {!minimalContent ? (
<Tooltip> <Tooltip>
...@@ -387,45 +387,48 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -387,45 +387,48 @@ const RecordDetailDrawer = (props, ref) => {
) : null} ) : null}
</div> </div>
<Modal.Header className="pr-16"> <Modal.Header className="shrink-0 pr-16">
<Modal.Heading>{title || '记录详情'}</Modal.Heading> <div className="flex w-full min-w-0 flex-wrap items-start gap-3">
{!hideRecordTitle ? ( <div className="min-w-0 flex-1">
<Tooltip delay={0}> <Modal.Heading>{title || '记录详情'}</Modal.Heading>
<Tooltip.Trigger className="block min-w-0 truncate text-start"> {!hideRecordTitle ? (
<Description>{loading ? '正在读取记录信息' : recordTitle}</Description> <Tooltip delay={0}>
</Tooltip.Trigger> <Tooltip.Trigger className="block min-w-0 truncate text-start">
<Tooltip.Content>{recordTitle}</Tooltip.Content> <Description>{loading ? '正在读取记录信息' : recordTitle}</Description>
</Tooltip> </Tooltip.Trigger>
) : ( <Tooltip.Content>{recordTitle}</Tooltip.Content>
<Description>查看并维护当前记录信息。</Description> </Tooltip>
)} ) : (
<Description>查看并维护当前记录信息。</Description>
)}
{detailViews.length > 1 ? (
<Tabs
aria-label="详情视图"
className="mt-3 w-fit max-w-full min-w-0"
selectedKey={activeView?.name}
variant="secondary"
onSelectionChange={setActiveTabKey}
>
<Tabs.ListContainer className="w-fit max-w-full min-w-0">
<Tabs.List className="w-max min-w-max">
{detailViews.map((view) => (
<Tabs.Tab
className="w-auto min-w-max flex-none whitespace-nowrap"
id={view.name}
key={view.name}
>
{view.title || view.name}
<Tabs.Indicator />
</Tabs.Tab>
))}
</Tabs.List>
</Tabs.ListContainer>
</Tabs>
) : null}
</div>
</div>
</Modal.Header> </Modal.Header>
{detailViews.length > 1 ? (
<Tabs
aria-label="详情视图"
className="w-fit max-w-full min-w-0"
selectedKey={activeView?.name}
variant="secondary"
onSelectionChange={setActiveTabKey}
>
<Tabs.ListContainer className="w-fit max-w-full min-w-0">
<Tabs.List className="w-max min-w-max">
{detailViews.map((view) => (
<Tabs.Tab
className="w-auto min-w-max flex-none whitespace-nowrap"
id={view.name}
key={view.name}
>
{view.title || view.name}
<Tabs.Indicator />
</Tabs.Tab>
))}
</Tabs.List>
</Tabs.ListContainer>
</Tabs>
) : null}
<Modal.Body> <Modal.Body>
{loading ? ( {loading ? (
<div className="flex min-h-48 items-center justify-center" role="status"> <div className="flex min-h-48 items-center justify-center" role="status">
......
...@@ -147,7 +147,7 @@ export default function ControlMeasureFormPage() { ...@@ -147,7 +147,7 @@ export default function ControlMeasureFormPage() {
toolbarActions.push({ toolbarActions.push({
key: 'submit', key: 'submit',
label: actionLoading === 'submit' ? '送审中...' : '送审', label: actionLoading === 'submit' ? '送审中...' : '送审',
variant: 'solid', variant: 'primary',
tone: 'primary', tone: 'primary',
disabled: Boolean(actionLoading) || records.length === 0, disabled: Boolean(actionLoading) || records.length === 0,
onClick: () => runFlowAction( onClick: () => runFlowAction(
...@@ -167,7 +167,7 @@ export default function ControlMeasureFormPage() { ...@@ -167,7 +167,7 @@ export default function ControlMeasureFormPage() {
{ {
key: 'approve', key: 'approve',
label: actionLoading === 'approve' ? '复核中...' : '一键复核', label: actionLoading === 'approve' ? '复核中...' : '一键复核',
variant: 'solid', variant: 'primary',
tone: 'primary', tone: 'primary',
disabled: Boolean(actionLoading) || records.length === 0, disabled: Boolean(actionLoading) || records.length === 0,
onClick: () => runFlowAction( onClick: () => runFlowAction(
...@@ -179,7 +179,7 @@ export default function ControlMeasureFormPage() { ...@@ -179,7 +179,7 @@ export default function ControlMeasureFormPage() {
{ {
key: 'reject', key: 'reject',
label: '驳回', label: '驳回',
variant: 'outline', variant: 'secondary',
tone: 'danger', tone: 'danger',
disabled: Boolean(actionLoading), disabled: Boolean(actionLoading),
onClick: () => setRejectType('auditor'), onClick: () => setRejectType('auditor'),
...@@ -196,7 +196,7 @@ export default function ControlMeasureFormPage() { ...@@ -196,7 +196,7 @@ export default function ControlMeasureFormPage() {
{ {
key: 'final-approve', key: 'final-approve',
label: actionLoading === 'final-approve' ? '审核中...' : '一键最终审核', label: actionLoading === 'final-approve' ? '审核中...' : '一键最终审核',
variant: 'solid', variant: 'primary',
tone: 'primary', tone: 'primary',
disabled: Boolean(actionLoading) || records.length === 0, disabled: Boolean(actionLoading) || records.length === 0,
onClick: () => runFlowAction( onClick: () => runFlowAction(
...@@ -208,7 +208,7 @@ export default function ControlMeasureFormPage() { ...@@ -208,7 +208,7 @@ export default function ControlMeasureFormPage() {
{ {
key: 'final-reject', key: 'final-reject',
label: '驳回审核', label: '驳回审核',
variant: 'outline', variant: 'secondary',
tone: 'danger', tone: 'danger',
disabled: Boolean(actionLoading), disabled: Boolean(actionLoading),
onClick: () => setRejectType('final'), onClick: () => setRejectType('final'),
......
...@@ -146,7 +146,7 @@ export default function IntegrityRiskWorkbenchPage({ pageType }) { ...@@ -146,7 +146,7 @@ export default function IntegrityRiskWorkbenchPage({ pageType }) {
toolbarActions: [{ toolbarActions: [{
key: 'export-process', key: 'export-process',
label: busyKey === 'export-process' ? '导出中...' : '导出过程表', label: busyKey === 'export-process' ? '导出中...' : '导出过程表',
variant: 'outline', variant: 'secondary',
disabled: disabledWhileBusy, disabled: disabledWhileBusy,
onClick: (context) => runAction({ onClick: (context) => runAction({
key: 'export-process', key: 'export-process',
...@@ -159,14 +159,14 @@ export default function IntegrityRiskWorkbenchPage({ pageType }) { ...@@ -159,14 +159,14 @@ export default function IntegrityRiskWorkbenchPage({ pageType }) {
{ {
key: 'push-flow', key: 'push-flow',
label: busyKey === 'push-flow' ? '推送中...' : '推送风险防控表', label: busyKey === 'push-flow' ? '推送中...' : '推送风险防控表',
variant: 'ghost', variant: 'secondary',
disabled: ({ selectedRecords }) => disabledWhileBusy() || selectedRecords.length === 0, disabled: ({ selectedRecords }) => disabledWhileBusy() || selectedRecords.length === 0,
onClick: (context) => setPushContext(context), onClick: (context) => setPushContext(context),
}, },
{ {
key: 'archive-process', key: 'archive-process',
label: busyKey === 'archive-process' ? '归档中...' : '一键归档', label: busyKey === 'archive-process' ? '归档中...' : '一键归档',
variant: 'ghost', variant: 'secondary',
disabled: ({ selectedRecords }) => disabledWhileBusy() || selectedRecords.length === 0, disabled: ({ selectedRecords }) => disabledWhileBusy() || selectedRecords.length === 0,
onClick: (context) => runAction({ onClick: (context) => runAction({
key: 'archive-process', key: 'archive-process',
...@@ -186,14 +186,14 @@ export default function IntegrityRiskWorkbenchPage({ pageType }) { ...@@ -186,14 +186,14 @@ export default function IntegrityRiskWorkbenchPage({ pageType }) {
{ {
key: 'import-show', key: 'import-show',
label: '导入过程表', label: '导入过程表',
variant: 'outline', variant: 'secondary',
disabled: disabledWhileBusy, disabled: disabledWhileBusy,
onClick: (context) => requestImport('show', context), onClick: (context) => requestImport('show', context),
}, },
{ {
key: 'export-show', key: 'export-show',
label: busyKey === 'export-show' ? '导出中...' : '导出展示表', label: busyKey === 'export-show' ? '导出中...' : '导出展示表',
variant: 'outline', variant: 'secondary',
disabled: disabledWhileBusy, disabled: disabledWhileBusy,
onClick: (context) => runAction({ onClick: (context) => runAction({
key: 'export-show', key: 'export-show',
...@@ -205,7 +205,7 @@ export default function IntegrityRiskWorkbenchPage({ pageType }) { ...@@ -205,7 +205,7 @@ export default function IntegrityRiskWorkbenchPage({ pageType }) {
{ {
key: 'export-measures', key: 'export-measures',
label: busyKey === 'export-measures' ? '导出中...' : '导出防控措施', label: busyKey === 'export-measures' ? '导出中...' : '导出防控措施',
variant: 'outline', variant: 'secondary',
disabled: disabledWhileBusy, disabled: disabledWhileBusy,
onClick: (context) => runAction({ onClick: (context) => runAction({
key: 'export-measures', key: 'export-measures',
...@@ -219,7 +219,7 @@ export default function IntegrityRiskWorkbenchPage({ pageType }) { ...@@ -219,7 +219,7 @@ export default function IntegrityRiskWorkbenchPage({ pageType }) {
{ {
key: 'export-selected-show', key: 'export-selected-show',
label: busyKey === 'export-selected-show' ? '导出中...' : '导出所选', label: busyKey === 'export-selected-show' ? '导出中...' : '导出所选',
variant: 'ghost', variant: 'secondary',
disabled: ({ selectedRecords }) => disabledWhileBusy() || selectedRecords.length === 0, disabled: ({ selectedRecords }) => disabledWhileBusy() || selectedRecords.length === 0,
onClick: (context) => runAction({ onClick: (context) => runAction({
key: 'export-selected-show', key: 'export-selected-show',
...@@ -231,7 +231,7 @@ export default function IntegrityRiskWorkbenchPage({ pageType }) { ...@@ -231,7 +231,7 @@ export default function IntegrityRiskWorkbenchPage({ pageType }) {
{ {
key: 'archive-show', key: 'archive-show',
label: busyKey === 'archive-show' ? '归档中...' : '一键归档', label: busyKey === 'archive-show' ? '归档中...' : '一键归档',
variant: 'ghost', variant: 'secondary',
disabled: ({ selectedRecords }) => disabledWhileBusy() || selectedRecords.length === 0, disabled: ({ selectedRecords }) => disabledWhileBusy() || selectedRecords.length === 0,
onClick: (context) => runAction({ onClick: (context) => runAction({
key: 'archive-show', key: 'archive-show',
...@@ -251,14 +251,14 @@ export default function IntegrityRiskWorkbenchPage({ pageType }) { ...@@ -251,14 +251,14 @@ export default function IntegrityRiskWorkbenchPage({ pageType }) {
{ {
key: 'import-measure', key: 'import-measure',
label: '导入防控措施', label: '导入防控措施',
variant: 'outline', variant: 'secondary',
disabled: disabledWhileBusy, disabled: disabledWhileBusy,
onClick: (context) => requestImport('measure', context), onClick: (context) => requestImport('measure', context),
}, },
{ {
key: 'export-measure', key: 'export-measure',
label: busyKey === 'export-measure' ? '导出中...' : '导出措施描述', label: busyKey === 'export-measure' ? '导出中...' : '导出措施描述',
variant: 'outline', variant: 'secondary',
disabled: disabledWhileBusy, disabled: disabledWhileBusy,
onClick: (context) => runAction({ onClick: (context) => runAction({
key: 'export-measure', key: 'export-measure',
...@@ -271,7 +271,7 @@ export default function IntegrityRiskWorkbenchPage({ pageType }) { ...@@ -271,7 +271,7 @@ export default function IntegrityRiskWorkbenchPage({ pageType }) {
bulkActions: [{ bulkActions: [{
key: 'archive-measure', key: 'archive-measure',
label: busyKey === 'archive-measure' ? '归档中...' : '一键归档', label: busyKey === 'archive-measure' ? '归档中...' : '一键归档',
variant: 'ghost', variant: 'secondary',
disabled: ({ selectedRecords }) => disabledWhileBusy() || selectedRecords.length === 0, disabled: ({ selectedRecords }) => disabledWhileBusy() || selectedRecords.length === 0,
onClick: (context) => runAction({ onClick: (context) => runAction({
key: 'archive-measure', key: 'archive-measure',
......
This diff is collapsed.
// Chart series colors resolve from the shared Xinyuan CSS tokens at render time.
export const XINYUAN_CHART_TOKENS = Object.freeze(
Array.from({ length: 16 }, (_, index) => `--rc-chart-${index + 1}`),
);
export function getChartColorToken(index = 0) {
const normalizedIndex = Math.max(0, Number(index) || 0);
if (normalizedIndex >= XINYUAN_CHART_TOKENS.length) {
throw new RangeError('Xinyuan charts support at most 16 distinct series colors.');
}
return `var(${XINYUAN_CHART_TOKENS[normalizedIndex]})`;
}
function hashChartKey(key) {
let hash = 2166136261;
for (let index = 0; index < key.length; index += 1) {
hash ^= key.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
}
export function createStableChartColorMap(keys = []) {
const uniqueKeys = [...new Set(keys.map((key) => String(key ?? '')).filter(Boolean))]
.sort((left, right) => left.localeCompare(right, 'zh-Hans'));
if (uniqueKeys.length > XINYUAN_CHART_TOKENS.length) {
throw new RangeError('Xinyuan charts support at most 16 distinct series colors.');
}
// Hash each business key into the shared palette so filtering and sorting do
// not change a series' color. Linear probing resolves deterministic collisions.
const usedIndexes = new Set();
return new Map(
uniqueKeys.map((key) => {
let paletteIndex = hashChartKey(key) % XINYUAN_CHART_TOKENS.length;
while (usedIndexes.has(paletteIndex)) {
paletteIndex = (paletteIndex + 1) % XINYUAN_CHART_TOKENS.length;
}
usedIndexes.add(paletteIndex);
return [key, getChartColorToken(paletteIndex)];
}),
);
}
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