Skip to content
Projects
Groups
Snippets
Help
Loading...
Sign in / Register
Toggle navigation
I
integrity_risk_prevention
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_risk_prevention
Commits
9d6dea24
Commit
9d6dea24
authored
Aug 27, 2026
by
Andy-bubu
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: align cross-app permissions and workflows
parent
473e68e6
Expand all
Hide whitespace changes
Inline
Side-by-side
Showing
26 changed files
with
829 additions
and
148 deletions
+829
-148
.umirc.ts
.umirc.ts
+5
-0
ui-architecture.md
docs/ui-architecture.md
+10
-1
ui-migration-checklist.md
docs/ui-migration-checklist.md
+12
-1
xinyuan-design-system.md
docs/xinyuan-design-system.md
+9
-0
xinyuan-ui-audit.md
docs/xinyuan-ui-audit.md
+41
-0
AppIcons.jsx
src/components/AppIcons.jsx
+2
-0
AppShell.jsx
src/components/AppShell.jsx
+4
-0
DashboardNavbar.jsx
src/components/DashboardNavbar.jsx
+36
-3
FileUploadField.jsx
src/components/FileUploadField.jsx
+7
-3
SubjectRecordWorkbench.jsx
src/components/SubjectRecordWorkbench.jsx
+38
-9
XinyuanAutocomplete.jsx
src/components/XinyuanAutocomplete.jsx
+2
-1
application.js
src/config/application.js
+5
-2
SubjectViewContent.jsx
...ages/RecordDetailDrawer/components/SubjectViewContent.jsx
+5
-3
TailwindEditableField.jsx
...s/RecordDetailDrawer/components/TailwindEditableField.jsx
+13
-38
useSubjectRecord.js
src/pages/RecordDetailDrawer/hooks/useSubjectRecord.js
+18
-1
index.jsx
src/pages/RecordDetailDrawer/index.jsx
+89
-19
ApplicationSwitcherDialog.jsx
...s/integrity_risk_prevention/ApplicationSwitcherDialog.jsx
+5
-5
IntegrityRiskMaterialField.jsx
.../integrity_risk_prevention/IntegrityRiskMaterialField.jsx
+0
-0
IntegrityRiskMaterialService.js
...integrity_risk_prevention/IntegrityRiskMaterialService.js
+122
-0
IntegrityRiskWorkbenchPage.jsx
.../integrity_risk_prevention/IntegrityRiskWorkbenchPage.jsx
+1
-7
Layout.jsx
src/pages/integrity_risk_prevention/Layout.jsx
+28
-0
integrity-risk-prevention.business-flow.md
...isk_prevention/integrity-risk-prevention.business-flow.md
+44
-2
NotificationListPage.jsx
...ty_risk_prevention/notifications/NotificationListPage.jsx
+0
-0
notificationService.js
...rity_risk_prevention/notifications/notificationService.js
+170
-0
SubjectWorkbenchService.js
src/services/SubjectWorkbenchService.js
+26
-17
subjectView.js
src/utils/subjectView.js
+137
-36
No files found.
.umirc.ts
View file @
9d6dea24
...
@@ -12,6 +12,7 @@ const ROUTE_PATHS = {
...
@@ -12,6 +12,7 @@ const ROUTE_PATHS = {
measureDescription
:
'/measure_description'
,
measureDescription
:
'/measure_description'
,
archive
:
'/integrity_risk_control_archive'
,
archive
:
'/integrity_risk_control_archive'
,
controlMeasureForm
:
'/control_measure_form'
,
controlMeasureForm
:
'/control_measure_form'
,
notifications
:
'/notifications'
,
}
as
const
;
}
as
const
;
const
APP_BASE_PATH
=
'/integrity_risk_prevention_new/'
;
const
APP_BASE_PATH
=
'/integrity_risk_prevention_new/'
;
...
@@ -61,6 +62,10 @@ export default defineConfig({
...
@@ -61,6 +62,10 @@ export default defineConfig({
path
:
ROUTE_PATHS
.
archive
,
path
:
ROUTE_PATHS
.
archive
,
component
:
'./integrity_risk_prevention/IntegrityRiskArchivePage'
,
component
:
'./integrity_risk_prevention/IntegrityRiskArchivePage'
,
},
},
{
path
:
ROUTE_PATHS
.
notifications
,
component
:
'./integrity_risk_prevention/notifications/NotificationListPage'
,
},
],
],
},
},
],
],
...
...
docs/ui-architecture.md
View file @
9d6dea24
...
@@ -34,4 +34,13 @@
...
@@ -34,4 +34,13 @@
## 验收证据
## 验收证据
浏览器截图、控制台检查、构建、依赖树和扫描结果已写入
`docs/xinyuan-ui-audit.md`
。本轮目标服务运行于
`http://localhost:8000/integrity_risk_prevention_new/`
,应用切换专项基准运行于
`http://localhost:8004/model_analysis/`
。业务 API、参数、数据结构、路由、权限、校验、分页口径、保存回调、登录退出和请求时机均未改动;变更集中在 HeroUI 组件结构、动态分组编排和共享布局几何。
浏览器截图、控制台检查、构建、依赖树和扫描结果已写入
`docs/xinyuan-ui-audit.md`
。本轮目标服务运行于
`http://localhost:8002/integrity_risk_prevention_new/`
;问题整改及专项基准的对照证据记录在审计文档中。业务 API、参数、数据结构、原路由、原权限、校验、分页口径、保存回调、登录退出和请求时机均保持不变;五项跨应用能力仅增加合同明确授权的路由、请求与状态。
## Subject View、通知与材料状态所有权
-
`SubjectWorkbenchService`
先读取 Subject 和完整 View 详情,使用目标应用单一授权项过滤 Grid View,只有存在可访问 View 时才发起记录查询。页面只消费授权后的 View,不制造普通用户兜底。
-
`subjectView.js`
负责严格
`shortTitle`
、标题搜索列和应用内 groups/roles/permissions 匹配;Detail 显示列与字段授权分离,
`RecordDetailDrawer`
按当前 Detail 或完整回退 Grid View 选择一套权限来源。
-
`APPLICATION_NAME`
是路由 base、应用切换当前态和 Subject View 授权的单一身份源。
`useSubjectRecord`
展开每个 View 摘要为完整配置;
`RecordDetailDrawer`
先按当前 Detail 是否存在有效字段权限选择整套 Detail 或活动 Grid,不合并权限、不逐字段回退。
-
`SubjectRecordWorkbench`
在 View 尚未加载时只渲染 HeroUI Spinner,在授权结果没有 Grid View 时终止数据区编排并直接渲染 HeroUI Pro
`EmptyState`
;
`buildGridViews`
永不制造兜底 View。
`XinyuanAutocomplete`
只在收到显式布尔
`isOpen`
时采用受控打开态,其余打开、焦点和 Portal 状态由 HeroUI 管理。
-
通知 service 复用 DataService 的平台接口,Shell 只持有未读数量和 60 秒刷新;通知页面持有分类、未读筛选、搜索、分页和操作状态。链接解析仅剥离当前应用 basePath。
-
`IntegrityRiskMaterialField`
持有材料搜索与上传草稿,
`RecordDetailDrawer`
持有编辑会话内待建档标记。所有待建档文件成功创建材料主档后,原
`handleFieldsUpdate`
才按既有 payload 更新业务记录;失败不会清空草稿或成功标记。
docs/ui-migration-checklist.md
View file @
9d6dea24
...
@@ -30,5 +30,16 @@
...
@@ -30,5 +30,16 @@
-
[
x
]
浅色与深色四视口浏览器验收完成,截图和控制台结果已记录在
`docs/xinyuan-ui-audit.md`
。
-
[
x
]
浅色与深色四视口浏览器验收完成,截图和控制台结果已记录在
`docs/xinyuan-ui-audit.md`
。
-
[
x
]
`pnpm run build`
、
`git diff --check`
、Skill audit 已执行;audit 的 pnpm store/CJS 子路径警告已人工复核为工具误报。
-
[
x
]
`pnpm run build`
、
`git diff --check`
、Skill audit 已执行;audit 的 pnpm store/CJS 子路径警告已人工复核为工具误报。
-
[
x
]
Umi watcher 已排除浏览器验收/构建产物;开发态
`umi.css`
/Inter 字体 HMR 循环已复测并记录。
-
[
x
]
Umi watcher 已排除浏览器验收/构建产物;开发态
`umi.css`
/Inter 字体 HMR 循环已复测并记录。
-
[
x
]
完整 View 详情已加载,Grid
`shortTitle`
保留标题搜索语义,Detail
`shortTitle`
跟随当前详情 View。
-
[
x
]
Subject View 授权只读取
`integrity_risk_prevention_new`
,无权限时不查询记录且隐藏新增、批量、选择和行操作。
-
[
x
]
新增独立
`/notifications`
页面和 Navbar 原生 Badge 通知入口,复用平台通知、已读、动态 action 与跳转协议。
-
[
x
]
可编辑 file 字段已接入材料中心/本地上传,使用目标
`source_app`
、文件快照和保存时延迟建档去重流程。
-
[
x
]
View 加载阶段只显示 HeroUI Spinner;无 Grid View 权限直接展示 HeroUI Pro EmptyState,不建立兜底 View、不查询业务记录且隐藏所有数据操作入口。
-
[
x
]
Autocomplete 未传入
`isOpen`
时保持非受控,避免 Trigger 被
`undefined`
锁死;受控打开态仍按调用方布尔值工作。
-
[
x
]
使用 Playwright fixture 验证四视口浅/深色的权限矩阵、通知数量边界、材料选择/本地上传、单值替换、多值 Tag 独立删除、取消和失败重试去重;后端恢复后已补做真实登录、列表/View、通知空态和应用切换只读验收。
-
[
x
]
通知事务覆盖搜索正则转义、未读、分页、单条/全部已读、动态 action 与站内外跳转;材料事务验证业务保存失败后草稿保留且重试不重复创建材料主档。
-
[
x
]
对齐问题整改
`c047ec4`
:稳定
`APPLICATION_NAME`
统一路由、应用切换与授权归属,顶层及其他应用“管理”角色不能越权。
-
[
x
]
对齐问题整改
`6763c26`
:详情加载完整 View,Detail 字段权限整套优先、空配置整套回退 Grid、
`disableColumns`
不可旁路且切换 Detail 立即重算。
-
[
x
]
Playwright 权限矩阵覆盖其他应用管理、顶层管理、系统账号、本应用管理、管理员
`disableColumns`
、Detail 优先、Grid 正反向回退、不逐字段合并和多 Detail 切换;控制台为零。
剩余说明:业务没有活动图表路由,因此没有凭空添加 KPI/图表数据;
`recharts`
仅保留原依赖且无源码 import。应用切换遵循
`model_analysis`
专项基准和鑫元规范,使用动态分组、移动一列及
`sm`
起固定两列。
剩余说明:业务没有活动图表路由,因此没有凭空添加 KPI/图表数据;
`recharts`
仅保留原依赖且无源码 import。应用切换遵循
`model_analysis`
专项基准和鑫元规范,使用动态分组、移动一列及
`sm`
起固定两列。
当前真实 Detail View 没有 editable file 字段,未为验收修改后端 View 配置;文件事务由浏览器 fixture 验收。
docs/xinyuan-design-system.md
View file @
9d6dea24
...
@@ -17,6 +17,7 @@
...
@@ -17,6 +17,7 @@
-
列表使用 HeroUI Pro DataGrid、ActionBar、EmptyState 和 HeroUI Pagination。
-
列表使用 HeroUI Pro DataGrid、ActionBar、EmptyState 和 HeroUI Pagination。
-
筛选使用 HeroUI Surface、SearchField、Autocomplete、DatePicker 和 Disclosure。
-
筛选使用 HeroUI Surface、SearchField、Autocomplete、DatePicker 和 Disclosure。
-
受控 Autocomplete 的已选值必须可逆操作:单选使用官方
`Autocomplete.ClearButton`
清除当前值;多选在 Trigger 内使用官方
`TagGroup + Tag + Tag.RemoveButton`
将每个值显示为独立 Tag 并支持单项删除,同时保留官方清除入口清空集合;下拉中的官方
`ListBox.Item`
再次点选也可移除单项。Tag、清除按钮和选中态都从同一受控
`selectedKeys`
派生,不得合并为逗号文本、使用页面自制标签或维护第二套选择状态。
-
受控 Autocomplete 的已选值必须可逆操作:单选使用官方
`Autocomplete.ClearButton`
清除当前值;多选在 Trigger 内使用官方
`TagGroup + Tag + Tag.RemoveButton`
将每个值显示为独立 Tag 并支持单项删除,同时保留官方清除入口清空集合;下拉中的官方
`ListBox.Item`
再次点选也可移除单项。Tag、清除按钮和选中态都从同一受控
`selectedKeys`
派生,不得合并为逗号文本、使用页面自制标签或维护第二套选择状态。
-
`isOpen`
只在调用方明确提供布尔值时作为受控属性传给 Autocomplete;未提供时必须让 HeroUI 管理打开态,禁止用
`isOpen={undefined}`
锁死 Trigger。
-
KPI 使用 HeroUI Pro KPI;图表使用 HeroUI Pro 图表与 ChartTooltip。
-
KPI 使用 HeroUI Pro KPI;图表使用 HeroUI Pro 图表与 ChartTooltip。
-
Modal/Dropdown/Popover/Tooltip 等 Portal 必须继承根级深色主题。
-
Modal/Dropdown/Popover/Tooltip 等 Portal 必须继承根级深色主题。
-
Modal 面板保持 HeroUI Overlay 的 24px 圆角;弹窗内结构性 Surface 只声明官方
`secondary/tertiary`
variant 和布局间距,不再通过业务 JSX 覆盖圆角、背景、描边或阴影。详情中的关联记录属于 L2 详情 Surface 内的静态 L3 内容,使用官方
`Surface variant="tertiary"`
承载,并在其内部使用共享
`DataTable`
的 HeroUI Pro DataGrid
`secondary`
variant;表头保持
`surface-secondary`
、表体保持透明并继承 L3 Surface。禁止页面自定义颜色、描边、阴影或 HeroUI 内部 BEM/slot 外观。
-
Modal 面板保持 HeroUI Overlay 的 24px 圆角;弹窗内结构性 Surface 只声明官方
`secondary/tertiary`
variant 和布局间距,不再通过业务 JSX 覆盖圆角、背景、描边或阴影。详情中的关联记录属于 L2 详情 Surface 内的静态 L3 内容,使用官方
`Surface variant="tertiary"`
承载,并在其内部使用共享
`DataTable`
的 HeroUI Pro DataGrid
`secondary`
variant;表头保持
`surface-secondary`
、表体保持透明并继承 L3 Surface。禁止页面自定义颜色、描边、阴影或 HeroUI 内部 BEM/slot 外观。
...
@@ -46,3 +47,11 @@
...
@@ -46,3 +47,11 @@
-
Sidebar 使用 Pro
`collapsible="offcanvas"`
,折叠后宽度为零。
-
Sidebar 使用 Pro
`collapsible="offcanvas"`
,折叠后宽度为零。
-
登录页使用居中认证结构,桌面卡片约 28rem,字段组间真实间距 24px。
-
登录页使用居中认证结构,桌面卡片约 28rem,字段组间真实间距 24px。
-
Umi 开发 watcher 必须忽略
`.playwright-cli`
、
`docs`
、
`dist`
、
`artifacts`
与
`node_modules`
运行产物,避免验收日志触发
`umi.css`
HMR 循环和 Inter 字体反复卸载/回退。
-
Umi 开发 watcher 必须忽略
`.playwright-cli`
、
`docs`
、
`dist`
、
`artifacts`
与
`node_modules`
运行产物,避免验收日志触发
`umi.css`
HMR 循环和 Inter 字体反复卸载/回退。
## 跨应用五项能力
-
廉洁风险防控以
`APPLICATION_NAME = integrity_risk_prevention_new`
作为路由 base、应用切换和 Subject View 授权的唯一稳定标识;授权只读取同名
`user.applications[]`
项,禁止合并顶层授权或其他应用项。无可访问 Grid View 时不查询业务记录,不渲染新增、批量、选择或行操作。
-
Grid
`viewAuth`
只控制 Tab 可见性。详情必须加载完整 Detail View;Detail 有任一有效
`auth`
/
`disableColumns`
时整套优先,两项都没有或不存在实际 Detail 时整套回退活动 Grid。两套权限不得合并或逐字段回退,
`disableColumns`
对管理员同样强制只读,编辑按钮只由当前 Detail 的展示字段计算。
-
`settings.shortTitle`
只接受严格布尔值
`true`
。Grid 隐藏的标题仍参与关键词搜索;Detail 按当前激活 View 同步隐藏记录标题和 title 字段,但始终保留 Modal 功能标题及可访问名称。
-
通知入口固定使用 HeroUI
`Badge.Anchor + Badge + Button + Tooltip`
,36px Ant Bell 位于主题按钮左侧;通知页面沿用平台协议并只渲染纯文本。
-
可编辑 file 字段使用 HeroUI Tabs、Autocomplete 和 HeroUI Pro DropZone。材料选择只保存文件快照;本地上传在详情保存时以
`source_app = integrity_risk_prevention`
延迟创建材料主档,并保留失败重试去重标记。
docs/xinyuan-ui-audit.md
View file @
9d6dea24
...
@@ -117,3 +117,44 @@
...
@@ -117,3 +117,44 @@
-
根因:Umi watcher 未排除
`.playwright-cli`
,浏览器控制台/截图日志持续写入时触发 CSS 重新编译;20 秒观测到
`umi.css`
重载 10 次,并伴随每次 3 份 Inter 字体请求。
-
根因:Umi watcher 未排除
`.playwright-cli`
,浏览器控制台/截图日志持续写入时触发 CSS 重新编译;20 秒观测到
`umi.css`
重载 10 次,并伴随每次 3 份 Inter 字体请求。
-
修复:
`.umirc.ts`
的
`chainWebpack`
增加
`config.watchOptions({ ignored: ['**/artifacts/**', '**/.playwright-cli/**', '**/docs/**', '**/dist/**', '**/node_modules/**'] })`
,与“问题整改”基准一致。该配置只影响开发 watcher,不改变生产构建、路由或业务状态。
-
修复:
`.umirc.ts`
的
`chainWebpack`
增加
`config.watchOptions({ ignored: ['**/artifacts/**', '**/.playwright-cli/**', '**/docs/**', '**/dist/**', '**/node_modules/**'] })`
,与“问题整改”基准一致。该配置只影响开发 watcher,不改变生产构建、路由或业务状态。
-
验收标准:重启开发服务后,在无源码变更的 20 秒浏览器观测窗口内
`umi.css`
与
`.woff2`
请求均为 0 次新增,
`document.fonts.status`
保持
`loaded`
,
`document.fonts.check('14px Inter')`
为
`true`
。
-
验收标准:重启开发服务后,在无源码变更的 20 秒浏览器观测窗口内
`umi.css`
与
`.woff2`
请求均为 0 次新增,
`document.fonts.status`
保持
`loaded`
,
`document.fonts.check('14px Inter')`
为
`true`
。
### 权限空态与选择器状态专项复核(2026-08-27)
-
`SubjectRecordWorkbench`
在 View 资源加载期间只渲染 HeroUI
`Spinner`
,无可访问 Grid View 时直接渲染 HeroUI Pro
`EmptyState`
;
`buildGridViews`
不再为零配置或未完成加载制造“全部记录”兜底。无权限时不发起记录查询,不显示 Tabs、搜索、分页、新增、批量、选择或行操作。已有可访问 View 的查询和业务回调保持原协议。
-
`XinyuanAutocomplete`
仅在调用方明确传入布尔
`isOpen`
时使用受控打开态;未传入时交由 HeroUI Autocomplete 自身管理,避免
`undefined`
属性锁死 Trigger。选中值、清除、Tag 删除和远程搜索协议不变。
-
本轮使用新的 Playwright 会话复核四视口浅/深色 Shell、应用切换、详情/编辑弹窗、通知页和材料字段;截图与控制台结果沿用
`/tmp/qa-target-*`
及专项截图目录。浏览器临时 route fixture 仅用于权限、通知边界和材料交互验证,不写入源码或后端业务数据。
-
本轮早期真实后端曾连接超时;最终复核时
`http://localhost:8002/api/info`
与
`https://xy.streams7.com/info`
已恢复为 HTTP 200,并已补做 Andy 真实认证会话的只读验收。失败重试等破坏性边界仍仅用浏览器 route fixture 验证,不写入后端业务数据。
### 五项能力浏览器事务验收(2026-08-27)
-
通知 Badge fixture:未读
`0 / 1 / 99 / 100`
分别得到隐藏、
`1`
、
`99`
、
`99+`
;Badge 的
`aria-label`
在 100 时仍为“100条未读通知”。
-
通知页 fixture:消息/待办、全部/未读、
`流程.*`
搜索转义、第二页、单条已读、全部已读、动态
`approval/approve`
action 均命中原平台接口;站内链接解析为
`/integrity_risk_prevention_new/risk_prevention_control_measures_table`
且不打开新窗口,外部
`https://example.com/workflow`
在新窗口打开,原页面保持通知路由。通知内容只以纯文本渲染。
-
通知页四视口浅/深色截图为
`/tmp/integrity-five-features-notifications-{1440x900,1280x720,768x1024,390x844}-{light,dark}.png`
;八组均满足
`documentElement.scrollWidth === clientWidth`
,正常路径控制台无 error/warning。
-
无权限 fixture:普通用户的目标应用授权不含 View 要求的 permission 时,
`/record/query`
请求数为 0,Grid、Tabs、搜索、新增入口均为 0,仅渲染一个 HeroUI Pro
`EmptyState`
。截图为
`/tmp/integrity-permission-empty-state-{1440x900,390x844}-light.png`
。
-
材料 fixture:材料中心 Trigger 可打开,
`制度.*`
远程搜索请求发送转义后的
`制度\\.\\*`
;选中后业务保存 payload 只包含
`{name,type,size,url}`
快照。多值已选附件独立显示并可单项删除。
-
本地上传事务 fixture:首次业务更新故意返回 HTTP 500 后,编辑草稿仍可见,材料主档创建计数为 1;重试成功时材料创建仍为 1、业务更新为 2,证明不会重复建档。第二次上传后取消,材料创建和业务更新计数均未增加,取消草稿已清除。材料 Subject 为
`am_material`
,
`source_app`
为
`integrity_risk_prevention`
。故意失败阶段控制台出现的 3 条 error 均对应预期的 HTTP 500;正常路径没有额外控制台错误。
### 真实后端只读复核(2026-08-27)
-
使用用户提供的 Andy 账号完成真实登录;认证状态只在 Playwright 临时上下文中使用,未写入仓库或环境文件。干净会话最终控制台为 0 error / 0 warning。
-
真实过程表加载 12 条可见行和两个可访问 Grid View;当前 View 的
`shortTitle=true`
最终渲染时标题列计数为 0,新建入口按管理员授权正常显示。
-
真实通知接口返回当前账号未读 0、列表为空;Navbar 不显示 Badge,独立
`/notifications`
页面显示 HeroUI Pro EmptyState,无加载错误。
-
真实应用切换弹窗计算宽度 736px、网格列为
`334px 334px`
、间距 12px,当前授权数据按“演示”分组显示;截图为
`/tmp/integrity-real-app-switcher-1440x900-light.png`
。
-
当前真实 Detail View 配置没有 editable file 字段,因此不会出现材料中心入口;未为验收修改 Subject/View 配置。材料查询、选择、本地上传、取消、失败与重试均由同一应用运行时的浏览器 fixture 覆盖。
### 问题整改权限提交对齐(2026-08-27)
-
直接审阅并对齐“问题整改”提交
`c047ec4`
(当前应用权限归属)和
`6763c26`
(Detail 字段权限优先级),没有仅依据总结重写逻辑。
-
`src/config/application.js`
现在以
`APPLICATION_NAME = 'integrity_risk_prevention_new'`
为唯一稳定标识,
`APPLICATION_BASE_PATH`
与
`AUTHORIZATION_APPLICATION_NAME`
直接派生;应用切换也直接使用
`APPLICATION_NAME`
,删除可独立漂移的 route-name 别名。
-
`subjectView.js`
只读取
`user.applications[]`
中名称为
`integrity_risk_prevention_new`
的一项,不读取用户顶层 roles/groups/permissions,也不合并其他应用授权。系统管理员、系统账号和本应用“管理”角色旁路普通授权,但
`disableColumns`
始终先判定并保持只读。
-
详情加载通过
`GET /subject/:subjectName/view/:viewName`
获取完整 View;当前 Detail 存在任一有效
`auth`
或
`disableColumns`
时整套使用 Detail 权限,两项均无有效配置或没有实际 Detail 时整套回退活动 Grid。Detail 与 Grid 不合并,也不逐字段回退;
`hasEditableFields`
只检查当前激活 Detail 的展示字段。
-
Playwright 权限矩阵结果:其他应用“管理”和顶层“管理”均为记录查询 0、Grid 0;系统账号与本应用“管理”均可进入 Grid;管理员遇到 Detail
`disableColumns`
时编辑按钮为 0;Detail 允许/Grid 拒绝时编辑按钮与输入框均为 1;空 Detail 回退到拒绝 Grid 时为 0、回退到允许 Grid 时为 1;Detail 只配置其他字段 auth 时当前字段不会回退 Grid;切换可编辑/只读 Detail 后编辑按钮从 1 立即变为 0。矩阵控制台 0 error / 0 warning。
# 五项跨应用能力迁移前审计(2026-08-26)
-
应用范围:统一展示名为“廉洁风险防控”,属于完整功能白名单,需实施 Grid/Detail
`shortTitle`
、Subject View 权限、通知中心和审计材料中心文件流程。
-
应用标识与路由:授权名和路由名均应使用
`integrity_risk_prevention_new`
,base/publicPath 为
`/integrity_risk_prevention_new/`
;禁止读取
`integrity_risk_manager`
、
`problem_rectification_new`
或其他应用授权。
-
Subject View:现有
`subjectView.js`
会合并顶层及全部
`user.applications[]`
的角色/权限,缺失规则默认允许;
`SubjectWorkbenchService`
在校验 Grid View 权限前并行查询全部记录,并在无可访问视图时生成“全部记录”兜底;
`RecordDetailDrawer`
还会以
`viewAuth`
二次过滤 Detail View。以上均不符合当前授权合同。
-
`shortTitle`
:现有
`buildViewColumns`
未按严格布尔值隐藏
`title`
,详情 Header 和字段区也未跟随当前激活 Detail View 隐藏记录标题;关键词搜索目前仅使用可见列,隐藏标题后会丢失标题检索语义。
-
通知:目标 DataService 已保留平台
`queryUserNotice`
、
`readUserNotice`
、
`unReadNoticeCount`
和动态 action 接口,但目标应用尚无
`/notifications`
路由、通知页面/service 或 Navbar Badge;Sidebar 当前没有通知入口,符合要求。
-
文件字段:运行时 Subject 协议支持
`file`
单值对象和
`multiple=true`
数组;现有详情仅直接上传业务附件。目标详情曾残留问题整改材料服务引用,必须改为廉洁风险防控自己的
`source_app`
,并保持
`{name,type,size,url}`
快照协议、延迟建档和重试去重语义。
-
UI 边界:活动业务源码使用直接 HeroUI/HeroUI Pro 组合;DataGrid、Modal、DropZone、Autocomplete、Badge/Tooltip 的新增实现继续保持原生 compound API,不引入 Catalyst、Headless UI、旧适配器或页面私有皮肤。
src/components/AppIcons.jsx
View file @
9d6dea24
import
BankOutlined
from
'@ant-design/icons/BankOutlined'
;
import
BankOutlined
from
'@ant-design/icons/BankOutlined'
;
import
BellOutlined
from
'@ant-design/icons/BellOutlined'
;
import
BarChartOutlined
from
'@ant-design/icons/BarChartOutlined'
;
import
BarChartOutlined
from
'@ant-design/icons/BarChartOutlined'
;
import
AppstoreOutlined
from
'@ant-design/icons/AppstoreOutlined'
;
import
AppstoreOutlined
from
'@ant-design/icons/AppstoreOutlined'
;
import
CalendarOutlined
from
'@ant-design/icons/CalendarOutlined'
;
import
CalendarOutlined
from
'@ant-design/icons/CalendarOutlined'
;
...
@@ -61,6 +62,7 @@ function withSize(Icon) {
...
@@ -61,6 +62,7 @@ function withSize(Icon) {
}
}
export
const
Bank
=
withSize
(
BankOutlined
);
export
const
Bank
=
withSize
(
BankOutlined
);
export
const
Bell
=
withSize
(
BellOutlined
);
export
const
Archive
=
withSize
(
InboxOutlined
);
export
const
Archive
=
withSize
(
InboxOutlined
);
export
const
AppGrid
=
withSize
(
AppstoreOutlined
);
export
const
AppGrid
=
withSize
(
AppstoreOutlined
);
export
const
BarChart
=
withSize
(
BarChartOutlined
);
export
const
BarChart
=
withSize
(
BarChartOutlined
);
...
...
src/components/AppShell.jsx
View file @
9d6dea24
...
@@ -22,6 +22,8 @@ export default function AppShell({
...
@@ -22,6 +22,8 @@ export default function AppShell({
children
,
children
,
open
,
open
,
onOpenChange
,
onOpenChange
,
notificationCount
,
notificationPath
,
title
,
title
,
...
sidebarProps
...
sidebarProps
})
{
})
{
...
@@ -52,6 +54,8 @@ export default function AppShell({
...
@@ -52,6 +54,8 @@ export default function AppShell({
<
DashboardSidebar
{
...
sidebarProps
}
/>
<
DashboardSidebar
{
...
sidebarProps
}
/>
<
Sidebar
.
Main
className=
"app-main-glass rc-app-main"
>
<
Sidebar
.
Main
className=
"app-main-glass rc-app-main"
>
<
DashboardNavbar
<
DashboardNavbar
notificationCount=
{
notificationCount
}
onOpenNotifications=
{
notificationPath
?
()
=>
history
.
push
(
notificationPath
)
:
undefined
}
theme=
{
theme
}
theme=
{
theme
}
title=
{
title
}
title=
{
title
}
onThemeChange=
{
setTheme
}
onThemeChange=
{
setTheme
}
...
...
src/components/DashboardNavbar.jsx
View file @
9d6dea24
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 +27,32 @@ export default function DashboardNavbar({ onThemeChange, theme, title }) {
...
@@ -20,6 +27,32 @@ 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"
>
{
onOpenNotifications
?
(
<
Tooltip
delay=
{
0
}
>
<
Badge
.
Anchor
>
<
Button
isIconOnly
aria
-
label=
"通知消息"
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
>
)
:
null
}
<
Tooltip
delay=
{
0
}
>
<
Tooltip
delay=
{
0
}
>
<
Button
<
Button
isIconOnly
isIconOnly
...
...
src/components/FileUploadField.jsx
View file @
9d6dea24
...
@@ -9,6 +9,7 @@ export default function FileUploadField({
...
@@ -9,6 +9,7 @@ export default function FileUploadField({
description
=
'将文件拖到此处,或从本地选择。'
,
description
=
'将文件拖到此处,或从本地选择。'
,
isDisabled
=
false
,
isDisabled
=
false
,
fileName
=
''
,
fileName
=
''
,
multiple
=
false
,
onFilesChange
,
onFilesChange
,
triggerLabel
=
'选择文件'
,
triggerLabel
=
'选择文件'
,
})
{
})
{
...
@@ -29,9 +30,9 @@ export default function FileUploadField({
...
@@ -29,9 +30,9 @@ export default function FileUploadField({
if
(
fileItems
.
length
===
0
)
return
;
if
(
fileItems
.
length
===
0
)
return
;
const
files
=
await
Promise
.
all
(
fileItems
.
map
((
item
)
=>
item
.
getFile
()));
const
files
=
await
Promise
.
all
(
fileItems
.
map
((
item
)
=>
item
.
getFile
()));
emitFiles
(
files
.
slice
(
0
,
1
));
emitFiles
(
multiple
?
files
:
files
.
slice
(
0
,
1
));
},
},
[
emitFiles
],
[
emitFiles
,
multiple
],
);
);
return
(
return
(
...
@@ -44,6 +45,7 @@ export default function FileUploadField({
...
@@ -44,6 +45,7 @@ export default function FileUploadField({
<
DropZone
.
Input
<
DropZone
.
Input
accept=
{
accept
}
accept=
{
accept
}
disabled=
{
isDisabled
}
disabled=
{
isDisabled
}
multiple=
{
multiple
}
onSelect=
{
emitFiles
}
onSelect=
{
emitFiles
}
/>
/>
<
DropZone
.
Icon
>
<
DropZone
.
Icon
>
...
@@ -51,7 +53,9 @@ export default function FileUploadField({
...
@@ -51,7 +53,9 @@ export default function FileUploadField({
</
DropZone
.
Icon
>
</
DropZone
.
Icon
>
<
DropZone
.
Label
>
{
fileName
||
'将文件拖到此处'
}
</
DropZone
.
Label
>
<
DropZone
.
Label
>
{
fileName
||
'将文件拖到此处'
}
</
DropZone
.
Label
>
<
DropZone
.
Description
>
<
DropZone
.
Description
>
{
fileName
?
'已选择文件,可重新选择进行替换。'
:
description
}
{
fileName
?
`已选择${multiple ? '文件,可继续添加。' : '文件,可重新选择进行替换。'}`
:
description
}
</
DropZone
.
Description
>
</
DropZone
.
Description
>
<
DropZone
.
Trigger
isDisabled=
{
isDisabled
}
>
<
DropZone
.
Trigger
isDisabled=
{
isDisabled
}
>
<
Upload
aria
-
hidden=
"true"
/>
<
Upload
aria
-
hidden=
"true"
/>
...
...
src/components/SubjectRecordWorkbench.jsx
View file @
9d6dea24
import
{
Button
}
from
'@heroui/react'
;
import
{
Button
,
Spinner
}
from
'@heroui/react'
;
import
{
EmptyState
}
from
'@heroui-pro/react/empty-state'
;
import
RecordWorkbench
from
'@/components/record-workbench'
;
import
RecordWorkbench
from
'@/components/record-workbench'
;
import
RecordDetailDrawer
from
'@/pages/RecordDetailDrawer'
;
import
RecordDetailDrawer
from
'@/pages/RecordDetailDrawer'
;
import
XinyuanModal
from
'@/components/XinyuanModal'
;
import
XinyuanModal
from
'@/components/XinyuanModal'
;
...
@@ -194,6 +195,7 @@ export default function SubjectRecordWorkbench({
...
@@ -194,6 +195,7 @@ export default function SubjectRecordWorkbench({
()
=>
gridViews
.
find
((
view
)
=>
view
.
name
===
activeViewName
)
||
gridViews
[
0
]
||
null
,
()
=>
gridViews
.
find
((
view
)
=>
view
.
name
===
activeViewName
)
||
gridViews
[
0
]
||
null
,
[
gridViews
,
activeViewName
],
[
gridViews
,
activeViewName
],
);
);
const
hasAccessibleGridView
=
Boolean
(
activeView
);
const
configuredView
=
useMemo
(
const
configuredView
=
useMemo
(
()
=>
buildConfiguredWorkbenchView
(
records
,
filters
,
activeView
,
subject
),
()
=>
buildConfiguredWorkbenchView
(
records
,
filters
,
activeView
,
subject
),
[
records
,
filters
,
activeView
,
subject
],
[
records
,
filters
,
activeView
,
subject
],
...
@@ -358,8 +360,8 @@ export default function SubjectRecordWorkbench({
...
@@ -358,8 +360,8 @@ export default function SubjectRecordWorkbench({
];
];
const
builtToolbarActions
=
[
const
builtToolbarActions
=
[
...
toolbarActions
.
map
(
wrapAction
),
...
(
hasAccessibleGridView
?
toolbarActions
.
map
(
wrapAction
)
:
[]
),
...(
allowCreate
&&
!
readonly
...(
hasAccessibleGridView
&&
allowCreate
&&
!
readonly
?
[{
?
[{
key
:
'create'
,
key
:
'create'
,
label
:
'新建记录'
,
label
:
'新建记录'
,
...
@@ -375,8 +377,8 @@ export default function SubjectRecordWorkbench({
...
@@ -375,8 +377,8 @@ export default function SubjectRecordWorkbench({
];
];
const
builtBulkActions
=
[
const
builtBulkActions
=
[
...
bulkActions
.
map
(
wrapAction
),
...
(
hasAccessibleGridView
?
bulkActions
.
map
(
wrapAction
)
:
[]
),
...(
allowDelete
&&
!
readonly
...(
hasAccessibleGridView
&&
allowDelete
&&
!
readonly
?
[{
?
[{
key
:
'bulk-delete'
,
key
:
'bulk-delete'
,
label
:
'删除所选'
,
label
:
'删除所选'
,
...
@@ -393,7 +395,7 @@ export default function SubjectRecordWorkbench({
...
@@ -393,7 +395,7 @@ export default function SubjectRecordWorkbench({
:
[]),
:
[]),
];
];
const
builtRowActions
=
[
const
builtRowActions
=
hasAccessibleGridView
?
[
{
{
key
:
'view-detail'
,
key
:
'view-detail'
,
label
:
'查看详情'
,
label
:
'查看详情'
,
...
@@ -432,7 +434,30 @@ export default function SubjectRecordWorkbench({
...
@@ -432,7 +434,30 @@ export default function SubjectRecordWorkbench({
onClick
:
({
record
})
=>
setDeleteTargets
([
record
.
raw
||
record
]),
onClick
:
({
record
})
=>
setDeleteTargets
([
record
.
raw
||
record
]),
}]
}]
:
[]),
:
[]),
];
]
:
[];
if
(
loading
&&
!
hasAccessibleGridView
)
{
return
(
<
div
className=
"flex min-h-64 items-center justify-center"
role=
"status"
aria
-
label=
{
`正在加载${title}`
}
>
<
Spinner
size=
"sm"
/>
</
div
>
);
}
if
(
!
loading
&&
!
error
&&
!
hasAccessibleGridView
)
{
return
(
<
div
className=
"flex min-h-64 items-center justify-center"
>
<
EmptyState
size=
"lg"
>
<
EmptyState
.
Header
>
<
EmptyState
.
Title
>
暂无可访问视图
</
EmptyState
.
Title
>
<
EmptyState
.
Description
>
当前账号没有该对象的 Grid View 查看权限。
</
EmptyState
.
Description
>
</
EmptyState
.
Header
>
</
EmptyState
>
</
div
>
);
}
return
(
return
(
<>
<>
...
@@ -464,6 +489,7 @@ export default function SubjectRecordWorkbench({
...
@@ -464,6 +489,7 @@ export default function SubjectRecordWorkbench({
records=
{
pageRecords
}
records=
{
pageRecords
}
selectedIds=
{
selectedIds
}
selectedIds=
{
selectedIds
}
onSelectionChange=
{
setSelectedIds
}
onSelectionChange=
{
setSelectedIds
}
selectable=
{
hasAccessibleGridView
}
toolbarActions=
{
builtToolbarActions
}
toolbarActions=
{
builtToolbarActions
}
bulkActions=
{
builtBulkActions
}
bulkActions=
{
builtBulkActions
}
rowActions=
{
builtRowActions
}
rowActions=
{
builtRowActions
}
...
@@ -478,6 +504,7 @@ export default function SubjectRecordWorkbench({
...
@@ -478,6 +504,7 @@ export default function SubjectRecordWorkbench({
open=
{
open
}
open=
{
open
}
recordName=
{
recordName
}
recordName=
{
recordName
}
subjectName=
{
subjectName
}
subjectName=
{
subjectName
}
authorizationView=
{
activeView
}
onClose=
{
onClose
}
onClose=
{
onClose
}
onDataChange=
{
onDataChange
}
onDataChange=
{
onDataChange
}
preferredViewName=
{
activeView
?.
title
||
activeView
?.
name
}
preferredViewName=
{
activeView
?.
title
||
activeView
?.
name
}
...
@@ -490,8 +517,10 @@ export default function SubjectRecordWorkbench({
...
@@ -490,8 +517,10 @@ export default function SubjectRecordWorkbench({
success
,
success
,
loadingTitle
:
`正在加载${title}`
,
loadingTitle
:
`正在加载${title}`
,
loadingDescription
:
'正在读取对象结构、视图配置和记录数据,请稍候。'
,
loadingDescription
:
'正在读取对象结构、视图配置和记录数据,请稍候。'
,
emptyTitle
:
'当前视图暂无数据'
,
emptyTitle
:
hasAccessibleGridView
?
'当前视图暂无数据'
:
'暂无可访问视图'
,
emptyDescription
:
emptyDescription
||
'可以切换其他视图或调整筛选条件。'
,
emptyDescription
:
hasAccessibleGridView
?
emptyDescription
||
'可以切换其他视图或调整筛选条件。'
:
'当前账号没有该对象的 Grid View 查看权限。'
,
}
}
}
}
pagination=
{
{
pagination=
{
{
page
:
safePage
,
page
:
safePage
,
...
...
src/components/XinyuanAutocomplete.jsx
View file @
9d6dea24
...
@@ -92,6 +92,7 @@ export default function XinyuanAutocomplete({
...
@@ -92,6 +92,7 @@ export default function XinyuanAutocomplete({
selectionMode
===
'multiple'
selectionMode
===
'multiple'
?
{
selectedKeys
}
?
{
selectedKeys
}
:
{
selectedKey
};
:
{
selectedKey
};
const
rootOpenProps
=
typeof
isOpen
===
'boolean'
?
{
isOpen
}
:
{};
const
activeFilter
=
shouldFilter
?
filter
||
defaultFilter
:
null
;
const
activeFilter
=
shouldFilter
?
filter
||
defaultFilter
:
null
;
const
visibleOptions
=
isLoading
||
errorText
const
visibleOptions
=
isLoading
||
errorText
?
[]
?
[]
...
@@ -139,12 +140,12 @@ export default function XinyuanAutocomplete({
...
@@ -139,12 +140,12 @@ export default function XinyuanAutocomplete({
return
(
return
(
<
Autocomplete
<
Autocomplete
{
...
rootOpenProps
}
{
...
rootSelectionProps
}
{
...
rootSelectionProps
}
aria
-
label=
{
ariaLabel
||
placeholder
}
aria
-
label=
{
ariaLabel
||
placeholder
}
className=
{
className
}
className=
{
className
}
fullWidth=
{
fullWidth
}
fullWidth=
{
fullWidth
}
isDisabled=
{
isDisabled
}
isDisabled=
{
isDisabled
}
isOpen=
{
isOpen
}
isRequired=
{
isRequired
}
isRequired=
{
isRequired
}
name=
{
name
}
name=
{
name
}
selectionMode=
{
selectionMode
}
selectionMode=
{
selectionMode
}
...
...
src/config/application.js
View file @
9d6dea24
export
const
APPLICATION_DISPLAY_NAME
=
'廉洁风险防控'
;
export
const
APPLICATION_DISPLAY_NAME
=
'廉洁风险防控'
;
export
const
APPLICATION_BASE_PATH
=
'/integrity_risk_prevention_new/'
;
export
const
APPLICATION_NAME
=
'integrity_risk_prevention_new'
;
export
const
APPLICATION_ROUTE_NAME
=
'integrity_risk_prevention_new'
;
export
const
APPLICATION_BASE_PATH
=
`/
${
APPLICATION_NAME
}
/`
;
export
const
AUTHORIZATION_APPLICATION_NAME
=
APPLICATION_NAME
;
export
const
AUDIT_MATERIAL_SOURCE_APP
=
'integrity_risk_prevention'
;
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
({
...
@@ -9,4 +11,5 @@ export const ROUTE_TITLES = Object.freeze({
...
@@ -9,4 +11,5 @@ export const ROUTE_TITLES = Object.freeze({
'/measure_description'
:
'廉洁风险防控措施数据库'
,
'/measure_description'
:
'廉洁风险防控措施数据库'
,
'/integrity_risk_control_archive'
:
'往期材料归档'
,
'/integrity_risk_control_archive'
:
'往期材料归档'
,
'/control_measure_form'
:
'廉洁风险防控填报'
,
'/control_measure_form'
:
'廉洁风险防控填报'
,
'/notifications'
:
'通知消息'
,
});
});
src/pages/RecordDetailDrawer/components/SubjectViewContent.jsx
View file @
9d6dea24
...
@@ -31,6 +31,7 @@ export default function SubjectViewContent({
...
@@ -31,6 +31,7 @@ export default function SubjectViewContent({
editSessionId
,
editSessionId
,
mode
=
'view'
,
mode
=
'view'
,
onFieldChange
,
onFieldChange
,
onPendingLocalMaterialChange
,
onFieldValidation
,
onFieldValidation
,
readonly
,
readonly
,
isFieldDisabled
,
isFieldDisabled
,
...
@@ -63,9 +64,9 @@ export default function SubjectViewContent({
...
@@ -63,9 +64,9 @@ export default function SubjectViewContent({
const
fieldType
=
field
.
fieldDef
?.
type
||
'text'
;
const
fieldType
=
field
.
fieldDef
?.
type
||
'text'
;
const
fieldLabel
=
getFieldDisplayLabel
(
field
.
fieldDef
,
field
.
field
);
const
fieldLabel
=
getFieldDisplayLabel
(
field
.
fieldDef
,
field
.
field
);
const
isFullWidth
=
FULL_WIDTH_FIELD_TYPES
.
has
(
fieldType
);
const
isFullWidth
=
FULL_WIDTH_FIELD_TYPES
.
has
(
fieldType
);
const
fieldDisabled
=
const
fieldDisabled
=
isFieldDisabled
isFieldDisabled
?.(
field
.
field
,
view
)
||
?
isFieldDisabled
(
field
.
field
,
view
)
isFieldDisabledByView
(
field
.
field
,
view
);
:
isFieldDisabledByView
(
field
.
field
,
view
);
return
(
return
(
<
div
<
div
...
@@ -97,6 +98,7 @@ export default function SubjectViewContent({
...
@@ -97,6 +98,7 @@ export default function SubjectViewContent({
)
)
}
}
onValidationChange=
{
onFieldValidation
}
onValidationChange=
{
onFieldValidation
}
onPendingLocalMaterialChange=
{
onPendingLocalMaterialChange
}
readonly=
{
readonly
}
readonly=
{
readonly
}
disabled=
{
fieldDisabled
}
disabled=
{
fieldDisabled
}
/>
/>
...
...
src/pages/RecordDetailDrawer/components/TailwindEditableField.jsx
View file @
9d6dea24
...
@@ -15,6 +15,7 @@ import FileUploadField from '@/components/FileUploadField';
...
@@ -15,6 +15,7 @@ import FileUploadField from '@/components/FileUploadField';
import
{
queryUsers
,
uploadFile
}
from
'@/services/DataService'
;
import
{
queryUsers
,
uploadFile
}
from
'@/services/DataService'
;
import
ReferenceSearchSelect
from
'@/components/ReferenceSearchSelect'
;
import
ReferenceSearchSelect
from
'@/components/ReferenceSearchSelect'
;
import
XinyuanAutocomplete
from
'@/components/XinyuanAutocomplete'
;
import
XinyuanAutocomplete
from
'@/components/XinyuanAutocomplete'
;
import
IntegrityRiskMaterialField
from
'@/pages/integrity_risk_prevention/IntegrityRiskMaterialField'
;
import
ReferenceRecordsTable
from
'./ReferenceRecordsTable'
;
import
ReferenceRecordsTable
from
'./ReferenceRecordsTable'
;
import
{
import
{
dateInputValue
,
dateInputValue
,
...
@@ -305,6 +306,7 @@ function ControlledFieldEditor({
...
@@ -305,6 +306,7 @@ function ControlledFieldEditor({
fieldVariant
,
fieldVariant
,
placeholder
,
placeholder
,
onChange
,
onChange
,
onPendingLocalMaterialChange
,
onValidationChange
,
onValidationChange
,
})
{
})
{
const
[
error
,
setError
]
=
useState
(
''
);
const
[
error
,
setError
]
=
useState
(
''
);
...
@@ -391,44 +393,15 @@ function ControlledFieldEditor({
...
@@ -391,44 +393,15 @@ function ControlledFieldEditor({
if
(
fieldType
===
'file'
)
{
if
(
fieldType
===
'file'
)
{
return
(
return
(
<
Card
variant=
"secondary"
>
<
IntegrityRiskMaterialField
<
Card
.
Content
className=
"space-y-3"
>
editSessionId=
{
editSessionId
}
{
toArray
(
value
).
length
===
0
?
(
fieldPath=
{
fieldPath
}
<
span
className=
"text-sm text-muted"
>
未上传
</
span
>
multiple=
{
Boolean
(
fieldDef
?.
multiple
)
}
)
:
(
value=
{
value
}
<
div
className=
"space-y-2"
>
onChange=
{
onChange
}
{
toArray
(
value
).
map
((
file
,
index
)
=>
{
onPendingLocalMaterialChange=
{
onPendingLocalMaterialChange
}
const
label
=
file
?.
name
||
file
?.
title
||
file
?.
url
||
`附件 ${index + 1}`
;
onValidationChange=
{
onValidationChange
}
return
(
/>
<
div
key=
{
`${label}-${index}`
}
className=
"flex items-center justify-between gap-3"
>
<
span
className=
"min-w-0 flex-1 truncate text-sm text-foreground"
>
{
label
}
</
span
>
<
Button
size=
"sm"
variant=
"danger-soft"
onPress=
{
()
=>
onChange
?.(
fieldDef
?.
multiple
?
toArray
(
value
).
filter
((
_
,
fileIndex
)
=>
fileIndex
!==
index
)
:
null
,
)
}
>
删除
</
Button
>
</
div
>
);
})
}
</
div
>
)
}
<
FileUploadField
isDisabled=
{
uploading
}
onFilesChange=
{
handleFileUpload
}
description=
"上传该字段的附件。"
triggerLabel=
"选择附件"
/>
<
FieldError
>
{
error
}
</
FieldError
>
</
Card
.
Content
>
</
Card
>
);
);
}
}
...
@@ -520,6 +493,7 @@ export default function TailwindEditableField({
...
@@ -520,6 +493,7 @@ export default function TailwindEditableField({
placeholder
,
placeholder
,
onSave
,
onSave
,
onChange
,
onChange
,
onPendingLocalMaterialChange
,
onValidationChange
,
onValidationChange
,
mode
=
'inline'
,
mode
=
'inline'
,
readonly
=
false
,
readonly
=
false
,
...
@@ -561,6 +535,7 @@ export default function TailwindEditableField({
...
@@ -561,6 +535,7 @@ export default function TailwindEditableField({
fieldVariant=
{
fieldVariant
}
fieldVariant=
{
fieldVariant
}
placeholder=
{
textPlaceholder
}
placeholder=
{
textPlaceholder
}
onChange=
{
onChange
}
onChange=
{
onChange
}
onPendingLocalMaterialChange=
{
onPendingLocalMaterialChange
}
onValidationChange=
{
onValidationChange
}
onValidationChange=
{
onValidationChange
}
/>
/>
);
);
...
...
src/pages/RecordDetailDrawer/hooks/useSubjectRecord.js
View file @
9d6dea24
import
{
useEffect
,
useRef
,
useState
}
from
'react'
;
import
{
useEffect
,
useRef
,
useState
}
from
'react'
;
import
{
import
{
getSubjectView
,
getSubjectView
,
getSubjectViewDetail
,
loadRecord
,
loadRecord
,
loadSubject
,
loadSubject
,
updateRecord
,
updateRecord
,
...
@@ -97,9 +98,25 @@ export function useSubjectRecord(
...
@@ -97,9 +98,25 @@ 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);
...
...
src/pages/RecordDetailDrawer/index.jsx
View file @
9d6dea24
...
@@ -9,15 +9,17 @@ import React, {
...
@@ -9,15 +9,17 @@ import React, {
useRef
,
useRef
,
useState
,
useState
,
}
from
'react'
;
}
from
'react'
;
import
{
UserContext
}
from
'@/wrapper/Auth'
;
import
{
createIntegrityRiskMaterial
}
from
'@/pages/integrity_risk_prevention/IntegrityRiskMaterialService'
;
import
{
useSubjectRecord
}
from
'./hooks/useSubjectRecord'
;
import
{
useSubjectRecord
}
from
'./hooks/useSubjectRecord'
;
import
SubjectViewContent
from
'./components/SubjectViewContent'
;
import
SubjectViewContent
from
'./components/SubjectViewContent'
;
import
{
import
{
buildViewColumns
,
buildViewColumns
,
checkViewCondition
,
checkViewCondition
,
getFieldValue
,
getFieldValue
,
isFieldDisabledByView
,
hasFieldAuthorizationConfiguration
,
isViewAccessible
,
setFieldValue
,
setFieldValue
,
shouldHideViewTitleColumn
,
}
from
'@/utils/subjectView'
;
}
from
'@/utils/subjectView'
;
const
EMPTY_ARRAY
=
[];
const
EMPTY_ARRAY
=
[];
...
@@ -58,6 +60,8 @@ const RecordDetailDrawer = (props, ref) => {
...
@@ -58,6 +60,8 @@ const RecordDetailDrawer = (props, ref) => {
deriveFieldValues
,
deriveFieldValues
,
}
=
props
;
}
=
props
;
const
currentUser
=
React
.
useContext
(
UserContext
);
const
[
refreshKey
,
setRefreshKey
]
=
useState
(()
=>
Date
.
now
());
const
[
refreshKey
,
setRefreshKey
]
=
useState
(()
=>
Date
.
now
());
const
[
isFullscreen
,
setIsFullscreen
]
=
useState
(
false
);
const
[
isFullscreen
,
setIsFullscreen
]
=
useState
(
false
);
const
[
activeTabKey
,
setActiveTabKey
]
=
useState
();
const
[
activeTabKey
,
setActiveTabKey
]
=
useState
();
...
@@ -68,6 +72,7 @@ const RecordDetailDrawer = (props, ref) => {
...
@@ -68,6 +72,7 @@ const RecordDetailDrawer = (props, ref) => {
const
[
validationErrors
,
setValidationErrors
]
=
useState
({});
const
[
validationErrors
,
setValidationErrors
]
=
useState
({});
const
[
saveError
,
setSaveError
]
=
useState
(
''
);
const
[
saveError
,
setSaveError
]
=
useState
(
''
);
const
editSessionRef
=
useRef
(
0
);
const
editSessionRef
=
useRef
(
0
);
const
pendingLocalMaterialsRef
=
useRef
({});
const
{
const
{
editableRecord
,
editableRecord
,
...
@@ -98,6 +103,7 @@ const RecordDetailDrawer = (props, ref) => {
...
@@ -98,6 +103,7 @@ const RecordDetailDrawer = (props, ref) => {
setDirtyFields
({});
setDirtyFields
({});
setValidationErrors
({});
setValidationErrors
({});
setSaveError
(
''
);
setSaveError
(
''
);
pendingLocalMaterialsRef
.
current
=
{};
editSessionRef
.
current
+=
1
;
editSessionRef
.
current
+=
1
;
},
[
open
,
recordName
]);
},
[
open
,
recordName
]);
...
@@ -111,7 +117,6 @@ const RecordDetailDrawer = (props, ref) => {
...
@@ -111,7 +117,6 @@ const RecordDetailDrawer = (props, ref) => {
const
filtered
=
(
views
||
[])
const
filtered
=
(
views
||
[])
.
filter
((
view
)
=>
view
.
type
===
'detail'
)
.
filter
((
view
)
=>
view
.
type
===
'detail'
)
.
filter
((
view
)
=>
(
filterView
.
length
===
0
?
true
:
filterView
.
includes
(
view
.
name
)))
.
filter
((
view
)
=>
(
filterView
.
length
===
0
?
true
:
filterView
.
includes
(
view
.
name
)))
.
filter
((
view
)
=>
isViewAccessible
(
view
))
.
filter
((
view
)
=>
checkViewCondition
(
view
.
settings
?.
visabled
,
remoteRecord
));
.
filter
((
view
)
=>
checkViewCondition
(
view
.
settings
?.
visabled
,
remoteRecord
));
if
(
filtered
.
length
>
0
)
{
if
(
filtered
.
length
>
0
)
{
...
@@ -159,6 +164,7 @@ const RecordDetailDrawer = (props, ref) => {
...
@@ -159,6 +164,7 @@ const RecordDetailDrawer = (props, ref) => {
);
);
const
activeView
=
detailViews
.
find
((
view
)
=>
view
.
name
===
activeTabKey
)
||
detailViews
[
0
];
const
activeView
=
detailViews
.
find
((
view
)
=>
view
.
name
===
activeTabKey
)
||
detailViews
[
0
];
const
hideRecordTitle
=
shouldHideViewTitleColumn
(
activeView
);
const
isRecordLocked
=
remoteRecord
?.
metadata
?.
is_locked
===
true
;
const
isRecordLocked
=
remoteRecord
?.
metadata
?.
is_locked
===
true
;
const
effectiveReadonly
=
readonly
||
isRecordLocked
;
const
effectiveReadonly
=
readonly
||
isRecordLocked
;
const
disabledFieldSet
=
useMemo
(
const
disabledFieldSet
=
useMemo
(
...
@@ -168,10 +174,16 @@ const RecordDetailDrawer = (props, ref) => {
...
@@ -168,10 +174,16 @@ const RecordDetailDrawer = (props, ref) => {
),
),
[
disabledFields
],
[
disabledFields
],
);
);
const
isDrawerFieldDisabled
=
(
fieldPath
,
currentView
)
=>
const
isDrawerFieldDisabled
=
(
fieldPath
,
currentView
)
=>
{
disabledFieldSet
.
has
(
fieldPath
)
||
const
effectiveAuthorizationView
=
hasFieldAuthorizationConfiguration
(
currentView
)
isFieldDisabled
(
fieldPath
,
currentView
)
||
?
currentView
isFieldDisabledByView
(
fieldPath
,
authorizationView
);
:
authorizationView
||
currentView
;
return
(
disabledFieldSet
.
has
(
fieldPath
)
||
isFieldDisabled
(
fieldPath
,
effectiveAuthorizationView
)
);
};
const
hasEditableFields
=
const
hasEditableFields
=
Boolean
(
editableRecord
)
&&
Boolean
(
editableRecord
)
&&
...
@@ -179,12 +191,10 @@ const RecordDetailDrawer = (props, ref) => {
...
@@ -179,12 +191,10 @@ const RecordDetailDrawer = (props, ref) => {
!
error
&&
!
error
&&
!
effectiveReadonly
&&
!
effectiveReadonly
&&
!
minimalContent
&&
!
minimalContent
&&
detailViews
.
some
((
view
)
=>
buildViewColumns
(
activeView
,
subject
).
some
(
buildViewColumns
(
view
,
subject
).
some
(
(
field
)
=>
(
field
)
=>
!
NON_EDITABLE_FIELD_TYPES
.
has
(
field
.
fieldDef
?.
type
)
&&
!
NON_EDITABLE_FIELD_TYPES
.
has
(
field
.
fieldDef
?.
type
)
&&
!
isDrawerFieldDisabled
(
field
.
field
,
activeView
),
!
isDrawerFieldDisabled
(
field
.
field
,
view
),
),
);
);
const
hasDirtyFields
=
Object
.
keys
(
dirtyFields
).
length
>
0
;
const
hasDirtyFields
=
Object
.
keys
(
dirtyFields
).
length
>
0
;
const
hasValidationErrors
=
Object
.
values
(
validationErrors
).
some
(
Boolean
);
const
hasValidationErrors
=
Object
.
values
(
validationErrors
).
some
(
Boolean
);
...
@@ -198,6 +208,7 @@ const RecordDetailDrawer = (props, ref) => {
...
@@ -198,6 +208,7 @@ const RecordDetailDrawer = (props, ref) => {
setDirtyFields
({});
setDirtyFields
({});
setValidationErrors
({});
setValidationErrors
({});
setSaveError
(
''
);
setSaveError
(
''
);
pendingLocalMaterialsRef
.
current
=
{};
};
};
const
handleRequestClose
=
()
=>
{
const
handleRequestClose
=
()
=>
{
...
@@ -213,6 +224,7 @@ const RecordDetailDrawer = (props, ref) => {
...
@@ -213,6 +224,7 @@ const RecordDetailDrawer = (props, ref) => {
setDirtyFields
({});
setDirtyFields
({});
setValidationErrors
({});
setValidationErrors
({});
setSaveError
(
''
);
setSaveError
(
''
);
pendingLocalMaterialsRef
.
current
=
{};
setIsEditing
(
true
);
setIsEditing
(
true
);
};
};
...
@@ -255,6 +267,36 @@ const RecordDetailDrawer = (props, ref) => {
...
@@ -255,6 +267,36 @@ const RecordDetailDrawer = (props, ref) => {
});
});
},
[]);
},
[]);
const
handlePendingLocalMaterialChange
=
useCallback
((
fieldPath
,
change
,
editSessionId
)
=>
{
if
(
editSessionId
!==
editSessionRef
.
current
)
return
;
const
nextPendingMaterials
=
{
...
pendingLocalMaterialsRef
.
current
};
const
currentFieldMaterials
=
Array
.
isArray
(
nextPendingMaterials
[
fieldPath
])
?
nextPendingMaterials
[
fieldPath
]
:
[];
let
nextFieldMaterials
=
currentFieldMaterials
;
if
(
change
?.
type
===
'replace'
)
{
nextFieldMaterials
=
change
.
materials
||
[];
}
else
if
(
change
?.
type
===
'add'
)
{
const
materialsById
=
new
Map
(
currentFieldMaterials
.
map
((
material
)
=>
[
material
.
clientId
,
material
]),
);
(
change
.
materials
||
[]).
forEach
((
material
)
=>
materialsById
.
set
(
material
.
clientId
,
material
));
nextFieldMaterials
=
[...
materialsById
.
values
()];
}
else
if
(
change
?.
type
===
'remove'
)
{
nextFieldMaterials
=
currentFieldMaterials
.
filter
(
(
material
)
=>
material
.
clientId
!==
change
.
clientId
,
);
}
if
(
nextFieldMaterials
.
length
>
0
)
{
nextPendingMaterials
[
fieldPath
]
=
nextFieldMaterials
;
}
else
{
delete
nextPendingMaterials
[
fieldPath
];
}
pendingLocalMaterialsRef
.
current
=
nextPendingMaterials
;
},
[]);
const
handleSave
=
async
()
=>
{
const
handleSave
=
async
()
=>
{
if
(
!
hasDirtyFields
||
hasValidationErrors
||
isSaving
)
return
;
if
(
!
hasDirtyFields
||
hasValidationErrors
||
isSaving
)
return
;
...
@@ -262,10 +304,33 @@ const RecordDetailDrawer = (props, ref) => {
...
@@ -262,10 +304,33 @@ const RecordDetailDrawer = (props, ref) => {
setSaveError
(
''
);
setSaveError
(
''
);
try
{
try
{
const
pendingMaterials
=
Object
.
entries
(
pendingLocalMaterialsRef
.
current
)
.
filter
(([
fieldPath
])
=>
dirtyFields
[
fieldPath
]);
for
(
const
[
fieldPath
,
fieldMaterials
]
of
pendingMaterials
)
{
for
(
const
pendingMaterial
of
fieldMaterials
)
{
if
(
pendingMaterial
.
materialRecordId
)
continue
;
const
createdMaterial
=
await
createIntegrityRiskMaterial
({
fileAsset
:
pendingMaterial
.
fileAsset
,
currentUser
,
});
const
currentFieldMaterials
=
pendingLocalMaterialsRef
.
current
[
fieldPath
]
||
[];
pendingLocalMaterialsRef
.
current
=
{
...
pendingLocalMaterialsRef
.
current
,
[
fieldPath
]:
currentFieldMaterials
.
map
((
material
)
=>
material
.
clientId
===
pendingMaterial
.
clientId
?
{
...
material
,
materialRecordId
:
createdMaterial
.
id
}
:
material
,
),
};
}
}
await
handleFieldsUpdate
(
Object
.
values
(
dirtyFields
));
await
handleFieldsUpdate
(
Object
.
values
(
dirtyFields
));
setIsEditing
(
false
);
setIsEditing
(
false
);
setDirtyFields
({});
setDirtyFields
({});
setValidationErrors
({});
setValidationErrors
({});
pendingLocalMaterialsRef
.
current
=
{};
onDataChange
?.(
true
);
onDataChange
?.(
true
);
}
catch
(
saveFailure
)
{
}
catch
(
saveFailure
)
{
setSaveError
(
saveFailure
?.
message
||
'保存失败,请稍后重试。'
);
setSaveError
(
saveFailure
?.
message
||
'保存失败,请稍后重试。'
);
...
@@ -324,12 +389,16 @@ const RecordDetailDrawer = (props, ref) => {
...
@@ -324,12 +389,16 @@ const RecordDetailDrawer = (props, ref) => {
<
Modal
.
Header
className=
"pr-16"
>
<
Modal
.
Header
className=
"pr-16"
>
<
Modal
.
Heading
>
{
title
||
'记录详情'
}
</
Modal
.
Heading
>
<
Modal
.
Heading
>
{
title
||
'记录详情'
}
</
Modal
.
Heading
>
<
Tooltip
delay=
{
0
}
>
{
!
hideRecordTitle
?
(
<
Tooltip
.
Trigger
className=
"block min-w-0 truncate text-start"
>
<
Tooltip
delay=
{
0
}
>
<
Description
>
{
loading
?
'正在读取记录信息'
:
recordTitle
}
</
Description
>
<
Tooltip
.
Trigger
className=
"block min-w-0 truncate text-start"
>
</
Tooltip
.
Trigger
>
<
Description
>
{
loading
?
'正在读取记录信息'
:
recordTitle
}
</
Description
>
<
Tooltip
.
Content
>
{
recordTitle
}
</
Tooltip
.
Content
>
</
Tooltip
.
Trigger
>
</
Tooltip
>
<
Tooltip
.
Content
>
{
recordTitle
}
</
Tooltip
.
Content
>
</
Tooltip
>
)
:
(
<
Description
>
查看并维护当前记录信息。
</
Description
>
)
}
</
Modal
.
Header
>
</
Modal
.
Header
>
{
detailViews
.
length
>
1
?
(
{
detailViews
.
length
>
1
?
(
...
@@ -390,6 +459,7 @@ const RecordDetailDrawer = (props, ref) => {
...
@@ -390,6 +459,7 @@ const RecordDetailDrawer = (props, ref) => {
mode=
{
isEditing
?
'edit'
:
'view'
}
mode=
{
isEditing
?
'edit'
:
'view'
}
onFieldChange=
{
handleDraftFieldChange
}
onFieldChange=
{
handleDraftFieldChange
}
onFieldValidation=
{
handleFieldValidation
}
onFieldValidation=
{
handleFieldValidation
}
onPendingLocalMaterialChange=
{
handlePendingLocalMaterialChange
}
readonly=
{
effectiveReadonly
}
readonly=
{
effectiveReadonly
}
isFieldDisabled=
{
isDrawerFieldDisabled
}
isFieldDisabled=
{
isDrawerFieldDisabled
}
minimal=
{
minimalContent
}
minimal=
{
minimalContent
}
...
...
src/pages/integrity_risk_prevention/ApplicationSwitcherDialog.jsx
View file @
9d6dea24
...
@@ -4,7 +4,7 @@ import ApplicationIdentityWell from '@/components/ApplicationIdentityWell';
...
@@ -4,7 +4,7 @@ import ApplicationIdentityWell from '@/components/ApplicationIdentityWell';
import
xinyuanLogo
from
'@/assets/xy-logo-red.png'
;
import
xinyuanLogo
from
'@/assets/xy-logo-red.png'
;
import
{
import
{
APPLICATION_DISPLAY_NAME
,
APPLICATION_DISPLAY_NAME
,
APPLICATION_
ROUTE_
NAME
,
APPLICATION_NAME
,
}
from
'@/config/application'
;
}
from
'@/config/application'
;
import
{
EmptyState
}
from
'@heroui-pro/react/empty-state'
;
import
{
EmptyState
}
from
'@heroui-pro/react/empty-state'
;
import
{
import
{
...
@@ -43,7 +43,7 @@ function resolveApplicationUrl(application) {
...
@@ -43,7 +43,7 @@ function resolveApplicationUrl(application) {
function
normalizeApplication
(
application
,
fallback
=
{})
{
function
normalizeApplication
(
application
,
fallback
=
{})
{
const
name
=
application
?.
name
||
fallback
?.
name
||
''
;
const
name
=
application
?.
name
||
fallback
?.
name
||
''
;
const
current
=
name
===
APPLICATION_
ROUTE_
NAME
;
const
current
=
name
===
APPLICATION_NAME
;
return
{
return
{
name
,
name
,
...
@@ -74,7 +74,7 @@ function mergeApplications(fetchedApplications, fallbackApplications) {
...
@@ -74,7 +74,7 @@ function mergeApplications(fetchedApplications, fallbackApplications) {
[
[
...
fallbackApplications
,
...
fallbackApplications
,
{
{
name
:
APPLICATION_
ROUTE_
NAME
,
name
:
APPLICATION_NAME
,
title
:
APPLICATION_DISPLAY_NAME
,
title
:
APPLICATION_DISPLAY_NAME
,
logo
:
xinyuanLogo
,
logo
:
xinyuanLogo
,
},
},
...
@@ -161,7 +161,7 @@ function buildApplicationSections(applications, applicationGroups) {
...
@@ -161,7 +161,7 @@ function buildApplicationSections(applications, applicationGroups) {
}
}
function
isCurrentApplication
(
application
)
{
function
isCurrentApplication
(
application
)
{
if
(
application
?.
name
===
APPLICATION_
ROUTE_
NAME
)
{
if
(
application
?.
name
===
APPLICATION_NAME
)
{
return
true
;
return
true
;
}
}
...
@@ -266,7 +266,7 @@ export default function ApplicationSwitcherDialog({
...
@@ -266,7 +266,7 @@ export default function ApplicationSwitcherDialog({
);
);
const
[
errorMessage
,
setErrorMessage
]
=
useState
(
''
);
const
[
errorMessage
,
setErrorMessage
]
=
useState
(
''
);
const
currentApplicationName
=
useMemo
(()
=>
{
const
currentApplicationName
=
useMemo
(()
=>
{
return
applications
.
find
(
isCurrentApplication
)?.
name
||
APPLICATION_
ROUTE_
NAME
;
return
applications
.
find
(
isCurrentApplication
)?.
name
||
APPLICATION_NAME
;
},
[
applications
]);
},
[
applications
]);
const
applicationSections
=
useMemo
(
const
applicationSections
=
useMemo
(
()
=>
buildApplicationSections
(
applications
,
siteInfo
?.
group
),
()
=>
buildApplicationSections
(
applications
,
siteInfo
?.
group
),
...
...
src/pages/integrity_risk_prevention/IntegrityRiskMaterialField.jsx
0 → 100644
View file @
9d6dea24
This diff is collapsed.
Click to expand it.
src/pages/integrity_risk_prevention/IntegrityRiskMaterialService.js
0 → 100644
View file @
9d6dea24
import
dayjs
from
'dayjs'
;
import
{
AUDIT_MATERIAL_SOURCE_APP
}
from
'@/config/application'
;
import
{
createRecord
,
queryRecords
,
uploadFile
}
from
'@/services/DataService'
;
export
const
AUDIT_MATERIAL_SUBJECT
=
'am_material'
;
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
||
''
,
};
}
export
async
function
queryIntegrityRiskMaterials
(
keyword
=
''
)
{
const
escapedKeyword
=
escapeRegExp
(
String
(
keyword
||
''
).
trim
());
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
uploadIntegrityRiskFile
(
file
)
{
if
(
!
file
)
throw
new
Error
(
'请选择要上传的本地文件。'
);
return
normalizeFileAsset
(
await
uploadFile
(
file
),
file
);
}
export
async
function
createIntegrityRiskMaterial
({
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
:
AUDIT_MATERIAL_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_risk_prevention/IntegrityRiskWorkbenchPage.jsx
View file @
9d6dea24
...
@@ -24,22 +24,17 @@ const PAGE_CONFIG = {
...
@@ -24,22 +24,17 @@ const PAGE_CONFIG = {
process
:
{
process
:
{
subjectName
:
INTEGRITY_RISK_SUBJECTS
.
process
,
subjectName
:
INTEGRITY_RISK_SUBJECTS
.
process
,
title
:
'廉洁风险防控表 (过程表)'
,
title
:
'廉洁风险防控表 (过程表)'
,
hiddenColumnFields
:
[
'title'
,
'metadata.department_control_items'
],
hiddenColumnFields
:
[
'metadata.department_control_items'
],
hiddenDetailFields
:
[
'title'
],
query
:
{
sort
:
'metadata.original_index'
},
query
:
{
sort
:
'metadata.original_index'
},
},
},
show
:
{
show
:
{
subjectName
:
INTEGRITY_RISK_SUBJECTS
.
show
,
subjectName
:
INTEGRITY_RISK_SUBJECTS
.
show
,
title
:
'廉洁风险防控表 (展示表)'
,
title
:
'廉洁风险防控表 (展示表)'
,
hiddenColumnFields
:
[
'title'
],
hiddenDetailFields
:
[
'title'
],
query
:
{
sort
:
'metadata.original_index'
},
query
:
{
sort
:
'metadata.original_index'
},
},
},
measure
:
{
measure
:
{
subjectName
:
INTEGRITY_RISK_SUBJECTS
.
measure
,
subjectName
:
INTEGRITY_RISK_SUBJECTS
.
measure
,
title
:
'廉洁风险防控措施数据库'
,
title
:
'廉洁风险防控措施数据库'
,
hiddenColumnFields
:
[
'title'
],
hiddenDetailFields
:
[
'title'
],
},
},
archive
:
{
archive
:
{
subjectName
:
INTEGRITY_RISK_SUBJECTS
.
archive
,
subjectName
:
INTEGRITY_RISK_SUBJECTS
.
archive
,
...
@@ -327,7 +322,6 @@ export default function IntegrityRiskWorkbenchPage({ pageType }) {
...
@@ -327,7 +322,6 @@ export default function IntegrityRiskWorkbenchPage({ pageType }) {
detailDrawerProps=
{
{
detailDrawerProps=
{
{
title
:
`${pageConfig.title}详情`
,
title
:
`${pageConfig.title}详情`
,
readonly
:
pageConfig
.
readonly
,
readonly
:
pageConfig
.
readonly
,
hiddenFields
:
pageConfig
.
hiddenDetailFields
||
[],
}
}
}
}
isRowExpandable=
{
pageType
===
'process'
isRowExpandable=
{
pageType
===
'process'
?
(
record
)
=>
normalizeDepartmentControlItems
(
record
).
length
>
0
?
(
record
)
=>
normalizeDepartmentControlItems
(
record
).
length
>
0
...
...
src/pages/integrity_risk_prevention/Layout.jsx
View file @
9d6dea24
...
@@ -2,6 +2,10 @@ import { APPLICATION_DISPLAY_NAME, ROUTE_TITLES } from '@/config/application';
...
@@ -2,6 +2,10 @@ import { APPLICATION_DISPLAY_NAME, ROUTE_TITLES } from '@/config/application';
import
AppShell
from
'@/components/AppShell'
;
import
AppShell
from
'@/components/AppShell'
;
import
{
Archive
,
ClipboardDocumentListIcon
,
Database
,
TableCellsIcon
}
from
'@/components/AppIcons'
;
import
{
Archive
,
ClipboardDocumentListIcon
,
Database
,
TableCellsIcon
}
from
'@/components/AppIcons'
;
import
ApplicationSwitcherDialog
from
'./ApplicationSwitcherDialog'
;
import
ApplicationSwitcherDialog
from
'./ApplicationSwitcherDialog'
;
import
{
loadUnreadNotificationCount
,
NOTIFICATION_STATE_EVENT
,
}
from
'./notifications/notificationService'
;
import
{
UserContext
}
from
'@/wrapper/Auth'
;
import
{
UserContext
}
from
'@/wrapper/Auth'
;
import
{
SiteContext
}
from
'@/wrapper/Site'
;
import
{
SiteContext
}
from
'@/wrapper/Site'
;
import
{
useContext
,
useEffect
,
useMemo
,
useState
}
from
'react'
;
import
{
useContext
,
useEffect
,
useMemo
,
useState
}
from
'react'
;
...
@@ -24,6 +28,7 @@ export default function IntegrityRiskLayout() {
...
@@ -24,6 +28,7 @@ export default function IntegrityRiskLayout() {
const
siteInfo
=
useContext
(
SiteContext
);
const
siteInfo
=
useContext
(
SiteContext
);
const
[
sidebarOpen
,
setSidebarOpen
]
=
useState
(
true
);
const
[
sidebarOpen
,
setSidebarOpen
]
=
useState
(
true
);
const
[
applicationDialogOpen
,
setApplicationDialogOpen
]
=
useState
(
false
);
const
[
applicationDialogOpen
,
setApplicationDialogOpen
]
=
useState
(
false
);
const
[
unreadNotificationCount
,
setUnreadNotificationCount
]
=
useState
(
0
);
const
currentTitle
=
useMemo
(
const
currentTitle
=
useMemo
(
()
=>
ROUTE_TITLES
[
location
.
pathname
]
||
APPLICATION_DISPLAY_NAME
,
()
=>
ROUTE_TITLES
[
location
.
pathname
]
||
APPLICATION_DISPLAY_NAME
,
[
location
.
pathname
],
[
location
.
pathname
],
...
@@ -33,10 +38,33 @@ export default function IntegrityRiskLayout() {
...
@@ -33,10 +38,33 @@ export default function IntegrityRiskLayout() {
document
.
title
=
`
${
currentTitle
}
-
${
APPLICATION_DISPLAY_NAME
}
`
;
document
.
title
=
`
${
currentTitle
}
-
${
APPLICATION_DISPLAY_NAME
}
`
;
},
[
currentTitle
]);
},
[
currentTitle
]);
useEffect
(()
=>
{
let
cancelled
=
false
;
const
refreshUnreadCount
=
async
()
=>
{
try
{
const
count
=
await
loadUnreadNotificationCount
();
if
(
!
cancelled
)
setUnreadNotificationCount
(
count
);
}
catch
(
error
)
{
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=
{
()
=>
setApplicationDialogOpen
(
true
)
}
onOpenApplicationSwitcher=
{
()
=>
setApplicationDialogOpen
(
true
)
}
open=
{
sidebarOpen
}
open=
{
sidebarOpen
}
...
...
src/pages/integrity_risk_prevention/integrity-risk-prevention.business-flow.md
View file @
9d6dea24
...
@@ -12,6 +12,7 @@
...
@@ -12,6 +12,7 @@
| 廉洁风险防控措施数据库 |
`/measure_description`
| 维护、导入、导出和归档防控措施 |
| 廉洁风险防控措施数据库 |
`/measure_description`
| 维护、导入、导出和归档防控措施 |
| 往期材料归档 |
`/integrity_risk_control_archive`
| 只读查看归档批次和历史快照 |
| 往期材料归档 |
`/integrity_risk_control_archive`
| 只读查看归档批次和历史快照 |
| 廉洁风险防控填写表 |
`/control_measure_form?id=<flow-record>`
| 填报、一级审核和最终审核 |
| 廉洁风险防控填写表 |
`/control_measure_form?id=<flow-record>`
| 填报、一级审核和最终审核 |
| 通知消息 |
`/notifications`
| 查看消息/待办并进入关联流程 |
侧栏只注册上述四个业务入口。
`streams-admin`
中的自查底稿、廉洁从业报告和其他廉洁
侧栏只注册上述四个业务入口。
`streams-admin`
中的自查底稿、廉洁从业报告和其他廉洁
风险页面不在本应用配置中,不注册为路由。
风险页面不在本应用配置中,不注册为路由。
...
@@ -27,8 +28,9 @@
...
@@ -27,8 +28,9 @@
5.
归档页为只读,不提供新建、编辑、复制或删除。
5.
归档页为只读,不提供新建、编辑、复制或删除。
6.
页面请求必须展示加载、成功和错误状态;删除与替换导入必须提供确认。删除确认弹窗
6.
页面请求必须展示加载、成功和错误状态;删除与替换导入必须提供确认。删除确认弹窗
仅展示选中记录数量和软删除说明,不展示记录标题列表。
仅展示选中记录数量和软删除说明,不展示记录标题列表。
7.
过程表、展示表和防控措施数据库的列表及详情侧栏隐藏 Record
`title`
字段;归档页
7.
Grid View 的
`settings.shortTitle === true`
时仅隐藏列表
`title`
展示列;Detail View
保留该字段。
的同名配置同时隐藏详情 Header 的记录标题和字段区
`title`
。
`false`
或未配置时正常
显示。该配置不删除标题数据,也不改变标题搜索、排序、筛选、详情身份或保存协议。
8.
Subject 配置为多选的选择字段,在新建弹窗和详情编辑中使用可搜索的下拉勾选器;
8.
Subject 配置为多选的选择字段,在新建弹窗和详情编辑中使用可搜索的下拉勾选器;
已选项以标签展示并支持逐项移除或清空,保存值仍为
`settings.options`
中的 value 数组。
已选项以标签展示并支持逐项移除或清空,保存值仍为
`settings.options`
中的 value 数组。
9.
列表工具栏默认只显示新建、导入和主要导出等常用入口;展示表的次要导出与归档动作
9.
列表工具栏默认只显示新建、导入和主要导出等常用入口;展示表的次要导出与归档动作
...
@@ -62,6 +64,36 @@
...
@@ -62,6 +64,36 @@
19.
过程表列表不直接展示内部嵌套字段
`department_control_items`
;部门展开行中的用户和引用
19.
过程表列表不直接展示内部嵌套字段
`department_control_items`
;部门展开行中的用户和引用
字段仍保存 ID,但列表批量解析并显示用户名称、记录标题,解析失败时才回退显示 ID。
字段仍保存 ID,但列表批量解析并显示用户名称、记录标题,解析失败时才回退显示 ID。
### 2.1 Subject View 授权
-
Grid View 的
`settings.viewAuth`
只控制视图可见性。普通用户只读取
`user.applications[]`
中
`name = "integrity_risk_prevention_new"`
的 groups、roles 和
permissions;缺少配置默认拒绝。系统管理员、系统账号和该应用自身“管理”角色可旁路。
-
无可访问 Grid View 时不查询记录,不展示新建、批量、选择和行操作;仅一个可访问 View
时隐藏 Tabs。View 详情通过
`/subject/:subjectName/view/:viewName`
完整读取。
-
Detail View 不再以
`viewAuth`
二次隐藏字段。字段编辑优先使用当前 Detail 的完整
`settings.auth`
/
`disableColumns`
;Detail 没有有效配置时整套回退当前 Grid View,不能逐字段
合并。普通用户缺少字段 auth 时只读,
`disableColumns`
对所有用户始终只读。
### 2.2 文件与审计材料中心
-
运行时 Subject 定义为可编辑
`file`
的字段使用“材料中心 / 本地上传”,单值保持对象,
`multiple=true`
保持对象数组;快照始终只含
`{name,type,size,url}`
。
-
材料中心查询
`am_material`
最近 20 条,按材料标题或文件名远程搜索;单值替换,多值追加
并按 URL 去重,清空业务附件不删除材料主档。
-
本地文件先调用
`/upload`
进入详情草稿。取消编辑不建档;保存详情时,先逐文件创建
`am_material`
,写入
`source_app = integrity_risk_prevention`
、当前用户和 ISO 上传时间,
全部成功后才更新业务记录。部分失败保留草稿与已建档标记,重试不重复建档。
### 2.3 通知消息
-
Navbar 的通知入口读取平台未读总数,0 隐藏、1-99 显示真实值、100 及以上显示
`99+`
;
每 60 秒和通知状态变化后刷新,Sidebar 不增加通知菜单。
-
`/notifications`
支持消息/待办、全部/未读、300ms 搜索、固定 10 条分页、单条/全部已读、
服务端动态 action 和关联流程跳转。通知内容只按纯文本显示。
-
本应用
`/integrity_risk_prevention_new`
前缀只在站内路由前移除;其他应用路径和外部 HTTP(S)
链接在新窗口打开,不改变原通知接口、
`notice_id`
payload 或鉴权。
## 3. 过程表操作
## 3. 过程表操作
### 3.1 推送风险防控表
### 3.1 推送风险防控表
...
@@ -203,3 +235,13 @@
...
@@ -203,3 +235,13 @@
加载关联记录以
`restriction_value`
为准。
加载关联记录以
`restriction_value`
为准。
-
Excel 替换和 OA 流程均跨多个非事务接口;页面能阻止明显的前置错误,但服务端部分成功
-
Excel 替换和 OA 流程均跨多个非事务接口;页面能阻止明显的前置错误,但服务端部分成功
时无法自动回滚,管理员需根据错误信息核查对应归档、待办和通知。
时无法自动回滚,管理员需根据错误信息核查对应归档、待办和通知。
## 8. Subject View 权限流程
1.
应用稳定标识为
`integrity_risk_prevention_new`
;路由 base、应用切换当前态和 View 授权均从该标识派生。
2.
View 授权只读取当前用户
`applications[]`
中同名应用的
`groups`
、
`roles`
、
`permissions`
,不读取顶层身份或其他应用授权。系统管理员、系统账号和本应用“管理”角色可旁路普通授权。
3.
Grid
`settings.viewAuth`
只控制业务 Tab 是否可见。没有可访问 Grid 时不查询记录,不展示新增、批量、选择或行操作,也不创建兜底 View。
4.
打开详情时加载每个 View 的完整配置,并把当前 Grid 完整配置作为
`authorizationView`
。Detail 不使用
`viewAuth`
隐藏字段,
`columns`
只控制展示范围和顺序。
5.
当前 Detail 的
`auth`
或
`disableColumns`
只要存在任一有效字段配置,就整套使用 Detail 权限;两项都没有或没有实际 Detail 时,整套回退当前 Grid。两套权限不合并,也不逐字段回退。
6.
选定权限来源后,普通用户未配置
`auth`
的展示字段默认只读;
`disableColumns`
对所有账号优先且强制只读,管理员也不能旁路。
7.
切换 Grid 或 Detail 后立即重新计算编辑按钮和字段状态;编辑按钮只检查当前 Detail 实际展示且可编辑的字段,不受其他 Detail 权限影响。
src/pages/integrity_risk_prevention/notifications/NotificationListPage.jsx
0 → 100644
View file @
9d6dea24
This diff is collapsed.
Click to expand it.
src/pages/integrity_risk_prevention/notifications/notificationService.js
0 → 100644
View file @
9d6dea24
import
{
exacteActionFunction
,
queryUserNotice
,
readUserNotice
,
unReadNoticeCount
,
}
from
'@/services/DataService'
;
import
{
APPLICATION_BASE_PATH
}
from
'@/config/application'
;
export
const
NOTIFICATION_PAGE_SIZE
=
10
;
export
const
NOTIFICATION_STATE_EVENT
=
'integrity-risk-prevention: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
,
};
}
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_risk_prevention_new
\/[^\s
<>"'
]
+|
\/
control_measure_form
(?:\?[^\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
.
origin
===
window
.
location
.
origin
)
{
return
resolveNotificationTarget
(
`
${
url
.
pathname
}${
url
.
search
}${
url
.
hash
}
`
);
}
return
{
href
:
url
.
toString
(),
external
:
true
};
}
catch
(
error
)
{
return
null
;
}
}
const
appPrefix
=
APPLICATION_BASE_PATH
.
replace
(
/
\/
$/
,
''
);
if
(
normalizedTarget
===
appPrefix
||
normalizedTarget
.
startsWith
(
`
${
appPrefix
}
/`
))
{
return
{
href
:
normalizedTarget
.
slice
(
appPrefix
.
length
)
||
'/'
,
external
:
false
,
};
}
if
(
normalizedTarget
.
startsWith
(
'/'
))
{
const
localRoutes
=
[
'/control_measure_form'
,
'/risk_prevention_control_measures_table'
,
'/risk_prevention_control_measures_table_show'
,
'/measure_description'
,
'/integrity_risk_control_archive'
,
'/notifications'
];
return
localRoutes
.
some
((
route
)
=>
normalizedTarget
===
route
||
normalizedTarget
.
startsWith
(
`
${
route
}
?`
))
?
{
href
:
normalizedTarget
,
external
:
false
}
:
{
href
:
normalizedTarget
,
external
:
true
};
}
return
null
;
}
export
function
notifyNotificationStateChanged
()
{
if
(
typeof
window
!==
'undefined'
)
{
window
.
dispatchEvent
(
new
CustomEvent
(
NOTIFICATION_STATE_EVENT
));
}
}
src/services/SubjectWorkbenchService.js
View file @
9d6dea24
import
{
import
{
getSubjectView
,
getSubjectView
,
getSubjectViewDetail
,
loadSubject
,
loadSubject
,
queryRecords
,
queryRecords
,
}
from
'@/services/DataService'
;
}
from
'@/services/DataService'
;
import
{
import
{
buildViewColumns
,
buildViewColumns
,
buildViewSearchColumns
,
checkViewCondition
,
checkViewCondition
,
formatFieldValue
,
formatFieldValue
,
getFieldValue
,
getFieldValue
,
...
@@ -56,11 +58,30 @@ export async function queryAllSubjectRecords(subjectName, query = {}) {
...
@@ -56,11 +58,30 @@ export async function queryAllSubjectRecords(subjectName, query = {}) {
}
}
export
async
function
loadSubjectWorkbenchResources
(
subjectName
,
query
=
{})
{
export
async
function
loadSubjectWorkbenchResources
(
subjectName
,
query
=
{})
{
const
[
records
,
subject
,
views
]
=
await
Promise
.
all
([
const
[
subject
,
viewSummaries
]
=
await
Promise
.
all
([
queryAllSubjectRecords
(
subjectName
,
query
),
loadSubject
(
subjectName
),
loadSubject
(
subjectName
),
getSubjectView
(
subjectName
),
getSubjectView
(
subjectName
),
]);
]);
const
views
=
await
Promise
.
all
(
(
Array
.
isArray
(
viewSummaries
)
?
viewSummaries
:
[]).
map
(
async
(
view
)
=>
{
if
(
!
view
?.
name
)
return
view
;
try
{
const
detail
=
await
getSubjectViewDetail
(
subjectName
,
view
.
name
);
return
{
...
view
,
...
detail
,
settings
:
detail
?.
settings
||
view
?.
settings
||
{},
};
}
catch
(
error
)
{
return
view
;
}
}),
);
const
accessibleGridViews
=
buildGridViews
(
views
);
const
records
=
accessibleGridViews
.
length
>
0
?
await
queryAllSubjectRecords
(
subjectName
,
query
)
:
[];
return
{
return
{
records
:
records
.
map
(
normalizeWorkbenchRecord
),
records
:
records
.
map
(
normalizeWorkbenchRecord
),
...
@@ -70,27 +91,15 @@ export async function loadSubjectWorkbenchResources(subjectName, query = {}) {
...
@@ -70,27 +91,15 @@ export async function loadSubjectWorkbenchResources(subjectName, query = {}) {
}
}
export
function
buildGridViews
(
views
=
[])
{
export
function
buildGridViews
(
views
=
[])
{
const
gridViews
=
views
return
views
.
filter
((
view
)
=>
view
.
type
===
'grid'
)
.
filter
((
view
)
=>
view
.
type
===
'grid'
)
.
filter
((
view
)
=>
isViewAccessible
(
view
));
.
filter
((
view
)
=>
isViewAccessible
(
view
));
if
(
gridViews
.
length
>
0
)
{
return
gridViews
;
}
return
[
{
title
:
'全部记录'
,
type
:
'grid'
,
name
:
'all'
,
settings
:
{},
},
];
}
}
export
function
buildConfiguredWorkbenchView
(
records
,
filters
,
view
,
subject
)
{
export
function
buildConfiguredWorkbenchView
(
records
,
filters
,
view
,
subject
)
{
const
keyword
=
normalizeText
(
filters
?.
keyword
).
toLowerCase
();
const
keyword
=
normalizeText
(
filters
?.
keyword
).
toLowerCase
();
const
columns
=
buildViewColumns
(
view
,
subject
);
const
columns
=
buildViewColumns
(
view
,
subject
);
const
searchColumns
=
buildViewSearchColumns
(
view
,
subject
);
const
filteredRecords
=
records
.
filter
((
record
)
=>
{
const
filteredRecords
=
records
.
filter
((
record
)
=>
{
const
rawRecord
=
record
.
raw
||
record
;
const
rawRecord
=
record
.
raw
||
record
;
...
@@ -114,7 +123,7 @@ export function buildConfiguredWorkbenchView(records, filters, view, subject) {
...
@@ -114,7 +123,7 @@ export function buildConfiguredWorkbenchView(records, filters, view, subject) {
return
true
;
return
true
;
}
}
const
searchText
=
c
olumns
const
searchText
=
searchC
olumns
.
map
((
column
)
=>
formatFieldValue
(
getFieldValue
(
rawRecord
,
column
.
field
),
column
.
fieldDef
))
.
map
((
column
)
=>
formatFieldValue
(
getFieldValue
(
rawRecord
,
column
.
field
),
column
.
fieldDef
))
.
join
(
' '
)
.
join
(
' '
)
.
toLowerCase
();
.
toLowerCase
();
...
...
src/utils/subjectView.js
View file @
9d6dea24
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
();
...
@@ -77,60 +78,103 @@ export function getCurrentUserFromStorage() {
...
@@ -77,60 +78,103 @@ export function getCurrentUserFromStorage() {
}
}
}
}
function
collectUserGroups
(
user
)
{
function
collectIdentityValues
(
values
)
{
return
toArray
(
user
?.
groups
)
return
toArray
(
values
)
.
flatMap
((
group
)
=>
[
group
?.
_id
,
group
?.
name
,
group
?.
display_name
,
group
])
.
flatMap
((
value
)
=>
{
if
(
value
&&
typeof
value
===
'object'
)
{
return
[
value
.
_id
,
value
.
name
,
value
.
display_name
,
value
.
displayName
,
value
.
username
,
];
}
return
value
;
})
.
map
(
normalizeText
)
.
map
(
normalizeText
)
.
filter
(
Boolean
);
.
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
));
}
function
getMultidimensionalTableAuthRule
(
authRule
)
{
return
[
'group'
,
'role'
,
'permission'
].
includes
(
authRule
?.
type
)
?
authRule
:
'管理'
;
}
}
export
function
hasAuthAccess
(
authRule
,
user
=
getCurrentUserFromStorage
())
{
export
function
isSystemAdministrator
(
user
=
getCurrentUserFromStorage
())
{
if
(
!
authRule
)
{
return
user
?.
type
===
'admin'
||
user
?.
type
===
'system'
;
}
export
function
hasApplicationManagementAccess
(
user
=
getCurrentUserFromStorage
(),
applicationName
=
AUTHORIZATION_APPLICATION_NAME
,
)
{
if
(
isSystemAdministrator
(
user
))
{
return
true
;
return
true
;
}
}
const
{
roles
}
=
collectApplicationAuthorization
(
user
,
applicationName
);
return
intersects
(
roles
,
[
'管理'
]);
}
export
function
hasAuthAccess
(
authRule
,
user
=
getCurrentUserFromStorage
(),
applicationName
=
AUTHORIZATION_APPLICATION_NAME
,
)
{
if
(
!
user
)
{
if
(
!
user
)
{
return
false
;
return
false
;
}
}
if
(
user
.
type
===
'admin'
)
{
if
(
hasApplicationManagementAccess
(
user
,
applicationName
)
)
{
return
true
;
return
true
;
}
}
if
(
authRule
.
type
===
'group'
)
{
const
{
groups
,
roles
,
permissions
}
=
collectApplicationAuthorization
(
return
intersects
(
collectUserGroups
(
user
),
authRule
.
groups
);
user
,
applicationName
,
);
if
(
typeof
authRule
===
'string'
)
{
return
intersects
(
roles
,
[
authRule
]);
}
}
if
(
authRule
.
type
===
'role
'
)
{
if
(
authRule
?.
type
===
'group
'
)
{
return
intersects
(
collectUserRoles
(
user
),
authRule
.
role
s
);
return
intersects
(
groups
,
authRule
.
group
s
);
}
}
if
(
authRule
.
type
===
'permission
'
)
{
if
(
authRule
?.
type
===
'role
'
)
{
return
intersects
(
collectUserPermissions
(
user
),
authRule
.
permission
s
);
return
intersects
(
roles
,
authRule
.
role
s
);
}
}
if
(
authRule
.
type
===
'username
'
)
{
if
(
authRule
?.
type
===
'permission
'
)
{
return
intersects
(
[
user
.
username
,
user
.
display_name
,
user
.
_id
],
authRule
.
username
s
);
return
intersects
(
permissions
,
authRule
.
permission
s
);
}
}
return
tru
e
;
return
fals
e
;
}
}
export
function
getFieldValue
(
record
,
fieldPath
)
{
export
function
getFieldValue
(
record
,
fieldPath
)
{
...
@@ -296,12 +340,21 @@ export function normalizeRecordLabel(value) {
...
@@ -296,12 +340,21 @@ 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
);
return
{
return
{
...
@@ -314,12 +367,14 @@ export function buildViewColumns(view, subject, { includeHidden = false } = {})
...
@@ -314,12 +367,14 @@ export function buildViewColumns(view, subject, { includeHidden = false } = {})
}
}
const
defaultColumns
=
[];
const
defaultColumns
=
[];
defaultColumns
.
push
({
if
(
!
hideTitleColumn
)
{
field
:
'title'
,
defaultColumns
.
push
({
width
:
180
,
field
:
'title'
,
label
:
'标题'
,
width
:
180
,
fieldDef
:
{
name
:
'title'
,
label
:
'标题'
,
type
:
'text'
},
label
:
'标题'
,
});
fieldDef
:
{
name
:
'title'
,
label
:
'标题'
,
type
:
'text'
},
});
}
toArray
(
subject
?.
fields
).
forEach
((
field
)
=>
{
toArray
(
subject
?.
fields
).
forEach
((
field
)
=>
{
defaultColumns
.
push
({
defaultColumns
.
push
({
...
@@ -333,6 +388,23 @@ export function buildViewColumns(view, subject, { includeHidden = false } = {})
...
@@ -333,6 +388,23 @@ export function buildViewColumns(view, subject, { includeHidden = false } = {})
return
defaultColumns
;
return
defaultColumns
;
}
}
export
function
buildViewSearchColumns
(
view
,
subject
)
{
const
displayColumns
=
buildViewColumns
(
view
,
subject
);
if
(
displayColumns
.
some
((
column
)
=>
column
.
field
===
'title'
))
{
return
displayColumns
;
}
return
[
{
field
:
'title'
,
width
:
180
,
label
:
'标题'
,
fieldDef
:
getFieldDefinition
(
'title'
,
subject
),
},
...
displayColumns
,
];
}
function
compareRuleValue
(
recordValue
,
comparator
,
expectedValue
)
{
function
compareRuleValue
(
recordValue
,
comparator
,
expectedValue
)
{
const
expectedValues
=
toArray
(
expectedValue
).
map
(
normalizeText
);
const
expectedValues
=
toArray
(
expectedValue
).
map
(
normalizeText
);
const
recordValues
=
toArray
(
recordValue
).
map
((
item
)
=>
{
const
recordValues
=
toArray
(
recordValue
).
map
((
item
)
=>
{
...
@@ -391,18 +463,47 @@ export function checkViewCondition(condition, record) {
...
@@ -391,18 +463,47 @@ 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
,
)
{
return
hasAuthAccess
(
getMultidimensionalTableAuthRule
(
view
?.
settings
?.
viewAuth
),
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
;
return
!
hasAuthAccess
(
getMultidimensionalTableAuthRule
(
fieldAuthRule
),
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