Skip to content
Projects
Groups
Snippets
Help
Loading...
Sign in / Register
Toggle navigation
I
integrity_practice_management_new
Project
Project
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Registry
Registry
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
David Yang
integrity_practice_management_new
Commits
5c519a8f
Commit
5c519a8f
authored
Sep 02, 2026
by
Andy-bubu
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: align integrity management UI with Xinyuan baseline
parent
5c566366
Hide whitespace changes
Inline
Side-by-side
Showing
28 changed files
with
2195 additions
and
721 deletions
+2195
-721
ui-architecture.md
docs/ui-architecture.md
+5
-5
ui-migration-checklist.md
docs/ui-migration-checklist.md
+14
-2
xinyuan-design-system.md
docs/xinyuan-design-system.md
+16
-10
xinyuan-ui-audit.md
docs/xinyuan-ui-audit.md
+10
-10
app.js
src/app.js
+5
-7
AppIcons.jsx
src/components/AppIcons.jsx
+0
-4
AppShell.jsx
src/components/AppShell.jsx
+2
-28
ApplicationSwitcherDialog.jsx
src/components/ApplicationSwitcherDialog.jsx
+409
-133
DashboardNavbar.jsx
src/components/DashboardNavbar.jsx
+2
-18
DashboardSidebar.jsx
src/components/DashboardSidebar.jsx
+150
-40
DataTable.jsx
src/components/DataTable.jsx
+665
-0
FilterSelect.jsx
src/components/FilterSelect.jsx
+65
-0
ReferenceSearchSelect.jsx
src/components/ReferenceSearchSelect.jsx
+4
-9
TableActionButton.jsx
src/components/TableActionButton.jsx
+39
-0
XinyuanAutocomplete.jsx
src/components/XinyuanAutocomplete.jsx
+29
-17
index.jsx
src/components/record-workbench/index.jsx
+141
-76
application.js
src/config/application.js
+0
-1
shell.css
src/design-system/shell.css
+281
-167
theme.css
src/design-system/theme.css
+104
-57
tokens.css
src/design-system/tokens.css
+35
-41
SubjectViewContent.jsx
...ages/RecordDetailDrawer/components/SubjectViewContent.jsx
+17
-29
TailwindEditableField.jsx
...s/RecordDetailDrawer/components/TailwindEditableField.jsx
+5
-1
index.jsx
src/pages/RecordDetailDrawer/index.jsx
+26
-27
SubjectWorkbench.jsx
src/pages/integrity_practice_management/SubjectWorkbench.jsx
+130
-29
IntegrityPracticeMaterialField.jsx
..._management/components/IntegrityPracticeMaterialField.jsx
+6
-1
integrityPracticeConfig.js
.../integrity_practice_management/integrityPracticeConfig.js
+7
-0
integrityPracticeWorkbenchService.js
..._practice_management/integrityPracticeWorkbenchService.js
+17
-1
NotificationListPage.jsx
...ractice_management/notifications/NotificationListPage.jsx
+11
-8
No files found.
docs/ui-architecture.md
View file @
5c519a8f
...
...
@@ -3,12 +3,12 @@
## 运行结构
```
text
app.js (主题初始化)
app.js (
固定浅色
主题初始化)
-> Site/Auth wrappers (现有鉴权与登出)
-> integrity_practice_management/Layout
-> AppShell (Pro Sidebar.Provider)
-> DashboardSidebar (Pro Sidebar + HeroUI Dropdown)
-> DashboardNavbar (Pro Navbar + Sidebar.Trigger)
-> DashboardNavbar (Pro Navbar + Sidebar.Trigger
+ Badge.Anchor 通知
)
-> Badge.Anchor + Badge 通知入口 -> /notifications
-> SubjectWorkbench (七个 Subject 配置入口)
-> RecordWorkbench (Tabs/Search/DataGrid/Pagination/ActionBar)
...
...
@@ -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 样式;页面不得复制这些规则。
-
`AppShell/DashboardSidebar/DashboardNavbar/ApplicationSwitcherDialog`
:业务无关的共享壳层。
-
`ApplicationSwitcherDialog`
从既有
`SiteContext.site.group`
读取动态分组,从
`GET /application/info`
与
`UserContext.applications`
合并真实可访问应用;分组仅影响呈现,不改变路由、会话或跳转。
-
`SubjectWorkbench`
:把现有 Subject/View/动作配置适配为工作台 props,不定义组件外观。
-
`record-workbench`
:DataGrid/Pagination/ActionBar 组合;详情能力统一映射为操作列首个预览图标,首个业务列仅展示文本;不发请求、不解释业务动作。
-
`record-workbench`
:DataGrid/Pagination/ActionBar 组合;
筛选使用
`FilterSelect`
/
`FormDatePicker`
/Disclosure,
详情能力统一映射为操作列首个预览图标,首个业务列仅展示文本;不发请求、不解释业务动作。
-
`RecordDetailDrawer`
:详情整窗草稿、Modal 状态和字段编排;一次保存通过
`useSubjectRecord.handleFieldsUpdate`
提交。
-
`integrityPracticeMaterialService`
:
`am_material`
查询、
`/upload`
结果快照规范化和材料建档;应用标识由廉洁从业业务层固定为
`integrity_practice_management`
。
-
`notifications/notificationService`
:平台通知查询、已读、动态 action、未读事件和 base-aware 链接解析。
...
...
@@ -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`
不用于隐藏详情字段。
主题状态写入
`xinyuan_color_theme`
,同时更新根
`data-theme`
与
`dark`
class,使 Portal 与页面一致
。应用切换数据来自真实 application info;登出调用现有 logout API 后清理登录态并回到 base-aware 登录路径。
根主题固定为
`light`
,内容画布保持浅色,Sidebar 通过独立 Token 保持深色;Portal 跟随内容区主题
。应用切换数据来自真实 application info;登出调用现有 logout API 后清理登录态并回到 base-aware 登录路径。
## 历史源码隔离
...
...
docs/ui-migration-checklist.md
View file @
5c519a8f
...
...
@@ -13,13 +13,14 @@
-
[
x
]
登录、鉴权、真实退出
-
[
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
]
Tabs、SearchField/Surface 筛选
-
[
x
]
Pro DataGrid 选择、排序、固定列、横向滚动
-
[
x
]
HeroUI Pagination
(固定每页 10 条、无页容量选择器
)、ActionBar、EmptyState
-
[
x
]
HeroUI Pagination
、共享 Autocomplete 页容量选择器(20/50/100
)、ActionBar、EmptyState
-
[
x
]
4+ 行动作 More 菜单与危险动作末位
-
[
x
]
详情/编辑 Modal、指派 Modal、删除 AlertDialog
-
[
x
]
新增记录 Modal:4xl 居中弹窗、blur Backdrop、标准关闭按钮、可滚动 Body、两列动态表单和次级/主操作层级
-
[
x
]
Select、Autocomplete、DatePicker、DropZone 直接 compound API
-
[
x
]
活动路由 KPI/图表判定为不适用
-
[
x
]
Grid
`shortTitle`
严格布尔隐藏标题展示列,标题数据与查询协议保留
...
...
@@ -54,12 +55,23 @@
-
[
x
]
所有活动 DataGrid 的操作列预览入口、首列只读文本、32px 图标按钮与 More 危险项顺序
-
[
x
]
操作列固定内容宽度;非溢出表格由首个业务列
`1fr`
吸收余量,表头与首个动作按标准内边距对齐,右侧无弹性空白
-
[
x
]
Tabs、Select、Autocomplete、DatePicker 打开态
-
[
x
]
筛选 Disclosure 收起态不保留隐藏面板高度,展开态正常显示扩展字段
-
[
x
]
编辑弹窗远程引用多选:长标题省略、数量汇总、搜索/清空、受控选择、移动端视口边界与单选关闭行为
-
[
x
]
详情查看/编辑、指派、删除确认
-
[
x
]
详情 Modal 4xl/24px、secondary 查看区、tertiary DataGrid 表头、default 表体及固定列层级(四视口浅/深色)
-
[
x
]
新页面重载控制台 0 error
-
[
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`
仍保留,且不进入当前应用路由。
...
...
docs/xinyuan-design-system.md
View file @
5c519a8f
# 廉洁从业管理鑫元设计系统
本文件约束
`/integrity_practice_management/`
的活动路由。唯一视觉和结构基准是已确认的“
问题整改
”应用;不得重新设计或在页面层覆盖 HeroUI 外观。
本文件约束
`/integrity_practice_management/`
的活动路由。唯一视觉和结构基准是已确认的“
审计项目管理
”应用;不得重新设计或在页面层覆盖 HeroUI 外观。
## 来源与加载顺序
...
...
@@ -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;用户菜单的退出项调用真实登出回调。
-
应用切换:严格使用
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 Surface + SearchField/Autocomplete/DatePicker/Disclosure。没有业务配置的筛选不得添加。
-
列表:HeroUI Pro DataGrid、EmptyState、ActionBar + HeroUI Pagination;分页固定沿用业务默认每页 10 条,不显示额外页容量选择器。首列只在真实横向溢出时固定且仅展示文本,详情入口统一为操作列首个预览图标。操作列宽度由 32px 动作按钮、8px 间距和标准单元格内边距计算,
`minWidth/width/maxWidth`
必须一致;表格未横向溢出时首个业务列使用
`1fr`
吸收剩余宽度,操作列不得参与余量分配;仅在横向溢出时固定在右端。
-
列表标题:除“自查底稿填写表”数据表外,其他列表在工具栏上方显示独立
`listTitle`
;列表标题不得复用顶部栏路由文案。
-
筛选: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;危险项排序最后。
-
Modal:详情/
表单使用 HeroUI Modal;确认使用 AlertDialog;内容型详情使用 4xl/1088px、24px 圆角,
查看态只允许一个 16px 圆角的 secondary Surface。嵌套 DataGrid 使用 20px 圆角,表头与固定表头使用 tertiary Surface,表体与固定单元格回到 default Surface,禁止表头与外层详情 Surface 使用同一层颜色。
-
动态字段:Input、TextArea、Checkbox、Select、Autocomplete、DatePicker;附件使用 Pro DropZone。
-
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。
Autocomplete 长标题候选项必须允许内容收缩、省略,并为右侧选中标记保留固定空间。
-
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。
-
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}`
。
-
远程引用:使用 HeroUI
`Popover.Root + Button + SearchField + controlled ListBox`
。多选触发器固定 40px 高并只显示“已选择 N 项”,候选长标题单行省略且通过
`title`
暴露全文;弹层最大 24rem、移动端收敛到
`100vw - 24px`
、列表最大 288px。多选后保持打开,单选后关闭,清空不改变原 ID 保存协议。
-
KPI/图表:仅在真实业务路由存在时使用 Pro KPI、PieChart、BarChart、ComposedChart、ChartTooltip;禁止 Card 数字卡或 ECharts 私有主题。
-
远程引用:使用 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;当前七条业务路由无 KPI/图表,不制造测试模块。
## 基准同步记录
2026-09-02 已同步审计项目管理的固定浅色内容画布、深色 Sidebar、Disclosure 分组、移动 Sheet、应用切换 Pro ItemCard 结构、共享 DataTable/FilterSelect/ActionButton 和工作台筛选/分页布局。页面仍只负责布局 className,业务数据、回调和请求边界保持原实现。
## 业务和数据保护
...
...
docs/xinyuan-ui-audit.md
View file @
5c519a8f
# 廉洁从业管理鑫元 UI 审计
审计日期:2026-0
8-20
审计日期:2026-0
9-02
分支:
`codex/xinyuan-ui-ux`
唯一实现基准:
`/Users/liuchenlong/Desktop/job/
problem_rectification
`
唯一实现基准:
`/Users/liuchenlong/Desktop/job/
audit_project_manager
`
## 安装硬门槛
...
...
@@ -45,17 +45,17 @@
-
登录:已使用基准居中 Card、Logo、字段高度、24px 节奏、验证码、filled/focus/autofill 状态;保留原登录协议。
-
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;当前业务仅配置标题关键词,不添加虚构筛选字段。
-
列表: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 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 子路径已验证可导入。
## 浏览器验收
目标
`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 | 浅/深色 |
| --- | --- | --- | --- | --- | --- |
...
...
@@ -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`
。
应用切换专项基准更新:应用切换
改以 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。
...
...
@@ -79,7 +79,7 @@
编辑弹窗远程引用专项复测:多选触发器不再拼接长标题,固定显示“已选择 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`
是会话夹具残留,不是产品退出流程的持久化行为。
...
...
@@ -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/`
。
-
材料协议:查询
`am_material`
最近 20 条;本地文件仍先走
`/upload`
,详情保存时才以
`source_app = integrity_practice_management`
建档,材料全部成功后才更新业务记录。
-
原始缺口:旧实现会在零授权 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 也没有制造业务数据验收;问题整改基准的独立登录会话未稳定建立。未使用伪造业务数据绕过这些限制。
src/app.js
View file @
5c519a8f
import
'@/global.less'
;
import
'@/tailwind.css'
;
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'
)
{
const
storedTheme
=
window
.
localStorage
?.
getItem
?.(
THEME_STORAGE_KEY
);
const
theme
=
storedTheme
===
'dark'
?
'dark'
:
'light'
;
document
.
documentElement
.
classList
.
toggle
(
'dark'
,
theme
===
'dark'
);
document
.
documentElement
.
classList
.
toggle
(
'light'
,
theme
===
'light'
);
document
.
documentElement
.
dataset
.
theme
=
theme
;
document
.
documentElement
.
style
.
colorScheme
=
theme
;
document
.
documentElement
.
classList
.
remove
(
'dark'
);
document
.
documentElement
.
classList
.
add
(
'light'
);
document
.
documentElement
.
dataset
.
theme
=
'light'
;
document
.
documentElement
.
style
.
colorScheme
=
'light'
;
}
const
appBasePath
=
String
(
...
...
src/components/AppIcons.jsx
View file @
5c519a8f
...
...
@@ -15,12 +15,10 @@ import FileTextOutlined from '@ant-design/icons/FileTextOutlined';
import
FullscreenExitOutlined
from
'@ant-design/icons/FullscreenExitOutlined'
;
import
FullscreenOutlined
from
'@ant-design/icons/FullscreenOutlined'
;
import
LogoutOutlined
from
'@ant-design/icons/LogoutOutlined'
;
import
MoonOutlined
from
'@ant-design/icons/MoonOutlined'
;
import
ReloadOutlined
from
'@ant-design/icons/ReloadOutlined'
;
import
SendOutlined
from
'@ant-design/icons/SendOutlined'
;
import
SearchOutlined
from
'@ant-design/icons/SearchOutlined'
;
import
SettingOutlined
from
'@ant-design/icons/SettingOutlined'
;
import
SunOutlined
from
'@ant-design/icons/SunOutlined'
;
import
ToolOutlined
from
'@ant-design/icons/ToolOutlined'
;
import
UploadOutlined
from
'@ant-design/icons/UploadOutlined'
;
import
PlusOutlined
from
'@ant-design/icons/PlusOutlined'
;
...
...
@@ -67,7 +65,6 @@ export const FileText = withSize(FileTextOutlined);
export
const
Fullscreen
=
withSize
(
FullscreenOutlined
);
export
const
FullscreenExit
=
withSize
(
FullscreenExitOutlined
);
export
const
LogOut
=
withSize
(
LogoutOutlined
);
export
const
Moon
=
withSize
(
MoonOutlined
);
export
const
More
=
withSize
(
MoreOutlined
);
export
const
Reload
=
withSize
(
ReloadOutlined
);
export
const
ArrowPathIcon
=
Reload
;
...
...
@@ -75,7 +72,6 @@ export const Send = withSize(SendOutlined);
export
const
Search
=
withSize
(
SearchOutlined
);
export
const
MagnifyingGlassIcon
=
Search
;
export
const
Settings
=
withSize
(
SettingOutlined
);
export
const
Sun
=
withSize
(
SunOutlined
);
export
const
Tool
=
withSize
(
ToolOutlined
);
export
const
Upload
=
withSize
(
UploadOutlined
);
export
const
Plus
=
withSize
(
PlusOutlined
);
...
...
src/components/AppShell.jsx
View file @
5c519a8f
import
{
THEME_STORAGE_KEY
}
from
'@/config/application'
;
import
{
Sidebar
}
from
'@heroui-pro/react/sidebar'
;
import
{
Toast
}
from
'@heroui/react'
;
import
{
useLayoutEffect
,
useState
}
from
'react'
;
import
{
history
}
from
'umi'
;
import
DashboardNavbar
from
'./DashboardNavbar'
;
import
DashboardSidebar
from
'./DashboardSidebar'
;
function
getInitialTheme
()
{
if
(
typeof
window
===
'undefined'
)
return
'light'
;
try
{
return
window
.
localStorage
.
getItem
(
THEME_STORAGE_KEY
)
===
'dark'
?
'dark'
:
'light'
;
}
catch
{
return
'light'
;
}
}
export
default
function
AppShell
({
children
,
notificationCount
,
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
(
<
Sidebar
.
Provider
className=
{
`template-dashboard-shell rc-app-shell ${theme}`
}
className=
"template-dashboard-shell rc-app-shell light"
collapsible=
"offcanvas"
navigate=
{
(
href
)
=>
history
.
push
(
href
)
}
open=
{
open
}
onOpenChange=
{
onOpenChange
}
>
<
DashboardSidebar
theme=
{
theme
}
{
...
sidebarProps
}
/>
<
DashboardSidebar
{
...
sidebarProps
}
/>
<
Sidebar
.
Main
className=
"app-main-glass rc-app-main"
>
<
DashboardNavbar
notificationCount=
{
notificationCount
}
onOpenNotifications=
{
notificationPath
?
()
=>
history
.
push
(
notificationPath
)
:
undefined
}
theme=
{
theme
}
title=
{
title
}
onThemeChange=
{
setTheme
}
/>
<
div
className=
"app-shell-content-scroll min-h-0 flex-1 overflow-y-auto"
>
<
div
className=
"rc-app-page"
>
{
children
}
</
div
>
...
...
src/components/ApplicationSwitcherDialog.jsx
View file @
5c519a8f
import
xinyuanLogo
from
'@/assets/xy-logo-red.png'
;
import
{
ExclamationTriangleIcon
}
from
'@/components/AppIcons'
;
import
{
APPLICATION_DISPLAY_NAME
,
APPLICATION_NAME
}
from
'@/config/application'
;
import
{
APPLICATION_BASE_PATH
,
APPLICATION_DISPLAY_NAME
,
APPLICATION_NAME
,
}
from
'@/config/application'
;
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
{
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'
;
const
CURRENT_APPLICATION_NAME
=
APPLICATION_NAME
;
function
unwrapApplications
(
response
)
{
for
(
const
candidate
of
[
response
,
response
?.
data
,
response
?.
applications
,
response
?.
apps
,
response
?.
records
,
response
?.
rows
,
response
?.
list
,
response
?.
result
])
{
if
(
Array
.
isArray
(
candidate
))
return
candidate
;
const
candidates
=
[
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
=
{})
{
const
name
=
application
.
name
||
''
;
const
current
=
name
===
APPLICATION_NAME
;
function
resolveApplicationUrl
(
application
)
{
if
(
application
?.
name
)
{
return
`/
${
application
.
name
}
`
;
}
return
'#'
;
}
function
normalizeApplication
(
application
,
fallback
=
{})
{
const
name
=
application
?.
name
||
fallback
?.
name
||
''
;
const
current
=
name
===
CURRENT_APPLICATION_NAME
;
return
{
name
,
title
:
current
?
APPLICATION_DISPLAY_NAME
:
application
.
title
||
name
||
'未命名应用'
,
description
:
application
.
description
||
application
.
subtitle
||
application
.
title
||
'进入对应应用继续处理业务'
,
logo
:
current
?
xinyuanLogo
:
application
.
logo
||
application
.
icon
||
null
,
order
:
Number
(
application
.
order
||
0
),
url
:
application
.
url
||
application
.
path
||
(
name
?
`/
${
name
}
`
:
'#'
),
title
:
current
?
APPLICATION_DISPLAY_NAME
:
application
?.
title
||
fallback
?.
title
||
application
?.
name
||
fallback
?.
name
||
'未命名应用'
,
description
:
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
)
{
const
merged
=
new
Map
();
[...
fallbacks
,
...
fetched
,
{
name
:
APPLICATION_NAME
,
title
:
APPLICATION_DISPLAY_NAME
}]
.
map
(
normalizeApplication
)
.
filter
((
application
)
=>
application
.
name
)
.
forEach
((
application
)
=>
merged
.
set
(
application
.
name
,
{
...
merged
.
get
(
application
.
name
),
...
application
}));
return
[...
merged
.
values
()].
sort
((
left
,
right
)
=>
left
.
order
-
right
.
order
||
left
.
title
.
localeCompare
(
right
.
title
,
'zh-CN'
));
function
mergeApplications
(
fetchedApplications
,
fallbackApplications
)
{
const
fallbackMap
=
new
Map
(
fallbackApplications
.
map
((
application
)
=>
[
application
.
name
,
application
]),
);
const
normalizedFetched
=
fetchedApplications
.
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
)
{
...
...
@@ -40,22 +136,31 @@ function buildApplicationSections(applications, applicationGroups) {
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
sections
=
applicationGroups
.
map
((
group
,
index
)
=>
{
const
groupApplications
=
Array
.
isArray
(
group
?.
applications
)
?
group
.
applications
.
map
((
name
)
=>
{
const
application
=
applicationMap
.
get
(
name
);
if
(
application
)
groupedApplicationNames
.
add
(
name
);
return
application
;
})
.
filter
(
Boolean
)
.
map
((
name
)
=>
{
if
(
groupedApplicationNames
.
has
(
name
))
{
return
null
;
}
const
application
=
applicationMap
.
get
(
name
);
if
(
application
)
{
groupedApplicationNames
.
add
(
name
);
}
return
application
;
})
.
filter
(
Boolean
)
:
[];
return
{
key
:
`group-
${
index
}
-
${
group
?.
label
||
'applications'
}
`,
key
:
group
?.
label
||
`group-
${
index
}
`
,
label
:
group
?.
label
||
'应用分组'
,
applications
:
groupApplications
,
};
...
...
@@ -65,6 +170,7 @@ function buildApplicationSections(applications, applicationGroups) {
const
ungroupedApplications
=
applications
.
filter
(
(
application
)
=>
!
groupedApplicationNames
.
has
(
application
.
name
),
);
if
(
ungroupedApplications
.
length
>
0
)
{
sections
.
push
({
key
:
'ungrouped'
,
...
...
@@ -77,149 +183,319 @@ function buildApplicationSections(applications, applicationGroups) {
}
function
isCurrentApplication
(
application
)
{
if (application.name === APPLICATION_NAME) return true;
if (typeof window === 'undefined') return false;
try {
const target = new URL(application.url, window.location.origin);
const root = application.name ? `
/
$
{
application
.
name
}
` : target.pathname;
return window.location.pathname === root || window.location.pathname.startsWith(`
$
{
root
}
/`
)
;
}
catch
{
if
(
application
?.
name
===
CURRENT_APPLICATION_NAME
)
{
return
true
;
}
if
(
typeof
window
===
'undefined'
)
{
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
})
{
const
target
=
/^https
?
:
\/\/
/i
.
test
(
application
.
url
)
?
'_blank'
:
undefined
;
const
initial
=
application
.
title
.
trim
().
slice
(
0
,
1
).
toUpperCase
()
||
'A'
;
const
target
=
application
?.
url
?.
startsWith
(
'http'
)
?
'_blank'
:
undefined
;
const
description
=
application
.
description
?.
trim
();
const
hasDistinctDescription
=
description
&&
description
!==
application
.
title
.
trim
();
return
(
<
Card
className=
"h-full"
variant=
"secondary"
>
<
Card
.
Content
>
<
a
aria
-
current=
{
current
?
'page'
:
undefined
}
className=
"flex min-w-0 items-center gap-3 no-underline"
href=
{
application
.
url
}
rel=
{
target
?
'noreferrer'
:
undefined
}
target=
{
target
}
>
<
Surface
aria
-
hidden=
"true"
className=
"rc-app-identity-well grid size-9 shrink-0 place-items-center overflow-hidden"
variant=
"tertiary"
>
<
Avatar
className=
"rc-app-identity-avatar size-full"
color=
"accent"
variant=
"soft"
>
{
application
.
logo
?
<
Avatar
.
Image
alt=
""
className=
"object-contain p-1.5"
src=
{
application
.
logo
}
/>
:
null
}
<
Avatar
.
Fallback
>
{
initial
}
</
Avatar
.
Fallback
>
</
Avatar
>
</
Surface
>
<
span
className=
"min-w-0 flex-1"
>
<
span
className=
"block truncate text-sm font-medium leading-5"
title=
{
application
.
title
}
>
{
application
.
title
}
</
span
>
<
Description
className=
"mt-0.5 block truncate leading-4"
title=
{
application
.
description
}
>
{
application
.
description
}
</
Description
>
</
span
>
{
current
?
<
Chip
color=
"accent"
size=
"sm"
>
当前
</
Chip
>
:
null
}
</
a
>
</
Card
.
Content
>
</
Card
>
<
ItemCard
aria
-
current=
{
current
?
'page'
:
undefined
}
className=
"h-full min-w-0 no-underline"
href=
{
application
.
url
}
rel=
{
target
?
'noreferrer'
:
undefined
}
render=
{
(
props
)
=>
<
a
{
...
props
}
/>
}
target=
{
target
}
variant=
{
current
?
'outline'
:
'default'
}
>
<
ItemCard
.
Icon
aria
-
hidden=
"true"
>
<
Surface
variant=
"default"
className=
"rc-application-identity-well"
>
<
Avatar
className=
"rc-application-identity-avatar"
color=
"accent"
variant=
"soft"
>
<
Avatar
.
Image
alt=
""
className=
"object-contain p-1.5"
src=
{
application
.
logo
||
xinyuanLogo
}
/>
<
Avatar
.
Fallback
>
<
img
alt=
""
className=
"object-contain p-1.5"
src=
{
xinyuanLogo
}
/>
</
Avatar
.
Fallback
>
</
Avatar
>
</
Surface
>
</
ItemCard
.
Icon
>
<
ItemCard
.
Content
>
<
ItemCard
.
Title
title=
{
application
.
title
}
>
{
application
.
title
}
</
ItemCard
.
Title
>
{
hasDistinctDescription
?
(
<
ItemCard
.
Description
title=
{
description
}
>
{
description
}
</
ItemCard
.
Description
>
)
:
null
}
</
ItemCard
.
Content
>
{
current
?
(
<
ItemCard
.
Action
>
<
Chip
color=
"accent"
size=
"sm"
>
当前
</
Chip
>
</
ItemCard
.
Action
>
)
:
null
}
</
ItemCard
>
);
}
function
LoadingSkeleton
()
{
return
(
<
div
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
)
=>
(
<
Card
key=
{
index
}
variant=
"secondary"
>
<
Card
.
Content
className=
"flex items-center gap-3"
>
<
Skeleton
className=
"size-9"
/>
<
div
className=
"flex-1 space-y-2"
><
Skeleton
className=
"h-4 w-2/3"
/><
Skeleton
className=
"h-3 w-full"
/></
div
>
</
Card
.
Content
>
</
Card
>
<
ItemCard
key=
{
index
}
variant=
"default"
>
<
ItemCard
.
Icon
><
Skeleton
className=
"size-9"
/></
ItemCard
.
Icon
>
<
ItemCard
.
Content
className=
"gap-2"
>
<
Skeleton
className=
"h-4 w-2/3"
/>
<
Skeleton
className=
"h-3 w-full"
/>
</
ItemCard
.
Content
>
</
ItemCard
>
))
}
</
div
>
</
ItemCardGroup
>
);
}
export
default
function
ApplicationSwitcherDialog
({
open
,
onClose
,
siteInfo
,
userInfo
})
{
const
fallbacks
=
useMemo
(
()
=>
(
Array
.
isArray
(
userInfo
?.
applications
)
?
userInfo
.
applications
:
[]),
[
userInfo
],
export
default
function
ApplicationSwitcherDialog
({
open
,
onClose
,
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
(
'idle'
);
const
[
errorMessage
,
setErrorMessage
]
=
useState
(
''
);
const
currentApplicationName
=
useMemo
(
()
=>
applications
.
find
(
isCurrentApplication
)?.
name
||
APPLICATION_NAME
,
[
applications
],
const
[
status
,
setStatus
]
=
useState
(
fallbackApplications
.
length
>
0
?
'success'
:
'idle'
,
);
const
[
errorMessage
,
setErrorMessage
]
=
useState
(
''
);
const
[
applicationSearch
,
setApplicationSearch
]
=
useState
(
''
);
const
currentApplicationName
=
useMemo
(()
=>
{
return
applications
.
find
(
isCurrentApplication
)?.
name
||
CURRENT_APPLICATION_NAME
;
},
[
applications
]);
const
applicationSections
=
useMemo
(
()
=>
buildApplicationSections
(
applications
,
siteInfo
?.
group
),
[
applications
,
siteInfo
?.
group
],
);
const
currentApplicationKey
=
useMemo
(()
=>
{
for
(
const
section
of
applicationSections
)
{
const
applicationIndex
=
section
.
applications
.
findIndex
(
(
application
)
=>
application
.
name
===
currentApplicationName
,
);
if
(
applicationIndex
>=
0
)
return
`
${
section
.
key
}
:
${
applicationIndex
}
:
${
currentApplicationName
}
`
;
const
filteredApplicationSections
=
useMemo
(()
=>
{
const
keyword
=
applicationSearch
.
trim
().
toLocaleLowerCase
();
if
(
!
keyword
)
{
return
applicationSections
;
}
return
applicationSections
.
map
((
section
)
=>
({
...
section
,
applications
:
section
.
applications
.
filter
((
application
)
=>
[
application
.
title
,
application
.
description
,
application
.
name
,
].
some
((
value
)
=>
String
(
value
||
''
).
toLocaleLowerCase
().
includes
(
keyword
))),
}))
.
filter
((
section
)
=>
section
.
applications
.
length
>
0
);
},
[
applicationSearch
,
applicationSections
]);
useEffect
(()
=>
{
if
(
fallbackApplications
.
length
===
0
)
{
return
;
}
return
''
;
},
[
applicationSections
,
currentApplicationName
]);
setApplications
((
previous
)
=>
mergeApplications
(
previous
,
fallbackApplications
),
);
},
[
fallbackApplications
]);
useEffect
(()
=>
{
if
(
!
open
)
return
undefined
;
if
(
!
open
)
{
setApplicationSearch
(
''
);
return
;
}
let
cancelled
=
false
;
setStatus
((
previous
)
=>
(
previous
===
'success'
?
previous
:
'loading'
));
setErrorMessage
(
''
);
getApplications
()
.
then
((
response
)
=>
{
if
(
cancelled
)
return
;
setApplications
(
mergeApplications
(
unwrapApplications
(
response
),
fallbacks
));
const
loadApplications
=
async
()
=>
{
setStatus
((
previous
)
=>
(
previous
===
'success'
?
previous
:
'loading'
));
setErrorMessage
(
''
);
try
{
const
response
=
await
getApplications
();
const
fetchedApplications
=
unwrapApplications
(
response
);
if
(
cancelled
)
{
return
;
}
const
mergedApplications
=
mergeApplications
(
fetchedApplications
,
fallbackApplications
,
);
setApplications
(
mergedApplications
);
setStatus
(
'success'
);
})
.
catch
(()
=>
{
if
(
cancelled
)
return
;
setApplications
(
mergeApplications
([],
fallbacks
));
}
catch
(
error
)
{
if
(
cancelled
)
{
return
;
}
setApplications
(
mergeApplications
([],
fallbackApplications
));
setStatus
(
'error'
);
setErrorMessage
(
'应用列表加载失败,已回退展示当前账号上的应用信息。'
);
});
return
()
=>
{
cancelled
=
true
;
};
},
[
fallbacks
,
open
]);
}
};
loadApplications
();
return
()
=>
{
cancelled
=
true
;
};
},
[
fallbackApplications
,
open
]);
return
(
<
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
.
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
aria
-
label=
"关闭"
style=
{
{
insetInlineEnd
:
'1.75rem'
,
top
:
'1.75rem'
}
}
/>
<
Modal
.
Header
>
<
Modal
.
Heading
>
应用切换
</
Modal
.
Heading
>
<
Description
className=
"leading-5"
>
选择你可访问的应用
</
Description
>
<
div
className=
"flex w-full min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
>
<
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
.
Body
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
}
{
status
===
'loading'
&&
applications
.
length
===
0
?
<
LoadingSkeleton
/>
:
applications
.
length
>
0
?
(
<
div
className=
"space-y-6"
>
{
applicationSections
.
map
((
section
)
=>
(
<
section
key=
{
section
.
key
}
>
<
div
className=
"mb-3 flex items-center justify-between gap-3"
>
<
h3
className=
"text-sm font-medium leading-5"
>
{
section
.
label
}
</
h3
>
<
Description
>
{
section
.
applications
.
length
}
个应用
</
Description
>
</
div
>
<
div
className=
"grid gap-3 sm:grid-cols-2"
>
{
section
.
applications
.
map
((
application
,
applicationIndex
)
=>
{
const
applicationKey
=
`${section.key}:${applicationIndex}:${application.name}`
;
return
(
<
ApplicationCard
application=
{
application
}
current=
{
applicationKey
===
currentApplicationKey
}
key=
{
applicationKey
}
/>
);
})
}
</
div
>
</
section
>
))
}
</
div
>
)
:
(
<
EmptyState
size=
"md"
><
EmptyState
.
Header
><
EmptyState
.
Title
>
暂无可切换应用
</
EmptyState
.
Title
><
EmptyState
.
Description
>
当前账号还没有分配其它应用权限。
</
EmptyState
.
Description
></
EmptyState
.
Header
></
EmptyState
>
)
}
<
Modal
.
Body
className=
"flex min-h-0 overflow-hidden"
style=
{
{
marginTop
:
'0.75rem'
}
}
>
<
ScrollShadow
className=
"h-full min-h-0 flex-1 overflow-y-auto pe-1 pb-1"
hideScrollBar=
{
false
}
offset=
{
12
}
size=
{
24
}
>
{
status
===
'error'
&&
errorMessage
?
(
<
Alert
className=
"mb-4"
status=
"warning"
>
<
Alert
.
Indicator
><
ExclamationTriangleIcon
size=
{
20
}
/></
Alert
.
Indicator
>
<
Alert
.
Content
><
Alert
.
Description
>
{
errorMessage
}
</
Alert
.
Description
></
Alert
.
Content
>
</
Alert
>
)
:
null
}
{
status
===
'loading'
&&
applications
.
length
===
0
?
(
<
LoadingSkeleton
/>
)
:
filteredApplicationSections
.
length
>
0
?
(
<
div
className=
"space-y-6"
>
{
filteredApplicationSections
.
map
((
section
)
=>
(
<
ItemCardGroup
key=
{
section
.
key
}
className=
"grid-cols-1 sm:grid-cols-2"
columns=
{
2
}
layout=
"grid"
variant=
"transparent"
>
<
ItemCardGroup
.
Header
className=
"flex items-center justify-between gap-3"
>
<
ItemCardGroup
.
Title
>
{
section
.
label
}
</
ItemCardGroup
.
Title
>
<
ItemCardGroup
.
Description
className=
"mt-0"
>
{
section
.
applications
.
length
}
个应用
</
ItemCardGroup
.
Description
>
</
ItemCardGroup
.
Header
>
{
section
.
applications
.
map
((
application
)
=>
(
<
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
.
Dialog
>
</
Modal
.
Container
>
...
...
src/components/DashboardNavbar.jsx
View file @
5c519a8f
import
{
Navbar
}
from
'@heroui-pro/react/navbar'
;
import
{
Sidebar
,
useSidebar
}
from
'@heroui-pro/react/sidebar'
;
import
{
Badge
,
Button
,
Tooltip
}
from
'@heroui/react'
;
import
{
Bell
,
Moon
,
SidebarPanel
,
Sun
}
from
'./AppIcons'
;
import
{
Bell
,
SidebarPanel
}
from
'./AppIcons'
;
export
default
function
DashboardNavbar
({
notificationCount
=
0
,
onOpenNotifications
,
onThemeChange
,
theme
,
title
})
{
export
default
function
DashboardNavbar
({
notificationCount
=
0
,
onOpenNotifications
,
title
})
{
const
{
isMobile
,
isOpen
}
=
useSidebar
();
const
isDark
=
theme
===
'dark'
;
const
themeLabel
=
isDark
?
'切换至浅色模式'
:
'切换至深色模式'
;
const
normalizedNotificationCount
=
Math
.
max
(
0
,
Number
(
notificationCount
)
||
0
);
return
(
...
...
@@ -41,20 +39,6 @@ export default function DashboardNavbar({ notificationCount = 0, onOpenNotificat
</
Badge
.
Anchor
>
<
Tooltip
.
Content
>
通知消息
</
Tooltip
.
Content
>
</
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
>
</
Navbar
.
Header
>
</
Navbar
>
...
...
src/components/DashboardSidebar.jsx
View file @
5c519a8f
import
xyLogoRed
from
'@/assets/xy-logo-red.png'
;
import
xyLogoWhite
from
'@/assets/xy-logo-white.png'
;
import
{
APPLICATION_DISPLAY_NAME
}
from
'@/config/application'
;
import
{
Sidebar
}
from
'@heroui-pro/react/sidebar'
;
import
{
Avatar
,
Button
,
Dropdown
,
Label
,
Tooltip
}
from
'@heroui/react'
;
import
{
AppGrid
,
LogOut
}
from
'./AppIcons'
;
import
{
Sheet
}
from
'@heroui-pro/react'
;
import
{
Sidebar
,
useSidebar
}
from
'@heroui-pro/react/sidebar'
;
import
{
Avatar
,
Button
,
Disclosure
,
Dropdown
,
Label
}
from
'@heroui/react'
;
import
{
AppGrid
,
ChevronDown
,
LogOut
}
from
'./AppIcons'
;
function
getUserDisplay
(
userInfo
)
{
const
displayName
=
userInfo
?.
display_name
||
userInfo
?.
username
||
'当前用户'
;
const
username
=
userInfo
?.
username
||
userInfo
?.
email
||
'已登录'
;
return
{
displayName
,
username
,
...
...
@@ -17,22 +18,39 @@ function getUserDisplay(userInfo) {
function
SidebarUserMenu
({
userInfo
,
onLogout
})
{
const
user
=
getUserDisplay
(
userInfo
);
return
(
<
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"
>
{
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
>
<
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-description"
>
{
user
.
username
}
</
span
>
</
span
>
</
Dropdown
.
Trigger
>
<
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"
>
<
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
.
Menu
>
</
Dropdown
.
Popover
>
...
...
@@ -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
(
<>
<
Sidebar
.
Header
>
<
div
className=
"app-sidebar-brand rc-sidebar-brand"
>
<
div
className=
"app-sidebar-brand-mark rc-sidebar-brand-mark"
>
<
img
alt=
"鑫元"
className=
"rc-sidebar-logo rc-sidebar-logo--light"
src=
{
xyLogoRed
}
/>
<
img
alt=
"鑫元"
className=
"rc-sidebar-logo rc-sidebar-logo--dark"
src=
{
xyLogoWhite
}
/>
<
img
alt=
"鑫元"
className=
"rc-sidebar-logo"
src=
{
xyLogoWhite
}
/>
</
div
>
<
div
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
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
className=
"app-sidebar-app-switcher rc-sidebar-app-switcher"
data
-
sidebar=
"label"
>
<
Tooltip
delay=
{
0
}
>
<
Button
isIconOnly
aria
-
label=
"切换应用"
className=
"app-sidebar-app-trigger rc-sidebar-app-trigger"
size=
"sm"
variant=
"ghost"
onPress=
{
onOpenApplicationSwitcher
}
>
<
AppGrid
size=
{
16
}
/>
</
Button
>
<
Tooltip
.
Content
>
切换应用
</
Tooltip
.
Content
>
</
Tooltip
>
<
div
className=
"app-sidebar-app-switcher rc-sidebar-app-switcher"
data
-
sidebar=
"label"
>
<
Button
isIconOnly
aria
-
label=
"切换应用"
className=
"app-sidebar-app-trigger rc-sidebar-app-trigger"
size=
"sm"
variant=
"ghost"
onPress=
{
onOpenApplicationSwitcher
}
>
<
AppGrid
size=
{
16
}
/>
</
Button
>
</
div
>
</
div
>
</
Sidebar
.
Header
>
<
Sidebar
.
Content
>
{
menuGroups
.
map
((
group
)
=>
(
<
Sidebar
.
Group
key=
{
group
.
title
}
>
{
group
.
showLabel
!==
false
?
<
Sidebar
.
GroupLabel
>
{
group
.
title
}
</
Sidebar
.
GroupLabel
>
:
null
}
<
Sidebar
.
Menu
aria
-
label=
{
group
.
title
}
>
{
group
.
items
.
map
((
item
)
=>
{
const
Icon
=
item
.
icon
;
return
(
<
Sidebar
.
MenuItem
href=
{
item
.
path
}
isCurrent=
{
pathname
===
item
.
path
}
key=
{
item
.
path
}
textValue=
{
item
.
name
}
tooltip=
{
item
.
name
}
>
<
Sidebar
.
MenuIcon
><
Icon
size=
{
16
}
/></
Sidebar
.
MenuIcon
>
<
Sidebar
.
MenuLabel
>
{
item
.
name
}
</
Sidebar
.
MenuLabel
>
</
Sidebar
.
MenuItem
>
);
})
}
</
Sidebar
.
Menu
>
</
Sidebar
.
Group
>
group
.
showLabel
===
false
?
(
<
Sidebar
.
Group
key=
{
group
.
title
}
>
{
renderMenu
(
group
)
}
</
Sidebar
.
Group
>
)
:
renderGroup
(
group
)
))
}
</
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
)
{
return
(
<>
<
Sidebar
className=
"app-sidebar-glass rc-sidebar"
><
SidebarContents
{
...
props
}
/></
Sidebar
>
<
Sidebar
.
Mobile
className=
"app-sidebar-glass rc-mobile-sidebar"
>
<
h2
className=
"sr-only"
slot=
"title"
>
{
APPLICATION_DISPLAY_NAME
}
导航
</
h2
>
<
Sidebar
className=
"app-sidebar-glass rc-sidebar"
>
<
SidebarContents
{
...
props
}
/>
</
Sidebar
.
Mobile
>
</
Sidebar
>
<
AccessibleMobileSidebar
{
...
props
}
/>
</>
);
}
src/components/DataTable.jsx
0 → 100644
View file @
5c519a8f
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
>
);
}
src/components/FilterSelect.jsx
0 → 100644
View file @
5c519a8f
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
}
/>
);
}
src/components/ReferenceSearchSelect.jsx
View file @
5c519a8f
import
XinyuanAutocomplete
from
'@/components/XinyuanAutocomplete'
;
import
{
queryRecords
}
from
'@/services/DataService'
;
import
{
Button
,
Label
}
from
'@heroui/react'
;
import
{
Label
}
from
'@heroui/react'
;
import
{
useEffect
,
useMemo
,
useRef
,
useState
}
from
'react'
;
const
DEFAULT_ID_LOOKUP_CHUNK_SIZE
=
50
;
...
...
@@ -574,27 +574,22 @@ export default function ReferenceSearchSelect({
errorText=
{
error
}
inputValue=
{
query
}
isDisabled=
{
disabled
}
isClearable=
{
allowClear
}
isLoading=
{
loading
}
isOpen=
{
active
}
options=
{
menuOptions
}
placement=
{
optionsAnchor
||
'bottom start'
}
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
}
selectionMode=
{
multiple
?
'multiple'
:
'single'
}
shouldFilter=
{
false
}
valueText=
{
displayText
}
variant=
{
variant
}
onInputChange=
{
setQuery
}
onClear=
{
clearSelection
}
onOpenChange=
{
handleOpenChange
}
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
}
>
{
option
.
label
}
</
Label
>
...
...
src/components/TableActionButton.jsx
0 → 100644
View file @
5c519a8f
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
};
src/components/XinyuanAutocomplete.jsx
View file @
5c519a8f
...
...
@@ -39,11 +39,13 @@ export default function XinyuanAutocomplete({
fullWidth
=
true
,
inputValue
,
isDisabled
=
false
,
isClearable
=
false
,
isLoading
=
false
,
isOpen
,
isRequired
=
false
,
name
,
onInputChange
,
onClear
,
onOpenChange
,
onSelectionChange
,
options
=
[],
...
...
@@ -52,6 +54,7 @@ export default function XinyuanAutocomplete({
popoverHeader
,
renderOption
,
searchPlaceholder
=
'输入关键词搜索'
,
showSearch
=
true
,
selectedKey
,
selectedKeys
,
selectionMode
=
'single'
,
...
...
@@ -101,6 +104,7 @@ export default function XinyuanAutocomplete({
isOpen=
{
isOpen
}
isRequired=
{
isRequired
}
name=
{
name
}
onClear=
{
onClear
}
selectionMode=
{
isMultiple
?
'single'
:
selectionMode
}
variant=
{
variant
}
onOpenChange=
{
onOpenChange
}
...
...
@@ -115,6 +119,11 @@ export default function XinyuanAutocomplete({
{
resolvedValueText
}
</
span
>
</
Autocomplete
.
Value
>
{
isClearable
?
(
<
Autocomplete
.
ClearButton
aria
-
label=
{
`清除${ariaLabel || placeholder}`
}
/>
)
:
null
}
<
Autocomplete
.
Indicator
>
<
ChevronDown
size=
{
16
}
style=
{
FIELD_SUFFIX_ICON_STYLE
}
/>
</
Autocomplete
.
Indicator
>
...
...
@@ -126,21 +135,23 @@ export default function XinyuanAutocomplete({
onInputChange=
{
onInputChange
}
>
{
popoverHeader
}
<
SearchField
aria
-
label=
{
searchPlaceholder
}
fullWidth
variant=
{
variant
}
>
<
SearchField
.
Group
>
<
SearchField
.
SearchIcon
>
<
Search
size=
{
16
}
/>
</
SearchField
.
SearchIcon
>
<
SearchField
.
Input
autoFocus
placeholder=
{
searchPlaceholder
}
/>
<
SearchField
.
ClearButton
aria
-
label=
"清空搜索"
>
<
Close
size=
{
14
}
/>
</
SearchField
.
ClearButton
>
</
SearchField
.
Group
>
</
SearchField
>
{
showSearch
?
(
<
SearchField
aria
-
label=
{
searchPlaceholder
}
fullWidth
variant=
{
variant
}
>
<
SearchField
.
Group
>
<
SearchField
.
SearchIcon
>
<
Search
size=
{
16
}
/>
</
SearchField
.
SearchIcon
>
<
SearchField
.
Input
autoFocus
placeholder=
{
searchPlaceholder
}
/>
<
SearchField
.
ClearButton
aria
-
label=
"清空搜索"
>
<
Close
size=
{
14
}
/>
</
SearchField
.
ClearButton
>
</
SearchField
.
Group
>
</
SearchField
>
)
:
null
}
<
ListBox
aria
-
label=
{
ariaLabel
||
placeholder
}
dependencies=
{
[
...
...
@@ -162,6 +173,7 @@ export default function XinyuanAutocomplete({
return
(
<
ListBox
.
Item
className=
"min-w-0"
id=
{
option
.
id
}
isDisabled=
{
option
.
isDisabled
}
isSelected=
{
isSelected
}
...
...
@@ -170,14 +182,14 @@ export default function XinyuanAutocomplete({
{
renderOption
?
(
renderOption
(
option
)
)
:
(
<
Label
className=
"truncate"
title=
{
option
.
label
}
>
<
Label
className=
"
min-w-0 flex-1
truncate"
title=
{
option
.
label
}
>
{
option
.
label
}
</
Label
>
)
}
{
selectionMode
===
'multiple'
?
(
<
span
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
}
</
span
>
...
...
src/components/record-workbench/index.jsx
View file @
5c519a8f
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
{
formatFieldValue
,
getFieldValue
}
from
'@/utils/subjectView'
;
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
{
Alert
,
Button
,
D
ropdown
,
Label
,
Pagination
,
SearchField
,
Spinner
,
Surface
,
Tabs
,
Tooltip
,
buttonVariants
}
from
'@heroui/react'
;
import
{
Alert
,
Button
,
D
isclosure
,
Dropdown
,
Input
,
Label
,
SearchField
,
Spinner
,
Surface
,
Tabs
,
Tooltip
,
buttonVariants
}
from
'@heroui/react'
;
import
{
useLayoutEffect
,
useMemo
,
useRef
,
useState
}
from
'react'
;
import
XinyuanAutocomplete
from
'@/components/XinyuanAutocomplete'
;
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_BUTTON_LAYOUT_CLASS
=
'grid !size-8 shrink-0 place-items-center p-0'
;
function
getRecordId
(
record
)
{
return
String
(
record
?.
id
||
record
?.
_id
||
record
?.
name
||
''
);
...
...
@@ -27,6 +29,16 @@ function getViewDisplayLabel(view) {
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) {
const values = records.map((record) => {
const value = formatFieldValue(getFieldValue(record?.raw || record, column.field), column.fieldDef) || '-';
...
...
@@ -83,31 +95,24 @@ function RowActions({ actions, context }) {
const overflowActions = orderedActions.length >= 4 ? orderedActions.slice(2) : [];
return (
<
div className="flex items-center justify-start gap-2"
>
<
TableActionGroup
>
{visibleActions.map((action) => {
const Icon = action.icon;
return (
<Tooltip delay={0} key={action.key}>
<Button
isIconOnly
aria-label={action.label}
isDisabled={isActionDisabled(action, context)}
size="sm"
variant={isDestructiveAction(action) ? 'danger-soft' : 'tertiary'}
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>
<TableActionButton
key={action.key}
icon={action.icon}
label={action.label}
tone={isDestructiveAction(action) ? 'delete' : 'view'}
isDisabled={isActionDisabled(action, context)}
onPress={() => action.onClick?.(context)}
/>
);
})}
{overflowActions.length > 0 ? (
<Dropdown>
<Dropdown.Trigger
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="更多操作"
>
<More className={ACTION_ICON_CLASS} size={16} aria-hidden="true" />
...
...
@@ -135,43 +140,28 @@ function RowActions({ actions, context }) {
</Dropdown.Popover>
</Dropdown>
) : null}
</
div
>
</
TableActionGroup
>
);
}
function WorkbenchPagination({ pagination }) {
if (!pagination) return null;
if (!pagination) {
return null;
}
const pageSize = Number(pagination.pageSize || 10);
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 (
<div className="min-w-0">
<Pagination aria-label="列表分页" size="sm">
<Pagination.Summary>
共 {totalRecords} 条,第 {totalRecords ? currentPage + 1 : 0} / {totalRecords ? totalPages : 0} 页
</Pagination.Summary>
<Pagination.Content>
<Pagination.Item>
<Pagination.Previous isDisabled={isDisabled || currentPage <= 0} onPress={() => pagination.onPageChange?.(currentPage - 1)}>
<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>
<DataTablePagination
isDisabled={Boolean(pagination.disabled)}
page={Math.max(0, Number(pagination.page || 0))}
pageSize={pageSize}
pageSizeOptions={pagination.pageSizeOptions || [20, 50, 100]}
total={totalRecords}
onPageChange={pagination.onPageChange}
onPageSizeChange={pagination.onPageSizeChange}
/>
);
}
...
...
@@ -185,6 +175,7 @@ export default function RecordWorkbench({
onFiltersChange,
onClearFilters,
title,
listTitle,
columns = EMPTY_ARRAY,
records = EMPTY_ARRAY,
selectedIds = EMPTY_ARRAY,
...
...
@@ -314,15 +305,57 @@ export default function RecordWorkbench({
else onSelectionChange?.(Array.from(selection).map(String));
};
const listSearchField = null;
const filterCardSchema = filterSchema;
const toolbarSearchField = filterSchema.find((field) => field.icon === 'search' || field.key === 'keyword');
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;
return (
<div className="flex min-w-0 flex-col gap-6">
{hasViewSwitch ? (
<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.List aria-label="数据视图" className="w-max min-w-max">
{views.map((view) => (
...
...
@@ -337,43 +370,75 @@ export default function RecordWorkbench({
</div>
) : null}
{filterCardSchema.length > 0 ? (
<Surface className="p-6">
<div className="flex flex-col gap-3 md:flex-row md:items-end">
<div className="grid min-w-0 flex-1 gap-3 md:grid-cols-3">
{filterCardSchema.map((field) => field.type === 'select' ? (
<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) : '')} />
) : (
<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>
))}
{structuredFilterFields.length > 0 ? (
<Surface className="rc-filter-surface p-6" variant="default">
<Disclosure isExpanded={filtersExpanded} onExpandedChange={setFiltersExpanded}>
<div className="grid min-w-0 gap-3 xl:grid-cols-[minmax(0,1fr)_auto] xl:items-end">
<div className="grid min-w-0 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{primaryFilterFields.map(renderFilterField)}
</div>
{onClearFilters ? (
<div className="flex shrink-0 items-center">
<div className="flex min-w-0 items-end justify-end gap-2">
{onClearFilters ? (
<Tooltip delay={0}>
<Button isIconOnly aria-label="重置筛选" size="sm" variant="ghost" onPress={onClearFilters}><Reload size={16} /></Button>
<Tooltip.Content>重置筛选</Tooltip.Content>
</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}
</div>
</Disclosure>
</Surface>
) : null}
<section className="flex min-w-0 flex-col gap-3">
{listSearchField || visibleToolbarActions.length > 0 ? (
<header className="flex min-w-0 flex-col gap-3 md:flex-row md:items-center md:justify-between">
{listSearchField ? (
<SearchField aria-label={listSearchField.label} className="w-full md:w-96" value={filters[listSearchField.key] || ''} onChange={(value) => onFiltersChange?.(listSearchField.key, value)}>
<SearchField.Group><SearchField.SearchIcon><Search size={16} /></SearchField.SearchIcon><SearchField.Input placeholder={listSearchField.placeholder} /><SearchField.ClearButton aria-label="清空搜索" /></SearchField.Group>
<section className="rc-list-stack">
{listTitle ? (
<header className="rc-page-toolbar">
<h2 className="rc-section-title">{listTitle}</h2>
{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}
</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>
) : 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>
) : 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="r
c-list-data-stack rc-data-table-sticky r
elative min-w-0" ref={gridWrapRef}>
{accessDenied ? (
<DataGridEmpty title="当前账号没有可访问的数据视图" />
) : (
...
...
@@ -387,7 +452,7 @@ export default function RecordWorkbench({
data={records}
getRowId={getRecordId}
renderEmptyState={() => <DataGridEmpty loading={states.loading} title={states.emptyTitle || '暂无数据'} />}
scrollContainerClassName="overflow-x-auto"
scrollContainerClassName="
data-table-scroll
overflow-x-auto"
selectedKeys={new Set(selectedIds)}
selectionMode={onSelectionChange ? 'multiple' : 'none'}
variant="primary"
...
...
src/config/application.js
View file @
5c519a8f
...
...
@@ -3,7 +3,6 @@ export const APPLICATION_NAME = 'integrity_practice_management';
export
const
APPLICATION_ROUTE_NAME
=
APPLICATION_NAME
;
export
const
AUTHORIZATION_APPLICATION_NAME
=
APPLICATION_NAME
;
export
const
APPLICATION_BASE_PATH
=
`/
${
APPLICATION_NAME
}
/`
;
export
const
THEME_STORAGE_KEY
=
'xinyuan_color_theme'
;
export
const
ROUTE_TITLES
=
Object
.
freeze
({
'/self_check_the_results_of_the_working_draft'
:
'自查底稿填写表'
,
...
...
src/design-system/shell.css
View file @
5c519a8f
...
...
@@ -435,6 +435,41 @@
gap
:
var
(
--rc-page-module-gap
);
}
.rc-section-title
{
margin
:
0
;
color
:
var
(
--rc-text-primary
);
font-size
:
1.125rem
;
font-weight
:
var
(
--rc-font-bold
);
line-height
:
1.5rem
;
letter-spacing
:
0
;
}
.rc-module-title
{
margin
:
0
;
color
:
var
(
--rc-text-primary
);
font-size
:
1rem
;
font-weight
:
var
(
--rc-font-semibold
);
line-height
:
1.5rem
;
letter-spacing
:
0
;
}
.rc-modal-header-actions
{
align-self
:
flex-start
;
display
:
flex
;
flex
:
0
0
auto
;
flex-wrap
:
wrap
;
justify-content
:
flex-end
;
gap
:
0.5rem
;
margin-inline-start
:
auto
;
max-width
:
100%
;
}
@media
(
max-width
:
40rem
)
{
.rc-modal-header-actions
{
flex-basis
:
100%
;
}
}
.rc-page-grid
{
min-width
:
0
;
gap
:
var
(
--rc-page-module-gap
);
...
...
@@ -453,7 +488,7 @@
grid-column
:
1
/
-1
;
}
/*
Recharts keeps
keyboard navigation while pointer focus stays visually quiet. */
/*
Charts keep
keyboard navigation while pointer focus stays visually quiet. */
.rc-chart-interaction
:focus:not
(
:focus-visible
),
.rc-chart-interaction
:focus:not
(
:focus-visible
)
{
outline
:
none
;
...
...
@@ -465,6 +500,10 @@
outline-offset
:
2px
;
}
.rc-echarts-tooltip
{
position
:
fixed
!important
;
}
.rc-kpi-grid
{
display
:
grid
;
grid-template-columns
:
minmax
(
0
,
1
fr
);
...
...
@@ -509,175 +548,37 @@
box-shadow
:
var
(
--rc-shadow-card
);
}
.rc-dashboard-card
{
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
(
100
dvh
-
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
);
.rc-filter-surface
{
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
{
min-height
:
3rem
;
border-radius
:
var
(
--rc-radius-container
);
}
.rc-reference-select-option__content
{
width
:
100%
;
max-width
:
100%
;
overflow
:
hidden
;
/* Filter fields paint their HeroUI focus/invalid ring outside the disclosure
panel. The workbench unmounts this content while the filter is collapsed. */
.rc-filter-disclosure-content
{
overflow
:
visible
;
}
.rc-reference-select-option__label
{
display
:
block
;
width
:
100%
;
max-width
:
100%
;
overflow
:
hidden
;
text-overflow
:
ellipsis
;
white-space
:
nowrap
;
.rc-dashboard-card
{
border-radius
:
1.5rem
;
}
.rc-reference-select-options
.rc-reference-select-option.list-box-item
:is
(
[
aria-selected
=
'true'
],
[
data-selected
=
'true'
]
)
{
color
:
var
(
--rc-brand-primary
);
background-color
:
var
(
--accent-soft
);
.rc-workspace-module
{
border-radius
:
1.5rem
;
border
:
1px
solid
var
(
--border
);
background
:
transparent
;
box-shadow
:
none
;
}
@media
(
hover
:
hover
)
{
.rc-reference-select-options
.rc-reference-select-option.list-box-item
:is
(
[
aria-selected
=
'true'
],
[
data-selected
=
'true'
]
)
:hover
{
color
:
var
(
--rc-brand-dark
);
background-color
:
var
(
--accent-soft-hover
);
}
.rc-empty-state-message
{
color
:
var
(
--rc-text-empty
);
font-size
:
var
(
--text-sm
);
font-weight
:
var
(
--font-weight-normal
);
}
@media
(
min-width
:
640px
)
{
.rc-dialog-panel--4xl.modal__dialog
{
width
:
calc
(
100vw
-
5re
m
);
}
.rc-empty-field-text
{
color
:
var
(
--rc-text-empty
);
font-size
:
var
(
--text-s
m
);
font-weight
:
var
(
--font-weight-normal
);
}
/* HeroUI Pro KPI.Icon keeps its compound role; this class supplies Xinyuan tones. */
...
...
@@ -822,6 +723,116 @@
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
(
60
dvh
,
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
(
54
dvh
,
32rem
);
}
}
@media
(
min-width
:
769px
)
{
.action-bar
:has
(
.rc-data-grid-action-bar
)
{
inset-inline
:
0
;
}
body
:has
(
.rc-app-shell
[
data-state
=
'expanded'
])
.action-bar
:has
(
.rc-data-grid-action-bar
)
{
inset-inline-start
:
var
(
--rc-sidebar-width
);
}
}
@media
(
min-width
:
80rem
)
{
.rc-chart-grid
{
grid-template-columns
:
repeat
(
2
,
minmax
(
0
,
1
fr
));
...
...
@@ -1029,6 +1040,23 @@
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-mobile-sidebar
.sidebar__content
{
min-height
:
0
;
...
...
@@ -1352,12 +1380,12 @@
place-items
:
center
;
padding
:
0
;
border-radius
:
50%
;
color
:
var
(
--foreground
);
color
:
var
(
--
rc-sidebar-
foreground
);
background-color
:
transparent
;
line-height
:
1
;
--button-bg-hover
:
var
(
--
accent-soft
);
--button-bg-pressed
:
var
(
--
accent-soft
);
--button-fg
:
var
(
--foreground
);
--button-bg-hover
:
var
(
--
rc-sidebar-item-selected
);
--button-bg-pressed
:
var
(
--
rc-sidebar-item-selected
);
--button-fg
:
var
(
--
rc-sidebar-item-selected-
foreground
);
}
.app-sidebar-app-trigger
.anticon
{
...
...
@@ -1377,16 +1405,16 @@
@media
(
hover
:
hover
)
{
.app-sidebar-app-trigger.button
:hover
,
.app-sidebar-app-trigger.button
[
data-hovered
=
'true'
]
{
color
:
var
(
--foreground
);
background-color
:
var
(
--
accent-soft
);
color
:
var
(
--
rc-sidebar-item-selected-
foreground
);
background-color
:
var
(
--
rc-sidebar-item-selected
);
}
}
.app-sidebar-app-trigger.button
:active
,
.app-sidebar-app-trigger.button
[
data-pressed
=
'true'
],
.app-sidebar-app-trigger.button
[
aria-expanded
=
'true'
]
{
color
:
var
(
--foreground
);
background-color
:
var
(
--
accent-soft
);
color
:
var
(
--
rc-sidebar-item-selected-
foreground
);
background-color
:
var
(
--
rc-sidebar-item-selected
);
}
.app-sidebar-app-dialog
{
...
...
@@ -1570,3 +1598,89 @@
border-right
:
0
;
}
}
/* Fixed split-shell theme: dark navigation, permanently light content canvas. */
.template-dashboard-shell
.app-sidebar-glass
,
.rc-mobile-sidebar.app-sidebar-glass
{
color
:
var
(
--rc-sidebar-foreground
);
border-color
:
var
(
--rc-sidebar-border
)
!important
;
background-color
:
var
(
--rc-sidebar-background
)
!important
;
}
.template-dashboard-shell
.sidebar__menu-item
[
data-current
=
'true'
]
.sidebar__menu-item-content
,
.rc-mobile-sidebar
.sidebar__menu-item
[
data-current
=
'true'
]
.sidebar__menu-item-content
{
background
:
var
(
--rc-sidebar-item-selected
);
}
.template-dashboard-shell
.sidebar__menu-item
[
data-current
=
'true'
]
.sidebar__menu-item-content
,
.template-dashboard-shell
.sidebar__menu-item
[
data-current
=
'true'
]
.sidebar__menu-item-content
*,
.rc-mobile-sidebar
.sidebar__menu-item
[
data-current
=
'true'
]
.sidebar__menu-item-content
,
.rc-mobile-sidebar
.sidebar__menu-item
[
data-current
=
'true'
]
.sidebar__menu-item-content
*
{
color
:
var
(
--rc-sidebar-item-selected-foreground
)
!important
;
}
.template-dashboard-shell
.sidebar__menu-item
:hover
.sidebar__menu-item-content
,
.template-dashboard-shell
.sidebar__menu-item
[
data-hovered
=
'true'
]
.sidebar__menu-item-content
,
.rc-mobile-sidebar
.sidebar__menu-item
:hover
.sidebar__menu-item-content
,
.rc-mobile-sidebar
.sidebar__menu-item
[
data-hovered
=
'true'
]
.sidebar__menu-item-content
,
.app-sidebar-user-trigger.dropdown__trigger
:hover
,
.app-sidebar-user-trigger.dropdown__trigger
[
data-hovered
=
'true'
]
{
background
:
var
(
--rc-sidebar-item-hover
);
}
.app-sidebar-user-trigger.dropdown__trigger
[
aria-expanded
=
'true'
],
.app-sidebar-app-trigger.button
[
data-pressed
=
'true'
],
.app-sidebar-app-trigger.button
[
aria-expanded
=
'true'
]
{
background
:
var
(
--rc-sidebar-item-selected
);
}
.app-sidebar-user-avatar
,
.app-sidebar-app-trigger.button
,
.app-sidebar-user-trigger.dropdown__trigger
{
color
:
var
(
--rc-sidebar-foreground
);
background
:
transparent
;
}
.app-sidebar-user-avatar
{
background
:
var
(
--rc-sidebar-item-hover
);
}
.template-dashboard-shell
.sidebar__group-label
,
.rc-mobile-sidebar
.sidebar__group-label
,
.template-dashboard-shell
.sidebar__menu-icon
,
.rc-mobile-sidebar
.sidebar__menu-icon
,
.app-sidebar-user-description
{
color
:
var
(
--rc-sidebar-muted
);
}
.template-dashboard-shell
.sidebar__menu-label
,
.rc-mobile-sidebar
.sidebar__menu-label
,
.app-sidebar-title
,
.app-sidebar-user-name
,
.app-sidebar-app-trigger.button
{
color
:
var
(
--rc-sidebar-foreground
);
}
.app-sidebar-user-trigger.dropdown__trigger
:focus-visible
,
.app-sidebar-app-trigger.button
:focus-visible
,
.template-dashboard-shell
.sidebar__menu-item
:focus-visible
,
.rc-mobile-sidebar
.sidebar__menu-item
:focus-visible
{
outline
:
2px
solid
var
(
--rc-sidebar-focus
);
outline-offset
:
2px
;
}
src/design-system/theme.css
View file @
5c519a8f
...
...
@@ -11,38 +11,37 @@
[
data-theme
=
"light"
],
[
data-theme
=
"default"
]
{
/* 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
);
--background
:
oklch
(
97.02%
0.00
45
27.03
);
--border
:
oklch
(
90.00%
0.00
45
27.03
);
--background
:
oklch
(
97.02%
0.00
00
29.20
);
--border
:
oklch
(
90.00%
0.00
00
29.20
);
--danger
:
oklch
(
45.32%
0.1661
27.03
);
--danger-foreground
:
oklch
(
99.11%
0
0
);
/* HeroUI control aliases: primary = L1, secondary = L2. */
--default
:
var
(
--surface-secondary
);
--default-foreground
:
var
(
--surface-secondary-foreground
);
--field-background
:
var
(
--surface
);
--default
:
oklch
(
94.00%
0.0000
29.20
);
--default-foreground
:
oklch
(
21.03%
0.0059
29.20
);
--field-background
:
oklch
(
100.00%
0.0000
29.20
);
--field-border
:
transparent
;
--field-foreground
:
var
(
--surface-foreground
);
--field-placeholder
:
oklch
(
55.17%
0.00
90
27.03
);
--focus
:
oklch
(
45.32%
0.1661
27.03
);
--foreground
:
oklch
(
21.03%
0.00
45
27.03
);
--muted
:
oklch
(
55.17%
0.00
90
27.03
);
--overlay
:
var
(
--surface
);
--overlay-foreground
:
var
(
--surface-foreground
);
--scrollbar
:
oklch
(
87.10%
0.00
45
27.03
);
--segment
:
oklch
(
100.00%
0.00
45
27.03
);
--segment-foreground
:
oklch
(
21.03%
0.00
45
27.03
);
--separator
:
oklch
(
92.00%
0.00
45
27.03
);
--success
:
oklch
(
73.29%
0.19
52
123.59
);
--success-foreground
:
oklch
(
21.03%
0.0059
123.
59
);
--surface
:
oklch
(
100.00%
0.00
22
27.03
);
--surface-foreground
:
oklch
(
21.03%
0.00
45
27.03
);
--surface-secondary
:
oklch
(
95.24%
0.00
36
27.03
);
--surface-secondary-foreground
:
oklch
(
21.03%
0.00
45
27.03
);
--surface-tertiary
:
oklch
(
93.73%
0.00
36
27.03
);
--surface-tertiary-foreground
:
oklch
(
21.03%
0.00
45
27.03
);
--warning
:
oklch
(
78.19%
0.15
99
45.11
);
--warning-foreground
:
oklch
(
21.03%
0.0059
45.
11
);
--field-foreground
:
oklch
(
21.03%
0.0000
29.20
);
--field-placeholder
:
oklch
(
55.17%
0.00
00
29.20
);
--focus
:
oklch
(
57.14%
0.2156
29.20
);
--foreground
:
oklch
(
21.03%
0.00
00
29.20
);
--muted
:
oklch
(
55.17%
0.00
00
29.20
);
--overlay
:
oklch
(
100.00%
0.0000
29.20
);
--overlay-foreground
:
oklch
(
21.03%
0.0000
29.20
);
--scrollbar
:
oklch
(
87.10%
0.00
00
29.20
);
--segment
:
oklch
(
100.00%
0.00
00
29.20
);
--segment-foreground
:
oklch
(
21.03%
0.00
00
29.20
);
--separator
:
oklch
(
92.00%
0.00
00
29.20
);
--success
:
oklch
(
73.29%
0.19
35
123.85
);
--success-foreground
:
oklch
(
21.03%
0.0059
123.
85
);
--surface
:
oklch
(
100.00%
0.00
00
29.20
);
--surface-foreground
:
oklch
(
21.03%
0.00
00
29.20
);
--surface-secondary
:
oklch
(
95.24%
0.00
00
29.20
);
--surface-secondary-foreground
:
oklch
(
21.03%
0.00
00
29.20
);
--surface-tertiary
:
oklch
(
93.73%
0.00
00
29.20
);
--surface-tertiary-foreground
:
oklch
(
21.03%
0.00
00
29.20
);
--warning
:
oklch
(
78.19%
0.15
85
45.37
);
--warning-foreground
:
oklch
(
21.03%
0.0059
45.
37
);
/* Border Radius */
--radius
:
0.5rem
;
...
...
@@ -51,42 +50,90 @@
/* Font Family */
/* Make sure to load Inter font in your app */
--font-sans
:
var
(
--font-inter
);
/*
* Xinyuan fixed split-shell contract.
* The application canvas always uses this light theme. The Sidebar is the
* only persistent dark plane and must consume these semantic tokens.
*/
--xinyuan-sidebar-background
:
oklch
(
16%
0.020
255
);
--xinyuan-sidebar-border
:
oklch
(
26%
0.022
255
);
--xinyuan-sidebar-foreground
:
oklch
(
97%
0.004
255
);
--xinyuan-sidebar-muted
:
oklch
(
76%
0.018
255
);
--xinyuan-sidebar-item-hover
:
oklch
(
22%
0.026
255
);
--xinyuan-sidebar-item-selected
:
oklch
(
28%
0.016
255
);
--xinyuan-sidebar-item-selected-foreground
:
oklch
(
99%
0.002
255
);
--xinyuan-sidebar-focus
:
oklch
(
78%
0.105
29.20
);
/*
* ECharts is the required chart renderer for Xinyuan applications.
* Ant Design remains the icon source. Resolve these CSS variables to
* concrete colors before assigning them to an ECharts option object.
*/
/* 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
,
[
data-theme
=
"dark"
]
{
color-scheme
:
dark
;
/* 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
);
--background
:
oklch
(
12.00%
0.00
45
27.03
);
--border
:
oklch
(
28.00%
0.00
45
27.03
);
--danger
:
oklch
(
62
.00%
0.1600
27.03
);
--background
:
oklch
(
12.00%
0.00
00
29.20
);
--border
:
oklch
(
28.00%
0.00
00
29.20
);
--danger
:
oklch
(
62
%
0.16
27.03
);
--danger-foreground
:
oklch
(
99.11%
0
0
);
/* HeroUI control aliases: primary = L1, secondary = L2. */
--default
:
var
(
--surface-secondary
);
--default-foreground
:
var
(
--surface-secondary-foreground
);
--field-background
:
var
(
--surface
);
--default
:
oklch
(
27.40%
0.0000
29.20
);
--default-foreground
:
oklch
(
99.11%
0
0
);
--field-background
:
oklch
(
21.03%
0.0000
29.20
);
--field-border
:
transparent
;
--field-foreground
:
var
(
--surface-foreground
);
--field-placeholder
:
oklch
(
70.50%
0.00
90
27.03
);
--focus
:
oklch
(
45.32%
0.1661
27.03
);
--foreground
:
oklch
(
99.11%
0.00
45
27.03
);
--muted
:
oklch
(
70.50%
0.00
90
27.03
);
--overlay
:
var
(
--surface
);
--overlay-foreground
:
var
(
--surface-foreground
);
--scrollbar
:
oklch
(
70.50%
0.00
45
27.03
);
--segment
:
oklch
(
39.64%
0.00
45
27.03
);
--segment-foreground
:
oklch
(
99.11%
0.00
45
27.03
);
--separator
:
oklch
(
25.00%
0.00
45
27.03
);
--success
:
oklch
(
73.29%
0.19
52
123.59
);
--success-foreground
:
oklch
(
21.03%
0.0059
123.
59
);
--surface
:
oklch
(
21.03%
0.00
90
27.03
);
--surface-foreground
:
oklch
(
99.11%
0.00
45
27.03
);
--surface-secondary
:
oklch
(
25.70%
0.00
67
27.03
);
--surface-secondary-foreground
:
oklch
(
99.11%
0.00
45
27.03
);
--surface-tertiary
:
oklch
(
27.21%
0.00
67
27.03
);
--surface-tertiary-foreground
:
oklch
(
99.11%
0.00
45
27.03
);
--warning
:
oklch
(
82.03%
0.1
400
49.12
);
--warning-foreground
:
oklch
(
21.03%
0.0059
49.
12
);
--field-foreground
:
oklch
(
99.11%
0.0000
29.20
);
--field-placeholder
:
oklch
(
70.50%
0.00
00
29.20
);
--focus
:
oklch
(
57.14%
0.2156
29.20
);
--foreground
:
oklch
(
99.11%
0.00
00
29.20
);
--muted
:
oklch
(
70.50%
0.00
00
29.20
);
--overlay
:
oklch
(
21.03%
0.0000
29.20
);
--overlay-foreground
:
oklch
(
99.11%
0.0000
29.20
);
--scrollbar
:
oklch
(
70.50%
0.00
00
29.20
);
--segment
:
oklch
(
39.64%
0.00
00
29.20
);
--segment-foreground
:
oklch
(
99.11%
0.00
00
29.20
);
--separator
:
oklch
(
25.00%
0.00
00
29.20
);
--success
:
oklch
(
73.29%
0.19
35
123.85
);
--success-foreground
:
oklch
(
21.03%
0.0059
123.
85
);
--surface
:
oklch
(
21.03%
0.00
00
29.20
);
--surface-foreground
:
oklch
(
99.11%
0.00
00
29.20
);
--surface-secondary
:
oklch
(
25.70%
0.00
00
29.20
);
--surface-secondary-foreground
:
oklch
(
99.11%
0.00
00
29.20
);
--surface-tertiary
:
oklch
(
27.21%
0.00
00
29.20
);
--surface-tertiary-foreground
:
oklch
(
99.11%
0.00
00
29.20
);
--warning
:
oklch
(
82.03%
0.1
388
49.38
);
--warning-foreground
:
oklch
(
21.03%
0.0059
49.
38
);
}
src/design-system/tokens.css
View file @
5c519a8f
...
...
@@ -2,9 +2,7 @@
.light
,
.default
,
[
data-theme
=
'light'
],
[
data-theme
=
'default'
],
.dark
,
[
data-theme
=
'dark'
]
{
[
data-theme
=
'default'
]
{
/* Xinyuan aliases and extension tokens. HeroUI core tokens are owned by theme.css. */
--rc-brand-primary
:
var
(
--accent
);
--rc-brand-light
:
color-mix
(
in
oklab
,
var
(
--accent
)
84%
,
white
);
...
...
@@ -27,6 +25,17 @@
var
(
--background
)
);
--rc-text-muted
:
var
(
--muted
);
--rc-text-empty
:
color-mix
(
in
oklab
,
var
(
--muted
)
72%
,
var
(
--background
));
/* Fixed dark Sidebar aliases. The content canvas remains light. */
--rc-sidebar-background
:
var
(
--xinyuan-sidebar-background
);
--rc-sidebar-border
:
var
(
--xinyuan-sidebar-border
);
--rc-sidebar-foreground
:
var
(
--xinyuan-sidebar-foreground
);
--rc-sidebar-muted
:
var
(
--xinyuan-sidebar-muted
);
--rc-sidebar-item-hover
:
var
(
--xinyuan-sidebar-item-hover
);
--rc-sidebar-item-selected
:
var
(
--xinyuan-sidebar-item-selected
);
--rc-sidebar-item-selected-foreground
:
var
(
--xinyuan-sidebar-item-selected-foreground
);
--rc-sidebar-focus
:
var
(
--xinyuan-sidebar-focus
);
--rc-font-sans
:
var
(
--font-sans
);
--rc-font-medium
:
500
;
...
...
@@ -45,6 +54,7 @@
/* Page geometry only. Component appearance remains owned by HeroUI. */
--rc-shell-inline-gutter
:
3rem
;
--rc-sidebar-width
:
15rem
;
--rc-navbar-inline-padding
:
var
(
--rc-shell-inline-gutter
);
--rc-page-inline-start
:
var
(
--rc-shell-inline-gutter
);
--rc-page-inline-end
:
var
(
--rc-shell-inline-gutter
);
...
...
@@ -58,10 +68,6 @@
--rc-radius-control
:
0.375rem
;
--rc-radius-container
:
0.5rem
;
--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-normal
:
180ms
;
--rc-ease-standard
:
cubic-bezier
(
0.2
,
0
,
0
,
1
);
...
...
@@ -142,40 +148,28 @@
);
/* Data visualization only. Do not reuse UI status colors as a series palette. */
--rc-chart-1
:
oklch
(
45.32%
0.1661
27.03
);
--rc-chart-2
:
oklch
(
56%
0.135
255
);
--rc-chart-3
:
oklch
(
64%
0.11
220
);
--rc-chart-4
:
oklch
(
56%
0.115
280
);
--rc-chart-5
:
oklch
(
60%
0.095
200
);
--rc-chart-6
:
oklch
(
70%
0.12
80
);
--rc-chart-7
:
oklch
(
60%
0.1
295
);
--rc-chart-8
:
oklch
(
57%
0.035
250
);
--rc-chart-1
:
var
(
--xinyuan-chart-1
);
--rc-chart-2
:
var
(
--xinyuan-chart-2
);
--rc-chart-3
:
var
(
--xinyuan-chart-3
);
--rc-chart-4
:
var
(
--xinyuan-chart-4
);
--rc-chart-5
:
var
(
--xinyuan-chart-5
);
--rc-chart-6
:
var
(
--xinyuan-chart-6
);
--rc-chart-7
:
var
(
--xinyuan-chart-7
);
--rc-chart-8
:
var
(
--xinyuan-chart-8
);
--rc-chart-9
:
var
(
--xinyuan-chart-9
);
--rc-chart-10
:
var
(
--xinyuan-chart-10
);
--rc-chart-11
:
var
(
--xinyuan-chart-11
);
--rc-chart-12
:
var
(
--xinyuan-chart-12
);
--rc-chart-13
:
var
(
--xinyuan-chart-13
);
--rc-chart-14
:
var
(
--xinyuan-chart-14
);
--rc-chart-15
:
var
(
--xinyuan-chart-15
);
--rc-chart-16
:
var
(
--xinyuan-chart-16
);
--rc-chart-primary
:
var
(
--rc-chart-1
);
--rc-chart-risk
:
oklch
(
47%
0.145
27.03
);
--rc-chart-warning
:
var
(
--rc-chart-
6
);
--rc-chart-positive
:
var
(
--rc-chart-
2
);
--rc-chart-comparison
:
var
(
--rc-chart-
4
);
--rc-chart-risk
:
var
(
--rc-chart-1
);
--rc-chart-warning
:
var
(
--rc-chart-
3
);
--rc-chart-positive
:
var
(
--rc-chart-
7
);
--rc-chart-comparison
:
var
(
--rc-chart-
5
);
--rc-chart-neutral
:
var
(
--rc-chart-8
);
--rc-chart-legend-font-size
:
0.75rem
;
--rc-chart-legend-line-height
:
1rem
;
}
.dark
,
[
data-theme
=
'dark'
]
{
color-scheme
:
dark
;
--rc-text-secondary
:
color-mix
(
in
oklab
,
var
(
--foreground
)
76%
,
var
(
--background
)
);
--rc-button-secondary-border
:
color-mix
(
in
oklab
,
var
(
--foreground
)
18%
,
transparent
);
--rc-shadow-raised
:
0
1px
2px
rgb
(
0
0
0
/
24%
),
0
8px
24px
rgb
(
0
0
0
/
24%
);
--rc-shadow-overlay
:
0
16px
40px
rgb
(
0
0
0
/
38%
);
--rc-shadow-card
:
0
1px
2px
rgb
(
0
0
0
/
20%
),
0
4px
12px
rgb
(
0
0
0
/
18%
);
--rc-chart-legend-font-size
:
var
(
--xinyuan-chart-legend-font-size
);
--rc-chart-legend-line-height
:
var
(
--xinyuan-chart-legend-line-height
);
}
src/pages/RecordDetailDrawer/components/SubjectViewContent.jsx
View file @
5c519a8f
import
clsx
from
'clsx'
;
import
{
EmptyState
}
from
'@heroui-pro/react/empty-state'
;
import
{
Surface
}
from
'@heroui/react'
;
import
{
EmptyStateSurface
}
from
'@/components/DataTable'
;
import
TailwindEditableField
from
'./TailwindEditableField'
;
import
{
buildViewColumns
,
...
...
@@ -12,20 +13,6 @@ const FIELD_LABEL_OVERRIDES = {
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
=
''
)
{
return
String
(
fieldPath
).
replace
(
/^metadata
\.
/
,
''
).
split
(
'.'
).
pop
();
}
...
...
@@ -53,6 +40,7 @@ export default function SubjectViewContent({
editSessionId
,
mode
=
'view'
,
onFieldChange
,
onFieldUpdate
,
onPendingLocalMaterialChange
,
onFieldValidation
,
readonly
,
...
...
@@ -73,21 +61,27 @@ export default function SubjectViewContent({
}
return
(
<
EmptyState
size=
"md"
>
<
EmptyState
.
Header
><
EmptyState
.
Title
>
暂无可显示的字段
</
EmptyState
.
Title
></
EmptyState
.
Header
>
</
EmptyState
>
<
Surface
className=
"rc-workspace-module w-full min-w-0 p-4"
variant=
"tertiary"
>
<
EmptyStateSurface
>
<
EmptyState
size=
"md"
>
<
EmptyState
.
Header
>
<
EmptyState
.
Description
className=
"rc-empty-state-message"
>
暂无可显示的字段
</
EmptyState
.
Description
>
</
EmptyState
.
Header
>
</
EmptyState
>
</
EmptyStateSurface
>
</
Surface
>
);
}
const
fields
=
(
<
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
)
=>
{
const
value
=
getFieldValue
(
editableRecord
,
field
.
field
);
const
fieldType
=
field
.
fieldDef
?.
type
||
'text'
;
const
fieldLabel
=
getFieldLabel
(
field
);
const
isFullWidth
=
FULL_WIDTH_FIELD_TYPES
.
has
(
fieldType
);
const
fieldDisabled
=
isFieldDisabled
?
isFieldDisabled
(
field
.
field
,
view
)
:
isFieldDisabledByView
(
field
.
field
,
view
);
...
...
@@ -98,7 +92,6 @@ export default function SubjectViewContent({
className=
{
clsx
(
'min-w-0'
,
minimal
?
'py-1'
:
'py-3'
,
isFullWidth
&&
'md:col-span-2'
,
)
}
>
<
div
className=
{
minimal
?
'sr-only'
:
'text-sm font-medium text-muted'
}
>
...
...
@@ -121,6 +114,9 @@ export default function SubjectViewContent({
editSessionId
,
)
}
onSave=
{
onFieldUpdate
?
(
newValue
)
=>
onFieldUpdate
(
field
.
field
,
newValue
,
field
.
fieldDef
)
:
undefined
}
onValidationChange=
{
onFieldValidation
}
onPendingLocalMaterialChange=
{
onPendingLocalMaterialChange
}
readonly=
{
readonly
}
...
...
@@ -134,13 +130,5 @@ export default function SubjectViewContent({
</
section
>
);
if
(
mode
===
'view'
&&
!
minimal
)
{
return
(
<
Surface
className=
"px-5 py-2 sm:px-6 sm:py-3"
variant=
"secondary"
>
{
fields
}
</
Surface
>
);
}
return
fields
;
}
src/pages/RecordDetailDrawer/components/TailwindEditableField.jsx
View file @
5c519a8f
...
...
@@ -234,6 +234,7 @@ function UserSelector({ value, onChange, multiple, variant }) {
aria
-
label=
"用户"
emptyText=
"暂无用户"
inputValue=
{
keyword
}
isClearable
options=
{
autocompleteOptions
}
placeholder=
"请选择用户"
searchPlaceholder=
"搜索用户名或显示名"
...
...
@@ -242,9 +243,10 @@ function UserSelector({ value, onChange, multiple, variant }) {
valueText=
{
valueText
||
'请选择用户'
}
variant=
{
variant
}
onInputChange=
{
setKeyword
}
onClear=
{
()
=>
onChange
(
multiple
?
[]
:
null
)
}
onSelectionChange=
{
handleSelectionChange
}
renderOption=
{
(
option
)
=>
(
<
span
className=
"min-w-0"
>
<
span
className=
"min-w-0
flex-1 overflow-hidden
"
>
<
Label
className=
"block truncate"
>
{
option
.
label
}
</
Label
>
<
span
className=
"block truncate text-xs text-muted"
>
{
option
.
description
}
...
...
@@ -289,6 +291,8 @@ function FieldOptionSelect({ multiple, onChange, options, value, variant }) {
selectionMode=
{
multiple
?
'multiple'
:
'single'
}
valueText=
{
displayValue
}
variant=
{
variant
}
isClearable
onClear=
{
()
=>
onChange
(
multiple
?
[]
:
''
)
}
/>
);
}
...
...
src/pages/RecordDetailDrawer/index.jsx
View file @
5c519a8f
...
...
@@ -390,7 +390,7 @@ const RecordDetailDrawer = (props, ref) => {
variant=
"blur"
>
<
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"
>
{
!
minimalContent
?
(
<
Tooltip
>
...
...
@@ -418,40 +418,39 @@ const RecordDetailDrawer = (props, ref) => {
)
:
null
}
</
div
>
<
Modal
.
Header
className=
"pr-16"
>
<
Modal
.
Header
className=
"
shrink-0
pr-16"
>
<
Modal
.
Heading
>
{
title
||
'记录详情'
}
</
Modal
.
Heading
>
{
!
hideRecordTitle
?
(
<
div
className=
"truncate text-sm text-muted"
title=
{
recordTitle
}
>
{
loading
?
'正在读取记录信息'
:
recordTitle
}
</
div
>
)
:
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
>
{
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
>
{
loading
?
(
<
div
className=
"flex min-h-48 items-center justify-center"
role=
"status"
>
...
...
src/pages/integrity_practice_management/SubjectWorkbench.jsx
View file @
5c519a8f
import
RecordWorkbench
from
'@/components/record-workbench'
;
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
{
Alert
Dialog
,
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
{
useLocation
}
from
'umi'
;
import
UserAssignmentDialog
from
'./UserAssignmentDialog'
;
import
XinyuanAutocomplete
from
'@/components/XinyuanAutocomplete'
;
import
FormDatePicker
from
'@/components/FormDatePicker'
;
import
{
getSubjectNameFromPath
,
INTEGRITY_PRACTICE_SUBJECT_CONFIG
,
...
...
@@ -24,7 +25,7 @@ import {
buildIntegrityCreatePayload
,
}
from
'./integrityPracticeWorkbenchService'
;
import
{
createRecord
}
from
'@/services/DataService'
;
import
{
getViewRestrictionDefaults
,
getSelectOptions
,
emptyValueForField
}
from
'@/utils/subjectView'
;
import
{
getViewRestrictionDefaults
,
getSelectOptions
,
emptyValueForField
,
toArray
}
from
'@/utils/subjectView'
;
const
EMPTY_ARRAY
=
[];
const
MUTATING_ACTION_PATTERN
=
/delete|archive|create|push/i
;
...
...
@@ -86,7 +87,7 @@ export default function SubjectWorkbench() {
const
[
selectedIds
,
setSelectedIds
]
=
useState
(
EMPTY_ARRAY
);
const
[
filters
,
setFilters
]
=
useState
({});
const
[
page
,
setPage
]
=
useState
(
0
);
const
pageSize
=
DEFAULT_PAGE_SIZE
;
const
[
pageSize
,
setPageSize
]
=
useState
(
DEFAULT_PAGE_SIZE
)
;
const
[
pagination
,
setPagination
]
=
useState
(
null
);
const
[
loading
,
setLoading
]
=
useState
(
true
);
const
[
actionLoading
,
setActionLoading
]
=
useState
(
false
);
...
...
@@ -121,6 +122,7 @@ export default function SubjectWorkbench() {
setSelectedIds
(
EMPTY_ARRAY
);
setFilters
({});
setPage
(
0
);
setPageSize
(
DEFAULT_PAGE_SIZE
);
loadSubjectResources
(
subjectName
,
subjectConfig
)
.
then
((
resources
)
=>
{
...
...
@@ -270,14 +272,14 @@ export default function SubjectWorkbench() {
const
toolbarActions
=
useMemo
(
()
=>
[
...(
gridConfig
.
readonly
?.
disableAddRow
?
[]
:
[{
key
:
'create'
,
label
:
'新增记录'
,
tone
:
'primary'
,
disabled
:
actionLoading
,
onClick
:
()
=>
{
setCreateError
(
''
);
setShowCreate
(
true
);
}
}]),
...(
gridConfig
.
tabBarRightSlot
||
[]).
map
((
action
,
index
)
=>
({
key
:
action
.
execute
||
`toolbar-
${
index
}
`
,
label
:
action
.
label
,
tone
:
'primary'
,
disabled
:
actionLoading
,
onClick
:
(
context
)
=>
requestAction
(
action
,
context
),
})),
key
:
action
.
execute
||
`toolbar-
${
index
}
`
,
label
:
action
.
label
,
tone
:
action
.
tone
||
'secondary'
,
disabled
:
actionLoading
,
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
],
);
...
...
@@ -323,6 +325,7 @@ export default function SubjectWorkbench() {
setPage
(
0
);
}
}
title=
{
subjectConfig
.
title
||
subject
?.
title
||
subjectName
}
listTitle=
{
subjectConfig
.
listTitle
}
description=
{
selectedIds
.
length
>
0
?
`已选择 ${selectedIds.length} 条记录;执行操作时优先处理所选记录。`
...
...
@@ -364,6 +367,11 @@ export default function SubjectWorkbench() {
?
{
...
pagination
,
onPageChange
:
setPage
,
pageSizeOptions
:
[
20
,
50
,
100
],
onPageSizeChange
:
(
nextPageSize
)
=>
{
setPageSize
(
Number
(
nextPageSize
)
||
DEFAULT_PAGE_SIZE
);
setPage
(
0
);
},
}
:
null
}
...
...
@@ -383,24 +391,117 @@ export default function SubjectWorkbench() {
<
Modal
.
Root
>
<
Modal
.
Trigger
aria
-
hidden=
"true"
className=
"sr-only"
tabIndex=
{
-
1
}
/>
<
Modal
.
Backdrop
isOpen=
{
showCreate
}
onOpenChange=
{
(
open
)
=>
{
if
(
!
open
&&
!
actionLoading
)
setShowCreate
(
false
);
}
}
>
<
Modal
.
Container
size=
"lg"
><
Modal
.
Dialog
>
<
Modal
.
Header
><
Modal
.
Heading
>
新增记录
</
Modal
.
Heading
></
Modal
.
Header
>
<
Modal
.
Body
>
{
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"
>
{
createFields
.
map
((
field
)
=>
{
const
name
=
field
.
fieldDef
.
name
;
const
value
=
createValues
[
name
]
??
''
;
const
options
=
getSelectOptions
(
field
.
fieldDef
);
return
<
div
key=
{
field
.
field
}
className=
"min-w-0"
>
<
label
className=
"mb-1.5 block text-sm font-medium"
>
{
field
.
label
||
field
.
fieldDef
.
label
||
name
}{
field
.
fieldDef
.
required
?
' *'
:
''
}
</
label
>
{
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
}))
}
/>
}
</
div
>;
})
}
</
div
>
</
Modal
.
Body
>
<
Modal
.
Footer
><
Button
variant=
"secondary"
onPress=
{
()
=>
setShowCreate
(
false
)
}
>
取消
</
Button
><
Button
isPending=
{
actionLoading
}
onPress=
{
handleCreate
}
>
确认新增
</
Button
></
Modal
.
Footer
>
</
Modal
.
Dialog
></
Modal
.
Container
>
<
Modal
.
Backdrop
isOpen=
{
showCreate
}
variant=
"blur"
onOpenChange=
{
(
open
)
=>
{
if
(
!
open
&&
!
actionLoading
)
setShowCreate
(
false
);
}
}
>
<
Modal
.
Container
placement=
"center"
scroll=
"inside"
size=
"lg"
>
<
Modal
.
Dialog
className=
"bg-background p-7 sm:max-w-[68rem]"
>
<
Modal
.
CloseTrigger
aria
-
label=
"关闭"
>
<
Close
size=
{
16
}
/>
</
Modal
.
CloseTrigger
>
<
Modal
.
Header
>
<
Modal
.
Heading
>
新增记录
</
Modal
.
Heading
>
</
Modal
.
Header
>
<
Modal
.
Body
className=
"min-w-0"
>
{
createError
?
(
<
Alert
className=
"mb-5"
status=
"danger"
>
<
Alert
.
Content
>
<
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
.
Root
>
...
...
src/pages/integrity_practice_management/components/IntegrityPracticeMaterialField.jsx
View file @
5c519a8f
...
...
@@ -308,6 +308,7 @@ export default function IntegrityPracticeMaterialField({
errorText={materialsError}
inputValue={keyword}
isDisabled={uploading}
isClearable
isLoading={materialsLoading}
options={materialOptions}
placeholder="请选择审计材料"
...
...
@@ -317,9 +318,13 @@ export default function IntegrityPracticeMaterialField({
valueText={selectedValueText}
variant="secondary"
onInputChange={setKeyword}
onClear={() => {
setSelectedMaterialId(null);
onChange?.(multiple ? [] : null);
}}
onSelectionChange={handleMaterialSelection}
renderOption={(material) => (
<span className="min-w-0">
<span className="min-w-0
flex-1 overflow-hidden
">
<Label className="block truncate" title={material.materialTitle}>
{material.materialTitle}
</Label>
...
...
src/pages/integrity_practice_management/integrityPracticeConfig.js
View file @
5c519a8f
...
...
@@ -72,6 +72,7 @@ export const INTEGRITY_PRACTICE_SUBJECT_CONFIG = {
},
self_check_the_draft_template
:
{
title
:
'自查底稿模版'
,
listTitle
:
'自查底稿模板列表'
,
gridRecordConfig
:
{
menuConfig
:
[
{
name
:
'编辑'
,
action
:
'integrityRiskManagerService.editDetail'
},
...
...
@@ -82,10 +83,12 @@ export const INTEGRITY_PRACTICE_SUBJECT_CONFIG = {
},
self_check_the_working_draft
:
{
title
:
'自查底稿项目'
,
listTitle
:
'自查底稿项目列表'
,
gridRecordConfig
:
{
menuConfig
:
[]
},
},
statistical_table
:
{
title
:
'廉洁从业规定落实情况统计表'
,
listTitle
:
'统计表记录'
,
gridRecordConfig
:
{
filterView
:
[],
menuConfig
:
[
...
...
@@ -97,12 +100,14 @@ export const INTEGRITY_PRACTICE_SUBJECT_CONFIG = {
{
label
:
'导出统计表'
,
execute
:
'integrityRiskManagerService.exportIntegrityPracticeStatisticsTable'
,
tone
:
'secondary'
,
},
],
},
},
self_inspection_submission_form
:
{
title
:
'廉洁从业自查底稿'
,
listTitle
:
'自查底稿报送记录'
,
gridRecordConfig
:
{
menuConfig
:
[
{
name
:
'编辑'
,
action
:
'integrityRiskManagerService.editDetail'
},
...
...
@@ -114,6 +119,7 @@ export const INTEGRITY_PRACTICE_SUBJECT_CONFIG = {
},
integrity_practice_report
:
{
title
:
'廉洁从业报告生成'
,
listTitle
:
'廉洁从业报告记录'
,
gridRecordConfig
:
{
filterView
:
[],
menuConfig
:
[
...
...
@@ -126,6 +132,7 @@ export const INTEGRITY_PRACTICE_SUBJECT_CONFIG = {
},
self_inspection_annual_submission_materials
:
{
title
:
'历年报送材料'
,
listTitle
:
'历史报送材料记录'
,
gridRecordConfig
:
{
pagination
:
false
,
menuConfig
:
[
...
...
src/pages/integrity_practice_management/integrityPracticeWorkbenchService.js
View file @
5c519a8f
...
...
@@ -174,7 +174,23 @@ export function buildSubjectFilterSchema(view, subject) {
const
fieldDef
=
column
.
fieldDef
||
{};
const
type
=
String
(
fieldDef
.
type
||
'text'
).
toLowerCase
();
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
,
};
});
}
...
...
src/pages/integrity_practice_management/notifications/NotificationListPage.jsx
View file @
5c519a8f
...
...
@@ -317,6 +317,17 @@ export default function NotificationListPage() {
return
(
<
div
className=
"rc-page-stack"
>
<
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
className=
"w-fit max-w-full min-w-0"
selectedKey=
{
category
}
...
...
@@ -337,14 +348,6 @@ export default function NotificationListPage() {
</
Tabs
.
ListContainer
>
</
Tabs
>
<
Button
isDisabled=
{
loading
||
unreadTotal
===
0
}
isLoading=
{
busyKey
===
'all'
}
variant=
"secondary"
onPress=
{
handleMarkAllRead
}
>
全部已读
</
Button
>
</
div
>
<
div
className=
"rc-page-toolbar"
>
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment