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
84dfb853
Commit
84dfb853
authored
Aug 26, 2026
by
Andy-bubu
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: align Xinyuan cross-app capabilities
parent
0f9e79a0
Expand all
Show whitespace changes
Inline
Side-by-side
Showing
28 changed files
with
1207 additions
and
133 deletions
+1207
-133
.umirc.ts
.umirc.ts
+5
-0
ui-architecture.md
docs/ui-architecture.md
+10
-2
ui-migration-checklist.md
docs/ui-migration-checklist.md
+23
-0
xinyuan-design-system.md
docs/xinyuan-design-system.md
+5
-0
xinyuan-ui-audit.md
docs/xinyuan-ui-audit.md
+13
-0
AppIcons.jsx
src/components/AppIcons.jsx
+10
-0
AppShell.jsx
src/components/AppShell.jsx
+8
-2
ApplicationSwitcherDialog.jsx
src/components/ApplicationSwitcherDialog.jsx
+5
-5
DashboardNavbar.jsx
src/components/DashboardNavbar.jsx
+24
-3
FileUploadField.jsx
src/components/FileUploadField.jsx
+67
-0
FormDatePicker.jsx
src/components/FormDatePicker.jsx
+234
-0
ReferenceSearchSelect.jsx
src/components/ReferenceSearchSelect.jsx
+0
-0
XinyuanAutocomplete.jsx
src/components/XinyuanAutocomplete.jsx
+159
-0
index.jsx
src/components/record-workbench/index.jsx
+7
-2
application.js
src/config/application.js
+5
-2
SubjectViewContent.jsx
...ages/RecordDetailDrawer/components/SubjectViewContent.jsx
+97
-35
TailwindEditableField.jsx
...s/RecordDetailDrawer/components/TailwindEditableField.jsx
+0
-0
useSubjectRecord.js
src/pages/RecordDetailDrawer/hooks/useSubjectRecord.js
+52
-29
index.jsx
src/pages/RecordDetailDrawer/index.jsx
+0
-0
Layout.jsx
src/pages/integrity_practice_management/Layout.jsx
+27
-0
SubjectWorkbench.jsx
src/pages/integrity_practice_management/SubjectWorkbench.jsx
+10
-6
IntegrityPracticeMaterialField.jsx
..._management/components/IntegrityPracticeMaterialField.jsx
+0
-0
integrity-practice-management.business-flow.md
...management/integrity-practice-management.business-flow.md
+21
-5
integrityPracticeMaterialService.js
...y_practice_management/integrityPracticeMaterialService.js
+159
-0
integrityPracticeWorkbenchService.js
..._practice_management/integrityPracticeWorkbenchService.js
+1
-4
NotificationListPage.jsx
...ractice_management/notifications/NotificationListPage.jsx
+0
-0
notificationService.js
..._practice_management/notifications/notificationService.js
+180
-0
subjectView.js
src/utils/subjectView.js
+85
-38
No files found.
.umirc.ts
View file @
84dfb853
...
@@ -14,6 +14,7 @@ const ROUTE_PATHS = {
...
@@ -14,6 +14,7 @@ const ROUTE_PATHS = {
selfCheckDraftTemplate
:
'/self_check_the_draft_template'
,
selfCheckDraftTemplate
:
'/self_check_the_draft_template'
,
selfCheckWorkingDraft
:
'/self_check_the_working_draft'
,
selfCheckWorkingDraft
:
'/self_check_the_working_draft'
,
annualSubmissionMaterials
:
'/self_inspection_annual_submission_materials'
,
annualSubmissionMaterials
:
'/self_inspection_annual_submission_materials'
,
notifications
:
'/notifications'
,
}
as
const
;
}
as
const
;
const
APP_BASE_PATH
=
'/integrity_practice_management/'
;
const
APP_BASE_PATH
=
'/integrity_practice_management/'
;
...
@@ -75,6 +76,10 @@ export default defineConfig({
...
@@ -75,6 +76,10 @@ export default defineConfig({
path
:
ROUTE_PATHS
.
annualSubmissionMaterials
,
path
:
ROUTE_PATHS
.
annualSubmissionMaterials
,
component
:
'./integrity_practice_management/SubjectWorkbench'
,
component
:
'./integrity_practice_management/SubjectWorkbench'
,
},
},
{
path
:
ROUTE_PATHS
.
notifications
,
component
:
'./integrity_practice_management/notifications/NotificationListPage'
,
},
],
],
},
},
],
],
...
...
docs/ui-architecture.md
View file @
84dfb853
...
@@ -9,21 +9,25 @@ app.js (主题初始化)
...
@@ -9,21 +9,25 @@ app.js (主题初始化)
-> AppShell (Pro Sidebar.Provider)
-> AppShell (Pro Sidebar.Provider)
-> DashboardSidebar (Pro Sidebar + HeroUI Dropdown)
-> DashboardSidebar (Pro Sidebar + HeroUI Dropdown)
-> DashboardNavbar (Pro Navbar + Sidebar.Trigger)
-> DashboardNavbar (Pro Navbar + Sidebar.Trigger)
-> Badge.Anchor + Badge 通知入口 -> /notifications
-> SubjectWorkbench (七个 Subject 配置入口)
-> SubjectWorkbench (七个 Subject 配置入口)
-> RecordWorkbench (Tabs/Search/DataGrid/Pagination/ActionBar)
-> RecordWorkbench (Tabs/Search/DataGrid/Pagination/ActionBar)
-> RecordDetailDrawer (Modal/Tabs/Surface/动态字段)
-> RecordDetailDrawer (Modal/Tabs/Surface/动态字段)
-> 整窗草稿 -> 待建档本地附件 -> am_material -> 业务 Record
-> UserAssignmentDialog / AlertDialog
-> UserAssignmentDialog / AlertDialog
```
```
## 责任边界
## 责任边界
-
`src/config/application.js`
:
应用名、base、主题存储键
。
-
`src/config/application.js`
:
稳定
`APPLICATION_NAME`
、展示名、base、主题存储键;路由、应用切换与授权判断从同一应用标识派生
。
-
`src/design-system/`
:权威主题、Tokens 和跨应用 Shell 样式;页面不得复制这些规则。
-
`src/design-system/`
:权威主题、Tokens 和跨应用 Shell 样式;页面不得复制这些规则。
-
`AppShell/DashboardSidebar/DashboardNavbar/ApplicationSwitcherDialog`
:业务无关的共享壳层。
-
`AppShell/DashboardSidebar/DashboardNavbar/ApplicationSwitcherDialog`
:业务无关的共享壳层。
-
`ApplicationSwitcherDialog`
从既有
`SiteContext.site.group`
读取动态分组,从
`GET /application/info`
与
`UserContext.applications`
合并真实可访问应用;分组仅影响呈现,不改变路由、会话或跳转。
-
`ApplicationSwitcherDialog`
从既有
`SiteContext.site.group`
读取动态分组,从
`GET /application/info`
与
`UserContext.applications`
合并真实可访问应用;分组仅影响呈现,不改变路由、会话或跳转。
-
`SubjectWorkbench`
:把现有 Subject/View/动作配置适配为工作台 props,不定义组件外观。
-
`SubjectWorkbench`
:把现有 Subject/View/动作配置适配为工作台 props,不定义组件外观。
-
`record-workbench`
:DataGrid/Pagination/ActionBar 组合;详情能力统一映射为操作列首个预览图标,首个业务列仅展示文本;不发请求、不解释业务动作。
-
`record-workbench`
:DataGrid/Pagination/ActionBar 组合;详情能力统一映射为操作列首个预览图标,首个业务列仅展示文本;不发请求、不解释业务动作。
-
`RecordDetailDrawer`
:详情草稿、Modal 状态和字段编排;保存仍由
`useSubjectRecord`
处理。
-
`RecordDetailDrawer`
:详情整窗草稿、Modal 状态和字段编排;一次保存通过
`useSubjectRecord.handleFieldsUpdate`
提交。
-
`integrityPracticeMaterialService`
:
`am_material`
查询、
`/upload`
结果快照规范化和材料建档;应用标识由廉洁从业业务层固定为
`integrity_practice_management`
。
-
`notifications/notificationService`
:平台通知查询、已读、动态 action、未读事件和 base-aware 链接解析。
-
`TailwindEditableField`
:按动态字段类型选择官方控件;不硬编码选项或 API 字段。
-
`TailwindEditableField`
:按动态字段类型选择官方控件;不硬编码选项或 API 字段。
-
service/hook:唯一业务请求与数据转换边界,UI 组件不改变协议。
-
service/hook:唯一业务请求与数据转换边界,UI 组件不改变协议。
...
@@ -33,8 +37,12 @@ app.js (主题初始化)
...
@@ -33,8 +37,12 @@ app.js (主题初始化)
路由 -> Subject 配置 -> GET Subject/View -> POST record/query
路由 -> Subject 配置 -> GET Subject/View -> POST record/query
-> DataGrid 展示/选择 -> 配置动作 -> 现有 service 回调
-> DataGrid 展示/选择 -> 配置动作 -> 现有 service 回调
-> 详情 Modal -> 本地草稿 -> 统一保存 -> PUT record/:name
-> 详情 Modal -> 本地草稿 -> 统一保存 -> PUT record/:name
-> 本地附件草稿 -> POST am_material(逐文件、可重试)-> PUT record/:name
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 登录路径。
主题状态写入
`xinyuan_color_theme`
,同时更新根
`data-theme`
与
`dark`
class,使 Portal 与页面一致。应用切换数据来自真实 application info;登出调用现有 logout API 后清理登录态并回到 base-aware 登录路径。
## 历史源码隔离
## 历史源码隔离
...
...
docs/ui-migration-checklist.md
View file @
84dfb853
...
@@ -22,6 +22,15 @@
...
@@ -22,6 +22,15 @@
-
[
x
]
详情/编辑 Modal、指派 Modal、删除 AlertDialog
-
[
x
]
详情/编辑 Modal、指派 Modal、删除 AlertDialog
-
[
x
]
Select、Autocomplete、DatePicker、DropZone 直接 compound API
-
[
x
]
Select、Autocomplete、DatePicker、DropZone 直接 compound API
-
[
x
]
活动路由 KPI/图表判定为不适用
-
[
x
]
活动路由 KPI/图表判定为不适用
-
[
x
]
Grid
`shortTitle`
严格布尔隐藏标题展示列,标题数据与查询协议保留
-
[
x
]
Detail
`shortTitle`
随活动详情 View 隐藏记录标题与 title 字段
-
[
x
]
View/字段授权只读取
`integrity_practice_management`
应用项;零 View 不查询、不展示操作,单 View 隐藏 Tabs
-
[
x
]
稳定
`APPLICATION_NAME`
统一路由、应用切换与授权归属;不合并用户顶层或其他应用角色/用户组
-
[
x
]
Detail 加载完整 View 配置;有效 Detail 字段权限整套优先,无配置时整体回退当前 Grid,不合并或逐字段回退
-
[
x
]
`disableColumns`
对管理员仍强制只读;编辑入口只检查当前激活 Detail 展示字段
-
[
x
]
`/notifications`
消息/待办、全部/未读、搜索、分页、单条/全部已读、动态 action 与 base-aware 跳转
-
[
x
]
Navbar 36px Bell、真实未读数和 0/99/99+ 显示规则
-
[
x
]
所有可编辑 file 字段提供材料中心/本地上传;本地草稿仅在详情保存时建档
## 保护与扫描
## 保护与扫描
...
@@ -55,3 +64,17 @@
...
@@ -55,3 +64,17 @@
-
[
]
全仓旧组件扫描为零:未完成。未挂载历史
`src/pages/problem_rectification`
与
`src/catalyst-ui-kit`
仍保留,且不进入当前应用路由。
-
[
]
全仓旧组件扫描为零:未完成。未挂载历史
`src/pages/problem_rectification`
与
`src/catalyst-ui-kit`
仍保留,且不进入当前应用路由。
-
[
]
KPI/图表 Tooltip 页面验收:不适用。七条活动路由没有这些业务模块,未伪造数据或页面。
-
[
]
KPI/图表 Tooltip 页面验收:不适用。七条活动路由没有这些业务模块,未伪造数据或页面。
-
[
]
五项功能全部真实线上失败/重试矩阵:未完成。当前列表、详情和通知接口可用,但没有制造材料部分成功、业务保存失败或通知 1/99/100 的业务数据;不得以浏览器夹具替代真实线上状态验收。
## 2026-08-26 五项功能追加验收
-
[
x
]
真实
`shortTitle: true`
Grid View 隐藏标题展示列,保留上下文首列和操作列预览入口
-
[
x
]
Navbar 未读 0 隐藏 Badge,Bell 进入本应用
`/notifications`
-
[
x
]
通知页真实调用
`/notice/customer/query`
与
`/notice/customer/count`
,空态、消息/待办和全部/未读控件可见
-
[
x
]
目标页面重新登录后的控制台 0 error;保留两条既有 React Aria 开发 warning
-
[
x
]
真实详情权限来源:Andy 打开未归档记录时记录、Subject、View 摘要及四个完整 View 请求均为 200;无字段权限配置的 Detail 整体回退 Grid,并正常显示编辑入口
-
[
x
]
真实
`disableColumns`
:已归档 Detail 的完整配置优先,Andy 虽为系统管理员,进入编辑后配置禁用的模板、部门、项目结果、填报人、审批人等仍为只读,仅未禁用字段可编辑
-
[
]
真实材料事务:尚未完成文件单值/多值、取消、失败和无重复重试验收;未制造业务数据
-
[
]
Detail 完整角色矩阵:Andy 是系统管理员且
`applications`
为空,未提供当前应用“管理”角色和普通用户登录凭据;无权限视图、普通用户默认只读及角色旁路仍未做真实账号验收
-
[
]
真实角色矩阵和通知 1/99/100/action:当前 Andy 运行数据只有 admin 身份且通知总数为 0,没有制造或写入测试业务数据
-
[
]
问题整改同视口稳定会话:基准服务在线,但独立登录验证码会话未稳定建立;本轮没有把仅代码 diff 计作浏览器对比通过
docs/xinyuan-design-system.md
View file @
84dfb853
...
@@ -24,6 +24,11 @@ Inter 禁止通过 `src/tailwind.css` 导入。Tailwind v4 内容扫描必须排
...
@@ -24,6 +24,11 @@ Inter 禁止通过 `src/tailwind.css` 导入。Tailwind v4 内容扫描必须排
-
行动作:1-3 项全部直出;4 项以上直出前两项和 More Dropdown;危险项排序最后。
-
行动作:1-3 项全部直出;4 项以上直出前两项和 More Dropdown;危险项排序最后。
-
Modal:详情/表单使用 HeroUI Modal;确认使用 AlertDialog;内容型详情使用 4xl/1088px、24px 圆角,查看态只允许一个 16px 圆角的 secondary Surface。嵌套 DataGrid 使用 20px 圆角,表头与固定表头使用 tertiary Surface,表体与固定单元格回到 default Surface,禁止表头与外层详情 Surface 使用同一层颜色。
-
Modal:详情/表单使用 HeroUI Modal;确认使用 AlertDialog;内容型详情使用 4xl/1088px、24px 圆角,查看态只允许一个 16px 圆角的 secondary Surface。嵌套 DataGrid 使用 20px 圆角,表头与固定表头使用 tertiary Surface,表体与固定单元格回到 default Surface,禁止表头与外层详情 Surface 使用同一层颜色。
-
动态字段:Input、TextArea、Checkbox、Select、Autocomplete、DatePicker;附件使用 Pro DropZone。
-
动态字段:Input、TextArea、Checkbox、Select、Autocomplete、DatePicker;附件使用 Pro DropZone。
-
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。
-
文件:可编辑 file 字段统一使用 HeroUI Tabs、Autocomplete 和 Pro DropZone;材料中心选择及本地上传都只向业务草稿写
`{name,type,size,url}`
。
-
远程引用:使用 HeroUI
`Popover.Root + Button + SearchField + controlled ListBox`
。多选触发器固定 40px 高并只显示“已选择 N 项”,候选长标题单行省略且通过
`title`
暴露全文;弹层最大 24rem、移动端收敛到
`100vw - 24px`
、列表最大 288px。多选后保持打开,单选后关闭,清空不改变原 ID 保存协议。
-
远程引用:使用 HeroUI
`Popover.Root + Button + SearchField + controlled ListBox`
。多选触发器固定 40px 高并只显示“已选择 N 项”,候选长标题单行省略且通过
`title`
暴露全文;弹层最大 24rem、移动端收敛到
`100vw - 24px`
、列表最大 288px。多选后保持打开,单选后关闭,清空不改变原 ID 保存协议。
-
KPI/图表:仅在真实业务路由存在时使用 Pro KPI、PieChart、BarChart、ComposedChart、ChartTooltip;禁止 Card 数字卡或 ECharts 私有主题。
-
KPI/图表:仅在真实业务路由存在时使用 Pro KPI、PieChart、BarChart、ComposedChart、ChartTooltip;禁止 Card 数字卡或 ECharts 私有主题。
...
...
docs/xinyuan-ui-audit.md
View file @
84dfb853
...
@@ -132,3 +132,16 @@ pnpm install --lockfile-only --offline --frozen-lockfile
...
@@ -132,3 +132,16 @@ pnpm install --lockfile-only --offline --frozen-lockfile
-
根运行时为 React/React DOM
`19.2.8`
、HeroUI/Styles
`3.2.4`
、React Aria Components
`1.20.0`
、React Aria Utils
`3.34.1`
,满足 HeroUI Pro
`1.0.0-beta.8`
peer 范围;冻结锁文件检查显示
`Already up to date`
。
-
根运行时为 React/React DOM
`19.2.8`
、HeroUI/Styles
`3.2.4`
、React Aria Components
`1.20.0`
、React Aria Utils
`3.34.1`
,满足 HeroUI Pro
`1.0.0-beta.8`
peer 范围;冻结锁文件检查显示
`Already up to date`
。
-
依赖树还包含 Umi/DVA 工具链内部 React/React DOM 18 副本;
`.umirc.ts`
已把应用运行时 alias 到根 React 19。没有缺失 HeroUI peer。
-
依赖树还包含 Umi/DVA 工具链内部 React/React DOM 18 副本;
`.umirc.ts`
已把应用运行时 alias 到根 React 19。没有缺失 HeroUI peer。
-
Node ESM 脚本已逐项导入根入口、Sidebar、Navbar、DataGrid、DropZone、ActionBar、EmptyState、KPI、PieChart、BarChart、ComposedChart、ChartTooltip,并解析 CSS 子路径,全部通过。
-
Node ESM 脚本已逐项导入根入口、Sidebar、Navbar、DataGrid、DropZone、ActionBar、EmptyState、KPI、PieChart、BarChart、ComposedChart、ChartTooltip,并解析 CSS 子路径,全部通过。
## 2026-08-26 五项功能审计
-
应用范围:统一展示名为“廉洁从业管理”,属于六个完整功能应用,实施 Grid/Detail
`shortTitle`
、Subject View 权限、通知中心和审计材料中心文件流程。
-
授权来源:
`APPLICATION_NAME = integrity_practice_management`
是路由、应用切换和授权判断的唯一稳定标识。只读取
`user.applications[]`
中该应用项目,不合并顶层身份、其他应用角色或其他应用用户组;系统管理员/系统账号及该应用“管理”角色旁路 View/字段授权,
`disableColumns`
始终只读。
-
Detail 权限来源:详情先获取 View 摘要,再逐项调用
`GET /subject/:subjectName/view/:viewName`
补齐完整配置。当前 Detail 有有效
`auth`
/
`disableColumns`
时整套优先;两项都未配置或没有实际 Detail 时整体回退当前 Grid,二者不合并也不逐字段回退。Grid
`viewAuth`
仅控制 Tab 可见性,编辑入口只检查当前激活 Detail 展示字段。
-
真实 View:
`self_inspection_submission_form`
、
`statistical_table`
、
`integrity_practice_report`
、
`self_check_the_working_draft`
已确认存在严格布尔
`shortTitle: true`
;未配置的 View 保留标题列。
-
文件协议:运行时确认
`integrity_practice_report.metadata.archive_attachment`
为
`file`
且
`multiple: true`
;业务值继续使用
`{name,type,size,url}`
快照。
-
通知协议:沿用既有
`/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)。
-
浏览器限制:Andy 是系统管理员且当前返回的
`applications`
为空,能够验证系统管理员旁路与 Detail
`disableColumns`
不可旁路,但不能替代目标应用“管理”角色、普通用户和无权限用户矩阵。材料选择/上传/取消/部分失败/重试、通知 1/99/100 和动态 action 也没有制造业务数据验收;问题整改基准的独立登录会话未稳定建立。未使用伪造业务数据绕过这些限制。
src/components/AppIcons.jsx
View file @
84dfb853
import
AppstoreOutlined
from
'@ant-design/icons/AppstoreOutlined'
;
import
AppstoreOutlined
from
'@ant-design/icons/AppstoreOutlined'
;
import
BellOutlined
from
'@ant-design/icons/BellOutlined'
;
import
DownloadOutlined
from
'@ant-design/icons/DownloadOutlined'
;
import
DownloadOutlined
from
'@ant-design/icons/DownloadOutlined'
;
import
CalendarOutlined
from
'@ant-design/icons/CalendarOutlined'
;
import
CalendarOutlined
from
'@ant-design/icons/CalendarOutlined'
;
import
CheckOutlined
from
'@ant-design/icons/CheckOutlined'
;
import
CheckOutlined
from
'@ant-design/icons/CheckOutlined'
;
...
@@ -25,11 +26,16 @@ import UploadOutlined from '@ant-design/icons/UploadOutlined';
...
@@ -25,11 +26,16 @@ import UploadOutlined from '@ant-design/icons/UploadOutlined';
import
PlusOutlined
from
'@ant-design/icons/PlusOutlined'
;
import
PlusOutlined
from
'@ant-design/icons/PlusOutlined'
;
import
WarningOutlined
from
'@ant-design/icons/WarningOutlined'
;
import
WarningOutlined
from
'@ant-design/icons/WarningOutlined'
;
import
MoreOutlined
from
'@ant-design/icons/MoreOutlined'
;
import
MoreOutlined
from
'@ant-design/icons/MoreOutlined'
;
import
ExportOutlined
from
'@ant-design/icons/ExportOutlined'
;
function
iconStyle
(
size
,
style
)
{
function
iconStyle
(
size
,
style
)
{
return
size
===
undefined
?
style
:
{
fontSize
:
size
,
...
style
};
return
size
===
undefined
?
style
:
{
fontSize
:
size
,
...
style
};
}
}
export
const
FIELD_SUFFIX_ICON_STYLE
=
Object
.
freeze
({
color
:
'var(--field-placeholder, var(--muted))'
,
});
function
withSize
(
Icon
)
{
function
withSize
(
Icon
)
{
return
function
AppIcon
({
className
,
size
,
style
,
...
props
})
{
return
function
AppIcon
({
className
,
size
,
style
,
...
props
})
{
return
(
return
(
...
@@ -44,6 +50,7 @@ function withSize(Icon) {
...
@@ -44,6 +50,7 @@ function withSize(Icon) {
}
}
export
const
AppGrid
=
withSize
(
AppstoreOutlined
);
export
const
AppGrid
=
withSize
(
AppstoreOutlined
);
export
const
Bell
=
withSize
(
BellOutlined
);
export
const
Archive
=
withSize
(
ArchiveOutlined
);
export
const
Archive
=
withSize
(
ArchiveOutlined
);
export
const
Calendar
=
withSize
(
CalendarOutlined
);
export
const
Calendar
=
withSize
(
CalendarOutlined
);
export
const
Check
=
withSize
(
CheckOutlined
);
export
const
Check
=
withSize
(
CheckOutlined
);
...
@@ -54,6 +61,7 @@ export const Delete = withSize(DeleteOutlined);
...
@@ -54,6 +61,7 @@ export const Delete = withSize(DeleteOutlined);
export
const
Download
=
withSize
(
DownloadOutlined
);
export
const
Download
=
withSize
(
DownloadOutlined
);
export
const
Edit
=
withSize
(
EditOutlined
);
export
const
Edit
=
withSize
(
EditOutlined
);
export
const
Eye
=
withSize
(
EyeOutlined
);
export
const
Eye
=
withSize
(
EyeOutlined
);
export
const
OpenExternal
=
withSize
(
ExportOutlined
);
export
const
FileDone
=
withSize
(
FileDoneOutlined
);
export
const
FileDone
=
withSize
(
FileDoneOutlined
);
export
const
FileText
=
withSize
(
FileTextOutlined
);
export
const
FileText
=
withSize
(
FileTextOutlined
);
export
const
Fullscreen
=
withSize
(
FullscreenOutlined
);
export
const
Fullscreen
=
withSize
(
FullscreenOutlined
);
...
@@ -62,8 +70,10 @@ export const LogOut = withSize(LogoutOutlined);
...
@@ -62,8 +70,10 @@ export const LogOut = withSize(LogoutOutlined);
export
const
Moon
=
withSize
(
MoonOutlined
);
export
const
Moon
=
withSize
(
MoonOutlined
);
export
const
More
=
withSize
(
MoreOutlined
);
export
const
More
=
withSize
(
MoreOutlined
);
export
const
Reload
=
withSize
(
ReloadOutlined
);
export
const
Reload
=
withSize
(
ReloadOutlined
);
export
const
ArrowPathIcon
=
Reload
;
export
const
Send
=
withSize
(
SendOutlined
);
export
const
Send
=
withSize
(
SendOutlined
);
export
const
Search
=
withSize
(
SearchOutlined
);
export
const
Search
=
withSize
(
SearchOutlined
);
export
const
MagnifyingGlassIcon
=
Search
;
export
const
Settings
=
withSize
(
SettingOutlined
);
export
const
Settings
=
withSize
(
SettingOutlined
);
export
const
Sun
=
withSize
(
SunOutlined
);
export
const
Sun
=
withSize
(
SunOutlined
);
export
const
Tool
=
withSize
(
ToolOutlined
);
export
const
Tool
=
withSize
(
ToolOutlined
);
...
...
src/components/AppShell.jsx
View file @
84dfb853
...
@@ -15,7 +15,7 @@ function getInitialTheme() {
...
@@ -15,7 +15,7 @@ function getInitialTheme() {
}
}
}
}
export
default
function
AppShell
({
children
,
open
,
onOpenChange
,
title
,
...
sidebarProps
})
{
export
default
function
AppShell
({
children
,
notificationCount
,
notificationPath
,
open
,
onOpenChange
,
title
,
...
sidebarProps
})
{
const
[
theme
,
setTheme
]
=
useState
(
getInitialTheme
);
const
[
theme
,
setTheme
]
=
useState
(
getInitialTheme
);
useLayoutEffect
(()
=>
{
useLayoutEffect
(()
=>
{
...
@@ -39,7 +39,13 @@ export default function AppShell({ children, open, onOpenChange, title, ...sideb
...
@@ -39,7 +39,13 @@ export default function AppShell({ children, open, onOpenChange, title, ...sideb
>
>
<
DashboardSidebar
theme=
{
theme
}
{
...
sidebarProps
}
/>
<
DashboardSidebar
theme=
{
theme
}
{
...
sidebarProps
}
/>
<
Sidebar
.
Main
className=
"app-main-glass rc-app-main"
>
<
Sidebar
.
Main
className=
"app-main-glass rc-app-main"
>
<
DashboardNavbar
theme=
{
theme
}
title=
{
title
}
onThemeChange=
{
setTheme
}
/>
<
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=
"app-shell-content-scroll min-h-0 flex-1 overflow-y-auto"
>
<
div
className=
"rc-app-page"
>
{
children
}
</
div
>
<
div
className=
"rc-app-page"
>
{
children
}
</
div
>
</
div
>
</
div
>
...
...
src/components/ApplicationSwitcherDialog.jsx
View file @
84dfb853
import
xinyuanLogo
from
'@/assets/xy-logo-red.png'
;
import
xinyuanLogo
from
'@/assets/xy-logo-red.png'
;
import
{
ExclamationTriangleIcon
}
from
'@/components/AppIcons'
;
import
{
ExclamationTriangleIcon
}
from
'@/components/AppIcons'
;
import
{
APPLICATION_DISPLAY_NAME
,
APPLICATION_
ROUTE_
NAME
}
from
'@/config/application'
;
import
{
APPLICATION_DISPLAY_NAME
,
APPLICATION_NAME
}
from
'@/config/application'
;
import
{
getApplications
}
from
'@/services/DataService'
;
import
{
getApplications
}
from
'@/services/DataService'
;
import
{
EmptyState
}
from
'@heroui-pro/react/empty-state'
;
import
{
EmptyState
}
from
'@heroui-pro/react/empty-state'
;
import
{
Alert
,
Avatar
,
Card
,
Chip
,
Description
,
Modal
,
Skeleton
,
Surface
}
from
'@heroui/react'
;
import
{
Alert
,
Avatar
,
Card
,
Chip
,
Description
,
Modal
,
Skeleton
,
Surface
}
from
'@heroui/react'
;
...
@@ -15,7 +15,7 @@ function unwrapApplications(response) {
...
@@ -15,7 +15,7 @@ function unwrapApplications(response) {
function
normalizeApplication
(
application
=
{})
{
function
normalizeApplication
(
application
=
{})
{
const
name
=
application
.
name
||
''
;
const
name
=
application
.
name
||
''
;
const
current
=
name
===
APPLICATION_
ROUTE_
NAME
;
const
current
=
name
===
APPLICATION_NAME
;
return
{
return
{
name
,
name
,
title
:
current
?
APPLICATION_DISPLAY_NAME
:
application
.
title
||
name
||
'未命名应用'
,
title
:
current
?
APPLICATION_DISPLAY_NAME
:
application
.
title
||
name
||
'未命名应用'
,
...
@@ -28,7 +28,7 @@ function normalizeApplication(application = {}) {
...
@@ -28,7 +28,7 @@ function normalizeApplication(application = {}) {
function
mergeApplications
(
fetched
,
fallbacks
)
{
function
mergeApplications
(
fetched
,
fallbacks
)
{
const
merged
=
new
Map
();
const
merged
=
new
Map
();
[...
fallbacks
,
...
fetched
,
{
name
:
APPLICATION_
ROUTE_
NAME
,
title
:
APPLICATION_DISPLAY_NAME
}]
[...
fallbacks
,
...
fetched
,
{
name
:
APPLICATION_NAME
,
title
:
APPLICATION_DISPLAY_NAME
}]
.
map
(
normalizeApplication
)
.
map
(
normalizeApplication
)
.
filter
((
application
)
=>
application
.
name
)
.
filter
((
application
)
=>
application
.
name
)
.
forEach
((
application
)
=>
merged
.
set
(
application
.
name
,
{
...
merged
.
get
(
application
.
name
),
...
application
}));
.
forEach
((
application
)
=>
merged
.
set
(
application
.
name
,
{
...
merged
.
get
(
application
.
name
),
...
application
}));
...
@@ -77,7 +77,7 @@ function buildApplicationSections(applications, applicationGroups) {
...
@@ -77,7 +77,7 @@ function buildApplicationSections(applications, applicationGroups) {
}
}
function isCurrentApplication(application) {
function isCurrentApplication(application) {
if (application.name === APPLICATION_
ROUTE_
NAME) return true;
if (application.name === APPLICATION_NAME) return true;
if (typeof window === 'undefined') return false;
if (typeof window === 'undefined') return false;
try {
try {
const target = new URL(application.url, window.location.origin);
const target = new URL(application.url, window.location.origin);
...
@@ -142,7 +142,7 @@ export default function ApplicationSwitcherDialog({ open, onClose, siteInfo, use
...
@@ -142,7 +142,7 @@ export default function ApplicationSwitcherDialog({ open, onClose, siteInfo, use
const
[
status
,
setStatus
]
=
useState
(
'idle'
);
const
[
status
,
setStatus
]
=
useState
(
'idle'
);
const
[
errorMessage
,
setErrorMessage
]
=
useState
(
''
);
const
[
errorMessage
,
setErrorMessage
]
=
useState
(
''
);
const
currentApplicationName
=
useMemo
(
const
currentApplicationName
=
useMemo
(
()
=>
applications
.
find
(
isCurrentApplication
)?.
name
||
APPLICATION_
ROUTE_
NAME
,
()
=>
applications
.
find
(
isCurrentApplication
)?.
name
||
APPLICATION_NAME
,
[
applications
],
[
applications
],
);
);
const
applicationSections
=
useMemo
(
const
applicationSections
=
useMemo
(
...
...
src/components/DashboardNavbar.jsx
View file @
84dfb853
import
{
Navbar
}
from
'@heroui-pro/react/navbar'
;
import
{
Navbar
}
from
'@heroui-pro/react/navbar'
;
import
{
Sidebar
,
useSidebar
}
from
'@heroui-pro/react/sidebar'
;
import
{
Sidebar
,
useSidebar
}
from
'@heroui-pro/react/sidebar'
;
import
{
Button
,
Tooltip
}
from
'@heroui/react'
;
import
{
B
adge
,
B
utton
,
Tooltip
}
from
'@heroui/react'
;
import
{
Moon
,
SidebarPanel
,
Sun
}
from
'./AppIcons'
;
import
{
Bell
,
Moon
,
SidebarPanel
,
Sun
}
from
'./AppIcons'
;
export
default
function
DashboardNavbar
({
onThemeChange
,
theme
,
title
})
{
export
default
function
DashboardNavbar
({
notificationCount
=
0
,
onOpenNotifications
,
onThemeChange
,
theme
,
title
})
{
const
{
isMobile
,
isOpen
}
=
useSidebar
();
const
{
isMobile
,
isOpen
}
=
useSidebar
();
const
isDark
=
theme
===
'dark'
;
const
isDark
=
theme
===
'dark'
;
const
themeLabel
=
isDark
?
'切换至浅色模式'
:
'切换至深色模式'
;
const
themeLabel
=
isDark
?
'切换至浅色模式'
:
'切换至深色模式'
;
const
normalizedNotificationCount
=
Math
.
max
(
0
,
Number
(
notificationCount
)
||
0
);
return
(
return
(
<
Navbar
className=
"app-navbar rc-navbar"
maxWidth=
"full"
>
<
Navbar
className=
"app-navbar rc-navbar"
maxWidth=
"full"
>
...
@@ -20,6 +21,26 @@ export default function DashboardNavbar({ onThemeChange, theme, title }) {
...
@@ -20,6 +21,26 @@ export default function DashboardNavbar({ onThemeChange, theme, title }) {
<
h1
className=
"app-navbar-page-title rc-navbar-title"
>
{
title
}
</
h1
>
<
h1
className=
"app-navbar-page-title rc-navbar-title"
>
{
title
}
</
h1
>
<
Navbar
.
Spacer
/>
<
Navbar
.
Spacer
/>
<
div
className=
"rc-navbar-actions"
>
<
div
className=
"rc-navbar-actions"
>
<
Tooltip
delay=
{
0
}
>
<
Badge
.
Anchor
>
<
Button
isIconOnly
aria
-
label=
{
`通知消息,${normalizedNotificationCount}条未读`
}
className=
"app-navbar-icon-button rc-navbar-icon-button"
size=
"sm"
variant=
"ghost"
onPress=
{
onOpenNotifications
}
>
<
Bell
size=
{
16
}
/>
</
Button
>
{
normalizedNotificationCount
>
0
?
(
<
Badge
aria
-
label=
{
`${normalizedNotificationCount}条未读通知`
}
color=
"danger"
size=
"sm"
>
{
normalizedNotificationCount
>
99
?
'99+'
:
normalizedNotificationCount
}
</
Badge
>
)
:
null
}
</
Badge
.
Anchor
>
<
Tooltip
.
Content
>
通知消息
</
Tooltip
.
Content
>
</
Tooltip
>
<
Tooltip
delay=
{
0
}
>
<
Tooltip
delay=
{
0
}
>
<
Button
<
Button
isIconOnly
isIconOnly
...
...
src/components/FileUploadField.jsx
0 → 100644
View file @
84dfb853
import
{
Upload
}
from
'@/components/AppIcons'
;
import
{
DropZone
}
from
'@heroui-pro/react/drop-zone'
;
import
{
useCallback
}
from
'react'
;
export
default
function
FileUploadField
({
accept
,
ariaLabel
=
'选择文件'
,
className
=
''
,
description
=
'将文件拖到此处,或从本地选择。'
,
isDisabled
=
false
,
fileName
=
''
,
multiple
=
false
,
onFilesChange
,
triggerLabel
=
'选择文件'
,
})
{
const
emitFiles
=
useCallback
(
(
files
)
=>
{
if
(
!
isDisabled
)
{
onFilesChange
?.(
Array
.
from
(
files
||
[]));
}
},
[
isDisabled
,
onFilesChange
],
);
const
handleDrop
=
useCallback
(
async
(
event
)
=>
{
const
fileItems
=
Array
.
from
(
event
?.
items
||
[]).
filter
(
(
item
)
=>
item
?.
kind
===
'file'
&&
typeof
item
.
getFile
===
'function'
,
);
if
(
fileItems
.
length
===
0
)
return
;
const
files
=
await
Promise
.
all
(
fileItems
.
map
((
item
)
=>
item
.
getFile
()));
emitFiles
(
multiple
?
files
:
files
.
slice
(
0
,
1
));
},
[
emitFiles
,
multiple
],
);
return
(
<
DropZone
className=
{
className
}
>
<
DropZone
.
Area
aria
-
label=
{
ariaLabel
}
isDisabled=
{
isDisabled
}
onDrop=
{
handleDrop
}
>
<
DropZone
.
Input
accept=
{
accept
}
disabled=
{
isDisabled
}
multiple=
{
multiple
}
onSelect=
{
emitFiles
}
/>
<
DropZone
.
Icon
>
<
Upload
aria
-
hidden=
"true"
/>
</
DropZone
.
Icon
>
<
DropZone
.
Label
>
{
fileName
||
'将文件拖到此处'
}
</
DropZone
.
Label
>
<
DropZone
.
Description
>
{
fileName
?
`已选择${multiple ? '文件,可继续添加。' : '文件,可重新选择进行替换。'}`
:
description
}
</
DropZone
.
Description
>
<
DropZone
.
Trigger
isDisabled=
{
isDisabled
}
>
<
Upload
aria
-
hidden=
"true"
/>
{
triggerLabel
}
</
DropZone
.
Trigger
>
</
DropZone
.
Area
>
</
DropZone
>
);
}
src/components/FormDatePicker.jsx
0 → 100644
View file @
84dfb853
import
{
Calendar
as
HeroCalendar
,
CloseButton
,
DateField
,
DatePicker
,
I18nProvider
,
TimeField
,
}
from
'@heroui/react'
;
import
{
parseDate
,
parseDateTime
,
parseTime
}
from
'@internationalized/date'
;
import
{
useMemo
}
from
'react'
;
import
{
Calendar
as
CalendarIcon
,
Close
,
FIELD_SUFFIX_ICON_STYLE
,
}
from
'./AppIcons'
;
function
parseValue
(
value
,
mode
)
{
if
(
!
value
)
{
return
null
;
}
try
{
if
(
mode
===
'month'
)
{
return
parseDate
(
`
${
String
(
value
).
slice
(
0
,
7
)}
-01`
);
}
return
mode
===
'datetime'
?
parseDateTime
(
String
(
value
).
slice
(
0
,
16
))
:
parseDate
(
String
(
value
).
slice
(
0
,
10
));
}
catch
{
return
null
;
}
}
function
formatValue
(
value
,
mode
)
{
if
(
!
value
)
{
return
''
;
}
const
rawValue
=
value
.
toString
();
if
(
mode
===
'month'
)
{
return
rawValue
.
slice
(
0
,
7
);
}
if
(
mode
===
'date'
)
{
return
rawValue
.
slice
(
0
,
10
);
}
if
(
rawValue
.
includes
(
'T'
))
{
return
rawValue
.
slice
(
0
,
16
);
}
return
`
${
rawValue
.
slice
(
0
,
10
)}
T00:00`
;
}
function
parseTimeValue
(
value
)
{
if
(
!
value
||
!
String
(
value
).
includes
(
'T'
))
{
return
null
;
}
try
{
return
parseTime
(
String
(
value
).
slice
(
11
,
16
));
}
catch
{
return
null
;
}
}
function
formatTimeValue
(
value
)
{
return
value
?
value
.
toString
().
slice
(
0
,
5
)
:
'00:00'
;
}
function
combineDateAndTime
(
value
,
dateValue
,
timeValue
)
{
const
dateText
=
dateValue
?
dateValue
.
toString
().
slice
(
0
,
10
)
:
String
(
value
||
''
).
slice
(
0
,
10
);
if
(
!
dateText
)
{
return
''
;
}
return
`
${
dateText
}
T
${
formatTimeValue
(
timeValue
)}
`
;
}
function
emitDateChange
(
onChange
,
name
,
nextValue
)
{
onChange
?.({
target
:
{
name
,
value
:
nextValue
,
},
});
}
export
default
function
FormDatePicker
({
'aria-label'
:
ariaLabel
,
className
,
disabled
=
false
,
granularity
,
isClearable
=
true
,
max
,
min
,
mode
,
name
,
onChange
,
readOnly
=
false
,
required
=
false
,
type
=
'date'
,
value
,
variant
,
...
props
})
{
const
resolvedMode
=
mode
===
'month'
||
type
===
'month'
?
'month'
:
mode
===
'datetime'
||
type
===
'datetime-local'
?
'datetime'
:
'date'
;
const
resolvedGranularity
=
granularity
||
(
resolvedMode
===
'datetime'
?
'minute'
:
resolvedMode
===
'month'
?
'month'
:
'day'
);
const
parsedValue
=
useMemo
(
()
=>
parseValue
(
value
,
resolvedMode
),
[
resolvedMode
,
value
],
);
const
minValue
=
useMemo
(()
=>
parseValue
(
min
,
resolvedMode
),
[
min
,
resolvedMode
]);
const
maxValue
=
useMemo
(()
=>
parseValue
(
max
,
resolvedMode
),
[
max
,
resolvedMode
]);
const
timeValue
=
useMemo
(()
=>
parseTimeValue
(
value
),
[
value
]);
const
canClear
=
isClearable
&&
Boolean
(
value
)
&&
!
disabled
&&
!
readOnly
&&
!
required
;
return
(
<
I18nProvider
locale=
"zh-CN"
>
<
DatePicker
{
...
props
}
aria
-
label=
{
ariaLabel
||
'选择日期'
}
className=
{
[
'w-full'
,
className
].
filter
(
Boolean
).
join
(
' '
)
}
granularity=
{
resolvedGranularity
}
isDisabled=
{
disabled
}
isReadOnly=
{
readOnly
}
isRequired=
{
required
}
maxValue=
{
maxValue
||
undefined
}
minValue=
{
minValue
||
undefined
}
value=
{
parsedValue
}
onChange=
{
(
nextValue
)
=>
emitDateChange
(
onChange
,
name
,
formatValue
(
nextValue
,
resolvedMode
))
}
>
<
DateField
.
Group
className=
"relative"
fullWidth
variant=
{
variant
}
>
<
DateField
.
Input
className=
{
canClear
?
'pe-14'
:
'pe-8'
}
>
{
(
segment
)
=>
(
<
DateField
.
Segment
segment=
{
segment
}
/>
)
}
</
DateField
.
Input
>
<
DateField
.
Suffix
className=
"absolute inset-y-0 end-1 mr-0 gap-0"
>
{
canClear
?
(
<
CloseButton
aria
-
label=
{
`清空${ariaLabel || '日期'}`
}
onPress=
{
()
=>
emitDateChange
(
onChange
,
name
,
''
)
}
>
<
Close
className=
"grid size-4 place-items-center leading-none [&>svg]:block [&>svg]:size-4"
size=
{
16
}
style=
{
FIELD_SUFFIX_ICON_STYLE
}
/>
</
CloseButton
>
)
:
null
}
<
DatePicker
.
Trigger
aria
-
label=
"打开日期选择"
className=
"size-6 w-6 shrink-0 justify-center p-1 text-field-placeholder"
>
<
DatePicker
.
TriggerIndicator
>
<
CalendarIcon
className=
"grid size-4 place-items-center leading-none [&>svg]:block [&>svg]:size-4"
size=
{
16
}
style=
{
FIELD_SUFFIX_ICON_STYLE
}
/>
</
DatePicker
.
TriggerIndicator
>
</
DatePicker
.
Trigger
>
</
DateField
.
Suffix
>
</
DateField
.
Group
>
<
DatePicker
.
Popover
placement=
"bottom start"
>
<
HeroCalendar
>
<
HeroCalendar
.
Header
>
<
HeroCalendar
.
NavButton
slot=
"previous"
/>
<
HeroCalendar
.
Heading
/>
<
HeroCalendar
.
NavButton
slot=
"next"
/>
</
HeroCalendar
.
Header
>
<
HeroCalendar
.
Grid
>
<
HeroCalendar
.
GridHeader
>
{
(
day
)
=>
<
HeroCalendar
.
HeaderCell
>
{
day
}
</
HeroCalendar
.
HeaderCell
>
}
</
HeroCalendar
.
GridHeader
>
<
HeroCalendar
.
GridBody
>
{
(
date
)
=>
<
HeroCalendar
.
Cell
date=
{
date
}
/>
}
</
HeroCalendar
.
GridBody
>
</
HeroCalendar
.
Grid
>
</
HeroCalendar
>
{
resolvedMode
===
'datetime'
?
(
<
div
className=
"flex items-center justify-between gap-3 p-3"
>
<
span
className=
"text-sm text-muted"
>
时间
</
span
>
<
TimeField
aria
-
label=
"选择时间"
className=
"w-36"
granularity=
"minute"
hideTimeZone
hourCycle=
{
24
}
value=
{
timeValue
}
onChange=
{
(
nextTime
)
=>
emitDateChange
(
onChange
,
name
,
combineDateAndTime
(
value
,
parsedValue
,
nextTime
),
)
}
>
<
TimeField
.
Group
fullWidth
variant=
{
variant
}
>
<
TimeField
.
Input
>
{
(
segment
)
=>
(
<
TimeField
.
Segment
segment=
{
segment
}
/>
)
}
</
TimeField
.
Input
>
</
TimeField
.
Group
>
</
TimeField
>
</
div
>
)
:
null
}
</
DatePicker
.
Popover
>
</
DatePicker
>
</
I18nProvider
>
);
}
src/components/ReferenceSearchSelect.jsx
0 → 100644
View file @
84dfb853
This diff is collapsed.
Click to expand it.
src/components/XinyuanAutocomplete.jsx
0 → 100644
View file @
84dfb853
import
{
ChevronDown
,
Close
,
FIELD_SUFFIX_ICON_STYLE
,
Search
,
}
from
'@/components/AppIcons'
;
import
{
Autocomplete
,
Label
,
ListBox
,
SearchField
,
Spinner
,
}
from
'@heroui/react'
;
function
defaultFilter
(
textValue
,
inputValue
)
{
const
query
=
String
(
inputValue
||
''
).
trim
().
toLocaleLowerCase
();
return
!
query
||
String
(
textValue
||
''
).
toLocaleLowerCase
().
includes
(
query
);
}
function
getSelectedKeySet
(
selectionMode
,
selectedKey
,
selectedKeys
)
{
if
(
selectionMode
===
'multiple'
)
{
return
new
Set
(
selectedKeys
||
[]);
}
return
new
Set
(
selectedKey
===
null
||
selectedKey
===
undefined
?
[]
:
[
selectedKey
]);
}
export
default
function
XinyuanAutocomplete
({
'aria-label'
:
ariaLabel
,
className
,
emptyText
=
'暂无可选项'
,
errorText
=
''
,
filter
,
fullWidth
=
true
,
inputValue
,
isDisabled
=
false
,
isLoading
=
false
,
isOpen
,
isRequired
=
false
,
name
,
onInputChange
,
onOpenChange
,
onSelectionChange
,
options
=
[],
placeholder
=
'请选择'
,
placement
=
'bottom start'
,
popoverHeader
,
renderOption
,
searchPlaceholder
=
'输入关键词搜索'
,
selectedKey
,
selectedKeys
,
selectionMode
=
'single'
,
shouldFilter
=
true
,
valueText
,
variant
,
})
{
const
selectedKeySet
=
getSelectedKeySet
(
selectionMode
,
selectedKey
,
selectedKeys
,
);
const
selectedOptions
=
options
.
filter
((
option
)
=>
selectedKeySet
.
has
(
option
.
id
),
);
const
resolvedValueText
=
valueText
??
(
selectedOptions
.
length
>
0
?
selectedOptions
.
map
((
option
)
=>
option
.
label
).
join
(
'、'
)
:
placeholder
);
const
rootSelectionProps
=
selectionMode
===
'multiple'
?
{
selectedKeys
}
:
{
selectedKey
};
const
visibleOptions
=
isLoading
||
errorText
?
[]
:
options
;
return
(
<
Autocomplete
{
...
rootSelectionProps
}
aria
-
label=
{
ariaLabel
||
placeholder
}
className=
{
className
}
fullWidth=
{
fullWidth
}
isDisabled=
{
isDisabled
}
isOpen=
{
isOpen
}
isRequired=
{
isRequired
}
name=
{
name
}
selectionMode=
{
selectionMode
}
variant=
{
variant
}
onOpenChange=
{
onOpenChange
}
onSelectionChange=
{
onSelectionChange
}
>
<
Autocomplete
.
Trigger
>
<
Autocomplete
.
Value
>
<
span
className=
"block min-w-0 truncate"
title=
{
resolvedValueText
}
>
{
resolvedValueText
}
</
span
>
</
Autocomplete
.
Value
>
<
Autocomplete
.
Indicator
>
<
ChevronDown
size=
{
16
}
style=
{
FIELD_SUFFIX_ICON_STYLE
}
/>
</
Autocomplete
.
Indicator
>
</
Autocomplete
.
Trigger
>
<
Autocomplete
.
Popover
placement=
{
placement
}
>
<
Autocomplete
.
Filter
filter=
{
shouldFilter
?
filter
||
defaultFilter
:
()
=>
true
}
inputValue=
{
inputValue
}
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
>
<
ListBox
aria
-
label=
{
ariaLabel
||
placeholder
}
items=
{
visibleOptions
}
renderEmptyState=
{
()
=>
(
<
div
className=
"grid min-h-20 place-items-center px-3 py-4"
data
-
slot=
"empty-state"
>
{
isLoading
?
<
Spinner
size=
"sm"
/>
:
errorText
||
emptyText
}
</
div
>
)
}
>
{
(
option
)
=>
(
<
ListBox
.
Item
id=
{
option
.
id
}
isDisabled=
{
option
.
isDisabled
}
textValue=
{
option
.
textValue
||
option
.
label
}
>
{
renderOption
?
(
renderOption
(
option
)
)
:
(
<
Label
className=
"truncate"
title=
{
option
.
label
}
>
{
option
.
label
}
</
Label
>
)
}
<
ListBox
.
ItemIndicator
/>
</
ListBox
.
Item
>
)
}
</
ListBox
>
</
Autocomplete
.
Filter
>
</
Autocomplete
.
Popover
>
</
Autocomplete
>
);
}
src/components/record-workbench/index.jsx
View file @
84dfb853
...
@@ -175,6 +175,7 @@ function WorkbenchPagination({ pagination }) {
...
@@ -175,6 +175,7 @@ function WorkbenchPagination({ pagination }) {
}
}
export default function RecordWorkbench({
export default function RecordWorkbench({
accessDenied = false,
views = EMPTY_ARRAY,
views = EMPTY_ARRAY,
activeViewName,
activeViewName,
onViewChange,
onViewChange,
...
@@ -370,6 +371,9 @@ export default function RecordWorkbench({
...
@@ -370,6 +371,9 @@ export default function RecordWorkbench({
) : null}
) : null}
{states.error ? <Alert status="danger"><Alert.Content><Alert.Description>{states.error}</Alert.Description></Alert.Content></Alert> : null}
{states.error ? <Alert status="danger"><Alert.Content><Alert.Description>{states.error}</Alert.Description></Alert.Content></Alert> : null}
<div className="relative min-w-0" ref={gridWrapRef}>
<div className="relative min-w-0" ref={gridWrapRef}>
{accessDenied ? (
<DataGridEmpty title="当前账号没有可访问的数据视图" />
) : (
<DataGrid
<DataGrid
showSelectionCheckboxes={Boolean(onSelectionChange)}
showSelectionCheckboxes={Boolean(onSelectionChange)}
allowsColumnResize
allowsColumnResize
...
@@ -387,12 +391,13 @@ export default function RecordWorkbench({
...
@@ -387,12 +391,13 @@ export default function RecordWorkbench({
verticalAlign="middle"
verticalAlign="middle"
onSelectionChange={onSelectionChange ? handleSelectionChange : undefined}
onSelectionChange={onSelectionChange ? handleSelectionChange : undefined}
/>
/>
)}
{states.loading && records.length > 0 ? <Surface className="absolute inset-0 grid place-items-center" variant="secondary"><Spinner size="sm" /></Surface> : null}
{states.loading && records.length > 0 ? <Surface className="absolute inset-0 grid place-items-center" variant="secondary"><Spinner size="sm" /></Surface> : null}
</div>
</div>
<WorkbenchPagination pagination={pagination} />
{!accessDenied ? <WorkbenchPagination pagination={pagination} /> : null}
</section>
</section>
<ActionBar aria-label="批量操作" isOpen={selectedIds.length > 0}>
<ActionBar aria-label="批量操作" isOpen={
!accessDenied &&
selectedIds.length > 0}>
<ActionBar.Prefix><span className="whitespace-nowrap text-sm text-muted">已选择 {selectedIds.length} 条记录</span></ActionBar.Prefix>
<ActionBar.Prefix><span className="whitespace-nowrap text-sm text-muted">已选择 {selectedIds.length} 条记录</span></ActionBar.Prefix>
{visibleSelectionActions.length > 0 ? (
{visibleSelectionActions.length > 0 ? (
<ActionBar.Content>
<ActionBar.Content>
...
...
src/config/application.js
View file @
84dfb853
export
const
APPLICATION_DISPLAY_NAME
=
'廉洁从业管理'
;
export
const
APPLICATION_DISPLAY_NAME
=
'廉洁从业管理'
;
export
const
APPLICATION_ROUTE_NAME
=
'integrity_practice_management'
;
export
const
APPLICATION_NAME
=
'integrity_practice_management'
;
export
const
APPLICATION_BASE_PATH
=
'/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
THEME_STORAGE_KEY
=
'xinyuan_color_theme'
;
export
const
ROUTE_TITLES
=
Object
.
freeze
({
export
const
ROUTE_TITLES
=
Object
.
freeze
({
...
@@ -11,4 +13,5 @@ export const ROUTE_TITLES = Object.freeze({
...
@@ -11,4 +13,5 @@ export const ROUTE_TITLES = Object.freeze({
'/self_check_the_draft_template'
:
'自查底稿模版'
,
'/self_check_the_draft_template'
:
'自查底稿模版'
,
'/self_check_the_working_draft'
:
'自查底稿项目'
,
'/self_check_the_working_draft'
:
'自查底稿项目'
,
'/self_inspection_annual_submission_materials'
:
'历年报送材料'
,
'/self_inspection_annual_submission_materials'
:
'历年报送材料'
,
'/notifications'
:
'通知消息'
,
});
});
src/pages/RecordDetailDrawer/components/SubjectViewContent.jsx
View file @
84dfb853
import
clsx
from
'clsx'
;
import
clsx
from
'clsx'
;
import
{
EmptyState
}
from
'@heroui-pro/react/empty-state'
;
import
{
EmptyState
}
from
'@heroui-pro/react/empty-state'
;
import
{
Surface
}
from
'@heroui/react'
;
import
TailwindEditableField
from
'./TailwindEditableField'
;
import
TailwindEditableField
from
'./TailwindEditableField'
;
import
{
import
{
buildViewColumns
,
buildViewColumns
,
getFieldValue
,
getFieldValue
,
isFieldDisabledByView
,
isFieldDisabledByView
,
isReferenceFieldType
,
}
from
'@/utils/subjectView'
;
}
from
'@/utils/subjectView'
;
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
();
}
function
getFieldLabel
(
field
)
{
const
fieldName
=
getFieldName
(
field
?.
field
);
const
configuredLabel
=
String
(
field
?.
label
||
''
).
trim
();
const
isTechnicalLabel
=
!
configuredLabel
||
configuredLabel
===
field
?.
field
||
configuredLabel
===
fieldName
||
configuredLabel
.
startsWith
(
'metadata.'
);
if
(
!
isTechnicalLabel
)
{
return
configuredLabel
;
}
return
FIELD_LABEL_OVERRIDES
[
fieldName
]
||
field
?.
fieldDef
?.
label
||
'其他信息'
;
}
export
default
function
SubjectViewContent
({
export
default
function
SubjectViewContent
({
view
,
view
,
subject
,
subject
,
editableRecord
,
editableRecord
,
onFieldUpdate
,
editSessionId
,
mode
=
'view'
,
mode
=
'view'
,
onFieldChange
,
onPendingLocalMaterialChange
,
onFieldValidation
,
readonly
,
readonly
,
isFieldDisabled
,
isFieldDisabled
,
onOpenReference
,
minimal
=
false
,
useAuditMaterialCenter
=
false
,
})
{
})
{
const
displayFields
=
buildViewColumns
(
view
,
subject
);
const
displayFields
=
buildViewColumns
(
view
,
subject
);
const
fullWidthFieldTypes
=
new
Set
([
const
contentFields
=
minimal
'long_text'
,
?
(
displayFields
||
[]).
filter
((
field
)
=>
field
.
field
!==
'title'
)
'rich_text'
,
:
displayFields
;
'object'
,
const
fieldVariant
=
'secondary'
;
'form'
,
'subject'
,
'file'
,
'reference'
,
]);
const
handleFieldUpdate
=
(
fieldPath
,
newValue
,
fieldDef
)
=>
{
if
(
!
contentFields
||
contentFields
.
length
===
0
)
{
onFieldUpdate
(
fieldPath
,
newValue
,
fieldDef
);
if
(
minimal
)
{
};
return
null
;
}
if
(
!
displayFields
||
displayFields
.
length
===
0
)
{
return
(
return
(
<
EmptyState
size=
"md"
>
<
EmptyState
size=
"md"
>
<
EmptyState
.
Header
><
EmptyState
.
Title
>
暂无可显示的字段
</
EmptyState
.
Title
></
EmptyState
.
Header
>
<
EmptyState
.
Header
><
EmptyState
.
Title
>
暂无可显示的字段
</
EmptyState
.
Title
></
EmptyState
.
Header
>
...
@@ -41,43 +78,68 @@ export default function SubjectViewContent({
...
@@ -41,43 +78,68 @@ export default function SubjectViewContent({
);
);
}
}
return
(
const
fields
=
(
<
section
className=
"min-w-0"
>
<
section
<
div
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-x-8 gap-y-2 md:grid-cols-2"
{
displayFields
.
map
((
field
)
=>
{
>
{
contentFields
.
map
((
field
)
=>
{
const
value
=
getFieldValue
(
editableRecord
,
field
.
field
);
const
value
=
getFieldValue
(
editableRecord
,
field
.
field
);
const
fieldDisabled
=
const
fieldType
=
field
.
fieldDef
?.
type
||
'text'
;
isFieldDisabled
?.(
field
.
field
,
view
)
||
const
fieldLabel
=
getFieldLabel
(
field
);
isFieldDisabledByView
(
field
.
field
,
view
);
const
isFullWidth
=
FULL_WIDTH_FIELD_TYPES
.
has
(
fieldType
);
const
isFullWidth
=
const
fieldDisabled
=
isFieldDisabled
fullWidthFieldTypes
.
has
(
field
.
fieldDef
?.
type
)
||
?
isFieldDisabled
(
field
.
field
,
view
)
isReferenceFieldType
(
field
.
fieldDef
?.
type
)
||
:
isFieldDisabledByView
(
field
.
field
,
view
);
field
.
fieldDef
?.
multiple
&&
field
.
fieldDef
?.
type
!==
'select'
;
return
(
return
(
<
section
<
div
key=
{
field
.
field
}
key=
{
field
.
field
}
className=
{
clsx
(
className=
{
clsx
(
'min-w-0 py-3'
,
'min-w-0'
,
minimal
?
'py-1'
:
'py-3'
,
isFullWidth
&&
'md:col-span-2'
,
isFullWidth
&&
'md:col-span-2'
,
)
}
)
}
>
>
<
div
className=
"text-sm font-medium text-muted"
>
{
field
.
label
}
</
div
>
<
div
className=
{
minimal
?
'sr-only'
:
'text-sm font-medium text-muted'
}
>
<
div
className=
"mt-1.5 min-w-0"
>
{
fieldLabel
}
</
div
>
<
div
className=
{
minimal
?
undefined
:
'mt-1.5 min-w-0'
}
>
<
TailwindEditableField
<
TailwindEditableField
value=
{
value
}
value=
{
value
}
fieldDef=
{
field
.
fieldDef
}
fieldDef=
{
field
.
fieldDef
}
onSave=
{
(
newValue
)
=>
handleFieldUpdate
(
field
.
field
,
newValue
,
field
.
fieldDef
)
}
fieldPath=
{
field
.
field
}
editSessionId=
{
editSessionId
}
fieldVariant=
{
fieldVariant
}
placeholder=
{
`请输入${fieldLabel}`
}
mode=
{
mode
}
mode=
{
mode
}
onChange=
{
(
newValue
)
=>
onFieldChange
?.(
field
.
field
,
newValue
,
field
.
fieldDef
,
editSessionId
,
)
}
onValidationChange=
{
onFieldValidation
}
onPendingLocalMaterialChange=
{
onPendingLocalMaterialChange
}
readonly=
{
readonly
}
readonly=
{
readonly
}
disabled=
{
fieldDisabled
}
disabled=
{
fieldDisabled
}
onOpenReference=
{
onOpenReference
}
useAuditMaterialCenter=
{
useAuditMaterialCenter
}
/>
/>
</
div
>
</
div
>
</
section
>
</
div
>
);
);
})
}
})
}
</
div
>
</
section
>
</
section
>
);
);
if
(
mode
===
'view'
&&
!
minimal
)
{
return
(
<
Surface
className=
"px-5 py-2 sm:px-6 sm:py-3"
variant=
"secondary"
>
{
fields
}
</
Surface
>
);
}
return
fields
;
}
}
src/pages/RecordDetailDrawer/components/TailwindEditableField.jsx
View file @
84dfb853
This diff is collapsed.
Click to expand it.
src/pages/RecordDetailDrawer/hooks/useSubjectRecord.js
View file @
84dfb853
import
{
useEffect
,
useRef
,
useState
}
from
'react'
;
import
{
useEffect
,
useRef
,
useState
}
from
'react'
;
import
{
import
{
getSubjectView
,
getSubjectView
,
getSubjectViewDetail
,
loadRecord
,
loadRecord
,
loadSubject
,
loadSubject
,
updateRecord
,
updateRecord
,
...
@@ -37,6 +38,14 @@ function transformSaveValue(newValue, fieldDef) {
...
@@ -37,6 +38,14 @@ function transformSaveValue(newValue, fieldDef) {
return
newValue
===
''
?
null
:
Number
(
newValue
);
return
newValue
===
''
?
null
:
Number
(
newValue
);
}
}
if
(
fieldDef
?.
type
===
'object'
&&
typeof
newValue
===
'string'
)
{
try
{
return
JSON
.
parse
(
newValue
);
}
catch
(
error
)
{
throw
new
Error
(
`
${
fieldDef
?.
label
||
'对象字段'
}
的
JSON
格式不正确,请检查后再保存。
`);
}
}
return newValue;
return newValue;
}
}
...
@@ -66,10 +75,8 @@ export function useSubjectRecord(
...
@@ -66,10 +75,8 @@ export function useSubjectRecord(
const [subject, setSubject] = useState(null);
const [subject, setSubject] = useState(null);
const [views, setViews] = useState([]);
const [views, setViews] = useState([]);
const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState(false);
const
[
saving
,
setSaving
]
=
useState
(
false
);
const [error, setError] = useState('');
const [error, setError] = useState('');
const requestIdRef = useRef(0);
const requestIdRef = useRef(0);
const
pendingPayloadRef
=
useRef
({});
useEffect(() => {
useEffect(() => {
let cancelled = false;
let cancelled = false;
...
@@ -91,14 +98,31 @@ export function useSubjectRecord(
...
@@ -91,14 +98,31 @@ export function useSubjectRecord(
try {
try {
const record = await loadRecord(recordName);
const record = await loadRecord(recordName);
const resolvedSubjectName = subjectName || record?.__t || record?.subject?.name || record?.subject;
const resolvedSubjectName = subjectName || record?.__t || record?.subject?.name || record?.subject;
const
[
subjectData
,
view
sData
]
=
resolvedSubjectName
const [subjectData, view
Summaries
] = resolvedSubjectName
? await Promise.all([loadSubject(resolvedSubjectName), getSubjectView(resolvedSubjectName)])
? await Promise.all([loadSubject(resolvedSubjectName), getSubjectView(resolvedSubjectName)])
: [null, []];
: [null, []];
const viewsData = await Promise.all(
(Array.isArray(viewSummaries) ? viewSummaries : []).map(async (view) => {
if (!view?.name || !resolvedSubjectName) {
return view;
}
try {
const detail = await getSubjectViewDetail(resolvedSubjectName, view.name);
return {
...view,
...detail,
settings: detail?.settings || view?.settings || {},
};
} catch (error) {
return view;
}
}),
);
if (!cancelled && requestIdRef.current === requestId) {
if (!cancelled && requestIdRef.current === requestId) {
setRemoteRecord(record);
setRemoteRecord(record);
setEditableRecord(JSON.parse(JSON.stringify(record)));
setEditableRecord(JSON.parse(JSON.stringify(record)));
pendingPayloadRef
.
current
=
{};
setSubject(subjectData);
setSubject(subjectData);
setViews(viewsData || []);
setViews(viewsData || []);
}
}
...
@@ -124,14 +148,26 @@ export function useSubjectRecord(
...
@@ -124,14 +148,26 @@ export function useSubjectRecord(
};
};
}, [recordName, refreshKey, subjectName]);
}, [recordName, refreshKey, subjectName]);
const
handleField
Update
=
(
fieldPath
,
newValue
,
fieldDef
)
=>
{
const handleField
sUpdate = async (changes = []
) => {
if (readonly || remoteRecord?.metadata?.is_locked === true || !editableRecord) {
if (readonly || remoteRecord?.metadata?.is_locked === true || !editableRecord) {
return;
return;
}
}
let
nextRecord
=
setFieldValue
(
editableRecord
,
fieldPath
,
newValue
);
const normalizedChanges = changes.filter((change) => change?.fieldPath);
if (normalizedChanges.length === 0) {
return;
}
const previousRecord = editableRecord;
let nextRecord = editableRecord;
let payload = {};
try {
normalizedChanges.forEach(({ fieldPath, newValue, fieldDef }) => {
const saveValue = transformSaveValue(newValue, fieldDef);
const saveValue = transformSaveValue(newValue, fieldDef);
let
payload
=
buildNestedPayload
(
fieldPath
,
saveValue
);
nextRecord = setFieldValue(nextRecord, fieldPath, saveValue);
payload = mergePayload(payload, buildNestedPayload(fieldPath, saveValue));
const derivedFieldValues = deriveFieldValues?.({
const derivedFieldValues = deriveFieldValues?.({
fieldPath,
fieldPath,
fieldDef,
fieldDef,
...
@@ -143,31 +179,20 @@ export function useSubjectRecord(
...
@@ -143,31 +179,20 @@ export function useSubjectRecord(
nextRecord = setFieldValue(nextRecord, derivedFieldPath, derivedValue);
nextRecord = setFieldValue(nextRecord, derivedFieldPath, derivedValue);
payload = mergePayload(payload, buildNestedPayload(derivedFieldPath, derivedValue));
payload = mergePayload(payload, buildNestedPayload(derivedFieldPath, derivedValue));
});
});
});
pendingPayloadRef
.
current
=
mergePayload
(
pendingPayloadRef
.
current
,
payload
);
setEditableRecord(nextRecord);
setEditableRecord(nextRecord);
};
const
resetDraft
=
()
=>
{
pendingPayloadRef
.
current
=
{};
setEditableRecord
(
remoteRecord
?
JSON
.
parse
(
JSON
.
stringify
(
remoteRecord
))
:
null
);
};
const
saveDraft
=
async
()
=>
{
if
(
readonly
||
remoteRecord
?.
metadata
?.
is_locked
===
true
||
!
editableRecord
)
return
;
const
payload
=
pendingPayloadRef
.
current
;
if
(
Object
.
keys
(
payload
).
length
===
0
)
return
;
setSaving
(
true
);
try
{
await updateRecord(recordName, payload);
await updateRecord(recordName, payload);
setRemoteRecord
(
JSON
.
parse
(
JSON
.
stringify
(
editableRecord
))
);
setRemoteRecord(
nextRecord
);
pendingPayloadRef
.
current
=
{};
} catch (updateError) {
}
finally
{
setEditableRecord(previousRecord);
setSaving
(
false
)
;
throw updateError
;
}
}
};
};
const handleFieldUpdate = (fieldPath, newValue, fieldDef) =>
handleFieldsUpdate([{ fieldPath, newValue, fieldDef }]);
const isFieldDisabled = (fieldPath, currentView) => {
const isFieldDisabled = (fieldPath, currentView) => {
return (
return (
fieldPath === 'is_locked' ||
fieldPath === 'is_locked' ||
...
@@ -184,9 +209,7 @@ export function useSubjectRecord(
...
@@ -184,9 +209,7 @@ export function useSubjectRecord(
views,
views,
error,
error,
handleFieldUpdate,
handleFieldUpdate,
resetDraft
,
handleFieldsUpdate,
saveDraft
,
saving
,
isFieldDisabled,
isFieldDisabled,
loading,
loading,
};
};
...
...
src/pages/RecordDetailDrawer/index.jsx
View file @
84dfb853
This diff is collapsed.
Click to expand it.
src/pages/integrity_practice_management/Layout.jsx
View file @
84dfb853
import
AppShell
from
'@/components/AppShell'
;
import
AppShell
from
'@/components/AppShell'
;
import
{
Archive
,
CheckSquare
,
FileDone
,
FileText
,
Settings
}
from
'@/components/AppIcons'
;
import
{
Archive
,
CheckSquare
,
FileDone
,
FileText
,
Settings
}
from
'@/components/AppIcons'
;
import
ApplicationSwitcherDialog
from
'@/components/ApplicationSwitcherDialog'
;
import
ApplicationSwitcherDialog
from
'@/components/ApplicationSwitcherDialog'
;
import
{
loadUnreadNotificationCount
,
NOTIFICATION_STATE_EVENT
,
}
from
'./notifications/notificationService'
;
import
{
APPLICATION_DISPLAY_NAME
,
ROUTE_TITLES
}
from
'@/config/application'
;
import
{
APPLICATION_DISPLAY_NAME
,
ROUTE_TITLES
}
from
'@/config/application'
;
import
{
UserContext
}
from
'@/wrapper/Auth'
;
import
{
UserContext
}
from
'@/wrapper/Auth'
;
import
{
SiteContext
}
from
'@/wrapper/Site'
;
import
{
SiteContext
}
from
'@/wrapper/Site'
;
...
@@ -25,6 +29,7 @@ export default function IntegrityPracticeLayout() {
...
@@ -25,6 +29,7 @@ export default function IntegrityPracticeLayout() {
const
siteInfo
=
useContext
(
SiteContext
);
const
siteInfo
=
useContext
(
SiteContext
);
const
[
sidebarOpen
,
setSidebarOpen
]
=
useState
(
true
);
const
[
sidebarOpen
,
setSidebarOpen
]
=
useState
(
true
);
const
[
switcherOpen
,
setSwitcherOpen
]
=
useState
(
false
);
const
[
switcherOpen
,
setSwitcherOpen
]
=
useState
(
false
);
const
[
unreadNotificationCount
,
setUnreadNotificationCount
]
=
useState
(
0
);
const
pageTitle
=
useMemo
(
const
pageTitle
=
useMemo
(
()
=>
ROUTE_TITLES
[
location
.
pathname
]
||
APPLICATION_DISPLAY_NAME
,
()
=>
ROUTE_TITLES
[
location
.
pathname
]
||
APPLICATION_DISPLAY_NAME
,
[
location
.
pathname
],
[
location
.
pathname
],
...
@@ -34,10 +39,32 @@ export default function IntegrityPracticeLayout() {
...
@@ -34,10 +39,32 @@ export default function IntegrityPracticeLayout() {
document
.
title
=
`
${
pageTitle
}
-
${
APPLICATION_DISPLAY_NAME
}
`
;
document
.
title
=
`
${
pageTitle
}
-
${
APPLICATION_DISPLAY_NAME
}
`
;
},
[
pageTitle
]);
},
[
pageTitle
]);
useEffect
(()
=>
{
let
cancelled
=
false
;
const
refreshUnreadCount
=
async
()
=>
{
try
{
const
count
=
await
loadUnreadNotificationCount
();
if
(
!
cancelled
)
setUnreadNotificationCount
(
count
);
}
catch
{
if
(
!
cancelled
)
setUnreadNotificationCount
(
0
);
}
};
refreshUnreadCount
();
window
.
addEventListener
(
NOTIFICATION_STATE_EVENT
,
refreshUnreadCount
);
const
timer
=
window
.
setInterval
(
refreshUnreadCount
,
60000
);
return
()
=>
{
cancelled
=
true
;
window
.
removeEventListener
(
NOTIFICATION_STATE_EVENT
,
refreshUnreadCount
);
window
.
clearInterval
(
timer
);
};
},
[
location
.
pathname
]);
return
(
return
(
<>
<>
<
AppShell
<
AppShell
menuGroups=
{
MENU_GROUPS
}
menuGroups=
{
MENU_GROUPS
}
notificationCount=
{
unreadNotificationCount
}
notificationPath=
"/notifications"
onLogout=
{
userInfo
?.
logout
}
onLogout=
{
userInfo
?.
logout
}
onOpenApplicationSwitcher=
{
()
=>
setSwitcherOpen
(
true
)
}
onOpenApplicationSwitcher=
{
()
=>
setSwitcherOpen
(
true
)
}
open=
{
sidebarOpen
}
open=
{
sidebarOpen
}
...
...
src/pages/integrity_practice_management/SubjectWorkbench.jsx
View file @
84dfb853
...
@@ -94,6 +94,7 @@ export default function SubjectWorkbench() {
...
@@ -94,6 +94,7 @@ export default function SubjectWorkbench() {
()
=>
views
.
find
((
view
)
=>
view
.
name
===
activeViewName
)
||
views
[
0
]
||
null
,
()
=>
views
.
find
((
view
)
=>
view
.
name
===
activeViewName
)
||
views
[
0
]
||
null
,
[
activeViewName
,
views
],
[
activeViewName
,
views
],
);
);
const
hasAccessibleView
=
views
.
length
>
0
;
const
columns
=
useMemo
(
const
columns
=
useMemo
(
()
=>
buildSubjectColumns
(
activeView
,
subject
),
()
=>
buildSubjectColumns
(
activeView
,
subject
),
[
activeView
,
subject
],
[
activeView
,
subject
],
...
@@ -274,6 +275,7 @@ export default function SubjectWorkbench() {
...
@@ -274,6 +275,7 @@ export default function SubjectWorkbench() {
<
Toast
.
Provider
placement=
"top"
/>
<
Toast
.
Provider
placement=
"top"
/>
<
RecordWorkbench
<
RecordWorkbench
accessDenied=
{
!
loading
&&
!
hasAccessibleView
}
views=
{
views
.
map
((
view
)
=>
({
...
view
}))
}
views=
{
views
.
map
((
view
)
=>
({
...
view
}))
}
activeViewName=
{
activeViewName
}
activeViewName=
{
activeViewName
}
onViewChange=
{
(
viewName
)
=>
{
onViewChange=
{
(
viewName
)
=>
{
...
@@ -307,17 +309,17 @@ export default function SubjectWorkbench() {
...
@@ -307,17 +309,17 @@ export default function SubjectWorkbench() {
columns=
{
columns
}
columns=
{
columns
}
records=
{
records
}
records=
{
records
}
selectedIds=
{
selectedIds
}
selectedIds=
{
selectedIds
}
onSelectionChange=
{
setSelectedIds
}
onSelectionChange=
{
hasAccessibleView
?
setSelectedIds
:
undefined
}
toolbarActions=
{
toolbarActions
}
toolbarActions=
{
hasAccessibleView
?
toolbarActions
:
EMPTY_ARRAY
}
selectionActions=
{
selectionActions
}
selectionActions=
{
hasAccessibleView
?
selectionActions
:
EMPTY_ARRAY
}
rowActions=
{
rowActions
}
rowActions=
{
hasAccessibleView
?
rowActions
:
EMPTY_ARRAY
}
states=
{
{
states=
{
{
loading
,
loading
,
error
,
error
,
loadingTitle
:
'正在加载业务数据'
,
loadingTitle
:
'正在加载业务数据'
,
loadingDescription
:
'正在读取对象结构、视图配置和记录数据。'
,
loadingDescription
:
'正在读取对象结构、视图配置和记录数据。'
,
}
}
}
}
detailPanel=
{
{
detailPanel=
{
hasAccessibleView
?
{
getRecordName
:
(
record
)
=>
record
?.
name
,
getRecordName
:
(
record
)
=>
record
?.
name
,
onDirtyClose
:
()
=>
setRefreshKey
((
value
)
=>
value
+
1
),
onDirtyClose
:
()
=>
setRefreshKey
((
value
)
=>
value
+
1
),
render
:
({
open
,
recordName
,
initialMode
,
onClose
,
onDataChange
})
=>
(
render
:
({
open
,
recordName
,
initialMode
,
onClose
,
onDataChange
})
=>
(
...
@@ -329,9 +331,11 @@ export default function SubjectWorkbench() {
...
@@ -329,9 +331,11 @@ export default function SubjectWorkbench() {
onClose=
{
onClose
}
onClose=
{
onClose
}
onDataChange=
{
onDataChange
}
onDataChange=
{
onDataChange
}
filterView=
{
subjectConfig
?.
drawerConfig
?.
filterView
||
EMPTY_ARRAY
}
filterView=
{
subjectConfig
?.
drawerConfig
?.
filterView
||
EMPTY_ARRAY
}
authorizationView=
{
activeView
}
useAuditMaterialCenter
/>
/>
),
),
}
}
}
:
null
}
pagination=
{
pagination=
{
pagination
pagination
?
{
?
{
...
...
src/pages/integrity_practice_management/components/IntegrityPracticeMaterialField.jsx
0 → 100644
View file @
84dfb853
This diff is collapsed.
Click to expand it.
src/pages/integrity_practice_management/integrity-practice-management.business-flow.md
View file @
84dfb853
...
@@ -21,21 +21,23 @@
...
@@ -21,21 +21,23 @@
## 2. 列表与视图流程
## 2. 列表与视图流程
1.
进入页面后并行调用
`GET /subject/:name`
和
`GET /subject/:name/view`
。
1.
进入页面后并行调用
`GET /subject/:name`
和
`GET /subject/:name/view`
;打开详情后再按 View 名调用
`GET /subject/:subjectName/view/:viewName`
获取完整 Detail 配置
。
2.
只展示当前用户有权访问的
`grid`
视图;配置包含
`filterView`
且非空时,只展示指定视图。
2.
只展示当前用户有权访问的
`grid`
视图;
授权只读取
`user.applications[]`
中
`name = integrity_practice_management`
的项目。系统管理员、系统账号和该应用“管理”角色可旁路,但不能旁路
`disableColumns`
。
配置包含
`filterView`
且非空时,只展示指定视图。
3.
切换视图后,根据视图的
`settings.restrictions`
、
`settings.filters.conditions`
、列排序配置调用
`POST /record/query`
。
3.
切换视图后,根据视图的
`settings.restrictions`
、
`settings.filters.conditions`
、列排序配置调用
`POST /record/query`
。
4.
`gridRecordConfig.pagination = false`
时单次最多读取 1000 条;其余页面使用服务端分页。
4.
`gridRecordConfig.pagination = false`
时单次最多读取 1000 条;其余页面使用服务端分页。
5.
关键词查找通过
`/record/query`
的
`keyword`
查询标题;清空筛选后回到第 1 页。
5.
关键词查找通过
`/record/query`
的
`keyword`
查询标题;清空筛选后回到第 1 页。
6.
列定义、列顺序、初始排序、固定列和字段显示格式由 Subject View 与 Subject 字段定义共同决定;HeroUI Pro DataGrid 仅在真实横向溢出时固定首列和操作列,并允许当前页原生列排序和列宽调整,不改变服务端分页口径。
6.
列定义、列顺序、初始排序、固定列和字段显示格式由 Subject View 与 Subject 字段定义共同决定;HeroUI Pro DataGrid 仅在真实横向溢出时固定首列和操作列,并允许当前页原生列排序和列宽调整,不改变服务端分页口径。
`settings.shortTitle === true`
时只隐藏 title 展示列,标题仍参与查询、记录身份和保存;false 或未配置时正常展示。
7.
DataGrid 使用原生 Checkbox 选择;选中后显示 HeroUI Pro ActionBar。行操作 1-3 项直接展示,4 项以上展示前两项与“更多”菜单,删除等危险动作始终排在最后。
7.
DataGrid 使用原生 Checkbox 选择;选中后显示 HeroUI Pro ActionBar。行操作 1-3 项直接展示,4 项以上展示前两项与“更多”菜单,删除等危险动作始终排在最后。
8.
点击列表操作列的预览图标打开 HeroUI Modal 并进入只读查看态;首个业务列只展示记录文本,不再承担预览交互。点击弹窗统一的“编辑”按钮或列表操作列铅笔图标进入整体编辑态,保存后返回查看态。字段权限、锁定规则和 Subject 字段类型保持不变。
8.
点击列表操作列的预览图标打开 HeroUI Modal 并进入只读查看态;首个业务列只展示记录文本,不再承担预览交互。点击弹窗统一的“编辑”按钮或列表操作列铅笔图标进入整体编辑态,保存后返回查看态。字段权限、锁定规则和 Subject 字段类型保持不变。
9.
编辑态远程引用字段通过 HeroUI 弹层搜索选择。多值字段在触发器中显示已选数量,可在弹层内连续选择、取消或清空;单值字段选中后关闭弹层。候选标题过长时单行省略但保留全文提示,写入值仍严格为
`_id`
或
`_id[]`
,不改变保存时机和
`metadata`
协议。
9.
当前 Detail 有有效
`settings.auth`
或
`settings.disableColumns`
时,字段权限整套使用 Detail;两项都未配置或没有实际 Detail 时,整体回退打开详情的当前 Grid。两套权限不合并、不逐字段回退;
`disableColumns`
对管理员也始终只读,普通用户在选定权限来源中缺少字段
`auth`
时只读。切换 Detail 后立即重算字段状态和编辑入口,编辑入口只检查当前 Detail 展示字段。Grid
`viewAuth`
只控制 Tab 可见性,不隐藏详情字段。
9.
编辑态的单选和多选属性使用 HeroUI Select/Autocomplete,日期使用 DatePicker,附件使用 HeroUI Pro DropZone;选择后立即更新当前弹窗草稿,最终仍由弹窗底部统一“保存”提交。
10.
编辑态远程引用字段通过 HeroUI 弹层搜索选择。多值字段在触发器中显示已选数量,可在弹层内连续选择、取消或清空;单值字段选中后关闭弹层。候选标题过长时单行省略但保留全文提示,写入值仍严格为
`_id`
或
`_id[]`
,不改变保存时机和
`metadata`
协议。
11.
编辑态的单选和多选属性使用 HeroUI Select/Autocomplete,日期使用 DatePicker,附件使用 HeroUI Pro DropZone;选择后立即更新当前弹窗草稿,最终仍由弹窗底部统一“保存”提交。
异常处理:
异常处理:
-
Subject、视图或记录加载失败时,使用 HeroUI Alert/EmptyState 显示错误或空态,不使用猜测字段兜底。
-
Subject、视图或记录加载失败时,使用 HeroUI Alert/EmptyState 显示错误或空态,不使用猜测字段兜底。
-
后端没有可用
`grid`
视图时,使用“全部”临时视图并按 Subject 字段生成列
。
-
没有可访问
`grid`
视图时不创建兜底 View、不查询记录,并隐藏新增、批量和行操作;只有一个可访问 View 时隐藏 Tabs 轨道
。
-
配置动作执行期间禁用重复点击;成功后使用顶部 Toast,失败时保留当前列表并显示服务端错误。
-
配置动作执行期间禁用重复点击;成功后使用顶部 Toast,失败时保留当前列表并显示服务端错误。
## 3. 记录选择与动作规则
## 3. 记录选择与动作规则
...
@@ -127,3 +129,17 @@
...
@@ -127,3 +129,17 @@
-
`POST /record/batch/remove`
-
`POST /record/batch/remove`
所有业务字段写入
`metadata`
;引用字段、人员字段和归档关联字段只保存
`_id`
或
`_id[]`
。日期由产生该状态的动作统一写入 ISO 8601 或历史协议明确要求的年份字符串。
所有业务字段写入
`metadata`
;引用字段、人员字段和归档关联字段只保存
`_id`
或
`_id[]`
。日期由产生该状态的动作统一写入 ISO 8601 或历史协议明确要求的年份字符串。
## 9. 通知中心
-
Navbar Bell 进入
`/notifications`
,Sidebar 不增加通知项;未读数来自
`GET /notice/customer/count`
,通知变更事件和 60 秒轮询刷新。
-
通知页按消息/待办、全部/未读、300ms 关键词、每页 10 条查询
`POST /notice/customer/query`
;未读状态传服务端
`status = unread`
。
-
单条和全部已读调用
`PUT /notice/customer/read`
;待办动态 action 保留服务端 action/method 名并只提交
`{notice_id}`
。
-
正文只按纯文本渲染。仅去除本应用
`/integrity_practice_management`
base 后站内跳转;其它应用和外部链接新窗口打开。
## 10. 文件与审计材料中心
-
所有运行时可编辑
`file`
字段按
`multiple`
提供“材料中心 / 本地上传”。材料中心查询
`am_material`
最近 20 条并远程搜索材料标题、文件名。
-
选择材料只保存
`{name,type,size,url}`
快照;单值替换,多值追加并按 URL 去重。清空附件不删除材料主档。
-
本地文件先调用
`/upload`
写入详情草稿;取消编辑不建档。点击保存后,对仍保留的本地文件逐个创建
`am_material`
,
`source_app = integrity_practice_management`
,全部成功后才更新业务 Record。
-
同一编辑会话记录已建档文件;材料部分成功或业务更新失败后重试只处理未建档项,避免重复材料。
src/pages/integrity_practice_management/integrityPracticeMaterialService.js
0 → 100644
View file @
84dfb853
import
dayjs
from
'dayjs'
;
import
{
createRecord
,
queryRecords
,
uploadFile
}
from
'@/services/DataService'
;
export
const
AUDIT_MATERIAL_SUBJECT
=
'am_material'
;
export
const
INTEGRITY_PRACTICE_SOURCE_APP
=
'integrity_practice_management'
;
const
MATERIAL_PAGE_SIZE
=
20
;
function
extractRecords
(
response
)
{
const
candidates
=
[
response
,
response
?.
data
,
response
?.
data
?.
list
,
response
?.
data
?.
records
,
response
?.
list
,
response
?.
records
,
response
?.
items
,
response
?.
result
,
response
?.
result
?.
records
,
];
return
candidates
.
find
((
candidate
)
=>
Array
.
isArray
(
candidate
))
||
[];
}
function
escapeRegExp
(
value
=
''
)
{
return
String
(
value
).
replace
(
/
[
.*+?^${}()|[
\]\\]
/g
,
'
\\
$&'
);
}
function
inferMaterialTitle
(
fileName
=
''
)
{
return
String
(
fileName
||
''
).
replace
(
/
\.[^
.
]
+$/
,
''
).
trim
()
||
'未命名材料'
;
}
function
normalizeFileAsset
(
uploadResult
,
file
)
{
const
candidates
=
[
uploadResult
?.
file
,
uploadResult
?.
data
?.
file
,
uploadResult
?.
data
,
uploadResult
?.
result
?.
file
,
uploadResult
?.
result
,
uploadResult
,
];
for
(
const
candidate
of
candidates
)
{
if
(
!
candidate
||
typeof
candidate
!==
'object'
)
continue
;
const
url
=
candidate
.
url
||
candidate
.
path
||
candidate
.
location
||
candidate
?.
data
?.
url
||
''
;
if
(
!
url
)
continue
;
return
{
name
:
candidate
.
name
||
candidate
.
filename
||
file
?.
name
||
'未命名文件'
,
type
:
candidate
.
type
||
candidate
.
mimetype
||
file
?.
type
||
'application/octet-stream'
,
size
:
Number
(
candidate
.
size
||
file
?.
size
||
0
),
url
,
};
}
throw
new
Error
(
'文件上传成功,但未获取到可用的文件地址。'
);
}
function
normalizeMaterial
(
record
=
{})
{
const
metadata
=
record
?.
metadata
||
{};
const
fileAsset
=
metadata
.
file_asset
||
{};
return
{
id
:
record
.
_id
||
record
.
id
||
record
.
name
||
''
,
materialTitle
:
metadata
.
material_title
||
record
.
title
||
fileAsset
.
name
||
'未命名材料'
,
fileAsset
,
archiveCategory
:
metadata
.
archive_category
||
''
,
archiveYear
:
metadata
.
archive_year
||
''
,
sourceApp
:
metadata
.
source_app
||
''
,
uploadedAt
:
metadata
.
uploaded_at
||
record
.
created_at
||
''
,
};
}
export
async
function
queryAuditMaterials
(
keyword
=
''
)
{
const
normalizedKeyword
=
String
(
keyword
||
''
).
trim
();
const
escapedKeyword
=
escapeRegExp
(
normalizedKeyword
);
const
filter
=
escapedKeyword
?
{
$or
:
[
{
'metadata.material_title'
:
{
$regex
:
escapedKeyword
,
$options
:
'i'
,
},
},
{
'metadata.file_asset.name'
:
{
$regex
:
escapedKeyword
,
$options
:
'i'
,
},
},
],
}
:
{};
const
response
=
await
queryRecords
({
subject
:
AUDIT_MATERIAL_SUBJECT
,
page
:
0
,
page_size
:
MATERIAL_PAGE_SIZE
,
filter
,
sort
:
'-updated_at -created_at'
,
mode
:
'full'
,
});
return
extractRecords
(
response
)
.
map
(
normalizeMaterial
)
.
filter
((
material
)
=>
material
.
id
&&
material
.
fileAsset
?.
url
);
}
export
async
function
uploadIntegrityPracticeFile
(
file
)
{
if
(
!
file
)
{
throw
new
Error
(
'请选择要上传的本地文件。'
);
}
const
uploadResult
=
await
uploadFile
(
file
);
return
normalizeFileAsset
(
uploadResult
,
file
);
}
export
async
function
createIntegrityPracticeMaterial
({
fileAsset
,
currentUser
})
{
if
(
!
fileAsset
?.
url
)
{
throw
new
Error
(
'缺少有效的文件地址,无法写入审计材料中心。'
);
}
const
uploaderId
=
currentUser
?.
_id
||
currentUser
?.
id
||
''
;
if
(
!
uploaderId
)
{
throw
new
Error
(
'未获取到当前登录用户,无法写入审计材料中心。'
);
}
const
materialTitle
=
inferMaterialTitle
(
fileAsset
.
name
);
const
response
=
await
createRecord
({
subject
:
AUDIT_MATERIAL_SUBJECT
,
title
:
materialTitle
,
content_type
:
'text'
,
metadata
:
{
material_title
:
materialTitle
,
file_asset
:
{
name
:
fileAsset
.
name
,
type
:
fileAsset
.
type
||
'application/octet-stream'
,
size
:
Number
(
fileAsset
.
size
||
0
),
url
:
fileAsset
.
url
,
},
source_app
:
INTEGRITY_PRACTICE_SOURCE_APP
,
uploader
:
uploaderId
,
uploaded_at
:
dayjs
().
toISOString
(),
},
});
const
createdRecord
=
response
?.
data
||
response
?.
result
||
response
;
return
{
id
:
createdRecord
?.
_id
||
createdRecord
?.
id
||
createdRecord
?.
name
||
true
,
record
:
createdRecord
,
};
}
src/pages/integrity_practice_management/integrityPracticeWorkbenchService.js
View file @
84dfb853
...
@@ -81,10 +81,7 @@ export async function loadSubjectResources(subjectName, config = {}) {
...
@@ -81,10 +81,7 @@ export async function loadSubjectResources(subjectName, config = {}) {
return
{
return
{
subject
,
subject
,
views
:
views
:
availableViews
,
availableViews
.
length
>
0
?
availableViews
:
[{
name
:
'all'
,
title
:
'全部'
,
type
:
'grid'
,
settings
:
{}
}],
};
};
}
}
...
...
src/pages/integrity_practice_management/notifications/NotificationListPage.jsx
0 → 100644
View file @
84dfb853
This diff is collapsed.
Click to expand it.
src/pages/integrity_practice_management/notifications/notificationService.js
0 → 100644
View file @
84dfb853
import
{
exacteActionFunction
,
queryUserNotice
,
readUserNotice
,
unReadNoticeCount
,
}
from
'@/services/DataService'
;
export
const
NOTIFICATION_PAGE_SIZE
=
10
;
export
const
NOTIFICATION_STATE_EVENT
=
'integrity-practice-management:notification-state'
;
function
toArray
(
value
)
{
return
Array
.
isArray
(
value
)
?
value
:
[];
}
function
toId
(
value
)
{
if
(
value
&&
typeof
value
===
'object'
)
{
return
String
(
value
.
_id
||
value
.
id
||
value
.
name
||
''
);
}
return
String
(
value
||
''
);
}
function
escapeRegExp
(
value
)
{
return
String
(
value
||
''
).
replace
(
/
[
.*+?^${}()|[
\]\\]
/g
,
'
\\
$&'
);
}
export
async
function
loadNotificationPage
({
page
=
0
,
pageSize
=
NOTIFICATION_PAGE_SIZE
,
category
=
'message'
,
readStatus
=
'all'
,
keyword
=
''
,
}
=
{})
{
const
filter
=
{
category
};
const
normalizedKeyword
=
String
(
keyword
||
''
).
trim
();
if
(
normalizedKeyword
)
{
filter
.
content
=
{
$regex
:
escapeRegExp
(
normalizedKeyword
),
$options
:
'i'
,
};
}
const
response
=
await
queryUserNotice
(
page
,
pageSize
,
filter
,
readStatus
===
'unread'
?
'unread'
:
null
,
);
return
{
list
:
toArray
(
response
?.
list
),
page
:
Number
(
response
?.
current_page
??
page
)
||
0
,
pageSize
:
Number
(
response
?.
page_size
??
pageSize
)
||
pageSize
,
total
:
Number
(
response
?.
total_notices
??
response
?.
total
??
0
)
||
0
,
totalPages
:
Number
(
response
?.
total_pages
??
0
)
||
0
,
};
}
export
async
function
loadUnreadNotificationCount
()
{
const
response
=
await
unReadNoticeCount
();
return
Math
.
max
(
0
,
Number
(
response
?.
total_notices
??
response
?.
count
??
response
?.
total
??
response
??
0
,
)
||
0
,
);
}
export
async
function
markNotificationRead
(
name
)
{
if
(
!
name
)
{
throw
new
Error
(
'通知缺少已读标识'
);
}
await
readUserNotice
(
name
);
notifyNotificationStateChanged
();
}
export
async
function
markAllNotificationsRead
()
{
await
readUserNotice
();
notifyNotificationStateChanged
();
}
export
async
function
executeNotificationAction
(
notification
,
action
)
{
const
actionName
=
typeof
action
?.
action
===
'string'
?
action
.
action
:
action
?.
action
?.
name
||
action
?.
name
;
const
methodName
=
action
?.
method_name
||
action
?.
methodName
;
if
(
!
actionName
||
!
methodName
)
{
throw
new
Error
(
'该待办操作缺少执行配置'
);
}
const
result
=
await
exacteActionFunction
(
actionName
,
methodName
,
{
notice_id
:
notification
?.
_id
,
});
notifyNotificationStateChanged
();
return
result
;
}
export
function
isNotificationRead
(
notification
,
userId
)
{
const
normalizedUserId
=
toId
(
userId
);
if
(
!
normalizedUserId
)
return
false
;
return
toArray
(
notification
?.
data
?.
logs
).
some
(
(
log
)
=>
toId
(
log
?.
user_id
)
===
normalizedUserId
,
);
}
export
function
getNotificationSender
(
notification
)
{
return
(
notification
?.
from
?.
display_name
||
notification
?.
from
?.
username
||
notification
?.
created_by
?.
display_name
||
notification
?.
created_by
?.
username
||
'系统'
);
}
export
function
getNotificationContent
(
notification
)
{
return
String
(
notification
?.
content
||
notification
?.
content_compile
||
''
).
trim
();
}
function
trimTarget
(
target
)
{
return
String
(
target
||
''
).
replace
(
/
[
),.;!?,。;!?)
]
+$/
u
,
''
);
}
export
function
extractNotificationTarget
(
notification
)
{
const
configuredTarget
=
notification
?.
settings
?.
url
||
notification
?.
settings
?.
link
||
notification
?.
data
?.
url
||
notification
?.
data
?.
link
;
if
(
configuredTarget
)
return
trimTarget
(
configuredTarget
);
const
content
=
getNotificationContent
(
notification
);
const
match
=
content
.
match
(
/https
?
:
\/\/[^\s
<>"'
]
+|
\/
integrity_practice_management
\/[^\s
<>"'
]
+/
,
);
return
trimTarget
(
match
?.[
0
]);
}
export
function
resolveNotificationTarget
(
target
)
{
const
normalizedTarget
=
trimTarget
(
target
);
if
(
!
normalizedTarget
)
return
null
;
if
(
/^https
?
:
\/\/
/i
.
test
(
normalizedTarget
))
{
try
{
const
url
=
new
URL
(
normalizedTarget
);
if
(
url
.
pathname
.
startsWith
(
'/integrity_practice_management/'
))
{
return
resolveNotificationTarget
(
`
${
url
.
pathname
}${
url
.
search
}${
url
.
hash
}
`
);
}
return
{
href
:
url
.
toString
(),
external
:
true
};
}
catch
(
error
)
{
return
null
;
}
}
const
appPrefix
=
'/integrity_practice_management'
;
if
(
!
normalizedTarget
.
startsWith
(
`
${
appPrefix
}
/`
))
{
return
normalizedTarget
.
startsWith
(
'/'
)
?
{
href
:
normalizedTarget
,
external
:
true
}
:
null
;
}
return
{
href
:
normalizedTarget
.
slice
(
appPrefix
.
length
),
external
:
false
,
};
}
export
function
notifyNotificationStateChanged
()
{
if
(
typeof
window
!==
'undefined'
)
{
window
.
dispatchEvent
(
new
CustomEvent
(
NOTIFICATION_STATE_EVENT
));
}
}
src/utils/subjectView.js
View file @
84dfb853
import
dayjs
from
'dayjs'
;
import
dayjs
from
'dayjs'
;
import
{
AUTHORIZATION_APPLICATION_NAME
}
from
'@/config/application'
;
export
function
normalizeText
(
value
)
{
export
function
normalizeText
(
value
)
{
return
String
(
value
??
''
).
trim
();
return
String
(
value
??
''
).
trim
();
...
@@ -57,62 +58,74 @@ export function getCurrentUserFromStorage() {
...
@@ -57,62 +58,74 @@ export function getCurrentUserFromStorage() {
}
}
}
}
function
collectUserGroups
(
user
)
{
function
collectIdentityValues
(
values
)
{
const
directGroups
=
toArray
(
user
?.
groups
)
return
toArray
(
values
)
.
flatMap
((
group
)
=>
[
group
?.
_id
,
group
?.
name
,
group
?.
display_name
,
group
])
.
flatMap
((
value
)
=>
value
&&
typeof
value
===
'object'
?
[
value
.
_id
,
value
.
name
,
value
.
display_name
,
value
.
displayName
,
value
.
username
]
:
value
)
.
map
(
normalizeText
)
.
map
(
normalizeText
)
.
filter
(
Boolean
);
.
filter
(
Boolean
);
const
appGroups
=
toArray
(
user
?.
applications
).
flatMap
((
app
)
=>
app
?.
groups
||
[]);
return
[...
directGroups
,
...
appGroups
].
map
(
normalizeText
).
filter
(
Boolean
);
}
}
function
collectUserRoles
(
user
)
{
function
getUserApplication
(
user
,
applicationName
=
AUTHORIZATION_APPLICATION_NAME
)
{
const
directRoles
=
toArray
(
user
?.
roles
);
return
toArray
(
user
?.
applications
).
find
(
const
appRoles
=
toArray
(
user
?.
applications
).
flatMap
((
app
)
=>
app
?.
roles
||
[]);
(
application
)
=>
normalizeText
(
application
?.
name
)
===
normalizeText
(
applicationName
),
return
[...
directRoles
,
...
appRoles
].
map
(
normalizeText
).
filter
(
Boolean
)
;
)
||
null
;
}
}
function
collectUserPermissions
(
user
)
{
function
collectApplicationAuthorization
(
user
,
applicationName
)
{
const
directPermissions
=
toArray
(
user
?.
permissions
);
const
application
=
getUserApplication
(
user
,
applicationName
);
const
appPermissions
=
toArray
(
user
?.
applications
).
flatMap
((
app
)
=>
app
?.
permissions
||
[]);
return
{
return
[...
directPermissions
,
...
appPermissions
].
map
(
normalizeText
).
filter
(
Boolean
);
groups
:
collectIdentityValues
(
application
?.
groups
),
roles
:
collectIdentityValues
(
application
?.
roles
),
permissions
:
collectIdentityValues
(
application
?.
permissions
),
};
}
}
function
intersects
(
left
,
right
)
{
function
intersects
(
left
,
right
)
{
const
rightSet
=
new
Set
(
toArray
(
right
).
map
(
normalizeText
).
filter
(
Boolean
));
const
rightSet
=
new
Set
(
collectIdentityValues
(
right
));
return
toArray
(
left
).
some
((
item
)
=>
rightSet
.
has
(
normalizeText
(
item
)
));
return
collectIdentityValues
(
left
).
some
((
item
)
=>
rightSet
.
has
(
item
));
}
}
export
function
hasAuthAccess
(
authRule
,
user
=
getCurrentUserFromStorage
())
{
export
function
isSystemAdministrator
(
user
=
getCurrentUserFromStorage
())
{
if
(
!
authRule
)
{
return
user
?.
type
===
'admin'
||
user
?.
type
===
'system'
;
return
true
;
}
}
export
function
hasApplicationManagementAccess
(
user
=
getCurrentUserFromStorage
(),
applicationName
=
AUTHORIZATION_APPLICATION_NAME
,
)
{
if
(
isSystemAdministrator
(
user
))
return
true
;
return
intersects
(
collectApplicationAuthorization
(
user
,
applicationName
).
roles
,
[
'管理'
]);
}
export
function
hasAuthAccess
(
authRule
,
user
=
getCurrentUserFromStorage
(),
applicationName
=
AUTHORIZATION_APPLICATION_NAME
,
)
{
if
(
!
user
)
{
if
(
!
user
)
{
return
false
;
return
false
;
}
}
if
(
user
.
type
===
'admin'
||
user
.
type
===
'system'
)
{
if
(
hasApplicationManagementAccess
(
user
,
applicationName
)
)
{
return
true
;
return
true
;
}
}
if
(
authRule
.
type
===
'group'
)
{
const
{
groups
,
roles
,
permissions
}
=
collectApplicationAuthorization
(
user
,
applicationName
);
return
intersects
(
collectUserGroups
(
user
),
authRule
.
groups
);
if
(
typeof
authRule
===
'string'
)
{
return
intersects
(
roles
,
[
authRule
]);
}
}
if
(
authRule
?.
type
===
'group'
)
{
if
(
authRule
.
type
===
'role'
)
{
return
intersects
(
groups
,
authRule
.
groups
);
return
intersects
(
collectUserRoles
(
user
),
authRule
.
roles
);
}
}
if
(
authRule
?.
type
===
'role'
)
{
if
(
authRule
.
type
===
'permission'
)
{
return
intersects
(
roles
,
authRule
.
roles
);
return
intersects
(
collectUserPermissions
(
user
),
authRule
.
permissions
);
}
}
if
(
authRule
?.
type
===
'permission'
)
{
if
(
authRule
.
type
===
'username'
)
{
return
intersects
(
permissions
,
authRule
.
permissions
);
return
intersects
([
user
.
username
,
user
.
display_name
,
user
.
_id
],
authRule
.
usernames
);
}
}
return
false
;
return
intersects
(
collectUserRoles
(
user
),
[
'管理'
]);
}
}
export
function
getFieldValue
(
record
,
fieldPath
)
{
export
function
getFieldValue
(
record
,
fieldPath
)
{
...
@@ -262,12 +275,18 @@ export function normalizeRecordLabel(value) {
...
@@ -262,12 +275,18 @@ export function normalizeRecordLabel(value) {
return
normalizeText
(
value
);
return
normalizeText
(
value
);
}
}
export
function
shouldHideViewTitleColumn
(
view
)
{
return
view
?.
settings
?.
shortTitle
===
true
;
}
export
function
buildViewColumns
(
view
,
subject
,
{
includeHidden
=
false
}
=
{})
{
export
function
buildViewColumns
(
view
,
subject
,
{
includeHidden
=
false
}
=
{})
{
const
configuredColumns
=
view
?.
settings
?.
columns
||
[];
const
configuredColumns
=
view
?.
settings
?.
columns
||
[];
const
hideTitleColumn
=
!
includeHidden
&&
shouldHideViewTitleColumn
(
view
);
if
(
configuredColumns
.
length
>
0
)
{
if
(
configuredColumns
.
length
>
0
)
{
return
configuredColumns
return
configuredColumns
.
filter
((
column
)
=>
includeHidden
||
!
column
.
hide
)
.
filter
((
column
)
=>
(
includeHidden
||
!
column
.
hide
)
&&
(
!
hideTitleColumn
||
column
.
field
!==
'title'
))
.
map
((
column
)
=>
{
.
map
((
column
)
=>
{
const
fieldDef
=
getFieldDefinition
(
column
.
field
,
subject
);
const
fieldDef
=
getFieldDefinition
(
column
.
field
,
subject
);
const
label
=
getFieldLabel
(
column
.
field
,
fieldDef
,
column
.
label
);
const
label
=
getFieldLabel
(
column
.
field
,
fieldDef
,
column
.
label
);
...
@@ -285,12 +304,14 @@ export function buildViewColumns(view, subject, { includeHidden = false } = {})
...
@@ -285,12 +304,14 @@ export function buildViewColumns(view, subject, { includeHidden = false } = {})
}
}
const
defaultColumns
=
[];
const
defaultColumns
=
[];
if
(
!
hideTitleColumn
)
{
defaultColumns
.
push
({
defaultColumns
.
push
({
field
:
'title'
,
field
:
'title'
,
width
:
180
,
width
:
180
,
label
:
'标题'
,
label
:
'标题'
,
fieldDef
:
{
name
:
'title'
,
label
:
'标题'
,
type
:
'text'
},
fieldDef
:
{
name
:
'title'
,
label
:
'标题'
,
type
:
'text'
},
});
});
}
toArray
(
subject
?.
fields
).
forEach
((
field
)
=>
{
toArray
(
subject
?.
fields
).
forEach
((
field
)
=>
{
const
fieldPath
=
`metadata.
${
field
.
name
}
`
;
const
fieldPath
=
`metadata.
${
field
.
name
}
`
;
...
@@ -363,18 +384,44 @@ export function checkViewCondition(condition, record) {
...
@@ -363,18 +384,44 @@ export function checkViewCondition(condition, record) {
:
results
.
every
(
Boolean
);
:
results
.
every
(
Boolean
);
}
}
export
function
isViewAccessible
(
view
,
user
=
getCurrentUserFromStorage
())
{
export
function
isViewAccessible
(
return
hasAuthAccess
(
view
?.
settings
?.
viewAuth
,
user
);
view
,
user
=
getCurrentUserFromStorage
(),
applicationName
=
AUTHORIZATION_APPLICATION_NAME
,
)
{
const
viewAuth
=
view
?.
settings
?.
viewAuth
;
const
rule
=
[
'group'
,
'role'
,
'permission'
].
includes
(
viewAuth
?.
type
)
?
viewAuth
:
'管理'
;
return
hasAuthAccess
(
rule
,
user
,
applicationName
);
}
export
function
hasFieldAuthorizationConfiguration
(
view
)
{
const
settings
=
view
?.
settings
;
return
(
toArray
(
settings
?.
auth
).
some
((
rule
)
=>
normalizeText
(
rule
?.
field
))
||
toArray
(
settings
?.
disableColumns
).
some
((
column
)
=>
normalizeText
(
column
?.
field
))
);
}
}
export
function
isFieldDisabledByView
(
fieldPath
,
view
,
user
=
getCurrentUserFromStorage
())
{
export
function
isFieldDisabledByView
(
fieldPath
,
view
,
user
=
getCurrentUserFromStorage
(),
applicationName
=
AUTHORIZATION_APPLICATION_NAME
,
)
{
const
disabledColumn
=
toArray
(
view
?.
settings
?.
disableColumns
).
some
((
column
)
=>
column
.
field
===
fieldPath
);
const
disabledColumn
=
toArray
(
view
?.
settings
?.
disableColumns
).
some
((
column
)
=>
column
.
field
===
fieldPath
);
if
(
disabledColumn
)
{
if
(
disabledColumn
)
{
return
true
;
return
true
;
}
}
if
(
hasApplicationManagementAccess
(
user
,
applicationName
))
{
return
false
;
}
const
fieldAuthRule
=
toArray
(
view
?.
settings
?.
auth
).
find
((
rule
)
=>
rule
.
field
===
fieldPath
);
const
fieldAuthRule
=
toArray
(
view
?.
settings
?.
auth
).
find
((
rule
)
=>
rule
.
field
===
fieldPath
);
return
fieldAuthRule
?
!
hasAuthAccess
(
fieldAuthRule
,
user
)
:
false
;
const
rule
=
[
'group'
,
'role'
,
'permission'
].
includes
(
fieldAuthRule
?.
type
)
?
fieldAuthRule
:
'管理'
;
return
!
hasAuthAccess
(
rule
,
user
,
applicationName
);
}
}
export
function
recordMatchesRestrictions
(
record
,
restrictions
=
[])
{
export
function
recordMatchesRestrictions
(
record
,
restrictions
=
[])
{
...
...
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