Commit 5c519a8f authored by Andy-bubu's avatar Andy-bubu

feat: align integrity management UI with Xinyuan baseline

parent 5c566366
...@@ -3,12 +3,12 @@ ...@@ -3,12 +3,12 @@
## 运行结构 ## 运行结构
```text ```text
app.js (主题初始化) app.js (固定浅色主题初始化)
-> Site/Auth wrappers (现有鉴权与登出) -> Site/Auth wrappers (现有鉴权与登出)
-> integrity_practice_management/Layout -> integrity_practice_management/Layout
-> AppShell (Pro Sidebar.Provider) -> AppShell (Pro Sidebar.Provider)
-> DashboardSidebar (Pro Sidebar + HeroUI Dropdown) -> DashboardSidebar (Pro Sidebar + HeroUI Dropdown)
-> DashboardNavbar (Pro Navbar + Sidebar.Trigger) -> DashboardNavbar (Pro Navbar + Sidebar.Trigger + Badge.Anchor 通知)
-> Badge.Anchor + Badge 通知入口 -> /notifications -> Badge.Anchor + Badge 通知入口 -> /notifications
-> SubjectWorkbench (七个 Subject 配置入口) -> SubjectWorkbench (七个 Subject 配置入口)
-> RecordWorkbench (Tabs/Search/DataGrid/Pagination/ActionBar) -> RecordWorkbench (Tabs/Search/DataGrid/Pagination/ActionBar)
...@@ -19,12 +19,12 @@ app.js (主题初始化) ...@@ -19,12 +19,12 @@ app.js (主题初始化)
## 责任边界 ## 责任边界
- `src/config/application.js`:稳定 `APPLICATION_NAME`、展示名、base、主题存储键;路由、应用切换与授权判断从同一应用标识派生。 - `src/config/application.js`:稳定 `APPLICATION_NAME`、展示名和 base;路由、应用切换与授权判断从同一应用标识派生。
- `src/design-system/`:权威主题、Tokens 和跨应用 Shell 样式;页面不得复制这些规则。 - `src/design-system/`:权威主题、Tokens 和跨应用 Shell 样式;页面不得复制这些规则。
- `AppShell/DashboardSidebar/DashboardNavbar/ApplicationSwitcherDialog`:业务无关的共享壳层。 - `AppShell/DashboardSidebar/DashboardNavbar/ApplicationSwitcherDialog`:业务无关的共享壳层。
- `ApplicationSwitcherDialog` 从既有 `SiteContext.site.group` 读取动态分组,从 `GET /application/info` 与 `UserContext.applications` 合并真实可访问应用;分组仅影响呈现,不改变路由、会话或跳转。 - `ApplicationSwitcherDialog` 从既有 `SiteContext.site.group` 读取动态分组,从 `GET /application/info` 与 `UserContext.applications` 合并真实可访问应用;分组仅影响呈现,不改变路由、会话或跳转。
- `SubjectWorkbench`:把现有 Subject/View/动作配置适配为工作台 props,不定义组件外观。 - `SubjectWorkbench`:把现有 Subject/View/动作配置适配为工作台 props,不定义组件外观。
- `record-workbench`:DataGrid/Pagination/ActionBar 组合;详情能力统一映射为操作列首个预览图标,首个业务列仅展示文本;不发请求、不解释业务动作。 - `record-workbench`:DataGrid/Pagination/ActionBar 组合;筛选使用 `FilterSelect`/`FormDatePicker`/Disclosure,详情能力统一映射为操作列首个预览图标,首个业务列仅展示文本;不发请求、不解释业务动作。
- `RecordDetailDrawer`:详情整窗草稿、Modal 状态和字段编排;一次保存通过 `useSubjectRecord.handleFieldsUpdate` 提交。 - `RecordDetailDrawer`:详情整窗草稿、Modal 状态和字段编排;一次保存通过 `useSubjectRecord.handleFieldsUpdate` 提交。
- `integrityPracticeMaterialService`:`am_material` 查询、`/upload` 结果快照规范化和材料建档;应用标识由廉洁从业业务层固定为 `integrity_practice_management`。 - `integrityPracticeMaterialService`:`am_material` 查询、`/upload` 结果快照规范化和材料建档;应用标识由廉洁从业业务层固定为 `integrity_practice_management`。
- `notifications/notificationService`:平台通知查询、已读、动态 action、未读事件和 base-aware 链接解析。 - `notifications/notificationService`:平台通知查询、已读、动态 action、未读事件和 base-aware 链接解析。
...@@ -43,7 +43,7 @@ Navbar -> GET notice/customer/count -> /notifications -> query/read/action ...@@ -43,7 +43,7 @@ Navbar -> GET notice/customer/count -> /notifications -> query/read/action
Grid View 的 `viewAuth` 在查询前过滤;没有可访问 View 时数据链在 `POST record/query` 之前终止。打开详情时,`useSubjectRecord` 在 View 摘要之后继续读取每个 View 的完整配置。当前 Detail 有有效 `auth` 或 `disableColumns` 时,其字段权限整套优先;两项都未配置或没有实际 Detail 时,整体回退打开它的 Grid `authorizationView`。Detail 与 Grid 不合并、也不逐字段回退;`disableColumns` 始终强制只读。切换 Detail 后,字段状态和编辑入口只按当前 Detail 的展示字段立即重算。Grid `viewAuth` 不用于隐藏详情字段。 Grid View 的 `viewAuth` 在查询前过滤;没有可访问 View 时数据链在 `POST record/query` 之前终止。打开详情时,`useSubjectRecord` 在 View 摘要之后继续读取每个 View 的完整配置。当前 Detail 有有效 `auth` 或 `disableColumns` 时,其字段权限整套优先;两项都未配置或没有实际 Detail 时,整体回退打开它的 Grid `authorizationView`。Detail 与 Grid 不合并、也不逐字段回退;`disableColumns` 始终强制只读。切换 Detail 后,字段状态和编辑入口只按当前 Detail 的展示字段立即重算。Grid `viewAuth` 不用于隐藏详情字段。
主题状态写入 `xinyuan_color_theme`,同时更新根 `data-theme` 与 `dark` class,使 Portal 与页面一致。应用切换数据来自真实 application info;登出调用现有 logout API 后清理登录态并回到 base-aware 登录路径。 根主题固定为 `light`,内容画布保持浅色,Sidebar 通过独立 Token 保持深色;Portal 跟随内容区主题。应用切换数据来自真实 application info;登出调用现有 logout API 后清理登录态并回到 base-aware 登录路径。
## 历史源码隔离 ## 历史源码隔离
......
...@@ -13,13 +13,14 @@ ...@@ -13,13 +13,14 @@
- [x] 登录、鉴权、真实退出 - [x] 登录、鉴权、真实退出
- [x] Pro Sidebar/Navbar/offcanvas/mobile - [x] Pro Sidebar/Navbar/offcanvas/mobile
- [x] HeroUI 应用切换 Modal:model_analysis 基准、46rem/42rem、28px inset、动态分组与真实计数、桌面两列/移动单列、API Logo/首字 identity、唯一当前态;用户 Dropdown - [x] HeroUI 应用切换 Modal:`audit_project_manager` 基准、46rem/42rem、28px inset、动态分组与真实计数、桌面两列/移动单列、鑫元 Logo identity well、唯一当前态;用户 Dropdown
- [x] 七条 SubjectWorkbench 路由 - [x] 七条 SubjectWorkbench 路由
- [x] Tabs、SearchField/Surface 筛选 - [x] Tabs、SearchField/Surface 筛选
- [x] Pro DataGrid 选择、排序、固定列、横向滚动 - [x] Pro DataGrid 选择、排序、固定列、横向滚动
- [x] HeroUI Pagination(固定每页 10 条、无页容量选择器)、ActionBar、EmptyState - [x] HeroUI Pagination、共享 Autocomplete 页容量选择器(20/50/100)、ActionBar、EmptyState
- [x] 4+ 行动作 More 菜单与危险动作末位 - [x] 4+ 行动作 More 菜单与危险动作末位
- [x] 详情/编辑 Modal、指派 Modal、删除 AlertDialog - [x] 详情/编辑 Modal、指派 Modal、删除 AlertDialog
- [x] 新增记录 Modal:4xl 居中弹窗、blur Backdrop、标准关闭按钮、可滚动 Body、两列动态表单和次级/主操作层级
- [x] Select、Autocomplete、DatePicker、DropZone 直接 compound API - [x] Select、Autocomplete、DatePicker、DropZone 直接 compound API
- [x] 活动路由 KPI/图表判定为不适用 - [x] 活动路由 KPI/图表判定为不适用
- [x] Grid `shortTitle` 严格布尔隐藏标题展示列,标题数据与查询协议保留 - [x] Grid `shortTitle` 严格布尔隐藏标题展示列,标题数据与查询协议保留
...@@ -54,12 +55,23 @@ ...@@ -54,12 +55,23 @@
- [x] 所有活动 DataGrid 的操作列预览入口、首列只读文本、32px 图标按钮与 More 危险项顺序 - [x] 所有活动 DataGrid 的操作列预览入口、首列只读文本、32px 图标按钮与 More 危险项顺序
- [x] 操作列固定内容宽度;非溢出表格由首个业务列 `1fr` 吸收余量,表头与首个动作按标准内边距对齐,右侧无弹性空白 - [x] 操作列固定内容宽度;非溢出表格由首个业务列 `1fr` 吸收余量,表头与首个动作按标准内边距对齐,右侧无弹性空白
- [x] Tabs、Select、Autocomplete、DatePicker 打开态 - [x] Tabs、Select、Autocomplete、DatePicker 打开态
- [x] 筛选 Disclosure 收起态不保留隐藏面板高度,展开态正常显示扩展字段
- [x] 编辑弹窗远程引用多选:长标题省略、数量汇总、搜索/清空、受控选择、移动端视口边界与单选关闭行为 - [x] 编辑弹窗远程引用多选:长标题省略、数量汇总、搜索/清空、受控选择、移动端视口边界与单选关闭行为
- [x] 详情查看/编辑、指派、删除确认 - [x] 详情查看/编辑、指派、删除确认
- [x] 详情 Modal 4xl/24px、secondary 查看区、tertiary DataGrid 表头、default 表体及固定列层级(四视口浅/深色) - [x] 详情 Modal 4xl/24px、secondary 查看区、tertiary DataGrid 表头、default 表体及固定列层级(四视口浅/深色)
- [x] 新页面重载控制台 0 error - [x] 新页面重载控制台 0 error
- [x] 真实退出请求 200、base-aware 登录跳转、`current_user`/`auth_token` 清理 - [x] 真实退出请求 200、base-aware 登录跳转、`current_user`/`auth_token` 清理
## 2026-09-02 审计项目管理基准同步
- [x] 目标基准切换为 `/Users/liuchenlong/Desktop/job/audit_project_manager`
- [x] 固定浅色内容画布,移除主题按钮和主题持久化读取
- [x] Sidebar 分组 Disclosure、移动 Sheet、用户 Dropdown 与通知 Navbar 保持 Pro compound API
- [x] 应用切换改为 Pro ItemCard/ItemCardGroup/ScrollShadow 两列结构并保留搜索/分组计数
- [x] 新增共享 `DataTable`、`FilterSelect`、`TableActionButton`,工作台筛选和分页接入共享层
- [x] 所有操作列继续在首个操作位提供预览图标,首列不承担点击预览
- [x] API、Subject/View、权限、路由、校验、保存回调和业务状态流未改
## 明确保留 ## 明确保留
- [ ] 全仓旧组件扫描为零:未完成。未挂载历史 `src/pages/problem_rectification` 与 `src/catalyst-ui-kit` 仍保留,且不进入当前应用路由。 - [ ] 全仓旧组件扫描为零:未完成。未挂载历史 `src/pages/problem_rectification` 与 `src/catalyst-ui-kit` 仍保留,且不进入当前应用路由。
......
# 廉洁从业管理鑫元设计系统 # 廉洁从业管理鑫元设计系统
本文件约束 `/integrity_practice_management/` 的活动路由。唯一视觉和结构基准是已确认的“问题整改”应用;不得重新设计或在页面层覆盖 HeroUI 外观。 本文件约束 `/integrity_practice_management/` 的活动路由。唯一视觉和结构基准是已确认的“审计项目管理”应用;不得重新设计或在页面层覆盖 HeroUI 外观。
## 来源与加载顺序 ## 来源与加载顺序
...@@ -15,22 +15,28 @@ Inter 禁止通过 `src/tailwind.css` 导入。Tailwind v4 内容扫描必须排 ...@@ -15,22 +15,28 @@ Inter 禁止通过 `src/tailwind.css` 导入。Tailwind v4 内容扫描必须排
## 组件合同 ## 组件合同
- Shell:`@heroui-pro/react/sidebar`、`navbar`;桌面 offcanvas 真折叠,移动端 `Sidebar.Mobile`。 - Shell:`@heroui-pro/react/sidebar`、`navbar`;桌面 offcanvas 真折叠,移动端 `Sidebar.Mobile`;导航分组首次进入时默认展开,用户仍可手动收起。
- 全局操作:HeroUI Button/Tooltip/Dropdown;用户菜单的退出项调用真实登出回调。 - 全局操作:HeroUI Button/Tooltip/Dropdown;用户菜单的退出项调用真实登出回调。
- 应用切换:严格使用 model_analysis 当前 HeroUI Modal + Card/Surface/Avatar/Chip 基准;最大 46rem x 42rem、28px inset、24px 圆角,桌面两列、移动端单列,间距 12px。分组来自 `site.group`,保持后端分组与组内顺序并显示真实数量;未分组应用进入“其他应用”,无配置时回退“全部应用”。API 有 Logo 时显示真实 Logo,无 Logo 时显示应用首字,当前应用只显示一个 accent Chip。 - 应用切换:严格使用 `audit_project_manager` 当前 HeroUI Modal + Pro ItemCard/ItemCardGroup/ScrollShadow/Surface/Avatar/Chip 基准;最大 46rem x 42rem、28px inset、24px 圆角,桌面两列、移动端单列,间距 12px。分组来自 `site.group`,保持后端分组与组内顺序并显示真实数量;搜索按名称、描述和标识实时过滤;所有应用使用鑫元 Logo,当前应用只显示一个 accent Chip。
- 数据视图:HeroUI Tabs;不手写 segmented/tab 外观。 - 数据视图:HeroUI Tabs;不手写 segmented/tab 外观。
- 筛选:HeroUI Surface + SearchField/Autocomplete/DatePicker/Disclosure。没有业务配置的筛选不得添加。 - 列表标题:除“自查底稿填写表”数据表外,其他列表在工具栏上方显示独立 `listTitle`;列表标题不得复用顶部栏路由文案。
- 列表:HeroUI Pro DataGrid、EmptyState、ActionBar + HeroUI Pagination;分页固定沿用业务默认每页 10 条,不显示额外页容量选择器。首列只在真实横向溢出时固定且仅展示文本,详情入口统一为操作列首个预览图标。操作列宽度由 32px 动作按钮、8px 间距和标准单元格内边距计算,`minWidth/width/maxWidth` 必须一致;表格未横向溢出时首个业务列使用 `1fr` 吸收剩余宽度,操作列不得参与余量分配;仅在横向溢出时固定在右端。 - 筛选:HeroUI Surface + SearchField/Autocomplete/DatePicker/Disclosure。没有业务配置的筛选不得添加;动态字段标签已说明字段含义时,控件 placeholder 使用简短动作提示(文本“请输入内容”、数字“请输入数值”、选择/引用“请选择”、日期按类型提示),禁止重复拼接“筛选+字段名”;后端显式配置的 placeholder 优先保留。
- 筛选 Disclosure 收起时卸载扩展字段面板,不在 Surface 内保留隐藏 Grid 行;展开后再挂载扩展字段,避免收起态出现额外空白高度。
- 列表:HeroUI Pro DataGrid、EmptyState、ActionBar + HeroUI Pagination;页容量选择器使用共享 HeroUI Autocomplete,弹层选项必须包含外层当前页容量,并与业务传入选项保持一致(例如 10/20/50/100),且必须隐藏搜索输入;切换后回到第一页并保留筛选/权限范围。首列只在真实横向溢出时固定且仅展示文本,详情入口统一为操作列首个预览图标。操作列宽度由 32px 动作按钮、8px 间距和标准单元格内边距计算,`minWidth/width/maxWidth` 必须一致;表格未横向溢出时首个业务列使用 `1fr` 吸收剩余空间,操作列不得参与余量分配;仅在横向溢出时固定在右端。
- 行动作:1-3 项全部直出;4 项以上直出前两项和 More Dropdown;危险项排序最后。 - 行动作:1-3 项全部直出;4 项以上直出前两项和 More Dropdown;危险项排序最后。
- Modal:详情/表单使用 HeroUI Modal;确认使用 AlertDialog;内容型详情使用 4xl/1088px、24px 圆角,查看态只允许一个 16px 圆角的 secondary Surface。嵌套 DataGrid 使用 20px 圆角,表头与固定表头使用 tertiary Surface,表体与固定单元格回到 default Surface,禁止表头与外层详情 Surface 使用同一层颜色。 - Modal:详情/新增/编辑表单使用 HeroUI Modal,普通动态表单采用 4xl/1088px、居中、blur Backdrop、24px 圆角、标准 CloseTrigger 和可滚动 Body;字段按两列网格排列,长文本/选择字段跨列,Footer 固定为次级取消在左、主提交在右。确认使用 AlertDialog;查看态只允许一个 16px 圆角的 secondary Surface。嵌套 DataGrid 使用 20px 圆角,表头与固定表头使用 tertiary Surface,表体与固定单元格回到 default Surface,禁止表头与外层详情 Surface 使用同一层颜色。
- 动态字段:Input、TextArea、Checkbox、Select、Autocomplete、DatePicker;附件使用 Pro DropZone。 - 动态字段:Input、TextArea、Checkbox、Select、Autocomplete、DatePicker;附件使用 Pro DropZone。Autocomplete 长标题候选项必须允许内容收缩、省略,并为右侧选中标记保留固定空间。
- View 标题:仅 `settings.shortTitle === true` 隐藏 Grid 的 title 展示列;Detail View 同配置隐藏记录副标题与 title 字段,Modal 功能标题始终存在。 - View 标题:仅 `settings.shortTitle === true` 隐藏 Grid 的 title 展示列;Detail View 同配置隐藏记录副标题与 title 字段,Modal 功能标题始终存在。
- View 权限:以稳定 `APPLICATION_NAME = integrity_practice_management` 定位授权,只读取 `user.applications[]` 中当前应用项目的 groups/roles/permissions;不得合并顶层身份或其他应用授权。Grid `viewAuth` 只控制 Tab 可见性;零可访问 Grid View 时禁止查询与操作,一个 View 时隐藏 Tabs。 - View 权限:以稳定 `APPLICATION_NAME = integrity_practice_management` 定位授权,只读取 `user.applications[]` 中当前应用项目的 groups/roles/permissions;不得合并顶层身份或其他应用授权。Grid `viewAuth` 只控制 Tab 可见性;零可访问 Grid View 时禁止查询与操作,一个 View 时隐藏 Tabs。
- Detail 字段权限:打开详情时加载 `GET /subject/:subjectName/view/:viewName` 的完整配置。当前 Detail 有有效 `auth` 或 `disableColumns` 时整套使用 Detail;两项均未配置或不存在实际 Detail 时整体回退打开它的 Grid。两套配置不合并、不逐字段回退;`disableColumns` 对所有用户始终只读,编辑入口只根据当前激活 Detail 展示字段计算。 - Detail 字段权限:打开详情时加载 `GET /subject/:subjectName/view/:viewName` 的完整配置。当前 Detail 有有效 `auth` 或 `disableColumns` 时整套使用 Detail;两项均未配置或不存在实际 Detail 时整体回退打开它的 Grid。两套配置不合并、不逐字段回退;`disableColumns` 对所有用户始终只读,编辑入口只根据当前激活 Detail 展示字段计算。
- 通知:Navbar 直接组合 `Badge.Anchor + Badge + Button + Tooltip`,Bell 位于主题按钮左侧;通知页使用平台真实接口和 HeroUI Tabs/SearchField/Pagination/Card。 - 通知:Navbar 直接组合 `Badge.Anchor + Badge + Button + Tooltip`,Bell 位于全局动作区;通知页使用平台真实接口和 HeroUI Tabs/SearchField/Pagination/Card。
- 文件:可编辑 file 字段统一使用 HeroUI Tabs、Autocomplete 和 Pro DropZone;材料中心选择及本地上传都只向业务草稿写 `{name,type,size,url}`。 - 文件:可编辑 file 字段统一使用 HeroUI Tabs、Autocomplete 和 Pro DropZone;材料中心选择及本地上传都只向业务草稿写 `{name,type,size,url}`。
- 远程引用:使用 HeroUI `Popover.Root + Button + SearchField + controlled ListBox`。多选触发器固定 40px 高并只显示“已选择 N 项”,候选长标题单行省略且通过 `title` 暴露全文;弹层最大 24rem、移动端收敛到 `100vw - 24px`、列表最大 288px。多选后保持打开,单选后关闭,清空不改变原 ID 保存协议。 - 远程引用:使用 HeroUI `Autocomplete`/`Popover.Root + Button + SearchField + controlled ListBox`。多选触发器固定 40px 高并只显示“已选择 N 项”,候选长标题单行省略且通过 `title` 暴露全文;弹层最大 24rem、移动端收敛到 `100vw - 24px`、列表最大 288px。多选后保持打开,单选后关闭;有值时显示官方 `Autocomplete.ClearButton`,清空分别回传空数组或原协议要求的空值,不改变 ID 保存协议。
- KPI/图表:仅在真实业务路由存在时使用 Pro KPI、PieChart、BarChart、ComposedChart、ChartTooltip;禁止 Card 数字卡或 ECharts 私有主题。 - KPI/图表:仅在真实业务路由存在时使用 Pro KPI、PieChart、BarChart、ComposedChart、ChartTooltip;当前七条业务路由无 KPI/图表,不制造测试模块。
## 基准同步记录
2026-09-02 已同步审计项目管理的固定浅色内容画布、深色 Sidebar、Disclosure 分组、移动 Sheet、应用切换 Pro ItemCard 结构、共享 DataTable/FilterSelect/ActionButton 和工作台筛选/分页布局。页面仍只负责布局 className,业务数据、回调和请求边界保持原实现。
## 业务和数据保护 ## 业务和数据保护
......
# 廉洁从业管理鑫元 UI 审计 # 廉洁从业管理鑫元 UI 审计
审计日期:2026-08-20 审计日期:2026-09-02
分支:`codex/xinyuan-ui-ux` 分支:`codex/xinyuan-ui-ux`
唯一实现基准:`/Users/liuchenlong/Desktop/job/problem_rectification` 唯一实现基准:`/Users/liuchenlong/Desktop/job/audit_project_manager`
## 安装硬门槛 ## 安装硬门槛
...@@ -45,17 +45,17 @@ ...@@ -45,17 +45,17 @@
- 登录:已使用基准居中 Card、Logo、字段高度、24px 节奏、验证码、filled/focus/autofill 状态;保留原登录协议。 - 登录:已使用基准居中 Card、Logo、字段高度、24px 节奏、验证码、filled/focus/autofill 状态;保留原登录协议。
- Shell:HeroUI Pro `Sidebar.Provider collapsible="offcanvas"`、Sidebar、Mobile、Trigger、Navbar 与真实用户 Dropdown/登出。 - Shell:HeroUI Pro `Sidebar.Provider collapsible="offcanvas"`、Sidebar、Mobile、Trigger、Navbar 与真实用户 Dropdown/登出。
- 应用切换:以 model_analysis 当前实现为唯一基准,HeroUI Modal 46rem/42rem、28px inset、桌面两列/移动端单列;复用 `SiteContext.site.group` 动态分组,显示真实计数,并保留唯一当前态与 identity well。 - 应用切换:以 `audit_project_manager` 当前实现为唯一基准,使用 HeroUI Modal + Pro ItemCard/ItemCardGroup/ScrollShadow,46rem/42rem、28px inset、桌面两列/移动端单列;搜索按名称/描述/标识过滤,复用 `SiteContext.site.group` 动态分组,显示真实计数,并保留唯一当前态与 identity well。
- Tabs/筛选:HeroUI Tabs、SearchField、Surface;当前业务仅配置标题关键词,不添加虚构筛选字段。 - Tabs/筛选:HeroUI Tabs、SearchField、Surface;当前业务仅配置标题关键词,不添加虚构筛选字段。
- 列表:Pro DataGrid、原生多选/排序/列宽/固定列/横向滚动、HeroUI Pagination、Pro ActionBar/EmptyState;分页固定每页 10 条并按问题整改基准移除页容量选择器;4 个以上动作只直出前两个,其余进入 Dropdown,危险动作最后。 - 列表:Pro DataGrid、原生多选/排序/列宽/固定列/横向滚动、HeroUI Pagination、Pro ActionBar/EmptyState;分页复用共享 `DataTablePagination`,页容量仅由业务分页对象显式提供时启用;4 个以上动作只直出前两个,其余进入 Dropdown,危险动作最后。
- 弹窗:HeroUI Modal/AlertDialog;详情查看态单一 secondary Surface;指派保留远程用户查询和 ID 提交。 - 弹窗:HeroUI Modal/AlertDialog;详情查看态单一 secondary Surface;指派保留远程用户查询和 ID 提交。
- 字段:HeroUI Input/TextArea/Checkbox/Select/Autocomplete/DatePicker,Pro DropZone;Select 选项仅来自 `settings.options`,用户/引用仅保存 ID,日期继续转 ISO。 - 字段:HeroUI Input/TextArea/Checkbox/Select/Autocomplete/DatePicker,Pro DropZone;Select 选项仅来自 `settings.options`,用户/引用仅保存 ID,日期继续转 ISO。
- Portal/暗色:根主题同步 `data-theme` 与 `dark`,Modal、Dropdown、Autocomplete、Select、DatePicker 均实测打开态。 - Portal/暗色:根主题固定 `light`,Sidebar 使用独立深色 Token;Modal、Dropdown、Autocomplete、Select、DatePicker 均继承内容区主题并实测打开态。
- KPI/图表:七条活动路由无 KPI 或图表模块,不适用;没有制造测试模块或业务数字。相关 Pro 子路径已验证可导入。 - KPI/图表:七条活动路由无 KPI 或图表模块,不适用;没有制造测试模块或业务数字。相关 Pro 子路径已验证可导入。
## 浏览器验收 ## 浏览器验收
目标 `http://localhost:8002/integrity_practice_management/` 与基准 `http://localhost:8102/problem_rectification_new/` 同时打开。Shell 使用真实主题按钮切换;登录页无主题按钮,暗色验收使用预置根主题状态。验收截图已按源代码管理要求移除。 目标开发服务为 `http://localhost:8006/integrity_practice_management/`;唯一基准为本机 `audit_project_manager` 工作区。登录页无主题按钮,内容画布固定浅色、Sidebar 固定深色;验收截图不纳入源代码管理。
| 项目 | 1440x900 | 1280x720 | 768x1024 | 390x844 | 浅/深色 | | 项目 | 1440x900 | 1280x720 | 768x1024 | 390x844 | 浅/深色 |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
...@@ -69,9 +69,9 @@ ...@@ -69,9 +69,9 @@
操作列宽度回归复测:与运行中的“问题整改”在 1440x900 同视口比较,基准操作列为 144px。当前主表恢复同一列宽分配规则:未横向溢出时由首个业务列 `1fr` 吸收剩余宽度,操作列保持 144px;实测操作列 `x=1244..1388`,内容区 `x=1260..1372`,左右内边距均为 16px,三个 32px 按钮间距均为 8px。1440x900、1280x720、768x1024、390x844 浅/深色均复测,操作列在每个视口都为 144px;窄视口横向溢出态固定在 DataGrid 右端,四个视口 `document.body.scrollWidth === innerWidth`。 操作列宽度回归复测:与运行中的“问题整改”在 1440x900 同视口比较,基准操作列为 144px。当前主表恢复同一列宽分配规则:未横向溢出时由首个业务列 `1fr` 吸收剩余宽度,操作列保持 144px;实测操作列 `x=1244..1388`,内容区 `x=1260..1372`,左右内边距均为 16px,三个 32px 按钮间距均为 8px。1440x900、1280x720、768x1024、390x844 浅/深色均复测,操作列在每个视口都为 144px;窄视口横向溢出态固定在 DataGrid 右端,四个视口 `document.body.scrollWidth === innerWidth`。
应用切换专项基准更新:应用切换改以 model_analysis 当前实现为唯一基准。结构使用 HeroUI Modal compound API,分组直接读取既有 `GET /info -> site.group`,应用仍由 `GET /application/info` 与登录用户可访问应用合并;保持后端分组及组内顺序、过滤空组和无权限项、显示真实数量,未引用项进入“其他应用”,无分组配置时回退“全部应用”。固定 46rem/42rem、28px inset、24px Modal 圆角、24px/12px 分组节奏、sm 起两列、移动一列、36px identity well,并确保只有一个“当前” Chip。 应用切换专项基准更新:应用切换严格对齐 `audit_project_manager` 当前实现。结构使用 HeroUI Modal compound API,分组直接读取既有 `site.group`,应用由 `/application/info` 与登录用户可访问应用合并;保持后端分组及组内顺序、过滤空组和无权限项、显示真实数量,未引用项进入“其他应用”,无分组配置时回退“全部应用”。固定 46rem/42rem、28px inset、24px Modal 圆角、12px 分组节奏、桌面两列/移动一列、36px identity well,并确保只有一个“当前” Chip。
应用切换浏览器验收:同时打开目标 `http://localhost:8002/integrity_practice_management/` 与基准 `http://localhost:8003/model_analysis/`,在 1440x900、1280x720、768x1024、390x844 的浅色和深色模式逐项比较。1440x900 实测两侧均为 736x672px、24px 圆角、28px inset、Header 56px、Header/Body 间距 12px、关闭按钮 24x24px 且距右上各 28px、两列各 334px且间距 12px;1280x720 为 736x640px,768x1024 仍为两列,390x844 为 358x672px、左右各 16px 且单列 302px。各视口 Body 均独立滚动,页面横向滚动宽度等于视口宽度。Esc、关闭按钮和遮罩均可关闭,焦点回到“切换应用”入口;打开后 Tab 顺序从 Dialog 到关闭按钮。无 `site.group` 时验证只回退一个“全部应用”分组;`GET /application/info` 失败时验证 warning 与登录用户应用回退;透明 Logo 保持原图,损坏或缺失 Logo 回退首字,当前应用使用鑫元 Logo,四类 identity well 均为 36x36px;全部路径都保持唯一当前态。全部稳定帧截图存放在 `/tmp/integrity-switcher-qa.*`,未写入源码或 Git。 应用切换浏览器验收:登录页已在 1440x900、1280x720、768x1024、390x844 视口打开并检查控制台;真实业务会话未建立,Modal 内应用卡片、下拉/Portal 等打开态待有效会话后继续验收。代码结构与 `audit_project_manager/src/pages/project_manager_xy/ApplicationSwitcherDialog.jsx` 保持一致。
操作图标三层几何专项复测:清除 HeroUI `.button svg` 默认的 `4px -2px` margin,并把表格操作按钮在桌面和移动端都锁定为 32x32 正圆。`button`、Ant `.anticon` 和内部 `svg` 逐层实测为 32x32 / 16x16 / 16x16,内层 SVG margin 为 0,水平及垂直圆心偏差均为 0px。 操作图标三层几何专项复测:清除 HeroUI `.button svg` 默认的 `4px -2px` margin,并把表格操作按钮在桌面和移动端都锁定为 32x32 正圆。`button`、Ant `.anticon` 和内部 `svg` 逐层实测为 32x32 / 16x16 / 16x16,内层 SVG margin 为 0,水平及垂直圆心偏差均为 0px。
...@@ -79,7 +79,7 @@ ...@@ -79,7 +79,7 @@
编辑弹窗远程引用专项复测:多选触发器不再拼接长标题,固定显示“已选择 N 项”;HeroUI Popover 内使用 SearchField 和受控 ListBox,长标题与记录标识分两行,标题实际宽度受弹层内容区约束并单行省略。390x844 弹层实测宽 364px、标题宽 320px,页面无横向溢出;1440x900 弹层宽 384px、标题宽 338px。1440x900、1280x720、768x1024、390x844 均完成浅/深色打开态验证。取消一项后触发器即时更新且弹层保持打开,重新选择、搜索清空、清空全部均通过;单选字段选中后关闭。夹具只存在于浏览器路由中,未写入源码或执行保存。 编辑弹窗远程引用专项复测:多选触发器不再拼接长标题,固定显示“已选择 N 项”;HeroUI Popover 内使用 SearchField 和受控 ListBox,长标题与记录标识分两行,标题实际宽度受弹层内容区约束并单行省略。390x844 弹层实测宽 364px、标题宽 320px,页面无横向溢出;1440x900 弹层宽 384px、标题宽 338px。1440x900、1280x720、768x1024、390x844 均完成浅/深色打开态验证。取消一项后触发器即时更新且弹层保持打开,重新选择、搜索清空、清空全部均通过;单选字段选中后关闭。夹具只存在于浏览器路由中,未写入源码或执行保存。
分页专项复测:按问题整改基准删除额外的“10 条/页”页容量选择器,只保留 HeroUI compound Pagination,业务分页口径固定为每页 10 条。21 条浏览器夹具下实测共 3 页,第 2 页切换成功且当前页保持 `aria-current="page"`;1440x900 浅色与 390x844 深色下页容量控件数量均为 0,移动端 `body.scrollWidth === innerWidth === 390`。 分页专项复测:页容量选择器使用共享 HeroUI Autocomplete,固定选项为 20/50/100;选择后回到第 1 页并保留筛选/权限范围。分页仍使用 HeroUI compound Pagination,移动端无页面级横向溢出。
退出登录使用独立 `logout-proof` 会话复测:点击 Sidebar 用户菜单的“退出登录”后,`POST /api/auth/logout` 返回 200,页面跳转到 `/integrity_practice_management/login`,`current_user` 与 `auth_token` 同时从 localStorage 删除,仅保留非鉴权主题键;控制台 0 error。此前旧验收会话中登录页仍带 `current_user` 是会话夹具残留,不是产品退出流程的持久化行为。 退出登录使用独立 `logout-proof` 会话复测:点击 Sidebar 用户菜单的“退出登录”后,`POST /api/auth/logout` 返回 200,页面跳转到 `/integrity_practice_management/login`,`current_user` 与 `auth_token` 同时从 localStorage 删除,仅保留非鉴权主题键;控制台 0 error。此前旧验收会话中登录页仍带 `current_user` 是会话夹具残留,不是产品退出流程的持久化行为。
...@@ -143,5 +143,5 @@ pnpm install --lockfile-only --offline --frozen-lockfile ...@@ -143,5 +143,5 @@ pnpm install --lockfile-only --offline --frozen-lockfile
- 通知协议:沿用既有 `/notice/customer/query`、`/notice/customer/count`、`/notice/customer/read` 与动态 action API,新增活动路由 `/notifications`,base 仍为 `/integrity_practice_management/`。 - 通知协议:沿用既有 `/notice/customer/query`、`/notice/customer/count`、`/notice/customer/read` 与动态 action API,新增活动路由 `/notifications`,base 仍为 `/integrity_practice_management/`。
- 材料协议:查询 `am_material` 最近 20 条;本地文件仍先走 `/upload`,详情保存时才以 `source_app = integrity_practice_management` 建档,材料全部成功后才更新业务记录。 - 材料协议:查询 `am_material` 最近 20 条;本地文件仍先走 `/upload`,详情保存时才以 `source_app = integrity_practice_management` 建档,材料全部成功后才更新业务记录。
- 原始缺口:旧实现会在零授权 View 时创建“全部”兜底并查询;详情按单字段累计 payload;Navbar 无通知入口;文件字段只有本地上传。上述缺口已按问题整改基准收敛。 - 原始缺口:旧实现会在零授权 View 时创建“全部”兜底并查询;详情按单字段累计 payload;Navbar 无通知入口;文件字段只有本地上传。上述缺口已按问题整改基准收敛。
- 浏览器实测:目标运行于 `http://localhost:8006/integrity_practice_management/`,问题整改基准运行于 `http://localhost:8004/problem_rectification_new/`。Andy 登录后,`self_inspection_submission_form` 的真实 `shortTitle: true` View 已确认不渲染标题列,首列不承担预览,操作列保留预览/编辑/更多;Navbar 未读数真实返回 0,角标不显示;点击 Bell 正确进入 `/notifications`,并实际发出 `/notice/customer/count` 与 `/notice/customer/query` 请求。打开未归档真实记录时,记录、Subject、View 摘要和四个 `GET /subject/:subjectName/view/:viewName` 请求全部为 200;该 Detail 无字段权限配置,整体回退当前 Grid 后正常显示编辑入口。切换已归档 Grid 后,实际 Detail 带 `disableColumns`,Andy 虽为系统管理员,编辑态中模板、部门、项目结果、填报人、审批人等仍为只读,仅未禁用字段可编辑。控制台 0 error(保留两条既有 React Aria 开发 warning)。 - 浏览器实测:目标运行于 `http://localhost:8006/integrity_practice_management/`,唯一基准为 `audit_project_manager` 当前工作区。登录页已确认四种视口可访问,控制台无新增错误;真实业务会话尚未建立,因此不虚报列表、详情、通知和权限矩阵的线上打开态结果。
- 浏览器限制:Andy 是系统管理员且当前返回的 `applications` 为空,能够验证系统管理员旁路与 Detail `disableColumns` 不可旁路,但不能替代目标应用“管理”角色、普通用户和无权限用户矩阵。材料选择/上传/取消/部分失败/重试、通知 1/99/100 和动态 action 也没有制造业务数据验收;问题整改基准的独立登录会话未稳定建立。未使用伪造业务数据绕过这些限制。 - 浏览器限制:Andy 是系统管理员且当前返回的 `applications` 为空,能够验证系统管理员旁路与 Detail `disableColumns` 不可旁路,但不能替代目标应用“管理”角色、普通用户和无权限用户矩阵。材料选择/上传/取消/部分失败/重试、通知 1/99/100 和动态 action 也没有制造业务数据验收;问题整改基准的独立登录会话未稳定建立。未使用伪造业务数据绕过这些限制。
import '@/global.less'; import '@/global.less';
import '@/tailwind.css'; import '@/tailwind.css';
import { defineApp } from 'umi'; import { defineApp } from 'umi';
import { APPLICATION_BASE_PATH, THEME_STORAGE_KEY } from '@/config/application'; import { APPLICATION_BASE_PATH } 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 appBasePath = String( const appBasePath = String(
......
...@@ -15,12 +15,10 @@ import FileTextOutlined from '@ant-design/icons/FileTextOutlined'; ...@@ -15,12 +15,10 @@ import FileTextOutlined from '@ant-design/icons/FileTextOutlined';
import FullscreenExitOutlined from '@ant-design/icons/FullscreenExitOutlined'; import FullscreenExitOutlined from '@ant-design/icons/FullscreenExitOutlined';
import FullscreenOutlined from '@ant-design/icons/FullscreenOutlined'; import FullscreenOutlined from '@ant-design/icons/FullscreenOutlined';
import LogoutOutlined from '@ant-design/icons/LogoutOutlined'; import LogoutOutlined from '@ant-design/icons/LogoutOutlined';
import MoonOutlined from '@ant-design/icons/MoonOutlined';
import ReloadOutlined from '@ant-design/icons/ReloadOutlined'; import ReloadOutlined from '@ant-design/icons/ReloadOutlined';
import SendOutlined from '@ant-design/icons/SendOutlined'; import SendOutlined from '@ant-design/icons/SendOutlined';
import SearchOutlined from '@ant-design/icons/SearchOutlined'; import SearchOutlined from '@ant-design/icons/SearchOutlined';
import SettingOutlined from '@ant-design/icons/SettingOutlined'; import SettingOutlined from '@ant-design/icons/SettingOutlined';
import SunOutlined from '@ant-design/icons/SunOutlined';
import ToolOutlined from '@ant-design/icons/ToolOutlined'; import ToolOutlined from '@ant-design/icons/ToolOutlined';
import UploadOutlined from '@ant-design/icons/UploadOutlined'; import UploadOutlined from '@ant-design/icons/UploadOutlined';
import PlusOutlined from '@ant-design/icons/PlusOutlined'; import PlusOutlined from '@ant-design/icons/PlusOutlined';
...@@ -67,7 +65,6 @@ export const FileText = withSize(FileTextOutlined); ...@@ -67,7 +65,6 @@ export const FileText = withSize(FileTextOutlined);
export const Fullscreen = withSize(FullscreenOutlined); export const Fullscreen = withSize(FullscreenOutlined);
export const FullscreenExit = withSize(FullscreenExitOutlined); export const FullscreenExit = withSize(FullscreenExitOutlined);
export const LogOut = withSize(LogoutOutlined); export const LogOut = withSize(LogoutOutlined);
export const Moon = withSize(MoonOutlined);
export const More = withSize(MoreOutlined); export const More = withSize(MoreOutlined);
export const Reload = withSize(ReloadOutlined); export const Reload = withSize(ReloadOutlined);
export const ArrowPathIcon = Reload; export const ArrowPathIcon = Reload;
...@@ -75,7 +72,6 @@ export const Send = withSize(SendOutlined); ...@@ -75,7 +72,6 @@ export const Send = withSize(SendOutlined);
export const Search = withSize(SearchOutlined); export const Search = withSize(SearchOutlined);
export const MagnifyingGlassIcon = Search; export const MagnifyingGlassIcon = Search;
export const Settings = withSize(SettingOutlined); export const Settings = withSize(SettingOutlined);
export const Sun = withSize(SunOutlined);
export const Tool = withSize(ToolOutlined); export const Tool = withSize(ToolOutlined);
export const Upload = withSize(UploadOutlined); export const Upload = withSize(UploadOutlined);
export const Plus = withSize(PlusOutlined); export const Plus = withSize(PlusOutlined);
......
import { THEME_STORAGE_KEY } from '@/config/application';
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 { useLayoutEffect, useState } from 'react';
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({ children, notificationCount, notificationPath, open, onOpenChange, title, ...sidebarProps }) { export default function AppShell({ children, notificationCount, notificationPath, open, onOpenChange, title, ...sidebarProps }) {
const [theme, setTheme] = useState(getInitialTheme);
useLayoutEffect(() => {
const root = document.documentElement;
root.classList.toggle('dark', theme === 'dark');
root.classList.toggle('light', theme === 'light');
root.dataset.theme = theme;
root.style.colorScheme = theme;
try {
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
} catch {}
}, [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}
onOpenChange={onOpenChange} onOpenChange={onOpenChange}
> >
<DashboardSidebar theme={theme} {...sidebarProps} /> <DashboardSidebar {...sidebarProps} />
<Sidebar.Main className="app-main-glass rc-app-main"> <Sidebar.Main className="app-main-glass rc-app-main">
<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 xinyuanLogo from '@/assets/xy-logo-red.png'; import xinyuanLogo from '@/assets/xy-logo-red.png';
import { ExclamationTriangleIcon } from '@/components/AppIcons'; import {
import { APPLICATION_DISPLAY_NAME, APPLICATION_NAME } from '@/config/application'; APPLICATION_BASE_PATH,
APPLICATION_DISPLAY_NAME,
APPLICATION_NAME,
} from '@/config/application';
import { getApplications } from '@/services/DataService'; import { getApplications } from '@/services/DataService';
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 { Alert, Avatar, Card, Chip, Description, Modal, Skeleton, Surface } from '@heroui/react'; import { ItemCard } from '@heroui-pro/react/item-card';
import { ItemCardGroup } from '@heroui-pro/react/item-card-group';
import {
Alert,
Avatar,
Chip,
Modal,
ScrollShadow,
SearchField,
Skeleton,
Surface,
} from '@heroui/react';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
const CURRENT_APPLICATION_NAME = APPLICATION_NAME;
function unwrapApplications(response) { function unwrapApplications(response) {
for (const candidate of [response, response?.data, response?.applications, response?.apps, response?.records, response?.rows, response?.list, response?.result]) { const candidates = [
if (Array.isArray(candidate)) return candidate; response,
response?.data,
response?.applications,
response?.apps,
response?.records,
response?.rows,
response?.list,
response?.result,
];
return candidates.find(Array.isArray) || [];
}
function normalizeLabelList(items) {
if (!Array.isArray(items)) {
return [];
} }
return [];
return items
.map((item) => {
if (typeof item === 'string') {
return item;
}
return item?.display_name || item?.title || item?.name || '';
})
.filter(Boolean);
} }
function normalizeApplication(application = {}) { function resolveApplicationUrl(application) {
const name = application.name || ''; if (application?.name) {
const current = name === APPLICATION_NAME; return `/${application.name}`;
}
return '#';
}
function normalizeApplication(application, fallback = {}) {
const name = application?.name || fallback?.name || '';
const current = name === CURRENT_APPLICATION_NAME;
return { return {
name, name,
title: current ? APPLICATION_DISPLAY_NAME : application.title || name || '未命名应用', title: current
description: application.description || application.subtitle || application.title || '进入对应应用继续处理业务', ? APPLICATION_DISPLAY_NAME
logo: current ? xinyuanLogo : application.logo || application.icon || null, : application?.title || fallback?.title || application?.name || fallback?.name || '未命名应用',
order: Number(application.order || 0), description:
url: application.url || application.path || (name ? `/${name}` : '#'), application?.description ||
fallback?.description ||
fallback?.title ||
application?.title ||
'进入对应应用继续处理业务',
logo: xinyuanLogo,
url: resolveApplicationUrl({ name }),
access: application?.access || fallback?.access || '',
status: application?.status || fallback?.status || '',
type: application?.type || fallback?.type || '',
order: application?.order ?? fallback?.order ?? 0,
roles: normalizeLabelList(application?.roles?.length ? application.roles : fallback?.roles),
groups: normalizeLabelList(application?.groups?.length ? application.groups : fallback?.groups),
}; };
} }
function mergeApplications(fetched, fallbacks) { function mergeApplications(fetchedApplications, fallbackApplications) {
const merged = new Map(); const fallbackMap = new Map(
[...fallbacks, ...fetched, { name: APPLICATION_NAME, title: APPLICATION_DISPLAY_NAME }] fallbackApplications.map((application) => [application.name, application]),
.map(normalizeApplication) );
.filter((application) => application.name)
.forEach((application) => merged.set(application.name, { ...merged.get(application.name), ...application })); const normalizedFetched = fetchedApplications
return [...merged.values()].sort((left, right) => left.order - right.order || left.title.localeCompare(right.title, 'zh-CN')); .map((application) =>
normalizeApplication(application, fallbackMap.get(application?.name)),
)
.filter((application) => application.name);
const mergedMap = new Map(
normalizedFetched.map((application) => [application.name, application]),
);
fallbackApplications.forEach((application) => {
if (!application?.name || mergedMap.has(application.name)) {
return;
}
mergedMap.set(application.name, normalizeApplication(application, application));
});
const currentApplication = mergedMap.get(CURRENT_APPLICATION_NAME);
mergedMap.set(
CURRENT_APPLICATION_NAME,
normalizeApplication(
{ name: CURRENT_APPLICATION_NAME },
currentApplication || { title: APPLICATION_DISPLAY_NAME },
),
);
return Array.from(mergedMap.values()).sort((left, right) => {
const orderDifference = Number(left.order || 0) - Number(right.order || 0);
if (orderDifference !== 0) {
return orderDifference;
}
return left.title.localeCompare(right.title, 'zh-CN');
});
} }
function buildApplicationSections(applications, applicationGroups) { function buildApplicationSections(applications, applicationGroups) {
...@@ -40,22 +136,31 @@ function buildApplicationSections(applications, applicationGroups) { ...@@ -40,22 +136,31 @@ function buildApplicationSections(applications, applicationGroups) {
return [{ key: 'all', label: '全部应用', applications }]; return [{ key: 'all', label: '全部应用', applications }];
} }
const applicationMap = new Map(applications.map((application) => [application.name, application])); const applicationMap = new Map(
applications.map((application) => [application.name, application]),
);
const groupedApplicationNames = new Set(); const groupedApplicationNames = new Set();
const sections = applicationGroups const sections = applicationGroups
.map((group, index) => { .map((group, index) => {
const groupApplications = Array.isArray(group?.applications) const groupApplications = Array.isArray(group?.applications)
? group.applications ? group.applications
.map((name) => { .map((name) => {
const application = applicationMap.get(name); if (groupedApplicationNames.has(name)) {
if (application) groupedApplicationNames.add(name); return null;
return application; }
})
.filter(Boolean) const application = applicationMap.get(name);
if (application) {
groupedApplicationNames.add(name);
}
return application;
})
.filter(Boolean)
: []; : [];
return { return {
key: `group-${index}-${group?.label || 'applications'}`, key: group?.label || `group-${index}`,
label: group?.label || '应用分组', label: group?.label || '应用分组',
applications: groupApplications, applications: groupApplications,
}; };
...@@ -65,6 +170,7 @@ function buildApplicationSections(applications, applicationGroups) { ...@@ -65,6 +170,7 @@ function buildApplicationSections(applications, applicationGroups) {
const ungroupedApplications = applications.filter( const ungroupedApplications = applications.filter(
(application) => !groupedApplicationNames.has(application.name), (application) => !groupedApplicationNames.has(application.name),
); );
if (ungroupedApplications.length > 0) { if (ungroupedApplications.length > 0) {
sections.push({ sections.push({
key: 'ungrouped', key: 'ungrouped',
...@@ -77,149 +183,319 @@ function buildApplicationSections(applications, applicationGroups) { ...@@ -77,149 +183,319 @@ function buildApplicationSections(applications, applicationGroups) {
} }
function isCurrentApplication(application) { function isCurrentApplication(application) {
if (application.name === APPLICATION_NAME) return true; if (application?.name === CURRENT_APPLICATION_NAME) {
if (typeof window === 'undefined') return false; return true;
try { }
const target = new URL(application.url, window.location.origin);
const root = application.name ? `/${application.name}` : target.pathname; if (typeof window === 'undefined') {
return window.location.pathname === root || window.location.pathname.startsWith(`${root}/`);
} catch {
return false; return false;
} }
const currentPath = window.location.pathname;
const applicationRoot = application?.name ? `/${application.name}` : '';
if (application?.url?.startsWith('http')) {
try {
const url = new URL(application.url);
return url.pathname === currentPath;
} catch (error) {
return false;
}
}
return (
application?.url === currentPath ||
(!!applicationRoot &&
(currentPath === applicationRoot ||
currentPath.startsWith(`${applicationRoot}/`)))
);
} }
function ApplicationCard({ application, current }) { function ApplicationCard({ application, current }) {
const target = /^https?:\/\//i.test(application.url) ? '_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"
aria-current={current ? 'page' : undefined} href={application.url}
className="flex min-w-0 items-center gap-3 no-underline" rel={target ? 'noreferrer' : undefined}
href={application.url} render={(props) => <a {...props} />}
rel={target ? 'noreferrer' : undefined} target={target}
target={target} variant={current ? 'outline' : 'default'}
> >
<Surface aria-hidden="true" className="rc-app-identity-well grid size-9 shrink-0 place-items-center overflow-hidden" variant="tertiary"> <ItemCard.Icon aria-hidden="true">
<Avatar className="rc-app-identity-avatar size-full" color="accent" variant="soft"> <Surface variant="default" className="rc-application-identity-well">
{application.logo ? <Avatar.Image alt="" className="object-contain p-1.5" src={application.logo} /> : null} <Avatar
<Avatar.Fallback>{initial}</Avatar.Fallback> className="rc-application-identity-avatar"
</Avatar> color="accent"
</Surface> variant="soft"
<span className="min-w-0 flex-1"> >
<span className="block truncate text-sm font-medium leading-5" title={application.title}>{application.title}</span> <Avatar.Image
<Description className="mt-0.5 block truncate leading-4" title={application.description}>{application.description}</Description> alt=""
</span> className="object-contain p-1.5"
{current ? <Chip color="accent" size="sm">当前</Chip> : null} src={application.logo || xinyuanLogo}
</a> />
</Card.Content> <Avatar.Fallback>
</Card> <img alt="" className="object-contain p-1.5" src={xinyuanLogo} />
</Avatar.Fallback>
</Avatar>
</Surface>
</ItemCard.Icon>
<ItemCard.Content>
<ItemCard.Title title={application.title}>
{application.title}
</ItemCard.Title>
{hasDistinctDescription ? (
<ItemCard.Description title={description}>
{description}
</ItemCard.Description>
) : null}
</ItemCard.Content>
{current ? (
<ItemCard.Action>
<Chip color="accent" size="sm">当前</Chip>
</ItemCard.Action>
) : null}
</ItemCard>
); );
} }
function LoadingSkeleton() { function LoadingSkeleton() {
return ( return (
<div aria-label="正在加载应用" className="grid gap-3 sm:grid-cols-2"> <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-3 w-full" /></div> <Skeleton className="h-4 w-2/3" />
</Card.Content> <Skeleton className="h-3 w-full" />
</Card> </ItemCard.Content>
</ItemCard>
))} ))}
</div> </ItemCardGroup>
); );
} }
export default function ApplicationSwitcherDialog({ open, onClose, siteInfo, userInfo }) { export default function ApplicationSwitcherDialog({
const fallbacks = useMemo( open,
() => (Array.isArray(userInfo?.applications) ? userInfo.applications : []), onClose,
[userInfo], siteInfo,
userInfo,
}) {
const fallbackApplications = useMemo(() => {
if (!Array.isArray(userInfo?.applications)) {
return [];
}
return userInfo.applications
.map((application) =>
normalizeApplication(application, {
...application,
description: application?.title,
}),
)
.filter((application) => application.name);
}, [userInfo]);
const [applications, setApplications] = useState(() =>
mergeApplications([], fallbackApplications),
); );
const [applications, setApplications] = useState(() => mergeApplications([], fallbacks)); const [status, setStatus] = useState(
const [status, setStatus] = useState('idle'); fallbackApplications.length > 0 ? 'success' : 'idle',
const [errorMessage, setErrorMessage] = useState('');
const currentApplicationName = useMemo(
() => applications.find(isCurrentApplication)?.name || APPLICATION_NAME,
[applications],
); );
const [errorMessage, setErrorMessage] = useState('');
const [applicationSearch, setApplicationSearch] = useState('');
const currentApplicationName = useMemo(() => {
return applications.find(isCurrentApplication)?.name || CURRENT_APPLICATION_NAME;
}, [applications]);
const applicationSections = useMemo( const applicationSections = useMemo(
() => buildApplicationSections(applications, siteInfo?.group), () => buildApplicationSections(applications, siteInfo?.group),
[applications, siteInfo?.group], [applications, siteInfo?.group],
); );
const currentApplicationKey = useMemo(() => { const filteredApplicationSections = useMemo(() => {
for (const section of applicationSections) { const keyword = applicationSearch.trim().toLocaleLowerCase();
const applicationIndex = section.applications.findIndex( if (!keyword) {
(application) => application.name === currentApplicationName, return applicationSections;
); }
if (applicationIndex >= 0) return `${section.key}:${applicationIndex}:${currentApplicationName}`;
return applicationSections
.map((section) => ({
...section,
applications: section.applications.filter((application) => [
application.title,
application.description,
application.name,
].some((value) => String(value || '').toLocaleLowerCase().includes(keyword))),
}))
.filter((section) => section.applications.length > 0);
}, [applicationSearch, applicationSections]);
useEffect(() => {
if (fallbackApplications.length === 0) {
return;
} }
return '';
}, [applicationSections, currentApplicationName]); setApplications((previous) =>
mergeApplications(previous, fallbackApplications),
);
}, [fallbackApplications]);
useEffect(() => { useEffect(() => {
if (!open) return undefined; if (!open) {
setApplicationSearch('');
return;
}
let cancelled = false; let cancelled = false;
setStatus((previous) => (previous === 'success' ? previous : 'loading'));
setErrorMessage(''); const loadApplications = async () => {
getApplications() setStatus((previous) => (previous === 'success' ? previous : 'loading'));
.then((response) => { setErrorMessage('');
if (cancelled) return;
setApplications(mergeApplications(unwrapApplications(response), fallbacks)); try {
const response = await getApplications();
const fetchedApplications = unwrapApplications(response);
if (cancelled) {
return;
}
const mergedApplications = mergeApplications(
fetchedApplications,
fallbackApplications,
);
setApplications(mergedApplications);
setStatus('success'); setStatus('success');
}) } catch (error) {
.catch(() => { if (cancelled) {
if (cancelled) return; return;
setApplications(mergeApplications([], fallbacks)); }
setApplications(mergeApplications([], fallbackApplications));
setStatus('error'); setStatus('error');
setErrorMessage('应用列表加载失败,已回退展示当前账号上的应用信息。'); setErrorMessage('应用列表加载失败,已回退展示当前账号上的应用信息。');
}); }
return () => { cancelled = true; }; };
}, [fallbacks, open]);
loadApplications();
return () => {
cancelled = true;
};
}, [fallbackApplications, open]);
return ( return (
<Modal.Root> <Modal.Root>
<Modal.Backdrop isOpen={open} variant="blur" onOpenChange={(isOpen) => !isOpen && onClose?.()}> <Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
<Modal.Backdrop
isOpen={open}
onOpenChange={(isOpen) => {
if (!isOpen) onClose?.();
}}
variant="blur"
>
<Modal.Container placement="center" scroll="inside" size="lg"> <Modal.Container placement="center" scroll="inside" size="lg">
<Modal.Dialog aria-label="应用切换" className="rc-app-switcher-dialog p-7"> <Modal.Dialog
aria-label="应用切换"
className="max-h-[min(42rem,calc(100dvh-2rem))] bg-background p-7"
style={{
maxWidth: 'min(46rem, calc(100vw - 2rem))',
width: 'min(46rem, calc(100vw - 2rem))',
}}
>
<Modal.CloseTrigger <Modal.CloseTrigger
aria-label="关闭" aria-label="关闭"
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">
<Description className="leading-5">选择你可访问的应用</Description> <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 ? <Alert status="warning"><Alert.Indicator><ExclamationTriangleIcon size={20} /></Alert.Indicator><Alert.Content><Alert.Description>{errorMessage}</Alert.Description></Alert.Content></Alert> : null} <ScrollShadow
{status === 'loading' && applications.length === 0 ? <LoadingSkeleton /> : applications.length > 0 ? ( className="h-full min-h-0 flex-1 overflow-y-auto pe-1 pb-1"
<div className="space-y-6"> hideScrollBar={false}
{applicationSections.map((section) => ( offset={12}
<section key={section.key}> size={24}
<div className="mb-3 flex items-center justify-between gap-3"> >
<h3 className="text-sm font-medium leading-5">{section.label}</h3> {status === 'error' && errorMessage ? (
<Description>{section.applications.length} 个应用</Description> <Alert className="mb-4" status="warning">
</div> <Alert.Indicator><ExclamationTriangleIcon size={20} /></Alert.Indicator>
<div className="grid gap-3 sm:grid-cols-2"> <Alert.Content><Alert.Description>{errorMessage}</Alert.Description></Alert.Content>
{section.applications.map((application, applicationIndex) => { </Alert>
const applicationKey = `${section.key}:${applicationIndex}:${application.name}`; ) : null}
return (
<ApplicationCard {status === 'loading' && applications.length === 0 ? (
application={application} <LoadingSkeleton />
current={applicationKey === currentApplicationKey} ) : filteredApplicationSections.length > 0 ? (
key={applicationKey} <div className="space-y-6">
/> {filteredApplicationSections.map((section) => (
); <ItemCardGroup
})} key={section.key}
</div> className="grid-cols-1 sm:grid-cols-2"
</section> columns={2}
))} layout="grid"
</div> variant="transparent"
) : ( >
<EmptyState size="md"><EmptyState.Header><EmptyState.Title>暂无可切换应用</EmptyState.Title><EmptyState.Description>当前账号还没有分配其它应用权限。</EmptyState.Description></EmptyState.Header></EmptyState> <ItemCardGroup.Header className="flex items-center justify-between gap-3">
)} <ItemCardGroup.Title>{section.label}</ItemCardGroup.Title>
<ItemCardGroup.Description className="mt-0">
{section.applications.length} 个应用
</ItemCardGroup.Description>
</ItemCardGroup.Header>
{section.applications.map((application) => (
<ApplicationCard
key={application.name}
application={application}
current={application.name === currentApplicationName}
/>
))}
</ItemCardGroup>
))}
</div>
) : (
<EmptyStateSurface>
<EmptyState size="md">
<EmptyState.Header>
<EmptyState.Title>
{applicationSearch.trim() ? '没有找到匹配的应用' : '暂无可切换应用'}
</EmptyState.Title>
<EmptyState.Description>
{applicationSearch.trim()
? '请尝试其他关键词。'
: '当前账号还没有分配其它应用权限。'}
</EmptyState.Description>
</EmptyState.Header>
</EmptyState>
</EmptyStateSurface>
)}
</ScrollShadow>
</Modal.Body> </Modal.Body>
</Modal.Dialog> </Modal.Dialog>
</Modal.Container> </Modal.Container>
......
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({ notificationCount = 0, onOpenNotifications, onThemeChange, theme, title }) { export default function DashboardNavbar({ notificationCount = 0, onOpenNotifications, 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 (
...@@ -41,20 +39,6 @@ export default function DashboardNavbar({ notificationCount = 0, onOpenNotificat ...@@ -41,20 +39,6 @@ export default function DashboardNavbar({ notificationCount = 0, onOpenNotificat
</Badge.Anchor> </Badge.Anchor>
<Tooltip.Content>通知消息</Tooltip.Content> <Tooltip.Content>通知消息</Tooltip.Content>
</Tooltip> </Tooltip>
<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 { Sidebar } from '@heroui-pro/react/sidebar'; import { Sheet } from '@heroui-pro/react';
import { Avatar, Button, Dropdown, Label, Tooltip } from '@heroui/react'; import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar';
import { AppGrid, LogOut } from './AppIcons'; import { Avatar, Button, Disclosure, Dropdown, Label } from '@heroui/react';
import { AppGrid, ChevronDown, LogOut } from './AppIcons';
function getUserDisplay(userInfo) { function getUserDisplay(userInfo) {
const displayName = userInfo?.display_name || userInfo?.username || '当前用户'; const displayName = userInfo?.display_name || userInfo?.username || '当前用户';
const username = userInfo?.username || userInfo?.email || '已登录'; const username = userInfo?.username || userInfo?.email || '已登录';
return { return {
displayName, displayName,
username, username,
...@@ -17,22 +18,39 @@ function getUserDisplay(userInfo) { ...@@ -17,22 +18,39 @@ function getUserDisplay(userInfo) {
function SidebarUserMenu({ userInfo, onLogout }) { function SidebarUserMenu({ userInfo, onLogout }) {
const user = getUserDisplay(userInfo); const user = getUserDisplay(userInfo);
return ( return (
<Dropdown> <Dropdown>
<Dropdown.Trigger aria-label="打开用户菜单" className="app-sidebar-user-trigger rc-sidebar-user-trigger"> <Dropdown.Trigger
aria-label="打开用户菜单"
className="app-sidebar-user-trigger rc-sidebar-user-trigger"
>
<Avatar className="app-sidebar-user-avatar rc-sidebar-avatar"> <Avatar className="app-sidebar-user-avatar rc-sidebar-avatar">
{userInfo?.avatar ? <Avatar.Image alt={user.displayName} src={userInfo.avatar} /> : null} {userInfo?.avatar ? (
<Avatar.Image alt={user.displayName} src={userInfo.avatar} />
) : null}
<Avatar.Fallback>{user.initials}</Avatar.Fallback> <Avatar.Fallback>{user.initials}</Avatar.Fallback>
</Avatar> </Avatar>
<span className="app-sidebar-user-copy rc-sidebar-user-copy" data-sidebar="label"> <span
className="app-sidebar-user-copy rc-sidebar-user-copy"
data-sidebar="label"
>
<span className="app-sidebar-user-name">{user.displayName}</span> <span className="app-sidebar-user-name">{user.displayName}</span>
<span className="app-sidebar-user-description">{user.username}</span> <span className="app-sidebar-user-description">{user.username}</span>
</span> </span>
</Dropdown.Trigger> </Dropdown.Trigger>
<Dropdown.Popover placement="top start"> <Dropdown.Popover placement="top start">
<Dropdown.Menu aria-label="用户菜单" onAction={(key) => key === 'logout' && onLogout?.()}> <Dropdown.Menu
aria-label="用户菜单"
onAction={(key) => {
if (key === 'logout') onLogout?.();
}}
>
<Dropdown.Item id="logout" textValue="退出登录" variant="danger"> <Dropdown.Item id="logout" textValue="退出登录" variant="danger">
<div className="rc-sidebar-user-menu-item"><Label>退出登录</Label><LogOut size={14} /></div> <div className="rc-sidebar-user-menu-item">
<Label>退出登录</Label>
<LogOut size={14} />
</div>
</Dropdown.Item> </Dropdown.Item>
</Dropdown.Menu> </Dropdown.Menu>
</Dropdown.Popover> </Dropdown.Popover>
...@@ -40,59 +58,151 @@ function SidebarUserMenu({ userInfo, onLogout }) { ...@@ -40,59 +58,151 @@ function SidebarUserMenu({ userInfo, onLogout }) {
); );
} }
function SidebarContents({ menuGroups, onLogout, onOpenApplicationSwitcher, pathname, userInfo }) { function SidebarContents({
menuGroups,
onLogout,
onOpenApplicationSwitcher,
pathname,
userInfo,
}) {
const renderMenu = (group) => (
<Sidebar.Menu aria-label={group.title}>
{group.items.map((item) => {
const Icon = item.icon;
return (
<Sidebar.MenuItem
href={item.path}
isCurrent={pathname === item.path}
key={item.path}
textValue={item.name}
tooltip={item.name}
>
<Sidebar.MenuIcon>
<Icon size={16} />
</Sidebar.MenuIcon>
<Sidebar.MenuLabel>{item.name}</Sidebar.MenuLabel>
</Sidebar.MenuItem>
);
})}
</Sidebar.Menu>
);
const renderGroup = (group) => {
const GroupIcon = group.icon;
return (
<Sidebar.Group key={group.title}>
<Disclosure defaultExpanded>
<Disclosure.Heading>
<Sidebar.GroupLabel>
<Disclosure.Trigger className="flex w-full min-w-0 items-center gap-2 text-left">
{GroupIcon ? <GroupIcon size={16} /> : null}
<span className="min-w-0 flex-1 truncate">{group.title}</span>
<Disclosure.Indicator>
<ChevronDown size={14} />
</Disclosure.Indicator>
</Disclosure.Trigger>
</Sidebar.GroupLabel>
</Disclosure.Heading>
<Disclosure.Content>
<Disclosure.Body>{renderMenu(group)}</Disclosure.Body>
</Disclosure.Content>
</Disclosure>
</Sidebar.Group>
);
};
return ( return (
<> <>
<Sidebar.Header> <Sidebar.Header>
<div className="app-sidebar-brand rc-sidebar-brand"> <div className="app-sidebar-brand rc-sidebar-brand">
<div className="app-sidebar-brand-mark rc-sidebar-brand-mark"> <div className="app-sidebar-brand-mark rc-sidebar-brand-mark">
<img alt="鑫元" className="rc-sidebar-logo rc-sidebar-logo--light" src={xyLogoRed} /> <img
<img alt="鑫元" className="rc-sidebar-logo rc-sidebar-logo--dark" src={xyLogoWhite} /> alt="鑫元"
className="rc-sidebar-logo"
src={xyLogoWhite}
/>
</div> </div>
<div className="app-sidebar-brand-main rc-sidebar-brand-main" data-sidebar="label"> <div
<span className="app-sidebar-title rc-sidebar-title">{APPLICATION_DISPLAY_NAME}</span> className="app-sidebar-brand-main rc-sidebar-brand-main"
data-sidebar="label"
>
<span className="app-sidebar-title rc-sidebar-title">
{APPLICATION_DISPLAY_NAME}
</span>
</div> </div>
<div className="app-sidebar-app-switcher rc-sidebar-app-switcher" data-sidebar="label"> <div
<Tooltip delay={0}> className="app-sidebar-app-switcher rc-sidebar-app-switcher"
<Button isIconOnly aria-label="切换应用" className="app-sidebar-app-trigger rc-sidebar-app-trigger" size="sm" variant="ghost" onPress={onOpenApplicationSwitcher}> data-sidebar="label"
<AppGrid size={16} /> >
</Button> <Button
<Tooltip.Content>切换应用</Tooltip.Content> isIconOnly
</Tooltip> aria-label="切换应用"
className="app-sidebar-app-trigger rc-sidebar-app-trigger"
size="sm"
variant="ghost"
onPress={onOpenApplicationSwitcher}
>
<AppGrid size={16} />
</Button>
</div> </div>
</div> </div>
</Sidebar.Header> </Sidebar.Header>
<Sidebar.Content> <Sidebar.Content>
{menuGroups.map((group) => ( {menuGroups.map((group) => (
<Sidebar.Group key={group.title}> group.showLabel === false ? (
{group.showLabel !== false ? <Sidebar.GroupLabel>{group.title}</Sidebar.GroupLabel> : null} <Sidebar.Group key={group.title}>{renderMenu(group)}</Sidebar.Group>
<Sidebar.Menu aria-label={group.title}> ) : renderGroup(group)
{group.items.map((item) => {
const Icon = item.icon;
return (
<Sidebar.MenuItem href={item.path} isCurrent={pathname === item.path} key={item.path} textValue={item.name} tooltip={item.name}>
<Sidebar.MenuIcon><Icon size={16} /></Sidebar.MenuIcon>
<Sidebar.MenuLabel>{item.name}</Sidebar.MenuLabel>
</Sidebar.MenuItem>
);
})}
</Sidebar.Menu>
</Sidebar.Group>
))} ))}
</Sidebar.Content> </Sidebar.Content>
<Sidebar.Footer><SidebarUserMenu userInfo={userInfo} onLogout={onLogout} /></Sidebar.Footer>
<Sidebar.Footer>
<SidebarUserMenu userInfo={userInfo} onLogout={onLogout} />
</Sidebar.Footer>
</> </>
); );
} }
function AccessibleMobileSidebar(props) {
const { isMobile, isMobileOpen, setMobileOpen, side } = useSidebar();
if (!isMobile) {
return null;
}
return (
<Sheet.Root
isOpen={isMobileOpen}
placement={side}
onOpenChange={setMobileOpen}
>
<Sheet.Backdrop variant="blur">
<Sheet.Content className="sidebar__mobile-sheet">
<Sheet.Dialog
aria-label={`${APPLICATION_DISPLAY_NAME}导航`}
className="sidebar__mobile-dialog"
>
<div
className="sidebar__mobile app-sidebar-glass rc-mobile-sidebar"
data-slot="sidebar-mobile"
>
<SidebarContents {...props} />
</div>
</Sheet.Dialog>
</Sheet.Content>
</Sheet.Backdrop>
</Sheet.Root>
);
}
export default function DashboardSidebar(props) { export default function DashboardSidebar(props) {
return ( return (
<> <>
<Sidebar className="app-sidebar-glass rc-sidebar"><SidebarContents {...props} /></Sidebar> <Sidebar className="app-sidebar-glass rc-sidebar">
<Sidebar.Mobile className="app-sidebar-glass rc-mobile-sidebar">
<h2 className="sr-only" slot="title">{APPLICATION_DISPLAY_NAME}导航</h2>
<SidebarContents {...props} /> <SidebarContents {...props} />
</Sidebar.Mobile> </Sidebar>
<AccessibleMobileSidebar {...props} />
</> </>
); );
} }
import { Close } from '@/components/AppIcons';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import { ActionBar } from '@heroui-pro/react/action-bar';
import { DataGrid } from '@heroui-pro/react/data-grid';
import { EmptyState } from '@heroui-pro/react/empty-state';
import {
Button,
Chip,
Pagination,
Separator,
Spinner,
Surface,
} from '@heroui/react';
import { Tooltip } from '@heroui/react';
import { useLayoutEffect, useRef, useState } from 'react';
const DATA_TABLE_LONG_TEXT_TYPES = new Set([
'long_text',
'rich_text',
'object',
'form',
'subject',
]);
const DATA_TABLE_LONG_TEXT_LABEL_PATTERN =
/需求明细|需求内容|材料清单|工作内容|检查内容|审计范围|整改情况|问题简述|整改措施|问题描述|具体问题|整改要求|描述|措施|原因|意见|备注|说明/;
export function isDataTableLongTextField(fieldDef, label = '') {
return DATA_TABLE_LONG_TEXT_TYPES.has(
String(fieldDef?.type || '').toLowerCase(),
) || DATA_TABLE_LONG_TEXT_LABEL_PATTERN.test(
String(label || fieldDef?.label || ''),
);
}
function normalizeDataTableEmptyTitle(title) {
const text = String(title || '').trim();
if (!text) {
return '暂无数据';
}
if (/^(暂无数据){2,}$/.test(text)) {
return '暂无数据';
}
return title;
}
export function EmptyStateSurface({ children, className = '' }) {
return (
<Surface
className={['w-full min-w-0 rounded-2xl p-4', className].filter(Boolean).join(' ')}
variant="secondary"
>
{children}
</Surface>
);
}
export function DataTableEmptyState({ title = '暂无数据', size = 'md' }) {
const resolvedTitle = normalizeDataTableEmptyTitle(title);
return (
<EmptyStateSurface>
<EmptyState size={size}>
<EmptyState.Header>
<EmptyState.Description className="rc-empty-state-message">
{resolvedTitle}
</EmptyState.Description>
</EmptyState.Header>
</EmptyState>
</EmptyStateSurface>
);
}
function DataTableLoadingState() {
return (
<div className="min-h-48 w-full" aria-hidden="true" />
);
}
function resolveRowKey(row, index, getRowKey) {
const explicitKey = getRowKey?.(row, index);
if (explicitKey) {
return String(explicitKey);
}
return String(
row?.recordKey ??
row?.recordId ??
row?.id ??
row?._id ??
row?.name ??
index,
);
}
function resolveColumnWidth(width) {
if (typeof width === 'number') {
return width;
}
const normalizedWidth = String(width || '').trim();
const pixelMatch = normalizedWidth.match(/^(\d+(?:\.\d+)?)px$/);
if (pixelMatch) {
return Number(pixelMatch[1]);
}
if (/^\d+(?:\.\d+)?(?:%|fr)$/.test(normalizedWidth)) {
return normalizedWidth;
}
return undefined;
}
export function DataTable({
ariaLabel = '数据表格',
columns,
rows,
isLoading = false,
getRowKey,
selectedRowKeys,
onSelectedRowKeysChange,
emptyTitle = '暂无数据',
emptyStateSize = 'md',
className = '',
isWide,
disabledRowKeys,
onRowAction,
sortDescriptor,
onSortChange,
renderSelectionActions,
allowsColumnResize = true,
}) {
const selectionEnabled = Boolean(selectedRowKeys && onSelectedRowKeysChange);
const selectedKeys = selectionEnabled ? new Set(selectedRowKeys) : undefined;
const gridViewportRef = useRef(null);
const [hasHorizontalOverflow, setHasHorizontalOverflow] = useState(false);
const firstBusinessColumnIndex = columns.findIndex(
(column) =>
!column.isSelection &&
!column.isActions &&
column.key !== 'actions',
);
useLayoutEffect(() => {
if (typeof isWide === 'boolean') {
setHasHorizontalOverflow(isWide);
return undefined;
}
const viewport = gridViewportRef.current;
if (!viewport) {
return undefined;
}
const updateOverflow = () => {
const scrollContainer =
viewport.querySelector('.table__resizable-container') ||
viewport.querySelector('.data-table-scroll');
if (!scrollContainer) return;
setHasHorizontalOverflow(
scrollContainer.scrollWidth > scrollContainer.clientWidth + 1,
);
};
updateOverflow();
if (typeof ResizeObserver === 'undefined') {
return undefined;
}
const resizeObserver = new ResizeObserver(updateOverflow);
resizeObserver.observe(viewport);
const scrollContainer =
viewport.querySelector('.table__resizable-container') ||
viewport.querySelector('.data-table-scroll');
if (scrollContainer) resizeObserver.observe(scrollContainer);
return () => resizeObserver.disconnect();
}, [columns, isWide, rows]);
const wide = typeof isWide === 'boolean' ? isWide : hasHorizontalOverflow;
const gridColumns = columns.map((column, index) => {
const isActions = column.isActions || column.key === 'actions';
const isPrimary =
column.isPrimary ?? (!isActions && index === firstBusinessColumnIndex);
const configuredWidth = resolveColumnWidth(column.width);
const numericWidth =
typeof configuredWidth === 'number' ? configuredWidth : undefined;
const pinned = wide
? column.pin || (isActions ? 'end' : isPrimary ? 'start' : undefined)
: undefined;
const minWidth =
column.minWidth ||
numericWidth ||
(isActions ? 104 : pinned ? 220 : 128);
const width =
!wide && isPrimary
? typeof configuredWidth === 'string' && configuredWidth.endsWith('fr')
? configuredWidth
: '1fr'
: configuredWidth || (pinned ? minWidth : undefined);
return {
id: String(column.key),
header: column.title,
accessorKey: typeof column.key === 'string' ? column.key : undefined,
align: column.align,
cell: (row) => {
const value = column.render ? column.render(row) : row?.[column.key];
if (value === undefined || value === null || value === '') {
return '-';
}
if (typeof value === 'string' || typeof value === 'number') {
return (
<DataTableText
text={value}
showTooltip={false}
className={
isPrimary ? 'font-medium' : ''
}
/>
);
}
return isActions ? value : (
<div className="min-w-0 max-w-full">
{value}
</div>
);
},
isRowHeader: index === firstBusinessColumnIndex,
allowsSorting: column.sortable === true,
headerClassName: column.headerClassName,
cellClassName: [
'min-w-0',
column.cellClassName,
].filter(Boolean).join(' '),
allowsResizing: true,
minWidth,
width,
pinned,
};
});
const handleSelectionChange = (selection) => {
if (!onSelectedRowKeysChange) {
return;
}
if (selection === 'all') {
const disabledKeySet = new Set(
Array.from(disabledRowKeys || []).map(String),
);
onSelectedRowKeysChange(
rows
.map((row, index) => resolveRowKey(row, index, getRowKey))
.filter((key) => !disabledKeySet.has(key)),
);
return;
}
onSelectedRowKeysChange(Array.from(selection).map(String));
};
return (
<div className={['relative min-w-0 w-full', className].filter(Boolean).join(' ')}>
<div ref={gridViewportRef} className="relative min-w-0 w-full">
{isLoading ? (
<div
className="absolute inset-0 z-10 grid place-items-center"
aria-label="数据加载中"
>
<Spinner size="sm" />
</div>
) : null}
<DataGrid
showSelectionCheckboxes={selectionEnabled}
allowsColumnResize={allowsColumnResize}
aria-label={ariaLabel}
columns={gridColumns}
contentClassName={wide ? 'min-w-max' : 'min-w-full'}
data={rows}
disabledKeys={disabledRowKeys}
getRowId={(row) => resolveRowKey(row, rows.indexOf(row), getRowKey)}
renderEmptyState={() =>
isLoading ? (
<DataTableLoadingState />
) : (
<DataTableEmptyState title={emptyTitle} size={emptyStateSize} />
)
}
scrollContainerClassName="data-table-scroll"
selectedKeys={selectedKeys}
selectionMode={selectionEnabled ? 'multiple' : 'none'}
variant="primary"
verticalAlign="middle"
onRowAction={onRowAction}
sortDescriptor={sortDescriptor}
onSortChange={onSortChange}
onSelectionChange={
selectionEnabled ? handleSelectionChange : undefined
}
/>
</div>
{selectionEnabled ? (
<DataTableActionBar
selectedCount={selectedRowKeys.length}
onClear={() => onSelectedRowKeysChange([])}
>
{renderSelectionActions ? (
renderSelectionActions(selectedRowKeys)
) : (
<span className="text-sm">已选择当前页记录</span>
)}
</DataTableActionBar>
) : null}
</div>
);
}
export function DataTableActionBar({
selectedCount,
onClear,
children,
ariaLabel = '批量操作',
}) {
return (
<ActionBar
aria-label={ariaLabel}
className="rc-data-grid-action-bar"
isOpen={selectedCount > 0}
>
<ActionBar.Prefix>
<Chip className="shrink-0 tabular-nums" size="sm">
{selectedCount}
</Chip>
</ActionBar.Prefix>
<Separator orientation="vertical" />
<ActionBar.Content>{children}</ActionBar.Content>
<Separator orientation="vertical" />
<ActionBar.Suffix>
<Button
isIconOnly
aria-label="清空选择"
size="sm"
variant="ghost"
onPress={onClear}
>
<Close size={16} />
</Button>
</ActionBar.Suffix>
</ActionBar>
);
}
export function DataTableText({
children,
text,
className = '',
showTooltip = false,
maxLines = 1,
}) {
const fullText = String(text ?? children ?? '').trim();
const multilineClassName =
maxLines > 1
? `overflow-hidden whitespace-normal break-words [display:-webkit-box] [-webkit-box-orient:vertical] ${
maxLines === 3 ? '[-webkit-line-clamp:3]' : '[-webkit-line-clamp:2]'
} [overflow-wrap:anywhere]`
: '';
const trigger = showTooltip ? (
<DataTableTextTooltipTrigger
className={[multilineClassName, className].filter(Boolean).join(' ')}
text={fullText}
truncate={maxLines === 1}
>
{children ?? (fullText || '-')}
</DataTableTextTooltipTrigger>
) : (
<span className={[
maxLines > 1 ? multilineClassName : 'truncate',
'block min-w-0 max-w-full overflow-hidden',
className,
].filter(Boolean).join(' ')}>
{children ?? (fullText || '-')}
</span>
);
if (!showTooltip || !fullText || fullText === '-') {
return trigger;
}
return (
<DataTableTextTooltipTrigger
className={[multilineClassName, className].filter(Boolean).join(' ')}
text={fullText}
truncate={maxLines === 1}
>
{children ?? (fullText || '-')}
</DataTableTextTooltipTrigger>
);
}
function DataTableTextTooltipTrigger({
children,
className = '',
text,
truncate = true,
}) {
return (
<Tooltip>
<Tooltip.Trigger
className={[
'block min-w-0 max-w-full overflow-hidden',
truncate ? 'min-w-0 truncate' : '',
className,
].filter(Boolean).join(' ')}
>
{children}
</Tooltip.Trigger>
<Tooltip.Content>
<span className="block max-w-[min(32rem,calc(100vw-2rem))] whitespace-pre-wrap break-words">
{text}
</span>
</Tooltip.Content>
</Tooltip>
);
}
export function DataTablePrimaryText({
children,
text,
className = '',
showTooltip = false,
}) {
return (
<DataTableText
text={text}
showTooltip={showTooltip}
className={['font-medium', className].filter(Boolean).join(' ')}
>
{children}
</DataTableText>
);
}
export function DataTablePrimaryAction({
children,
text,
className = '',
onPress,
onClick,
isDisabled = false,
disabled = false,
ariaLabel,
}) {
const fullText = String(text ?? children ?? '').trim() || '-';
const handlePress = onPress || onClick;
return (
<Button
aria-label={ariaLabel || fullText}
className={className}
isDisabled={isDisabled || disabled}
size="sm"
variant="ghost"
onPress={handlePress}
>
<span className="min-w-0 truncate">
{children ?? fullText}
</span>
</Button>
);
}
export function DataTableSecondaryText({
children,
text,
className = '',
showTooltip = false,
}) {
return (
<DataTableText
text={text}
showTooltip={showTooltip}
className={['text-sm text-muted', className]
.filter(Boolean)
.join(' ')}
>
{children}
</DataTableText>
);
}
export function DataTableTextPair({
primary,
secondary,
className = '',
primaryClassName = '',
secondaryClassName = '',
}) {
const primaryText = String(primary ?? '').trim() || '-';
const hasSecondary =
secondary !== undefined && secondary !== null && secondary !== '';
const secondaryText = hasSecondary ? String(secondary).trim() : '';
const tooltipText = [primaryText, secondaryText].filter(Boolean).join('\n');
return (
<DataTableTextTooltipTrigger
className={['flex min-w-0 flex-col', className]
.filter(Boolean)
.join(' ')}
text={tooltipText}
truncate={false}
>
<span
className={['min-w-0 truncate font-medium', primaryClassName]
.filter(Boolean)
.join(' ')}
>
{primaryText}
</span>
{hasSecondary ? (
<span
className={[
'min-w-0 truncate text-sm text-muted',
secondaryClassName,
]
.filter(Boolean)
.join(' ')}
>
{secondaryText}
</span>
) : null}
</DataTableTextTooltipTrigger>
);
}
export function DataTablePersonIdentity({ name, account, className = '' }) {
const primaryText = String(name || '').trim() || '-';
const secondaryText = String(account || '').trim();
return (
<DataTableTextPair
primary={primaryText}
secondary={secondaryText}
className={className}
/>
);
}
export function DataTableChip({ children, text, className = '' }) {
const fullText = String(text ?? children ?? '').trim();
const trigger = <Chip className={className} size="sm">{children ?? (fullText || '-')}</Chip>;
if (!fullText || fullText === '-' || fullText.length <= 18) {
return trigger;
}
return (
<Tooltip delay={0}>
{trigger}
<Tooltip.Content>{fullText}</Tooltip.Content>
</Tooltip>
);
}
export function DataTablePagination({
page,
pageSize,
pageSizeOptions = [],
total,
onPageChange,
onPageSizeChange,
isDisabled = false,
itemLabel = '条',
}) {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const pageWindowStart = Math.max(0, Math.min(page - 2, totalPages - 5));
const pageWindowEnd = total
? Math.min(totalPages, pageWindowStart + 5)
: pageWindowStart;
const normalizedPageSize = Number(pageSize);
const normalizedPageSizeOptions = [
...new Set([
...pageSizeOptions.map((value) => Number(value)),
...(Number.isFinite(normalizedPageSize) && normalizedPageSize > 0
? [normalizedPageSize]
: []),
]),
]
.filter((value) => Number.isFinite(value) && value > 0)
.sort((left, right) => left - right);
const pageSizeOptionsForPicker = normalizedPageSizeOptions.map((value) => ({
id: String(value),
label: `${value} 条/页`,
}));
const hasPageSizePicker =
pageSizeOptionsForPicker.length > 0 && typeof onPageSizeChange === 'function';
return (
<div className="flex min-w-0 flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 shrink-0 items-center gap-3">
<span className="min-w-0 text-sm text-muted">
共 {total} {itemLabel},第 {total ? page + 1 : 0} / {total ? totalPages : 0} 页
</span>
{hasPageSizePicker ? (
<XinyuanAutocomplete
aria-label="每页条数"
className="w-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.Item>
<Pagination.Previous
isDisabled={isDisabled || page <= 0}
onPress={() => onPageChange(page - 1)}
>
<Pagination.PreviousIcon />
<span>上一页</span>
</Pagination.Previous>
</Pagination.Item>
{Array.from(
{ length: pageWindowEnd - pageWindowStart },
(_, index) => {
const pageIndex = pageWindowStart + index;
return (
<Pagination.Item key={pageIndex}>
<Pagination.Link
isActive={pageIndex === page}
isDisabled={isDisabled}
onPress={() => onPageChange(pageIndex)}
>
{pageIndex + 1}
</Pagination.Link>
</Pagination.Item>
);
},
)}
<Pagination.Item>
<Pagination.Next
isDisabled={isDisabled || page + 1 >= totalPages || total === 0}
onPress={() => onPageChange(page + 1)}
>
<span>下一页</span>
<Pagination.NextIcon />
</Pagination.Next>
</Pagination.Item>
</Pagination.Content>
</Pagination>
</div>
);
}
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import { useMemo } from 'react';
function normalizeOption(option) {
if (option && typeof option === 'object') {
return {
label: String(option.label ?? option.value ?? ''),
value: String(option.value ?? ''),
disabled: Boolean(option.disabled),
};
}
return {
label: String(option ?? ''),
value: String(option ?? ''),
disabled: false,
};
}
export default function FilterSelect({
'aria-label': ariaLabel,
className,
disabled = false,
emptyText = '暂无可选项',
onChange,
options = [],
placeholder = '请选择',
value = '',
variant,
}) {
const normalizedOptions = useMemo(
() =>
options.map(normalizeOption).map((option) => ({
...option,
id: option.value,
isDisabled: option.disabled,
})).filter((option) => option.value !== ''),
[options],
);
const selectedValue = String(value ?? '');
const selectedOption = normalizedOptions.find(
(option) => option.value === selectedValue,
);
const selectedKey = selectedValue === '' ? null : selectedValue;
return (
<XinyuanAutocomplete
aria-label={ariaLabel || placeholder}
className={className}
isDisabled={disabled}
isClearable
emptyText={emptyText}
options={normalizedOptions}
placeholder={placeholder}
selectedKey={selectedKey}
variant={variant}
onClear={() => onChange?.('')}
onSelectionChange={(key) => {
const nextValue = String(key ?? '');
onChange?.(nextValue);
}}
valueText={selectedOption?.label || placeholder}
/>
);
}
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete'; import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import { queryRecords } from '@/services/DataService'; import { queryRecords } from '@/services/DataService';
import { Button, Label } from '@heroui/react'; import { Label } from '@heroui/react';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
const DEFAULT_ID_LOOKUP_CHUNK_SIZE = 50; const DEFAULT_ID_LOOKUP_CHUNK_SIZE = 50;
...@@ -574,27 +574,22 @@ export default function ReferenceSearchSelect({ ...@@ -574,27 +574,22 @@ export default function ReferenceSearchSelect({
errorText={error} errorText={error}
inputValue={query} inputValue={query}
isDisabled={disabled} isDisabled={disabled}
isClearable={allowClear}
isLoading={loading} isLoading={loading}
isOpen={active} isOpen={active}
options={menuOptions} options={menuOptions}
placement={optionsAnchor || 'bottom start'} placement={optionsAnchor || 'bottom start'}
placeholder={placeholder} placeholder={placeholder}
popoverHeader={allowClear ? (
<div className="flex justify-end px-3 pb-1">
<Button size="sm" variant="tertiary" onPress={clearSelection}>
清空
</Button>
</div>
) : null}
searchPlaceholder={placeholder} searchPlaceholder={placeholder}
selectionMode={multiple ? 'multiple' : 'single'} selectionMode={multiple ? 'multiple' : 'single'}
shouldFilter={false} shouldFilter={false}
valueText={displayText} valueText={displayText}
variant={variant} variant={variant}
onInputChange={setQuery} onInputChange={setQuery}
onClear={clearSelection}
onOpenChange={handleOpenChange} onOpenChange={handleOpenChange}
renderOption={(option) => ( renderOption={(option) => (
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1 overflow-hidden">
<Label className="block truncate" title={option.label}> <Label className="block truncate" title={option.label}>
{option.label} {option.label}
</Label> </Label>
......
import { Button, Tooltip } from '@heroui/react';
import { Close, Delete, Edit, Eye, More } from '@/components/AppIcons';
const ACTION_ICON_CLASS = 'grid size-4 shrink-0 place-items-center !leading-none [&>svg]:!m-0 [&>svg]:block [&>svg]:!size-4';
const ICON_BY_ACTION = {
view: Eye,
edit: Edit,
delete: Delete,
close: Close,
more: More,
};
export function TableActionButton({ icon: Icon, label, tone, isDisabled, onPress, className = '' }) {
const ActionIcon = Icon || ICON_BY_ACTION[tone] || Eye;
const variant = tone === 'delete' ? 'danger-soft' : 'tertiary';
return (
<Tooltip delay={0}>
<Button
isIconOnly
aria-label={label}
className={`grid !size-8 shrink-0 place-items-center p-0 ${className}`.trim()}
isDisabled={isDisabled}
size="sm"
variant={variant}
onPress={onPress}
>
<ActionIcon aria-hidden="true" className={ACTION_ICON_CLASS} size={16} />
</Button>
<Tooltip.Content>{label}</Tooltip.Content>
</Tooltip>
);
}
export function TableActionGroup({ children, className = '' }) {
return <div className={`flex flex-nowrap items-center justify-start gap-2 whitespace-nowrap ${className}`.trim()}>{children}</div>;
}
export { ACTION_ICON_CLASS };
...@@ -39,11 +39,13 @@ export default function XinyuanAutocomplete({ ...@@ -39,11 +39,13 @@ export default function XinyuanAutocomplete({
fullWidth = true, fullWidth = true,
inputValue, inputValue,
isDisabled = false, isDisabled = false,
isClearable = false,
isLoading = false, isLoading = false,
isOpen, isOpen,
isRequired = false, isRequired = false,
name, name,
onInputChange, onInputChange,
onClear,
onOpenChange, onOpenChange,
onSelectionChange, onSelectionChange,
options = [], options = [],
...@@ -52,6 +54,7 @@ export default function XinyuanAutocomplete({ ...@@ -52,6 +54,7 @@ export default function XinyuanAutocomplete({
popoverHeader, popoverHeader,
renderOption, renderOption,
searchPlaceholder = '输入关键词搜索', searchPlaceholder = '输入关键词搜索',
showSearch = true,
selectedKey, selectedKey,
selectedKeys, selectedKeys,
selectionMode = 'single', selectionMode = 'single',
...@@ -101,6 +104,7 @@ export default function XinyuanAutocomplete({ ...@@ -101,6 +104,7 @@ export default function XinyuanAutocomplete({
isOpen={isOpen} isOpen={isOpen}
isRequired={isRequired} isRequired={isRequired}
name={name} name={name}
onClear={onClear}
selectionMode={isMultiple ? 'single' : selectionMode} selectionMode={isMultiple ? 'single' : selectionMode}
variant={variant} variant={variant}
onOpenChange={onOpenChange} onOpenChange={onOpenChange}
...@@ -115,6 +119,11 @@ export default function XinyuanAutocomplete({ ...@@ -115,6 +119,11 @@ export default function XinyuanAutocomplete({
{resolvedValueText} {resolvedValueText}
</span> </span>
</Autocomplete.Value> </Autocomplete.Value>
{isClearable ? (
<Autocomplete.ClearButton
aria-label={`清除${ariaLabel || placeholder}`}
/>
) : null}
<Autocomplete.Indicator> <Autocomplete.Indicator>
<ChevronDown size={16} style={FIELD_SUFFIX_ICON_STYLE} /> <ChevronDown size={16} style={FIELD_SUFFIX_ICON_STYLE} />
</Autocomplete.Indicator> </Autocomplete.Indicator>
...@@ -126,21 +135,23 @@ export default function XinyuanAutocomplete({ ...@@ -126,21 +135,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}
dependencies={[ dependencies={[
...@@ -162,6 +173,7 @@ export default function XinyuanAutocomplete({ ...@@ -162,6 +173,7 @@ export default function XinyuanAutocomplete({
return ( return (
<ListBox.Item <ListBox.Item
className="min-w-0"
id={option.id} id={option.id}
isDisabled={option.isDisabled} isDisabled={option.isDisabled}
isSelected={isSelected} isSelected={isSelected}
...@@ -170,14 +182,14 @@ export default function XinyuanAutocomplete({ ...@@ -170,14 +182,14 @@ export default function XinyuanAutocomplete({
{renderOption ? ( {renderOption ? (
renderOption(option) renderOption(option)
) : ( ) : (
<Label className="truncate" title={option.label}> <Label className="min-w-0 flex-1 truncate" title={option.label}>
{option.label} {option.label}
</Label> </Label>
)} )}
{selectionMode === 'multiple' ? ( {selectionMode === 'multiple' ? (
<span <span
aria-hidden="true" aria-hidden="true"
className="ml-auto inline-flex w-5 justify-center text-foreground" className="ml-auto inline-flex w-5 shrink-0 justify-center text-foreground"
> >
{isSelected ? <Check size={14} /> : null} {isSelected ? <Check size={14} /> : null}
</span> </span>
......
import { Close, Eye, More, Reload, Search } from '@/components/AppIcons'; import { Close, Eye, More, Reload, Search } from '@/components/AppIcons';
import { TableActionButton, TableActionGroup } from '@/components/TableActionButton';
import FilterSelect from '@/components/FilterSelect';
import FormDatePicker from '@/components/FormDatePicker';
import { DataTablePagination } from '@/components/DataTable';
import RecordDetailDrawer from '@/pages/RecordDetailDrawer'; import RecordDetailDrawer from '@/pages/RecordDetailDrawer';
import { formatFieldValue, getFieldValue } from '@/utils/subjectView'; import { formatFieldValue, getFieldValue } from '@/utils/subjectView';
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';
import { Alert, Button, Dropdown, Label, Pagination, SearchField, Spinner, Surface, Tabs, Tooltip, buttonVariants } from '@heroui/react'; import { Alert, Button, Disclosure, Dropdown, Input, Label, SearchField, Spinner, Surface, Tabs, Tooltip, buttonVariants } from '@heroui/react';
import { useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useLayoutEffect, useMemo, useRef, useState } from 'react';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
const EMPTY_ARRAY = []; const EMPTY_ARRAY = [];
const ACTION_ICON_CLASS = '!grid size-4 shrink-0 place-items-center !leading-none [&>svg]:!m-0 [&>svg]:block [&>svg]:!size-4'; const ACTION_ICON_CLASS = '!grid size-4 shrink-0 place-items-center !leading-none [&>svg]:!m-0 [&>svg]:block [&>svg]:!size-4';
const ACTION_BUTTON_LAYOUT_CLASS = 'grid !size-8 shrink-0 place-items-center p-0';
function getRecordId(record) { function getRecordId(record) {
return String(record?.id || record?._id || record?.name || ''); return String(record?.id || record?._id || record?.name || '');
...@@ -27,6 +29,16 @@ function getViewDisplayLabel(view) { ...@@ -27,6 +29,16 @@ function getViewDisplayLabel(view) {
return view?.title || view?.name; return view?.title || view?.name;
} }
function getFilterPlaceholder(field, fieldType) {
if (field?.placeholder) return field.placeholder;
if (fieldType === 'select' || fieldType === 'reference') return '请选择';
if (fieldType === 'date') return '请选择日期';
if (fieldType === 'datetime') return '请选择时间';
if (fieldType === 'month') return '请选择月份';
if (fieldType === 'number') return '请输入数值';
return '请输入内容';
}
function estimatePrimaryColumnWidth(records, column) { function estimatePrimaryColumnWidth(records, column) {
const values = records.map((record) => { const values = records.map((record) => {
const value = formatFieldValue(getFieldValue(record?.raw || record, column.field), column.fieldDef) || '-'; const value = formatFieldValue(getFieldValue(record?.raw || record, column.field), column.fieldDef) || '-';
...@@ -83,31 +95,24 @@ function RowActions({ actions, context }) { ...@@ -83,31 +95,24 @@ function RowActions({ actions, context }) {
const overflowActions = orderedActions.length >= 4 ? orderedActions.slice(2) : []; const overflowActions = orderedActions.length >= 4 ? orderedActions.slice(2) : [];
return ( return (
<div className="flex items-center justify-start gap-2"> <TableActionGroup>
{visibleActions.map((action) => { {visibleActions.map((action) => {
const Icon = action.icon;
return ( return (
<Tooltip delay={0} key={action.key}> <TableActionButton
<Button key={action.key}
isIconOnly icon={action.icon}
aria-label={action.label} label={action.label}
isDisabled={isActionDisabled(action, context)} tone={isDestructiveAction(action) ? 'delete' : 'view'}
size="sm" isDisabled={isActionDisabled(action, context)}
variant={isDestructiveAction(action) ? 'danger-soft' : 'tertiary'} onPress={() => action.onClick?.(context)}
onPress={() => action.onClick?.(context)} />
className={ACTION_BUTTON_LAYOUT_CLASS}
>
<Icon className={ACTION_ICON_CLASS} size={16} aria-hidden="true" />
</Button>
<Tooltip.Content>{action.label}</Tooltip.Content>
</Tooltip>
); );
})} })}
{overflowActions.length > 0 ? ( {overflowActions.length > 0 ? (
<Dropdown> <Dropdown>
<Dropdown.Trigger <Dropdown.Trigger
aria-label="更多操作" aria-label="更多操作"
className={`${buttonVariants({ isIconOnly: true, size: 'sm', variant: 'tertiary' })} ${ACTION_BUTTON_LAYOUT_CLASS}`} className={`${buttonVariants({ isIconOnly: true, size: 'sm', variant: 'tertiary' })} grid !size-8 shrink-0 place-items-center p-0`}
title="更多操作" title="更多操作"
> >
<More className={ACTION_ICON_CLASS} size={16} aria-hidden="true" /> <More className={ACTION_ICON_CLASS} size={16} aria-hidden="true" />
...@@ -135,43 +140,28 @@ function RowActions({ actions, context }) { ...@@ -135,43 +140,28 @@ function RowActions({ actions, context }) {
</Dropdown.Popover> </Dropdown.Popover>
</Dropdown> </Dropdown>
) : null} ) : null}
</div> </TableActionGroup>
); );
} }
function WorkbenchPagination({ pagination }) { function WorkbenchPagination({ pagination }) {
if (!pagination) return null; if (!pagination) {
return null;
}
const pageSize = Number(pagination.pageSize || 10); const pageSize = Number(pagination.pageSize || 10);
const totalRecords = Math.max(0, Number(pagination.totalRecords || 0)); const totalRecords = Math.max(0, Number(pagination.totalRecords || 0));
const totalPages = Math.max(1, Number(pagination.totalPages || Math.ceil(totalRecords / pageSize) || 1));
const currentPage = Math.min(Math.max(0, Number(pagination.page || 0)), totalPages - 1);
const pageWindowStart = Math.max(0, Math.min(currentPage - 2, totalPages - 5));
const pageWindowEnd = totalRecords ? Math.min(totalPages, pageWindowStart + 5) : pageWindowStart;
const isDisabled = Boolean(pagination.disabled);
return ( return (
<div className="min-w-0"> <DataTablePagination
<Pagination aria-label="列表分页" size="sm"> isDisabled={Boolean(pagination.disabled)}
<Pagination.Summary> page={Math.max(0, Number(pagination.page || 0))}
共 {totalRecords} 条,第 {totalRecords ? currentPage + 1 : 0} / {totalRecords ? totalPages : 0} 页 pageSize={pageSize}
</Pagination.Summary> pageSizeOptions={pagination.pageSizeOptions || [20, 50, 100]}
<Pagination.Content> total={totalRecords}
<Pagination.Item> onPageChange={pagination.onPageChange}
<Pagination.Previous isDisabled={isDisabled || currentPage <= 0} onPress={() => pagination.onPageChange?.(currentPage - 1)}> onPageSizeChange={pagination.onPageSizeChange}
<Pagination.PreviousIcon /><span>上一页</span> />
</Pagination.Previous>
</Pagination.Item>
{Array.from({ length: pageWindowEnd - pageWindowStart }, (_, index) => {
const page = pageWindowStart + index;
return <Pagination.Item key={page}><Pagination.Link isActive={page === currentPage} isDisabled={isDisabled} onPress={() => pagination.onPageChange?.(page)}>{page + 1}</Pagination.Link></Pagination.Item>;
})}
<Pagination.Item>
<Pagination.Next isDisabled={isDisabled || currentPage >= totalPages - 1 || totalRecords === 0} onPress={() => pagination.onPageChange?.(currentPage + 1)}>
<span>下一页</span><Pagination.NextIcon />
</Pagination.Next>
</Pagination.Item>
</Pagination.Content>
</Pagination>
</div>
); );
} }
...@@ -185,6 +175,7 @@ export default function RecordWorkbench({ ...@@ -185,6 +175,7 @@ export default function RecordWorkbench({
onFiltersChange, onFiltersChange,
onClearFilters, onClearFilters,
title, title,
listTitle,
columns = EMPTY_ARRAY, columns = EMPTY_ARRAY,
records = EMPTY_ARRAY, records = EMPTY_ARRAY,
selectedIds = EMPTY_ARRAY, selectedIds = EMPTY_ARRAY,
...@@ -314,15 +305,57 @@ export default function RecordWorkbench({ ...@@ -314,15 +305,57 @@ export default function RecordWorkbench({
else onSelectionChange?.(Array.from(selection).map(String)); else onSelectionChange?.(Array.from(selection).map(String));
}; };
const listSearchField = null; const toolbarSearchField = filterSchema.find((field) => field.icon === 'search' || field.key === 'keyword');
const filterCardSchema = filterSchema; const structuredFilterFields = filterSchema.filter((field) => field !== toolbarSearchField);
const primaryFilterFields = structuredFilterFields.slice(0, 3);
const expandedFilterFields = structuredFilterFields.slice(3);
const [filtersExpanded, setFiltersExpanded] = useState(false);
const renderFilterField = (field) => {
const value = filters[field.key] || '';
const fieldType = String(field.type || 'text').toLowerCase();
return (
<div className="w-full min-w-0" key={field.key}>
<Label className="mb-1.5 block">{field.label}</Label>
{fieldType === 'select' ? (
<FilterSelect
aria-label={field.label}
className="w-full"
options={field.options || []}
placeholder={getFilterPlaceholder(field, fieldType)}
value={value}
variant="secondary"
onChange={(nextValue) => onFiltersChange?.(field.key, nextValue)}
/>
) : fieldType === 'date' || fieldType === 'datetime' || fieldType === 'month' ? (
<FormDatePicker
aria-label={field.label}
className="w-full"
mode={fieldType === 'month' ? 'month' : fieldType === 'datetime' ? 'datetime' : 'date'}
value={value}
variant="secondary"
onChange={(event) => onFiltersChange?.(field.key, event.target.value)}
/>
) : (
<Input
aria-label={field.label}
className="w-full"
placeholder={getFilterPlaceholder(field, fieldType)}
type={fieldType === 'number' ? 'number' : 'text'}
value={value}
variant="secondary"
onChange={(event) => onFiltersChange?.(field.key, event.target.value)}
/>
)}
</div>
);
};
const hasViewSwitch = views.length > 1; const hasViewSwitch = views.length > 1;
return ( return (
<div className="flex min-w-0 flex-col gap-6"> <div className="flex min-w-0 flex-col gap-6">
{hasViewSwitch ? ( {hasViewSwitch ? (
<div className="min-w-0 max-w-full"> <div className="min-w-0 max-w-full">
<Tabs className="w-fit max-w-full min-w-0" selectedKey={activeViewName} onSelectionChange={(key) => onViewChange?.(String(key))}> <Tabs className="w-fit max-w-full min-w-0" selectedKey={activeViewName} variant="primary" onSelectionChange={(key) => onViewChange?.(String(key))}>
<Tabs.ListContainer className="w-fit max-w-full min-w-0"> <Tabs.ListContainer className="w-fit max-w-full min-w-0">
<Tabs.List aria-label="数据视图" className="w-max min-w-max"> <Tabs.List aria-label="数据视图" className="w-max min-w-max">
{views.map((view) => ( {views.map((view) => (
...@@ -337,43 +370,75 @@ export default function RecordWorkbench({ ...@@ -337,43 +370,75 @@ export default function RecordWorkbench({
</div> </div>
) : null} ) : null}
{filterCardSchema.length > 0 ? ( {structuredFilterFields.length > 0 ? (
<Surface className="p-6"> <Surface className="rc-filter-surface p-6" variant="default">
<div className="flex flex-col gap-3 md:flex-row md:items-end"> <Disclosure isExpanded={filtersExpanded} onExpandedChange={setFiltersExpanded}>
<div className="grid min-w-0 flex-1 gap-3 md:grid-cols-3"> <div className="grid min-w-0 gap-3 xl:grid-cols-[minmax(0,1fr)_auto] xl:items-end">
{filterCardSchema.map((field) => field.type === 'select' ? ( <div className="grid min-w-0 gap-3 sm:grid-cols-2 xl:grid-cols-3">
<XinyuanAutocomplete key={field.key} aria-label={field.label} options={field.options || []} selectedKey={filters[field.key] || null} placeholder={field.placeholder} valueText={(field.options || []).find((item) => item.value === filters[field.key])?.label || field.placeholder} onSelectionChange={(key) => onFiltersChange?.(field.key, key ? String(key) : '')} /> {primaryFilterFields.map(renderFilterField)}
) : (
<SearchField aria-label={field.label} fullWidth key={field.key} value={filters[field.key] || ''} onChange={(value) => onFiltersChange?.(field.key, value)}>
<SearchField.Group><SearchField.SearchIcon><Search size={16} /></SearchField.SearchIcon><SearchField.Input type={field.type === 'date' ? 'date' : 'text'} placeholder={field.placeholder} /><SearchField.ClearButton aria-label="清空搜索" /></SearchField.Group>
</SearchField>
))}
</div> </div>
{onClearFilters ? ( <div className="flex min-w-0 items-end justify-end gap-2">
<div className="flex shrink-0 items-center"> {onClearFilters ? (
<Tooltip delay={0}> <Tooltip delay={0}>
<Button isIconOnly aria-label="重置筛选" size="sm" variant="ghost" onPress={onClearFilters}><Reload size={16} /></Button> <Button isIconOnly aria-label="重置筛选" size="sm" variant="ghost" onPress={onClearFilters}><Reload size={16} /></Button>
<Tooltip.Content>重置筛选</Tooltip.Content> <Tooltip.Content>重置筛选</Tooltip.Content>
</Tooltip> </Tooltip>
</div> ) : null}
{expandedFilterFields.length > 0 ? (
<Disclosure.Trigger className={buttonVariants({ size: 'sm', variant: 'ghost' })}>
<span>{filtersExpanded ? '收起' : '展开'}</span>
</Disclosure.Trigger>
) : null}
</div>
{expandedFilterFields.length > 0 && filtersExpanded ? (
<Disclosure.Content className="rc-filter-disclosure-content min-w-0 xl:col-start-1 xl:col-end-2">
<div className="grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{expandedFilterFields.map(renderFilterField)}
</div>
</Disclosure.Content>
) : null} ) : null}
</div> </div>
</Disclosure>
</Surface> </Surface>
) : null} ) : null}
<section className="flex min-w-0 flex-col gap-3"> <section className="rc-list-stack">
{listSearchField || visibleToolbarActions.length > 0 ? ( {listTitle ? (
<header className="flex min-w-0 flex-col gap-3 md:flex-row md:items-center md:justify-between"> <header className="rc-page-toolbar">
{listSearchField ? ( <h2 className="rc-section-title">{listTitle}</h2>
<SearchField aria-label={listSearchField.label} className="w-full md:w-96" value={filters[listSearchField.key] || ''} onChange={(value) => onFiltersChange?.(listSearchField.key, value)}> {visibleToolbarActions.length > 0 ? (
<SearchField.Group><SearchField.SearchIcon><Search size={16} /></SearchField.SearchIcon><SearchField.Input placeholder={listSearchField.placeholder} /><SearchField.ClearButton aria-label="清空搜索" /></SearchField.Group> <div className="flex flex-wrap items-center justify-end gap-2">
{visibleToolbarActions.map((action) => (
<Button
isDisabled={isActionDisabled(action, baseContext)}
key={action.key}
size="sm"
variant={action.tone === 'primary' ? 'primary' : 'secondary'}
onPress={() => action.onClick?.(baseContext)}
>
{action.label}
</Button>
))}
</div>
) : null}
</header>
) : null}
{toolbarSearchField || (visibleToolbarActions.length > 0 && !listTitle) ? (
<header className="rc-page-toolbar rc-page-toolbar-responsive">
{toolbarSearchField ? (
<SearchField aria-label={toolbarSearchField.label || '关键词搜索'} className="w-full md:w-96" value={filters[toolbarSearchField.key] || ''} onChange={(value) => onFiltersChange?.(toolbarSearchField.key, value)}>
<SearchField.Group><SearchField.SearchIcon><Search size={16} /></SearchField.SearchIcon><SearchField.Input placeholder={toolbarSearchField.placeholder} /><SearchField.ClearButton aria-label="清空关键词" /></SearchField.Group>
</SearchField> </SearchField>
) : null} ) : null}
{visibleToolbarActions.length > 0 ? <div className="flex flex-wrap items-center justify-end gap-2">{visibleToolbarActions.map((action) => <Button isDisabled={isActionDisabled(action, baseContext)} key={action.key} size="sm" variant={action.tone === 'primary' ? 'primary' : 'secondary'} onPress={() => action.onClick?.(baseContext)}>{action.label}</Button>)}</div> : null} {!listTitle ? (
<div className="flex flex-wrap items-center justify-end gap-2 md:ml-auto">
{visibleToolbarActions.map((action) => <Button isDisabled={isActionDisabled(action, baseContext)} key={action.key} size="sm" variant={action.tone === 'primary' ? 'primary' : 'secondary'} onPress={() => action.onClick?.(baseContext)}>{action.label}</Button>)}
</div>
) : null}
</header> </header>
) : null} ) : null}
{states.error ? <Alert status="danger"><Alert.Content><Alert.Description>{states.error}</Alert.Description></Alert.Content></Alert> : null} {states.error ? <Alert status="danger"><Alert.Content><Alert.Description>{states.error}</Alert.Description></Alert.Content></Alert> : null}
<div className="relative min-w-0" ref={gridWrapRef}> <div className="rc-list-data-stack rc-data-table-sticky relative min-w-0" ref={gridWrapRef}>
{accessDenied ? ( {accessDenied ? (
<DataGridEmpty title="当前账号没有可访问的数据视图" /> <DataGridEmpty title="当前账号没有可访问的数据视图" />
) : ( ) : (
...@@ -387,7 +452,7 @@ export default function RecordWorkbench({ ...@@ -387,7 +452,7 @@ export default function RecordWorkbench({
data={records} data={records}
getRowId={getRecordId} getRowId={getRecordId}
renderEmptyState={() => <DataGridEmpty loading={states.loading} title={states.emptyTitle || '暂无数据'} />} renderEmptyState={() => <DataGridEmpty loading={states.loading} title={states.emptyTitle || '暂无数据'} />}
scrollContainerClassName="overflow-x-auto" scrollContainerClassName="data-table-scroll overflow-x-auto"
selectedKeys={new Set(selectedIds)} selectedKeys={new Set(selectedIds)}
selectionMode={onSelectionChange ? 'multiple' : 'none'} selectionMode={onSelectionChange ? 'multiple' : 'none'}
variant="primary" variant="primary"
......
...@@ -3,7 +3,6 @@ export const APPLICATION_NAME = 'integrity_practice_management'; ...@@ -3,7 +3,6 @@ export const APPLICATION_NAME = 'integrity_practice_management';
export const APPLICATION_ROUTE_NAME = APPLICATION_NAME; export const APPLICATION_ROUTE_NAME = APPLICATION_NAME;
export const AUTHORIZATION_APPLICATION_NAME = APPLICATION_NAME; export const AUTHORIZATION_APPLICATION_NAME = APPLICATION_NAME;
export const APPLICATION_BASE_PATH = `/${APPLICATION_NAME}/`; export const APPLICATION_BASE_PATH = `/${APPLICATION_NAME}/`;
export const THEME_STORAGE_KEY = 'xinyuan_color_theme';
export const ROUTE_TITLES = Object.freeze({ export const ROUTE_TITLES = Object.freeze({
'/self_check_the_results_of_the_working_draft': '自查底稿填写表', '/self_check_the_results_of_the_working_draft': '自查底稿填写表',
......
...@@ -435,6 +435,41 @@ ...@@ -435,6 +435,41 @@
gap: var(--rc-page-module-gap); gap: var(--rc-page-module-gap);
} }
.rc-section-title {
margin: 0;
color: var(--rc-text-primary);
font-size: 1.125rem;
font-weight: var(--rc-font-bold);
line-height: 1.5rem;
letter-spacing: 0;
}
.rc-module-title {
margin: 0;
color: var(--rc-text-primary);
font-size: 1rem;
font-weight: var(--rc-font-semibold);
line-height: 1.5rem;
letter-spacing: 0;
}
.rc-modal-header-actions {
align-self: flex-start;
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.5rem;
margin-inline-start: auto;
max-width: 100%;
}
@media (max-width: 40rem) {
.rc-modal-header-actions {
flex-basis: 100%;
}
}
.rc-page-grid { .rc-page-grid {
min-width: 0; min-width: 0;
gap: var(--rc-page-module-gap); gap: var(--rc-page-module-gap);
...@@ -453,7 +488,7 @@ ...@@ -453,7 +488,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 +500,10 @@ ...@@ -465,6 +500,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);
...@@ -509,175 +548,37 @@ ...@@ -509,175 +548,37 @@
box-shadow: var(--rc-shadow-card); box-shadow: var(--rc-shadow-card);
} }
.rc-dashboard-card { .rc-filter-surface {
border-radius: 1.5rem;
}
/* Detail overlays use three explicit surface levels: dialog, detail well, and grid header. */
.rc-dialog-panel--4xl.modal__dialog {
width: calc(100vw - 2rem);
max-width: var(--rc-dialog-width-4xl);
}
.rc-detail-dialog.modal__dialog {
padding: 1.75rem;
border-radius: var(--rc-radius-modal);
background-color: var(--overlay);
box-shadow: var(--rc-shadow-overlay);
}
.rc-detail-dialog > .modal__close-trigger {
inset-inline-end: 1.75rem;
top: 1.75rem;
}
.rc-detail-dialog > .modal__header + .modal__body {
margin-top: 0.75rem;
}
.rc-detail-dialog > .modal__body + .modal__footer {
margin-top: 1.5rem;
}
.rc-app-switcher-dialog {
width: min(46rem, calc(100vw - 2rem));
max-width: min(46rem, calc(100vw - 2rem));
max-height: min(42rem, calc(100dvh - 2rem));
}
.rc-app-identity-well,
.rc-app-identity-avatar {
border-radius: var(--rc-radius-control);
}
.rc-app-identity-avatar {
background: transparent;
}
.rc-detail-surface.surface--secondary {
border-radius: var(--rc-radius-detail-surface);
}
.rc-detail-data-grid.table-root--primary {
overflow: clip;
border-radius: var(--rc-radius-data-grid);
background-color: var(--surface-tertiary);
}
.rc-detail-data-grid :is(.table__header, .table__column),
.rc-detail-data-grid[data-slot='data-grid'] .table__column[data-pinned] {
background-color: var(--surface-tertiary);
}
.rc-detail-data-grid :is(.table__cell, .data-grid__selection-cell, .data-grid__drag-handle-cell) {
background-color: var(--surface);
}
/* Remote reference fields keep long selections compact and scroll inside the popup. */
.rc-reference-select-trigger.button {
width: 100%;
min-width: 0;
height: 2.5rem;
min-height: 2.5rem;
justify-content: flex-start;
gap: var(--rc-space-2);
padding-inline: var(--rc-space-3);
border-radius: var(--rc-radius-container);
color: var(--rc-text-primary);
font-weight: var(--rc-font-medium);
}
.rc-reference-select-trigger__value {
min-width: 0;
flex: 1;
overflow: hidden;
text-align: start;
text-overflow: ellipsis;
white-space: nowrap;
}
.rc-reference-select-trigger__indicator,
.rc-reference-select-trigger__indicator > .anticon {
display: grid;
width: 1rem;
height: 1rem;
flex: 0 0 1rem;
place-items: center;
color: var(--rc-text-muted);
line-height: 1;
}
.rc-reference-select-trigger__indicator > .anticon > svg {
display: block;
width: 1rem;
height: 1rem;
margin: 0;
}
.rc-reference-select-trigger[aria-expanded='true']
.rc-reference-select-trigger__indicator {
color: var(--rc-text-secondary);
}
.rc-reference-select-popover.popover {
width: min(24rem, calc(100vw - 1.5rem));
overflow: clip;
border: 1px solid var(--rc-border-default);
border-radius: var(--rc-radius-overlay); border-radius: var(--rc-radius-overlay);
background-color: var(--rc-bg-surface);
box-shadow: var(--rc-shadow-raised);
}
.rc-reference-select-options.list-box {
max-height: 18rem;
overflow-y: auto;
overscroll-behavior: contain;
} }
.rc-reference-select-options .rc-reference-select-option.list-box-item { /* Filter fields paint their HeroUI focus/invalid ring outside the disclosure
min-height: 3rem; panel. The workbench unmounts this content while the filter is collapsed. */
border-radius: var(--rc-radius-container); .rc-filter-disclosure-content {
} overflow: visible;
.rc-reference-select-option__content {
width: 100%;
max-width: 100%;
overflow: hidden;
} }
.rc-reference-select-option__label { .rc-dashboard-card {
display: block; border-radius: 1.5rem;
width: 100%;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.rc-reference-select-options .rc-workspace-module {
.rc-reference-select-option.list-box-item:is( border-radius: 1.5rem;
[aria-selected='true'], border: 1px solid var(--border);
[data-selected='true'] background: transparent;
) { box-shadow: none;
color: var(--rc-brand-primary);
background-color: var(--accent-soft);
} }
@media (hover: hover) { .rc-empty-state-message {
.rc-reference-select-options color: var(--rc-text-empty);
.rc-reference-select-option.list-box-item:is( font-size: var(--text-sm);
[aria-selected='true'], font-weight: var(--font-weight-normal);
[data-selected='true']
):hover {
color: var(--rc-brand-dark);
background-color: var(--accent-soft-hover);
}
} }
@media (min-width: 640px) { .rc-empty-field-text {
.rc-dialog-panel--4xl.modal__dialog { color: var(--rc-text-empty);
width: calc(100vw - 5rem); 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. */
...@@ -822,6 +723,116 @@ ...@@ -822,6 +723,116 @@
gap: var(--rc-list-stack-gap); gap: var(--rc-list-stack-gap);
} }
/* Keep long data sets inside the table while the column headings remain visible. */
.rc-data-table-sticky .data-table-scroll {
position: relative;
max-height: min(60dvh, 42rem);
overflow-x: auto;
overflow-y: auto;
overscroll-behavior: contain;
}
/* DataGrid adds a non-scrolling resize wrapper. Let sticky headers use the
outer scroll container as their containing block instead of this wrapper. */
.rc-data-table-sticky .table__resizable-container {
overflow: visible;
}
.rc-data-table-sticky [data-slot='table-header'] .table__column {
position: sticky;
top: 0;
inset-block-start: 0;
z-index: 4;
display: table-cell;
color: var(--muted);
background: var(--surface-secondary);
box-shadow: inset 0 -1px 0 var(--separator);
}
.rc-data-table-sticky [data-slot='table-header'] .table__column.bg-accent-soft {
background: var(--accent-soft);
color: var(--accent-soft-foreground);
}
/* The leading pinned column must sit above scrolling body cells. Keep its
surface opaque so text from adjacent columns cannot bleed through while
horizontal or vertical scrolling. */
.rc-data-table-sticky [data-pinned='start'] {
position: sticky;
z-index: 6;
background: var(--surface);
}
.rc-data-table-sticky [data-slot='table-header'] [data-pinned='start'] {
z-index: 8;
background: var(--surface-secondary);
}
/* Mirror HeroUI's row states on the opaque pinned surface so the leading
column still responds to hover and selection instead of looking detached. */
@media (hover: hover) {
.rc-data-table-sticky [data-slot='data-grid']
.table__row:hover
[data-pinned='start'].table__cell,
.rc-data-table-sticky [data-slot='data-grid']
.table__row[data-hovered='true']
[data-pinned='start'].table__cell {
/* Keep the pinned cell opaque; transparent hover fills let scrolled
sibling-cell text show through the fixed column. */
background-color: var(--color-surface-secondary);
}
@supports (color: color-mix(in oklab, white, black)) {
.rc-data-table-sticky [data-slot='data-grid']
.table__row:hover
[data-pinned='start'].table__cell,
.rc-data-table-sticky [data-slot='data-grid']
.table__row[data-hovered='true']
[data-pinned='start'].table__cell {
background-color: color-mix(
in oklab,
var(--color-surface) 40%,
var(--color-surface-secondary)
);
}
}
}
.rc-data-table-sticky [data-slot='data-grid']
.table__row[data-selected='true']
[data-pinned='start'].table__cell {
background-color: var(--color-surface-secondary);
}
@supports (color: color-mix(in oklab, white, black)) {
.rc-data-table-sticky [data-slot='data-grid']
.table__row[data-selected='true']
[data-pinned='start'].table__cell {
background-color: color-mix(
in oklab,
var(--color-surface) 10%,
var(--color-surface-secondary)
);
}
}
@media (max-width: 48rem) {
.rc-data-table-sticky .data-table-scroll {
max-height: min(54dvh, 32rem);
}
}
@media (min-width: 769px) {
.action-bar:has(.rc-data-grid-action-bar) {
inset-inline: 0;
}
body:has(.rc-app-shell[data-state='expanded'])
.action-bar:has(.rc-data-grid-action-bar) {
inset-inline-start: var(--rc-sidebar-width);
}
}
@media (min-width: 80rem) { @media (min-width: 80rem) {
.rc-chart-grid { .rc-chart-grid {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
...@@ -1029,6 +1040,23 @@ ...@@ -1029,6 +1040,23 @@
font-weight: var(--rc-font-semibold); font-weight: var(--rc-font-semibold);
} }
.rc-application-identity-well {
display: grid;
width: 2.25rem;
height: 2.25rem;
flex: none;
place-items: center;
overflow: hidden;
border-radius: var(--rc-radius-control);
}
.rc-application-identity-avatar {
width: 100%;
height: 100%;
border-radius: var(--rc-radius-control);
background: transparent;
}
.rc-app-shell .sidebar__content, .rc-app-shell .sidebar__content,
.rc-mobile-sidebar .sidebar__content { .rc-mobile-sidebar .sidebar__content {
min-height: 0; min-height: 0;
...@@ -1352,12 +1380,12 @@ ...@@ -1352,12 +1380,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 {
...@@ -1377,16 +1405,16 @@ ...@@ -1377,16 +1405,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 {
...@@ -1570,3 +1598,89 @@ ...@@ -1570,3 +1598,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,90 @@ ...@@ -51,42 +50,90 @@
/* 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.
*/
/* First pass: red -> pink -> orange -> yellow -> blue -> purple -> cyan -> neutral. */
--xinyuan-chart-1: #991b1b;
--xinyuan-chart-2: #db2777;
--xinyuan-chart-3: #ea580c;
--xinyuan-chart-4: #eab308;
--xinyuan-chart-5: #1d4ed8;
--xinyuan-chart-6: #7e22ce;
--xinyuan-chart-7: #0891b2;
--xinyuan-chart-8: #475569;
/* Second pass: stronger/lighter companions for charts with more series. */
--xinyuan-chart-9: #dc2626;
--xinyuan-chart-10: #ec4899;
--xinyuan-chart-11: #f97316;
--xinyuan-chart-12: #facc15;
--xinyuan-chart-13: #60a5fa;
--xinyuan-chart-14: #a855f7;
--xinyuan-chart-15: #06b6d4;
--xinyuan-chart-16: #1e293b;
--xinyuan-chart-sector-gap: var(--surface);
--xinyuan-chart-sector-radius: 10px;
--xinyuan-chart-grid: #e8e8e8;
--xinyuan-chart-axis: #737373;
--xinyuan-chart-label: #737373;
--xinyuan-chart-tooltip-background: #ffffff;
--xinyuan-chart-tooltip-border: #dedede;
--xinyuan-chart-tooltip-foreground: #222222;
--xinyuan-chart-tooltip-shadow: 0 16px 40px rgb(15 23 42 / 14%);
--xinyuan-chart-legend-font-size: 0.75rem;
--xinyuan-chart-legend-line-height: 1rem;
} }
.dark, .dark,
[data-theme="dark"] { [data-theme="dark"] {
color-scheme: dark; color-scheme: dark;
/* Theme Colors (Dark Mode) */ /* Theme Colors (Dark Mode) */
--accent: oklch(45.32% 0.1661 27.03); --accent: 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;
...@@ -45,6 +54,7 @@ ...@@ -45,6 +54,7 @@
/* Page geometry only. Component appearance remains owned by HeroUI. */ /* Page geometry only. Component appearance remains owned by HeroUI. */
--rc-shell-inline-gutter: 3rem; --rc-shell-inline-gutter: 3rem;
--rc-sidebar-width: 15rem;
--rc-navbar-inline-padding: var(--rc-shell-inline-gutter); --rc-navbar-inline-padding: var(--rc-shell-inline-gutter);
--rc-page-inline-start: var(--rc-shell-inline-gutter); --rc-page-inline-start: var(--rc-shell-inline-gutter);
--rc-page-inline-end: var(--rc-shell-inline-gutter); --rc-page-inline-end: var(--rc-shell-inline-gutter);
...@@ -58,10 +68,6 @@ ...@@ -58,10 +68,6 @@
--rc-radius-control: 0.375rem; --rc-radius-control: 0.375rem;
--rc-radius-container: 0.5rem; --rc-radius-container: 0.5rem;
--rc-radius-overlay: 0.75rem; --rc-radius-overlay: 0.75rem;
--rc-radius-detail-surface: 1rem;
--rc-radius-data-grid: 1.25rem;
--rc-radius-modal: 1.5rem;
--rc-dialog-width-4xl: 68rem;
--rc-motion-fast: 120ms; --rc-motion-fast: 120ms;
--rc-motion-normal: 180ms; --rc-motion-normal: 180ms;
--rc-ease-standard: cubic-bezier(0.2, 0, 0, 1); --rc-ease-standard: cubic-bezier(0.2, 0, 0, 1);
...@@ -142,40 +148,28 @@ ...@@ -142,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: var(--rc-chart-1);
--rc-chart-warning: var(--rc-chart-6); --rc-chart-warning: var(--rc-chart-3);
--rc-chart-positive: var(--rc-chart-2); --rc-chart-positive: var(--rc-chart-7);
--rc-chart-comparison: var(--rc-chart-4); --rc-chart-comparison: var(--rc-chart-5);
--rc-chart-neutral: var(--rc-chart-8); --rc-chart-neutral: var(--rc-chart-8);
--rc-chart-legend-font-size: 0.75rem; --rc-chart-legend-font-size: var(--xinyuan-chart-legend-font-size);
--rc-chart-legend-line-height: 1rem; --rc-chart-legend-line-height: var(--xinyuan-chart-legend-line-height);
}
.dark,
[data-theme='dark'] {
color-scheme: dark;
--rc-text-secondary: color-mix(
in oklab,
var(--foreground) 76%,
var(--background)
);
--rc-button-secondary-border: color-mix(
in oklab,
var(--foreground) 18%,
transparent
);
--rc-shadow-raised: 0 1px 2px rgb(0 0 0 / 24%),
0 8px 24px rgb(0 0 0 / 24%);
--rc-shadow-overlay: 0 16px 40px rgb(0 0 0 / 38%);
--rc-shadow-card: 0 1px 2px rgb(0 0 0 / 20%),
0 4px 12px rgb(0 0 0 / 18%);
} }
import clsx from 'clsx'; import clsx from 'clsx';
import { EmptyState } from '@heroui-pro/react/empty-state'; import { EmptyState } from '@heroui-pro/react/empty-state';
import { Surface } from '@heroui/react'; import { Surface } from '@heroui/react';
import { EmptyStateSurface } from '@/components/DataTable';
import TailwindEditableField from './TailwindEditableField'; import TailwindEditableField from './TailwindEditableField';
import { import {
buildViewColumns, buildViewColumns,
...@@ -12,20 +13,6 @@ const FIELD_LABEL_OVERRIDES = { ...@@ -12,20 +13,6 @@ 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();
} }
...@@ -53,6 +40,7 @@ export default function SubjectViewContent({ ...@@ -53,6 +40,7 @@ export default function SubjectViewContent({
editSessionId, editSessionId,
mode = 'view', mode = 'view',
onFieldChange, onFieldChange,
onFieldUpdate,
onPendingLocalMaterialChange, onPendingLocalMaterialChange,
onFieldValidation, onFieldValidation,
readonly, readonly,
...@@ -73,21 +61,27 @@ export default function SubjectViewContent({ ...@@ -73,21 +61,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 fieldDisabled = isFieldDisabled const fieldDisabled = isFieldDisabled
? isFieldDisabled(field.field, view) ? isFieldDisabled(field.field, view)
: isFieldDisabledByView(field.field, view); : isFieldDisabledByView(field.field, view);
...@@ -98,7 +92,6 @@ export default function SubjectViewContent({ ...@@ -98,7 +92,6 @@ export default function SubjectViewContent({
className={clsx( className={clsx(
'min-w-0', 'min-w-0',
minimal ? 'py-1' : 'py-3', minimal ? 'py-1' : 'py-3',
isFullWidth && 'md:col-span-2',
)} )}
> >
<div className={minimal ? 'sr-only' : 'text-sm font-medium text-muted'}> <div className={minimal ? 'sr-only' : 'text-sm font-medium text-muted'}>
...@@ -121,6 +114,9 @@ export default function SubjectViewContent({ ...@@ -121,6 +114,9 @@ export default function SubjectViewContent({
editSessionId, editSessionId,
) )
} }
onSave={onFieldUpdate
? (newValue) => onFieldUpdate(field.field, newValue, field.fieldDef)
: undefined}
onValidationChange={onFieldValidation} onValidationChange={onFieldValidation}
onPendingLocalMaterialChange={onPendingLocalMaterialChange} onPendingLocalMaterialChange={onPendingLocalMaterialChange}
readonly={readonly} readonly={readonly}
...@@ -134,13 +130,5 @@ export default function SubjectViewContent({ ...@@ -134,13 +130,5 @@ export default function SubjectViewContent({
</section> </section>
); );
if (mode === 'view' && !minimal) {
return (
<Surface className="px-5 py-2 sm:px-6 sm:py-3" variant="secondary">
{fields}
</Surface>
);
}
return fields; return fields;
} }
...@@ -234,6 +234,7 @@ function UserSelector({ value, onChange, multiple, variant }) { ...@@ -234,6 +234,7 @@ function UserSelector({ value, onChange, multiple, variant }) {
aria-label="用户" aria-label="用户"
emptyText="暂无用户" emptyText="暂无用户"
inputValue={keyword} inputValue={keyword}
isClearable
options={autocompleteOptions} options={autocompleteOptions}
placeholder="请选择用户" placeholder="请选择用户"
searchPlaceholder="搜索用户名或显示名" searchPlaceholder="搜索用户名或显示名"
...@@ -242,9 +243,10 @@ function UserSelector({ value, onChange, multiple, variant }) { ...@@ -242,9 +243,10 @@ function UserSelector({ value, onChange, multiple, variant }) {
valueText={valueText || '请选择用户'} valueText={valueText || '请选择用户'}
variant={variant} variant={variant}
onInputChange={setKeyword} onInputChange={setKeyword}
onClear={() => onChange(multiple ? [] : null)}
onSelectionChange={handleSelectionChange} onSelectionChange={handleSelectionChange}
renderOption={(option) => ( renderOption={(option) => (
<span className="min-w-0"> <span className="min-w-0 flex-1 overflow-hidden">
<Label className="block truncate">{option.label}</Label> <Label className="block truncate">{option.label}</Label>
<span className="block truncate text-xs text-muted"> <span className="block truncate text-xs text-muted">
{option.description} {option.description}
...@@ -289,6 +291,8 @@ function FieldOptionSelect({ multiple, onChange, options, value, variant }) { ...@@ -289,6 +291,8 @@ function FieldOptionSelect({ multiple, onChange, options, value, variant }) {
selectionMode={multiple ? 'multiple' : 'single'} selectionMode={multiple ? 'multiple' : 'single'}
valueText={displayValue} valueText={displayValue}
variant={variant} variant={variant}
isClearable
onClear={() => onChange(multiple ? [] : '')}
/> />
); );
} }
......
...@@ -390,7 +390,7 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -390,7 +390,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>
...@@ -418,40 +418,39 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -418,40 +418,39 @@ const RecordDetailDrawer = (props, ref) => {
) : null} ) : null}
</div> </div>
<Modal.Header className="pr-16"> <Modal.Header className="shrink-0 pr-16">
<Modal.Heading>{title || '记录详情'}</Modal.Heading> <Modal.Heading>{title || '记录详情'}</Modal.Heading>
{!hideRecordTitle ? ( {!hideRecordTitle ? (
<div className="truncate text-sm text-muted" title={recordTitle}> <div className="truncate text-sm text-muted" title={recordTitle}>
{loading ? '正在读取记录信息' : recordTitle} {loading ? '正在读取记录信息' : recordTitle}
</div> </div>
) : null} ) : null}
{detailViews.length > 1 ? (
<Tabs
aria-label="详情视图"
className="mt-3 w-fit max-w-full min-w-0"
selectedKey={activeView?.name}
variant="primary"
onSelectionChange={setActiveTabKey}
>
<Tabs.ListContainer className="w-fit max-w-full min-w-0">
<Tabs.List className="w-max min-w-max">
{detailViews.map((view) => (
<Tabs.Tab
className="w-auto min-w-max flex-none whitespace-nowrap"
id={view.name}
key={view.name}
>
{view.title || view.name}
<Tabs.Indicator />
</Tabs.Tab>
))}
</Tabs.List>
</Tabs.ListContainer>
</Tabs>
) : null}
</Modal.Header> </Modal.Header>
{detailViews.length > 1 ? (
<Tabs
aria-label="详情视图"
className="w-fit max-w-full min-w-0"
selectedKey={activeView?.name}
variant="secondary"
onSelectionChange={setActiveTabKey}
>
<Tabs.ListContainer className="w-fit max-w-full min-w-0">
<Tabs.List className="w-max min-w-max">
{detailViews.map((view) => (
<Tabs.Tab
className="w-auto min-w-max flex-none whitespace-nowrap"
id={view.name}
key={view.name}
>
{view.title || view.name}
<Tabs.Indicator />
</Tabs.Tab>
))}
</Tabs.List>
</Tabs.ListContainer>
</Tabs>
) : null}
<Modal.Body> <Modal.Body>
{loading ? ( {loading ? (
<div className="flex min-h-48 items-center justify-center" role="status"> <div className="flex min-h-48 items-center justify-center" role="status">
......
import RecordWorkbench from '@/components/record-workbench'; import RecordWorkbench from '@/components/record-workbench';
import RecordDetailDrawer from '@/pages/RecordDetailDrawer'; import RecordDetailDrawer from '@/pages/RecordDetailDrawer';
import { Archive, Delete, Download, Edit, Plus, Reload, Send } from '@/components/AppIcons'; import { Archive, Close, Delete, Download, Edit, Plus, Reload, Send } from '@/components/AppIcons';
import { UserContext } from '@/wrapper/Auth'; import { UserContext } from '@/wrapper/Auth';
import { AlertDialog, Button, Input, Modal, TextArea, Toast, toast } from '@heroui/react'; import { Alert, AlertDialog, Button, Input, Label, Modal, TextArea, TextField, Toast, toast } from '@heroui/react';
import { useCallback, useContext, useEffect, useMemo, useState } from 'react'; import { useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { useLocation } from 'umi'; import { useLocation } from 'umi';
import UserAssignmentDialog from './UserAssignmentDialog'; import UserAssignmentDialog from './UserAssignmentDialog';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete'; import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import FormDatePicker from '@/components/FormDatePicker';
import { import {
getSubjectNameFromPath, getSubjectNameFromPath,
INTEGRITY_PRACTICE_SUBJECT_CONFIG, INTEGRITY_PRACTICE_SUBJECT_CONFIG,
...@@ -24,7 +25,7 @@ import { ...@@ -24,7 +25,7 @@ import {
buildIntegrityCreatePayload, buildIntegrityCreatePayload,
} from './integrityPracticeWorkbenchService'; } from './integrityPracticeWorkbenchService';
import { createRecord } from '@/services/DataService'; import { createRecord } from '@/services/DataService';
import { getViewRestrictionDefaults, getSelectOptions, emptyValueForField } from '@/utils/subjectView'; import { getViewRestrictionDefaults, getSelectOptions, emptyValueForField, toArray } from '@/utils/subjectView';
const EMPTY_ARRAY = []; const EMPTY_ARRAY = [];
const MUTATING_ACTION_PATTERN = /delete|archive|create|push/i; const MUTATING_ACTION_PATTERN = /delete|archive|create|push/i;
...@@ -86,7 +87,7 @@ export default function SubjectWorkbench() { ...@@ -86,7 +87,7 @@ export default function SubjectWorkbench() {
const [selectedIds, setSelectedIds] = useState(EMPTY_ARRAY); const [selectedIds, setSelectedIds] = useState(EMPTY_ARRAY);
const [filters, setFilters] = useState({}); const [filters, setFilters] = useState({});
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const pageSize = DEFAULT_PAGE_SIZE; const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
const [pagination, setPagination] = useState(null); const [pagination, setPagination] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState(false); const [actionLoading, setActionLoading] = useState(false);
...@@ -121,6 +122,7 @@ export default function SubjectWorkbench() { ...@@ -121,6 +122,7 @@ export default function SubjectWorkbench() {
setSelectedIds(EMPTY_ARRAY); setSelectedIds(EMPTY_ARRAY);
setFilters({}); setFilters({});
setPage(0); setPage(0);
setPageSize(DEFAULT_PAGE_SIZE);
loadSubjectResources(subjectName, subjectConfig) loadSubjectResources(subjectName, subjectConfig)
.then((resources) => { .then((resources) => {
...@@ -270,14 +272,14 @@ export default function SubjectWorkbench() { ...@@ -270,14 +272,14 @@ export default function SubjectWorkbench() {
const toolbarActions = useMemo( const toolbarActions = useMemo(
() => () =>
[ [
...(gridConfig.readonly?.disableAddRow ? [] : [{ key: 'create', label: '新增记录', tone: 'primary', disabled: actionLoading, onClick: () => { setCreateError(''); setShowCreate(true); } }]),
...(gridConfig.tabBarRightSlot || []).map((action, index) => ({ ...(gridConfig.tabBarRightSlot || []).map((action, index) => ({
key: action.execute || `toolbar-${index}`, key: action.execute || `toolbar-${index}`,
label: action.label, label: action.label,
tone: 'primary', tone: action.tone || 'secondary',
disabled: actionLoading, disabled: actionLoading,
onClick: (context) => requestAction(action, context), onClick: (context) => requestAction(action, context),
})), })),
...(gridConfig.readonly?.disableAddRow ? [] : [{ key: 'create', label: '新增记录', tone: 'primary', disabled: actionLoading, onClick: () => { setCreateError(''); setShowCreate(true); } }]),
], ],
[actionLoading, gridConfig.readonly, gridConfig.tabBarRightSlot, requestAction], [actionLoading, gridConfig.readonly, gridConfig.tabBarRightSlot, requestAction],
); );
...@@ -323,6 +325,7 @@ export default function SubjectWorkbench() { ...@@ -323,6 +325,7 @@ export default function SubjectWorkbench() {
setPage(0); setPage(0);
}} }}
title={subjectConfig.title || subject?.title || subjectName} title={subjectConfig.title || subject?.title || subjectName}
listTitle={subjectConfig.listTitle}
description={ description={
selectedIds.length > 0 selectedIds.length > 0
? `已选择 ${selectedIds.length} 条记录;执行操作时优先处理所选记录。` ? `已选择 ${selectedIds.length} 条记录;执行操作时优先处理所选记录。`
...@@ -364,6 +367,11 @@ export default function SubjectWorkbench() { ...@@ -364,6 +367,11 @@ export default function SubjectWorkbench() {
? { ? {
...pagination, ...pagination,
onPageChange: setPage, onPageChange: setPage,
pageSizeOptions: [20, 50, 100],
onPageSizeChange: (nextPageSize) => {
setPageSize(Number(nextPageSize) || DEFAULT_PAGE_SIZE);
setPage(0);
},
} }
: null : null
} }
...@@ -383,24 +391,117 @@ export default function SubjectWorkbench() { ...@@ -383,24 +391,117 @@ export default function SubjectWorkbench() {
<Modal.Root> <Modal.Root>
<Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} /> <Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
<Modal.Backdrop isOpen={showCreate} onOpenChange={(open) => { if (!open && !actionLoading) setShowCreate(false); }}> <Modal.Backdrop
<Modal.Container size="lg"><Modal.Dialog> isOpen={showCreate}
<Modal.Header><Modal.Heading>新增记录</Modal.Heading></Modal.Header> variant="blur"
<Modal.Body> onOpenChange={(open) => { if (!open && !actionLoading) setShowCreate(false); }}
{createError ? <AlertDialog.Body><div className="whitespace-pre-line text-danger">{createError}</div></AlertDialog.Body> : null} >
<div className="grid grid-cols-1 gap-4 md:grid-cols-2"> <Modal.Container placement="center" scroll="inside" size="lg">
{createFields.map((field) => { <Modal.Dialog className="bg-background p-7 sm:max-w-[68rem]">
const name = field.fieldDef.name; const value = createValues[name] ?? ''; <Modal.CloseTrigger aria-label="关闭">
const options = getSelectOptions(field.fieldDef); <Close size={16} />
return <div key={field.field} className="min-w-0"> </Modal.CloseTrigger>
<label className="mb-1.5 block text-sm font-medium">{field.label || field.fieldDef.label || name}{field.fieldDef.required ? ' *' : ''}</label> <Modal.Header>
{options.length ? <XinyuanAutocomplete aria-label={field.label || name} options={options.map((option) => ({ ...option, id: String(option.value) }))} selectedKey={value || null} valueText={options.find((option) => option.value === value)?.label || '请选择'} isDisabled={Object.prototype.hasOwnProperty.call(restrictionDefaults.metadata, name)} onSelectionChange={(key) => setCreateValues((current) => ({ ...current, [name]: key ? String(key) : '' }))} /> : field.fieldDef.type === 'long_text' ? <TextArea value={value || ''} onChange={(event) => setCreateValues((current) => ({ ...current, [name]: event.target.value }))} /> : <Input type={field.fieldDef.type === 'number' ? 'number' : field.fieldDef.type === 'date' ? 'date' : 'text'} value={value || ''} isDisabled={Object.prototype.hasOwnProperty.call(restrictionDefaults.metadata, name)} onChange={(event) => setCreateValues((current) => ({ ...current, [name]: event.target.value }))} />} <Modal.Heading>新增记录</Modal.Heading>
</div>; </Modal.Header>
})} <Modal.Body className="min-w-0">
</div> {createError ? (
</Modal.Body> <Alert className="mb-5" status="danger">
<Modal.Footer><Button variant="secondary" onPress={() => setShowCreate(false)}>取消</Button><Button isPending={actionLoading} onPress={handleCreate}>确认新增</Button></Modal.Footer> <Alert.Content>
</Modal.Dialog></Modal.Container> <Alert.Description className="whitespace-pre-line">{createError}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
<div className="grid min-w-0 grid-cols-1 gap-x-8 gap-y-5 md:grid-cols-2">
{createFields.map((field) => {
const name = field.fieldDef.name;
const value = createValues[name] ?? '';
const fieldLabel = field.label || field.fieldDef.label || name;
const options = getSelectOptions(field.fieldDef);
const fieldType = String(field.fieldDef.type || 'text').toLowerCase();
const isMultiple = Boolean(field.fieldDef.multiple);
const selectedValues = isMultiple ? toArray(value).map(String) : [];
const selectedLabels = options
.filter((option) => selectedValues.includes(String(option.value)))
.map((option) => option.label)
.join('、');
const isFieldDisabled = Object.prototype.hasOwnProperty.call(
restrictionDefaults.metadata,
name,
);
return (
<TextField
className={`${name === 'title' || options.length || ['long_text', 'rich_text'].includes(fieldType) ? 'md:col-span-2 ' : ''}min-w-0`}
fullWidth
isRequired={Boolean(field.fieldDef.required)}
key={field.field}
>
<Label>{fieldLabel}</Label>
{options.length ? (
<XinyuanAutocomplete
aria-label={fieldLabel}
isClearable
isDisabled={isFieldDisabled || actionLoading}
options={options.map((option) => ({ ...option, id: String(option.value) }))}
selectedKey={isMultiple ? undefined : value || null}
selectedKeys={isMultiple ? new Set(selectedValues) : undefined}
selectionMode={isMultiple ? 'multiple' : 'single'}
valueText={isMultiple ? selectedLabels || '请选择' : options.find((option) => String(option.value) === String(value))?.label || '请选择'}
onClear={() => setCreateValues((current) => ({ ...current, [name]: isMultiple ? [] : '' }))}
onSelectionChange={(keys) => setCreateValues((current) => ({
...current,
[name]: isMultiple
? keys === 'all'
? options.map((option) => option.value)
: Array.from(keys || []).map(String)
: keys
? String(keys)
: '',
}))}
/>
) : ['date', 'datetime', 'date_time', 'month'].includes(fieldType) ? (
<FormDatePicker
aria-label={fieldLabel}
disabled={isFieldDisabled || actionLoading}
mode={fieldType === 'month' ? 'month' : ['datetime', 'date_time'].includes(fieldType) ? 'datetime' : 'date'}
value={value || ''}
variant="secondary"
onChange={(event) => setCreateValues((current) => ({ ...current, [name]: event.target.value }))}
/>
) : ['long_text', 'rich_text'].includes(fieldType) ? (
<TextArea
fullWidth
rows={4}
value={value || ''}
variant="secondary"
disabled={isFieldDisabled || actionLoading}
onChange={(event) => setCreateValues((current) => ({ ...current, [name]: event.target.value }))}
/>
) : (
<Input
type={fieldType === 'number' ? 'number' : fieldType === 'date' ? 'date' : 'text'}
value={value || ''}
variant="secondary"
disabled={isFieldDisabled || actionLoading}
onChange={(event) => setCreateValues((current) => ({ ...current, [name]: event.target.value }))}
/>
)}
</TextField>
);
})}
</div>
</Modal.Body>
<Modal.Footer>
<Button isDisabled={actionLoading} variant="secondary" onPress={() => setShowCreate(false)}>
取消
</Button>
<Button isDisabled={actionLoading} isPending={actionLoading} variant="primary" onPress={handleCreate}>
确认新增
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop> </Modal.Backdrop>
</Modal.Root> </Modal.Root>
......
...@@ -308,6 +308,7 @@ export default function IntegrityPracticeMaterialField({ ...@@ -308,6 +308,7 @@ export default function IntegrityPracticeMaterialField({
errorText={materialsError} errorText={materialsError}
inputValue={keyword} inputValue={keyword}
isDisabled={uploading} isDisabled={uploading}
isClearable
isLoading={materialsLoading} isLoading={materialsLoading}
options={materialOptions} options={materialOptions}
placeholder="请选择审计材料" placeholder="请选择审计材料"
...@@ -317,9 +318,13 @@ export default function IntegrityPracticeMaterialField({ ...@@ -317,9 +318,13 @@ export default function IntegrityPracticeMaterialField({
valueText={selectedValueText} valueText={selectedValueText}
variant="secondary" variant="secondary"
onInputChange={setKeyword} onInputChange={setKeyword}
onClear={() => {
setSelectedMaterialId(null);
onChange?.(multiple ? [] : null);
}}
onSelectionChange={handleMaterialSelection} onSelectionChange={handleMaterialSelection}
renderOption={(material) => ( renderOption={(material) => (
<span className="min-w-0"> <span className="min-w-0 flex-1 overflow-hidden">
<Label className="block truncate" title={material.materialTitle}> <Label className="block truncate" title={material.materialTitle}>
{material.materialTitle} {material.materialTitle}
</Label> </Label>
......
...@@ -72,6 +72,7 @@ export const INTEGRITY_PRACTICE_SUBJECT_CONFIG = { ...@@ -72,6 +72,7 @@ export const INTEGRITY_PRACTICE_SUBJECT_CONFIG = {
}, },
self_check_the_draft_template: { self_check_the_draft_template: {
title: '自查底稿模版', title: '自查底稿模版',
listTitle: '自查底稿模板列表',
gridRecordConfig: { gridRecordConfig: {
menuConfig: [ menuConfig: [
{ name: '编辑', action: 'integrityRiskManagerService.editDetail' }, { name: '编辑', action: 'integrityRiskManagerService.editDetail' },
...@@ -82,10 +83,12 @@ export const INTEGRITY_PRACTICE_SUBJECT_CONFIG = { ...@@ -82,10 +83,12 @@ export const INTEGRITY_PRACTICE_SUBJECT_CONFIG = {
}, },
self_check_the_working_draft: { self_check_the_working_draft: {
title: '自查底稿项目', title: '自查底稿项目',
listTitle: '自查底稿项目列表',
gridRecordConfig: { menuConfig: [] }, gridRecordConfig: { menuConfig: [] },
}, },
statistical_table: { statistical_table: {
title: '廉洁从业规定落实情况统计表', title: '廉洁从业规定落实情况统计表',
listTitle: '统计表记录',
gridRecordConfig: { gridRecordConfig: {
filterView: [], filterView: [],
menuConfig: [ menuConfig: [
...@@ -97,12 +100,14 @@ export const INTEGRITY_PRACTICE_SUBJECT_CONFIG = { ...@@ -97,12 +100,14 @@ export const INTEGRITY_PRACTICE_SUBJECT_CONFIG = {
{ {
label: '导出统计表', label: '导出统计表',
execute: 'integrityRiskManagerService.exportIntegrityPracticeStatisticsTable', execute: 'integrityRiskManagerService.exportIntegrityPracticeStatisticsTable',
tone: 'secondary',
}, },
], ],
}, },
}, },
self_inspection_submission_form: { self_inspection_submission_form: {
title: '廉洁从业自查底稿', title: '廉洁从业自查底稿',
listTitle: '自查底稿报送记录',
gridRecordConfig: { gridRecordConfig: {
menuConfig: [ menuConfig: [
{ name: '编辑', action: 'integrityRiskManagerService.editDetail' }, { name: '编辑', action: 'integrityRiskManagerService.editDetail' },
...@@ -114,6 +119,7 @@ export const INTEGRITY_PRACTICE_SUBJECT_CONFIG = { ...@@ -114,6 +119,7 @@ export const INTEGRITY_PRACTICE_SUBJECT_CONFIG = {
}, },
integrity_practice_report: { integrity_practice_report: {
title: '廉洁从业报告生成', title: '廉洁从业报告生成',
listTitle: '廉洁从业报告记录',
gridRecordConfig: { gridRecordConfig: {
filterView: [], filterView: [],
menuConfig: [ menuConfig: [
...@@ -126,6 +132,7 @@ export const INTEGRITY_PRACTICE_SUBJECT_CONFIG = { ...@@ -126,6 +132,7 @@ export const INTEGRITY_PRACTICE_SUBJECT_CONFIG = {
}, },
self_inspection_annual_submission_materials: { self_inspection_annual_submission_materials: {
title: '历年报送材料', title: '历年报送材料',
listTitle: '历史报送材料记录',
gridRecordConfig: { gridRecordConfig: {
pagination: false, pagination: false,
menuConfig: [ menuConfig: [
......
...@@ -174,7 +174,23 @@ export function buildSubjectFilterSchema(view, subject) { ...@@ -174,7 +174,23 @@ export function buildSubjectFilterSchema(view, subject) {
const fieldDef = column.fieldDef || {}; const fieldDef = column.fieldDef || {};
const type = String(fieldDef.type || 'text').toLowerCase(); const type = String(fieldDef.type || 'text').toLowerCase();
const options = getSelectOptions(fieldDef); const options = getSelectOptions(fieldDef);
return { key: column.field, field: column.field, label: column.label || fieldDef.label || column.field, type: type === 'select' && options.length ? 'select' : isDateFieldType(type) ? 'date' : isReferenceFieldType(type) && getReferenceSubjectName(fieldDef) ? 'reference' : 'text', options, fieldDef, placeholder: `筛选${column.label || fieldDef.label || column.field}` }; return {
key: column.field,
field: column.field,
label: column.label || fieldDef.label || column.field,
type: type === 'select' && options.length
? 'select'
: isDateFieldType(type)
? 'date'
: isReferenceFieldType(type) && getReferenceSubjectName(fieldDef)
? 'reference'
: type === 'number'
? 'number'
: 'text',
options,
fieldDef,
placeholder: column.placeholder,
};
}); });
} }
......
...@@ -317,6 +317,17 @@ export default function NotificationListPage() { ...@@ -317,6 +317,17 @@ export default function NotificationListPage() {
return ( return (
<div className="rc-page-stack"> <div className="rc-page-stack">
<div className="rc-page-toolbar rc-page-toolbar-responsive"> <div className="rc-page-toolbar rc-page-toolbar-responsive">
<div className="rc-page-toolbar">
<h2 className="rc-section-title">通知记录</h2>
<Button
isDisabled={loading || unreadTotal === 0}
isLoading={busyKey === 'all'}
variant="secondary"
onPress={handleMarkAllRead}
>
全部已读
</Button>
</div>
<Tabs <Tabs
className="w-fit max-w-full min-w-0" className="w-fit max-w-full min-w-0"
selectedKey={category} selectedKey={category}
...@@ -337,14 +348,6 @@ export default function NotificationListPage() { ...@@ -337,14 +348,6 @@ export default function NotificationListPage() {
</Tabs.ListContainer> </Tabs.ListContainer>
</Tabs> </Tabs>
<Button
isDisabled={loading || unreadTotal === 0}
isLoading={busyKey === 'all'}
variant="secondary"
onPress={handleMarkAllRead}
>
全部已读
</Button>
</div> </div>
<div className="rc-page-toolbar"> <div className="rc-page-toolbar">
......
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