Commit 07a1a18d authored by Andy-bubu's avatar Andy-bubu

feat: complete Xinyuan UI migration and application workflows

parent 050ffd91
...@@ -12,37 +12,71 @@ Hover/Focus/Selected/Disabled。`src/pages` 下没有页面私有 CSS/Less/SCSS ...@@ -12,37 +12,71 @@ Hover/Focus/Selected/Disabled。`src/pages` 下没有页面私有 CSS/Less/SCSS
## 共享组合 ## 共享组合
- `AppShell`:根主题、Pro Sidebar Provider、Navbar 和 base-aware 页面滚动容器。 - `AppShell`:固定浅色根主题、Pro Sidebar Provider、Navbar 和 base-aware 页面滚动容器。
- `DashboardSidebar` / `DashboardNavbar`:导航、offcanvas、应用切换、用户菜单与主题动作。 - `DashboardSidebar` / `DashboardNavbar`:固定深色导航、offcanvas、应用切换、用户菜单与通知动作;
Navbar 不再提供主题切换。
移动 Sidebar 直接组合 Pro Sheet,并在真实 `Sheet.Dialog` 上提供可访问名称,继续复用 移动 Sidebar 直接组合 Pro Sheet,并在真实 `Sheet.Dialog` 上提供可访问名称,继续复用
`useSidebar` 的开关状态和同一份 Sidebar 内容。 `useSidebar` 的开关状态和同一份 Sidebar 内容。
- `ApplicationSwitcherDialog`:直接组合 HeroUI Modal/Card/Surface/Avatar 与 Pro EmptyState; - `ApplicationSwitcherDialog`:直接组合 HeroUI Modal/ScrollShadow/Surface/Avatar 与 Pro
ItemCardGroup/ItemCard/EmptyState;Modal Dialog 使用页面 `background`,ItemCard 保持
`surface`,Header 固定、Body 独立滚动,当前项使用官方 outline/Chip 状态;
所有应用统一使用鑫元红色 Logo `xy-logo-red.png`(当前应用同样使用),identity well 使用
页面同层 `Surface default`,不使用标题首字母;
从既有 `/application/info` 和用户应用合并可访问集合,再按 `SiteContext` 的 从既有 `/application/info` 和用户应用合并可访问集合,再按 `SiteContext` 的
`/info -> site.group` 配置生成有序分组,不拥有接口、权限、会话或跳转逻辑。 `/info -> site.group` 配置生成有序分组;Header 右侧提供 HeroUI `SearchField secondary`,
- `DataTable`:Pro DataGrid 原生选择、排序、固定列、内部滚动、EmptyState、ActionBar。 按应用名称、描述和标识过滤当前已授权集合,空分组隐藏、分组数量反映匹配结果,清空后恢复
原顺序,不拥有接口、权限、会话或跳转逻辑。
- `DataTable`:Pro DataGrid 原生选择、排序、固定列、内部滚动、EmptyState、ActionBar;共享
`DataTablePagination` 将摘要与 HeroUI Autocomplete 页容量选择器组成左侧同组,选择器紧接摘要
右侧;Pagination 页码控制靠右。项目业务列表使用 20/50/100 条,选择器不显示搜索框,变更时重置到第一页。
该结构是跨应用固定基线,业务页面不得改变其层级、顺序、对齐和尺寸。
- `XinyuanAutocomplete` / `FilterSelect` / `ReferenceSearchSelect`:当前 HeroUI - `XinyuanAutocomplete` / `FilterSelect` / `ReferenceSearchSelect`:当前 HeroUI
`selectedKey(s)`、`onSelectionChange`、`inputValue` 合同。 `selectedKey(s)`、`onSelectionChange`、`inputValue` 合同。
- `FormDatePicker`:HeroUI DatePicker/DateField/Calendar 与既有字符串协议的页面边界映射。 - `FormDatePicker`:HeroUI DatePicker/DateField/Calendar 与既有字符串协议的页面边界映射。
- `FileUploadField`:Pro DropZone files 到既有业务回调的页面边界映射。 - `FileUploadField`:Pro DropZone files 到既有业务回调的页面边界映射。
- `XinyuanModal`:HeroUI Modal compound 结构、批准宽度和 Surface 层级。 - `XinyuanModal`:HeroUI Modal compound 结构、批准宽度和 Surface 层级。
- `TableActionButton`:HeroUI Button/Dropdown/Tooltip 与统一 Ant Design 图标。 - `TableActionButton`:HeroUI Button/Dropdown/Tooltip 与统一 Ant Design 图标。
- `DynamicFormValueEditor`:将 Subject `form` 的 `{ form, data, settings }` 协议映射为 Tabs、 - `XinyuanEChart`:`echarts-for-react` 的共享主题边界,统一解析 `--xinyuan-chart-*`,注入
Disclosure、Pro DataGrid、Autocomplete、Modal 和 AlertDialog;组件只通过 `onChange` 回传 ARIA、Tooltip、图例、坐标轴和响应式;业务页面只拥有 series/data 等业务配置。
- `DynamicFormValueEditor`:将 Subject `form` 的 `{ form, data, settings }` 协议映射为纵向
HeroUI Tabs 分组导航、Disclosure、Pro DataGrid、Autocomplete、Modal 和 AlertDialog;组件只通过 `onChange` 回传
草稿,不拥有记录更新接口或提交时机。 草稿,不拥有记录更新接口或提交时机。
只读详情与编辑态共享主分支的信息架构:顶部统计 Disclosure、左侧纵向分组 Tabs、右侧当前
分组标题和表单实例 Disclosure。查看态隐藏新增、删除、模板/分组选择和行编辑操作,只保留
分组切换、表单展开和 DataGrid;编辑态继续在相同位置提供原有维护操作。
页面主数据集分类、详情弹窗业务阶段与动态表单 `settings.group` 全部直接使用 HeroUI 页面主数据集分类、详情弹窗业务阶段与动态表单 `settings.group` 全部直接使用 HeroUI
`Tabs variant="primary"` 的官方胶囊轨道。三者共用内容宽度 `Tabs variant="primary"` 的官方胶囊轨道。三者共用内容宽度
与官方 `Tabs.ListContainer` 滚动合同,应用不拥有 Tabs 的背景、描边、圆角、选中指示器或 与官方 `Tabs.ListContainer` 滚动合同,应用不拥有 Tabs 的背景、描边、圆角、选中指示器或
交互样式。 交互样式。
详情弹窗的标题、阶段 Tabs 和顶部业务操作共同属于 HeroUI `Modal.Header`;Header 不参与 详情弹窗的功能标题、阶段 Tabs 和顶部业务操作共同属于 HeroUI `Modal.Header`;Header 不参与
正文滚动,`Modal.Body` 是唯一纵向滚动容器。正文不得从 Tabs 下方穿过,也不得通过给 Tabs 正文滚动,`Modal.Body` 是唯一纵向滚动容器。Header 不展示记录 ID、内部名称或冗余副标题,
增加私有背景、描边或阴影来遮挡滚动内容。 业务标题仍按视图字段在正文中呈现。正文不得从 Tabs 下方穿过,也不得通过给 Tabs 增加私有
背景、描边或阴影来遮挡滚动内容。
`RecordDetailDrawer` 的查看态结构固定为 Modal Overlay/L1 -> 完整字段组 `Surface secondary`/L2
-> `form` 或引用记录 `Surface tertiary`/L3。动态表单内部继续使用 Pro DataGrid 自有的 L2 所有 Modal/AlertDialog Dialog 均使用页面 `background` 作为面板底色;`RecordDetailDrawer` 的字段
表头和 L1 数据单元格;不得通过 DataGrid 私有 CSS、边框或阴影制造分隔。编辑态不创建 L3, 按视图配置顺序单列纵向排列,普通字段直接位于 Dialog 背景。动态表单等带统计、导航的完整业务板块
所有输入仍直接使用 HeroUI `secondary` 控件。 使用共享 `rc-workspace-module`(24px 圆角、1px 灰色描边、透明背景)统一承载板块内容;关联记录短列表保持透明摘要,直接使用 Pro DataGrid 自有的 L2 表头和 L1 数据单元格;数据为空时仅在数据区域使用一层紧凑的 `Surface secondary` 灰色背景承载 EmptyState;
不得通过 DataGrid 私有 CSS、边框或阴影制造分隔。编辑态所有输入仍直接使用 HeroUI `secondary`
控件。
全应用所有“暂无数据/暂无关联记录/暂无通知/暂无内容”等空数据分支统一由共享
`EmptyStateSurface`(HeroUI `Surface secondary`)承载,统一使用 `rounded-2xl`(16px)圆角;
有真实数据时不挂载该层。纯空状态文案使用 HeroUI Pro `EmptyState.Description` 的正常字重和弱化
颜色,不使用默认半粗 `EmptyState.Title`。详情字段空值使用 `.rc-empty-field-text`,比字段标题的
`text-muted` 再降低一层,避免“暂无内容”与标题混成同一视觉层级。加载、错误、权限和模板不可用
状态保持各自反馈组件,不复用空数据背景。
通用新增/编辑表单和动态表单行弹窗在桌面使用等宽双列;其中所有选择字段(枚举、用户、引用)
必须跨满弹窗内容宽度,不得出现半列短选择框。详情弹窗(`SubjectViewContent`)始终
使用单列纵向字段流,避免字段按响应式断点错位。普通输入、日期、单选、多选和引用选择在通用
表单中按字段顺序配对;文件字段及其外层控件占满两列以锁定后续字段顺序,其中材料中心 Autocomplete
等选择控件始终通铺整行;本地 DropZone 上传交互区也默认通铺,只有确有文件预览并行需求时才可在
组件内部采用独立宽度。长文本和
对象、表单、Subject 等大型复合内容在通用表单中跨两列。
详情查看态附件和关联内容也遵循同一单列顺序;仅通用编辑表单中的复合字段允许跨两列,移动端所有字段统一回退单列。
上述组合均是业务无关的 HeroUI-native 组合,不合成旧 DOM event,也不隐藏页面 API、权限 上述组合均是业务无关的 HeroUI-native 组合,不合成旧 DOM event,也不隐藏页面 API、权限
或数据映射。 或数据映射。
...@@ -88,27 +122,30 @@ HeroUI 当前 value/files/selection 表达到既有页面回调,不引入“ ...@@ -88,27 +122,30 @@ HeroUI 当前 value/files/selection 表达到既有页面回调,不引入“
生成一次记录更新请求。 生成一次记录更新请求。
详情查看态将空实例与编辑占位分开:空实例只参与编辑草稿,不进入查看态计数、分组或 详情查看态将空实例与编辑占位分开:空实例只参与编辑草稿,不进入查看态计数、分组或
Disclosure;已选择模板但零行时使用紧凑 EmptyState,不挂载 DataGrid。引用字段的展示层 Disclosure;已选择模板但零行时使用单一紧凑 EmptyState 模块,不创建可横向滚动的空
DataGrid;有真实行数据时才挂载 DataGrid。空数据占位保留在透明底、灰色描边的 `rc-workspace-module` 模块内,并在数据区域使用紧凑的 `Surface secondary` 灰色层;引用字段的展示层
统一解包 `raw`,但引用 ID、Subject、查询参数、返回数据和保存值均保持原协议。 统一解包 `raw`,但引用 ID、Subject、查询参数、返回数据和保存值均保持原协议。
## 结构图 ## 结构图
```text ```text
Umi routes / auth wrappers Umi routes / auth wrappers
-> AppShell (root theme + Pro Sidebar/Navbar) -> AppShell (fixed light root + dark Pro Sidebar + light Navbar/content)
-> route page (layout and business state) -> route page (layout and business state)
-> HeroUI base compounds -> HeroUI base compounds
-> HeroUI Pro DataGrid / DropZone / KPI / Charts -> HeroUI Pro DataGrid / DropZone / KPI
-> XinyuanEChart (ECharts renderer + shared theme adapter)
-> business-neutral shared compositions -> business-neutral shared compositions
-> existing service callbacks and data protocols -> existing service callbacks and data protocols
``` ```
Portal 与页面共享 `documentElement` 的 `.light/.dark` 和 `data-theme`;Modal、Dropdown、 Portal 与页面共享 `documentElement` 的固定 `.light` 和 `data-theme="light"`;Modal、Dropdown、
Autocomplete、Calendar、Tooltip、ActionBar 与 ChartTooltip 不建立局部主题。 Autocomplete、Calendar、Tooltip、ActionBar 与 ECharts HTML Tooltip 不建立可切换局部主题。
Sidebar 只通过 `--xinyuan-sidebar-*` 建立固定深色导航面,不把暗色变量传播到内容或 Portal。
详情查看态的 `rc-detail-view-surface` 是共享结构类,只把权威 `--radius` 映射为设计系统规定的 详情查看态字段按配置顺序单列纵向排列,普通字段直接位于 Dialog 页面背景;动态表单、关联记录
16px 容器圆角;背景、前景、边框和交互仍由 HeroUI `Surface variant="secondary"` 所有。 等完整业务板块使用官方 `Surface secondary` 作为统一承载层。页面不允许为 Surface、Tabs、DataGrid、
页面不允许为 Surface、Tabs、DataGrid、Modal 或 AlertDialog 增加私有外观规则。 Modal 或 AlertDialog 增加私有外观规则。
Inter 400/500/600 拉丁子集由 `.umirc.ts` 生成的常驻 `public/fonts/inter.css` Inter 400/500/600 拉丁子集由 `.umirc.ts` 生成的常驻 `public/fonts/inter.css`
stylesheet link 加载,不参与 `umi.css` 热替换。`src/tailwind.css` 不导入字体 stylesheet link 加载,不参与 `umi.css` 热替换。`src/tailwind.css` 不导入字体
......
...@@ -12,9 +12,10 @@ ...@@ -12,9 +12,10 @@
## 架构与主题 ## 架构与主题
- [x] 权威 `theme.css` 字节一致,Tokens 只扩展 `--rc-*` - [x] 用户新 `theme.css` 与项目副本字节一致,Tokens 只扩展 `--rc-*`
- [x] 根级浅/深色主题、持久化和 Portal 继承 - [x] 根级固定浅色、主题切换/持久化入口移除,Portal 始终继承浅色
- [x] Catalyst、Headless UI、Heroicons、ECharts 和旧包装引用归零 - [x] Sidebar 使用命名隔离的 `--xinyuan-sidebar-*` 固定深色 Token,未污染内容区
- [x] Catalyst、Headless UI、Heroicons、活动 Recharts/HeroUI Pro Charts 和旧包装引用归零
- [x] 可见原生 select/date/datetime/file 和浏览器 confirm/alert 归零 - [x] 可见原生 select/date/datetime/file 和浏览器 confirm/alert 归零
- [x] 页面/共享组件私有外观 utility 与页面私有样式文件归零 - [x] 页面/共享组件私有外观 utility 与页面私有样式文件归零
- [x] `file:`、本机依赖路径和授权字面值归零 - [x] `file:`、本机依赖路径和授权字面值归零
...@@ -26,42 +27,54 @@ ...@@ -26,42 +27,54 @@
- [x] HeroUI Pro Sidebar 真 offcanvas,移动面板与基准同为 80vw - [x] HeroUI Pro Sidebar 真 offcanvas,移动面板与基准同为 80vw
- [x] 移动 Sidebar 使用官方 Sheet 结构,真实 Dialog 具备可访问名称且控制台无警告 - [x] 移动 Sidebar 使用官方 Sheet 结构,真实 Dialog 具备可访问名称且控制台无警告
- [x] Pro Navbar/Sidebar.Trigger 与 48/32/16px 对称边距 - [x] Pro Navbar/Sidebar.Trigger 与 48/32/16px 对称边距
- [x] 应用切换同步 `model_analysis` HeroUI Modal 的 46rem/42rem、28px inset、Body 滚动 - [x] 应用切换使用 HeroUI Modal 的 46rem/42rem、28px inset 与 ScrollShadow Body 滚动
- [x] 应用切换 Header 使用 HeroUI `SearchField secondary` 通栏搜索已授权应用;不渲染重复可见标题,Modal 保留无障碍名称,按名称/描述/标识实时过滤并隐藏空分组,清空恢复原分组顺序
- [x] `/info -> site.group` 动态分组、真实数量、空组过滤、“其他应用/全部应用”回退 - [x] `/info -> site.group` 动态分组、真实数量、空组过滤、“其他应用/全部应用”回退
- [x] 应用卡移动一列/`sm+` 两列、36px identity well 和唯一当前状态 - [x] Pro ItemCardGroup/ItemCard 移动一列/`sm+` 两列、36px identity well、去重描述和唯一当前状态
- [x] 应用切换统一使用鑫元红色 Logo,所有应用身份一致,不使用标题首字母
- [x] Logo identity well 使用页面同层 Surface default,避免 Logo 背景出现突兀灰色块
- [x] 应用切换移除重复可见标题和冗余说明副标题,保留 Modal 无障碍名称;搜索框通栏铺满 Header
- [x] 动态表单分组导航固定将“默认分组”置于第一项,其余分组保持原数据顺序
- [x] Dropdown 用户菜单、真实退出、token 清理和 base-aware 跳转 - [x] Dropdown 用户菜单、真实退出、token 清理和 base-aware 跳转
- [x] 浅深色主题切换、刷新持久化与深色 Portal - [x] Navbar 主题按钮移除;通知按钮成为最右侧全局动作
## 页面与组件 ## 页面与组件
- [x] 驾驶舱:4 个 Pro KPI、PieChart/BarChart/ComposedChart/ChartTooltip - [x] 驾驶舱:4 个 Pro KPI;状态饼图和阶段柱图统一使用共享 ECharts 主题适配器
- [x] 项目列表/归档/外部填写:Tabs、Surface、Autocomplete、DatePicker、Disclosure - [x] 项目列表/归档/外部填写:Tabs、Surface、Autocomplete、DatePicker、Disclosure
- [x] 全部业务列表:Pro DataGrid、原生选择/排序/固定列、Pagination、ActionBar、EmptyState - [x] 全部业务列表:Pro DataGrid、原生选择/排序/固定列、Pagination、ActionBar、EmptyState
- [x] 项目业务列表 Pagination 同行左侧摘要右侧提供 HeroUI 20/50/100 条页容量切换(无搜索框),页码控制靠右,切换后回到第一页
- [x] DataGrid 表头只显示业务标签,内部 `metadata.*` 路径和 snake_case 标识不对用户可见 - [x] DataGrid 表头只显示业务标签,内部 `metadata.*` 路径和 snake_case 标识不对用户可见
- [x] 工具栏顺序与移动端搜索独占行 - [x] 工具栏顺序与移动端搜索独占行
- [x] 工具栏、详情操作区和字段编辑区的 `primary` 主操作统一位于最右侧 - [x] 工具栏、详情操作区和字段编辑区的 `primary` 主操作统一位于最右侧
- [x] 详情/表单:4xl Modal、查看态单一 L2 Surface、编辑态官方 secondary 控件 - [x] 详情/表单:4xl Modal、详情字段单列纵向、查看态无大面积 Surface、编辑态官方 secondary 控件
- [x] 删除/危险操作:AlertDialog,取消不发请求 - [x] 删除/危险操作:AlertDialog,取消不发请求
- [x] 项目表单与附件:Pro DropZone,无可见原生 file 控件 - [x] 项目表单与附件:Pro DropZone,无可见原生 file 控件
- [x] form 字段:名称/行数/动态字段 DataGrid,不暴露内部 JSON 或元数据 - [x] form 字段:名称/行数/动态字段 DataGrid,不暴露内部 JSON 或元数据
- [x] form 编辑:Autocomplete、Disclosure、Tabs、Modal、AlertDialog,统一外层保存 - [x] form 编辑:Autocomplete、Disclosure、纵向 Tabs 分组导航、Modal、AlertDialog,统一外层保存
- [x] 检查底稿及同类多值表单采用左侧分组导航、右侧当前内容;新增分组在左侧底部,新增表单在右侧标题行
- [x] 动态表单行/通用记录弹窗的枚举、用户、引用选择字段统一全宽,不混用半列短选择框
- [x] Dashboard 图表和 KPI 不使用测试数字或源码 fixture - [x] Dashboard 图表和 KPI 不使用测试数字或源码 fixture
- [x] 动态表单模板不可用状态使用内嵌 Pro EmptyState,不产生额外浮起 Surface - [x] 动态表单模板不可用状态使用内嵌 Pro EmptyState,不产生额外浮起 Surface
- [x] 动态表单、关联制度/流程图等暂无数据状态使用数据区域内的 `Surface secondary` 灰色背景和紧凑 EmptyState,不叠加整块模块背景
- [x] 动态表单的统计、导航、标题和内容统一位于透明底、1px 灰色描边、24px 圆角的 `rc-workspace-module` 业务板块内;关联记录短列表保持透明摘要并直接使用 DataGrid 层级
- [x] 全应用空数据分支统一使用共享 `EmptyStateSurface` / `Surface secondary` 灰色背景和 `rounded-2xl`(16px)圆角;纯空文案使用官方 `EmptyState.Description` 弱化层级,详情“暂无内容”使用 `.rc-empty-field-text`,有数据时不渲染空态层,加载/错误/权限状态单独处理
- [x] 动态表单顶部移除常驻分组辅助副文案,Disclosure 展开图标与主统计文字同一水平行对齐
## 浏览器验收 ## 浏览器验收
- [x] 1440x900 浅色/深色目标与基准对照 - [x] 1440x900 固定深色 Sidebar + 浅色内容区
- [x] 1280x720 浅色/深色目标与基准对照 - [x] 1280x720 固定深色 Sidebar + 浅色内容区
- [x] 768x1024 浅色/深色目标与基准对照 - [x] 768x1024 固定深色 Sidebar + 浅色内容区
- [x] 390x844 浅色/深色目标与基准对照 - [x] 390x844 固定深色移动 Sidebar + 浅色内容区
- [ ] 验证码恢复后的首次加载、点击刷新、失败重载及四视口浅深色对照(本轮浏览器实例不可用,待重验) - [x] 验证码真实加载、失败刷新与成功登录;固定浅色登录页通过
- [x] 应用切换四视口浅/深色与 `model_analysis` 打开态重新验收 - [x] 应用切换四视口浅/深色与 `model_analysis` 打开态重新验收
- [x] 登录、退出、Sidebar、Navbar 和用户菜单 - [x] 登录、退出、Sidebar、Navbar 和用户菜单
- [x] Tabs、筛选展开/收起、Dropdown/ListBox、DatePicker、Tooltip - [x] Tabs、筛选展开/收起、Dropdown/ListBox、DatePicker、Tooltip
- [x] 三处活动 Tabs 均显式使用 HeroUI `primary` 胶囊轨道,按文字收缩且窄屏由官方容器滚动 - [x] 三处活动 Tabs 均显式使用 HeroUI `primary` 胶囊轨道,按文字收缩且窄屏由官方容器滚动
- [ ] 页面数据集、详情阶段与动态表单分组的切换业务回调未变,本轮浏览器对照待补 - [ ] 页面数据集、详情阶段与动态表单分组的切换业务回调未变,本轮浏览器对照待补
- [ ] 详情标题、阶段 Tabs 和顶部操作固定在 Modal Header,只有 Body 滚动;本轮浏览器滚动对照待补 - [ ] 详情标题、阶段 Tabs 和顶部操作固定在 Modal Header,只有 Body 滚动;本轮浏览器滚动对照待补
- [x] Modal 查看态复杂字段使用官方 L3 Surface,与 L2 DataGrid 表头和 L1 数据行分层 - [x] Modal 详情字段按配置顺序单列纵向排列;查看态普通字段与嵌套内容直接使用 Dialog 背景,不创建大面积灰色 Surface;DataGrid 保留官方 L2/L1 层级
- [x] 结构化筛选使用原生 Surface;关键词只在列表工具栏,单/双字段不再铺满页面背景 - [x] 结构化筛选使用原生 Surface;关键词只在列表工具栏,单/双字段不再铺满页面背景
- [x] 实时筛选无全局刷新/重置;状态与年度选择通过 Autocomplete 框内清除 - [x] 实时筛选无全局刷新/重置;状态与年度选择通过 Autocomplete 框内清除
- [x] Checkbox 选中/半选、DataGrid 内部滚动/固定列、Pagination、ActionBar - [x] Checkbox 选中/半选、DataGrid 内部滚动/固定列、Pagination、ActionBar
...@@ -70,22 +83,27 @@ ...@@ -70,22 +83,27 @@
- [x] ActionBar 随 Sidebar 展开/折叠在主内容视口内重新居中 - [x] ActionBar 随 Sidebar 展开/折叠在主内容视口内重新居中
- [x] 驾驶舱重复项目清单使用 HeroUI Pro ListView,不使用 Button 模拟列表项 - [x] 驾驶舱重复项目清单使用 HeroUI Pro ListView,不使用 Button 模拟列表项
- [x] Modal 查看/编辑/取消、AlertDialog 取消、DropZone - [x] Modal 查看/编辑/取消、AlertDialog 取消、DropZone
- [x] Modal 24px 圆角,详情 L2 Surface 16px 圆角;Overlay 与 DataGrid 表头计算色阶可区分 - [x] Modal 24px 圆角,详情字段单列纵向排列;查看态不创建大面积灰色 Surface
- [x] 项目表单/检查底稿/审计需求材料在四视口浅深色下显示真实业务行 - [x] 所有 Modal/AlertDialog Dialog 使用页面 `bg-background`,内部层级仅通过官方 Surface 区分
- [x] 项目表单/检查底稿/审计需求材料既有四视口业务验收保持有效
- [x] 动态表单行修改先进入草稿,保存前无 PUT,外层保存后一次 PUT - [x] 动态表单行修改先进入草稿,保存前无 PUT,外层保存后一次 PUT
- [x] 详情弹窗空表单实例不计数、不显示空分组,零行不创建空 DataGrid - [x] 详情弹窗空表单实例不计数、不显示空分组;零行使用单一紧凑 EmptyState,不渲染空 DataGrid
- [x] 关联记录解包 raw、隐藏全空列和技术字段,纯 ID 使用明确不可用状态 - [x] 关联记录解包 raw、隐藏全空列和技术字段,纯 ID 使用明确不可用状态
- [x] 查看态标题不在正文重复,占位标题不显示为 Modal 副标题 - [x] 详情 Modal Header 仅保留功能标题和阶段 Tabs,不显示记录 ID、内部名称或冗余副标题
- [x] KPI、Pie/Bar/Composed Chart 和打开态 ChartTooltip - [x] KPI、ECharts Pie/Bar、ARIA、canvas 非空和打开态 HTML Tooltip
- [x] 四视口无页面横向溢出,目标控制台 0 errors / 0 warnings - [x] 四视口无页面横向溢出,目标控制台 0 errors / 0 warnings
- [ ] 长文本列修复后的四视口浅/深色截图与 Tooltip 复验(本轮浏览器实例不可用) - [ ] 长文本列修复后的四视口浅/深色截图与 Tooltip 复验(本轮浏览器实例不可用)
## 交付检查 ## 交付检查
- [x] 全局弹窗选择字段复核:枚举、用户、引用选择字段统一跨满弹窗内容宽度;详情字段固定单列纵向排列
- [x] 文件字段以全行外层锁定字段顺序;材料中心 Autocomplete 等选择器通铺全行,仅本地 DropZone 上传交互区可按文件内容使用独立宽度
- [x] 单字段小弹窗、导入弹窗不强制制造空白双列;移动端所有弹窗字段回退单列
- [x] `npm run build` 成功 - [x] `npm run build` 成功
- [x] `git diff --check` 成功 - [x] `git diff --check` 成功
- [x] 鑫元 `audit_project.sh` 退出 0 - [x] 鑫元 `audit_project.sh` 退出 0
- [x] 权威主题 `cmp` 成功 - [x] 用户新主题与项目副本 `cmp` 成功
- [x] 旧组件、原生控件、旧颜色、页面私有视觉和路径扫描为零 - [x] 旧组件、原生控件、旧颜色、页面私有视觉和路径扫描为零
- [x] 全局路由加载态使用 HeroUI Spinner,页面直接导入图标包为零 - [x] 全局路由加载态使用 HeroUI Spinner,页面直接导入图标包为零
- [x] `pnpm ls` 与 HeroUI/React Aria 定向 `npm ls` 通过 - [x] `pnpm ls` 与 HeroUI/React Aria 定向 `npm ls` 通过
...@@ -105,7 +123,7 @@ ...@@ -105,7 +123,7 @@
- [x] 通知未读 0/1/99/100、站内外跳转、纯文本正文完成浏览器验收 - [x] 通知未读 0/1/99/100、站内外跳转、纯文本正文完成浏览器验收
- [x] 所有 editable file 字段支持材料中心和本地上传 - [x] 所有 editable file 字段支持材料中心和本地上传
- [x] 单值/多值、材料选择、URL 去重、取消、失败与重试事务完成浏览器验收 - [x] 单值/多值、材料选择、URL 去重、取消、失败与重试事务完成浏览器验收
- [x] 四视口浅深色完成;最终构建、审计脚本、定向扫描和干净控制台复验通过 - [x] 五项功能既有业务验收保持有效;当前主题固定为深色 Sidebar + 浅色内容
说明:扩展的全项目 `npm ls ... react react-dom` 会报告两个 Pro peer 包仍声明 React 18 说明:扩展的全项目 `npm ls ... react react-dom` 会报告两个 Pro peer 包仍声明 React 18
peer,以及 Umi 工具链锁定的 React 18 子图;应用与 HeroUI/React Aria 子图实际统一解析 peer,以及 Umi 工具链锁定的 React 18 子图;应用与 HeroUI/React Aria 子图实际统一解析
......
...@@ -2,9 +2,10 @@ ...@@ -2,9 +2,10 @@
## 权威边界 ## 权威边界
本应用以“问题整改”的实际运行结构作为唯一实现基准,并使用原生 HeroUI 3 / HeroUI Pro 本应用使用原生 HeroUI 3 / HeroUI Pro compound API,并以用户提供的
compound API。`src/design-system/theme.css` 必须与 `xinyuan-design-system` 权威副本逐字节 `/Users/liuchenlong/Desktop/theme.css` 作为新的唯一主题基准。`src/design-system/theme.css`
一致;`tokens.css` 只增加批准的 `--rc-*` 语义与布局 Token,不重定义 HeroUI 核心主题。 必须与该文件逐字节一致;`tokens.css` 只增加批准的 `--rc-*` 语义与布局 Token,不重定义
HeroUI 核心主题。
全局加载顺序为 Tailwind、HeroUI、HeroUI Pro、权威 `theme.css`、鑫元 `tokens.css`、共享 全局加载顺序为 Tailwind、HeroUI、HeroUI Pro、权威 `theme.css`、鑫元 `tokens.css`、共享
Shell 样式。页面 className 只承担布局;组件外观由官方 variant/size/state、权威主题和批准 Shell 样式。页面 className 只承担布局;组件外观由官方 variant/size/state、权威主题和批准
...@@ -17,38 +18,105 @@ Inter 与系统回退字体之间切换。 ...@@ -17,38 +18,105 @@ Inter 与系统回退字体之间切换。
## 身份与 Shell ## 身份与 Shell
- 统一应用名称为 `审计项目管理`;浅色使用红色鑫元 Logo,深色使用白色 Logo。 - 统一应用名称为 `审计项目管理`;登录与浅色内容面使用红色鑫元 Logo,固定深色 Sidebar
使用白色 Logo。
- 登录按“问题整改”保留真实验证码:首次加载 `/auth/captcha`,提交 `captcha/token`,失败后 - 登录按“问题整改”保留真实验证码:首次加载 `/auth/captcha`,提交 `captcha/token`,失败后
清空并刷新;账号、密码和验证码控件等高,验证码输入与图片使用批准的两列网格。 清空并刷新;账号、密码和验证码控件等高,验证码输入与图片使用批准的两列网格。
- HeroUI Pro Sidebar 为桌面 240px,`collapsible="offcanvas"`;移动端使用官方 80vw。 - HeroUI Pro Sidebar 为桌面 240px,`collapsible="offcanvas"`;移动端使用官方 80vw。
- HeroUI Pro Navbar 高 64px,直接使用 `Sidebar.Trigger`;Navbar 与页面共享桌面/平板/ - HeroUI Pro Navbar 高 64px,直接使用 `Sidebar.Trigger`;Navbar 与页面共享桌面/平板/
手机 48/32/16px 对称 gutter。 手机 48/32/16px 对称 gutter。
- Sidebar 用户入口必须使用 `Dropdown.Trigger`,退出调用既有 auth service 和 base-aware 路由。 - Sidebar 用户入口必须使用 `Dropdown.Trigger`,退出调用既有 auth service 和 base-aware 路由。
- 应用切换以 `model_analysis` 为唯一基准,使用 HeroUI Modal:46rem/42rem 几何、28px - 应用切换使用 HeroUI Modal 与 HeroUI Pro `ItemCardGroup + ItemCard`:46rem/42rem 几何、
inset、24px 官方关闭控件,移动一列、`sm` 起始终两列;每个 Logo 使用 36px 28px inset、24px 官方关闭控件,移动一列、`sm` 起始终两列;每个 Logo 使用 36px
`Surface tertiary + Avatar soft` identity well,只允许一个当前应用。 `Surface default + Avatar soft` identity well,当前应用使用 `outline + Chip`,只允许一个
当前状态。弹窗标题只保留“应用切换”,不重复显示说明副标题。Modal Dialog 使用 HeroUI
`bg-background` 与页面背景同层,应用项保留 Pro
`surface` 形成清晰的 L1/L2 层级;Header 使用通栏 HeroUI `SearchField secondary`,桌面和移动端
均铺满可用内容宽度,按已授权应用的名称、描述或标识实时过滤,隐藏空分组并同步
匹配数量,清空后恢复原分组顺序;Header 固定,Body 内使用 HeroUI `ScrollShadow` 独立滚动。
- 应用切换卡片统一使用鑫元红色 Logo `xy-logo-red.png`,所有应用(包括当前审计项目管理)保持
相同身份标识和 36px identity well;不按应用标题生成图标,不使用标题首字母或裸文本冒充 Logo。
### 表单分组导航
动态表单的纵向 HeroUI Tabs 按业务数据生成分组;隐式“默认分组”始终排在第一项,其余自定义分组
保持服务端/数据源顺序。排序只影响导航展示,不改变表单归属、字段值或保存协议。
- 应用项标题与描述必须形成真实层级;当接口描述与标题完全相同时只展示一次标题,禁止为了
填满卡片重复同一文案。普通项使用 Pro 默认 surface,不再使用整块灰色 secondary Card。
- 应用分组读取 `/info -> site.group`,按配置和账号可访问应用的真实顺序匹配,隐藏空组, - 应用分组读取 `/info -> site.group`,按配置和账号可访问应用的真实顺序匹配,隐藏空组,
显示真实数量,未引用应用进入“其他应用”,无分组配置时使用“全部应用”。 显示真实数量,未引用应用进入“其他应用”,无分组配置时使用“全部应用”。
- 根主题写入 `xinyuan_color_theme`,所有 Portal、Sidebar.Mobile、ActionBar 和图表 Tooltip - 应用根固定为 `.light` / `data-theme="light"`,不提供深浅模式切换,也不写入主题存储。
继承同一根主题。 Sidebar 独立使用 `--xinyuan-sidebar-*` 深色 Token;Portal、ActionBar 和右侧内容始终使用
新主题的浅色 Token。
- Sidebar 背景固定为 `oklch(16% 0.020 255)` 的近黑蓝灰,Hover 使用
`oklch(22% 0.026 255)`,Selected 使用低饱和的 `oklch(28% 0.016 255)` 且选中文字和图标反白;
边框、文字和 Focus 均取自同一组主题 Token,禁止页面自行替换为纯黑、深蓝或局部色板。
### 应用切换弹窗详细规范
应用切换弹窗是跨应用共享的固定组合,后续应用只替换自身应用常量、权限过滤和路由,不得改变
以下结构、层级和几何:
| 区域 | 规范 |
| --- | --- |
| 组合 | `Modal.Root` -> `Modal.Backdrop variant="blur"` -> `Modal.Container placement="center" scroll="inside"` -> `Modal.Dialog` |
| Dialog | 宽度 `min(46rem, calc(100vw - 2rem))`,最大高度 `min(42rem, calc(100dvh - 2rem))`,`p-7`(28px),HeroUI 官方圆角计算值 24px,语义背景 `bg-background` |
| 关闭 | 使用官方 `Modal.CloseTrigger`,24px 圆形控制,距右/上 28px,提供 `aria-label="关闭"` |
| Header | 不渲染重复的可见标题,Modal 通过 `aria-label="应用切换"` 保留无障碍名称;Header 固定,不参与正文滚动 |
| Search | 使用 HeroUI `SearchField secondary` 通栏铺满 Header 内容区;桌面端从左侧内容边界延伸到右侧,移动端同样占满整行 |
| Body | 与 Header 间 12px;Body `min-h-0`,内部只有一个 `ScrollShadow` 负责纵向滚动,避免页面和弹窗双滚动 |
| 分组 | 读取实时 `site.group`,保留配置顺序,隐藏空组;每组标题左对齐,右侧显示真实 `{count} 个应用` |
| 应用网格 | `ItemCardGroup layout="grid" columns={2}`;`sm` 以下单列,`sm` 及以上严格两列,横纵间距均 12px,不得扩展为桌面三列 |
| 应用卡片 | 使用 Pro `ItemCard`,根节点必须是真实链接;普通项 `variant="default"`,当前项仅一个 `variant="outline"` 并显示 `Chip color="accent" size="sm"` 的“当前” |
| Logo 区域 | 固定 36x36px;`Surface variant="default"` 与页面同层,内部 `Avatar variant="soft"`,圆角使用共享 control Token。所有应用统一使用 `xy-logo-red.png`,`object-contain` 和稳定内边距,禁止首字母、任意图标或裸文本 |
| 文案层级 | 标题 14px/500/20px 单行截断;描述使用 Pro `ItemCard.Description` 单行截断;描述与标题完全相同则省略描述,不重复占位 |
| 加载/空/错 | Loading 使用与卡片相同网格的 Pro `Skeleton`;无可访问应用使用 Pro `EmptyState`;接口失败保留现有账号应用并在 Body 顶部使用 HeroUI `Alert status="warning"` |
弹窗只拥有展示和导航职责:应用集合来自 `/application/info`、当前用户 `applications` 和当前应用
兜底,分组来自 `/info -> site.group`;不得在组件内硬编码应用成员、复制其他应用权限、修改会话或
保存协议。内部链接沿用目标应用 basePath,外部链接使用新窗口并带 `rel="noreferrer"`。Escape、
官方关闭控件和 Backdrop 关闭必须有效,当前项使用 `aria-current="page"`。应用卡片不显示重复业务
字段 Tooltip,不新增 hover 背景、边框、阴影或页面私有视觉 CSS。
## Surface 与页面节奏 ## Surface 与页面节奏
- L0 页面为 `background`;首层结构为 `Surface default` / L1。 - L0 页面为 `background`;首层结构为 `Surface default` / L1。
- Surface/Card/Modal 内字段一律使用 HeroUI `secondary` / L2;查看态 `form` 与引用记录属于 - 编辑态 Modal 内输入控件一律使用 HeroUI `secondary`;查看态普通标量字段直接落在 Dialog 的
L2 字段组中的真实静态结构对象,整体使用官方 `Surface tertiary` / L3,使其中的 DataGrid 页面背景上,不包裹大面积 Surface。动态表单等带统计、导航的完整业务板块使用
L2 表头和 L1 数据行保持可辨;普通标量字段不得逐项卡片化。 `rc-workspace-module`(24px 圆角、1px 灰色描边、透明背景)承载板块内容;关联记录短列表保持
透明摘要,直接使用 DataGrid 自有的灰色表头与白色数据区;数据为空时仅在数据区域使用一层紧凑的
`Surface secondary` 灰色背景承载 EmptyState。普通标量
字段不得逐项卡片化。无业务数据时使用所属层级内唯一的紧凑 `EmptyState`。
- 全应用空数据状态(暂无数据、暂无关联记录、暂无通知、暂无内容等)统一在空数据区域增加一层
HeroUI `Surface secondary` 灰色背景,容器使用统一 `rounded-2xl` 圆角(16px);真实数据出现时
不渲染该层。纯空状态文案使用官方 `EmptyState.Description` 的正常字重弱化层级,不使用默认半粗
`EmptyState.Title` 冒充业务数据。加载中、接口错误、权限拒绝和模板不可用属于独立状态,不得误套
空数据背景。
- 详情字段的空标量、空数组、空对象或格式化后无内容统一显示共享 `.rc-empty-field-text`(14px、
正常字重、`--rc-text-empty`)的“暂无内容”。该颜色比字段标题的 `--muted` 更弱,且不使用输入框、
边框或额外灰色块制造空值层级;有值时恢复正文 Token。
- 所有 HeroUI Modal 与 AlertDialog 的 Dialog 面板背景固定使用页面 `background`(`bg-background`),
与页面画布同层;弹窗内容如需层级区分,只能在内部使用官方 `Surface secondary/tertiary`,不得
使用默认白色 overlay、页面私有背景或额外卡片模拟弹窗底色。
- Modal、Popover、Dropdown、Tooltip 和 Calendar 使用官方 `overlay`,不属于 L3。 - Modal、Popover、Dropdown、Tooltip 和 Calendar 使用官方 `overlay`,不属于 L3。
- 页面首层模块、普通模块、紧密相关列表/KPI 分别使用 32/24/16px 间距;移动端为 24/16px。 - 页面首层模块、普通模块、紧密相关列表/KPI 分别使用 32/24/16px 间距;移动端为 24/16px。
- 列表标题和工具栏透明无框;搜索在左、列分组紧随其后、业务操作在右、主操作最右; - 列表标题和工具栏透明无框;搜索在左、列分组紧随其后、业务操作在右、主操作最右;
手机搜索独占第一行。DataGrid 是列表唯一数据 Surface。 手机搜索独占第一行。DataGrid 是列表唯一数据 Surface。
- 列表普通业务列不显示 Hover 文案 Tooltip,避免每个单元格悬浮都弹出重复内容;通过可读列宽、
单/双行截断和表格内部滚动解决长文案。操作列必须保留,操作列内的图标按钮可继续使用
Tooltip 和无障碍名称,因为它们说明的是操作而不是重复数据。
- 列表存在 View 分组时,搜索框后紧跟 HeroUI `ToggleButton` 分组控制,显示真实分组名称与
`{count} 列`;默认显示,选中表示隐藏。Hover/focus 预览整组表头高亮,点击只切换该组列并
保留滚动位置,显示时定位到首列;表头用“分组名(弱)+ 业务字段名(主)”双层结构,操作列
始终固定在最右。
## 组件合同 ## 组件合同
- 基础交互直接从 `@heroui/react` 导入;Sidebar、Navbar、DataGrid、DropZone、ActionBar、 - 基础交互直接从 `@heroui/react` 导入;Sidebar、Navbar、DataGrid、DropZone、ActionBar、
EmptyState、KPI 和 Chart 使用 `@heroui-pro/react` 或已验证子路径。 EmptyState 和 KPI 使用 `@heroui-pro/react` 或已验证子路径。
- 交互使用 `onPress`,compound 组件使用点号结构;图标统一从 `AppIcons` 输出 Ant Design - 交互使用 `onPress`,compound 组件使用点号结构;图标统一从 `AppIcons` 输出 Ant Design
Icons,图标按钮必须有 Tooltip 和可访问名称。 Icons,图标按钮通常必须有 Tooltip 和可访问名称。Sidebar 顶部应用切换按钮是已确认的例外:
只保留 `aria-label="切换应用"`,不显示 Hover Tooltip。
- 三个及以上结构化筛选使用 HeroUI Surface,前 3 个字段在主行,其余字段进入 - 三个及以上结构化筛选使用 HeroUI Surface,前 3 个字段在主行,其余字段进入
Disclosure;枚举/引用为 Autocomplete,日期为 DatePicker,附件为 Pro DropZone。 Disclosure;枚举/引用为 Autocomplete,日期为 DatePicker,附件为 Pro DropZone。
- 页面级、模块级与详情级 Tabs 均使用 HeroUI compound API、显式 `variant="primary"` 并按 - 页面级、模块级与详情级 Tabs 均使用 HeroUI compound API、显式 `variant="primary"` 并按
...@@ -58,28 +126,60 @@ Inter 与系统回退字体之间切换。 ...@@ -58,28 +126,60 @@ Inter 与系统回退字体之间切换。
- 同一操作组中辅助按钮保持原相对顺序,HeroUI `primary` 主操作始终位于最右侧;共享工具栏、 - 同一操作组中辅助按钮保持原相对顺序,HeroUI `primary` 主操作始终位于最右侧;共享工具栏、
批量 ActionBar 和详情配置化操作区必须统一执行该排序,页面不得用私有定位样式修正按钮顺序。 批量 ActionBar 和详情配置化操作区必须统一执行该排序,页面不得用私有定位样式修正按钮顺序。
- DataGrid 使用原生 `selectedKeys`、`onSelectionChange`、排序、固定列、Pagination、 - DataGrid 使用原生 `selectedKeys`、`onSelectionChange`、排序、固定列、Pagination、
ActionBar 和 EmptyState。固定每页 10 条,不显示页容量控件。 ActionBar 和 EmptyState。业务数据列表默认每页 20 条,并在 Pagination 左侧使用 HeroUI
Autocomplete 提供 20/50/100 条切换;页容量控件与左侧分页摘要(“共…条,第…页”)同组同一行,
紧接摘要右侧;该三项选择器关闭搜索框,页码控制继续靠右。切换回到第一页并保留筛选、排序和权限范围。
- 上述列表分页组合是跨应用固定基线:列表模块、分页摘要、页容量选择器和页码控制的层级、顺序、
对齐方式与尺寸不得由业务页面重新设计;后续应用只接入自己的数据和回调。
- DataGrid 表头必须显示 Subject 或 View 提供的业务标签;`metadata.*`、snake_case 和其它 - DataGrid 表头必须显示 Subject 或 View 提供的业务标签;`metadata.*`、snake_case 和其它
技术路径不得作为可见表头。已确认的跨对象兼容字段使用数据定义文档中的标签和类型。 技术路径不得作为可见表头。已确认的跨对象兼容字段使用数据定义文档中的标签和类型。
- 普通详情/表单使用 4xl HeroUI Modal;查看态整组字段仅有一个 secondary Surface,编辑态 - 项目年度(`metadata.project_year`)在列表、筛选选项和编辑输入中统一展示为纯数字年份,
移除该 Surface,取消恢复原值且不得请求接口。删除使用受控 AlertDialog。 不显示末尾“年”等单位后缀;筛选值、保存值和接口字段仍沿用原始业务协议,不在展示层改写。
- 普通详情/表单使用 4xl HeroUI Modal;详情字段按视图配置顺序始终单列纵向排列,普通字段直接位于
Dialog 页面背景;form 等完整业务板块使用透明底、1px 灰色描边的 `rc-workspace-module` 模块层,关联记录列表不再额外包裹模块 Surface,编辑态直接使用
secondary 控件,取消恢复原值且不得请求接口。详情 Modal Header 只保留功能标题和阶段 Tabs,
不展示记录 ID、内部名称或冗余小标题。删除使用受控 AlertDialog。
- Subject `form` 字段保持 `{ form, data, settings }` 存储协议,但业务 UI 只显示表单名称、 - Subject `form` 字段保持 `{ form, data, settings }` 存储协议,但业务 UI 只显示表单名称、
行数和动态字段 DataGrid。多值表单按 `settings.group` 使用 Tabs 分组,实例使用 Disclosure; 行数和动态字段 DataGrid。多值表单按 `settings.group` 使用 HeroUI `Tabs orientation="vertical"`
分组:左侧为纵向分组导航,右侧为当前分组内容;实例使用 Disclosure,新增分组位于左侧导航底部,
新增表单位于右侧当前分组标题行。该布局适用于检查底稿及同类多值表单,不复制业务数据结构;
禁止以 JSON、`pre`、Chip 摘要或内部 `_id/status/settings` 代替实际表单内容。 禁止以 JSON、`pre`、Chip 摘要或内部 `_id/status/settings` 代替实际表单内容。
- 动态表单模块顶部只保留“X 个表单,共 Y 行数据”主统计,删除“X 个分组 · 完整内容已展开”等
常驻辅助副文案;Disclosure 展开指示器与主统计文字在同一水平行内垂直居中对齐。
- 只读详情中的多值 `form` 与主分支编辑布局保持同一信息架构:顶部统计 Disclosure、左侧纵向
分组 Tabs、右侧当前分组标题和表单实例 Disclosure。查看态只隐藏新增、删除、模板/分组选择
和行编辑操作,不把内容改成跨分组的长列表;切换分组与展开表单仍可用。
表单无数据时只展示一个带 `Surface secondary` 灰色层的紧凑 HeroUI Pro EmptyState 模块,不得创建可横向滚动的空 DataGrid;
有真实行数据时才挂载带字段表头的 Pro DataGrid。
- 动态表单模板使用 HeroUI Autocomplete,数据行使用 4xl Modal 和既有动态字段校验;行级 - 动态表单模板使用 HeroUI Autocomplete,数据行使用 4xl Modal 和既有动态字段校验;行级
修改只进入详情草稿,必须由详情外层保存统一写回,不得增加即时更新请求。 修改只进入详情草稿,必须由详情外层保存统一写回,不得增加即时更新请求。
- 动态表单数据行弹窗及通用记录弹窗中的选择字段(枚举、用户、引用)统一占满弹窗内容宽度,
不允许同一弹窗出现半列短选择框与整行选择框混排;选择器仍直接使用 HeroUI Autocomplete
或其业务中立组合,字段标签、校验和保存协议不变。
- 动态表单查看态只呈现具有真实模板标识的实例;空实例和空分组仅属于编辑草稿。零行模板 - 动态表单查看态只呈现具有真实模板标识的实例;空实例和空分组仅属于编辑草稿。零行模板
使用 HeroUI Pro EmptyState,不渲染空 DataGrid。 使用单一紧凑 `EmptyState size="sm"` 模块,并以官方 `Surface secondary` 区分空状态层,不渲染可横向滚动的空 DataGrid;有真实行数据时
才挂载带字段表头的 HeroUI Pro DataGrid。
- 动态表单模板不可用时使用当前 Disclosure 内的 HeroUI Pro EmptyState,不使用带阴影的 - 动态表单模板不可用时使用当前 Disclosure 内的 HeroUI Pro EmptyState,不使用带阴影的
Alert 形成额外 Surface,不继续渲染 DataGrid,也不向业务用户暴露内部模板 ID 或英文错误。 Alert 形成额外 Surface,不继续渲染 DataGrid,也不向业务用户暴露内部模板 ID 或英文错误。
- 引用记录展示必须解包 `raw`,以标题为首列,仅保留有业务值且具有业务标签的字段;只有 - 引用记录展示必须解包 `raw`,以标题为首列,仅保留有业务值且具有业务标签的字段;只有
ID 而没有可展示明细时使用明确状态,不得以空 DataGrid 冒充内容。 ID 而没有可展示明细时使用明确状态,不得以空 DataGrid 冒充内容。
- 数字指标使用 Pro KPI;图表使用 Pro PieChart、BarChart、ComposedChart 与 ChartTooltip, - 详情和关联记录中的历史数组字符串必须先解析再展示:数组值使用业务标签以“、”连接;流程图/文档结构等结构化对象展示节点、连线、章节和内容块摘要;无法解析出名称的内部 ID 显示“已关联记录/已关联 N 项”,禁止把 JSON、ObjectId 或存储字段原文直接暴露给业务人员。编辑态 JSON 输入仍保持原数据协议。
系列色来自批准的图表 Token,不自制 Tooltip 或图表主题。 - 数字指标使用 Pro KPI;所有图表统一通过共享 `XinyuanEChart` 使用 `echarts` 与
`echarts-for-react`。系列色、坐标轴、网格、图例、Tooltip Surface/边框/阴影均读取
`theme.css` 的 `--xinyuan-chart-*` Token。当前主题固定提供 16 个鲜艳且稳定的分类色
(红、粉、橙、黄、绿、青、蓝、靛、紫、品红及中性灰),页面只提供业务数据和图表 option。图标始终使用
`AppIcons` 汇出的 Ant Design Icons,ECharts 不作为图标来源。
- 环形/饼图需要在图表外展示分类内容时,必须使用 ECharts 原生 `label` 与 `labelLine` 引导线,
使用与标签端点对齐的完整引导线连接对应标签,标签按“分类名称 / 真实数量”分两行显示;分类名称保持
13px/500,数量使用 14px/600;标签位置必须锚定到对应引导线终点,避免自动避让造成错位。
不得把引导线拉到卡片边缘或只保留
底部图例代替内容标注。
- 严谨业务界面不使用粉色、玫红或洋红危险色;HeroUI `danger` / `danger-soft` 必须继承鑫元红系
`--danger` Token。图表中的红色序列同样使用红系,不使用粉色替代危险语义。
## 禁止事项 ## 禁止事项
- Catalyst、Headless UI、Heroicons、ECharts、兼容旧 props 的 UI 包装层。 - Catalyst、Headless UI、Heroicons、活动页面 Recharts/HeroUI Pro Charts、兼容旧 props 的 UI 包装层。
- 可见原生 select/date/datetime/file、原生 table、window confirm/alert。 - 可见原生 select/date/datetime/file、原生 table、window confirm/alert。
- 页面自制背景、颜色、边框、圆角、阴影、Hover/Focus/Selected/Disabled 外观。 - 页面自制背景、颜色、边框、圆角、阴影、Hover/Focus/Selected/Disabled 外观。
- 手写表格、分页、弹窗、KPI、Tooltip、图表主题或 HeroUI 仿制组件。 - 手写表格、分页、弹窗、KPI、Tooltip、图表主题或 HeroUI 仿制组件。
...@@ -88,7 +188,7 @@ Inter 与系统回退字体之间切换。 ...@@ -88,7 +188,7 @@ Inter 与系统回退字体之间切换。
## 新功能验收 ## 新功能验收
每个新模块必须在目标与“问题整改”相同视口、相同主题下同时打开比较,并覆盖打开态 每个新模块必须在 1440x900、1280x720、768x1024、390x844 的固定 split shell 下验证,并覆盖打开态
Portal、错误/空/加载、键盘 focus 和移动响应式。合入前运行 `npm run build`、 Portal、错误/空/加载、键盘 focus 和移动响应式。合入前运行 `npm run build`、
`git diff --check`、鑫元审计脚本、旧组件/私有视觉/本机路径扫描、依赖树和 Pro 子路径检查; `git diff --check`、鑫元审计脚本、旧组件/私有视觉/本机路径扫描、依赖树和 Pro 子路径检查;
不得以构建成功替代浏览器验收。 不得以构建成功替代浏览器验收。
...@@ -102,10 +202,12 @@ Portal、错误/空/加载、键盘 focus 和移动响应式。合入前运行 ` ...@@ -102,10 +202,12 @@ Portal、错误/空/加载、键盘 focus 和移动响应式。合入前运行 `
- Grid `viewAuth` 只控制 Tab 可见性。详情字段权限优先使用当前完整 Detail View 的整套 - Grid `viewAuth` 只控制 Tab 可见性。详情字段权限优先使用当前完整 Detail View 的整套
`auth/disableColumns`;两项都没有有效配置或不存在实际 Detail 时,整体回退当前 Grid,禁止 `auth/disableColumns`;两项都没有有效配置或不存在实际 Detail 时,整体回退当前 Grid,禁止
合并或逐字段回退。编辑按钮只检查当前 Detail 展示字段。 合并或逐字段回退。编辑按钮只检查当前 Detail 展示字段。
- Navbar 通知使用 HeroUI `Badge.Anchor + Badge + Button + Tooltip`,位于主题按钮左侧;通知页面 - Navbar 通知使用 HeroUI `Badge.Anchor + Badge + Button + Tooltip`,作为最右侧全局动作;通知页面
使用原平台接口,正文按纯文本呈现,站内路径剥离 `/audit_project_manager_new` 后交给 Umi。 使用原平台接口,正文按纯文本呈现,站内路径剥离 `/audit_project_manager_new` 后交给 Umi。
- editable file 字段统一提供“材料中心 / 本地上传”。选择材料只写文件快照;本地文件先上传到 - editable file 字段统一提供“材料中心 / 本地上传”。选择材料只写文件快照;本地文件先上传到
详情草稿,保存时逐个创建 `am_material`,全部成功后才能更新业务记录,失败重试不得重复建档。 详情草稿,保存时逐个创建 `am_material`,全部成功后才能更新业务记录,失败重试不得重复建档。
- 材料来源切换使用内容宽度 HeroUI `Tabs` 的 `secondary` variant;选择材料使用 Overlay 内 - 材料来源切换使用内容宽度 HeroUI `Tabs` 的 `primary` variant;选择材料使用 Overlay 内
`secondary` Autocomplete,本地上传使用 HeroUI Pro `DropZone`。运行时 `multiple` 必须传给 `secondary` Autocomplete,选择器在所有弹窗和详情编辑布局中始终通铺全行,不得与半列选择框混排。
`DropZone.Input` 和拖放处理,不能只在业务层把返回值当数组。 本地上传使用 HeroUI Pro `DropZone`,文件字段以全行外层锁定字段顺序;仅 DropZone 这类上传交互区
可按文件内容采用独立宽度,材料选择、枚举、用户、引用等所有选择类控件仍必须全宽。移动端回退单列。
运行时 `multiple` 必须传给 `DropZone.Input` 和拖放处理,不能只在业务层把返回值当数组。
...@@ -7,8 +7,8 @@ ...@@ -7,8 +7,8 @@
- 分支:`codex/ui-ux-xinyuan-migration` - 分支:`codex/ui-ux-xinyuan-migration`
- 唯一实现基准:`problem_rectification` - 唯一实现基准:`problem_rectification`
- 应用基路径:`/audit_project_manager_new/` - 应用基路径:`/audit_project_manager_new/`
- 本轮启动命令:`npm run dev -- --port 8003`(Umi 检测端口占用后选择 `8007`) - 本轮启动地址:现有 Umi 开发服务 `http://127.0.0.1:8001/audit_project_manager_new/`
- 目标验收地址:`http://127.0.0.1:8007/audit_project_manager_new/` - 目标验收地址:`http://127.0.0.1:8001/audit_project_manager_new/`
- 基准验收地址:`http://127.0.0.1:8001/problem_rectification_new/` - 基准验收地址:`http://127.0.0.1:8001/problem_rectification_new/`
迁移前已启动未修改项目并记录业务基线。原登录页为蓝色营销式左右分栏,Shell、表格、 迁移前已启动未修改项目并记录业务基线。原登录页为蓝色营销式左右分栏,Shell、表格、
...@@ -55,7 +55,7 @@ CSS 当 JavaScript 直接 import,因此审计脚本的子路径 WARN 是检查 ...@@ -55,7 +55,7 @@ CSS 当 JavaScript 直接 import,因此审计脚本的子路径 WARN 是检查
`captcha` 与验证码 `token`,失败后清空输入并刷新验证码;账号/密码加密、登录 token、 `captcha` 与验证码 `token`,失败后清空输入并刷新验证码;账号/密码加密、登录 token、
redirect 与失败反馈保持原流程。详情查看/编辑/取消期间资源请求数保持 14; redirect 与失败反馈保持原流程。详情查看/编辑/取消期间资源请求数保持 14;
取消编辑不发请求。退出仍执行 `POST /api/auth/logout`(200),清除 `current_user` 与 取消编辑不发请求。退出仍执行 `POST /api/auth/logout`(200),清除 `current_user` 与
`auth_token`,保留主题并跳转到 base-aware `/audit_project_manager_new/login`。删除确认框 `auth_token`,并跳转到 base-aware `/audit_project_manager_new/login`。删除确认框
按取消后对话框关闭且不产生 DELETE 请求。 按取消后对话框关闭且不产生 DELETE 请求。
代表性列表/表单数据仅通过 Playwright 会话的浏览器 route fixture 注入,用于呈现已存在的 代表性列表/表单数据仅通过 Playwright 会话的浏览器 route fixture 注入,用于呈现已存在的
...@@ -64,16 +64,20 @@ redirect 与失败反馈保持原流程。详情查看/编辑/取消期间资源 ...@@ -64,16 +64,20 @@ redirect 与失败反馈保持原流程。详情查看/编辑/取消期间资源
## 最终迁移盘点 ## 最终迁移盘点
- 登录:居中 Card、鑫元 Logo、统一字段高度、24px 字段节奏、filled/autofill 与真实验证码加载/刷新/提交。 - 登录:居中 Card、鑫元 Logo、统一字段高度、24px 字段节奏、filled/autofill 与真实验证码加载/刷新/提交。
- Shell:HeroUI Pro Sidebar/Navbar、offcanvas 真折叠、应用切换、主题、用户菜单和真实退出。 - Shell:HeroUI Pro Sidebar/Navbar、offcanvas 真折叠、应用切换、固定深色 Sidebar、浅色内容、用户菜单和真实退出。
- 应用切换已按 `model_analysis` 基准同步 HeroUI Modal 结构和固定几何;保留既有 - 应用切换已按 `model_analysis` 基准同步 HeroUI Modal 结构和固定几何;保留既有
`/application/info`、用户应用合并、鉴权和跳转,新增消费已有 `/info -> site.group` 配置, `/application/info`、用户应用合并、鉴权和跳转,新增消费已有 `/info -> site.group` 配置,
按真实权限和后端顺序显示非空分组、数量及“其他应用/全部应用”回退。 按真实权限和后端顺序显示非空分组、数量及“其他应用/全部应用”回退;Header 使用通栏 HeroUI
`SearchField secondary`,按应用名称、描述或标识实时过滤,隐藏空分组、同步匹配数量,清空后
恢复原分组顺序,不改变授权和跳转。
- 应用切换身份统一使用鑫元红色 Logo `xy-logo-red.png`,包括当前审计项目管理;不再使用重复
或语义不一致的应用图标,也不以应用标题首字母作为卡片视觉身份。
- 页面:HeroUI Tabs、Surface、Autocomplete、DatePicker、Disclosure、DropZone。 - 页面:HeroUI Tabs、Surface、Autocomplete、DatePicker、Disclosure、DropZone。
- 列表:HeroUI Pro DataGrid 原生选择/排序/固定列/内部横向滚动、Pagination、ActionBar、 - 列表:HeroUI Pro DataGrid 原生选择/排序/固定列/内部横向滚动、Pagination、ActionBar、
EmptyState;固定每页 10 条,无页容量控件。 EmptyState;项目业务列表默认每页 20 条,Pagination 左侧提供 20/50/100 条切换。
- 弹窗:HeroUI Modal/AlertDialog,4xl 详情/表单,查看态单一 secondary Surface,编辑态移除。 - 弹窗:HeroUI Modal/AlertDialog,4xl 详情/表单,详情字段按配置顺序单列纵向排列;查看态不创建大面积灰色 Surface。
- 看板:HeroUI Pro KPI、PieChart、BarChart、ComposedChart 与 ChartTooltip。 - 看板:HeroUI Pro KPI;Pie/Bar 统一通过 `XinyuanEChart` 使用 ECharts。
- 清理:Catalyst、Headless UI、Heroicons、ECharts、旧 demo 页面/服务和死兼容实现均已移除。 - 清理:Catalyst、Headless UI、Heroicons、活动 Recharts/HeroUI Pro Charts、旧 demo 页面/服务和死兼容实现均已移除。
## 字体运行时稳定性 ## 字体运行时稳定性
...@@ -93,7 +97,11 @@ redirect 与失败反馈保持原流程。详情查看/编辑/取消期间资源 ...@@ -93,7 +97,11 @@ redirect 与失败反馈保持原流程。详情查看/编辑/取消期间资源
`umi.css` 完成一次正常热替换后服务无后续编译循环。间隔 3 秒的两张 `umi.css` 完成一次正常热替换后服务无后续编译循环。间隔 3 秒的两张
1440x900 登录页截图 SHA-256 一致,控制台 0 错误 / 0 警告。 1440x900 登录页截图 SHA-256 一致,控制台 0 错误 / 0 警告。
## 浏览器对照验收 ## 历史浏览器对照验收
以下浅/深色记录只用于追溯 2026-08-28 前的迁移过程;新的固定 split shell 规范已经取代
旧主题切换,不得据此恢复暗色内容区或 Navbar 主题按钮。当前验收事实见本文
“2026-08-28 新主题与 ECharts 迁移”。
目标与基准在 `1440x900`、`1280x720`、`768x1024`、`390x844` 的浅色与深色模式逐项 目标与基准在 `1440x900`、`1280x720`、`768x1024`、`390x844` 的浅色与深色模式逐项
同时打开比较。四视口均无页面级横向溢出; 同时打开比较。四视口均无页面级横向溢出;
...@@ -109,8 +117,9 @@ redirect 与失败反馈保持原流程。详情查看/编辑/取消期间资源 ...@@ -109,8 +117,9 @@ redirect 与失败反馈保持原流程。详情查看/编辑/取消期间资源
Sheet/Dialog/Sidebar 内容同宽且无空白条;桌面折叠后为零宽。 Sheet/Dialog/Sidebar 内容同宽且无空白条;桌面折叠后为零宽。
- 应用切换:移动端 358px 单列,桌面 736px 两列,只有一个“当前”;本次动态分组同步后的 - 应用切换:移动端 358px 单列,桌面 736px 两列,只有一个“当前”;本次动态分组同步后的
四视口浅/深色打开态将重新验收,不复用旧平铺版本结论。 四视口浅/深色打开态将重新验收,不复用旧平铺版本结论。
- Modal:移动端 4xl 为 `358x812`,桌面 4xl 为 1088px;查看态 1 个 secondary Surface、 - Modal:移动端 4xl 为 `358x812`,桌面 4xl 为 1088px;详情字段按视图配置顺序单列纵向排列,
0 个输入,编辑态 0 个组 Surface、7 个官方控件,取消后恢复原查看态。 查看态普通字段与嵌套 form/引用内容直接使用 Dialog 页面背景,不创建大面积灰色 Surface,
编辑态使用官方控件。
- DataGrid:滚动层 `clientWidth=1096`、`scrollWidth=1624`、最大滚动 528px;首列与操作列 - DataGrid:滚动层 `clientWidth=1096`、`scrollWidth=1624`、最大滚动 528px;首列与操作列
在滚动首尾坐标不变。第 2 页显示 2 条,首条为 `2026年度审计项目11`。 在滚动首尾坐标不变。第 2 页显示 2 条,首条为 `2026年度审计项目11`。
- DropZone:`464x198`;唯一原生 file input 是官方内部 `1x1` 隐藏输入。 - DropZone:`464x198`;唯一原生 file input 是官方内部 `1x1` 隐藏输入。
...@@ -136,6 +145,19 @@ redirect 与失败反馈保持原流程。详情查看/编辑/取消期间资源 ...@@ -136,6 +145,19 @@ redirect 与失败反馈保持原流程。详情查看/编辑/取消期间资源
`Sheet.Dialog` 上提供“审计项目管理导航”可访问名称;390x844 下 Sidebar 仍为 312x844, `Sheet.Dialog` 上提供“审计项目管理导航”可访问名称;390x844 下 Sidebar 仍为 312x844,
开关状态、遮罩、位置、内容和应用切换入口未变,控制台复验为 0 error / 0 warning。 开关状态、遮罩、位置、内容和应用切换入口未变,控制台复验为 0 error / 0 warning。
### 2026-08-28 应用切换视觉层级优化
- 保留 `/application/info`、`user.applications`、`/info -> site.group`、当前应用识别和站内外
跳转逻辑,只替换应用入口的展示组合。
- 普通灰色 `Card secondary` 改为 HeroUI Pro `ItemCardGroup + ItemCard`;分组标题、真实数量、
Logo identity well、标题、有效描述和当前 Chip 分别使用官方 compound slot。
- Logo identity well 使用页面同层 `Surface default`,避免 Logo 后出现突兀灰色块。
- 接口描述与标题完全相同时不重复渲染;当前应用使用 `outline + accent Chip`,其它应用使用
Pro 默认 surface。Modal Header 保持固定,Body 内由 HeroUI `ScrollShadow` 独立滚动并保留
可见滚动条,避免长列表在底部硬裁切。Modal Dialog 使用页面 `background`,与白色应用
`surface` 项形成明确层级,不再让弹窗背景和卡片背景混为一体;移除重复可见标题和冗余说明副标题,
保留 Modal 无障碍名称,搜索框通栏铺满 Header 内容区。
### 2026-08-21 筛选模块复核 ### 2026-08-21 筛选模块复核
- 活动路由的筛选入口统一收敛到 `RecordWorkbench`:关键词使用列表工具栏的 HeroUI - 活动路由的筛选入口统一收敛到 `RecordWorkbench`:关键词使用列表工具栏的 HeroUI
...@@ -216,16 +238,24 @@ HeroUI Context。`@heroui/styles@3.2.4` 的两个 pnpm peer 虚拟目录仅分 ...@@ -216,16 +238,24 @@ HeroUI Context。`@heroui/styles@3.2.4` 的两个 pnpm peer 虚拟目录仅分
- 查看态曾把 `{ form: null, data: [], settings }` 当成真实表单,导致空分组、`未选择表单` - 查看态曾把 `{ form: null, data: [], settings }` 当成真实表单,导致空分组、`未选择表单`
和错误的表单数量同时出现。现仅统计和展示具有真实表单标识的实例;编辑态仍保留空实例, 和错误的表单数量同时出现。现仅统计和展示具有真实表单标识的实例;编辑态仍保留空实例,
因此新增表单与分组草稿协议不变。 因此新增表单与分组草稿协议不变。
- 已选择模板但数据为零行时,直接使用 HeroUI Pro `EmptyState`,不再创建只有表头的 - 已选择模板但数据为零行时使用单一紧凑 Pro `EmptyState` 模块,不创建可横向滚动的空
DataGrid。没有任何有效模板的字段直接显示 `暂无内容`。 `DataGrid`;没有任何有效模板的字段直接显示 `暂无内容`。
- 关联记录统一解包 `record.raw || record`,始终以记录标题为首列,只显示至少存在一个非空 - 关联记录统一解包 `record.raw || record`,始终以记录标题为首列,只显示至少存在一个非空
值的业务列,并隐藏没有中文业务标签的技术字段。只有 ID、无法取得明细时显示 值的业务列,并隐藏没有中文业务标签的技术字段。只有 ID、无法取得明细时显示
`已关联记录,但关联明细暂不可用`,不再绘制空表。 `已关联记录,但关联明细暂不可用`,不再绘制空表。
- 查看态标题只在 Modal Header 呈现;正文排除 `title`,Header 对 `无标题`、`(无标题)`、 - 详情 Modal Header 只保留功能标题和阶段 Tabs,不再显示记录 ID、内部名称或其它冗余副标题;
`未命名记录` 等占位值不显示副标题。编辑态继续保留标题字段。 业务项目名称仍按视图字段在正文中展示,编辑态继续保留标题字段。
- 详情中的动态表单等完整业务板块统一使用官方 `Surface variant="tertiary"` 灰色模块层(24px 圆角)
承载板块内容;相关法规/制度/流程图等关联记录短列表保持透明摘要,直接使用 DataGrid 自有的
灰色表头和白色数据区,不额外叠加灰色背景;暂无数据时在数据区域使用共享 `EmptyStateSurface` /
`Surface secondary` 灰色层,统一 `rounded-2xl`(16px)圆角和弱化的 `EmptyState.Description` 文案,
真实数据时不渲染该空态层。详情字段“暂无内容”使用 `.rc-empty-field-text`,与字段标题保持明确
层级差异。
- 动态表单统计仅显示“X 个表单,共 Y 行数据”,移除分组展开状态辅助文案;顶部 Disclosure 图标
与主统计同行并垂直居中,避免图标落到副文案行。
- Playwright 夹具覆盖空实例、两个空分组、有效零行表单、`raw` 包装记录、普通记录和纯 ID。 - Playwright 夹具覆盖空实例、两个空分组、有效零行表单、`raw` 包装记录、普通记录和纯 ID。
1440x900 浅色与 390x844 深色实测无页面横向溢出,空分组和技术字段名为零,零行表单 1440x900 浅色与 390x844 深色实测无页面横向溢出,空分组和技术字段名为零,零行表单
不存在 DataGrid,控制台 0 errors / 0 warnings。 保留 DataGrid 表头并在内部显示紧凑空状态,控制台 0 errors / 0 warnings。
### 2026-08-24 全局 Tabs 样式统一 ### 2026-08-24 全局 Tabs 样式统一
...@@ -239,18 +269,73 @@ HeroUI Context。`@heroui/styles@3.2.4` 的两个 pnpm peer 虚拟目录仅分 ...@@ -239,18 +269,73 @@ HeroUI Context。`@heroui/styles@3.2.4` 的两个 pnpm peer 虚拟目录仅分
`Modal.Body` 保持官方 `scroll="inside"` 的唯一纵向滚动边界;未添加 Tabs 背景、分隔线、 `Modal.Body` 保持官方 `scroll="inside"` 的唯一纵向滚动边界;未添加 Tabs 背景、分隔线、
阴影或 sticky 私有样式。滚动态浏览器回归待补验。 阴影或 sticky 私有样式。滚动态浏览器回归待补验。
### 2026-08-21 驾驶舱阶段柱图密度修复 ### 2026-08-28 新主题与 ECharts 迁移
- “开展中项目进度”继续使用 HeroUI Pro `BarChart.Bar`,通过官方 `maxBarSize={28}` 限制 - `/Users/liuchenlong/Desktop/theme.css` 成为新的唯一主题基准,并与项目
宽屏柱宽,不增加 Recharts 私有选择器或图表外观 CSS,数据、坐标轴和 Tooltip 协议不变。 `src/design-system/theme.css` 保持字节一致。应用根固定为 `.light`,移除本地存储主题状态、
- 浏览器实测 1440px 下三根柱均不超过 28px;390px 下随分类带宽自然收缩,保持等距 Navbar 主题按钮及其 Sun/Moon 图标。
居中且页面横向溢出为 0。浅色、深色及 Hover ChartTooltip 均正常,控制台 0 error/warning。 - Sidebar 使用命名隔离的 `--xinyuan-sidebar-*`:背景计算值为
`oklch(0.16 0.020 255)`,边框 `oklch(0.26 0.022 255)`,Selected 为
### 2026-08-21 详情结构 Surface 与表单分组 Tabs 修复 `oklch(0.28 0.016 255)` 且选中文字/图标反白,内容画布继续为
`oklch(0.9702 0 29.2)`。HeroUI Pro 内部同名变量不会再覆盖该主题。
- 顶部应用切换按钮保持 36px Ant Design 应用网格图标;Hover/按下/展开使用 Sidebar
Selected Token 并反白。该按钮按确认方案不显示 Hover Tooltip,仅提供
`aria-label="切换应用"`。
- 驾驶舱状态饼图和阶段柱图已从 HeroUI Pro/Recharts 切换到 ECharts 6.1.0 与
echarts-for-react 3.0.6。共享 `XinyuanEChart` 统一 ARIA、主题系列色、图例、坐标轴、网格和
body HTML Tooltip;柱宽仍限制为 28px,API、统计口径、项目详情入口和数据结构未改。
- 图表分类色已扩展为 `--xinyuan-chart-1` 至 `--xinyuan-chart-16`,使用鲜艳固定色值;
通过稳定业务 key 分配颜色,禁止页面写入随机或局部色值。
- 状态环图已启用 ECharts 外部分类标签和 `labelLine` 引导线,标签显示真实状态与数量,
使用 `labelLayout` 根据 `labelLinePoints` 将标签锚定到各自折线终点,避免文案与折线错位。
- 删除、拒绝和危险操作的 `danger-soft` 背景已从默认粉色修正为鑫元红系,活动页面不再出现
粉色危险操作视觉。
- 列表普通业务单元格已关闭 Hover 文案 Tooltip;操作列仍保留查看、编辑、删除等真实操作按钮
及其操作说明 Tooltip,未删除操作入口。
- 列表列分组已按问题整改基准同步:搜索后显示分组 ToggleButton、真实列数、分组 Hover/focus
表头预览、显示/隐藏切换和滚动保留;分组表头显示弱化分组名与主业务字段名,操作列始终保留。
- 1440x900、1280x720、768x1024 与 390x844 实测无页面横向溢出;对应 canvas 宽度为
508、428、640、262px,高度均为 320px。两张 canvas 均为非空像素。Hover Tooltip 为
固定定位白色 Overlay、`#dedede` 边框及主题阴影,内容为真实
`计划 / 项目数量 / 3 个`。移动 Sidebar 为 `312x844`,固定深色与桌面一致。
### 2026-08-28 分页页容量选择器
- 项目列表在 Pagination 同行左侧摘要右侧渲染 HeroUI Autocomplete;1440x900 打开态仅显示真实
选项 `20 条/页`、`50 条/页`、`100 条/页`,不显示搜索框,也没有原生 `<select>` 或页面私有分页控件。
- 选择 `50`、`100` 后列表立即回到第 1 页,当前页切片边界更新;在已有一条选中记录时切换
页容量会清空选中状态和批量 ActionBar,筛选、排序、权限和请求协议保持不变。
- 390x844 下选择器仍位于分页左侧并保持可操作,页面无横向溢出;未配置页容量选项的通知页
继续使用紧凑 Pagination,不显示选择器。以上打开态和切换操作控制台均为 0 error / 0 warning。
### 2026-08-28 弹窗背景统一
- 所有共享 `Modal.Dialog` 与 `AlertDialog.Dialog` 统一显式使用 `bg-background`,与当前页面
`background` 同层;应用切换弹窗、详情弹窗、通用表单和删除确认不再依赖默认白色 overlay。
- 弹窗内部需要区分内容时,继续使用 HeroUI `Surface secondary/tertiary`,不通过页面私有背景、
阴影或额外 Card 改变 Dialog 底色。接口、表单校验、保存回调和关闭行为未改变。
### 2026-08-28 检查底稿分组布局与弹窗选择字段统一
- 多值 `form` 字段(包括检查底稿)使用 HeroUI `Tabs orientation="vertical"`:左侧纵向分组
导航,右侧显示当前分组标题、表单数量和实例 Disclosure;新增分组固定在左侧导航底部,新增
表单固定在右侧当前分组标题行。切换只改变展示分组,不改变 `{ form, data, settings }` 数据协议。
- 动态表单行弹窗与通用记录弹窗中的枚举、用户、引用字段统一跨满弹窗内容宽度;移除选择字段的
半列宽度例外,HeroUI Autocomplete、校验和保存回调保持不变。
- 审计材料中心文件字段的材料选择 Autocomplete 同样通铺弹窗内容宽度;本地上传 DropZone 仍保留
文件交互所需的独立宽度例外,选择器不再受文件字段半列外层约束。
- 详情关联列表展示层补齐历史数组字符串、流程图/文档结构 JSON 和内部 ID 的业务化格式化,分别
输出业务标签、结构摘要和“已关联”状态,不改变接口字段和保存协议。
- 只读动态表单按主分支恢复与编辑态一致的工作台层级:顶部统计 Disclosure、左侧纵向分组 Tabs、
右侧当前分组标题和表单实例 Disclosure;查看态隐藏全部维护按钮和选择控件。零行表单只显示
一个紧凑“暂无表单数据”模块,不出现空表格左右滚动;有真实数据时使用 DataGrid。
### 2026-08-21 详情结构 Surface 与表单分组 Tabs 修复(历史记录,已由 2026-08-28 单列无灰底规则取代)
- 根因是 Modal 查看态完整字段组与 DataGrid 表头同时解析为 `surface-secondary` / L2,动态 - 根因是 Modal 查看态完整字段组与 DataGrid 表头同时解析为 `surface-secondary` / L2,动态
表单和引用记录作为嵌套结构却没有 L3 边界,导致表头与父背景融合。现保留完整字段组 L2, 表单和引用记录作为嵌套结构却没有 L3 边界,导致表头与父背景融合。该历史方案已被
仅将查看态 `form` 和引用字段整体放入官方 `Surface tertiary` / L3;DataGrid 外观未修改。 2026-08-28 规则取代:详情字段按配置顺序单列排列,普通字段与嵌套内容均直接使用 Dialog
页面背景,不再包裹整组或字段级灰色 Surface;DataGrid 外观未修改。
- 检查底稿分组使用 HeroUI compound Tabs,并补齐内容宽度、 - 检查底稿分组使用 HeroUI compound Tabs,并补齐内容宽度、
`whitespace-nowrap` 与 `Tabs.ListContainer` 滚动合同,不新增 `whitespace-nowrap` 与 `Tabs.ListContainer` 滚动合同,不新增
`.tabs__*` 或表头私有 CSS。 `.tabs__*` 或表头私有 CSS。
...@@ -313,10 +398,8 @@ HeroUI Context。`@heroui/styles@3.2.4` 的两个 pnpm peer 虚拟目录仅分 ...@@ -313,10 +398,8 @@ HeroUI Context。`@heroui/styles@3.2.4` 的两个 pnpm peer 虚拟目录仅分
24px 圆角、L1 Overlay;DataGrid 表头保持 L2,背景层级可区分。 24px 圆角、L1 Overlay;DataGrid 表头保持 L2,背景层级可区分。
- 页面数据集、动态表单分组和详情阶段统一使用 HeroUI `primary` 胶囊 Tabs;应用未定义 - 页面数据集、动态表单分组和详情阶段统一使用 HeroUI `primary` 胶囊 Tabs;应用未定义
`.tabs__*`、`.modal__*` 或 DataGrid 页面私有外观。 `.tabs__*`、`.modal__*` 或 DataGrid 页面私有外观。
- 全局复核修正详情查看态 L2 Surface 的共享 16px 圆角。390x844 深色计算样式为:Modal - 全局复核修正详情查看态字段间距。当前 Modal Overlay 使用页面 `background`,字段按配置顺序
Overlay `oklch(0.2103 0.009 27.03)` / 24px,详情 Surface 与 DataGrid 表头均为 单列纵向排列,普通字段和嵌套内容均无额外灰色 Surface,未覆写 HeroUI slot。
`oklch(0.257 0.0067 27.03)`,但详情 Surface 为 16px 圆角;Modal 本体与列表表头保持明确
色阶差。圆角仅由共享 `rc-detail-view-surface` 映射权威 `--radius`,未覆写 HeroUI slot。
- Auth/Site 路由加载态删除 `text-slate-500`,改用 HeroUI Spinner;详情全屏图标统一收口到 - Auth/Site 路由加载态删除 `text-slate-500`,改用 HeroUI Spinner;详情全屏图标统一收口到
`AppIcons`,页面直接导入图标包归零。 `AppIcons`,页面直接导入图标包归零。
...@@ -453,8 +536,8 @@ rg -n 'hp_[A-Za-z0-9]+' . --glob '!node_modules/**' --glob '!dist/**' --glob '!. ...@@ -453,8 +536,8 @@ rg -n 'hp_[A-Za-z0-9]+' . --glob '!node_modules/**' --glob '!dist/**' --glob '!.
`subjectsDefinition.md` 显示为日期字段“项目完成时间”。未知 `metadata.*` 和 snake_case `subjectsDefinition.md` 显示为日期字段“项目完成时间”。未知 `metadata.*` 和 snake_case
标识不再原样进入表头;列路径、取值、排序、筛选和服务端 View 配置均未修改。 标识不再原样进入表头;列路径、取值、排序、筛选和服务端 View 配置均未修改。
- 同步扫描动态表单和关联记录回退列:关联记录已过滤技术字段;动态表单在缺少字段定义时 - 同步扫描动态表单和关联记录回退列:关联记录已过滤技术字段;动态表单在缺少字段定义时
改为仅保留标题列,动态表单改为 Pro EmptyState,不再从行数据 key 推断英文表头。正常 仅保留标题列,不从行数据 key 推断英文表头。已选择模板但零行时仍保留真实字段表头的
字段定义和已有行数据不改写。 Pro DataGrid,并在列表内部使用紧凑 Pro EmptyState;正常字段定义和已有行数据不改写。
- 共享文本 Tooltip 触发器改为块级有界容器,统一 `min-width: 0`、`max-width: 100%` 和 - 共享文本 Tooltip 触发器改为块级有界容器,统一 `min-width: 0`、`max-width: 100%` 和
`overflow: hidden`,长文案不再穿透当前单元格遮挡相邻列。 `overflow: hidden`,长文案不再穿透当前单元格遮挡相邻列。
- `long_text`、`rich_text` 及需求明细、材料清单、工作内容、备注、说明等业务长文本统一使用 - `long_text`、`rich_text` 及需求明细、材料清单、工作内容、备注、说明等业务长文本统一使用
......
...@@ -29,6 +29,8 @@ ...@@ -29,6 +29,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",
......
...@@ -71,6 +71,12 @@ importers: ...@@ -71,6 +71,12 @@ importers:
dayjs: dayjs:
specifier: ^1.11.20 specifier: ^1.11.20
version: 1.11.21 version: 1.11.21
echarts:
specifier: ^6.1.0
version: 6.1.0
echarts-for-react:
specifier: ^3.0.6
version: 3.0.6(echarts@6.1.0)(react@19.2.8)
embla-carousel: embla-carousel:
specifier: ^8.6.0 specifier: ^8.6.0
version: 8.6.0 version: 8.6.0
...@@ -3668,6 +3674,15 @@ packages: ...@@ -3668,6 +3674,15 @@ packages:
eastasianwidth@0.2.0: eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
echarts-for-react@3.0.6:
resolution: {integrity: sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==}
peerDependencies:
echarts: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0
react: ^15.0.0 || >=16.0.0
echarts@6.1.0:
resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==}
ee-first@1.1.1: ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
...@@ -7164,6 +7179,9 @@ packages: ...@@ -7164,6 +7179,9 @@ packages:
sisteransi@1.0.5: sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
size-sensor@1.0.3:
resolution: {integrity: sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==}
slash@3.0.0: slash@3.0.0:
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
engines: {node: '>=8'} engines: {node: '>=8'}
...@@ -7565,6 +7583,9 @@ packages: ...@@ -7565,6 +7583,9 @@ packages:
tslib@1.14.1: tslib@1.14.1:
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
tslib@2.3.0:
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
tslib@2.8.1: tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
...@@ -7980,6 +8001,9 @@ packages: ...@@ -7980,6 +8001,9 @@ packages:
resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==}
engines: {node: '>=12.20'} engines: {node: '>=12.20'}
zrender@6.1.0:
resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==}
zwitch@2.0.4: zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
...@@ -12271,6 +12295,18 @@ snapshots: ...@@ -12271,6 +12295,18 @@ snapshots:
eastasianwidth@0.2.0: {} eastasianwidth@0.2.0: {}
echarts-for-react@3.0.6(echarts@6.1.0)(react@19.2.8):
dependencies:
echarts: 6.1.0
fast-deep-equal: 3.1.3
react: 19.2.8
size-sensor: 1.0.3
echarts@6.1.0:
dependencies:
tslib: 2.3.0
zrender: 6.1.0
ee-first@1.1.1: {} ee-first@1.1.1: {}
electron-to-chromium@1.5.399: {} electron-to-chromium@1.5.399: {}
...@@ -16602,6 +16638,8 @@ snapshots: ...@@ -16602,6 +16638,8 @@ snapshots:
sisteransi@1.0.5: {} sisteransi@1.0.5: {}
size-sensor@1.0.3: {}
slash@3.0.0: {} slash@3.0.0: {}
slash@4.0.0: {} slash@4.0.0: {}
...@@ -17079,6 +17117,8 @@ snapshots: ...@@ -17079,6 +17117,8 @@ snapshots:
tslib@1.14.1: {} tslib@1.14.1: {}
tslib@2.3.0: {}
tslib@2.8.1: {} tslib@2.8.1: {}
tsutils@3.21.0(typescript@5.9.3): tsutils@3.21.0(typescript@5.9.3):
...@@ -17574,4 +17614,8 @@ snapshots: ...@@ -17574,4 +17614,8 @@ snapshots:
yocto-queue@1.2.2: {} yocto-queue@1.2.2: {}
zrender@6.1.0:
dependencies:
tslib: 2.3.0
zwitch@2.0.4: {} zwitch@2.0.4: {}
...@@ -3,16 +3,13 @@ import { defineApp } from 'umi'; ...@@ -3,16 +3,13 @@ import { defineApp } from 'umi';
import { import {
APPLICATION_BASE_PATH, APPLICATION_BASE_PATH,
APPLICATION_NAME, APPLICATION_NAME,
THEME_STORAGE_KEY,
} from '@/config/application'; } from '@/config/application';
if (typeof document !== 'undefined') { if (typeof document !== 'undefined') {
const storedTheme = window.localStorage?.getItem?.(THEME_STORAGE_KEY); document.documentElement.classList.remove('dark');
const theme = storedTheme === 'dark' ? 'dark' : 'light'; document.documentElement.classList.add('light');
document.documentElement.classList.toggle('dark', theme === 'dark'); document.documentElement.dataset.theme = 'light';
document.documentElement.classList.toggle('light', theme === 'light'); document.documentElement.style.colorScheme = 'light';
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
} }
const LEGACY_LOGIN_PATH = '/login'; const LEGACY_LOGIN_PATH = '/login';
......
...@@ -28,7 +28,6 @@ import LockOutlined from '@ant-design/icons/LockOutlined'; ...@@ -28,7 +28,6 @@ import LockOutlined from '@ant-design/icons/LockOutlined';
import InboxOutlined from '@ant-design/icons/InboxOutlined'; import InboxOutlined from '@ant-design/icons/InboxOutlined';
import InfoCircleOutlined from '@ant-design/icons/InfoCircleOutlined'; import InfoCircleOutlined from '@ant-design/icons/InfoCircleOutlined';
import MoreOutlined from '@ant-design/icons/MoreOutlined'; import MoreOutlined from '@ant-design/icons/MoreOutlined';
import MoonOutlined from '@ant-design/icons/MoonOutlined';
import OrderedListOutlined from '@ant-design/icons/OrderedListOutlined'; import OrderedListOutlined from '@ant-design/icons/OrderedListOutlined';
import PieChartOutlined from '@ant-design/icons/PieChartOutlined'; import PieChartOutlined from '@ant-design/icons/PieChartOutlined';
import PlusOutlined from '@ant-design/icons/PlusOutlined'; import PlusOutlined from '@ant-design/icons/PlusOutlined';
...@@ -38,7 +37,6 @@ import RightOutlined from '@ant-design/icons/RightOutlined'; ...@@ -38,7 +37,6 @@ import RightOutlined from '@ant-design/icons/RightOutlined';
import RiseOutlined from '@ant-design/icons/RiseOutlined'; import RiseOutlined from '@ant-design/icons/RiseOutlined';
import SearchOutlined from '@ant-design/icons/SearchOutlined'; import SearchOutlined from '@ant-design/icons/SearchOutlined';
import SendOutlined from '@ant-design/icons/SendOutlined'; import SendOutlined from '@ant-design/icons/SendOutlined';
import SunOutlined from '@ant-design/icons/SunOutlined';
import TeamOutlined from '@ant-design/icons/TeamOutlined'; import TeamOutlined from '@ant-design/icons/TeamOutlined';
import UserOutlined from '@ant-design/icons/UserOutlined'; import UserOutlined from '@ant-design/icons/UserOutlined';
import UploadOutlined from '@ant-design/icons/UploadOutlined'; import UploadOutlined from '@ant-design/icons/UploadOutlined';
...@@ -117,11 +115,9 @@ export function SidebarPanel({ size = 20, style, ...props }) { ...@@ -117,11 +115,9 @@ export function SidebarPanel({ size = 20, style, ...props }) {
); );
} }
export const More = withSize(MoreOutlined); export const More = withSize(MoreOutlined);
export const Moon = withSize(MoonOutlined);
export const Project = withSize(ProjectOutlined); export const Project = withSize(ProjectOutlined);
export const Search = withSize(SearchOutlined); export const Search = withSize(SearchOutlined);
export const Send = withSize(SendOutlined); export const Send = withSize(SendOutlined);
export const Sun = withSize(SunOutlined);
export const Team = withSize(TeamOutlined); export const Team = withSize(TeamOutlined);
export const User = withSize(UserOutlined); export const User = withSize(UserOutlined);
export const Upload = withSize(UploadOutlined); export const Upload = withSize(UploadOutlined);
......
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,
notificationCount, notificationCount,
...@@ -27,25 +13,9 @@ export default function AppShell({ ...@@ -27,25 +13,9 @@ export default function AppShell({
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'; 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, Dropdown, Label } from '@heroui/react';
import { AppGrid, LogOut } from './AppIcons'; import { AppGrid, LogOut } from './AppIcons';
function getUserDisplay(userInfo) { function getUserDisplay(userInfo) {
...@@ -64,7 +63,6 @@ function SidebarContents({ ...@@ -64,7 +63,6 @@ function SidebarContents({
onLogout, onLogout,
onOpenApplicationSwitcher, onOpenApplicationSwitcher,
pathname, pathname,
theme,
userInfo, userInfo,
}) { }) {
return ( return (
...@@ -74,12 +72,7 @@ function SidebarContents({ ...@@ -74,12 +72,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,19 +88,16 @@ function SidebarContents({ ...@@ -95,19 +88,16 @@ 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>
......
import { Close } from '@/components/AppIcons'; import { Close } from '@/components/AppIcons';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import { ActionBar } from '@heroui-pro/react/action-bar'; import { ActionBar } from '@heroui-pro/react/action-bar';
import { DataGrid } from '@heroui-pro/react/data-grid'; import { DataGrid } from '@heroui-pro/react/data-grid';
import { EmptyState } from '@heroui-pro/react/empty-state'; import { EmptyState } from '@heroui-pro/react/empty-state';
...@@ -8,6 +9,7 @@ import { ...@@ -8,6 +9,7 @@ import {
Pagination, Pagination,
Separator, Separator,
Spinner, Spinner,
Surface,
} from '@heroui/react'; } from '@heroui/react';
import { Tooltip } from '@heroui/react'; import { Tooltip } from '@heroui/react';
import { useLayoutEffect, useRef, useState } from 'react'; import { useLayoutEffect, useRef, useState } from 'react';
...@@ -43,15 +45,30 @@ function normalizeDataTableEmptyTitle(title) { ...@@ -43,15 +45,30 @@ function normalizeDataTableEmptyTitle(title) {
return title; return title;
} }
export function DataTableEmptyState({ title = '暂无数据' }) { export function EmptyStateSurface({ children, className = '' }) {
return (
<Surface
className={['w-full min-w-0 rounded-2xl p-4', className].filter(Boolean).join(' ')}
variant="secondary"
>
{children}
</Surface>
);
}
export function DataTableEmptyState({ title = '暂无数据', size = 'md' }) {
const resolvedTitle = normalizeDataTableEmptyTitle(title); const resolvedTitle = normalizeDataTableEmptyTitle(title);
return ( return (
<EmptyState size="md"> <EmptyStateSurface>
<EmptyState.Header> <EmptyState size={size}>
<EmptyState.Title>{resolvedTitle}</EmptyState.Title> <EmptyState.Header>
</EmptyState.Header> <EmptyState.Description className="rc-empty-state-message">
</EmptyState> {resolvedTitle}
</EmptyState.Description>
</EmptyState.Header>
</EmptyState>
</EmptyStateSurface>
); );
} }
...@@ -106,6 +123,7 @@ export function DataTable({ ...@@ -106,6 +123,7 @@ export function DataTable({
selectedRowKeys, selectedRowKeys,
onSelectedRowKeysChange, onSelectedRowKeysChange,
emptyTitle = '暂无数据', emptyTitle = '暂无数据',
emptyStateSize = 'md',
className = '', className = '',
isWide, isWide,
disabledRowKeys, disabledRowKeys,
...@@ -197,7 +215,7 @@ export function DataTable({ ...@@ -197,7 +215,7 @@ export function DataTable({
return ( return (
<DataTableText <DataTableText
text={value} text={value}
showTooltip showTooltip={false}
className={ className={
isPrimary ? 'font-medium' : '' isPrimary ? 'font-medium' : ''
} }
...@@ -264,7 +282,11 @@ export function DataTable({ ...@@ -264,7 +282,11 @@ export function DataTable({
disabledKeys={disabledRowKeys} disabledKeys={disabledRowKeys}
getRowId={(row) => resolveRowKey(row, rows.indexOf(row), getRowKey)} getRowId={(row) => resolveRowKey(row, rows.indexOf(row), getRowKey)}
renderEmptyState={() => renderEmptyState={() =>
isLoading ? <DataTableLoadingState /> : <DataTableEmptyState title={emptyTitle} /> isLoading ? (
<DataTableLoadingState />
) : (
<DataTableEmptyState title={emptyTitle} size={emptyStateSize} />
)
} }
scrollContainerClassName="data-table-scroll" scrollContainerClassName="data-table-scroll"
selectedKeys={selectedKeys} selectedKeys={selectedKeys}
...@@ -363,11 +385,7 @@ export function DataTableText({ ...@@ -363,11 +385,7 @@ export function DataTableText({
</span> </span>
); );
if ( if (!showTooltip || !fullText || fullText === '-') {
!fullText ||
fullText === '-' ||
(!showTooltip && fullText.length <= 18)
) {
return trigger; return trigger;
} }
...@@ -439,21 +457,18 @@ export function DataTablePrimaryAction({ ...@@ -439,21 +457,18 @@ export function DataTablePrimaryAction({
const handlePress = onPress || onClick; const handlePress = onPress || onClick;
return ( return (
<Tooltip> <Button
<Button aria-label={ariaLabel || fullText}
aria-label={ariaLabel || fullText} className={className}
className={className} isDisabled={isDisabled || disabled}
isDisabled={isDisabled || disabled} size="sm"
size="sm" variant="ghost"
variant="ghost" onPress={handlePress}
onPress={handlePress} >
> <span className="min-w-0 truncate">
<span className="min-w-0 truncate"> {children ?? fullText}
{children ?? fullText} </span>
</span> </Button>
</Button>
<Tooltip.Content>{fullText}</Tooltip.Content>
</Tooltip>
); );
} }
...@@ -552,8 +567,10 @@ export function DataTableChip({ children, text, className = '' }) { ...@@ -552,8 +567,10 @@ export function DataTableChip({ children, text, className = '' }) {
export function DataTablePagination({ export function DataTablePagination({
page, page,
pageSize, pageSize,
pageSizeOptions = [],
total, total,
onPageChange, onPageChange,
onPageSizeChange,
isDisabled = false, isDisabled = false,
itemLabel = '条', itemLabel = '条',
}) { }) {
...@@ -562,13 +579,43 @@ export function DataTablePagination({ ...@@ -562,13 +579,43 @@ export function DataTablePagination({
const pageWindowEnd = total const pageWindowEnd = total
? Math.min(totalPages, pageWindowStart + 5) ? Math.min(totalPages, pageWindowStart + 5)
: pageWindowStart; : pageWindowStart;
const normalizedPageSizeOptions = pageSizeOptions
.map((value) => Number(value))
.filter((value, index, values) => Number.isFinite(value) && value > 0 && values.indexOf(value) === index);
const pageSizeOptionsForPicker = normalizedPageSizeOptions.map((value) => ({
id: String(value),
label: `${value} 条/页`,
}));
const hasPageSizePicker =
pageSizeOptionsForPicker.length > 0 && typeof onPageSizeChange === 'function';
return ( return (
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex min-w-0 flex-wrap items-center justify-between gap-3">
<Pagination size="sm"> <div className="flex min-w-0 shrink-0 items-center gap-3">
<Pagination.Summary> <span className="min-w-0 text-sm text-muted">
共 {total} {itemLabel},第{' '} 共 {total} {itemLabel},第 {total ? page + 1 : 0} / {total ? totalPages : 0} 页
{total ? page + 1 : 0} / {total ? totalPages : 0} 页 </span>
</Pagination.Summary> {hasPageSizePicker ? (
<XinyuanAutocomplete
aria-label="每页条数"
className="w-24 shrink-0"
isDisabled={isDisabled}
options={pageSizeOptionsForPicker}
placeholder="每页条数"
selectedKey={String(pageSize)}
showSearch={false}
valueText={`${pageSize} 条/页`}
variant="secondary"
onSelectionChange={(key) => {
const nextPageSize = Number(key);
if (normalizedPageSizeOptions.includes(nextPageSize)) {
onPageSizeChange(nextPageSize);
}
}}
/>
) : null}
</div>
<Pagination className="ml-auto w-auto shrink-0" size={hasPageSizePicker ? 'lg' : 'sm'}>
<Pagination.Content> <Pagination.Content>
<Pagination.Item> <Pagination.Item>
<Pagination.Previous <Pagination.Previous
......
...@@ -51,7 +51,7 @@ function DeleteConfirmDialog({ description, isOpen, onCancel, onConfirm, title } ...@@ -51,7 +51,7 @@ function DeleteConfirmDialog({ description, isOpen, onCancel, onConfirm, title }
<AlertDialog.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} /> <AlertDialog.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
<AlertDialog.Backdrop isOpen={isOpen} variant="blur"> <AlertDialog.Backdrop isOpen={isOpen} variant="blur">
<AlertDialog.Container placement="center" size="md"> <AlertDialog.Container placement="center" size="md">
<AlertDialog.Dialog> <AlertDialog.Dialog className="bg-background">
<AlertDialog.Header> <AlertDialog.Header>
<AlertDialog.Icon status="danger" /> <AlertDialog.Icon status="danger" />
<AlertDialog.Heading>{title}</AlertDialog.Heading> <AlertDialog.Heading>{title}</AlertDialog.Heading>
...@@ -220,7 +220,7 @@ function FieldDialog({ open, onClose, onSaved, formName, field }) { ...@@ -220,7 +220,7 @@ function FieldDialog({ open, onClose, onSaved, formName, field }) {
<Label>字段别名</Label> <Label>字段别名</Label>
<Input variant="secondary" disabled={Boolean(field)} value={values.name} onChange={(event) => setValues((current) => ({ ...current, name: event.target.value }))} /> <Input variant="secondary" disabled={Boolean(field)} value={values.name} onChange={(event) => setValues((current) => ({ ...current, name: event.target.value }))} />
</TextField> </TextField>
<TextField fullWidth> <TextField className="sm:col-span-2" fullWidth>
<Label>字段类型</Label> <Label>字段类型</Label>
<FormSelect <FormSelect
value={values.type} value={values.type}
...@@ -518,7 +518,7 @@ export default function DynamicFormManager() { ...@@ -518,7 +518,7 @@ export default function DynamicFormManager() {
fieldDef: { type: 'text', label: '表单名称' }, fieldDef: { type: 'text', label: '表单名称' },
render: (record) => ( render: (record) => (
<DataTablePrimaryText <DataTablePrimaryText
showTooltip showTooltip={false}
text={record.title || '未命名表单'} text={record.title || '未命名表单'}
/> />
), ),
...@@ -555,7 +555,7 @@ export default function DynamicFormManager() { ...@@ -555,7 +555,7 @@ export default function DynamicFormManager() {
page, page,
pageSize, pageSize,
totalRecords: filteredRecords.length, totalRecords: filteredRecords.length,
pageSizeOptions: [10, 20, 50, 100], pageSizeOptions: [20, 50, 100],
onPageChange: setPage, onPageChange: setPage,
onPageSizeChange: (nextPageSize) => { onPageSizeChange: (nextPageSize) => {
setPageSize(nextPageSize); setPageSize(nextPageSize);
......
...@@ -6,6 +6,7 @@ import { ...@@ -6,6 +6,7 @@ import {
Disclosure, Disclosure,
Input, Input,
Label, Label,
Surface,
Tabs, Tabs,
TextField, TextField,
} from '@heroui/react'; } from '@heroui/react';
...@@ -13,6 +14,7 @@ import { EmptyState } from '@heroui-pro/react/empty-state'; ...@@ -13,6 +14,7 @@ import { EmptyState } from '@heroui-pro/react/empty-state';
import { import {
DataTable, DataTable,
DataTableText, DataTableText,
EmptyStateSurface,
isDataTableLongTextField, isDataTableLongTextField,
} from '@/components/DataTable'; } from '@/components/DataTable';
import { Delete, Edit, ExclamationTriangleIcon } from '@/components/AppIcons'; import { Delete, Edit, ExclamationTriangleIcon } from '@/components/AppIcons';
...@@ -21,7 +23,10 @@ import XinyuanAutocomplete from '@/components/XinyuanAutocomplete'; ...@@ -21,7 +23,10 @@ import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import XinyuanModal from '@/components/XinyuanModal'; import XinyuanModal from '@/components/XinyuanModal';
import { getDFormDetail, queryDForm } from '@/services/DataService'; import { getDFormDetail, queryDForm } from '@/services/DataService';
import { formatFieldValue } from '@/utils/subjectView'; import { formatFieldValue } from '@/utils/subjectView';
import { DynamicField, prepareValuesForSubmit } from './DynamicRecordDialog'; import {
DynamicField,
prepareValuesForSubmit,
} from './DynamicRecordDialog';
const DEFAULT_GROUP = '默认分组'; const DEFAULT_GROUP = '默认分组';
function toFormDescriptor(form) { function toFormDescriptor(form) {
...@@ -171,7 +176,7 @@ function FormDataTable({ instance, definition, loading, rowActions = null }) { ...@@ -171,7 +176,7 @@ function FormDataTable({ instance, definition, loading, rowActions = null }) {
return ( return (
<DataTableText <DataTableText
maxLines={isLongText ? 2 : 1} maxLines={isLongText ? 2 : 1}
showTooltip showTooltip={false}
text={text} text={text}
/> />
); );
...@@ -190,22 +195,30 @@ function FormDataTable({ instance, definition, loading, rowActions = null }) { ...@@ -190,22 +195,30 @@ function FormDataTable({ instance, definition, loading, rowActions = null }) {
}); });
} }
if (!loading && sourceRows.length === 0) { if (!loading && fields.length === 0) {
return ( return (
<EmptyState size="sm"> <EmptyStateSurface>
<EmptyState.Header><EmptyState.Title>暂无表单数据</EmptyState.Title></EmptyState.Header> <EmptyState size="sm">
</EmptyState> <EmptyState.Header>
<EmptyState.Title>暂无可展示字段</EmptyState.Title>
<EmptyState.Description>当前表单缺少字段定义,暂无法展示已有数据。</EmptyState.Description>
</EmptyState.Header>
</EmptyState>
</EmptyStateSurface>
); );
} }
if (!loading && fields.length === 0) { if (!loading && sourceRows.length === 0) {
return ( return (
<EmptyState size="sm"> <EmptyStateSurface>
<EmptyState.Header> <EmptyState size="sm">
<EmptyState.Title>暂无可展示字段</EmptyState.Title> <EmptyState.Header>
<EmptyState.Description>当前表单缺少字段定义,暂无法展示已有数据。</EmptyState.Description> <EmptyState.Description className="rc-empty-state-message">
</EmptyState.Header> 暂无表单数据
</EmptyState> </EmptyState.Description>
</EmptyState.Header>
</EmptyState>
</EmptyStateSurface>
); );
} }
...@@ -213,7 +226,6 @@ function FormDataTable({ instance, definition, loading, rowActions = null }) { ...@@ -213,7 +226,6 @@ function FormDataTable({ instance, definition, loading, rowActions = null }) {
<DataTable <DataTable
ariaLabel={`${getFormLabel(instance?.form)}数据`} ariaLabel={`${getFormLabel(instance?.form)}数据`}
columns={columns} columns={columns}
emptyTitle="暂无表单数据"
getRowKey={(row) => `form-row-${row.__rowIndex}`} getRowKey={(row) => `form-row-${row.__rowIndex}`}
isLoading={loading} isLoading={loading}
rows={sourceRows.map((row, index) => ({ rows={sourceRows.map((row, index) => ({
...@@ -269,7 +281,7 @@ function FormRowDialog({ definition, isOpen, row, onClose, onSave }) { ...@@ -269,7 +281,7 @@ function FormRowDialog({ definition, isOpen, row, onClose, onSave }) {
</> </>
)} )}
> >
<div className="space-y-5"> <div className="grid min-w-0 grid-cols-1 gap-y-5">
{(definition?.fields || []).map((field) => ( {(definition?.fields || []).map((field) => (
<DynamicField <DynamicField
key={field.name} key={field.name}
...@@ -298,7 +310,7 @@ function ConfirmDeleteDialog({ pending, onCancel, onConfirm }) { ...@@ -298,7 +310,7 @@ function ConfirmDeleteDialog({ pending, onCancel, onConfirm }) {
<AlertDialog.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} /> <AlertDialog.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
<AlertDialog.Backdrop isOpen={Boolean(pending)} variant="blur"> <AlertDialog.Backdrop isOpen={Boolean(pending)} variant="blur">
<AlertDialog.Container placement="center" size="md"> <AlertDialog.Container placement="center" size="md">
<AlertDialog.Dialog> <AlertDialog.Dialog className="bg-background">
<AlertDialog.Header> <AlertDialog.Header>
<AlertDialog.Icon status="danger" /> <AlertDialog.Icon status="danger" />
<AlertDialog.Heading>{pending?.title || '确认删除'}</AlertDialog.Heading> <AlertDialog.Heading>{pending?.title || '确认删除'}</AlertDialog.Heading>
...@@ -348,19 +360,32 @@ function FormInstance({ ...@@ -348,19 +360,32 @@ function FormInstance({
return ( return (
<Disclosure defaultExpanded> <Disclosure defaultExpanded>
<Disclosure.Heading> <Disclosure.Heading>
<Disclosure.Trigger className="w-full"> <span className="flex min-w-0 items-center gap-2">
<span className="flex min-w-0 flex-1 items-center justify-between gap-3 text-left"> <Disclosure.Trigger className="flex min-w-0 flex-1 items-center gap-3 text-left">
<span className="min-w-0 truncate font-medium">{getFormLabel(instance.form)}</span> <span className="flex min-w-0 flex-1 items-center justify-between gap-3">
<span className="shrink-0 text-sm text-muted">{instance.data?.length || 0} 行</span> <span className="min-w-0 truncate font-medium">{getFormLabel(instance.form)}</span>
</span> <span className="shrink-0 text-sm text-muted">{instance.data?.length || 0} 行</span>
<Disclosure.Indicator /> </span>
</Disclosure.Trigger> <Disclosure.Indicator />
</Disclosure.Trigger>
{canEdit ? (
<Button
isIconOnly
aria-label="删除当前表单"
size="sm"
variant="danger-soft"
onPress={() => onRequestDelete()}
>
<Delete size={16} />
</Button>
) : null}
</span>
</Disclosure.Heading> </Disclosure.Heading>
<Disclosure.Content> <Disclosure.Content>
<Disclosure.Body> <Disclosure.Body>
<div className="space-y-4 py-2"> <div className="space-y-4 py-2">
{canEdit ? ( {canEdit ? (
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] md:items-end"> <div className="grid gap-4 md:grid-cols-2 md:items-end">
<TextField fullWidth> <TextField fullWidth>
<Label>表单模板</Label> <Label>表单模板</Label>
<XinyuanAutocomplete <XinyuanAutocomplete
...@@ -386,14 +411,6 @@ function FormInstance({ ...@@ -386,14 +411,6 @@ function FormInstance({
onSelectionChange={(key) => key && onChange(withGroup(instance, String(key)))} onSelectionChange={(key) => key && onChange(withGroup(instance, String(key)))}
/> />
</TextField> </TextField>
<Button
isIconOnly
aria-label="删除当前表单"
variant="danger-soft"
onPress={() => onRequestDelete()}
>
<Delete size={16} />
</Button>
</div> </div>
) : null} ) : null}
...@@ -445,9 +462,15 @@ function FormInstance({ ...@@ -445,9 +462,15 @@ function FormInstance({
/> />
</div> </div>
) : ( ) : (
<EmptyState size="sm"> <EmptyStateSurface>
<EmptyState.Header><EmptyState.Title>尚未选择表单</EmptyState.Title></EmptyState.Header> <EmptyState size="sm">
</EmptyState> <EmptyState.Header>
<EmptyState.Description className="rc-empty-state-message">
尚未选择表单
</EmptyState.Description>
</EmptyState.Header>
</EmptyState>
</EmptyStateSurface>
)} )}
</div> </div>
</Disclosure.Body> </Disclosure.Body>
...@@ -466,6 +489,47 @@ function FormInstance({ ...@@ -466,6 +489,47 @@ function FormInstance({
); );
} }
function ReadonlyFormInstance({ instance }) {
const { definition, loading, error } = useFormDefinition(instance);
return (
<Disclosure defaultExpanded>
<Disclosure.Heading>
<Disclosure.Trigger className="w-full">
<span className="flex min-w-0 flex-1 items-center justify-between gap-3 text-left">
<span className="min-w-0 truncate font-medium">{getFormLabel(instance.form)}</span>
<span className="shrink-0 text-sm text-muted">{instance.data?.length || 0} 行</span>
</span>
<Disclosure.Indicator />
</Disclosure.Trigger>
</Disclosure.Heading>
<Disclosure.Content>
<Disclosure.Body>
<div className="py-2">
{error ? (
<EmptyState role="alert" size="sm">
<EmptyState.Header>
<EmptyState.Media variant="icon">
<ExclamationTriangleIcon aria-hidden="true" />
</EmptyState.Media>
<EmptyState.Title>表单模板不可用</EmptyState.Title>
<EmptyState.Description>{error}</EmptyState.Description>
</EmptyState.Header>
</EmptyState>
) : (
<FormDataTable
definition={definition}
instance={instance}
loading={loading}
/>
)}
</div>
</Disclosure.Body>
</Disclosure.Content>
</Disclosure>
);
}
export default function DynamicFormValueEditor({ export default function DynamicFormValueEditor({
disabled = false, disabled = false,
fieldDef, fieldDef,
...@@ -519,7 +583,15 @@ export default function DynamicFormValueEditor({ ...@@ -519,7 +583,15 @@ export default function DynamicFormValueEditor({
const configured = Array.from(new Set( const configured = Array.from(new Set(
displayInstances.map(({ instance }) => getInstanceGroup(instance)).filter(Boolean), displayInstances.map(({ instance }) => getInstanceGroup(instance)).filter(Boolean),
)); ));
return configured.length > 0 ? configured : canEdit ? [DEFAULT_GROUP] : []; if (configured.length === 0) {
return canEdit ? [DEFAULT_GROUP] : [];
}
// Keep the implicit default bucket first while preserving the source order of custom groups.
return [
...(configured.includes(DEFAULT_GROUP) ? [DEFAULT_GROUP] : []),
...configured.filter((group) => group !== DEFAULT_GROUP),
];
}, [canEdit, displayInstances]); }, [canEdit, displayInstances]);
useEffect(() => { useEffect(() => {
...@@ -582,80 +654,224 @@ export default function DynamicFormValueEditor({ ...@@ -582,80 +654,224 @@ export default function DynamicFormValueEditor({
); );
if (selectedInstances.length === 0 && !canEdit) { if (selectedInstances.length === 0 && !canEdit) {
return <span className="text-sm text-muted">暂无内容</span>; return (
<Surface className="rc-workspace-module w-full min-w-0 p-4" variant="tertiary">
<EmptyStateSurface>
<EmptyState size="sm">
<EmptyState.Header>
<EmptyState.Description className="rc-empty-state-message">
暂无内容
</EmptyState.Description>
</EmptyState.Header>
</EmptyState>
</EmptyStateSurface>
</Surface>
);
} }
return ( if (!canEdit) {
<div className="min-w-0 space-y-4"> return (
<div className="flex flex-wrap items-center justify-between gap-3"> <Surface className="rc-workspace-module w-full min-w-0 p-4" variant="tertiary">
<div className="text-sm text-muted"> <div className="min-w-0">
{selectedInstances.length} 个表单,共 {totalRows} 行数据 <Disclosure defaultExpanded>
<Disclosure.Heading>
<Disclosure.Trigger className="flex w-full items-center gap-3 text-left">
<span className="min-w-0 flex-1 text-left">
<span className="block text-base font-medium text-foreground">
{selectedInstances.length} 个表单,共 {totalRows} 行数据
</span>
</span>
<Disclosure.Indicator />
</Disclosure.Trigger>
</Disclosure.Heading>
<Disclosure.Content>
<Disclosure.Body>
{multiple ? (
<div className="grid min-w-0 gap-5 lg:grid-cols-[13rem_minmax(0,1fr)] lg:items-start">
<Tabs
className="w-full min-w-0"
orientation="vertical"
selectedKey={activeGroup}
variant="primary"
onSelectionChange={(key) => setActiveGroup(String(key))}
>
<Tabs.ListContainer className="w-full max-w-full min-w-0">
<Tabs.List aria-label="表单分组" className="w-full min-w-0">
{groups.map((group) => (
<Tabs.Tab
id={group}
key={group}
className="w-full min-w-0 justify-start whitespace-nowrap text-left"
>
<span className="min-w-0 truncate">{group}</span>
<Tabs.Indicator />
</Tabs.Tab>
))}
</Tabs.List>
</Tabs.ListContainer>
</Tabs>
<div className="min-w-0 space-y-3">
<div className="min-w-0">
<div className="text-base font-medium text-foreground">{activeGroup}</div>
<div className="mt-1 text-sm text-muted">
当前分组 {visibleInstances.length} 个表单
</div>
</div>
<div className="space-y-3">
{visibleInstances.map(({ instance, index }) => (
<ReadonlyFormInstance
key={`${getFormIdentifier(instance.form) || 'empty'}-${index}`}
instance={instance}
/>
))}
</div>
</div>
</div>
) : (
<div className="space-y-3">
{visibleInstances.map(({ instance, index }) => (
<ReadonlyFormInstance
key={`${getFormIdentifier(instance.form) || 'empty'}-${index}`}
instance={instance}
/>
))}
</div>
)}
</Disclosure.Body>
</Disclosure.Content>
</Disclosure>
</div> </div>
{canEdit ? ( </Surface>
<div className="flex flex-wrap items-center gap-2"> );
{multiple ? ( }
<Button size="sm" variant="tertiary" onPress={() => setGroupDialogOpen(true)}>
新增分组
</Button>
) : null}
{multiple || instances.length === 0 ? (
<Button size="sm" variant="secondary" onPress={addForm}>
{multiple ? '添加表单' : '选择表单'}
</Button>
) : null}
</div>
) : null}
</div>
{formsError ? ( return (
<Alert status="danger"> <Surface className="rc-workspace-module w-full min-w-0 p-4" variant="tertiary">
<Alert.Content><Alert.Description>{formsError}</Alert.Description></Alert.Content> <div className="min-w-0 space-y-4">
</Alert> <Disclosure defaultExpanded>
) : null} <Disclosure.Heading>
<Disclosure.Trigger className="flex w-full items-center gap-3 text-left">
{multiple && (groups.length > 1 || groups[0] !== DEFAULT_GROUP) ? ( <span className="flex min-w-0 flex-1 items-center justify-between gap-3 text-left">
<div className="flex min-w-0 items-center gap-2"> <span className="min-w-0">
<Tabs <span className="block text-base font-medium text-foreground">
className="w-fit max-w-full min-w-0" {selectedInstances.length} 个表单,共 {totalRows} 行数据
selectedKey={activeGroup} </span>
variant="primary" </span>
onSelectionChange={(key) => setActiveGroup(String(key))} </span>
> <Disclosure.Indicator />
<Tabs.ListContainer className="w-fit max-w-full min-w-0"> </Disclosure.Trigger>
<Tabs.List aria-label="表单分组" className="w-max min-w-max"> </Disclosure.Heading>
{groups.map((group) => ( <Disclosure.Content>
<Tabs.Tab <Disclosure.Body>
id={group} <div className="space-y-4">
key={group} {formsError ? (
className="w-auto min-w-max flex-none whitespace-nowrap" <Alert status="danger">
<Alert.Content><Alert.Description>{formsError}</Alert.Description></Alert.Content>
</Alert>
) : null}
{multiple ? (
<div className="grid min-w-0 gap-5 lg:grid-cols-[13rem_minmax(0,1fr)] lg:items-start">
<div className="min-w-0 space-y-3">
<Tabs
className="w-full min-w-0"
orientation="vertical"
selectedKey={activeGroup}
variant="primary"
onSelectionChange={(key) => setActiveGroup(String(key))}
>
<Tabs.ListContainer className="w-full max-w-full min-w-0">
<Tabs.List aria-label="表单分组" className="w-full min-w-0">
{groups.map((group) => (
<Tabs.Tab
id={group}
key={group}
className="w-full min-w-0 justify-start whitespace-nowrap text-left"
>
<span className="min-w-0 truncate">{group}</span>
<Tabs.Indicator />
</Tabs.Tab>
))}
</Tabs.List>
</Tabs.ListContainer>
</Tabs>
{canEdit ? (
<div className="flex items-center gap-2">
<Button className="flex-1" size="sm" variant="secondary" onPress={() => setGroupDialogOpen(true)}>
添加分组
</Button>
{activeGroup !== DEFAULT_GROUP ? (
<Button
isIconOnly
aria-label={`删除分组 ${activeGroup}`}
size="sm"
variant="danger-soft"
onPress={() => setPendingDelete({
type: 'group',
group: activeGroup,
title: '删除表单分组',
message: `“${activeGroup}”中的表单将移至默认分组。`,
})}
> >
{group} <Delete size={16} />
<Tabs.Indicator /> </Button>
</Tabs.Tab> ) : null}
</div>
) : null}
</div>
<div className="min-w-0 space-y-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-base font-medium text-foreground">{activeGroup}</div>
<div className="mt-1 text-sm text-muted">
当前分组 {visibleInstances.length} 个表单
</div>
</div>
{canEdit ? (
<Button size="sm" variant="secondary" onPress={addForm}>添加表单</Button>
) : null}
</div>
{visibleInstances.length > 0 ? (
<div className="space-y-3">
{visibleInstances.map(({ instance, index }) => (
<FormInstance
key={`${getFormIdentifier(instance.form) || 'empty'}-${index}`}
canEdit={canEdit}
forms={forms}
formsLoading={formsLoading}
groups={groups}
instance={instance}
onChange={(nextInstance) => updateInstance(index, nextInstance)}
onRequestDelete={(rowIndex) => setPendingDelete(Number.isInteger(rowIndex) ? {
type: 'row',
instanceIndex: index,
rowIndex,
title: '删除表单数据',
message: '该行数据将从当前编辑草稿中移除。',
} : {
type: 'form',
instanceIndex: index,
title: '删除关联表单',
message: `“${getFormLabel(instance.form)}”及其数据将从当前编辑草稿中移除。`,
})}
/>
))} ))}
</Tabs.List> </div>
</Tabs.ListContainer> ) : (
</Tabs> <EmptyStateSurface>
{canEdit && activeGroup !== DEFAULT_GROUP ? ( <EmptyState size="sm">
<Button <EmptyState.Header>
isIconOnly <EmptyState.Description className="rc-empty-state-message">
aria-label={`删除分组 ${activeGroup}`} 当前分组暂无表单
size="sm" </EmptyState.Description>
variant="danger-soft" </EmptyState.Header>
onPress={() => setPendingDelete({ </EmptyState>
type: 'group', </EmptyStateSurface>
group: activeGroup, )}
title: '删除表单分组', </div>
message: `“${activeGroup}”中的表单将移至默认分组。`,
})}
>
<Delete size={16} />
</Button>
) : null}
</div> </div>
) : null} ) : visibleInstances.length > 0 ? (
{visibleInstances.length > 0 ? (
<div className="space-y-3"> <div className="space-y-3">
{visibleInstances.map(({ instance, index }) => ( {visibleInstances.map(({ instance, index }) => (
<FormInstance <FormInstance
...@@ -681,11 +897,21 @@ export default function DynamicFormValueEditor({ ...@@ -681,11 +897,21 @@ export default function DynamicFormValueEditor({
/> />
))} ))}
</div> </div>
) : ( ) : (
<EmptyState size="sm"> <EmptyStateSurface>
<EmptyState.Header><EmptyState.Title>当前分组暂无表单</EmptyState.Title></EmptyState.Header> <EmptyState size="sm">
</EmptyState> <EmptyState.Header>
)} <EmptyState.Description className="rc-empty-state-message">
当前分组暂无表单
</EmptyState.Description>
</EmptyState.Header>
</EmptyState>
</EmptyStateSurface>
)}
</div>
</Disclosure.Body>
</Disclosure.Content>
</Disclosure>
<XinyuanModal <XinyuanModal
isOpen={isGroupDialogOpen} isOpen={isGroupDialogOpen}
...@@ -716,6 +942,7 @@ export default function DynamicFormValueEditor({ ...@@ -716,6 +942,7 @@ export default function DynamicFormValueEditor({
onCancel={() => setPendingDelete(null)} onCancel={() => setPendingDelete(null)}
onConfirm={confirmDelete} onConfirm={confirmDelete}
/> />
</div> </div>
</Surface>
); );
} }
import clsx from 'clsx';
import { Alert, Button, Checkbox, Input, Label, Surface, TextArea, TextField } from '@heroui/react'; import { Alert, Button, Checkbox, Input, Label, Surface, TextArea, TextField } from '@heroui/react';
import ReferenceSearchSelect from '@/components/ReferenceSearchSelect'; import ReferenceSearchSelect from '@/components/ReferenceSearchSelect';
import FileUploadField from '@/components/FileUploadField'; import FileUploadField from '@/components/FileUploadField';
...@@ -17,6 +18,7 @@ import { ...@@ -17,6 +18,7 @@ import {
getSelectOptions, getSelectOptions,
isDateFieldType, isDateFieldType,
isReferenceFieldType, isReferenceFieldType,
normalizeProjectYearDisplay,
normalizeValueForSubmit, normalizeValueForSubmit,
toArray, toArray,
} from '@/utils/subjectView'; } from '@/utils/subjectView';
...@@ -195,16 +197,21 @@ function NestedSubjectFields({ fieldDef, value, onChange, disabled, depth }) { ...@@ -195,16 +197,21 @@ function NestedSubjectFields({ fieldDef, value, onChange, disabled, depth }) {
return <div className="text-sm text-muted">正在加载子对象结构...</div>; return <div className="text-sm text-muted">正在加载子对象结构...</div>;
} }
const renderFields = (item, handleFieldChange) => (nestedSubject.fields || []).map((nestedField) => ( const renderFields = (item, handleFieldChange) => (
<DynamicField <div className="grid min-w-0 grid-cols-1 gap-x-8 gap-y-5 md:grid-cols-2">
key={nestedField.name} {(nestedSubject.fields || []).map((nestedField) => (
fieldDef={nestedField} <DynamicField
value={item?.[nestedField.name]} key={nestedField.name}
disabled={disabled} className={shouldSpanDynamicFieldColumns(nestedField) ? 'md:col-span-2' : undefined}
onChange={(nextValue) => handleFieldChange(nestedField.name, nextValue)} fieldDef={nestedField}
depth={depth + 1} value={item?.[nestedField.name]}
/> disabled={disabled}
)); onChange={(nextValue) => handleFieldChange(nestedField.name, nextValue)}
depth={depth + 1}
/>
))}
</div>
);
if (fieldDef?.multiple) { if (fieldDef?.multiple) {
const items = Array.isArray(value) ? value : []; const items = Array.isArray(value) ? value : [];
...@@ -359,6 +366,10 @@ export function DynamicField({ fieldDef, value, onChange, disabled = false, dept ...@@ -359,6 +366,10 @@ export function DynamicField({ fieldDef, value, onChange, disabled = false, dept
); );
} else { } else {
const dateInputType = ['datetime', 'date_time'].includes(type) ? 'datetime-local' : 'date'; const dateInputType = ['datetime', 'date_time'].includes(type) ? 'datetime-local' : 'date';
const isProjectYearField = fieldDef?.name === 'project_year';
const inputValue = isProjectYearField
? normalizeProjectYearDisplay(value)
: value ?? '';
control = isDateFieldType(type) ? ( control = isDateFieldType(type) ? (
<FormDatePicker <FormDatePicker
disabled={disabled} disabled={disabled}
...@@ -371,7 +382,7 @@ export function DynamicField({ fieldDef, value, onChange, disabled = false, dept ...@@ -371,7 +382,7 @@ export function DynamicField({ fieldDef, value, onChange, disabled = false, dept
<Input <Input
type={type === 'number' ? 'number' : 'text'} type={type === 'number' ? 'number' : 'text'}
placeholder={inputPlaceholder} placeholder={inputPlaceholder}
value={value ?? ''} value={inputValue}
disabled={disabled} disabled={disabled}
variant="secondary" variant="secondary"
onChange={(event) => onChange(type === 'number' && event.target.value !== '' onChange={(event) => onChange(type === 'number' && event.target.value !== ''
...@@ -381,16 +392,25 @@ export function DynamicField({ fieldDef, value, onChange, disabled = false, dept ...@@ -381,16 +392,25 @@ export function DynamicField({ fieldDef, value, onChange, disabled = false, dept
); );
} }
const isSelectionField = type === 'select' || type === 'user' || isReferenceFieldType(type);
return ( return (
<TextField className={className} fullWidth isRequired={required}> <TextField className={clsx(className, isSelectionField && 'md:col-span-2')} fullWidth isRequired={required}>
<Label> <Label>
{fieldLabel} {fieldLabel}
</Label> </Label>
{control} {type === 'file' ? (
<div className="w-full min-w-0">{control}</div>
) : control}
</TextField> </TextField>
); );
} }
export function shouldSpanDynamicFieldColumns(fieldDef) {
const type = String(fieldDef?.type || 'text').toLowerCase();
return ['long_text', 'rich_text', 'file', 'subject', 'object', 'form'].includes(type);
}
export async function prepareValuesForSubmit(subject, values, path = '') { export async function prepareValuesForSubmit(subject, values, path = '') {
const errors = []; const errors = [];
const metadata = {}; const metadata = {};
...@@ -547,12 +567,10 @@ export default function DynamicRecordDialog({ ...@@ -547,12 +567,10 @@ export default function DynamicRecordDialog({
/> />
</TextField> </TextField>
{(subject?.fields || []).map((field) => { {(subject?.fields || []).map((field) => {
const type = String(field?.type || 'text').toLowerCase();
const spansColumns = ['long_text', 'rich_text', 'file', 'subject', 'object', 'form'].includes(type);
return ( return (
<DynamicField <DynamicField
key={field.name} key={field.name}
className={spansColumns ? 'md:col-span-2' : undefined} className={shouldSpanDynamicFieldColumns(field) ? 'md:col-span-2' : undefined}
fieldDef={field} fieldDef={field}
value={values[field.name]} value={values[field.name]}
disabled={saving || (editing && isPrimaryField(field))} disabled={saving || (editing && isPrimaryField(field))}
......
...@@ -19,7 +19,8 @@ import { ...@@ -19,7 +19,8 @@ import {
loadSubjectWorkbenchResources, loadSubjectWorkbenchResources,
} from '@/pages/project_manager_xy/SubjectWorkbenchService'; } from '@/pages/project_manager_xy/SubjectWorkbenchService';
const DEFAULT_PAGE_SIZE = 10; const DEFAULT_PAGE_SIZE = 20;
const PAGE_SIZE_OPTIONS = [20, 50, 100];
function normalizeActionContext(context, helpers) { function normalizeActionContext(context, helpers) {
return { return {
...@@ -57,6 +58,7 @@ export default function SubjectRecordWorkbench({ ...@@ -57,6 +58,7 @@ export default function SubjectRecordWorkbench({
const [filters, setFilters] = useState({ keyword: '', status: '', year: '' }); const [filters, setFilters] = useState({ keyword: '', status: '', year: '' });
const [selectedIds, setSelectedIds] = useState([]); const [selectedIds, setSelectedIds] = useState([]);
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
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([]);
...@@ -114,11 +116,11 @@ export default function SubjectRecordWorkbench({ ...@@ -114,11 +116,11 @@ export default function SubjectRecordWorkbench({
const hiddenFields = new Set(hiddenColumnFields); const hiddenFields = new Set(hiddenColumnFields);
return (configuredView.columns || []).filter((column) => !hiddenFields.has(column.field)); return (configuredView.columns || []).filter((column) => !hiddenFields.has(column.field));
}, [configuredView.columns, hiddenColumnFields]); }, [configuredView.columns, hiddenColumnFields]);
const totalPages = Math.max(1, Math.ceil(filteredRecords.length / DEFAULT_PAGE_SIZE)); const totalPages = Math.max(1, Math.ceil(filteredRecords.length / pageSize));
const safePage = Math.min(page, totalPages - 1); const safePage = Math.min(page, totalPages - 1);
const pageRecords = filteredRecords.slice( const pageRecords = filteredRecords.slice(
safePage * DEFAULT_PAGE_SIZE, safePage * pageSize,
(safePage + 1) * DEFAULT_PAGE_SIZE, (safePage + 1) * pageSize,
); );
useEffect(() => { useEffect(() => {
...@@ -352,13 +354,20 @@ export default function SubjectRecordWorkbench({ ...@@ -352,13 +354,20 @@ export default function SubjectRecordWorkbench({
}} }}
pagination={{ pagination={{
page: safePage, page: safePage,
pageSize: DEFAULT_PAGE_SIZE, 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
/> />
...@@ -382,7 +391,7 @@ export default function SubjectRecordWorkbench({ ...@@ -382,7 +391,7 @@ export default function SubjectRecordWorkbench({
<AlertDialog.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} /> <AlertDialog.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
<AlertDialog.Backdrop isOpen={deleteTargets.length > 0} variant="blur"> <AlertDialog.Backdrop isOpen={deleteTargets.length > 0} variant="blur">
<AlertDialog.Container placement="center" size="md"> <AlertDialog.Container placement="center" size="md">
<AlertDialog.Dialog> <AlertDialog.Dialog className="bg-background">
<AlertDialog.Header> <AlertDialog.Header>
<AlertDialog.Icon status="danger" /> <AlertDialog.Icon status="danger" />
<AlertDialog.Heading>确认删除记录</AlertDialog.Heading> <AlertDialog.Heading>确认删除记录</AlertDialog.Heading>
......
...@@ -54,6 +54,7 @@ export default function XinyuanAutocomplete({ ...@@ -54,6 +54,7 @@ export default function XinyuanAutocomplete({
popoverHeader, popoverHeader,
renderOption, renderOption,
searchPlaceholder = '输入关键词搜索', searchPlaceholder = '输入关键词搜索',
showSearch = true,
selectedKey, selectedKey,
selectedKeys, selectedKeys,
selectionMode = 'single', selectionMode = 'single',
...@@ -153,21 +154,23 @@ export default function XinyuanAutocomplete({ ...@@ -153,21 +154,23 @@ export default function XinyuanAutocomplete({
onInputChange={onInputChange} onInputChange={onInputChange}
> >
{popoverHeader} {popoverHeader}
<SearchField {showSearch ? (
aria-label={searchPlaceholder} <SearchField
fullWidth aria-label={searchPlaceholder}
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}
items={visibleOptions} items={visibleOptions}
......
import {
getXinyuanEChartsTheme,
} from '@/design-system/chartPalette';
import ReactECharts from 'echarts-for-react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
function mergeAxisTheme(axis, theme) {
if (!axis) return axis;
const applyTheme = (item) => ({
...item,
axisLabel: {
color: theme.axis,
fontFamily: theme.fontFamily,
fontSize: 12,
...item?.axisLabel,
},
axisLine: {
...item?.axisLine,
lineStyle: {
color: theme.grid,
...item?.axisLine?.lineStyle,
},
},
axisTick: {
...item?.axisTick,
lineStyle: {
color: theme.grid,
...item?.axisTick?.lineStyle,
},
},
splitLine: {
...item?.splitLine,
lineStyle: {
color: theme.grid,
...item?.splitLine?.lineStyle,
},
},
});
return Array.isArray(axis) ? axis.map(applyTheme) : applyTheme(axis);
}
export default function XinyuanEChart({
ariaLabel,
className = '',
height = 320,
option,
}) {
const chartRef = useRef(null);
const hideTooltip = useCallback(() => {
chartRef.current?.getEchartsInstance?.().dispatchAction({ type: 'hideTip' });
}, []);
useEffect(() => {
window.addEventListener('resize', hideTooltip);
return () => window.removeEventListener('resize', hideTooltip);
}, [hideTooltip]);
const themedOption = useMemo(() => {
const theme = getXinyuanEChartsTheme();
return {
animationDuration: 320,
animationDurationUpdate: 240,
animationEasing: 'cubicOut',
aria: {
enabled: true,
decal: { show: false },
...option?.aria,
},
color: option?.color || theme.palette,
textStyle: {
color: theme.label,
fontFamily: theme.fontFamily,
...option?.textStyle,
},
...option,
legend: option?.legend ? {
icon: 'circle',
itemHeight: 8,
itemWidth: 8,
...option.legend,
textStyle: {
color: theme.label,
fontFamily: theme.fontFamily,
fontSize: 12,
lineHeight: 16,
...option.legend?.textStyle,
},
} : undefined,
tooltip: {
appendToBody: true,
backgroundColor: theme.tooltipBackground,
borderColor: theme.tooltipBorder,
borderWidth: 1,
className: 'rc-echarts-tooltip',
confine: true,
enterable: false,
extraCssText: `border-radius: 12px; box-shadow: ${theme.tooltipShadow};`,
padding: [10, 12],
renderMode: 'html',
...option?.tooltip,
textStyle: {
color: theme.foreground,
fontFamily: theme.fontFamily,
fontSize: 12,
...option?.tooltip?.textStyle,
},
},
xAxis: mergeAxisTheme(option?.xAxis, theme),
yAxis: mergeAxisTheme(option?.yAxis, theme),
};
}, [option]);
return (
<div
aria-label={ariaLabel}
className={`rc-chart-interaction w-full min-w-0 ${className}`.trim()}
role="img"
tabIndex={0}
>
<ReactECharts
lazyUpdate
notMerge
onEvents={{ globalout: hideTooltip }}
option={themedOption}
opts={{ renderer: 'canvas' }}
ref={chartRef}
style={{ height, width: '100%' }}
/>
</div>
);
}
...@@ -33,6 +33,7 @@ export default function XinyuanModal({ ...@@ -33,6 +33,7 @@ export default function XinyuanModal({
<Modal.Dialog <Modal.Dialog
className={clsx( className={clsx(
STANDARD_PANEL_PADDING_SIZES.has(size) && 'p-7', STANDARD_PANEL_PADDING_SIZES.has(size) && 'p-7',
'bg-background',
DIALOG_WIDTH_CLASS[size], DIALOG_WIDTH_CLASS[size],
dialogClassName, dialogClassName,
)} )}
......
...@@ -24,7 +24,7 @@ import { ...@@ -24,7 +24,7 @@ import {
} from '@/components/AppIcons'; } from '@/components/AppIcons';
import FilterSelect from '@/components/FilterSelect'; import FilterSelect from '@/components/FilterSelect';
import FormDatePicker from '@/components/FormDatePicker'; import FormDatePicker from '@/components/FormDatePicker';
import { formatFieldValue, getFieldValue } from '@/utils/subjectView'; import { formatFieldValue, getFieldValue, normalizeText } from '@/utils/subjectView';
import { import {
Alert, Alert,
Avatar, Avatar,
...@@ -163,8 +163,16 @@ function buildColumnGroupMeta(columnGroups = [], columns = []) { ...@@ -163,8 +163,16 @@ function buildColumnGroupMeta(columnGroups = [], columns = []) {
return { return {
...group, ...group,
id, id,
name: group?.name || id, name:
normalizeText(group?.label) ||
normalizeText(group?.title) ||
normalizeText(group?.name) ||
id,
children, children,
columnLabels: children.map((field) => {
const column = columns.find((item) => item.field === field);
return String(column?.label || field).trim();
}),
}; };
}) })
.filter((group) => group.children.length > 0); .filter((group) => group.children.length > 0);
...@@ -353,7 +361,7 @@ function defaultRenderCell(record, column) { ...@@ -353,7 +361,7 @@ function defaultRenderCell(record, column) {
<DataTableText <DataTableText
className="text-sm text-foreground" className="text-sm text-foreground"
maxLines={isLongText ? 2 : 1} maxLines={isLongText ? 2 : 1}
showTooltip showTooltip={false}
text={text} text={text}
/> />
); );
...@@ -402,28 +410,56 @@ function getWorkbenchColumnWidth(column, stickyMeta) { ...@@ -402,28 +410,56 @@ function getWorkbenchColumnWidth(column, stickyMeta) {
return Math.min(readableWidth, 280); return Math.min(readableWidth, 280);
} }
function ColumnGroupControl({ group, collapsed, index, onToggle }) { function ColumnGroupControl({ group, collapsed, index, onPreviewChange, onToggle }) {
const Icon = index % 2 === 0 ? Group : ChartBarSquareIcon; const Icon = index % 2 === 0 ? Group : ChartBarSquareIcon;
const actionLabel = `${collapsed ? '显示' : '隐藏'}${group.name}列`; const actionLabel = `${collapsed ? '显示' : '隐藏'}${group.name}列`;
const columnSummary = group.columnLabels.join('、');
return ( return (
<Tooltip delay={300}> <Tooltip closeDelay={80} delay={150}>
<ToggleButton <ToggleButton
size="sm" size="sm"
isSelected={collapsed} isSelected={collapsed}
onChange={onToggle} onChange={onToggle}
onBlur={() => onPreviewChange?.(null)}
onFocus={() => onPreviewChange?.(group.id)}
onHoverEnd={() => onPreviewChange?.(null)}
onHoverStart={() => onPreviewChange?.(group.id)}
aria-label={actionLabel} aria-label={actionLabel}
> >
<Icon size={16} /> <Icon size={16} />
<span>{group.name}</span> <span>{group.name}</span>
<span aria-hidden="true" className="text-xs opacity-70">
{group.children.length} 列
</span>
</ToggleButton> </ToggleButton>
<Tooltip.Content> <Tooltip.Content className="max-w-80" placement="bottom">
{actionLabel} <div className="space-y-1">
<div className="font-medium">{actionLabel}</div>
<div className="text-xs opacity-80">包含:{columnSummary}</div>
</div>
</Tooltip.Content> </Tooltip.Content>
</Tooltip> </Tooltip>
); );
} }
function ColumnHeaderLabel({ label, groupName }) {
if (!groupName) {
return label;
}
return (
<span className="flex min-w-0 flex-col items-start gap-0.5 py-0.5">
<span aria-hidden="true" className="text-[11px] font-normal leading-4 text-muted">
{groupName}
</span>
<span className="text-xs font-medium leading-4 text-foreground">
{label}
</span>
</span>
);
}
function renderRowActionButton({ action, isDisabled, onPress }) { function renderRowActionButton({ action, isDisabled, onPress }) {
const Icon = action.icon || DEFAULT_ROW_ACTION_ICONS[action.key]; const Icon = action.icon || DEFAULT_ROW_ACTION_ICONS[action.key];
...@@ -467,8 +503,10 @@ function WorkbenchPagination({ pagination }) { ...@@ -467,8 +503,10 @@ function WorkbenchPagination({ pagination }) {
isDisabled={Boolean(pagination.disabled)} isDisabled={Boolean(pagination.disabled)}
page={Math.max(0, Number(pagination.page || 0))} page={Math.max(0, Number(pagination.page || 0))}
pageSize={pageSize} pageSize={pageSize}
pageSizeOptions={pagination.pageSizeOptions}
total={totalRecords} total={totalRecords}
onPageChange={pagination.onPageChange} onPageChange={pagination.onPageChange}
onPageSizeChange={pagination.onPageSizeChange}
/> />
); );
} }
...@@ -512,6 +550,7 @@ export default function RecordWorkbench({ ...@@ -512,6 +550,7 @@ export default function RecordWorkbench({
const [drawerDirty, setDrawerDirty] = useState(false); const [drawerDirty, setDrawerDirty] = useState(false);
const [filtersExpanded, setFiltersExpanded] = useState(false); const [filtersExpanded, setFiltersExpanded] = useState(false);
const [collapsedColumnGroups, setCollapsedColumnGroups] = useState({}); const [collapsedColumnGroups, setCollapsedColumnGroups] = useState({});
const [previewColumnGroupId, setPreviewColumnGroupId] = useState(null);
const [columnGroupAnnouncement, setColumnGroupAnnouncement] = useState(''); const [columnGroupAnnouncement, setColumnGroupAnnouncement] = useState('');
const [expandedRowIds, setExpandedRowIds] = useState({}); const [expandedRowIds, setExpandedRowIds] = useState({});
const filterFieldVariant = 'secondary'; const filterFieldVariant = 'secondary';
...@@ -811,9 +850,23 @@ export default function RecordWorkbench({ ...@@ -811,9 +850,23 @@ export default function RecordWorkbench({
: []), : []),
...stickyColumns.orderedColumns.map((column, columnIndex) => { ...stickyColumns.orderedColumns.map((column, columnIndex) => {
const stickyMeta = stickyColumns.stickyMeta[column.field]; const stickyMeta = stickyColumns.stickyMeta[column.field];
const columnGroupId = columnGroupMeta.fieldToGroup[column.field];
const columnGroup = columnGroupId
? columnGroupMeta.groupById[columnGroupId]
: null;
const isPreviewedGroupColumn = columnGroupId === previewColumnGroupId;
return { return {
key: column.field, key: column.field,
title: column.label, title: (
<ColumnHeaderLabel
groupName={columnGroup?.name}
label={column.label}
/>
),
headerClassName: clsx(
column.headerClassName,
isPreviewedGroupColumn && 'bg-accent-soft text-accent-soft-foreground',
),
width: getWorkbenchColumnWidth(column, stickyMeta), width: getWorkbenchColumnWidth(column, stickyMeta),
minWidth: getWorkbenchColumnWidth(column, stickyMeta), minWidth: getWorkbenchColumnWidth(column, stickyMeta),
pin: pin:
...@@ -1084,6 +1137,7 @@ export default function RecordWorkbench({ ...@@ -1084,6 +1137,7 @@ export default function RecordWorkbench({
group={group} group={group}
collapsed={Boolean(collapsedColumnGroups[group.id])} collapsed={Boolean(collapsedColumnGroups[group.id])}
index={index} index={index}
onPreviewChange={setPreviewColumnGroupId}
onToggle={() => toggleColumnGroup(group.id)} onToggle={() => toggleColumnGroup(group.id)}
/> />
))} ))}
......
...@@ -3,7 +3,6 @@ export const APPLICATION_NAME = 'audit_project_manager_new'; ...@@ -3,7 +3,6 @@ export const APPLICATION_NAME = 'audit_project_manager_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 SOURCE_APPLICATION_NAME = APPLICATION_NAME; export const SOURCE_APPLICATION_NAME = APPLICATION_NAME;
export const THEME_STORAGE_KEY = 'xinyuan_color_theme';
export const ROUTE_TITLES = Object.freeze({ export const ROUTE_TITLES = Object.freeze({
'/audit_project_table/dashboard': '驾驶舱', '/audit_project_table/dashboard': '驾驶舱',
......
...@@ -7,6 +7,14 @@ export const XINYUAN_CHART_PALETTE = Object.freeze([ ...@@ -7,6 +7,14 @@ export const XINYUAN_CHART_PALETTE = Object.freeze([
'var(--rc-chart-6)', 'var(--rc-chart-6)',
'var(--rc-chart-7)', 'var(--rc-chart-7)',
'var(--rc-chart-8)', 'var(--rc-chart-8)',
'var(--rc-chart-9)',
'var(--rc-chart-10)',
'var(--rc-chart-11)',
'var(--rc-chart-12)',
'var(--rc-chart-13)',
'var(--rc-chart-14)',
'var(--rc-chart-15)',
'var(--rc-chart-16)',
]); ]);
export const XINYUAN_CHART_SEMANTIC = Object.freeze({ export const XINYUAN_CHART_SEMANTIC = Object.freeze({
...@@ -29,15 +37,6 @@ function hashChartKey(key) { ...@@ -29,15 +37,6 @@ function hashChartKey(key) {
); );
} }
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 = []) { export function createXinyuanChartColorMap(keys = []) {
const stableKeys = [...new Set(keys.map(normalizeChartKey).filter(Boolean))] const stableKeys = [...new Set(keys.map(normalizeChartKey).filter(Boolean))]
.sort(); .sort();
...@@ -65,46 +64,49 @@ export function createXinyuanChartColorMap(keys = []) { ...@@ -65,46 +64,49 @@ export function createXinyuanChartColorMap(keys = []) {
return colorMap; return colorMap;
} }
function isVisibleChartColor(color) { function readRootToken(tokenName, fallback = '') {
if (typeof color !== 'string') { if (typeof window === 'undefined' || typeof document === 'undefined') {
return false; return fallback;
} }
const normalizedColor = color.trim().toLowerCase(); return window.getComputedStyle(document.documentElement)
return Boolean(normalizedColor) .getPropertyValue(tokenName)
&& normalizedColor !== 'none' .trim() || fallback;
&& normalizedColor !== 'transparent'; }
export function resolveXinyuanChartColor(color, fallback = '') {
const match = String(color || '').trim().match(/^var\((--[^)]+)\)$/);
return match ? readRootToken(match[1], fallback) : String(color || fallback);
} }
export function colorizeXinyuanChartTooltipPayload( export function createResolvedXinyuanChartColorMap(keys = []) {
payload = [], return new Map(
resolveColor, Array.from(createXinyuanChartColorMap(keys)).map(([key, color]) => [
) { key,
return payload.map((entry, index) => { resolveXinyuanChartColor(color),
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 { export function getXinyuanEChartsTheme() {
...entry, const documentFont = typeof document === 'undefined'
color, ? 'Inter, sans-serif'
fill: color, : window.getComputedStyle(document.body).fontFamily;
name: entry?.name ?? sourceDatum?.name ?? entry?.dataKey,
stroke: color, return {
}; axis: readRootToken('--xinyuan-chart-axis', '#737373'),
}); fontFamily: documentFont || 'Inter, sans-serif',
foreground: readRootToken('--xinyuan-chart-tooltip-foreground', '#222222'),
grid: readRootToken('--xinyuan-chart-grid', '#e8e8e8'),
label: readRootToken('--xinyuan-chart-label', '#737373'),
palette: Array.from({ length: 16 }, (_, index) => (
readRootToken(`--xinyuan-chart-${index + 1}`, '#667085')
)),
tooltipBackground: readRootToken('--xinyuan-chart-tooltip-background', '#ffffff'),
tooltipBorder: readRootToken('--xinyuan-chart-tooltip-border', '#dedede'),
tooltipShadow: readRootToken(
'--xinyuan-chart-tooltip-shadow',
'0 16px 40px rgb(15 23 42 / 14%)',
),
};
} }
...@@ -453,7 +453,7 @@ ...@@ -453,7 +453,7 @@
grid-column: 1 / -1; grid-column: 1 / -1;
} }
/* Recharts keeps keyboard navigation while pointer focus stays visually quiet. */ /* Charts keep keyboard navigation while pointer focus stays visually quiet. */
.rc-chart-interaction:focus:not(:focus-visible), .rc-chart-interaction:focus:not(:focus-visible),
.rc-chart-interaction :focus:not(:focus-visible) { .rc-chart-interaction :focus:not(:focus-visible) {
outline: none; outline: none;
...@@ -465,6 +465,10 @@ ...@@ -465,6 +465,10 @@
outline-offset: 2px; outline-offset: 2px;
} }
.rc-echarts-tooltip {
position: fixed !important;
}
.rc-kpi-grid { .rc-kpi-grid {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
...@@ -513,12 +517,27 @@ ...@@ -513,12 +517,27 @@
border-radius: var(--rc-radius-overlay); border-radius: var(--rc-radius-overlay);
} }
.rc-detail-view-surface { .rc-dashboard-card {
border-radius: calc(var(--radius) * 2); border-radius: 1.5rem;
} }
.rc-dashboard-card { .rc-workspace-module {
border-radius: 1.5rem; border-radius: 1.5rem;
border: 1px solid var(--border);
background: transparent;
box-shadow: none;
}
.rc-empty-state-message {
color: var(--rc-text-empty);
font-size: var(--text-sm);
font-weight: var(--font-weight-normal);
}
.rc-empty-field-text {
color: var(--rc-text-empty);
font-size: var(--text-sm);
font-weight: var(--font-weight-normal);
} }
/* HeroUI Pro KPI.Icon keeps its compound role; this class supplies Xinyuan tones. */ /* HeroUI Pro KPI.Icon keeps its compound role; this class supplies Xinyuan tones. */
...@@ -1221,12 +1240,12 @@ ...@@ -1221,12 +1240,12 @@
place-items: center; place-items: center;
padding: 0; padding: 0;
border-radius: 50%; border-radius: 50%;
color: var(--foreground); color: var(--rc-sidebar-foreground);
background-color: transparent; background-color: transparent;
line-height: 1; line-height: 1;
--button-bg-hover: var(--accent-soft); --button-bg-hover: var(--rc-sidebar-item-selected);
--button-bg-pressed: var(--accent-soft); --button-bg-pressed: var(--rc-sidebar-item-selected);
--button-fg: var(--foreground); --button-fg: var(--rc-sidebar-item-selected-foreground);
} }
.app-sidebar-app-trigger .anticon { .app-sidebar-app-trigger .anticon {
...@@ -1246,16 +1265,16 @@ ...@@ -1246,16 +1265,16 @@
@media (hover: hover) { @media (hover: hover) {
.app-sidebar-app-trigger.button:hover, .app-sidebar-app-trigger.button:hover,
.app-sidebar-app-trigger.button[data-hovered='true'] { .app-sidebar-app-trigger.button[data-hovered='true'] {
color: var(--foreground); color: var(--rc-sidebar-item-selected-foreground);
background-color: var(--accent-soft); background-color: var(--rc-sidebar-item-selected);
} }
} }
.app-sidebar-app-trigger.button:active, .app-sidebar-app-trigger.button:active,
.app-sidebar-app-trigger.button[data-pressed='true'], .app-sidebar-app-trigger.button[data-pressed='true'],
.app-sidebar-app-trigger.button[aria-expanded='true'] { .app-sidebar-app-trigger.button[aria-expanded='true'] {
color: var(--foreground); color: var(--rc-sidebar-item-selected-foreground);
background-color: var(--accent-soft); background-color: var(--rc-sidebar-item-selected);
} }
.app-sidebar-app-dialog { .app-sidebar-app-dialog {
...@@ -1439,3 +1458,89 @@ ...@@ -1439,3 +1458,89 @@
border-right: 0; border-right: 0;
} }
} }
/* Fixed split-shell theme: dark navigation, permanently light content canvas. */
.template-dashboard-shell .app-sidebar-glass,
.rc-mobile-sidebar.app-sidebar-glass {
color: var(--rc-sidebar-foreground);
border-color: var(--rc-sidebar-border) !important;
background-color: var(--rc-sidebar-background) !important;
}
.template-dashboard-shell
.sidebar__menu-item[data-current='true']
.sidebar__menu-item-content,
.rc-mobile-sidebar
.sidebar__menu-item[data-current='true']
.sidebar__menu-item-content {
background: var(--rc-sidebar-item-selected);
}
.template-dashboard-shell
.sidebar__menu-item[data-current='true']
.sidebar__menu-item-content,
.template-dashboard-shell
.sidebar__menu-item[data-current='true']
.sidebar__menu-item-content *,
.rc-mobile-sidebar
.sidebar__menu-item[data-current='true']
.sidebar__menu-item-content,
.rc-mobile-sidebar
.sidebar__menu-item[data-current='true']
.sidebar__menu-item-content * {
color: var(--rc-sidebar-item-selected-foreground) !important;
}
.template-dashboard-shell .sidebar__menu-item:hover .sidebar__menu-item-content,
.template-dashboard-shell
.sidebar__menu-item[data-hovered='true']
.sidebar__menu-item-content,
.rc-mobile-sidebar .sidebar__menu-item:hover .sidebar__menu-item-content,
.rc-mobile-sidebar
.sidebar__menu-item[data-hovered='true']
.sidebar__menu-item-content,
.app-sidebar-user-trigger.dropdown__trigger:hover,
.app-sidebar-user-trigger.dropdown__trigger[data-hovered='true'] {
background: var(--rc-sidebar-item-hover);
}
.app-sidebar-user-trigger.dropdown__trigger[aria-expanded='true'],
.app-sidebar-app-trigger.button[data-pressed='true'],
.app-sidebar-app-trigger.button[aria-expanded='true'] {
background: var(--rc-sidebar-item-selected);
}
.app-sidebar-user-avatar,
.app-sidebar-app-trigger.button,
.app-sidebar-user-trigger.dropdown__trigger {
color: var(--rc-sidebar-foreground);
background: transparent;
}
.app-sidebar-user-avatar {
background: var(--rc-sidebar-item-hover);
}
.template-dashboard-shell .sidebar__group-label,
.rc-mobile-sidebar .sidebar__group-label,
.template-dashboard-shell .sidebar__menu-icon,
.rc-mobile-sidebar .sidebar__menu-icon,
.app-sidebar-user-description {
color: var(--rc-sidebar-muted);
}
.template-dashboard-shell .sidebar__menu-label,
.rc-mobile-sidebar .sidebar__menu-label,
.app-sidebar-title,
.app-sidebar-user-name,
.app-sidebar-app-trigger.button {
color: var(--rc-sidebar-foreground);
}
.app-sidebar-user-trigger.dropdown__trigger:focus-visible,
.app-sidebar-app-trigger.button:focus-visible,
.template-dashboard-shell .sidebar__menu-item:focus-visible,
.rc-mobile-sidebar .sidebar__menu-item:focus-visible {
outline: 2px solid var(--rc-sidebar-focus);
outline-offset: 2px;
}
...@@ -11,38 +11,37 @@ ...@@ -11,38 +11,37 @@
[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); --accent: oklch(57.14% 0.2156 29.20);
--accent-foreground: oklch(99.11% 0 0); --accent-foreground: oklch(99.11% 0 0);
--background: oklch(97.02% 0.0045 27.03); --background: oklch(97.02% 0.0000 29.20);
--border: oklch(90.00% 0.0045 27.03); --border: oklch(90.00% 0.0000 29.20);
--danger: oklch(45.32% 0.1661 27.03); --danger: oklch(45.32% 0.1661 27.03);
--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: oklch(57.14% 0.2156 29.20);
--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: oklch(73.29% 0.1935 123.85);
--success-foreground: oklch(21.03% 0.0059 123.59); --success-foreground: oklch(21.03% 0.0059 123.85);
--surface: oklch(100.00% 0.0022 27.03); --surface: oklch(100.00% 0.0000 29.20);
--surface-foreground: oklch(21.03% 0.0045 27.03); --surface-foreground: oklch(21.03% 0.0000 29.20);
--surface-secondary: oklch(95.24% 0.0036 27.03); --surface-secondary: oklch(95.24% 0.0000 29.20);
--surface-secondary-foreground: oklch(21.03% 0.0045 27.03); --surface-secondary-foreground: oklch(21.03% 0.0000 29.20);
--surface-tertiary: oklch(93.73% 0.0036 27.03); --surface-tertiary: oklch(93.73% 0.0000 29.20);
--surface-tertiary-foreground: oklch(21.03% 0.0045 27.03); --surface-tertiary-foreground: oklch(21.03% 0.0000 29.20);
--warning: oklch(78.19% 0.1599 45.11); --warning: oklch(78.19% 0.1585 45.37);
--warning-foreground: oklch(21.03% 0.0059 45.11); --warning-foreground: oklch(21.03% 0.0059 45.37);
/* Border Radius */ /* Border Radius */
--radius: 0.5rem; --radius: 0.5rem;
...@@ -51,42 +50,86 @@ ...@@ -51,42 +50,86 @@
/* 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.
*/
--xinyuan-chart-1: #d92d20;
--xinyuan-chart-2: #ef4444;
--xinyuan-chart-3: #f97316;
--xinyuan-chart-4: #f59e0b;
--xinyuan-chart-5: #22c55e;
--xinyuan-chart-6: #14b87a;
--xinyuan-chart-7: #06b6d4;
--xinyuan-chart-8: #0ea5e9;
--xinyuan-chart-9: #3b82f6;
--xinyuan-chart-10: #6366f1;
--xinyuan-chart-11: #8b5cf6;
--xinyuan-chart-12: #7c3aed;
--xinyuan-chart-13: #b42318;
--xinyuan-chart-14: #84cc16;
--xinyuan-chart-15: #a855f7;
--xinyuan-chart-16: #64748b;
--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: oklch(57.14% 0.2156 29.20);
--accent-foreground: oklch(99.11% 0 0); --accent-foreground: oklch(99.11% 0 0);
--background: oklch(12.00% 0.0045 27.03); --background: oklch(12.00% 0.0000 29.20);
--border: oklch(28.00% 0.0045 27.03); --border: oklch(28.00% 0.0000 29.20);
--danger: oklch(62.00% 0.1600 27.03); --danger: oklch(62% 0.16 27.03);
--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: oklch(57.14% 0.2156 29.20);
--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: oklch(73.29% 0.1935 123.85);
--success-foreground: oklch(21.03% 0.0059 123.59); --success-foreground: oklch(21.03% 0.0059 123.85);
--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,9 +2,7 @@ ...@@ -2,9 +2,7 @@
.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-light: color-mix(in oklab, var(--accent) 84%, white); --rc-brand-light: color-mix(in oklab, var(--accent) 84%, white);
...@@ -27,6 +25,17 @@ ...@@ -27,6 +25,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;
...@@ -139,40 +148,28 @@ ...@@ -139,40 +148,28 @@
); );
/* 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);
--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: oklch(47% 0.145 27.03);
--rc-chart-warning: var(--rc-chart-6); --rc-chart-warning: var(--rc-chart-6);
--rc-chart-positive: var(--rc-chart-2); --rc-chart-positive: var(--rc-chart-2);
--rc-chart-comparison: var(--rc-chart-4); --rc-chart-comparison: var(--rc-chart-4);
--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-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%);
} }
...@@ -131,7 +131,7 @@ function CellValue({ value, field }) { ...@@ -131,7 +131,7 @@ function CellValue({ value, field }) {
<DataTableText <DataTableText
className={['object', 'form', 'subject'].includes(fieldType) ? 'text-muted' : 'text-foreground'} className={['object', 'form', 'subject'].includes(fieldType) ? 'text-muted' : 'text-foreground'}
maxLines={isLongText ? 2 : 1} maxLines={isLongText ? 2 : 1}
showTooltip showTooltip={false}
text={text} text={text}
/> />
); );
...@@ -151,7 +151,7 @@ function getReferenceColumnWidth(column, index) { ...@@ -151,7 +151,7 @@ function getReferenceColumnWidth(column, index) {
function ReferenceRecordsFrame({ action, count, children }) { function ReferenceRecordsFrame({ action, count, children }) {
return ( return (
<div className="space-y-2"> <div className="w-full min-w-0 space-y-2">
<div className="flex items-center justify-between gap-3 text-xs text-muted"> <div className="flex items-center justify-between gap-3 text-xs text-muted">
<span>共 {count} 条关联记录</span> <span>共 {count} 条关联记录</span>
{action} {action}
......
import clsx from 'clsx'; import clsx from 'clsx';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { Surface } from '@heroui/react'; import { Surface } from '@heroui/react';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { EmptyStateSurface } from '@/components/DataTable';
import TailwindEditableField from './TailwindEditableField'; import TailwindEditableField from './TailwindEditableField';
import { import {
buildViewColumns, buildViewColumns,
getFieldValue, getFieldValue,
isFieldDisabledByView, isFieldDisabledByView,
isReferenceFieldType,
} from '@/utils/subjectView'; } from '@/utils/subjectView';
const FIELD_LABEL_OVERRIDES = { const FIELD_LABEL_OVERRIDES = {
problem_discovery_situation: '问题发现情况', problem_discovery_situation: '问题发现情况',
}; };
const FULL_WIDTH_FIELD_TYPES = new Set([
'long_text',
'rich_text',
'object',
'form',
'subject',
'file',
'reference',
'document',
'view',
'template',
'task',
]);
function getFieldName(fieldPath = '') { function getFieldName(fieldPath = '') {
return String(fieldPath).replace(/^metadata\./, '').split('.').pop(); return String(fieldPath).replace(/^metadata\./, '').split('.').pop();
} }
...@@ -73,25 +59,27 @@ export default function SubjectViewContent({ ...@@ -73,25 +59,27 @@ export default function SubjectViewContent({
} }
return ( return (
<EmptyState size="md"> <Surface className="rc-workspace-module w-full min-w-0 p-4" variant="tertiary">
<EmptyState.Header><EmptyState.Title>暂无可显示的字段</EmptyState.Title></EmptyState.Header> <EmptyStateSurface>
</EmptyState> <EmptyState size="md">
<EmptyState.Header>
<EmptyState.Description className="rc-empty-state-message">
暂无可显示的字段
</EmptyState.Description>
</EmptyState.Header>
</EmptyState>
</EmptyStateSurface>
</Surface>
); );
} }
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 = getFieldLabel(field); const fieldLabel = getFieldLabel(field);
const isFullWidth = FULL_WIDTH_FIELD_TYPES.has(fieldType);
const isNestedStaticField =
mode === 'view' &&
!minimal &&
(fieldType === 'form' || isReferenceFieldType(fieldType));
const fieldDisabled = isFieldDisabled const fieldDisabled = isFieldDisabled
? isFieldDisabled(field.field, view) ? isFieldDisabled(field.field, view)
: isFieldDisabledByView(field.field, view); : isFieldDisabledByView(field.field, view);
...@@ -130,15 +118,9 @@ export default function SubjectViewContent({ ...@@ -130,15 +118,9 @@ export default function SubjectViewContent({
const fieldClassName = clsx( const fieldClassName = clsx(
'min-w-0', 'min-w-0',
minimal ? 'py-1' : 'py-3', minimal ? 'py-1' : 'py-3',
isNestedStaticField && 'px-4 sm:px-5',
isFullWidth && 'md:col-span-2',
); );
return isNestedStaticField ? ( return (
<Surface key={field.field} className={fieldClassName} variant="tertiary">
{fieldContent}
</Surface>
) : (
<div key={field.field} className={fieldClassName}> <div key={field.field} className={fieldClassName}>
{fieldContent} {fieldContent}
</div> </div>
...@@ -148,11 +130,7 @@ export default function SubjectViewContent({ ...@@ -148,11 +130,7 @@ export default function SubjectViewContent({
); );
if (mode === 'view' && !minimal) { if (mode === 'view' && !minimal) {
return ( return fields;
<Surface className="rc-detail-view-surface px-5 py-2 sm:px-6 sm:py-3" variant="secondary">
{fields}
</Surface>
);
} }
return fields; return fields;
......
...@@ -86,6 +86,10 @@ function isEmptyFieldValue(value) { ...@@ -86,6 +86,10 @@ function isEmptyFieldValue(value) {
return false; return false;
} }
function EmptyFieldValue() {
return <span className="rc-empty-field-text">暂无内容</span>;
}
function getTextPlaceholder(placeholder, fieldDef) { function getTextPlaceholder(placeholder, fieldDef) {
const explicitPlaceholder = String(placeholder || '').trim(); const explicitPlaceholder = String(placeholder || '').trim();
if (explicitPlaceholder) return explicitPlaceholder; if (explicitPlaceholder) return explicitPlaceholder;
...@@ -94,9 +98,9 @@ function getTextPlaceholder(placeholder, fieldDef) { ...@@ -94,9 +98,9 @@ function getTextPlaceholder(placeholder, fieldDef) {
return fieldLabel ? `请输入${fieldLabel}` : '请输入内容'; return fieldLabel ? `请输入${fieldLabel}` : '请输入内容';
} }
function FieldValue({ value, fieldDef }) { function FieldValue({ value, fieldDef, plainNested = false }) {
if (isEmptyFieldValue(value)) { if (isEmptyFieldValue(value)) {
return <span className="text-sm text-muted">暂无内容</span>; return <EmptyFieldValue />;
} }
if (fieldDef?.type === 'select') { if (fieldDef?.type === 'select') {
...@@ -157,23 +161,18 @@ function FieldValue({ value, fieldDef }) { ...@@ -157,23 +161,18 @@ function FieldValue({ value, fieldDef }) {
} }
if (fieldDef?.type === 'object' || fieldDef?.type === 'subject') { if (fieldDef?.type === 'object' || fieldDef?.type === 'subject') {
return ( const text = formatFieldValue(value, fieldDef);
<Card variant="tertiary"> return isEmptyFieldValue(text)
<Card.Content> ? <EmptyFieldValue />
<pre className="max-h-48 overflow-auto text-xs leading-5"> : <span className="whitespace-pre-wrap text-foreground">{text}</span>;
{JSON.stringify(value, null, 2)}
</pre>
</Card.Content>
</Card>
);
} }
const text = formatFieldValue(value, fieldDef); const text = formatFieldValue(value, fieldDef);
return ( if (isEmptyFieldValue(text)) {
<span className={clsx('whitespace-pre-wrap', isEmptyFieldValue(text) ? 'text-sm text-muted' : 'text-foreground')}> return <EmptyFieldValue />;
{isEmptyFieldValue(text) ? '暂无内容' : text} }
</span>
); return <span className={clsx('whitespace-pre-wrap', 'text-foreground')}>{text}</span>;
} }
function UserSelector({ value, onChange, multiple, placeholder = '请选择用户', variant }) { function UserSelector({ value, onChange, multiple, placeholder = '请选择用户', variant }) {
...@@ -305,7 +304,7 @@ function ReadonlyFieldValue({ value, fieldDef }) { ...@@ -305,7 +304,7 @@ function ReadonlyFieldValue({ value, fieldDef }) {
return <ReferenceRecordsTable value={value} fieldDef={fieldDef} />; return <ReferenceRecordsTable value={value} fieldDef={fieldDef} />;
} }
return <FieldValue value={value} fieldDef={fieldDef} />; return <FieldValue value={value} fieldDef={fieldDef} plainNested />;
} }
function ControlledFieldEditor({ function ControlledFieldEditor({
......
...@@ -20,21 +20,10 @@ import { ...@@ -20,21 +20,10 @@ import {
getFieldValue, getFieldValue,
hasFieldAuthorizationConfiguration, hasFieldAuthorizationConfiguration,
setFieldValue, setFieldValue,
shouldHideViewTitleColumn,
} from '@/utils/subjectView'; } from '@/utils/subjectView';
const EMPTY_ARRAY = []; const EMPTY_ARRAY = [];
const NON_EDITABLE_FIELD_TYPES = new Set(['subject']); const NON_EDITABLE_FIELD_TYPES = new Set(['subject']);
const PLACEHOLDER_TITLES = new Set([
'(无标题)',
'(无标题)',
'无标题',
'未命名记录',
]);
function isPlaceholderTitle(value) {
return PLACEHOLDER_TITLES.has(String(value || '').trim());
}
function cloneRecord(record) { function cloneRecord(record) {
return record ? JSON.parse(JSON.stringify(record)) : null; return record ? JSON.parse(JSON.stringify(record)) : null;
...@@ -255,7 +244,6 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -255,7 +244,6 @@ const RecordDetailDrawer = (props, ref) => {
const activeView = enabledDetailViews.find((view) => view.name === activeTabKey) const activeView = enabledDetailViews.find((view) => view.name === activeTabKey)
|| enabledDetailViews[enabledDetailViews.length - 1] || enabledDetailViews[enabledDetailViews.length - 1]
|| detailViews[0]; || detailViews[0];
const hideRecordTitle = shouldHideViewTitleColumn(activeView);
const isRecordLocked = remoteRecord?.metadata?.is_locked === true; const isRecordLocked = remoteRecord?.metadata?.is_locked === true;
const effectiveReadonly = readonly || isRecordLocked; const effectiveReadonly = readonly || isRecordLocked;
const disabledFieldSet = useMemo( const disabledFieldSet = useMemo(
...@@ -470,14 +458,6 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -470,14 +458,6 @@ const RecordDetailDrawer = (props, ref) => {
} }
}; };
const recordTitle =
displayedRecord?.title ||
displayedRecord?.display_name ||
displayedRecord?.name ||
recordName ||
'记录';
const showRecordTitle = !hideRecordTitle && (loading || !isPlaceholderTitle(recordTitle));
return ( return (
<Modal.Root> <Modal.Root>
<Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} /> <Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
...@@ -491,7 +471,7 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -491,7 +471,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 : 'p-7 sm:max-w-[68rem]'}> <Modal.Dialog className={isFullscreen ? 'bg-background' : 'bg-background p-7 sm:max-w-[68rem]'}>
<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>
...@@ -521,11 +501,6 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -521,11 +501,6 @@ const RecordDetailDrawer = (props, ref) => {
<Modal.Header className="shrink-0 pr-16"> <Modal.Header className="shrink-0 pr-16">
<Modal.Heading>{title || '记录详情'}</Modal.Heading> <Modal.Heading>{title || '记录详情'}</Modal.Heading>
{showRecordTitle ? (
<div className="truncate text-sm text-muted" title={loading ? undefined : recordTitle}>
{loading ? '正在读取记录信息' : recordTitle}
</div>
) : null}
{detailViews.length > 1 ? ( {detailViews.length > 1 ? (
<Tabs <Tabs
......
...@@ -5,15 +5,18 @@ import { ...@@ -5,15 +5,18 @@ import {
APPLICATION_NAME, APPLICATION_NAME,
} from '@/config/application'; } from '@/config/application';
import { getApplications } from '@/services/DataService'; import { getApplications } from '@/services/DataService';
import { ExclamationTriangleIcon } from '@/components/AppIcons'; import { EmptyStateSurface } from '@/components/DataTable';
import { ExclamationTriangleIcon, MagnifyingGlassIcon } from '@/components/AppIcons';
import { EmptyState } from '@heroui-pro/react/empty-state'; import { EmptyState } from '@heroui-pro/react/empty-state';
import { ItemCard } from '@heroui-pro/react/item-card';
import { ItemCardGroup } from '@heroui-pro/react/item-card-group';
import { import {
Alert, Alert,
Avatar, Avatar,
Card,
Chip, Chip,
Description,
Modal, Modal,
ScrollShadow,
SearchField,
Skeleton, Skeleton,
Surface, Surface,
} from '@heroui/react'; } from '@heroui/react';
...@@ -75,13 +78,7 @@ function normalizeApplication(application, fallback = {}) { ...@@ -75,13 +78,7 @@ function normalizeApplication(application, fallback = {}) {
fallback?.title || fallback?.title ||
application?.title || application?.title ||
'进入对应应用继续处理业务', '进入对应应用继续处理业务',
logo: current logo: xinyuanLogo,
? xinyuanLogo
: application?.logo ||
application?.icon ||
fallback?.logo ||
fallback?.icon ||
null,
url: resolveApplicationUrl({ name }), url: resolveApplicationUrl({ name }),
access: application?.access || fallback?.access || '', access: application?.access || fallback?.access || '',
status: application?.status || fallback?.status || '', status: application?.status || fallback?.status || '',
...@@ -216,70 +213,76 @@ function isCurrentApplication(application) { ...@@ -216,70 +213,76 @@ function isCurrentApplication(application) {
function ApplicationCard({ application, current }) { function ApplicationCard({ application, current }) {
const target = application?.url?.startsWith('http') ? '_blank' : undefined; const target = application?.url?.startsWith('http') ? '_blank' : undefined;
const initial = application.title.trim().slice(0, 1).toUpperCase() || 'A'; const description = application.description?.trim();
const hasDistinctDescription =
description && description !== application.title.trim();
return ( return (
<Card className="h-full" variant="secondary"> <ItemCard
<Card.Content> aria-current={current ? 'page' : undefined}
<a className="h-full min-w-0 no-underline"
href={application.url} href={application.url}
target={target} rel={target ? 'noreferrer' : undefined}
rel={target ? 'noreferrer' : undefined} render={(props) => <a {...props} />}
aria-current={current ? 'page' : undefined} target={target}
className="flex min-w-0 items-center gap-3 no-underline" variant={current ? 'outline' : 'default'}
> >
<Surface aria-hidden="true" variant="tertiary" className="rc-application-identity-well"> <ItemCard.Icon aria-hidden="true">
<Avatar <Surface variant="default" className="rc-application-identity-well">
className="rc-application-identity-avatar" <Avatar
color="accent" className="rc-application-identity-avatar"
variant="soft" color="accent"
> variant="soft"
{application.logo ? ( >
<Avatar.Image <Avatar.Image
alt="" alt=""
className="object-contain p-1.5" className="object-contain p-1.5"
src={application.logo} src={application.logo || xinyuanLogo}
/> />
) : null} <Avatar.Fallback>
<Avatar.Fallback>{initial}</Avatar.Fallback> <img alt="" className="object-contain p-1.5" src={xinyuanLogo} />
</Avatar> </Avatar.Fallback>
</Surface> </Avatar>
<span className="min-w-0 flex-1"> </Surface>
<span </ItemCard.Icon>
className="block truncate text-sm font-medium leading-5" <ItemCard.Content>
title={application.title} <ItemCard.Title title={application.title}>
> {application.title}
{application.title} </ItemCard.Title>
</span> {hasDistinctDescription ? (
<Description <ItemCard.Description title={description}>
className="mt-0.5 block truncate leading-4" {description}
title={application.description} </ItemCard.Description>
> ) : null}
{application.description || application.name || '应用'} </ItemCard.Content>
</Description> {current ? (
</span> <ItemCard.Action>
{current ? <Chip color="accent" size="sm">当前</Chip> : null} <Chip color="accent" size="sm">当前</Chip>
</a> </ItemCard.Action>
</Card.Content> ) : null}
</Card> </ItemCard>
); );
} }
function LoadingSkeleton() { function LoadingSkeleton() {
return ( return (
<div className="grid gap-3 sm:grid-cols-2" aria-label="正在加载应用"> <ItemCardGroup
aria-label="正在加载应用"
className="grid-cols-1 sm:grid-cols-2"
columns={2}
layout="grid"
variant="transparent"
>
{Array.from({ length: 6 }).map((_, index) => ( {Array.from({ length: 6 }).map((_, index) => (
<Card key={index} variant="secondary"> <ItemCard key={index} variant="default">
<Card.Content className="flex items-center gap-3"> <ItemCard.Icon><Skeleton className="size-9" /></ItemCard.Icon>
<Skeleton className="size-9" /> <ItemCard.Content className="gap-2">
<div className="flex-1 space-y-2"> <Skeleton className="h-4 w-2/3" />
<Skeleton className="h-4 w-2/3" /> <Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-full" /> </ItemCard.Content>
</div> </ItemCard>
</Card.Content>
</Card>
))} ))}
</div> </ItemCardGroup>
); );
} }
...@@ -311,6 +314,7 @@ export default function ApplicationSwitcherDialog({ ...@@ -311,6 +314,7 @@ export default function ApplicationSwitcherDialog({
fallbackApplications.length > 0 ? 'success' : 'idle', fallbackApplications.length > 0 ? 'success' : 'idle',
); );
const [errorMessage, setErrorMessage] = useState(''); const [errorMessage, setErrorMessage] = useState('');
const [applicationSearch, setApplicationSearch] = useState('');
const currentApplicationName = useMemo(() => { const currentApplicationName = useMemo(() => {
return applications.find(isCurrentApplication)?.name || CURRENT_APPLICATION_NAME; return applications.find(isCurrentApplication)?.name || CURRENT_APPLICATION_NAME;
}, [applications]); }, [applications]);
...@@ -318,6 +322,23 @@ export default function ApplicationSwitcherDialog({ ...@@ -318,6 +322,23 @@ export default function ApplicationSwitcherDialog({
() => buildApplicationSections(applications, siteInfo?.group), () => buildApplicationSections(applications, siteInfo?.group),
[applications, siteInfo?.group], [applications, siteInfo?.group],
); );
const filteredApplicationSections = useMemo(() => {
const keyword = applicationSearch.trim().toLocaleLowerCase();
if (!keyword) {
return applicationSections;
}
return applicationSections
.map((section) => ({
...section,
applications: section.applications.filter((application) => [
application.title,
application.description,
application.name,
].some((value) => String(value || '').toLocaleLowerCase().includes(keyword))),
}))
.filter((section) => section.applications.length > 0);
}, [applicationSearch, applicationSections]);
useEffect(() => { useEffect(() => {
if (fallbackApplications.length === 0) { if (fallbackApplications.length === 0) {
...@@ -331,6 +352,7 @@ export default function ApplicationSwitcherDialog({ ...@@ -331,6 +352,7 @@ export default function ApplicationSwitcherDialog({
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
setApplicationSearch('');
return; return;
} }
...@@ -386,7 +408,7 @@ export default function ApplicationSwitcherDialog({ ...@@ -386,7 +408,7 @@ export default function ApplicationSwitcherDialog({
<Modal.Container placement="center" scroll="inside" size="lg"> <Modal.Container placement="center" scroll="inside" size="lg">
<Modal.Dialog <Modal.Dialog
aria-label="应用切换" aria-label="应用切换"
className="max-h-[min(42rem,calc(100dvh-2rem))] p-7" className="max-h-[min(42rem,calc(100dvh-2rem))] bg-background p-7"
style={{ style={{
maxWidth: 'min(46rem, calc(100vw - 2rem))', maxWidth: 'min(46rem, calc(100vw - 2rem))',
width: 'min(46rem, calc(100vw - 2rem))', width: 'min(46rem, calc(100vw - 2rem))',
...@@ -397,30 +419,56 @@ export default function ApplicationSwitcherDialog({ ...@@ -397,30 +419,56 @@ export default function ApplicationSwitcherDialog({
style={{ insetInlineEnd: '1.75rem', top: '1.75rem' }} style={{ insetInlineEnd: '1.75rem', top: '1.75rem' }}
/> />
<Modal.Header> <Modal.Header>
<Modal.Heading>应用切换</Modal.Heading> <div className="flex w-full min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm text-muted">选择你可访问的应用</div> <SearchField
aria-label="搜索应用"
className="w-full min-w-0 max-w-full sm:flex-1 sm:w-auto"
value={applicationSearch}
variant="secondary"
onChange={setApplicationSearch}
>
<SearchField.Group>
<SearchField.SearchIcon>
<MagnifyingGlassIcon size={16} />
</SearchField.SearchIcon>
<SearchField.Input placeholder="搜索应用" />
<SearchField.ClearButton aria-label="清空应用搜索" />
</SearchField.Group>
</SearchField>
</div>
</Modal.Header> </Modal.Header>
<Modal.Body style={{ marginTop: '0.75rem' }}> <Modal.Body className="flex min-h-0 overflow-hidden" style={{ marginTop: '0.75rem' }}>
{status === 'error' && errorMessage ? ( <ScrollShadow
<Alert status="warning"> className="h-full min-h-0 flex-1 overflow-y-auto pe-1 pb-1"
<Alert.Indicator><ExclamationTriangleIcon size={20} /></Alert.Indicator> hideScrollBar={false}
<Alert.Content><Alert.Description>{errorMessage}</Alert.Description></Alert.Content> offset={12}
</Alert> size={24}
) : null} >
{status === 'error' && errorMessage ? (
{status === 'loading' && applications.length === 0 ? ( <Alert className="mb-4" status="warning">
<LoadingSkeleton /> <Alert.Indicator><ExclamationTriangleIcon size={20} /></Alert.Indicator>
) : applications.length > 0 ? ( <Alert.Content><Alert.Description>{errorMessage}</Alert.Description></Alert.Content>
<div className="space-y-6"> </Alert>
{applicationSections.map((section) => ( ) : null}
<section key={section.key}>
<div className="mb-3 flex items-center justify-between gap-3"> {status === 'loading' && applications.length === 0 ? (
<h3 className="text-sm font-medium leading-5"> <LoadingSkeleton />
{section.label} ) : filteredApplicationSections.length > 0 ? (
</h3> <div className="space-y-6">
<Description>{section.applications.length} 个应用</Description> {filteredApplicationSections.map((section) => (
</div> <ItemCardGroup
<div className="grid gap-3 sm:grid-cols-2"> key={section.key}
className="grid-cols-1 sm:grid-cols-2"
columns={2}
layout="grid"
variant="transparent"
>
<ItemCardGroup.Header className="flex items-center justify-between gap-3">
<ItemCardGroup.Title>{section.label}</ItemCardGroup.Title>
<ItemCardGroup.Description className="mt-0">
{section.applications.length} 个应用
</ItemCardGroup.Description>
</ItemCardGroup.Header>
{section.applications.map((application) => ( {section.applications.map((application) => (
<ApplicationCard <ApplicationCard
key={application.name} key={application.name}
...@@ -428,18 +476,26 @@ export default function ApplicationSwitcherDialog({ ...@@ -428,18 +476,26 @@ export default function ApplicationSwitcherDialog({
current={application.name === currentApplicationName} current={application.name === currentApplicationName}
/> />
))} ))}
</div> </ItemCardGroup>
</section> ))}
))} </div>
</div> ) : (
) : ( <EmptyStateSurface>
<EmptyState size="md"> <EmptyState size="md">
<EmptyState.Header> <EmptyState.Header>
<EmptyState.Title>暂无可切换应用</EmptyState.Title> <EmptyState.Title>
<EmptyState.Description>当前账号还没有分配其它应用权限。</EmptyState.Description> {applicationSearch.trim() ? '没有找到匹配的应用' : '暂无可切换应用'}
</EmptyState.Header> </EmptyState.Title>
</EmptyState> <EmptyState.Description>
)} {applicationSearch.trim()
? '请尝试其他关键词。'
: '当前账号还没有分配其它应用权限。'}
</EmptyState.Description>
</EmptyState.Header>
</EmptyState>
</EmptyStateSurface>
)}
</ScrollShadow>
</Modal.Body> </Modal.Body>
</Modal.Dialog> </Modal.Dialog>
</Modal.Container> </Modal.Container>
......
import RecordDetailDrawer from '@/pages/RecordDetailDrawer'; import RecordDetailDrawer from '@/pages/RecordDetailDrawer';
import { EmptyStateSurface } from '@/components/DataTable';
import XinyuanModal from '@/components/XinyuanModal'; import XinyuanModal from '@/components/XinyuanModal';
import XinyuanEChart from '@/components/XinyuanEChart';
import { import {
colorizeXinyuanChartTooltipPayload, createResolvedXinyuanChartColorMap,
createXinyuanChartColorMap, getXinyuanEChartsTheme,
getXinyuanChartColor,
} from '@/design-system/chartPalette'; } from '@/design-system/chartPalette';
import { import {
CheckCircleIcon, CheckCircleIcon,
...@@ -12,12 +13,9 @@ import { ...@@ -12,12 +13,9 @@ import {
ChartPieIcon, ChartPieIcon,
} from '@/components/AppIcons'; } from '@/components/AppIcons';
import { Alert, Button, Card, Chip, Spinner } from '@heroui/react'; import { Alert, Button, Card, Chip, Spinner } from '@heroui/react';
import { BarChart } from '@heroui-pro/react/bar-chart';
import { ChartTooltipContent } from '@heroui-pro/react/chart-tooltip';
import { EmptyState } from '@heroui-pro/react/empty-state'; import { EmptyState } from '@heroui-pro/react/empty-state';
import { KPI } from '@heroui-pro/react/kpi'; import { KPI } from '@heroui-pro/react/kpi';
import { ListView } from '@heroui-pro/react/list-view'; import { ListView } from '@heroui-pro/react/list-view';
import { PieChart } from '@heroui-pro/react/pie-chart';
import { PressableFeedback } from '@heroui-pro/react/pressable-feedback'; import { PressableFeedback } from '@heroui-pro/react/pressable-feedback';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
...@@ -102,9 +100,15 @@ function ProjectPanel({ title, records, emptyText, onOpen }) { ...@@ -102,9 +100,15 @@ function ProjectPanel({ title, records, emptyText, onOpen }) {
aria-label={title} aria-label={title}
items={records} items={records}
renderEmptyState={() => ( renderEmptyState={() => (
<EmptyState className="min-h-48" size="sm"> <EmptyStateSurface>
<EmptyState.Header><EmptyState.Title>{emptyText}</EmptyState.Title></EmptyState.Header> <EmptyState className="min-h-48" size="sm">
</EmptyState> <EmptyState.Header>
<EmptyState.Description className="rc-empty-state-message">
{emptyText}
</EmptyState.Description>
</EmptyState.Header>
</EmptyState>
</EmptyStateSurface>
)} )}
> >
{(record) => <ProjectRow record={record} onOpen={onOpen} />} {(record) => <ProjectRow record={record} onOpen={onOpen} />}
...@@ -115,50 +119,129 @@ function ProjectPanel({ title, records, emptyText, onOpen }) { ...@@ -115,50 +119,129 @@ function ProjectPanel({ title, records, emptyText, onOpen }) {
} }
function StatusChart({ data }) { function StatusChart({ data }) {
const colorMap = createXinyuanChartColorMap(data.map((item) => item.name)); const option = useMemo(() => {
return ( const theme = getXinyuanEChartsTheme();
<PieChart className="rc-chart-interaction" height={320}> const colorMap = createResolvedXinyuanChartColorMap(
<PieChart.Pie data={data} dataKey="value" innerRadius="52%" nameKey="name" outerRadius="72%" paddingAngle={2}> data.map((item) => item.name),
{data.map((item, index) => ( );
<PieChart.Cell key={item.name} fill={colorMap.get(item.name) || getXinyuanChartColor(item.name, index)} />
))} return {
</PieChart.Pie> legend: {
<PieChart.Tooltip content={({ active, label, payload }) => ( bottom: 0,
<ChartTooltipContent left: 'center',
active={active} },
label={label} series: [{
payload={colorizeXinyuanChartTooltipPayload(payload, ({ key, index }) => colorMap.get(key) || getXinyuanChartColor(key, index))} avoidLabelOverlap: true,
valueFormatter={(value) => `${value} 个`} data: data.map((item) => ({
/> ...item,
)} /> itemStyle: { color: colorMap.get(item.name) },
</PieChart> })),
); emphasis: {
scaleSize: 4,
},
label: {
alignTo: 'labelLine',
distanceToLabelLine: 6,
formatter: ({ name, value }) => `{name|${name}}\n{value|${value} 条}`,
lineHeight: 18,
minMargin: 10,
rich: {
name: {
color: theme.label,
fontSize: 13,
fontWeight: 500,
lineHeight: 18,
},
value: {
color: theme.label,
fontSize: 14,
fontWeight: 600,
lineHeight: 20,
},
},
show: true,
},
labelLine: {
length: 20,
length2: 22,
lineStyle: {
color: theme.grid,
width: 1.5,
},
show: true,
},
labelLayout: (params) => {
const points = params?.labelLinePoints;
if (!Array.isArray(points) || points.length < 3) return null;
const endpoint = points[2];
const isRight = endpoint[0] >= points[0][0];
return {
align: isRight ? 'left' : 'right',
hideOverlap: true,
moveOverlap: 'none',
verticalAlign: 'middle',
x: endpoint[0] + (isRight ? 8 : -8),
y: endpoint[1],
};
},
name: '项目状态',
radius: ['52%', '72%'],
center: ['50%', '44%'],
type: 'pie',
}],
tooltip: {
trigger: 'item',
valueFormatter: (value) => `${value} 个`,
},
};
}, [data]);
return <XinyuanEChart ariaLabel="项目状态分布图" option={option} />;
} }
function StageChart({ data }) { function StageChart({ data }) {
const colorMap = createXinyuanChartColorMap(data.map((item) => item.name)); const option = useMemo(() => {
return ( const theme = getXinyuanEChartsTheme();
<BarChart className="rc-chart-interaction" data={data} height={320}>
<BarChart.Grid vertical={false} /> return {
<BarChart.XAxis dataKey="name" /> grid: {
<BarChart.YAxis allowDecimals={false} /> bottom: 20,
<BarChart.Tooltip content={({ active, label, payload }) => ( containLabel: true,
<ChartTooltipContent left: 12,
active={active} right: 12,
label={label} top: 12,
payload={colorizeXinyuanChartTooltipPayload(payload, ({ key, index }) => colorMap.get(key) || getXinyuanChartColor(key, index))} },
valueFormatter={(value) => `${value} 个`} series: [{
/> barMaxWidth: 28,
)} /> data: data.map((item) => item.value),
<BarChart.Bar itemStyle: {
dataKey="value" borderRadius: [8, 8, 0, 0],
fill="var(--rc-chart-primary)" color: theme.palette[0],
maxBarSize={28} },
name="项目数量" name: '项目数量',
radius={[8, 8, 0, 0]} type: 'bar',
/> }],
</BarChart> tooltip: {
); axisPointer: { type: 'shadow' },
trigger: 'axis',
valueFormatter: (value) => `${value} 个`,
},
xAxis: {
axisTick: { show: false },
data: data.map((item) => item.name),
type: 'category',
},
yAxis: {
minInterval: 1,
splitLine: { show: true },
type: 'value',
},
};
}, [data]);
return <XinyuanEChart ariaLabel="开展中项目进度柱状图" option={option} />;
} }
function ProjectListDialog({ group, onClose, onOpenRecord }) { function ProjectListDialog({ group, onClose, onOpenRecord }) {
......
...@@ -11,6 +11,7 @@ import { ...@@ -11,6 +11,7 @@ import {
getFieldValue, getFieldValue,
isViewAccessible, isViewAccessible,
normalizeText, normalizeText,
normalizeProjectYearDisplay,
recordMatchesRestrictions, recordMatchesRestrictions,
sortRecordsByView, sortRecordsByView,
} from '@/utils/subjectView'; } from '@/utils/subjectView';
...@@ -156,15 +157,18 @@ export function findSubjectField(subject, fieldName) { ...@@ -156,15 +157,18 @@ export function findSubjectField(subject, fieldName) {
} }
export function collectProjectYears(records = []) { export function collectProjectYears(records = []) {
return Array.from( const yearValues = new Map();
new Set( records.forEach((record) => {
records const value = normalizeText((record.raw || record)?.metadata?.project_year);
.map((record) => normalizeText((record.raw || record)?.metadata?.project_year)) const label = normalizeProjectYearDisplay(value);
.filter(Boolean), if (value && label && !yearValues.has(label)) {
), yearValues.set(label, value);
) }
.sort((left, right) => right.localeCompare(left, 'zh-CN')) });
.map((value) => ({ label: value, value }));
return Array.from(yearValues.entries())
.sort(([left], [right]) => right.localeCompare(left, 'zh-CN'))
.map(([label, value]) => ({ label, value }));
} }
export function collectProjectStatuses(records = []) { export function collectProjectStatuses(records = []) {
......
...@@ -178,7 +178,7 @@ export default function AuditProjectMaterialField({ ...@@ -178,7 +178,7 @@ export default function AuditProjectMaterialField({
}; };
return ( return (
<div className="space-y-4"> <div className="w-full min-w-0 space-y-4">
{fileValues.length > 0 ? ( {fileValues.length > 0 ? (
<div className="space-y-2"> <div className="space-y-2">
{fileValues.map((file, index) => { {fileValues.map((file, index) => {
...@@ -206,9 +206,9 @@ export default function AuditProjectMaterialField({ ...@@ -206,9 +206,9 @@ export default function AuditProjectMaterialField({
<Tabs <Tabs
aria-label="附件来源" aria-label="附件来源"
className="w-fit max-w-full min-w-0" className="w-full min-w-0"
selectedKey={activeTab} selectedKey={activeTab}
variant="secondary" variant="primary"
onSelectionChange={(key) => setActiveTab(String(key))} onSelectionChange={(key) => setActiveTab(String(key))}
> >
<Tabs.ListContainer className="w-fit max-w-full min-w-0"> <Tabs.ListContainer className="w-fit max-w-full min-w-0">
...@@ -223,8 +223,8 @@ export default function AuditProjectMaterialField({ ...@@ -223,8 +223,8 @@ export default function AuditProjectMaterialField({
</Tabs.Tab> </Tabs.Tab>
</Tabs.List> </Tabs.List>
</Tabs.ListContainer> </Tabs.ListContainer>
<Tabs.Panel className="pt-3" id={MATERIAL_CENTER_TAB}> <Tabs.Panel className="w-full min-w-0 pt-3" id={MATERIAL_CENTER_TAB}>
<div className="space-y-3"> <div className="w-full min-w-0 space-y-3">
<XinyuanAutocomplete <XinyuanAutocomplete
aria-label="选择审计材料" aria-label="选择审计材料"
emptyText="没有找到可选择的审计材料" emptyText="没有找到可选择的审计材料"
...@@ -255,8 +255,8 @@ export default function AuditProjectMaterialField({ ...@@ -255,8 +255,8 @@ export default function AuditProjectMaterialField({
) : null} ) : null}
</div> </div>
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel className="pt-3" id={LOCAL_UPLOAD_TAB}> <Tabs.Panel className="w-full min-w-0 pt-3" id={LOCAL_UPLOAD_TAB}>
<div className="space-y-3"> <div className="w-full min-w-0 space-y-3">
<FileUploadField <FileUploadField
description={multiple description={multiple
? '可一次选择多个文件;上传后先保留为详情草稿,保存详情时逐个同步写入审计材料中心。' ? '可一次选择多个文件;上传后先保留为详情草稿,保存详情时逐个同步写入审计材料中心。'
......
import { Bell, Close, MagnifyingGlassIcon, OpenExternal } from '@/components/AppIcons'; import { Bell, Close, MagnifyingGlassIcon, OpenExternal } from '@/components/AppIcons';
import { DataTablePagination } from '@/components/DataTable'; import { DataTablePagination, EmptyStateSurface } from '@/components/DataTable';
import { UserContext } from '@/wrapper/Auth'; import { UserContext } from '@/wrapper/Auth';
import { EmptyState } from '@heroui-pro/react/empty-state'; import { EmptyState } from '@heroui-pro/react/empty-state';
import { import {
...@@ -262,15 +262,17 @@ export default function NotificationListPage() { ...@@ -262,15 +262,17 @@ export default function NotificationListPage() {
<div className="flex min-h-64 items-center justify-center" aria-label="通知加载中"><Spinner size="sm" /></div> <div className="flex min-h-64 items-center justify-center" aria-label="通知加载中"><Spinner size="sm" /></div>
) : null} ) : null}
{!loading && !error && notifications.length === 0 ? ( {!loading && !error && notifications.length === 0 ? (
<EmptyState className="min-h-64" size="md"> <EmptyStateSurface>
<EmptyState.Media><Bell size={22} /></EmptyState.Media> <EmptyState className="min-h-64" size="md">
<EmptyState.Header> <EmptyState.Media><Bell size={22} /></EmptyState.Media>
<EmptyState.Title>{category === 'todo' ? '暂无待办通知' : '暂无通知消息'}</EmptyState.Title> <EmptyState.Header>
<EmptyState.Description> <EmptyState.Title>{category === 'todo' ? '暂无待办通知' : '暂无通知消息'}</EmptyState.Title>
{keyword ? '没有找到匹配的通知,请调整搜索条件。' : readStatus === 'unread' ? '当前没有未读通知。' : '新的通知会显示在这里。'} <EmptyState.Description>
</EmptyState.Description> {keyword ? '没有找到匹配的通知,请调整搜索条件。' : readStatus === 'unread' ? '当前没有未读通知。' : '新的通知会显示在这里。'}
</EmptyState.Header> </EmptyState.Description>
</EmptyState> </EmptyState.Header>
</EmptyState>
</EmptyStateSurface>
) : null} ) : null}
{notifications.length > 0 ? ( {notifications.length > 0 ? (
<div className="flex min-w-0 flex-col gap-3" aria-busy={loading}> <div className="flex min-w-0 flex-col gap-3" aria-busy={loading}>
......
...@@ -22,7 +22,7 @@ ...@@ -22,7 +22,7 @@
按后端分组及成员顺序过滤无权限和空分组,未被配置引用的可访问应用归入“其他应用”, 按后端分组及成员顺序过滤无权限和空分组,未被配置引用的可访问应用归入“其他应用”,
无分组配置时显示“全部应用”。应用点击仍使用既有名称到根路径的跳转规则。 无分组配置时显示“全部应用”。应用点击仍使用既有名称到根路径的跳转规则。
侧栏将“驾驶舱”作为独立入口;“审计项目管理”和“往期项目归档”归入可展开的“开展审计工作”,“项目表单”归入可展开的“表单配置及维护”。两个分组默认展开,并支持手动收起。模块主题色与 `problem_rectification` 保持一致,使用品牌红 `#b42318` 统一导航选中态、主按钮、复选框、当前视图和焦点反馈。 侧栏将“驾驶舱”作为独立入口;“审计项目管理”和“往期项目归档”归入可展开的“开展审计工作”,“项目表单”归入可展开的“表单配置及维护”。两个分组默认展开,并支持手动收起。应用不提供深浅模式切换:根内容固定使用新 HeroUI 浅色主题,侧栏固定使用 `--xinyuan-sidebar-*` 深色主题,主按钮、复选框、当前视图和焦点反馈继续使用 HeroUI `--accent`。
## 2. 项目列表与详情 ## 2. 项目列表与详情
...@@ -83,6 +83,10 @@ Word 导出分别读取 `audit_plain_template`、`audit_verification_template` ...@@ -83,6 +83,10 @@ Word 导出分别读取 `audit_plain_template`、`audit_verification_template`
- 逾期未完成:未进入 `整改`/`归档`,且 `project_completion_date` 早于当天。 - 逾期未完成:未进入 `整改`/`归档`,且 `project_completion_date` 早于当天。
- 待开展:状态为 `待启动`,且 `planned_start_up_time` 在未来 1 至 30 天内。 - 待开展:状态为 `待启动`,且 `planned_start_up_time` 在未来 1 至 30 天内。
驾驶舱的状态分布和开展中进度统一通过共享 `XinyuanEChart` 使用 ECharts 渲染;图表迁移不
改变 `POST /record/query`、统计口径、数据字段、项目详情入口或刷新时机。图标继续统一使用
Ant Design Icons。
统计卡片和项目行可打开项目列表或详情;加载失败时保留重试入口。 统计卡片和项目行可打开项目列表或详情;加载失败时保留重试入口。
## 7. 外部填写与复核链接 ## 7. 外部填写与复核链接
...@@ -109,8 +113,11 @@ Word 导出分别读取 `audit_plain_template`、`audit_verification_template` ...@@ -109,8 +113,11 @@ Word 导出分别读取 `audit_plain_template`、`audit_verification_template`
- 字段管理表格仅展示字段标签和类型,不展示内部字段别名或字段属性;这些数据仍作为字段接口和编辑弹窗的内部配置使用。 - 字段管理表格仅展示字段标签和类型,不展示内部字段别名或字段属性;这些数据仍作为字段接口和编辑弹窗的内部配置使用。
- 字段类型、必填、多值、主键和 `settings` 由表单字段接口保存。 - 字段类型、必填、多值、主键和 `settings` 由表单字段接口保存。
- `settings` 必须是合法 JSON;选择字段选项存放在 `settings.options`。 - `settings` 必须是合法 JSON;选择字段选项存放在 `settings.options`。
- 审计项目中的 `form` 字段在项目详情侧栏内默认完整展开:左侧按 `settings.group` 展示默认分组和自定义分组,右侧展示当前分组下的全部表单及数据表格。 - 审计项目中的 `form` 字段在项目详情侧栏内默认完整展开:查看态与编辑态均保持顶部统计、左侧
- 表单字段支持在侧栏内添加/删除分组、添加/删除表单、切换表单模板和调整所属分组;删除自定义分组时,其中已配置的表单移入默认分组。 `settings.group` 纵向分组导航和右侧当前分组表单的主分支布局。查看态只允许切换分组、展开表单
和查看数据表格;零行表单显示单一空数据占位,不出现无意义的横向滚动;新增、删除、模板/分组
选择和行维护操作仅在编辑态出现。
- 表单字段支持在侧栏内添加/删除分组、添加/删除表单、切换表单模板和调整所属分组;默认分组固定显示在分组导航最上方,其余自定义分组保持数据中的顺序;删除自定义分组时,其中已配置的表单移入默认分组。
- 表单数据行使用 HeroUI Pro DataGrid 展示,通过 HeroUI Modal 新增或编辑;详情中的修改先作为 - 表单数据行使用 HeroUI Pro DataGrid 展示,通过 HeroUI Modal 新增或编辑;详情中的修改先作为
草稿保留,点击详情“保存”后统一写回 `{ form, data, settings }`,不向业务用户暴露 JSON。 草稿保留,点击详情“保存”后统一写回 `{ form, data, settings }`,不向业务用户暴露 JSON。
- 关联的表单模板不存在或加载失败时,在当前表单内容内显示“表单模板不可用”,不展示内部 - 关联的表单模板不存在或加载失败时,在当前表单内容内显示“表单模板不可用”,不展示内部
...@@ -139,7 +146,7 @@ Word 导出分别读取 `audit_plain_template`、`audit_verification_template` ...@@ -139,7 +146,7 @@ Word 导出分别读取 `audit_plain_template`、`audit_verification_template`
## 10. 消息通知 ## 10. 消息通知
- Navbar 的通知按钮位于主题按钮左侧,未读数来自 `GET /notice/customer/count`;0 不显示, - Navbar 不再提供主题按钮;通知按钮作为最右侧全局动作,未读数来自 `GET /notice/customer/count`;0 不显示,
1–99 显示真实数量,100 及以上显示 `99+`,并通过通知状态事件和 60 秒轮询刷新。 1–99 显示真实数量,100 及以上显示 `99+`,并通过通知状态事件和 60 秒轮询刷新。
- `/notifications` 使用原平台通知接口,支持消息/待办、全部/未读、300ms 搜索、固定每页 10 条、 - `/notifications` 使用原平台通知接口,支持消息/待办、全部/未读、300ms 搜索、固定每页 10 条、
单条已读、全部已读和服务端配置的动态动作;未读筛选向服务端提交 `status = "unread"`。 单条已读、全部已读和服务端配置的动态动作;未读筛选向服务端提交 `status = "unread"`。
......
...@@ -25,11 +25,6 @@ html { ...@@ -25,11 +25,6 @@ html {
color-scheme: light !important; color-scheme: light !important;
} }
html.dark,
html[data-theme="dark"] {
color-scheme: dark !important;
}
body { body {
margin: 0; margin: 0;
font-family: var(--rc-font-sans); font-family: var(--rc-font-sans);
......
...@@ -5,6 +5,11 @@ export function normalizeText(value) { ...@@ -5,6 +5,11 @@ export function normalizeText(value) {
return String(value ?? '').trim(); return String(value ?? '').trim();
} }
// 项目年度在界面统一展示为纯数字,业务字段仍保留原始值。
export function normalizeProjectYearDisplay(value) {
return normalizeText(value).replace(/\s*年\s*$/, '');
}
export function toArray(value) { export function toArray(value) {
if (value === null || value === undefined || value === '') { if (value === null || value === undefined || value === '') {
return []; return [];
...@@ -525,6 +530,60 @@ export function normalizeUserLabel(value) { ...@@ -525,6 +530,60 @@ export function normalizeUserLabel(value) {
return value.display_name || value.displayName || value.username || value.name || value.title || value._id || ''; return value.display_name || value.displayName || value.username || value.name || value.title || value._id || '';
} }
function parseStructuredText(value) {
if (typeof value !== 'string') return value;
const text = value.trim();
if (!text || !/^[\[{]/.test(text)) return value;
try {
return JSON.parse(text);
} catch (error) {
return value;
}
}
function countNestedBlocks(sections) {
return toArray(sections).reduce((count, section) => count + toArray(section?.blocks).length, 0);
}
function formatStructuredObject(value) {
if (!value || typeof value !== 'object') return '';
if (Array.isArray(value)) {
return value.map((item) => formatStructuredValue(item)).filter(Boolean).join('、');
}
if (Array.isArray(value.nodes) || Array.isArray(value.edges)) {
const nodes = toArray(value.nodes).length;
const edges = toArray(value.edges).length;
if (nodes || edges) return `包含 ${nodes} 个节点、${edges} 条连线`;
}
if (Array.isArray(value.sections)) {
const sections = value.sections.length;
const blocks = countNestedBlocks(value.sections);
if (sections || blocks) return `包含 ${sections} 个章节、${blocks} 个内容块`;
}
const identity = normalizeRecordLabel(value);
if (identity && identity !== normalizeText(value._id)) return identity;
const readableValues = Object.entries(value)
.filter(([key, item]) => !['_id', 'id', 'type', 'style', 'position', 'positionAbsolute', 'selected', 'dragging'].includes(key))
.map(([, item]) => formatStructuredValue(item))
.filter(Boolean);
return readableValues.length > 0 ? readableValues.slice(0, 3).join('、') : '已配置';
}
function formatStructuredValue(value) {
const parsed = parseStructuredText(value);
if (parsed === null || parsed === undefined || parsed === '') return '';
if (Array.isArray(parsed)) {
const scalarValues = parsed.map((item) => normalizeText(item)).filter(Boolean);
if (scalarValues.length > 1 && scalarValues.every((item) => /^[a-f0-9]{24}$/i.test(item))) {
return `已关联 ${scalarValues.length} 项`;
}
return parsed.map((item) => formatStructuredValue(item)).filter(Boolean).join('、');
}
if (typeof parsed === 'object') return formatStructuredObject(parsed);
const text = normalizeText(parsed);
return /^[a-f0-9]{24}$/i.test(text) ? '已关联记录' : text;
}
export function formatDateText(value) { export function formatDateText(value) {
if (!value) { if (!value) {
return ''; return '';
...@@ -568,58 +627,75 @@ export function formatFieldValue(value, fieldDef) { ...@@ -568,58 +627,75 @@ export function formatFieldValue(value, fieldDef) {
return ''; return '';
} }
if (Array.isArray(value) && value.length === 0) { const parsedValue = parseStructuredText(value);
if (Array.isArray(parsedValue) && parsedValue.length === 0) {
return ''; return '';
} }
if (fieldDef?.name === 'project_year') {
return toArray(parsedValue)
.map(normalizeProjectYearDisplay)
.filter(Boolean)
.join('、');
}
if (isDateFieldType(fieldDef?.type)) { if (isDateFieldType(fieldDef?.type)) {
return formatDateText(value); return formatDateText(parsedValue);
} }
if (fieldDef?.type === 'boolean') { if (fieldDef?.type === 'boolean') {
return value ? '是' : '否'; return parsedValue ? '是' : '否';
} }
if (fieldDef?.type === 'number') { if (fieldDef?.type === 'number') {
return typeof value === 'number' ? value.toLocaleString() : normalizeText(value); return typeof parsedValue === 'number' ? parsedValue.toLocaleString() : normalizeText(parsedValue);
} }
if (fieldDef?.type === 'select') { if (fieldDef?.type === 'select') {
const options = getSelectOptions(fieldDef); const options = getSelectOptions(fieldDef);
const labelForValue = (item) => { const labelForValue = (item) => {
const rawValue = typeof item === 'object' && item !== null ? item.value : item; const rawValue = typeof item === 'object' && item !== null ? item.value : item;
const option = options.find((candidate) => candidate.value === rawValue); const option = options.find((candidate) => String(candidate.value) === String(rawValue));
return option?.label || normalizeText(rawValue); const fallback = normalizeText(rawValue);
return option?.label || (/^[a-f0-9]{24}$/i.test(fallback) ? '已关联记录' : fallback);
}; };
return toArray(value).map(labelForValue).filter(Boolean).join('、'); return toArray(parsedValue).map(labelForValue).filter(Boolean).join('、');
} }
if (fieldDef?.type === 'user') { if (fieldDef?.type === 'user') {
return toArray(value).map(normalizeUserLabel).filter(Boolean).join('、'); return toArray(parsedValue).map(normalizeUserLabel).filter(Boolean).join('、');
} }
if (isReferenceFieldType(fieldDef?.type)) { if (isReferenceFieldType(fieldDef?.type)) {
return toArray(value).map(normalizeRecordLabel).filter(Boolean).join('、'); const labels = toArray(parsedValue).map(normalizeRecordLabel).filter(Boolean);
const unresolvedIds = labels.filter((label) => /^[a-f0-9]{24}$/i.test(label));
if (unresolvedIds.length === labels.length && unresolvedIds.length > 1) {
return `已关联 ${unresolvedIds.length} 项`;
}
return labels
.map((label) => /^[a-f0-9]{24}$/i.test(label) ? '已关联记录' : label)
.join('、');
} }
if (fieldDef?.type === 'file') { if (fieldDef?.type === 'file') {
return toArray(value) return toArray(parsedValue)
.map((file) => file?.name || file?.title || file?.url || normalizeText(file)) .map((file) => file?.name || file?.title || file?.url || normalizeText(file))
.filter(Boolean) .filter(Boolean)
.join('、'); .join('、');
} }
if (fieldDef?.type === 'form') { if (fieldDef?.type === 'form') {
return getFormValueSummaries(value, fieldDef?.label || '表单') return getFormValueSummaries(parsedValue, fieldDef?.label || '表单')
.map((item) => `${item.label}(${item.rowCount})`) .map((item) => `${item.label}(${item.rowCount})`)
.join('、'); .join('、');
} }
if (typeof value === 'object') { if (typeof parsedValue === 'object') {
return JSON.stringify(value); return formatStructuredValue(parsedValue);
} }
return normalizeText(value); return normalizeText(parsedValue);
} }
export function dateInputValue(value) { export function dateInputValue(value) {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment