Skip to content
Projects
Groups
Snippets
Help
Loading...
Sign in / Register
Toggle navigation
R
risk_case_library
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
risk_case_library
Commits
b4b5a6e7
Commit
b4b5a6e7
authored
Aug 27, 2026
by
David Yang
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Revert "feat: align risk case views and permissions"
This reverts commit
177dadc8
.
parent
17393f5a
Expand all
Show whitespace changes
Inline
Side-by-side
Showing
21 changed files
with
25 additions
and
1121 deletions
+25
-1121
.umirc.ts
.umirc.ts
+2
-8
ui-architecture.md
docs/ui-architecture.md
+0
-16
ui-migration-checklist.md
docs/ui-migration-checklist.md
+0
-15
xinyuan-design-system.md
docs/xinyuan-design-system.md
+0
-12
xinyuan-ui-audit.md
docs/xinyuan-ui-audit.md
+2
-27
AppIcons.jsx
src/components/AppIcons.jsx
+0
-2
FileUploadField.jsx
src/components/FileUploadField.jsx
+2
-4
XinyuanAutocomplete.jsx
src/components/XinyuanAutocomplete.jsx
+0
-96
app.js
src/constants/app.js
+1
-3
AppIcons.jsx
src/pages/project_manager_xy/AppIcons.jsx
+0
-2
ApplicationSwitcherDialog.jsx
src/pages/project_manager_xy/ApplicationSwitcherDialog.jsx
+5
-5
Layout.jsx
src/pages/project_manager_xy/Layout.jsx
+3
-55
RiskCaseMaterialField.jsx
src/pages/project_manager_xy/RiskCaseMaterialField.jsx
+0
-137
audit-material-center.business-flow.md
...project_manager_xy/audit-material-center.business-flow.md
+0
-32
materialService.js
src/pages/project_manager_xy/materialService.js
+10
-70
NotificationListPage.jsx
...project_manager_xy/notifications/NotificationListPage.jsx
+0
-148
notificationService.js
...s/project_manager_xy/notifications/notificationService.js
+0
-142
index.jsx
src/pages/project_manager_xy/resource-booking/index.jsx
+0
-0
riskCaseMaterialService.js
src/pages/project_manager_xy/riskCaseMaterialService.js
+0
-111
riskCaseViewService.js
src/pages/project_manager_xy/riskCaseViewService.js
+0
-34
subjectView.js
src/utils/subjectView.js
+0
-202
No files found.
.umirc.ts
View file @
b4b5a6e7
import
{
defineConfig
}
from
'@umijs/max'
;
import
path
from
'path'
;
import
{
APPLICATION_BASE_PATH
}
from
'./src/constants/app'
;
function
resolvePackageRoot
(
packageName
:
string
)
{
return
path
.
dirname
(
require
.
resolve
(
`
${
packageName
}
/package.json`
));
...
...
@@ -10,14 +9,13 @@ const ROUTE_PATHS = {
login
:
'/login'
,
resourceStat
:
'/resource/stat'
,
resourceBooking
:
'/resource/booking'
,
notifications
:
'/notifications'
,
}
as
const
;
export
default
defineConfig
({
layout
:
false
,
fastRefresh
:
true
,
base
:
APPLICATION_BASE_PATH
,
publicPath
:
APPLICATION_BASE_PATH
,
base
:
'/risk_case_library/'
,
publicPath
:
'/risk_case_library/'
,
alias
:
{
react
:
resolvePackageRoot
(
'react'
),
'react-dom'
:
resolvePackageRoot
(
'react-dom'
),
...
...
@@ -49,10 +47,6 @@ export default defineConfig({
path
:
ROUTE_PATHS
.
resourceBooking
,
component
:
'./project_manager_xy/resource-booking/index'
,
},
{
path
:
ROUTE_PATHS
.
notifications
,
component
:
'./project_manager_xy/notifications/NotificationListPage'
,
},
],
},
],
...
...
docs/ui-architecture.md
View file @
b4b5a6e7
...
...
@@ -25,19 +25,3 @@
## 业务边界
服务层继续负责
`risk_case_library`
的
`/record`
协议,所有业务字段仍写入
`metadata`
。本次迁移不得改变路由、鉴权、权限、验证、分页、保存回调、请求时机、错误处理或业务状态流。
## Subject View 与材料事务边界
-
`riskCaseViewService.js`
先读取 Subject、View 摘要和每个 View 详情;页面只有在存在当前用户可访问的 Grid View 后才进入案例查询数据流。
-
`src/constants/app.js`
定义唯一稳定
`APPLICATION_NAME = risk_case_library`
,路由 base、应用切换、授权归属和材料
`source_app`
均由该标识派生。
-
`src/utils/subjectView.js`
是业务中立的 View 解析工具,默认授权应用从
`APPLICATION_NAME`
派生。它只读取当前应用项,不读取或合并其他应用及用户顶层的 groups、roles 或 permissions。
-
Grid View 负责可见性、查询限制、排序和列表字段。详情字段权限优先取当前完整 Detail View 的整套
`auth/disableColumns`
;Detail 完全没有有效字段权限配置或不存在实际 Detail 时,整体回退活动 Grid,不合并两套配置。
-
Detail
`columns`
独立负责字段展示范围、顺序、条件和
`shortTitle`
;编辑入口与字段状态只按当前 Detail 展示字段和当前选定权限源计算。
-
`RiskCaseMaterialField.jsx`
是 HeroUI 原生材料选择/上传组合,业务服务通过
`/upload`
、
`am_material`
和
`source_app=risk_case_library`
工作。本地上传先进入详情草稿,保存详情前创建材料主档;成功标记保留在当前编辑会话中,重试不重复建档。
-
现网
`risk_case_library`
Subject 当前没有 file 字段,因此材料组件处于运行时待配置状态,不添加假字段,也不改变现有 Record schema。
## 通知架构
-
`/notifications`
由独立页面承载,Navbar 只负责真实未读计数和路由入口,Sidebar 不增加通知项。
-
页面使用平台
`/notice/customer/query`
、
`/notice/customer/count`
、已读和动态 Action 接口;通知正文按纯文本渲染。
-
站内链接只移除自身
`/risk_case_library`
base 后交给 Umi history;其他应用或外部 HTTP(S) 链接新窗口打开。
docs/ui-migration-checklist.md
View file @
b4b5a6e7
...
...
@@ -17,13 +17,6 @@
-
[
x
]
案例管理:Tabs、筛选工具栏、DataGrid、Pagination、ActionBar、EmptyState(代码结构)
-
[
x
]
弹窗:新建、查看/编辑、批量修改、导入、删除确认(代码结构)
-
[
x
]
清除 Catalyst/Headless/ECharts/原生可见控件与页面私有视觉 CSS
-
[
x
]
Grid
`shortTitle`
:严格布尔值,仅隐藏
`title`
展示列,操作入口保留在右侧操作列
-
[
x
]
Detail
`shortTitle`
:随当前 Detail View 切换,功能标题始终保留
-
[
x
]
Subject View 权限:目标应用授权隔离、管理员/管理旁路、
`disableColumns`
优先
-
[
x
]
Detail 权限来源:完整 Detail 有有效
`auth/disableColumns`
时整套优先,否则整体回退活动 Grid;编辑入口只检查当前 Detail 字段
-
[
x
]
零 Grid View:无兜底、无案例查询、无新增/批量/行操作;单 View 隐藏 Tabs
-
[
x
]
通知中心:Navbar Badge、独立页面、搜索/分页/已读/动态操作/站内外跳转
-
[
x
]
材料中心:运行时 file 字段、Autocomplete、DropZone、草稿/保存建档/重试去重
## 浏览器验收
...
...
@@ -35,14 +28,6 @@
-
[
]
打开 Sidebar、用户退出菜单、应用切换真实动态分组、Dropdown/ListBox/DatePicker
-
[
]
验证 Tabs、筛选、Checkbox、DataGrid、Pagination、ActionBar、Modal、KPI、图表 Tooltip
-
[
x
]
Login 控制台 0 runtime/slot/provider/accessibility 错误或警告
-
[
x
]
1280x720 浅色:Navbar Bell、0 角标隐藏、零 View 空状态、控制台 0 错误/警告
-
[
x
]
零 View 网络门禁:仅 Subject、View、通知计数请求,无案例
`record/query`
或
`record/count`
-
[
x
]
零 View 状态已覆盖 1440x900、1280x720、768x1024、390x844 浅/深色;无页面横向溢出或控制台错误
-
[
x
]
通知空状态已覆盖 1280x720 浅/深色;查询和计数接口均返回 200,控制台无错误
-
[
]
shortTitle true/false、Detail/Grid 权限优先、三类用户、disableColumns:后端当前没有任何 View 配置,无法真实触发
-
[
]
单值/多值文件、材料选择、取消、失败与重试:Subject 当前没有 file 字段,无法真实触发
-
[
]
通知 1/99/100、已读/全部已读/动态操作和站内外跳转:当前账号通知总数为 0,未伪造数据
-
[
]
通知非空态和可配置 View/file 状态的四视口浅/深色并排对照:真实后端状态缺失,未伪造数据
## 最终门槛
...
...
docs/xinyuan-design-system.md
View file @
b4b5a6e7
...
...
@@ -20,18 +20,6 @@
-
指标必须使用 HeroUI Pro KPI compound API;图表必须使用 Pro PieChart、BarChart、ComposedChart 与 ChartTooltipContent。
-
图表业务序列只使用
`--rc-chart-*`
Token,不使用 UI 状态色或页面硬编码颜色。
## 跨应用五项功能
警示案例库属于鑫元六个完整功能应用,以下规则作为运行时契约:
-
Grid 与 Detail View 只把严格布尔值
`settings.shortTitle === true`
解释为隐藏标题。Grid 仅隐藏
`title`
展示列;Detail 隐藏记录标题和
`title`
字段,但始终保留 Modal 功能标题及可访问名称。
-
Grid View 可见性只读取
`settings.viewAuth`
。详情编辑权限优先使用当前 Detail View 的整套
`settings.auth`
/
`settings.disableColumns`
;两项均无有效字段配置或不存在实际 Detail 时,整体回退当前 Grid View。两套权限不合并、不逐字段回退,选定来源的
`disableColumns`
对任何用户始终只读。
-
应用授权只读取
`user.applications[]`
中
`name = "risk_case_library"`
的项目。系统管理员和该应用自身“管理”角色可旁路 View/字段 auth,但不能旁路
`disableColumns`
,也不能制造不存在的 View。
-
Detail
`columns`
只控制当前详情字段展示范围和顺序,
`auth`
可配置更多字段;“编辑”按钮只检查当前 Detail 实际展示且可编辑的字段。
-
零个可访问 Grid View 时不建立兜底 View、不查询案例记录,也不展示新建、批量或行操作;单个可访问 View 不显示 Tabs 轨道。
-
Navbar 通知使用真实未读数和独立
`/notifications`
页面,0 隐藏角标,1-99 显示真实值,100 以上显示
`99+`
。
-
运行时可编辑
`file`
字段使用材料中心/本地上传。材料快照只保存
`{name,type,size,url}`
,目标
`source_app`
固定为
`risk_case_library`
;当前 Subject 未配置 file 字段时不显示入口。
## 禁止项
-
Catalyst、Headless UI、旧 UI 包装层、可见原生 select/date/file 控件。
...
...
docs/xinyuan-ui-audit.md
View file @
b4b5a6e7
# 鑫元 UI 审计
审计日期:2026-08-26。目标仓库为 Umi 4 / React 19,部署基路径为
`/risk_case_library/`
。整体实现
及本次五项功能的唯一基准为“问题整改”
。
审计日期:2026-08-26。目标仓库为 Umi 4 / React 19,部署基路径为
`/risk_case_library/`
。整体实现
基准为“问题整改”,应用切换专项基准为
`model_analysis`
当前 HeroUI 实现
。
## 安装与运行基线
-
官方安装命令已在项目根目录执行成功,
`@heroui-pro/react`
解析为本项目
`node_modules`
内的
`1.0.0-beta.8`
,不是符号链接或跨项目
`file:`
依赖。
-
项目使用 npm,保留
`package-lock.json`
;旧
`pnpm-lock.yaml`
已删除。
-
当前迁移实例运行于
`http://localhost:800
2
/risk_case_library/`
。
-
当前迁移实例运行于
`http://localhost:800
0
/risk_case_library/`
。
-
“问题整改”基准运行于
`http://localhost:8001/problem_rectification_new/`
。
-
应用切换专项基准
`model_analysis`
运行于
`http://localhost:8002/model_analysis/`
;该隔离浏览器会话停留在登录页,因此没有把未登录状态冒充为打开态对照。
...
...
@@ -36,28 +36,3 @@
-
不改变 API URL、请求参数、响应映射、路由、权限、校验、请求时机、保存回调、登录退出或业务状态流。
-
不在源码或浏览器中加入测试记录、伪造 token 或统计数字。
-
应用切换已使用现有真实登录会话完成浅/深色四视口验收;未注入 token、应用、分组或业务数据。其他模块的既有未验收项仍按迁移清单保留,不能据此标记全量应用迁移完成。
## 五项功能审计
| 审计项 | 真实状态 | 实施结论 |
| --- | --- | --- |
| 应用范围 | 应用名为“警示案例库” | 属于六应用完整范围,实施功能 1-5 |
| 用户结构 | Andy:
`type=admin`
,
`applications=[]`
| 系统管理员旁路 auth,但不制造 View;普通用户授权只能读取
`risk_case_library`
应用项 |
| Subject View |
`GET /subject/risk_case_library/view`
返回
`[]`
| 案例管理进入“暂无可访问的案例视图”;不查询记录,不展示业务操作 |
| Detail 权限来源 | View 摘要与每个完整 View 详情均通过现有 Subject View 接口加载 | 当前 Detail 有有效
`auth/disableColumns`
时整套优先;否则整体回退活动 Grid;两套配置不合并 |
| Subject file 字段 | 当前字段集中没有
`type=file`
| 文件能力按运行时字段接入,不增加测试字段或伪造入口 |
| 通知 |
`GET /notice/customer/count`
真实返回 0;查询返回空列表 | Navbar 显示 Bell 且隐藏角标;通知页实现真实空状态 |
| 审计材料 |
`am_material`
可查询且存在真实记录 | 只在未来可编辑 file 字段中按最近 20 条和远程搜索使用 |
| 路由与 Shell | base 为
`/risk_case_library`
,通知路由为
`/notifications`
| 站内目标只去除自身 base,Sidebar 无通知入口 |
代码审计确认原页面会无条件调用案例查询,并在后端空数据时展示
`DEMO_CASES`
。本次 View 门禁使零可访问 View 状态不进入该数据流;没有删除旧业务回调或演示常量,但受门禁保护的案例管理页面不会触发或展示它们。
## 本轮真实浏览器证据
-
零可访问 Grid View 状态已在 1440x900、1280x720、768x1024、390x844 的浅色和深色模式逐项打开;Navbar Bell、空状态、响应式 Shell 和页面边距正常,控制台无错误或警告。
-
1280x720 网络记录只包含
`/api/info`
、
`/api/user/info`
、Subject/View 与通知计数请求,没有案例
`record/query`
或
`record/count`
。
-
通知空状态已在 1280x720 浅色和深色模式打开;通知 query/count 均返回 200,页面按真实 0 条数据展示且控制台干净。
-
本轮收尾重新使用真实账号完成验证码登录,并再次打开案例管理、通知页、应用切换和用户菜单:案例管理只新增 Subject/View/通知计数请求,未新增案例 query/count;应用切换显示真实动态分组与唯一当前态;用户菜单保留真实退出入口;目标应用控制台仍为 0 错误、0 警告。
-
“问题整改”基准在同一浏览器第二标签打开,但独立验证码登录未成功,因此本轮没有把其登录页冒充为已登录业务态对照;此前完成的对照证据继续保留,缺失状态仍不标记完成。
-
截图保存在本机
`/tmp/risk-booking-*.png`
、
`/tmp/risk-notifications-1280-light.png`
与
`/tmp/risk-notifications-1280-dark.png`
,不纳入 Git。
-
当前后端没有 View、file 字段或非空通知,故 shortTitle、Detail/Grid 权限优先矩阵、材料事务异常链路与通知 1/99/100 等状态仍明确未验收;没有注入测试记录或篡改接口返回。
src/components/AppIcons.jsx
View file @
b4b5a6e7
import
BankOutlined
from
'@ant-design/icons/BankOutlined'
;
import
BellOutlined
from
'@ant-design/icons/BellOutlined'
;
import
BarChartOutlined
from
'@ant-design/icons/BarChartOutlined'
;
import
AppstoreOutlined
from
'@ant-design/icons/AppstoreOutlined'
;
import
CalendarOutlined
from
'@ant-design/icons/CalendarOutlined'
;
...
...
@@ -55,7 +54,6 @@ function withSize(Icon) {
}
export
const
Bank
=
withSize
(
BankOutlined
);
export
const
Bell
=
withSize
(
BellOutlined
);
export
const
Archive
=
withSize
(
InboxOutlined
);
export
const
AppGrid
=
withSize
(
AppstoreOutlined
);
export
const
BarChart
=
withSize
(
BarChartOutlined
);
...
...
src/components/FileUploadField.jsx
View file @
b4b5a6e7
...
...
@@ -9,7 +9,6 @@ export default function FileUploadField({
description
=
'将文件拖到此处,或从本地选择。'
,
isDisabled
=
false
,
fileName
=
''
,
multiple
=
false
,
onFilesChange
,
triggerLabel
=
'选择文件'
,
})
{
...
...
@@ -30,9 +29,9 @@ export default function FileUploadField({
if
(
fileItems
.
length
===
0
)
return
;
const
files
=
await
Promise
.
all
(
fileItems
.
map
((
item
)
=>
item
.
getFile
()));
emitFiles
(
multiple
?
files
:
files
.
slice
(
0
,
1
));
emitFiles
(
files
.
slice
(
0
,
1
));
},
[
emitFiles
,
multiple
],
[
emitFiles
],
);
return
(
...
...
@@ -45,7 +44,6 @@ export default function FileUploadField({
<
DropZone
.
Input
accept=
{
accept
}
disabled=
{
isDisabled
}
multiple=
{
multiple
}
onSelect=
{
emitFiles
}
/>
<
DropZone
.
Icon
>
...
...
src/components/XinyuanAutocomplete.jsx
deleted
100644 → 0
View file @
17393f5a
import
{
ChevronDown
,
Close
,
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
);
}
export
default
function
XinyuanAutocomplete
({
'aria-label'
:
ariaLabel
,
className
,
emptyText
=
'暂无可选项'
,
errorText
=
''
,
filter
,
inputValue
,
isDisabled
=
false
,
isLoading
=
false
,
onInputChange
,
onSelectionChange
,
options
=
[],
placeholder
=
'请选择'
,
renderOption
,
searchPlaceholder
=
'输入关键词搜索'
,
selectedKey
,
shouldFilter
=
true
,
valueText
,
variant
=
'secondary'
,
})
{
const
selectedOption
=
options
.
find
((
option
)
=>
option
.
id
===
selectedKey
);
const
resolvedValue
=
valueText
??
selectedOption
?.
label
??
placeholder
;
const
visibleOptions
=
isLoading
||
errorText
?
[]
:
options
;
return
(
<
Autocomplete
aria
-
label=
{
ariaLabel
||
placeholder
}
className=
{
className
}
fullWidth
isDisabled=
{
isDisabled
}
selectedKey=
{
selectedKey
}
variant=
{
variant
}
onSelectionChange=
{
onSelectionChange
}
>
<
Autocomplete
.
Trigger
>
<
Autocomplete
.
Value
>
<
span
className=
"block min-w-0 truncate"
title=
{
resolvedValue
}
>
{
resolvedValue
}
</
span
>
</
Autocomplete
.
Value
>
<
Autocomplete
.
Indicator
>
<
ChevronDown
size=
{
16
}
/>
</
Autocomplete
.
Indicator
>
</
Autocomplete
.
Trigger
>
<
Autocomplete
.
Popover
placement=
"bottom start"
>
<
Autocomplete
.
Filter
filter=
{
shouldFilter
?
filter
||
defaultFilter
:
()
=>
true
}
inputValue=
{
inputValue
}
onInputChange=
{
onInputChange
}
>
<
SearchField
aria
-
label=
{
searchPlaceholder
}
fullWidth
variant=
"secondary"
>
<
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"
>
{
isLoading
?
<
Spinner
size=
"sm"
/>
:
errorText
||
emptyText
}
</
div
>
)
}
>
{
(
option
)
=>
(
<
ListBox
.
Item
id=
{
option
.
id
}
textValue=
{
option
.
textValue
||
option
.
label
}
>
{
renderOption
?
renderOption
(
option
)
:
<
Label
>
{
option
.
label
}
</
Label
>
}
<
ListBox
.
ItemIndicator
/>
</
ListBox
.
Item
>
)
}
</
ListBox
>
</
Autocomplete
.
Filter
>
</
Autocomplete
.
Popover
>
</
Autocomplete
>
);
}
src/constants/app.js
View file @
b4b5a6e7
export
const
APPLICATION_NAME
=
'risk_case_library'
;
export
const
APPLICATION_BASE_PATH
=
`/
${
APPLICATION_NAME
}
/`
;
export
const
AUTHORIZATION_APPLICATION_NAME
=
APPLICATION_NAME
;
export
const
APP_ROUTE_NAME
=
'risk_case_library'
;
export
const
APP_DISPLAY_NAME
=
'警示案例库'
;
export
const
APP_LOGIN_SUBTITLE
=
'案例库工作台'
;
src/pages/project_manager_xy/AppIcons.jsx
View file @
b4b5a6e7
import
BankOutlined
from
'@ant-design/icons/BankOutlined'
;
import
BellOutlined
from
'@ant-design/icons/BellOutlined'
;
import
BarChartOutlined
from
'@ant-design/icons/BarChartOutlined'
;
import
AppstoreOutlined
from
'@ant-design/icons/AppstoreOutlined'
;
import
CalendarOutlined
from
'@ant-design/icons/CalendarOutlined'
;
...
...
@@ -55,7 +54,6 @@ function withSize(Icon) {
}
export
const
Bank
=
withSize
(
BankOutlined
);
export
const
Bell
=
withSize
(
BellOutlined
);
export
const
Archive
=
withSize
(
InboxOutlined
);
export
const
AppGrid
=
withSize
(
AppstoreOutlined
);
export
const
BarChart
=
withSize
(
BarChartOutlined
);
...
...
src/pages/project_manager_xy/ApplicationSwitcherDialog.jsx
View file @
b4b5a6e7
...
...
@@ -3,7 +3,7 @@ import xinyuanLogo from '@/assets/identity/xy-logo-red.png';
import
{
APP_DISPLAY_NAME
,
APP_LOGIN_SUBTITLE
,
APP
LICATION
_NAME
,
APP
_ROUTE
_NAME
,
}
from
'@/constants/app'
;
import
WarningOutlined
from
'@ant-design/icons/WarningOutlined'
;
import
{
EmptyState
}
from
'@heroui-pro/react/empty-state'
;
...
...
@@ -36,7 +36,7 @@ function resolveApplicationUrl(application) {
function
normalizeApplication
(
application
,
fallback
=
{})
{
const
name
=
application
?.
name
||
fallback
?.
name
||
''
;
const
isCurrentSystem
=
name
===
APP
LICATION
_NAME
;
const
isCurrentSystem
=
name
===
APP
_ROUTE
_NAME
;
return
{
name
,
...
...
@@ -97,11 +97,11 @@ function mergeApplications(fetchedApplications, fallbackApplications) {
);
});
if
(
!
mergedMap
.
has
(
APP
LICATION
_NAME
))
{
if
(
!
mergedMap
.
has
(
APP
_ROUTE
_NAME
))
{
mergedMap
.
set
(
APP
LICATION
_NAME
,
APP
_ROUTE
_NAME
,
normalizeApplication
({
name
:
APP
LICATION
_NAME
,
name
:
APP
_ROUTE
_NAME
,
title
:
APP_DISPLAY_NAME
,
description
:
APP_LOGIN_SUBTITLE
,
logo
:
xinyuanLogo
,
...
...
src/pages/project_manager_xy/Layout.jsx
View file @
b4b5a6e7
...
...
@@ -2,19 +2,15 @@ import xyLogoRed from '@/assets/identity/xy-logo-red.png';
import
xyLogoWhite
from
'@/assets/identity/xy-logo-white.png'
;
import
{
APP_DISPLAY_NAME
}
from
'@/constants/app'
;
import
ApplicationSwitcherDialog
from
'@/pages/project_manager_xy/ApplicationSwitcherDialog'
;
import
{
loadUnreadNotificationCount
,
NOTIFICATION_STATE_EVENT
,
}
from
'@/pages/project_manager_xy/notifications/notificationService'
;
import
{
UserContext
}
from
'@/wrapper/Auth'
;
import
{
SiteContext
}
from
'@/wrapper/Site'
;
import
{
Navbar
}
from
'@heroui-pro/react/navbar'
;
import
{
Sidebar
,
useSidebar
}
from
'@heroui-pro/react/sidebar'
;
import
{
Avatar
,
B
adge
,
Button
,
Dropdown
,
Label
,
Toast
,
Tooltip
}
from
'@heroui/react'
;
import
{
useContext
,
use
Effect
,
use
LayoutEffect
,
useMemo
,
useState
}
from
'react'
;
import
{
Avatar
,
B
utton
,
Dropdown
,
Label
,
Tooltip
}
from
'@heroui/react'
;
import
{
useContext
,
useLayoutEffect
,
useMemo
,
useState
}
from
'react'
;
import
{
Heading
}
from
'react-aria-components/Dialog'
;
import
{
history
,
Outlet
,
useLocation
}
from
'umi'
;
import
{
AppGrid
,
Archive
,
BarChart
,
Bell
,
LogOut
,
Moon
,
Sun
}
from
'./AppIcons'
;
import
{
AppGrid
,
Archive
,
BarChart
,
LogOut
,
Moon
,
Sun
}
from
'./AppIcons'
;
const
APP_NAME
=
APP_DISPLAY_NAME
;
const
THEME_STORAGE_KEY
=
'xinyuan_color_theme'
;
...
...
@@ -42,7 +38,6 @@ function getInitialTheme() {
}
function
getRouteTitle
(
pathname
)
{
if
(
pathname
===
'/notifications'
)
return
'通知消息'
;
for
(
const
group
of
menuGroups
)
{
const
activeItem
=
group
.
items
.
find
((
item
)
=>
item
.
path
===
pathname
);
if
(
activeItem
)
return
activeItem
.
name
;
...
...
@@ -221,8 +216,6 @@ function SidebarContents({ onOpenApplicationSwitcher, pathname, userInfo }) {
}
function
DashboardNavbar
({
notificationCount
,
onOpenNotifications
,
onThemeChange
,
theme
,
title
,
...
...
@@ -230,7 +223,6 @@ function DashboardNavbar({
const
{
isMobile
,
isOpen
}
=
useSidebar
();
const
isDark
=
theme
===
'dark'
;
const
themeLabel
=
isDark
?
'切换至浅色模式'
:
'切换至深色模式'
;
const
unreadCount
=
Math
.
max
(
0
,
Number
(
notificationCount
)
||
0
);
return
(
<
Navbar
className=
"app-navbar rc-navbar"
maxWidth=
"full"
>
...
...
@@ -242,26 +234,6 @@ function DashboardNavbar({
<
h1
className=
"app-navbar-page-title rc-navbar-title"
>
{
title
}
</
h1
>
<
Navbar
.
Spacer
/>
<
div
className=
"rc-navbar-actions"
>
<
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
>
{
unreadCount
>
0
?
(
<
Badge
aria
-
label=
{
`${unreadCount}条未读通知`
}
color=
"danger"
size=
"sm"
>
{
unreadCount
>
99
?
'99+'
:
unreadCount
}
</
Badge
>
)
:
null
}
</
Badge
.
Anchor
>
<
Tooltip
.
Content
>
通知消息
</
Tooltip
.
Content
>
</
Tooltip
>
<
Tooltip
delay=
{
0
}
>
<
Button
isIconOnly
...
...
@@ -287,7 +259,6 @@ export default function ProjectManagerLayout() {
const
[
isApplicationDialogOpen
,
setIsApplicationDialogOpen
]
=
useState
(
false
);
const
[
sidebarOpen
,
setSidebarOpen
]
=
useState
(
true
);
const
[
theme
,
setTheme
]
=
useState
(
getInitialTheme
);
const
[
unreadNotificationCount
,
setUnreadNotificationCount
]
=
useState
(
0
);
const
userInfo
=
useContext
(
UserContext
);
const
siteInfo
=
useContext
(
SiteContext
);
...
...
@@ -309,26 +280,6 @@ export default function ProjectManagerLayout() {
document
.
title
=
APP_NAME
;
},
[]);
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
]);
const
routeTitle
=
useMemo
(
()
=>
getRouteTitle
(
location
.
pathname
),
[
location
.
pathname
],
...
...
@@ -349,8 +300,6 @@ export default function ProjectManagerLayout() {
/>
<
Sidebar
.
Main
className=
"app-main-glass rc-app-main"
>
<
DashboardNavbar
notificationCount=
{
unreadNotificationCount
}
onOpenNotifications=
{
()
=>
history
.
push
(
'/notifications'
)
}
theme=
{
theme
}
title=
{
routeTitle
}
onThemeChange=
{
setTheme
}
...
...
@@ -369,7 +318,6 @@ export default function ProjectManagerLayout() {
userInfo=
{
userInfo
}
siteInfo=
{
siteInfo
}
/>
<
Toast
.
Provider
maxVisibleToasts=
{
3
}
placement=
"top"
/>
</
Sidebar
.
Provider
>
);
}
src/pages/project_manager_xy/RiskCaseMaterialField.jsx
deleted
100644 → 0
View file @
17393f5a
import
{
ArrowPathIcon
,
Delete
}
from
'@/components/AppIcons'
;
import
FileUploadField
from
'@/components/FileUploadField'
;
import
XinyuanAutocomplete
from
'@/components/XinyuanAutocomplete'
;
import
{
Alert
,
Button
,
Label
,
Tabs
,
Tooltip
}
from
'@heroui/react'
;
import
{
useEffect
,
useMemo
,
useState
}
from
'react'
;
import
{
queryRiskCaseMaterials
,
uploadRiskCaseFile
}
from
'./riskCaseMaterialService'
;
const
MATERIAL_CENTER_TAB
=
'material-center'
;
const
LOCAL_UPLOAD_TAB
=
'local-upload'
;
function
toFiles
(
value
)
{
return
(
Array
.
isArray
(
value
)
?
value
:
value
?
[
value
]
:
[]).
filter
(
Boolean
);
}
function
fileKey
(
file
=
{})
{
return
file
.
url
||
[
file
.
name
,
file
.
type
,
file
.
size
].
filter
(
Boolean
).
join
(
'|'
);
}
function
appendUnique
(
current
,
next
)
{
return
[...
new
Map
([...
current
,
...
next
].
map
((
file
)
=>
[
fileKey
(
file
),
file
])).
values
()];
}
export
default
function
RiskCaseMaterialField
({
editSessionId
,
fieldPath
,
multiple
=
false
,
onChange
,
onPendingLocalMaterialChange
,
onValidationChange
,
value
,
})
{
const
[
activeTab
,
setActiveTab
]
=
useState
(
MATERIAL_CENTER_TAB
);
const
[
keyword
,
setKeyword
]
=
useState
(
''
);
const
[
debouncedKeyword
,
setDebouncedKeyword
]
=
useState
(
''
);
const
[
materials
,
setMaterials
]
=
useState
([]);
const
[
selectedKey
,
setSelectedKey
]
=
useState
(
null
);
const
[
loading
,
setLoading
]
=
useState
(
false
);
const
[
error
,
setError
]
=
useState
(
''
);
const
[
retryKey
,
setRetryKey
]
=
useState
(
0
);
const
[
uploading
,
setUploading
]
=
useState
(
false
);
const
[
localKeys
,
setLocalKeys
]
=
useState
(()
=>
new
Set
());
const
files
=
useMemo
(()
=>
multiple
?
toFiles
(
value
)
:
toFiles
(
value
).
slice
(
0
,
1
),
[
multiple
,
value
]);
useEffect
(()
=>
{
setActiveTab
(
MATERIAL_CENTER_TAB
);
setKeyword
(
''
);
setSelectedKey
(
null
);
setError
(
''
);
setLocalKeys
(
new
Set
());
},
[
editSessionId
]);
useEffect
(()
=>
{
const
timer
=
window
.
setTimeout
(()
=>
setDebouncedKeyword
(
keyword
),
300
);
return
()
=>
window
.
clearTimeout
(
timer
);
},
[
keyword
]);
useEffect
(()
=>
{
if
(
activeTab
!==
MATERIAL_CENTER_TAB
)
return
undefined
;
let
cancelled
=
false
;
setLoading
(
true
);
setError
(
''
);
queryRiskCaseMaterials
(
debouncedKeyword
)
.
then
((
items
)
=>
{
if
(
!
cancelled
)
setMaterials
(
items
);
})
.
catch
((
requestError
)
=>
{
if
(
!
cancelled
)
{
setMaterials
([]);
setError
(
requestError
?.
message
||
'审计材料加载失败。'
);
}
})
.
finally
(()
=>
{
if
(
!
cancelled
)
setLoading
(
false
);
});
return
()
=>
{
cancelled
=
true
;
};
},
[
activeTab
,
debouncedKeyword
,
retryKey
]);
const
options
=
materials
.
map
((
material
)
=>
({
...
material
,
label
:
material
.
materialTitle
,
textValue
:
`
${
material
.
materialTitle
}
${
material
.
fileAsset
?.
name
||
''
}
`,
}));
function handleMaterialSelection(key) {
const material = materials.find((item) => String(item.id) === String(key || ''));
if (!material?.fileAsset?.url) return;
const snapshot = {
name: material.fileAsset.name,
type: material.fileAsset.type || 'application/octet-stream',
size: Number(material.fileAsset.size || 0),
url: material.fileAsset.url,
};
setSelectedKey(multiple ? null : material.id);
if (!multiple) {
setLocalKeys(new Set());
onPendingLocalMaterialChange?.(fieldPath, { type: 'replace', materials: [] }, editSessionId);
}
onChange?.(multiple ? appendUnique(files, [snapshot]) : snapshot);
}
async function handleLocalUpload(selectedFiles) {
const nextFiles = multiple ? toFiles(selectedFiles) : toFiles(selectedFiles).slice(0, 1);
if (nextFiles.length === 0) return;
setUploading(true);
setError('');
onValidationChange?.(fieldPath, '附件正在上传,请稍候。', editSessionId);
const uploaded = [];
const failed = [];
for (const file of nextFiles) {
try { uploaded.push(await uploadRiskCaseFile(file)); }
catch (uploadError) { failed.push({ file, uploadError }); }
}
if (uploaded.length > 0) {
const pending = uploaded.map((fileAsset) => ({ clientId: fileKey(fileAsset), fileAsset, materialRecordId: null }));
setLocalKeys((current) => new Set(multiple ? [...current, ...pending.map((item) => item.clientId)] : pending.map((item) => item.clientId)));
setSelectedKey(null);
onPendingLocalMaterialChange?.(fieldPath, { type: multiple ? 'add' : 'replace', materials: pending }, editSessionId);
onChange?.(multiple ? appendUnique(files, uploaded) : uploaded[uploaded.length - 1]);
}
if (failed.length > 0) setError(failed.length === nextFiles.length ? failed[0].uploadError?.message || '附件上传失败。' : `
部分附件上传失败:
$
{
failed
.
map
((
item
)
=>
item
.
file
?.
name
).
filter
(
Boolean
).
join
(
'、'
)}
`);
setUploading(false);
onValidationChange?.(fieldPath, '', editSessionId);
}
function removeFile(file, index) {
const key = fileKey(file);
setLocalKeys((current) => { const next = new Set(current); next.delete(key); return next; });
onPendingLocalMaterialChange?.(fieldPath, { type: 'remove', clientId: key }, editSessionId);
onChange?.(multiple ? files.filter((_, fileIndex) => fileIndex !== index) : null);
}
return <div className="space-y-4">
{files.length > 0 ? <div className="space-y-2">{files.map((file, index) => <div className="flex min-w-0 items-center justify-between gap-3" key={`
$
{
fileKey
(
file
)}
-
$
{
index
}
`}>
<div className="min-w-0"><div className="truncate text-sm font-medium" title={file.name || file.url}>{file.name || file.url}</div><div className="mt-1 text-xs text-muted">{localKeys.has(fileKey(file)) ? '本地文件,保存后同步至审计材料中心' : '已选择附件'}</div></div>
<Tooltip><Button isIconOnly aria-label={`
删除附件:
$
{
file
.
name
||
index
+
1
}
`} isDisabled={uploading} size="sm" variant="danger-soft" onPress={() => removeFile(file, index)}><Delete size={16} /></Button><Tooltip.Content>删除当前附件</Tooltip.Content></Tooltip>
</div>)}</div> : <span className="text-sm text-muted">未上传</span>}
<Tabs selectedKey={activeTab} variant="secondary" onSelectionChange={(key) => setActiveTab(String(key))}>
<Tabs.ListContainer><Tabs.List aria-label="附件来源"><Tabs.Tab id={MATERIAL_CENTER_TAB}>材料中心<Tabs.Indicator /></Tabs.Tab><Tabs.Tab id={LOCAL_UPLOAD_TAB}>本地上传<Tabs.Indicator /></Tabs.Tab></Tabs.List></Tabs.ListContainer>
<Tabs.Panel className="pt-3" id={MATERIAL_CENTER_TAB}>
<XinyuanAutocomplete aria-label="选择审计材料" emptyText="没有找到可选择的审计材料" errorText={error} inputValue={keyword} isDisabled={uploading} isLoading={loading} options={options} placeholder={multiple && files.length ? `
已选
$
{
files
.
length
}
个附件,可继续添加
` : '请选择审计材料'} searchPlaceholder="搜索材料标题或文件名" selectedKey={selectedKey} shouldFilter={false} onInputChange={setKeyword} onSelectionChange={handleMaterialSelection} renderOption={(material) => <span className="min-w-0"><Label className="block truncate">{material.materialTitle}</Label><span className="block truncate text-xs text-muted">{[material.fileAsset?.name, material.archiveCategory, material.archiveYear].filter(Boolean).join(' · ')}</span></span>} />
{error ? <Alert className="mt-3" status="danger"><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content><Button size="sm" variant="danger-soft" onPress={() => setRetryKey((value) => value + 1)}><ArrowPathIcon size={14} />重试</Button></Alert> : null}
</Tabs.Panel>
<Tabs.Panel className="pt-3" id={LOCAL_UPLOAD_TAB}><FileUploadField isDisabled={uploading} multiple={multiple} triggerLabel={uploading ? '上传中...' : '选择本地文件'} onFilesChange={handleLocalUpload} /></Tabs.Panel>
</Tabs>
</div>;
}
src/pages/project_manager_xy/audit-material-center.business-flow.md
View file @
b4b5a6e7
...
...
@@ -15,7 +15,6 @@
-
每条案例对应一条独立
`Record`
-
案例详情以文本为主,不额外拆分附件对象
-
页面优先保证“看起来直观、方便查”
-
Grid/Detail 的展示与编辑权限以运行时 Subject View 为准;没有可访问 Grid View 时不绕过授权查询案例
---
...
...
@@ -181,37 +180,6 @@
5.
前端逐条调用
`DELETE /record/:name`
6.
删除后刷新列表,并保留失败项提示
### 4.9 Subject View、shortTitle 与权限
1.
进入案例管理后,前端先读取
`risk_case_library`
Subject、View 摘要及每个 View 详情。
2.
仅
`type = "grid"`
且通过
`settings.viewAuth`
的 View 可作为列表视图;普通用户缺少配置时默认拒绝,系统管理员及
`user.applications[]`
中
`name = "risk_case_library"`
的“管理”角色可旁路。
3.
没有可访问 Grid View 时显示无可访问视图状态,不建立兜底 View,不调用案例记录/计数接口,也不展示新建、导入导出、批量或行操作。
4.
只有一个可访问 Grid View 时隐藏 Tabs 轨道;多个 View 时切换后立即应用其 columns、restrictions、filters 与 sort,并回到第一页。
5.
Grid View 的
`settings.shortTitle === true`
时只隐藏根
`title`
展示列,
`title`
仍参与关键词搜索、排序、查询和保存;查看详情继续通过固定右侧操作列进入。
6.
Detail View 决定详情字段顺序、条件和
`shortTitle`
。当前 Detail View
`shortTitle === true`
时隐藏记录标题和 title 字段,但始终保留“案例详情”功能标题和 Modal 可访问名称。
7.
详情字段编辑权限优先使用当前完整 Detail View:只要其
`settings.auth`
或
`settings.disableColumns`
中存在任一有效字段配置,就整套使用该 Detail;两项均无有效配置或不存在实际 Detail 时,整体回退当前 Grid View。Detail 与 Grid 权限不合并,也不逐字段回退。
8.
选定权限来源后,
`disableColumns`
对包括管理员在内的所有用户始终只读;普通用户缺少字段
`auth`
时默认只读。系统管理员、系统账号和
`risk_case_library`
自身“管理”角色只能旁路字段
`auth`
。
9.
Detail
`columns`
仅控制当前详情字段的展示范围和顺序,和
`auth`
相互独立;未展示字段的权限配置不产生界面效果。“编辑”按钮只检查当前 Detail 实际展示的字段,切换 Detail 或 Grid 后立即重算。
### 4.10 通知消息
1.
Navbar 主题按钮左侧显示 36px Bell,点击进入独立
`/notifications`
页面;Sidebar 不增加通知入口。
2.
Navbar 每 60 秒读取平台真实未读数,并在通知状态变化后立即刷新;0 隐藏角标,1-99 显示真实值,100 以上显示
`99+`
。
3.
页面支持消息/待办、全部/未读、300ms 远程搜索和固定每页 10 条分页。
4.
用户可标记单条已读、全部已读,并执行服务器配置的动态 Action;Action 请求保留
`{notice_id}`
协议。
5.
通知正文只作为纯文本显示,不执行 HTML。站内链接移除自身
`/risk_case_library`
base 后跳转;其他应用和外部 HTTP(S) 地址新窗口打开。
6.
查询或操作失败时保留页面状态并展示可重试错误,不伪造通知数量或内容。
### 4.11 文件与审计材料中心
1.
仅当运行时 Subject/Detail View 中出现可编辑
`type = "file"`
字段时,展示“材料中心 / 本地上传”;单值或多值由字段
`multiple`
配置决定。
2.
材料中心通过
`am_material`
查询最近 20 条,搜索同时匹配材料标题和文件名。选中后仅把
`{name,type,size,url}`
快照写入详情草稿,不保存材料 ID。
3.
本地文件先通过
`/upload`
上传并进入详情草稿,不立即创建
`am_material`
。取消编辑会清空会话草稿与待建档标记,不创建材料主档。
4.
用户保存详情时,前端先为仍保留在草稿中的本地文件逐个创建
`am_material`
,
`source_app`
固定为
`risk_case_library`
;全部成功后再调用原案例更新回调。
5.
若部分材料创建或业务更新失败,详情草稿与已建档标记保留。重试时跳过已有
`materialRecordId`
的文件,避免重复建档。
6.
单值字段替换,多值字段追加并按 URL 去重。清空或替换业务附件不删除材料中心主档或已上传二进制。
7.
当前真实
`risk_case_library`
Subject 没有 file 字段,因此现网页面不会显示材料入口,也不会增加测试字段或改变既有 schema。
---
## 5. 联动规则
...
...
src/pages/project_manager_xy/materialService.js
View file @
b4b5a6e7
...
...
@@ -5,7 +5,6 @@ import {
removeRecord
,
updateRecord
,
}
from
'@/services/DataService'
;
import
{
buildViewSearchColumns
,
toArray
}
from
'@/utils/subjectView'
;
import
dayjs
from
'dayjs'
;
import
*
as
XLSX
from
'xlsx'
;
...
...
@@ -337,7 +336,7 @@ export function createCaseTitle(values = {}) {
return
'未命名案例'
;
}
function
buildKeywordFilter
(
keyword
=
''
,
searchColumns
=
[]
)
{
function
buildKeywordFilter
(
keyword
=
''
)
{
const
normalizedKeyword
=
String
(
keyword
||
''
).
trim
();
if
(
!
normalizedKeyword
)
{
return
null
;
...
...
@@ -348,8 +347,8 @@ function buildKeywordFilter(keyword = '', searchColumns = []) {
$options
:
'i'
,
};
const
configuredFields
=
searchColumns
.
map
((
column
)
=>
column
?.
field
).
filter
(
Boolean
);
const
fields
=
configuredFields
.
length
>
0
?
[...
new
Set
(
configuredFields
)]
:
[
return
{
$or
:
[
{
title
:
regex
},
{
'metadata.label'
:
regex
},
{
'metadata.case_type'
:
regex
},
...
...
@@ -361,13 +360,8 @@ function buildKeywordFilter(keyword = '', searchColumns = []) {
{
'metadata.case_level'
:
regex
},
{
'metadata.category'
:
regex
},
{
'metadata.tag'
:
regex
},
];
if
(
configuredFields
.
length
>
0
)
{
return
{
$or
:
fields
.
map
((
field
)
=>
({
[
field
]:
regex
}))
};
}
return
{
$or
:
fields
};
],
};
}
function
buildMetadataTextFilter
(
fieldName
,
value
=
''
)
{
...
...
@@ -384,52 +378,9 @@ function buildMetadataTextFilter(fieldName, value = '') {
};
}
function
buildComparatorFilter
(
field
,
comparator
=
'$eq'
,
value
)
{
if
(
!
field
)
return
null
;
if
(
comparator
===
'$eq'
)
return
{
[
field
]:
value
};
if
(
comparator
===
'$contains'
)
{
return
{
[
field
]:
{
$regex
:
escapeRegExp
(
String
(
value
??
''
)),
$options
:
'i'
}
};
}
if
(
comparator
===
'$exists'
)
return
{
[
field
]:
{
$exists
:
Boolean
(
value
)
}
};
return
{
[
field
]:
{
[
comparator
]:
value
}
};
}
function
buildViewClauses
(
view
)
{
const
restrictions
=
toArray
(
view
?.
settings
?.
restrictions
)
.
filter
((
restriction
)
=>
restriction
?.
type
===
'field'
&&
restriction
.
field
)
.
map
((
restriction
)
=>
{
const
comparator
=
restriction
.
comparator
||
'$in'
;
return
buildComparatorFilter
(
restriction
.
field
,
comparator
,
[
'$in'
,
'$nin'
].
includes
(
comparator
)
?
toArray
(
restriction
.
value
)
:
restriction
.
value
,
);
});
const
filterSettings
=
view
?.
settings
?.
filters
||
{};
const
conditions
=
toArray
(
filterSettings
.
conditions
)
.
filter
((
condition
)
=>
condition
?.
field
)
.
map
((
condition
)
=>
buildComparatorFilter
(
condition
.
field
,
condition
.
comparator
||
'$eq'
,
[
'$in'
,
'$nin'
].
includes
(
condition
.
comparator
)
?
toArray
(
condition
.
value
)
:
condition
.
value
,
));
if
(
conditions
.
length
>
0
)
{
restrictions
.
push
(
filterSettings
.
relationship
===
'any'
||
filterSettings
.
relationship
===
'or'
?
{
$or
:
conditions
}
:
conditions
.
length
===
1
?
conditions
[
0
]
:
{
$and
:
conditions
},
);
}
return
restrictions
.
filter
(
Boolean
);
}
function
buildRecordFilter
(
filters
=
{},
view
=
null
,
subject
=
null
)
{
function
buildRecordFilter
(
filters
=
{})
{
const
clauses
=
[];
const
keywordFilter
=
buildKeywordFilter
(
filters
.
keyword
,
view
?
buildViewSearchColumns
(
view
,
subject
)
:
[],
);
const
keywordFilter
=
buildKeywordFilter
(
filters
.
keyword
);
if
(
keywordFilter
)
{
clauses
.
push
(
keywordFilter
);
...
...
@@ -464,8 +415,6 @@ function buildRecordFilter(filters = {}, view = null, subject = null) {
}
});
clauses
.
push
(...
buildViewClauses
(
view
));
if
(
clauses
.
length
===
0
)
{
return
{};
}
...
...
@@ -479,14 +428,6 @@ function buildRecordFilter(filters = {}, view = null, subject = null) {
};
}
function
buildViewSort
(
view
)
{
const
configured
=
toArray
(
view
?.
settings
?.
columns
)
.
filter
((
column
)
=>
column
?.
field
&&
column
.
sort
)
.
sort
((
left
,
right
)
=>
(
left
.
sortIndex
??
0
)
-
(
right
.
sortIndex
??
0
))
.
map
((
column
)
=>
column
.
sort
===
'desc'
?
`-
${
column
.
field
}
`
:
column
.
field
);
return
[...
configured
,
'-updated_at'
,
'-created_at'
,
'_id'
].
join
(
' '
);
}
function
hasActiveFilters
(
filters
=
{})
{
return
Boolean
(
String
(
filters
.
keyword
||
''
).
trim
()
||
...
...
@@ -639,8 +580,8 @@ async function queryCaseRecordPage(filters = {}) {
subject
:
CASE_SUBJECT
,
page
:
Number
(
filters
.
page
||
0
),
page_size
:
Number
(
filters
.
pageSize
||
DEFAULT_PAGE_SIZE
),
filter
:
buildRecordFilter
(
filters
,
filters
.
view
,
filters
.
subject
),
sort
:
buildViewSort
(
filters
.
view
)
,
filter
:
buildRecordFilter
(
filters
),
sort
:
'-updated_at -created_at'
,
});
const
records
=
extractArray
(
response
);
...
...
@@ -654,7 +595,7 @@ async function queryCaseRecordPage(filters = {}) {
export
async
function
loadCasePage
(
filters
=
{})
{
const
page
=
Number
(
filters
.
page
||
0
);
const
pageSize
=
Number
(
filters
.
pageSize
||
DEFAULT_PAGE_SIZE
);
const
recordFilter
=
buildRecordFilter
(
filters
,
filters
.
view
,
filters
.
subject
);
const
recordFilter
=
buildRecordFilter
(
filters
);
try
{
const
pageResult
=
await
queryCaseRecordPage
({
...
...
@@ -790,7 +731,6 @@ export function buildCasePayload(values = {}) {
content_type
:
'text'
,
content
:
caseDetails
,
metadata
:
{
...(
values
.
metadataDraft
||
{}),
label
,
category
,
tag
,
...
...
src/pages/project_manager_xy/notifications/NotificationListPage.jsx
deleted
100644 → 0
View file @
17393f5a
import
{
Bell
,
Close
,
MagnifyingGlassIcon
,
OpenExternal
}
from
'@/components/AppIcons'
;
import
{
DataTablePagination
}
from
'@/components/DataTable'
;
import
{
UserContext
}
from
'@/wrapper/Auth'
;
import
{
EmptyState
}
from
'@heroui-pro/react/empty-state'
;
import
{
Alert
,
Avatar
,
Button
,
Card
,
Chip
,
SearchField
,
Spinner
,
Tabs
,
toast
}
from
'@heroui/react'
;
import
{
useCallback
,
useContext
,
useEffect
,
useState
}
from
'react'
;
import
{
history
}
from
'umi'
;
import
{
executeNotificationAction
,
extractNotificationTarget
,
getNotificationContent
,
getNotificationSender
,
isNotificationRead
,
loadNotificationPage
,
loadUnreadNotificationCount
,
markAllNotificationsRead
,
markNotificationRead
,
NOTIFICATION_PAGE_SIZE
,
resolveNotificationTarget
,
}
from
'./notificationService'
;
const
CATEGORY_OPTIONS
=
[{
key
:
'message'
,
label
:
'消息'
},
{
key
:
'todo'
,
label
:
'待办'
}];
const
READ_STATUS_OPTIONS
=
[{
key
:
'all'
,
label
:
'全部'
},
{
key
:
'unread'
,
label
:
'未读'
}];
function
formatTime
(
value
)
{
if
(
!
value
)
return
'时间未知'
;
const
date
=
new
Date
(
value
);
return
Number
.
isNaN
(
date
.
getTime
())
?
String
(
value
)
:
new
Intl
.
DateTimeFormat
(
'zh-CN'
,
{
year
:
'numeric'
,
month
:
'2-digit'
,
day
:
'2-digit'
,
hour
:
'2-digit'
,
minute
:
'2-digit'
,
hour12
:
false
,
}).
format
(
date
);
}
function
NotificationItem
({
notification
,
category
,
currentUserId
,
busy
,
onAction
,
onMarkRead
,
onOpenTarget
})
{
const
read
=
isNotificationRead
(
notification
,
currentUserId
);
const
sender
=
getNotificationSender
(
notification
);
const
target
=
resolveNotificationTarget
(
extractNotificationTarget
(
notification
));
const
actions
=
Array
.
isArray
(
notification
?.
actions
)
?
notification
.
actions
:
[];
return
(
<
Card
>
<
Card
.
Content
className=
"flex flex-col gap-4 p-4 sm:p-5"
>
<
div
className=
"flex min-w-0 items-start gap-3"
>
<
Avatar
className=
"shrink-0"
size=
"sm"
><
Avatar
.
Fallback
>
{
sender
.
trim
().
slice
(
0
,
1
)
||
'系'
}
</
Avatar
.
Fallback
></
Avatar
>
<
div
className=
"min-w-0 flex-1"
>
<
div
className=
"flex flex-wrap items-start justify-between gap-2"
>
<
div
className=
"min-w-0"
>
<
h2
className=
"break-words text-sm font-medium sm:text-base"
>
{
notification
?.
title
||
(
category
===
'todo'
?
'待办通知'
:
'通知消息'
)
}
</
h2
>
<
p
className=
"mt-1 text-xs text-muted sm:text-sm"
>
{
sender
}
·
{
formatTime
(
notification
?.
sent_at
||
notification
?.
created_at
)
}
</
p
>
</
div
>
<
Chip
color=
{
read
?
undefined
:
'danger'
}
size=
"sm"
variant=
"soft"
>
{
read
?
(
category
===
'todo'
?
'已完成'
:
'已读'
)
:
category
===
'todo'
?
'待处理'
:
'未读'
}
</
Chip
>
</
div
>
<
p
className=
"mt-3 whitespace-pre-wrap break-words text-sm leading-6"
>
{
getNotificationContent
(
notification
)
||
'暂无通知内容'
}
</
p
>
</
div
>
</
div
>
<
div
className=
"flex min-h-8 flex-wrap items-center justify-end gap-2"
>
{
!
read
?
<
Button
isDisabled=
{
busy
}
size=
"sm"
variant=
"secondary"
onPress=
{
()
=>
onMarkRead
(
notification
)
}
>
标为已读
</
Button
>
:
null
}
{
category
===
'todo'
?
actions
.
map
((
action
,
index
)
=>
(
<
Button
isDisabled=
{
busy
||
read
}
key=
{
`${action?.label || 'action'}-${index}`
}
size=
"sm"
variant=
"secondary"
onPress=
{
()
=>
onAction
(
notification
,
action
)
}
>
{
action
?.
label
||
'处理'
}
</
Button
>
))
:
null
}
{
target
?
<
Button
isDisabled=
{
busy
}
size=
"sm"
variant=
"primary"
onPress=
{
()
=>
onOpenTarget
(
notification
,
target
)
}
><
OpenExternal
size=
{
14
}
/>
进入流程
</
Button
>
:
null
}
</
div
>
</
Card
.
Content
>
</
Card
>
);
}
export
default
function
NotificationListPage
()
{
const
userInfo
=
useContext
(
UserContext
);
const
[
category
,
setCategory
]
=
useState
(
'message'
);
const
[
readStatus
,
setReadStatus
]
=
useState
(
'all'
);
const
[
searchText
,
setSearchText
]
=
useState
(
''
);
const
[
keyword
,
setKeyword
]
=
useState
(
''
);
const
[
page
,
setPage
]
=
useState
(
0
);
const
[
notifications
,
setNotifications
]
=
useState
([]);
const
[
total
,
setTotal
]
=
useState
(
0
);
const
[
unreadTotal
,
setUnreadTotal
]
=
useState
(
0
);
const
[
loading
,
setLoading
]
=
useState
(
true
);
const
[
error
,
setError
]
=
useState
(
''
);
const
[
busyKey
,
setBusyKey
]
=
useState
(
''
);
const
[
reloadKey
,
setReloadKey
]
=
useState
(
0
);
useEffect
(()
=>
{
const
timer
=
window
.
setTimeout
(()
=>
{
setKeyword
(
searchText
.
trim
());
setPage
(
0
);
},
300
);
return
()
=>
window
.
clearTimeout
(
timer
);
},
[
searchText
]);
useEffect
(()
=>
{
let
cancelled
=
false
;
setLoading
(
true
);
setError
(
''
);
Promise
.
all
([
loadNotificationPage
({
page
,
category
,
readStatus
,
keyword
}),
loadUnreadNotificationCount
(),
]).
then
(([
result
,
count
])
=>
{
if
(
cancelled
)
return
;
setNotifications
(
result
.
list
);
setTotal
(
result
.
total
);
setUnreadTotal
(
count
);
}).
catch
((
requestError
)
=>
{
if
(
!
cancelled
)
{
setNotifications
([]);
setTotal
(
0
);
setError
(
requestError
?.
message
||
'通知列表加载失败,请稍后重试'
);
}
}).
finally
(()
=>
{
if
(
!
cancelled
)
setLoading
(
false
);
});
return
()
=>
{
cancelled
=
true
;
};
},
[
category
,
keyword
,
page
,
readStatus
,
reloadKey
]);
const
refresh
=
useCallback
(()
=>
setReloadKey
((
value
)
=>
value
+
1
),
[]);
const
runOperation
=
useCallback
(
async
(
key
,
operation
,
message
)
=>
{
setBusyKey
(
key
);
try
{
await
operation
();
toast
.
success
(
message
,
{
timeout
:
3000
});
refresh
();
return
true
;
}
catch
(
operationError
)
{
toast
.
danger
(
operationError
?.
message
||
'操作失败,请稍后重试'
,
{
timeout
:
4000
});
return
false
;
}
finally
{
setBusyKey
(
''
);
}
},
[
refresh
]);
const
handleOpenTarget
=
useCallback
(
async
(
notification
,
target
)
=>
{
setBusyKey
(
notification
.
name
);
try
{
if
(
!
isNotificationRead
(
notification
,
userInfo
?.
_id
))
await
markNotificationRead
(
notification
.
name
);
if
(
target
.
external
)
window
.
open
(
target
.
href
,
'_blank'
,
'noopener,noreferrer'
);
else
history
.
push
(
target
.
href
);
refresh
();
}
catch
(
operationError
)
{
toast
.
danger
(
operationError
?.
message
||
'无法打开通知关联流程'
,
{
timeout
:
4000
});
}
finally
{
setBusyKey
(
''
);
}
},
[
refresh
,
userInfo
?.
_id
]);
return
(
<
div
className=
"rc-page-stack"
>
<
div
className=
"rc-page-toolbar rc-page-toolbar-responsive"
>
<
Tabs
className=
"w-fit max-w-full min-w-0"
selectedKey=
{
category
}
onSelectionChange=
{
(
key
)
=>
{
setCategory
(
String
(
key
));
setPage
(
0
);
}
}
>
<
Tabs
.
ListContainer
className=
"w-fit max-w-full min-w-0"
><
Tabs
.
List
aria
-
label=
"通知分类"
className=
"w-max min-w-max"
>
{
CATEGORY_OPTIONS
.
map
((
option
)
=>
<
Tabs
.
Tab
className=
"w-auto min-w-max flex-none whitespace-nowrap"
id=
{
option
.
key
}
key=
{
option
.
key
}
>
{
option
.
label
}
<
Tabs
.
Indicator
/></
Tabs
.
Tab
>)
}
</
Tabs
.
List
></
Tabs
.
ListContainer
>
</
Tabs
>
<
Button
isDisabled=
{
loading
||
unreadTotal
===
0
}
isPending=
{
busyKey
===
'all'
}
size=
"sm"
variant=
"secondary"
onPress=
{
()
=>
runOperation
(
'all'
,
markAllNotificationsRead
,
'全部通知已标记为已读'
)
}
>
全部已读
</
Button
>
</
div
>
<
div
className=
"rc-page-toolbar"
>
<
div
className=
"w-full min-w-0 sm:w-80 lg:w-96"
>
<
SearchField
aria
-
label=
"搜索通知内容"
fullWidth
value=
{
searchText
}
variant=
"primary"
onChange=
{
setSearchText
}
>
<
SearchField
.
Group
><
SearchField
.
SearchIcon
><
MagnifyingGlassIcon
size=
{
16
}
/></
SearchField
.
SearchIcon
><
SearchField
.
Input
placeholder=
"搜索通知内容"
/><
SearchField
.
ClearButton
aria
-
label=
"清空通知搜索"
><
Close
size=
{
12
}
/></
SearchField
.
ClearButton
></
SearchField
.
Group
>
</
SearchField
>
</
div
>
<
Tabs
selectedKey=
{
readStatus
}
onSelectionChange=
{
(
key
)
=>
{
setReadStatus
(
String
(
key
));
setPage
(
0
);
}
}
>
<
Tabs
.
ListContainer
><
Tabs
.
List
aria
-
label=
"通知状态"
>
{
READ_STATUS_OPTIONS
.
map
((
option
)
=>
<
Tabs
.
Tab
className=
"w-auto min-w-max flex-none whitespace-nowrap"
id=
{
option
.
key
}
key=
{
option
.
key
}
>
{
option
.
label
}
<
Tabs
.
Indicator
/></
Tabs
.
Tab
>)
}
</
Tabs
.
List
></
Tabs
.
ListContainer
>
</
Tabs
>
</
div
>
{
error
?
<
Alert
status=
"danger"
><
Alert
.
Content
><
Alert
.
Description
>
{
error
}
</
Alert
.
Description
></
Alert
.
Content
><
Button
size=
"sm"
variant=
"danger-soft"
onPress=
{
refresh
}
>
重试
</
Button
></
Alert
>
:
null
}
{
loading
&&
notifications
.
length
===
0
?
<
div
className=
"flex min-h-64 items-center justify-center"
><
Spinner
size=
"sm"
/></
div
>
:
null
}
{
!
loading
&&
!
error
&&
notifications
.
length
===
0
?
<
EmptyState
className=
"min-h-64"
size=
"md"
><
EmptyState
.
Media
><
Bell
size=
{
22
}
/></
EmptyState
.
Media
><
EmptyState
.
Header
><
EmptyState
.
Title
>
{
category
===
'todo'
?
'暂无待办通知'
:
'暂无通知消息'
}
</
EmptyState
.
Title
><
EmptyState
.
Description
>
{
keyword
?
'没有找到匹配的通知,请调整搜索条件。'
:
readStatus
===
'unread'
?
'当前没有未读通知。'
:
'新的通知会显示在这里。'
}
</
EmptyState
.
Description
></
EmptyState
.
Header
></
EmptyState
>
:
null
}
{
notifications
.
length
>
0
?
<
div
className=
"flex min-w-0 flex-col gap-3"
aria
-
busy=
{
loading
}
>
{
notifications
.
map
((
notification
)
=>
<
NotificationItem
notification=
{
notification
}
category=
{
category
}
currentUserId=
{
userInfo
?.
_id
}
busy=
{
busyKey
===
notification
.
name
}
key=
{
notification
.
name
||
notification
.
_id
}
onMarkRead=
{
(
item
)
=>
runOperation
(
item
.
name
,
()
=>
markNotificationRead
(
item
.
name
),
'已标记为已读'
)
}
onAction=
{
(
item
,
action
)
=>
runOperation
(
item
.
name
,
()
=>
executeNotificationAction
(
item
,
action
),
'待办已处理'
)
}
onOpenTarget=
{
handleOpenTarget
}
/>)
}
</
div
>
:
null
}
{
!
error
&&
total
>
0
?
<
DataTablePagination
isDisabled=
{
loading
}
itemLabel=
"条通知"
page=
{
page
}
pageSize=
{
NOTIFICATION_PAGE_SIZE
}
total=
{
total
}
onPageChange=
{
setPage
}
/>
:
null
}
</
div
>
);
}
src/pages/project_manager_xy/notifications/notificationService.js
deleted
100644 → 0
View file @
17393f5a
import
{
exacteActionFunction
,
queryUserNotice
,
readUserNotice
,
unReadNoticeCount
,
}
from
'@/services/DataService'
;
import
{
APPLICATION_NAME
}
from
'@/constants/app'
;
export
const
NOTIFICATION_PAGE_SIZE
=
10
;
export
const
NOTIFICATION_STATE_EVENT
=
'risk-case-library:notification-state'
;
const
APPLICATION_PATH_PREFIX
=
`/
${
APPLICATION_NAME
}
`
;
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
,
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
,
NOTIFICATION_PAGE_SIZE
,
filter
,
readStatus
===
'unread'
?
'unread'
:
null
,
);
return
{
list
:
toArray
(
response
?.
list
),
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
);
return
normalizedUserId
?
toArray
(
notification
?.
data
?.
logs
).
some
((
log
)
=>
toId
(
log
?.
user_id
)
===
normalizedUserId
)
:
false
;
}
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
match
=
getNotificationContent
(
notification
).
match
(
new
RegExp
(
`https?:\\/\\/[^\\s<>"']+|
${
escapeRegExp
(
APPLICATION_PATH_PREFIX
)}
\\/[^\\s<>"']+|\\/resource\\/(?:stat|booking)(?:\\?[^\\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
&&
url
.
pathname
.
startsWith
(
`
${
APPLICATION_PATH_PREFIX
}
/`
))
{
return
resolveNotificationTarget
(
`
${
url
.
pathname
}${
url
.
search
}${
url
.
hash
}
`
);
}
return
{
href
:
url
.
toString
(),
external
:
true
};
}
catch
{
return
null
;
}
}
const
appPrefix
=
APPLICATION_PATH_PREFIX
;
if
(
normalizedTarget
===
appPrefix
||
normalizedTarget
.
startsWith
(
`
${
appPrefix
}
/`
))
{
return
{
href
:
normalizedTarget
.
slice
(
appPrefix
.
length
)
||
'/'
,
external
:
false
};
}
if
(
normalizedTarget
.
startsWith
(
'/resource/'
)
||
normalizedTarget
===
'/notifications'
)
{
return
{
href
:
normalizedTarget
,
external
:
false
};
}
return
normalizedTarget
.
startsWith
(
'/'
)
?
{
href
:
normalizedTarget
,
external
:
true
}
:
null
;
}
export
function
notifyNotificationStateChanged
()
{
window
.
dispatchEvent
(
new
CustomEvent
(
NOTIFICATION_STATE_EVENT
));
}
src/pages/project_manager_xy/resource-booking/index.jsx
View file @
b4b5a6e7
This diff is collapsed.
Click to expand it.
src/pages/project_manager_xy/riskCaseMaterialService.js
deleted
100644 → 0
View file @
17393f5a
import
dayjs
from
'dayjs'
;
import
{
APPLICATION_NAME
}
from
'@/constants/app'
;
import
{
createRecord
,
queryRecords
,
uploadFile
}
from
'@/services/DataService'
;
export
const
AUDIT_MATERIAL_SUBJECT
=
'am_material'
;
export
const
RISK_CASE_SOURCE_APP
=
APPLICATION_NAME
;
const
MATERIAL_PAGE_SIZE
=
20
;
function
extractRecords
(
response
)
{
return
[
response
,
response
?.
data
,
response
?.
data
?.
list
,
response
?.
data
?.
records
,
response
?.
list
,
response
?.
records
,
response
?.
items
,
response
?.
result
,
response
?.
result
?.
records
,
].
find
(
Array
.
isArray
)
||
[];
}
function
escapeRegExp
(
value
=
''
)
{
return
String
(
value
).
replace
(
/
[
.*+?^${}()|[
\]\\]
/g
,
'
\\
$&'
);
}
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
queryRiskCaseMaterials
(
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
uploadRiskCaseFile
(
file
)
{
if
(
!
file
)
throw
new
Error
(
'请选择要上传的本地文件。'
);
return
normalizeFileAsset
(
await
uploadFile
(
file
),
file
);
}
export
async
function
createRiskCaseMaterial
({
fileAsset
,
currentUser
})
{
if
(
!
fileAsset
?.
url
)
throw
new
Error
(
'缺少有效的文件地址,无法写入审计材料中心。'
);
const
uploaderId
=
currentUser
?.
_id
||
currentUser
?.
id
;
if
(
!
uploaderId
)
throw
new
Error
(
'未获取到当前登录用户,无法写入审计材料中心。'
);
const
materialTitle
=
String
(
fileAsset
.
name
||
''
).
replace
(
/
\.[^
.
]
+$/
,
''
).
trim
()
||
'未命名材料'
;
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
:
RISK_CASE_SOURCE_APP
,
uploader
:
uploaderId
,
uploaded_at
:
dayjs
().
toISOString
(),
},
});
const
created
=
response
?.
data
||
response
?.
result
||
response
;
return
{
id
:
created
?.
_id
||
created
?.
id
||
created
?.
name
||
true
,
record
:
created
};
}
src/pages/project_manager_xy/riskCaseViewService.js
deleted
100644 → 0
View file @
17393f5a
import
{
getSubjectView
,
getSubjectViewDetail
,
loadSubject
,
}
from
'@/services/DataService'
;
import
{
isViewAccessible
,
toArray
}
from
'@/utils/subjectView'
;
import
{
CASE_SUBJECT
}
from
'./materialService'
;
export
async
function
loadRiskCaseViewResources
(
user
)
{
const
[
subject
,
viewSummaries
]
=
await
Promise
.
all
([
loadSubject
(
CASE_SUBJECT
),
getSubjectView
(
CASE_SUBJECT
),
]);
const
views
=
await
Promise
.
all
(
toArray
(
viewSummaries
).
map
(
async
(
view
)
=>
{
if
(
!
view
?.
name
)
return
view
;
try
{
const
detail
=
await
getSubjectViewDetail
(
CASE_SUBJECT
,
view
.
name
);
return
{
...
view
,
...
detail
,
settings
:
detail
?.
settings
||
view
?.
settings
||
{},
};
}
catch
{
return
view
;
}
}));
return
{
subject
,
views
,
gridViews
:
views
.
filter
((
view
)
=>
view
?.
type
===
'grid'
&&
isViewAccessible
(
view
,
user
)),
detailViews
:
views
.
filter
((
view
)
=>
view
?.
type
===
'detail'
),
};
}
src/utils/subjectView.js
deleted
100644 → 0
View file @
17393f5a
import
{
AUTHORIZATION_APPLICATION_NAME
}
from
'@/constants/app'
;
export
function
normalizeText
(
value
)
{
return
String
(
value
??
''
).
trim
();
}
export
function
toArray
(
value
)
{
if
(
value
===
null
||
value
===
undefined
||
value
===
''
)
return
[];
return
Array
.
isArray
(
value
)
?
value
:
[
value
];
}
function
collectIdentityValues
(
values
)
{
return
toArray
(
values
)
.
flatMap
((
value
)
=>
value
&&
typeof
value
===
'object'
?
[
value
.
_id
,
value
.
name
,
value
.
display_name
,
value
.
displayName
,
value
.
username
]
:
value
)
.
map
(
normalizeText
)
.
filter
(
Boolean
);
}
export
function
getUserApplication
(
user
,
applicationName
=
AUTHORIZATION_APPLICATION_NAME
)
{
return
toArray
(
user
?.
applications
).
find
(
(
application
)
=>
normalizeText
(
application
?.
name
)
===
normalizeText
(
applicationName
),
)
||
null
;
}
function
getApplicationAuthorization
(
user
,
applicationName
)
{
const
application
=
getUserApplication
(
user
,
applicationName
);
return
{
groups
:
collectIdentityValues
(
application
?.
groups
),
roles
:
collectIdentityValues
(
application
?.
roles
),
permissions
:
collectIdentityValues
(
application
?.
permissions
),
};
}
function
intersects
(
left
,
right
)
{
const
allowed
=
new
Set
(
collectIdentityValues
(
right
));
return
collectIdentityValues
(
left
).
some
((
value
)
=>
allowed
.
has
(
value
));
}
function
normalizeAuthRule
(
authRule
)
{
return
[
'group'
,
'role'
,
'permission'
].
includes
(
authRule
?.
type
)
?
authRule
:
'管理'
;
}
export
function
isSystemAdministrator
(
user
)
{
return
user
?.
type
===
'admin'
||
user
?.
type
===
'system'
;
}
export
function
hasApplicationManagementAccess
(
user
,
applicationName
=
AUTHORIZATION_APPLICATION_NAME
,
)
{
if
(
isSystemAdministrator
(
user
))
return
true
;
return
intersects
(
getApplicationAuthorization
(
user
,
applicationName
).
roles
,
[
'管理'
]);
}
export
function
hasAuthAccess
(
authRule
,
user
,
applicationName
=
AUTHORIZATION_APPLICATION_NAME
,
)
{
if
(
!
user
)
return
false
;
if
(
hasApplicationManagementAccess
(
user
,
applicationName
))
return
true
;
const
{
groups
,
roles
,
permissions
}
=
getApplicationAuthorization
(
user
,
applicationName
);
if
(
typeof
authRule
===
'string'
)
return
intersects
(
roles
,
[
authRule
]);
if
(
authRule
?.
type
===
'group'
)
return
intersects
(
groups
,
authRule
.
groups
);
if
(
authRule
?.
type
===
'role'
)
return
intersects
(
roles
,
authRule
.
roles
);
if
(
authRule
?.
type
===
'permission'
)
return
intersects
(
permissions
,
authRule
.
permissions
);
return
false
;
}
export
function
isViewAccessible
(
view
,
user
,
applicationName
=
AUTHORIZATION_APPLICATION_NAME
,
)
{
return
hasAuthAccess
(
normalizeAuthRule
(
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
,
applicationName
=
AUTHORIZATION_APPLICATION_NAME
,
)
{
if
(
toArray
(
view
?.
settings
?.
disableColumns
).
some
((
column
)
=>
column
?.
field
===
fieldPath
))
{
return
true
;
}
if
(
hasApplicationManagementAccess
(
user
,
applicationName
))
return
false
;
const
fieldAuth
=
toArray
(
view
?.
settings
?.
auth
).
find
((
rule
)
=>
rule
?.
field
===
fieldPath
);
return
!
hasAuthAccess
(
normalizeAuthRule
(
fieldAuth
),
user
,
applicationName
);
}
export
function
shouldHideViewTitleColumn
(
view
)
{
return
view
?.
settings
?.
shortTitle
===
true
;
}
export
function
getFieldValue
(
record
,
fieldPath
)
{
if
(
!
record
||
!
fieldPath
)
return
null
;
return
fieldPath
.
split
(
'.'
).
reduce
((
value
,
key
)
=>
value
?.[
key
],
record
);
}
export
function
getFieldDefinition
(
fieldPath
,
subject
)
{
if
(
fieldPath
===
'title'
)
return
{
name
:
'title'
,
label
:
'标题'
,
type
:
'text'
};
const
fieldName
=
String
(
fieldPath
||
''
).
replace
(
/^metadata
\.
/
,
''
).
split
(
'.'
).
pop
();
return
toArray
(
subject
?.
fields
).
find
((
field
)
=>
field
?.
name
===
fieldName
)
||
null
;
}
function
getColumnLabel
(
column
,
fieldDefinition
)
{
return
[
column
?.
label
,
column
?.
title
,
column
?.
header
,
column
?.
displayName
,
column
?.
display_name
]
.
map
(
normalizeText
)
.
find
(
Boolean
)
||
fieldDefinition
?.
label
||
column
?.
field
||
'未命名字段'
;
}
export
function
buildViewColumns
(
view
,
subject
,
{
includeHidden
=
false
}
=
{})
{
const
hideTitle
=
!
includeHidden
&&
shouldHideViewTitleColumn
(
view
);
const
configuredColumns
=
toArray
(
view
?.
settings
?.
columns
);
const
columns
=
configuredColumns
.
length
>
0
?
configuredColumns
:
[
{
field
:
'title'
,
width
:
240
},
...
toArray
(
subject
?.
fields
).
map
((
field
)
=>
({
field
:
`metadata.
${
field
.
name
}
`
,
width
:
180
})),
];
return
columns
.
filter
((
column
)
=>
column
?.
field
&&
(
includeHidden
||
!
column
.
hide
))
.
filter
((
column
)
=>
!
hideTitle
||
column
.
field
!==
'title'
)
.
map
((
column
)
=>
{
const
fieldDef
=
getFieldDefinition
(
column
.
field
,
subject
)
||
{
name
:
column
.
field
,
label
:
column
.
field
,
type
:
'text'
,
};
return
{
...
column
,
label
:
getColumnLabel
(
column
,
fieldDef
),
width
:
Number
(
column
.
width
)
||
180
,
fieldDef
,
};
});
}
export
function
buildViewSearchColumns
(
view
,
subject
)
{
const
columns
=
buildViewColumns
(
view
,
subject
);
return
columns
.
some
((
column
)
=>
column
.
field
===
'title'
)
?
columns
:
[{
field
:
'title'
,
label
:
'标题'
,
width
:
240
,
fieldDef
:
getFieldDefinition
(
'title'
,
subject
)
},
...
columns
];
}
function
compareValue
(
recordValue
,
comparator
,
expectedValue
)
{
const
actual
=
toArray
(
recordValue
).
map
((
value
)
=>
normalizeText
(
value
&&
typeof
value
===
'object'
?
value
.
_id
||
value
.
name
||
value
.
title
||
value
.
display_name
||
value
.
username
:
value
,
));
const
expected
=
toArray
(
expectedValue
).
map
(
normalizeText
);
if
(
comparator
===
'$nin'
||
comparator
===
'$ne'
)
return
!
actual
.
some
((
value
)
=>
expected
.
includes
(
value
));
if
(
comparator
===
'$exists'
)
return
Boolean
(
expectedValue
)
?
recordValue
!=
null
:
recordValue
==
null
;
if
(
comparator
===
'$contains'
)
return
actual
.
some
((
value
)
=>
value
.
includes
(
expected
.
join
(
' '
)));
return
actual
.
some
((
value
)
=>
expected
.
includes
(
value
));
}
export
function
checkViewCondition
(
condition
,
record
)
{
if
(
condition
===
undefined
||
condition
===
null
)
return
true
;
if
(
typeof
condition
===
'boolean'
)
return
condition
;
const
rules
=
toArray
(
condition
?.
rules
||
condition
?.
conditions
);
if
(
rules
.
length
===
0
)
return
true
;
const
results
=
rules
.
map
((
rule
)
=>
compareValue
(
getFieldValue
(
record
,
rule
.
field
),
rule
.
comparator
||
'$eq'
,
rule
.
value
,
));
return
condition
.
relationship
===
'any'
||
condition
.
relationship
===
'or'
?
results
.
some
(
Boolean
)
:
results
.
every
(
Boolean
);
}
export
function
formatFieldValue
(
value
,
fieldDef
)
{
if
(
value
===
null
||
value
===
undefined
||
value
===
''
)
return
''
;
if
(
Array
.
isArray
(
value
))
{
return
value
.
map
((
item
)
=>
formatFieldValue
(
item
,
{
...
fieldDef
,
multiple
:
false
})).
filter
(
Boolean
).
join
(
'、'
);
}
if
(
fieldDef
?.
type
===
'boolean'
)
return
value
?
'是'
:
'否'
;
if
(
fieldDef
?.
type
===
'file'
)
return
value
?.
name
||
value
?.
url
||
''
;
if
(
typeof
value
===
'object'
)
{
return
value
.
title
||
value
.
display_name
||
value
.
username
||
value
.
name
||
value
.
_id
||
JSON
.
stringify
(
value
);
}
return
String
(
value
);
}
export
function
getFieldNameFromPath
(
fieldPath
)
{
return
String
(
fieldPath
||
''
).
replace
(
/^metadata
\.
/
,
''
).
split
(
'.'
).
pop
();
}
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