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
2078e013
Commit
2078e013
authored
Aug 27, 2026
by
Andy-bubu
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: sync risk case Xinyuan capabilities
parent
b4b5a6e7
Show whitespace changes
Inline
Side-by-side
Showing
19 changed files
with
1327 additions
and
280 deletions
+1327
-280
.umirc.ts
.umirc.ts
+5
-0
ui-architecture.md
docs/ui-architecture.md
+9
-1
ui-migration-checklist.md
docs/ui-migration-checklist.md
+17
-1
xinyuan-design-system.md
docs/xinyuan-design-system.md
+11
-0
xinyuan-ui-audit.md
docs/xinyuan-ui-audit.md
+24
-2
AppIcons.jsx
src/components/AppIcons.jsx
+6
-0
FileUploadField.jsx
src/components/FileUploadField.jsx
+4
-2
XinyuanAutocomplete.jsx
src/components/XinyuanAutocomplete.jsx
+165
-0
AppIcons.jsx
src/pages/project_manager_xy/AppIcons.jsx
+2
-0
Layout.jsx
src/pages/project_manager_xy/Layout.jsx
+55
-3
RiskCaseMaterialField.jsx
src/pages/project_manager_xy/RiskCaseMaterialField.jsx
+137
-0
audit-material-center.business-flow.md
...project_manager_xy/audit-material-center.business-flow.md
+33
-2
materialService.js
src/pages/project_manager_xy/materialService.js
+1
-0
NotificationListPage.jsx
...project_manager_xy/notifications/NotificationListPage.jsx
+148
-0
notificationService.js
...s/project_manager_xy/notifications/notificationService.js
+142
-0
index.jsx
src/pages/project_manager_xy/resource-booking/index.jsx
+309
-269
riskCaseMaterialService.js
src/pages/project_manager_xy/riskCaseMaterialService.js
+111
-0
riskCaseViewService.js
src/pages/project_manager_xy/riskCaseViewService.js
+34
-0
subjectView.js
src/utils/subjectView.js
+114
-0
No files found.
.umirc.ts
View file @
2078e013
...
@@ -9,6 +9,7 @@ const ROUTE_PATHS = {
...
@@ -9,6 +9,7 @@ const ROUTE_PATHS = {
login
:
'/login'
,
login
:
'/login'
,
resourceStat
:
'/resource/stat'
,
resourceStat
:
'/resource/stat'
,
resourceBooking
:
'/resource/booking'
,
resourceBooking
:
'/resource/booking'
,
notifications
:
'/notifications'
,
}
as
const
;
}
as
const
;
export
default
defineConfig
({
export
default
defineConfig
({
...
@@ -47,6 +48,10 @@ export default defineConfig({
...
@@ -47,6 +48,10 @@ export default defineConfig({
path
:
ROUTE_PATHS
.
resourceBooking
,
path
:
ROUTE_PATHS
.
resourceBooking
,
component
:
'./project_manager_xy/resource-booking/index'
,
component
:
'./project_manager_xy/resource-booking/index'
,
},
},
{
path
:
ROUTE_PATHS
.
notifications
,
component
:
'./project_manager_xy/notifications/NotificationListPage'
,
},
],
],
},
},
],
],
...
...
docs/ui-architecture.md
View file @
2078e013
...
@@ -8,7 +8,7 @@
...
@@ -8,7 +8,7 @@
-
UI 原语与 compound component 在页面或业务中立共享组合中直接导入 HeroUI/HeroUI Pro。
-
UI 原语与 compound component 在页面或业务中立共享组合中直接导入 HeroUI/HeroUI Pro。
-
共享组合只可封装重复布局、可访问性与当前 HeroUI value/selection/event 模型,不得复刻 Catalyst props、DOM event 或旧 CSS hook。
-
共享组合只可封装重复布局、可访问性与当前 HeroUI value/selection/event 模型,不得复刻 Catalyst props、DOM event 或旧 CSS hook。
-
DataGrid 组合映射列定义并转发原生 selection/fixed-column/ActionBar/Pagination;
分页容量继续保持 20/50/100,并使用 HeroUI Autocomplete compound API
。
-
DataGrid 组合映射列定义并转发原生 selection/fixed-column/ActionBar/Pagination;
案例列表分页容量固定为 20,不展示容量选择器
。
-
文件上传采用 HeroUI Pro DropZone 的
`File[]`
协议;页面边界继续只取既有单文件值。
-
文件上传采用 HeroUI Pro DropZone 的
`File[]`
协议;页面边界继续只取既有单文件值。
-
应用切换直接组合 HeroUI Modal/Card/Surface/Avatar 与 HeroUI Pro EmptyState;分组只读取现有
`SiteContext`
的
`/info -> site.group`
,应用仍合并
`/application/info`
与当前用户权限数据,不引入硬编码分组或应用。
-
应用切换直接组合 HeroUI Modal/Card/Surface/Avatar 与 HeroUI Pro EmptyState;分组只读取现有
`SiteContext`
的
`/info -> site.group`
,应用仍合并
`/application/info`
与当前用户权限数据,不引入硬编码分组或应用。
...
@@ -25,3 +25,11 @@
...
@@ -25,3 +25,11 @@
## 业务边界
## 业务边界
服务层继续负责
`risk_case_library`
的
`/record`
协议,所有业务字段仍写入
`metadata`
。本次迁移不得改变路由、鉴权、权限、验证、分页、保存回调、请求时机、错误处理或业务状态流。
服务层继续负责
`risk_case_library`
的
`/record`
协议,所有业务字段仍写入
`metadata`
。本次迁移不得改变路由、鉴权、权限、验证、分页、保存回调、请求时机、错误处理或业务状态流。
## 四项功能数据流
-
`riskCaseViewService.js`
读取
`risk_case_library`
Subject 及完整 Grid/Detail View。页面只消费 columns、visabled 与 shortTitle;不读取或执行
`viewAuth`
、
`auth`
、
`disableColumns`
、角色、用户组或权限旁路。
-
View 资源不可用时,案例查询照常执行,并回退到原案例字段集合;不会因为配置接口失败而只剩标题列,也不会改变
`POST /record/query`
的筛选和
`-updated_at -created_at`
排序。
-
`notifications/notificationService.js`
封装平台
`/notice/customer/query`
、
`/count`
、
`/read`
与动态 action;站内目标剥离
`/risk_case_library`
base,外部目标新窗口打开,通知 HTML 只作为文本展示。
-
`RiskCaseMaterialField.jsx`
按运行时
`multiple`
决定单值替换或多值追加。材料中心只写文件快照;本地
`/upload`
先进入详情草稿,保存详情时才创建
`am_material`
,再更新业务记录。
-
本地材料草稿按编辑会话隔离;取消不建档。创建成功标记保留在草稿中,后续业务保存失败重试时不重复创建材料主档。
docs/ui-migration-checklist.md
View file @
2078e013
...
@@ -18,6 +18,15 @@
...
@@ -18,6 +18,15 @@
-
[
x
]
弹窗:新建、查看/编辑、批量修改、导入、删除确认(代码结构)
-
[
x
]
弹窗:新建、查看/编辑、批量修改、导入、删除确认(代码结构)
-
[
x
]
清除 Catalyst/Headless/ECharts/原生可见控件与页面私有视觉 CSS
-
[
x
]
清除 Catalyst/Headless/ECharts/原生可见控件与页面私有视觉 CSS
## 四项功能
-
[
x
]
Grid shortTitle 只隐藏 title 展示列,详情入口保留在操作列
-
[
x
]
Detail shortTitle 随激活 View 隐藏记录标题和 title 字段
-
[
x
]
`/notifications`
、Navbar Badge、平台通知交互与 base-aware 跳转
-
[
x
]
可编辑 file 字段的材料中心/本地上传、草稿、保存建档与失败重试去重
-
[
x
]
View/字段权限逻辑不在本次范围,活动实现扫描为零
-
[
x
]
Subject/View 加载失败时回退完整默认案例字段,业务查询不阻断
## 浏览器验收
## 浏览器验收
-
[
x
]
Login 1440x900 light/dark
-
[
x
]
Login 1440x900 light/dark
...
@@ -26,7 +35,14 @@
...
@@ -26,7 +35,14 @@
-
[
x
]
Login 390x844 light/dark
-
[
x
]
Login 390x844 light/dark
-
[
x
]
应用切换 1440x900、1280x720、768x1024、390x844 light/dark:尺寸、两列/单列、动态分组、全部应用回退、唯一当前态、Body 滚动、Escape 与焦点回归
-
[
x
]
应用切换 1440x900、1280x720、768x1024、390x844 light/dark:尺寸、两列/单列、动态分组、全部应用回退、唯一当前态、Body 滚动、Escape 与焦点回归
-
[
]
打开 Sidebar、用户退出菜单、应用切换真实动态分组、Dropdown/ListBox/DatePicker
-
[
]
打开 Sidebar、用户退出菜单、应用切换真实动态分组、Dropdown/ListBox/DatePicker
-
[
]
验证 Tabs、筛选、Checkbox、DataGrid、Pagination、ActionBar、Modal、KPI、图表 Tooltip
-
[
]
验证 KPI 动态状态与图表 Tooltip
-
[
x
]
目标与问题整改在 1440x900、1280x720、768x1024、390x844 完成浅/深色成对截图,页面无 viewport 级横向溢出
-
[
x
]
真实验证 Tabs、三项折叠筛选、Checkbox、DataGrid、Pagination、ActionBar、详情查看/编辑/取消和移动端通知空态
-
[
x
]
目标与基准最终控制台 0 error / 0 warning
-
[
]
验证 shortTitle true/false、单/多值附件、材料选择、本地上传、取消、失败与重试
-
[
]
验证通知未读 0/1/99/100、单条/全部已读、动态操作与站内外跳转
未勾选原因:真实 View 接口返回空数组,Subject 没有 file 字段,通知接口返回 0 条;未伪造后台配置、通知或业务数据。
-
[
x
]
Login 控制台 0 runtime/slot/provider/accessibility 错误或警告
-
[
x
]
Login 控制台 0 runtime/slot/provider/accessibility 错误或警告
## 最终门槛
## 最终门槛
...
...
docs/xinyuan-design-system.md
View file @
2078e013
...
@@ -20,6 +20,17 @@
...
@@ -20,6 +20,17 @@
-
指标必须使用 HeroUI Pro KPI compound API;图表必须使用 Pro PieChart、BarChart、ComposedChart 与 ChartTooltipContent。
-
指标必须使用 HeroUI Pro KPI compound API;图表必须使用 Pro PieChart、BarChart、ComposedChart 与 ChartTooltipContent。
-
图表业务序列只使用
`--rc-chart-*`
Token,不使用 UI 状态色或页面硬编码颜色。
-
图表业务序列只使用
`--rc-chart-*`
Token,不使用 UI 状态色或页面硬编码颜色。
## 跨应用能力
风险案例库属于“警示案例库”,按完整功能应用范围实施本次四项能力:
-
Grid View 的
`settings.shortTitle === true`
只隐藏
`title`
展示列;原始标题数据、查询、排序、筛选、详情和保存协议不变,详情入口保留在右侧操作列。
-
当前 Detail View 的
`settings.shortTitle === true`
同时隐藏记录标题和
`title`
字段;Modal 功能标题与可访问名称始终存在。
-
通知中心使用独立
`/notifications`
路由和平台原通知接口,Navbar 以 HeroUI
`Badge.Anchor + Badge + Button + Tooltip`
提供入口。
-
运行时 Subject 中可编辑的
`file`
字段使用 HeroUI Pro DropZone 本地上传,或通过 HeroUI Autocomplete 选择
`am_material`
;业务记录只保存
`{name,type,size,url}`
快照。
本次范围不包含 View 权限、字段权限、角色旁路或用户组判断。View 配置只决定列、字段顺序、条件可见性和
`shortTitle`
展示。
## 禁止项
## 禁止项
-
Catalyst、Headless UI、旧 UI 包装层、可见原生 select/date/file 控件。
-
Catalyst、Headless UI、旧 UI 包装层、可见原生 select/date/file 控件。
...
...
docs/xinyuan-ui-audit.md
View file @
2078e013
# 鑫元 UI 审计
# 鑫元 UI 审计
审计日期:2026-08-2
6
。目标仓库为 Umi 4 / React 19,部署基路径为
`/risk_case_library/`
。整体实现基准为“问题整改”,应用切换专项基准为
`model_analysis`
当前 HeroUI 实现。
审计日期:2026-08-2
7
。目标仓库为 Umi 4 / React 19,部署基路径为
`/risk_case_library/`
。整体实现基准为“问题整改”,应用切换专项基准为
`model_analysis`
当前 HeroUI 实现。
## 安装与运行基线
## 安装与运行基线
...
@@ -31,8 +31,30 @@
...
@@ -31,8 +31,30 @@
| 旧组件 | Catalyst、Headless、ECharts、旧 Select/Date/Table 兼容层已移除 | 活动源码扫描为零 |
| 旧组件 | Catalyst、Headless、ECharts、旧 Select/Date/Table 兼容层已移除 | 活动源码扫描为零 |
| 样式 |
`theme.css`
匹配 Skill;
`tokens.css`
、
`shell.css`
、
`tailwind.css`
、
`global.less`
匹配问题整改 |
`portable.css`
与页面私有视觉 CSS 已移除 |
| 样式 |
`theme.css`
匹配 Skill;
`tokens.css`
、
`shell.css`
、
`tailwind.css`
、
`global.less`
匹配问题整改 |
`portable.css`
与页面私有视觉 CSS 已移除 |
## 四项功能审计
| 功能 | 目标实现 | 业务保护 |
| --- | --- | --- |
| Grid shortTitle | 完整 Grid View 的
`settings.shortTitle`
仅过滤
`title`
展示列;详情入口固定在操作列 | 不删除标题数据,不修改查询、排序、筛选、详情或保存 payload |
| Detail shortTitle | 随当前 Detail View 即时隐藏顶部记录标题和
`title`
字段 | Modal 标题和 aria-label 保留;Detail 切换不触发业务请求 |
| 消息通知 | 新增
`/notifications`
;消息/待办、全部/未读、搜索、分页、单条/全部已读、动态 actions、站内外跳转;Navbar Bell 显示 1–99/99+ | 复用平台原通知接口;不执行通知 HTML;Sidebar 无通知入口 |
| 文件与材料中心 | 可编辑 file 字段按 multiple 支持材料中心/本地上传;最近 20 条与远程搜索;保存时建档 | 快照固定
`{name,type,size,url}`
;
`source_app=risk_case_library`
;取消不建档,失败重试去重 |
View 配置审计确认本次没有实现权限功能:活动代码不读取
`viewAuth`
、
`settings.auth`
、
`disableColumns`
、用户角色、用户组或 permissions。
## 风险与保护
## 风险与保护
-
不改变 API URL、请求参数、响应映射、路由、权限
、校验、请求时机、保存回调、登录退出或业务状态流。
-
除新增
`/notifications`
和材料中心明确请求外,不改变原 API URL、请求参数、响应映射、路由
、校验、请求时机、保存回调、登录退出或业务状态流。
-
不在源码或浏览器中加入测试记录、伪造 token 或统计数字。
-
不在源码或浏览器中加入测试记录、伪造 token 或统计数字。
-
应用切换已使用现有真实登录会话完成浅/深色四视口验收;未注入 token、应用、分组或业务数据。其他模块的既有未验收项仍按迁移清单保留,不能据此标记全量应用迁移完成。
-
应用切换已使用现有真实登录会话完成浅/深色四视口验收;未注入 token、应用、分组或业务数据。其他模块的既有未验收项仍按迁移清单保留,不能据此标记全量应用迁移完成。
## 2026-08-27 浏览器验收
-
使用账号 Andy 经真实验证码登录目标应用和“问题整改”,未注入 token、通知、View、附件或测试记录。
-
在 1440x900、1280x720、768x1024、390x844 分别采集两应用浅色和深色截图。各视口目标页面
`body.scrollWidth === innerWidth`
,宽表滚动限制在 DataGrid 内;桌面 Sidebar/Navbar 为 240px/64px,移动端 Main 与 Navbar 均占满视口。
-
对照确认两应用在相同视口下的 Sidebar、Navbar、Trigger、Bell、主题按钮、页面内容边界、Tabs、筛选 Surface 与工具栏层级一致。
-
目标真实验证:筛选默认三项与 Disclosure、卡片/表格 Tabs、DataGrid 固定操作列、Checkbox、ActionBar、固定 20 条 Pagination、详情 4xl Modal、查看态单一 secondary Surface、编辑 dirty、取消恢复且无 PUT、移动端通知空态、移动端 Sidebar、应用切换分组/计数/当前态。
-
目标与基准最终控制台均为 0 error、0 warning(忽略开发模式 React DevTools info)。
-
当前真实接口
`GET /subject/risk_case_library/view`
返回空数组,因此没有 shortTitle true/false 配置可触发;代码路径与配置失败完整字段回退已审计,但未把 Mock 当作浏览器通过。
-
当前 Subject 的 11 个字段均非
`file`
,无法在真实页面触发单值/多值、材料选择、本地上传、取消、失败与重试。
-
当前通知列表和未读数均为 0,可验证空态与 Badge 隐藏;1/99/100、单条/全部已读、动态 actions 和站内外目标没有真实通知数据可操作。
src/components/AppIcons.jsx
View file @
2078e013
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'
;
...
@@ -45,6 +46,10 @@ function iconStyle(size, style) {
...
@@ -45,6 +46,10 @@ function iconStyle(size, style) {
return
size
===
undefined
?
style
:
{
fontSize
:
size
,
...
style
};
return
size
===
undefined
?
style
:
{
fontSize
:
size
,
...
style
};
}
}
export
const
FIELD_SUFFIX_ICON_STYLE
=
Object
.
freeze
({
color
:
'var(--field-placeholder, var(--muted))'
,
});
function
withSize
(
Icon
)
{
function
withSize
(
Icon
)
{
return
function
AppIcon
({
size
,
style
,
...
props
})
{
return
function
AppIcon
({
size
,
style
,
...
props
})
{
return
(
return
(
...
@@ -54,6 +59,7 @@ function withSize(Icon) {
...
@@ -54,6 +59,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/FileUploadField.jsx
View file @
2078e013
...
@@ -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
>
...
...
src/components/XinyuanAutocomplete.jsx
0 → 100644
View file @
2078e013
import
{
ChevronDown
,
Check
,
Close
,
FIELD_SUFFIX_ICON_STYLE
,
Search
,
}
from
'@/components/AppIcons'
;
import
{
Autocomplete
,
Label
,
ListBox
,
SearchField
,
Spinner
,
}
from
'@heroui/react'
;
function
defaultFilter
(
textValue
,
inputValue
)
{
const
query
=
String
(
inputValue
||
''
).
trim
().
toLocaleLowerCase
();
return
!
query
||
String
(
textValue
||
''
).
toLocaleLowerCase
().
includes
(
query
);
}
function
getSelectedKeySet
(
selectionMode
,
selectedKey
,
selectedKeys
)
{
if
(
selectionMode
===
'multiple'
)
{
return
new
Set
(
selectedKeys
||
[]);
}
return
new
Set
(
selectedKey
===
null
||
selectedKey
===
undefined
?
[]
:
[
selectedKey
]);
}
const
toSelectionKey
=
(
value
)
=>
String
(
value
??
''
);
export
default
function
XinyuanAutocomplete
({
'aria-label'
:
ariaLabel
,
className
,
emptyText
=
'暂无可选项'
,
errorText
=
''
,
filter
,
fullWidth
=
true
,
inputValue
,
isDisabled
=
false
,
isLoading
=
false
,
isOpen
,
isRequired
=
false
,
name
,
onInputChange
,
onOpenChange
,
onSelectionChange
,
options
=
[],
placeholder
=
'请选择'
,
renderOption
,
searchPlaceholder
=
'输入关键词搜索'
,
selectedKey
,
selectedKeys
,
selectionMode
=
'single'
,
shouldFilter
=
true
,
valueText
,
variant
=
'secondary'
,
})
{
const
selectedKeySet
=
getSelectedKeySet
(
selectionMode
,
selectedKey
,
selectedKeys
);
const
selectedKeyStringSet
=
new
Set
(
Array
.
from
(
selectedKeySet
).
map
(
toSelectionKey
),
);
const
selectedOptions
=
options
.
filter
((
option
)
=>
selectedKeyStringSet
.
has
(
toSelectionKey
(
option
.
id
)));
const
resolvedValue
=
valueText
??
(
selectedOptions
.
length
>
0
?
selectedOptions
.
map
((
option
)
=>
option
.
label
).
join
(
'、'
)
:
placeholder
);
const
rootSelectionProps
=
selectionMode
===
'multiple'
?
{
selectedKeys
}
:
{
selectedKey
};
const
listBoxSelectionProps
=
selectionMode
===
'multiple'
?
{
selectionMode
,
selectedKeys
}
:
{};
const
visibleOptions
=
isLoading
||
errorText
?
[]
:
options
;
const
handleMultipleOptionPress
=
(
option
)
=>
{
if
(
selectionMode
!==
'multiple'
||
!
onSelectionChange
||
option
?.
isDisabled
)
return
;
const
optionKey
=
toSelectionKey
(
option
.
id
);
if
(
!
optionKey
)
return
;
const
nextKeys
=
new
Set
(
Array
.
from
(
selectedKeyStringSet
).
filter
(
Boolean
));
if
(
nextKeys
.
has
(
optionKey
))
nextKeys
.
delete
(
optionKey
);
else
nextKeys
.
add
(
optionKey
);
onSelectionChange
(
nextKeys
);
};
return
(
<
Autocomplete
{
...
rootSelectionProps
}
aria
-
label=
{
ariaLabel
||
placeholder
}
className=
{
className
}
fullWidth=
{
fullWidth
}
isDisabled=
{
isDisabled
}
isOpen=
{
isOpen
}
isRequired=
{
isRequired
}
name=
{
name
}
selectionMode=
{
selectionMode
}
variant=
{
variant
}
onOpenChange=
{
onOpenChange
}
onSelectionChange=
{
onSelectionChange
}
>
<
Autocomplete
.
Trigger
>
<
Autocomplete
.
Value
>
<
span
className=
"block min-w-0 truncate"
title=
{
resolvedValue
}
>
{
resolvedValue
}
</
span
>
</
Autocomplete
.
Value
>
<
Autocomplete
.
Indicator
>
<
ChevronDown
size=
{
16
}
style=
{
FIELD_SUFFIX_ICON_STYLE
}
/>
</
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
}
{
...
listBoxSelectionProps
}
renderEmptyState=
{
()
=>
(
<
div
className=
"grid min-h-20 place-items-center px-3 py-4"
>
{
isLoading
?
<
Spinner
size=
"sm"
/>
:
errorText
||
emptyText
}
</
div
>
)
}
>
{
(
option
)
=>
{
const
isSelected
=
selectedKeyStringSet
.
has
(
toSelectionKey
(
option
.
id
));
return
(
<
ListBox
.
Item
id=
{
option
.
id
}
isDisabled=
{
option
.
isDisabled
}
textValue=
{
option
.
textValue
||
option
.
label
}
{
...
(
selectionMode
===
'
multiple
'
?
{
onPress
:
()
=
>
handleMultipleOptionPress(option) }
:
{}
)}
>
{
renderOption
?
renderOption
(
option
)
:
<
Label
>
{
option
.
label
}
</
Label
>
}
{
selectionMode
===
'multiple'
?
(
<
span
aria
-
hidden=
"true"
className=
"ml-auto inline-flex w-5 justify-center text-foreground"
>
{
isSelected
?
<
Check
size=
{
14
}
/>
:
null
}
</
span
>
)
:
(
<
ListBox
.
ItemIndicator
/>
)
}
</
ListBox
.
Item
>
);
}
}
</
ListBox
>
</
Autocomplete
.
Filter
>
</
Autocomplete
.
Popover
>
</
Autocomplete
>
);
}
src/pages/project_manager_xy/AppIcons.jsx
View file @
2078e013
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'
;
...
@@ -54,6 +55,7 @@ function withSize(Icon) {
...
@@ -54,6 +55,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/pages/project_manager_xy/Layout.jsx
View file @
2078e013
...
@@ -2,15 +2,19 @@ import xyLogoRed from '@/assets/identity/xy-logo-red.png';
...
@@ -2,15 +2,19 @@ import xyLogoRed from '@/assets/identity/xy-logo-red.png';
import
xyLogoWhite
from
'@/assets/identity/xy-logo-white.png'
;
import
xyLogoWhite
from
'@/assets/identity/xy-logo-white.png'
;
import
{
APP_DISPLAY_NAME
}
from
'@/constants/app'
;
import
{
APP_DISPLAY_NAME
}
from
'@/constants/app'
;
import
ApplicationSwitcherDialog
from
'@/pages/project_manager_xy/ApplicationSwitcherDialog'
;
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
{
UserContext
}
from
'@/wrapper/Auth'
;
import
{
SiteContext
}
from
'@/wrapper/Site'
;
import
{
SiteContext
}
from
'@/wrapper/Site'
;
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
{
Avatar
,
B
utton
,
Dropdown
,
Label
,
Tooltip
}
from
'@heroui/react'
;
import
{
Avatar
,
B
adge
,
Button
,
Dropdown
,
Label
,
Toast
,
Tooltip
}
from
'@heroui/react'
;
import
{
useContext
,
useLayoutEffect
,
useMemo
,
useState
}
from
'react'
;
import
{
useContext
,
use
Effect
,
use
LayoutEffect
,
useMemo
,
useState
}
from
'react'
;
import
{
Heading
}
from
'react-aria-components/Dialog'
;
import
{
Heading
}
from
'react-aria-components/Dialog'
;
import
{
history
,
Outlet
,
useLocation
}
from
'umi'
;
import
{
history
,
Outlet
,
useLocation
}
from
'umi'
;
import
{
AppGrid
,
Archive
,
BarChart
,
LogOut
,
Moon
,
Sun
}
from
'./AppIcons'
;
import
{
AppGrid
,
Archive
,
BarChart
,
Bell
,
LogOut
,
Moon
,
Sun
}
from
'./AppIcons'
;
const
APP_NAME
=
APP_DISPLAY_NAME
;
const
APP_NAME
=
APP_DISPLAY_NAME
;
const
THEME_STORAGE_KEY
=
'xinyuan_color_theme'
;
const
THEME_STORAGE_KEY
=
'xinyuan_color_theme'
;
...
@@ -38,6 +42,7 @@ function getInitialTheme() {
...
@@ -38,6 +42,7 @@ function getInitialTheme() {
}
}
function
getRouteTitle
(
pathname
)
{
function
getRouteTitle
(
pathname
)
{
if
(
pathname
===
'/notifications'
)
return
'通知消息'
;
for
(
const
group
of
menuGroups
)
{
for
(
const
group
of
menuGroups
)
{
const
activeItem
=
group
.
items
.
find
((
item
)
=>
item
.
path
===
pathname
);
const
activeItem
=
group
.
items
.
find
((
item
)
=>
item
.
path
===
pathname
);
if
(
activeItem
)
return
activeItem
.
name
;
if
(
activeItem
)
return
activeItem
.
name
;
...
@@ -216,6 +221,8 @@ function SidebarContents({ onOpenApplicationSwitcher, pathname, userInfo }) {
...
@@ -216,6 +221,8 @@ function SidebarContents({ onOpenApplicationSwitcher, pathname, userInfo }) {
}
}
function
DashboardNavbar
({
function
DashboardNavbar
({
notificationCount
,
onOpenNotifications
,
onThemeChange
,
onThemeChange
,
theme
,
theme
,
title
,
title
,
...
@@ -223,6 +230,7 @@ function DashboardNavbar({
...
@@ -223,6 +230,7 @@ function DashboardNavbar({
const
{
isMobile
,
isOpen
}
=
useSidebar
();
const
{
isMobile
,
isOpen
}
=
useSidebar
();
const
isDark
=
theme
===
'dark'
;
const
isDark
=
theme
===
'dark'
;
const
themeLabel
=
isDark
?
'切换至浅色模式'
:
'切换至深色模式'
;
const
themeLabel
=
isDark
?
'切换至浅色模式'
:
'切换至深色模式'
;
const
unreadCount
=
Math
.
max
(
0
,
Number
(
notificationCount
)
||
0
);
return
(
return
(
<
Navbar
className=
"app-navbar rc-navbar"
maxWidth=
"full"
>
<
Navbar
className=
"app-navbar rc-navbar"
maxWidth=
"full"
>
...
@@ -234,6 +242,26 @@ function DashboardNavbar({
...
@@ -234,6 +242,26 @@ function DashboardNavbar({
<
h1
className=
"app-navbar-page-title rc-navbar-title"
>
{
title
}
</
h1
>
<
h1
className=
"app-navbar-page-title rc-navbar-title"
>
{
title
}
</
h1
>
<
Navbar
.
Spacer
/>
<
Navbar
.
Spacer
/>
<
div
className=
"rc-navbar-actions"
>
<
div
className=
"rc-navbar-actions"
>
<
Tooltip
delay=
{
0
}
>
<
Badge
.
Anchor
>
<
Button
isIconOnly
aria
-
label=
"通知消息"
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
}
>
<
Tooltip
delay=
{
0
}
>
<
Button
<
Button
isIconOnly
isIconOnly
...
@@ -259,6 +287,7 @@ export default function ProjectManagerLayout() {
...
@@ -259,6 +287,7 @@ export default function ProjectManagerLayout() {
const
[
isApplicationDialogOpen
,
setIsApplicationDialogOpen
]
=
useState
(
false
);
const
[
isApplicationDialogOpen
,
setIsApplicationDialogOpen
]
=
useState
(
false
);
const
[
sidebarOpen
,
setSidebarOpen
]
=
useState
(
true
);
const
[
sidebarOpen
,
setSidebarOpen
]
=
useState
(
true
);
const
[
theme
,
setTheme
]
=
useState
(
getInitialTheme
);
const
[
theme
,
setTheme
]
=
useState
(
getInitialTheme
);
const
[
unreadNotificationCount
,
setUnreadNotificationCount
]
=
useState
(
0
);
const
userInfo
=
useContext
(
UserContext
);
const
userInfo
=
useContext
(
UserContext
);
const
siteInfo
=
useContext
(
SiteContext
);
const
siteInfo
=
useContext
(
SiteContext
);
...
@@ -280,6 +309,26 @@ export default function ProjectManagerLayout() {
...
@@ -280,6 +309,26 @@ export default function ProjectManagerLayout() {
document
.
title
=
APP_NAME
;
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
(
const
routeTitle
=
useMemo
(
()
=>
getRouteTitle
(
location
.
pathname
),
()
=>
getRouteTitle
(
location
.
pathname
),
[
location
.
pathname
],
[
location
.
pathname
],
...
@@ -300,6 +349,8 @@ export default function ProjectManagerLayout() {
...
@@ -300,6 +349,8 @@ export default function ProjectManagerLayout() {
/>
/>
<
Sidebar
.
Main
className=
"app-main-glass rc-app-main"
>
<
Sidebar
.
Main
className=
"app-main-glass rc-app-main"
>
<
DashboardNavbar
<
DashboardNavbar
notificationCount=
{
unreadNotificationCount
}
onOpenNotifications=
{
()
=>
history
.
push
(
'/notifications'
)
}
theme=
{
theme
}
theme=
{
theme
}
title=
{
routeTitle
}
title=
{
routeTitle
}
onThemeChange=
{
setTheme
}
onThemeChange=
{
setTheme
}
...
@@ -318,6 +369,7 @@ export default function ProjectManagerLayout() {
...
@@ -318,6 +369,7 @@ export default function ProjectManagerLayout() {
userInfo=
{
userInfo
}
userInfo=
{
userInfo
}
siteInfo=
{
siteInfo
}
siteInfo=
{
siteInfo
}
/>
/>
<
Toast
.
Provider
maxVisibleToasts=
{
3
}
placement=
"top"
/>
</
Sidebar
.
Provider
>
</
Sidebar
.
Provider
>
);
);
}
}
src/pages/project_manager_xy/RiskCaseMaterialField.jsx
0 → 100644
View file @
2078e013
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 @
2078e013
...
@@ -13,7 +13,7 @@
...
@@ -13,7 +13,7 @@
当前阶段的核心原则是:
当前阶段的核心原则是:
-
每条案例对应一条独立
`Record`
-
每条案例对应一条独立
`Record`
-
案例详情以文本为主
,不额外拆分附件对象
-
案例详情以文本为主
;若运行时 Subject/View 配置出现可编辑
`file`
字段,则按统一文件快照协议接入审计材料中心
-
页面优先保证“看起来直观、方便查”
-
页面优先保证“看起来直观、方便查”
---
---
...
@@ -341,5 +341,36 @@
...
@@ -341,5 +341,36 @@
1.
`risk_case_library`
1.
`risk_case_library`
当前不拆分独立关键字表、案例性质表或
附件表
。
当前不拆分独立关键字表、案例性质表或
案例专属附件表。运行时
`file`
字段可引用共享
`am_material`
,但风险案例记录只保存文件快照,不保存材料 ID
。
案例性质先通过文本字段实现,关键字通过多值文本数组实现,后续如有需要再演进。
案例性质先通过文本字段实现,关键字通过多值文本数组实现,后续如有需要再演进。
---
## 11. Subject View 展示规则
1.
页面读取
`risk_case_library`
的 Grid 与 Detail View 完整配置。
2.
Grid
`settings.shortTitle === true`
时只隐藏根字段
`title`
的展示列;标题数据仍参与原有业务协议,查看入口统一保留在右侧操作列。
3.
当前 Detail View
`settings.shortTitle === true`
时,隐藏弹窗顶部记录标题和字段列表中的根
`title`
字段;Modal 功能标题与可访问名称保持不变。
4.
切换 Grid View 只切换列展示,切换 Detail View 只切换字段展示;两者都不修改案例查询、排序、筛选或保存协议。
5.
Subject/View 加载失败时继续查询案例,并回退展示原案例字段集合。
6.
本模块不根据 View 配置执行可见权限或字段编辑权限,不读取用户角色、用户组或其他应用授权。
## 12. 消息通知流程
1.
Navbar 主题按钮左侧的 Bell 打开独立
`/notifications`
页面;Sidebar 不提供通知入口。
2.
页面通过平台通知接口查询消息/待办,支持全部/未读、300ms 搜索与固定 10 条分页。
3.
单条已读调用平台已读接口;全部已读调用同一接口的空 name 协议;操作成功后刷新列表和 Navbar 数量。
4.
待办 actions 使用通知原配置调用动态 action 接口,不构造自定义动作。
5.
关联地址带
`/risk_case_library`
前缀时去除自身 base 后站内跳转;外部地址新窗口打开。
6.
通知内容始终按纯文本渲染,不执行其中的 HTML。
7.
未读数为 0 时隐藏 Badge,1–99 显示真实值,100 及以上显示
`99+`
。
## 13. 文件与审计材料中心流程
1.
仅运行时 Detail View 中可编辑的
`file`
字段显示“材料中心 / 本地上传”,并按字段
`multiple`
配置决定单值或多值。
2.
材料中心查询
`am_material`
最近 20 条,搜索时远程匹配材料标题和文件名。
3.
选中材料后只向案例草稿写入
`{name,type,size,url}`
;不保存材料 ID。单值替换,多值按 URL 去重追加。
4.
本地文件先调用
`/upload`
,成功后只进入当前详情编辑会话的草稿。
5.
用户保存详情时,前端先为仍保留在草稿中的本地文件创建
`am_material`
,
`source_app`
固定使用
`risk_case_library`
;全部成功后才更新风险案例记录。
6.
取消编辑或关闭详情不创建材料主档。材料建档成功但案例保存失败时保留建档标记,重试不重复建档。
7.
清空或替换案例附件只更新案例记录,不删除共享材料中心主档。
src/pages/project_manager_xy/materialService.js
View file @
2078e013
...
@@ -731,6 +731,7 @@ export function buildCasePayload(values = {}) {
...
@@ -731,6 +731,7 @@ export function buildCasePayload(values = {}) {
content_type
:
'text'
,
content_type
:
'text'
,
content
:
caseDetails
,
content
:
caseDetails
,
metadata
:
{
metadata
:
{
...(
values
.
metadataDraft
||
{}),
label
,
label
,
category
,
category
,
tag
,
tag
,
...
...
src/pages/project_manager_xy/notifications/NotificationListPage.jsx
0 → 100644
View file @
2078e013
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
0 → 100644
View file @
2078e013
import
{
exacteActionFunction
,
queryUserNotice
,
readUserNotice
,
unReadNoticeCount
,
}
from
'@/services/DataService'
;
import
{
APP_ROUTE_NAME
}
from
'@/constants/app'
;
export
const
NOTIFICATION_PAGE_SIZE
=
10
;
export
const
NOTIFICATION_STATE_EVENT
=
'risk-case-library:notification-state'
;
const
APPLICATION_PATH_PREFIX
=
`/
${
APP_ROUTE_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 @
2078e013
import
{
import
{
DataTable
,
DataTable
,
DataTableChip
,
DataTablePagination
,
DataTablePagination
,
DataTablePrimaryText
,
DataTableSecondaryText
,
DataTableText
,
DataTableText
,
DataTableTextPair
,
}
from
'@/components/DataTable'
;
}
from
'@/components/DataTable'
;
import
{
TableActionButton
,
TABLE_ACTION_ICONS
}
from
'@/components/TableActionButton'
;
import
{
TableActionButton
,
TABLE_ACTION_ICONS
}
from
'@/components/TableActionButton'
;
import
FileUploadField
from
'@/components/FileUploadField'
;
import
FileUploadField
from
'@/components/FileUploadField'
;
import
{
UserContext
}
from
'@/wrapper/Auth'
;
import
{
buildViewColumns
,
checkViewCondition
,
formatFieldValue
,
getFieldNameFromPath
,
getFieldValue
,
shouldHideViewTitleColumn
,
}
from
'@/utils/subjectView'
;
import
{
import
{
Alert
,
Alert
,
Button
,
Button
,
Checkbox
,
Chip
,
Chip
,
Disclosure
,
Disclosure
,
Input
,
Input
,
...
@@ -40,11 +46,16 @@ import {
...
@@ -40,11 +46,16 @@ import {
import
clsx
from
'clsx'
;
import
clsx
from
'clsx'
;
import
{
import
{
startTransition
,
startTransition
,
useContext
,
useEffect
,
useEffect
,
useMemo
,
useMemo
,
useRef
,
useState
,
useState
,
}
from
'react'
;
}
from
'react'
;
import
EmptyStatePage
from
'../EmptyStatePage'
;
import
EmptyStatePage
from
'../EmptyStatePage'
;
import
RiskCaseMaterialField
from
'../RiskCaseMaterialField'
;
import
{
createRiskCaseMaterial
}
from
'../riskCaseMaterialService'
;
import
{
loadRiskCaseViewResources
}
from
'../riskCaseViewService'
;
import
{
import
{
buildCaseDashboard
,
buildCaseDashboard
,
createCaseRecord
,
createCaseRecord
,
...
@@ -67,6 +78,50 @@ const ALL_CATEGORY_KEY = '__all__';
...
@@ -67,6 +78,50 @@ const ALL_CATEGORY_KEY = '__all__';
const
CARD_VIEW_MODE
=
'card'
;
const
CARD_VIEW_MODE
=
'card'
;
const
TABLE_VIEW_MODE
=
'table'
;
const
TABLE_VIEW_MODE
=
'table'
;
const
XINYUAN_4XL_DIALOG_CLASS
=
'sm:max-w-[68rem] p-7'
;
const
XINYUAN_4XL_DIALOG_CLASS
=
'sm:max-w-[68rem] p-7'
;
const
FALLBACK_CASE_FIELDS
=
[
{
name
:
'label'
,
label
:
'标题'
,
type
:
'text'
},
{
name
:
'category'
,
label
:
'分类'
,
type
:
'text'
},
{
name
:
'tag'
,
label
:
'标签'
,
type
:
'text'
},
{
name
:
'case_type'
,
label
:
'案例类型'
,
type
:
'text'
},
{
name
:
'case_attribute'
,
label
:
'案例属性'
,
type
:
'text'
},
{
name
:
'case_details'
,
label
:
'案例详情'
,
type
:
'long_text'
},
{
name
:
'case_warning'
,
label
:
'案例警示'
,
type
:
'long_text'
},
{
name
:
'keyword'
,
label
:
'关键字'
,
type
:
'text'
,
multiple
:
true
},
{
name
:
'case_source'
,
label
:
'案例来源'
,
type
:
'text'
},
{
name
:
'case_level'
,
label
:
'案例等级'
,
type
:
'text'
},
{
name
:
'case_year'
,
label
:
'案例年份'
,
type
:
'number'
},
];
const
FALLBACK_CASE_SUBJECT
=
{
fields
:
FALLBACK_CASE_FIELDS
};
const
FALLBACK_GRID_VIEW
=
{
name
:
'default-grid'
,
type
:
'grid'
,
settings
:
{
columns
:
[
{
field
:
'title'
,
label
:
'标题'
,
width
:
380
,
fixed
:
'left'
},
{
field
:
'metadata.category'
,
label
:
'分类'
,
width
:
180
},
{
field
:
'metadata.tag'
,
label
:
'标签'
,
width
:
240
},
{
field
:
'metadata.case_type'
,
label
:
'案例类型'
,
width
:
220
},
{
field
:
'metadata.keyword'
,
label
:
'关键字'
,
width
:
280
},
{
field
:
'metadata.case_source'
,
label
:
'案例来源'
,
width
:
180
},
{
field
:
'metadata.case_level'
,
label
:
'案例等级'
,
width
:
140
},
{
field
:
'metadata.case_year'
,
label
:
'案例年份'
,
width
:
140
},
{
field
:
'updated_at'
,
label
:
'更新时间'
,
width
:
160
},
],
},
};
const
FALLBACK_DETAIL_VIEW
=
{
name
:
'base'
,
title
:
'基础信息'
,
type
:
'detail'
,
settings
:
{
columns
:
[
{
field
:
'title'
,
label
:
'标题'
},
...
FALLBACK_CASE_FIELDS
.
filter
((
field
)
=>
field
.
name
!==
'label'
)
.
map
((
field
)
=>
({
field
:
`metadata.
${
field
.
name
}
`
,
label
:
field
.
label
})),
],
},
};
const
DEFAULT_CATEGORY_TABS
=
[
const
DEFAULT_CATEGORY_TABS
=
[
{
{
key
:
ALL_CATEGORY_KEY
,
key
:
ALL_CATEGORY_KEY
,
...
@@ -326,6 +381,7 @@ function buildEmptyCaseForm() {
...
@@ -326,6 +381,7 @@ function buildEmptyCaseForm() {
caseSource
:
''
,
caseSource
:
''
,
caseLevel
:
''
,
caseLevel
:
''
,
caseYear
:
''
,
caseYear
:
''
,
metadataDraft
:
{},
};
};
}
}
...
@@ -346,9 +402,48 @@ function buildCaseForm(caseItem) {
...
@@ -346,9 +402,48 @@ function buildCaseForm(caseItem) {
caseSource
:
caseItem
.
caseSource
||
''
,
caseSource
:
caseItem
.
caseSource
||
''
,
caseLevel
:
caseItem
.
caseLevel
||
''
,
caseLevel
:
caseItem
.
caseLevel
||
''
,
caseYear
:
caseItem
.
caseYear
||
''
,
caseYear
:
caseItem
.
caseYear
||
''
,
metadataDraft
:
{
...(
caseItem
.
rawMetadata
||
{})
},
};
};
}
}
const
FORM_FIELD_KEYS
=
{
label
:
'label'
,
category
:
'category'
,
tag
:
'tag'
,
case_type
:
'caseType'
,
case_attribute
:
'caseAttribute'
,
case_details
:
'caseDetails'
,
case_warning
:
'caseWarning'
,
keyword
:
'keyword'
,
case_source
:
'caseSource'
,
case_level
:
'caseLevel'
,
case_year
:
'caseYear'
,
};
function
getDetailFormValue
(
form
,
fieldPath
)
{
if
(
fieldPath
===
'title'
)
return
form
.
label
;
const
fieldName
=
getFieldNameFromPath
(
fieldPath
);
const
formKey
=
FORM_FIELD_KEYS
[
fieldName
];
return
formKey
?
form
[
formKey
]
:
form
.
metadataDraft
?.[
fieldName
];
}
function
setDetailFormValue
(
form
,
fieldPath
,
value
)
{
if
(
fieldPath
===
'title'
)
return
{
...
form
,
label
:
value
};
const
fieldName
=
getFieldNameFromPath
(
fieldPath
);
const
formKey
=
FORM_FIELD_KEYS
[
fieldName
];
if
(
formKey
)
return
{
...
form
,
[
formKey
]:
value
};
return
{
...
form
,
metadataDraft
:
{
...(
form
.
metadataDraft
||
{}),
[
fieldName
]:
value
},
};
}
function
getCaseColumnDisplayValue
(
item
,
column
)
{
if
(
column
.
field
===
'updated_at'
)
return
item
.
updatedAtLabel
;
if
(
column
.
field
===
'created_at'
)
return
item
.
createdAtLabel
;
return
formatFieldValue
(
getFieldValue
(
item
.
rawRecord
,
column
.
field
),
column
.
fieldDef
);
}
function
CaseDetailField
({
children
,
fullWidth
=
false
,
label
,
value
})
{
function
CaseDetailField
({
children
,
fullWidth
=
false
,
label
,
value
})
{
const
isEmpty
=
Array
.
isArray
(
value
)
const
isEmpty
=
Array
.
isArray
(
value
)
?
value
.
length
===
0
?
value
.
length
===
0
...
@@ -415,6 +510,7 @@ function buildImportPreviewRows(preview) {
...
@@ -415,6 +510,7 @@ function buildImportPreviewRows(preview) {
}
}
export
default
function
ResourceBookingPage
()
{
export
default
function
ResourceBookingPage
()
{
const
currentUser
=
useContext
(
UserContext
);
const
[
query
,
setQuery
]
=
useState
(
''
);
const
[
query
,
setQuery
]
=
useState
(
''
);
const
[
tagFilter
,
setTagFilter
]
=
useState
(
''
);
const
[
tagFilter
,
setTagFilter
]
=
useState
(
''
);
const
[
caseSourceFilter
,
setCaseSourceFilter
]
=
useState
(
''
);
const
[
caseSourceFilter
,
setCaseSourceFilter
]
=
useState
(
''
);
...
@@ -457,11 +553,40 @@ export default function ResourceBookingPage() {
...
@@ -457,11 +553,40 @@ export default function ResourceBookingPage() {
const
[
batchEditForm
,
setBatchEditForm
]
=
useState
(
buildEmptyBatchEditForm
);
const
[
batchEditForm
,
setBatchEditForm
]
=
useState
(
buildEmptyBatchEditForm
);
const
[
importFile
,
setImportFile
]
=
useState
(
null
);
const
[
importFile
,
setImportFile
]
=
useState
(
null
);
const
[
importPreview
,
setImportPreview
]
=
useState
(
null
);
const
[
importPreview
,
setImportPreview
]
=
useState
(
null
);
const
[
subject
,
setSubject
]
=
useState
(
null
);
const
[
gridViews
,
setGridViews
]
=
useState
([]);
const
[
detailViews
,
setDetailViews
]
=
useState
([]);
const
[
activeGridViewName
,
setActiveGridViewName
]
=
useState
(
''
);
const
[
activeDetailViewName
,
setActiveDetailViewName
]
=
useState
(
''
);
const
[
resourceError
,
setResourceError
]
=
useState
(
''
);
const
[
materialValidationErrors
,
setMaterialValidationErrors
]
=
useState
({});
const
pendingLocalMaterialsRef
=
useRef
({});
const
editSessionRef
=
useRef
(
0
);
const
activeGridView
=
useMemo
(
()
=>
gridViews
.
find
((
view
)
=>
view
.
name
===
activeGridViewName
)
||
null
,
[
activeGridViewName
,
gridViews
],
);
const
activeCase
=
useMemo
(()
=>
{
const
activeCase
=
useMemo
(()
=>
{
return
cases
.
find
((
item
)
=>
item
.
id
===
activeCaseId
)
||
null
;
return
cases
.
find
((
item
)
=>
item
.
id
===
activeCaseId
)
||
null
;
},
[
activeCaseId
,
cases
]);
},
[
activeCaseId
,
cases
]);
const
visibleDetailViews
=
useMemo
(()
=>
{
const
configured
=
detailViews
.
filter
((
view
)
=>
checkViewCondition
(
view
?.
settings
?.
visabled
,
activeCase
?.
rawRecord
));
return
configured
.
length
>
0
?
configured
:
[
FALLBACK_DETAIL_VIEW
];
},
[
activeCase
?.
rawRecord
,
detailViews
]);
const
activeDetailView
=
useMemo
(
()
=>
visibleDetailViews
.
find
((
view
)
=>
view
.
name
===
activeDetailViewName
)
||
visibleDetailViews
[
0
],
[
activeDetailViewName
,
visibleDetailViews
],
);
const
activeDetailColumns
=
useMemo
(
()
=>
buildViewColumns
(
activeDetailView
,
subject
||
FALLBACK_CASE_SUBJECT
),
[
activeDetailView
,
subject
],
);
const
hideDetailTitle
=
shouldHideViewTitleColumn
(
activeDetailView
);
const
isDetailDirty
=
useMemo
(
const
isDetailDirty
=
useMemo
(
()
=>
JSON
.
stringify
(
detailForm
)
!==
JSON
.
stringify
(
buildCaseForm
(
activeCase
)),
()
=>
JSON
.
stringify
(
detailForm
)
!==
JSON
.
stringify
(
buildCaseForm
(
activeCase
)),
[
activeCase
,
detailForm
],
[
activeCase
,
detailForm
],
...
@@ -514,100 +639,23 @@ export default function ResourceBookingPage() {
...
@@ -514,100 +639,23 @@ export default function ResourceBookingPage() {
const
totalPages
=
Math
.
max
(
1
,
Math
.
ceil
(
total
/
pageSize
));
const
totalPages
=
Math
.
max
(
1
,
Math
.
ceil
(
total
/
pageSize
));
const
caseTableColumns
=
useMemo
(
const
caseTableColumns
=
useMemo
(
()
=>
[
()
=>
[
{
...
buildViewColumns
(
key
:
'label'
,
activeGridView
||
FALLBACK_GRID_VIEW
,
title
:
'标题'
,
subject
||
FALLBACK_CASE_SUBJECT
,
width
:
380
,
).
map
((
column
,
index
)
=>
({
pin
:
'start'
,
key
:
column
.
field
,
isPrimary
:
true
,
title
:
column
.
label
,
width
:
column
.
width
,
pin
:
column
.
fixed
===
'left'
||
column
.
pin
===
'start'
?
'start'
:
undefined
,
isPrimary
:
index
===
0
,
sortable
:
Boolean
(
column
.
sortable
||
column
.
sort
),
render
:
(
item
)
=>
(
render
:
(
item
)
=>
(
<
div
className=
"flex min-w-0 max-w-full flex-col gap-1 overflow-hidden"
>
<
DataTableText
<
DataTablePrimaryText
text=
{
item
.
label
||
'未命名案例'
}
showTooltip
showTooltip
>
text=
{
getCaseColumnDisplayValue
(
item
,
column
)
||
'-'
}
{
item
.
label
||
'未命名案例'
}
</
DataTablePrimaryText
>
<
DataTableSecondaryText
text=
{
item
.
contentPreview
||
'-'
}
>
{
item
.
contentPreview
||
'-'
}
</
DataTableSecondaryText
>
</
div
>
),
},
{
key
:
'category'
,
title
:
'分类'
,
width
:
180
,
render
:
(
item
)
=>
(
<
DataTableChip
color=
"accent"
text=
{
item
.
category
||
'-'
}
variant=
"soft"
>
{
item
.
category
||
'-'
}
</
DataTableChip
>
),
},
{
key
:
'tag'
,
title
:
'标签'
,
width
:
380
,
render
:
(
item
)
=>
(
item
.
tag
?
(
<
DataTableChip
color=
"success"
text=
{
item
.
tag
}
variant=
"soft"
>
{
item
.
tag
}
</
DataTableChip
>
)
:
'-'
),
},
{
key
:
'caseType'
,
title
:
'案例类型'
,
width
:
240
,
render
:
(
item
)
=>
(
<
DataTableTextPair
primary=
{
item
.
caseType
||
'-'
}
secondary=
{
item
.
caseAttribute
||
''
}
/>
/>
),
),
},
})),
{
key
:
'keyword'
,
title
:
'关键字'
,
width
:
340
,
render
:
(
item
)
=>
(
<
KeywordChips
value=
{
item
.
keywordList
||
item
.
keyword
}
emptyText=
"-"
limit=
{
3
}
/>
),
},
{
key
:
'caseSource'
,
title
:
'案例来源'
,
width
:
180
,
render
:
(
item
)
=>
(
<
DataTableText
text=
{
item
.
caseSource
||
'-'
}
showTooltip
/>
),
},
{
key
:
'caseLevel'
,
title
:
'等级 / 年份'
,
width
:
150
,
render
:
(
item
)
=>
(
<
DataTableTextPair
primary=
{
item
.
caseLevel
||
'-'
}
secondary=
{
item
.
caseYear
?
`${item.caseYear} 年`
:
''
}
/>
),
},
{
key
:
'updatedAtLabel'
,
title
:
'更新时间'
,
width
:
150
,
render
:
(
item
)
=>
(
<
DataTableText
text=
{
item
.
updatedAtLabel
||
'-'
}
/>
),
},
{
{
key
:
'actions'
,
key
:
'actions'
,
title
:
'操作'
,
title
:
'操作'
,
...
@@ -624,9 +672,40 @@ export default function ResourceBookingPage() {
...
@@ -624,9 +672,40 @@ export default function ResourceBookingPage() {
),
),
},
},
],
],
[],
[
activeGridView
,
subject
],
);
);
useEffect
(()
=>
{
let
cancelled
=
false
;
setResourceError
(
''
);
loadRiskCaseViewResources
()
.
then
((
resources
)
=>
{
if
(
cancelled
)
return
;
setSubject
(
resources
.
subject
);
setGridViews
(
resources
.
gridViews
);
setDetailViews
(
resources
.
detailViews
);
setActiveGridViewName
((
current
)
=>
resources
.
gridViews
.
some
((
view
)
=>
view
.
name
===
current
)
?
current
:
resources
.
gridViews
[
0
]?.
name
||
''
);
})
.
catch
((
loadError
)
=>
{
if
(
!
cancelled
)
{
setSubject
(
null
);
setGridViews
([]);
setDetailViews
([]);
setResourceError
(
loadError
?.
message
||
'案例视图配置加载失败。'
);
}
});
return
()
=>
{
cancelled
=
true
;
};
},
[]);
useEffect
(()
=>
{
if
(
!
visibleDetailViews
.
some
((
view
)
=>
view
.
name
===
activeDetailViewName
))
{
setActiveDetailViewName
(
visibleDetailViews
[
0
]?.
name
||
''
);
}
},
[
activeDetailViewName
,
visibleDetailViews
]);
useEffect
(()
=>
{
useEffect
(()
=>
{
setDetailForm
(
buildCaseForm
(
activeCase
));
setDetailForm
(
buildCaseForm
(
activeCase
));
},
[
activeCase
]);
},
[
activeCase
]);
...
@@ -772,6 +851,9 @@ export default function ResourceBookingPage() {
...
@@ -772,6 +851,9 @@ export default function ResourceBookingPage() {
}
}
function
openCaseDetail
(
caseItem
)
{
function
openCaseDetail
(
caseItem
)
{
editSessionRef
.
current
+=
1
;
pendingLocalMaterialsRef
.
current
=
{};
setMaterialValidationErrors
({});
setActiveCaseId
(
caseItem
.
id
);
setActiveCaseId
(
caseItem
.
id
);
setDetailForm
(
buildCaseForm
(
caseItem
));
setDetailForm
(
buildCaseForm
(
caseItem
));
setIsDetailEditing
(
false
);
setIsDetailEditing
(
false
);
...
@@ -786,6 +868,8 @@ export default function ResourceBookingPage() {
...
@@ -786,6 +868,8 @@ export default function ResourceBookingPage() {
}
}
if
(
!
isOpen
)
{
if
(
!
isOpen
)
{
pendingLocalMaterialsRef
.
current
=
{};
setMaterialValidationErrors
({});
setDetailForm
(
buildCaseForm
(
activeCase
));
setDetailForm
(
buildCaseForm
(
activeCase
));
setIsDetailEditing
(
false
);
setIsDetailEditing
(
false
);
}
}
...
@@ -794,11 +878,42 @@ export default function ResourceBookingPage() {
...
@@ -794,11 +878,42 @@ export default function ResourceBookingPage() {
}
}
function
handleCancelDetailEdit
()
{
function
handleCancelDetailEdit
()
{
editSessionRef
.
current
+=
1
;
pendingLocalMaterialsRef
.
current
=
{};
setMaterialValidationErrors
({});
setDetailForm
(
buildCaseForm
(
activeCase
));
setDetailForm
(
buildCaseForm
(
activeCase
));
setIsDetailEditing
(
false
);
setIsDetailEditing
(
false
);
setError
(
''
);
setError
(
''
);
}
}
function
handlePendingLocalMaterialChange
(
fieldPath
,
change
,
editSessionId
)
{
if
(
editSessionId
!==
editSessionRef
.
current
)
return
;
const
pendingByField
=
{
...
pendingLocalMaterialsRef
.
current
};
const
current
=
Array
.
isArray
(
pendingByField
[
fieldPath
])
?
pendingByField
[
fieldPath
]
:
[];
let
next
=
current
;
if
(
change
?.
type
===
'replace'
)
next
=
change
.
materials
||
[];
if
(
change
?.
type
===
'add'
)
{
next
=
[...
new
Map
([...
current
,
...(
change
.
materials
||
[])]
.
map
((
material
)
=>
[
material
.
clientId
,
material
])).
values
()];
}
if
(
change
?.
type
===
'remove'
)
{
next
=
current
.
filter
((
material
)
=>
material
.
clientId
!==
change
.
clientId
);
}
if
(
next
.
length
>
0
)
pendingByField
[
fieldPath
]
=
next
;
else
delete
pendingByField
[
fieldPath
];
pendingLocalMaterialsRef
.
current
=
pendingByField
;
}
function
handleMaterialValidation
(
fieldPath
,
message
,
editSessionId
)
{
if
(
editSessionId
!==
editSessionRef
.
current
)
return
;
setMaterialValidationErrors
((
current
)
=>
{
const
next
=
{
...
current
};
if
(
message
)
next
[
fieldPath
]
=
message
;
else
delete
next
[
fieldPath
];
return
next
;
});
}
async
function
handleCreateSubmit
()
{
async
function
handleCreateSubmit
()
{
if
(
if
(
!
String
(
createForm
.
label
||
''
).
trim
()
&&
!
String
(
createForm
.
label
||
''
).
trim
()
&&
...
@@ -847,11 +962,32 @@ export default function ResourceBookingPage() {
...
@@ -847,11 +962,32 @@ export default function ResourceBookingPage() {
setNotice
(
''
);
setNotice
(
''
);
try
{
try
{
for
(
const
[
fieldPath
,
pendingMaterials
]
of
Object
.
entries
(
pendingLocalMaterialsRef
.
current
))
{
const
currentValue
=
getDetailFormValue
(
detailForm
,
fieldPath
);
const
retainedUrls
=
new
Set
((
Array
.
isArray
(
currentValue
)
?
currentValue
:
currentValue
?
[
currentValue
]
:
[])
.
map
((
file
)
=>
file
?.
url
).
filter
(
Boolean
));
for
(
const
pendingMaterial
of
pendingMaterials
)
{
if
(
pendingMaterial
.
materialRecordId
||
!
retainedUrls
.
has
(
pendingMaterial
.
fileAsset
?.
url
))
continue
;
const
createdMaterial
=
await
createRiskCaseMaterial
({
fileAsset
:
pendingMaterial
.
fileAsset
,
currentUser
,
});
pendingLocalMaterialsRef
.
current
=
{
...
pendingLocalMaterialsRef
.
current
,
[
fieldPath
]:
(
pendingLocalMaterialsRef
.
current
[
fieldPath
]
||
[]).
map
((
material
)
=>
material
.
clientId
===
pendingMaterial
.
clientId
?
{
...
material
,
materialRecordId
:
createdMaterial
.
id
}
:
material
),
};
}
}
const
updated
=
await
updateCaseRecord
(
activeCase
,
detailForm
);
const
updated
=
await
updateCaseRecord
(
activeCase
,
detailForm
);
setNotice
(
`案例“
${
updated
.
label
}
”已更新。`
);
setNotice
(
`案例“
${
updated
.
label
}
”已更新。`
);
await
refreshCurrentList
();
await
refreshCurrentList
();
setActiveCaseId
(
updated
.
id
||
activeCase
.
id
);
setActiveCaseId
(
updated
.
id
||
activeCase
.
id
);
setIsDetailEditing
(
false
);
setIsDetailEditing
(
false
);
pendingLocalMaterialsRef
.
current
=
{};
setMaterialValidationErrors
({});
}
catch
(
saveError
)
{
}
catch
(
saveError
)
{
setError
(
saveError
?.
message
||
'案例保存失败,请稍后重试。'
);
setError
(
saveError
?.
message
||
'案例保存失败,请稍后重试。'
);
}
finally
{
}
finally
{
...
@@ -1095,6 +1231,36 @@ export default function ResourceBookingPage() {
...
@@ -1095,6 +1231,36 @@ export default function ResourceBookingPage() {
return (
return (
<div className="space-y-6">
<div className="space-y-6">
{resourceError ? (
<Alert status="warning">
<Alert.Content>
<Alert.Description>{resourceError} 当前继续使用默认案例字段展示。</Alert.Description>
</Alert.Content>
</Alert>
) : null}
{gridViews.length > 1 ? (
<Tabs
className="w-fit max-w-full min-w-0"
selectedKey={activeGridViewName}
variant="primary"
onSelectionChange={(key) => {
setActiveGridViewName(String(key));
setPage(0);
setSelectedCaseIds([]);
}}
>
<Tabs.ListContainer className="w-fit max-w-full min-w-0">
<Tabs.List aria-label="案例视图" className="w-max min-w-max">
{gridViews.map((view) => (
<Tabs.Tab className="w-auto min-w-max flex-none whitespace-nowrap" id={view.name} key={view.name}>
{view.title || view.name}
<Tabs.Indicator />
</Tabs.Tab>
))}
</Tabs.List>
</Tabs.ListContainer>
</Tabs>
) : null}
<div className="min-w-0 max-w-full">
<div className="min-w-0 max-w-full">
<Tabs
<Tabs
className="w-fit max-w-full min-w-0"
className="w-fit max-w-full min-w-0"
...
@@ -1578,195 +1744,69 @@ export default function ResourceBookingPage() {
...
@@ -1578,195 +1744,69 @@ export default function ResourceBookingPage() {
<Modal.Body>
<Modal.Body>
{activeCase ? (
{activeCase ? (
<div className="space-y-6">
<div className="space-y-6">
{!hideDetailTitle ? (
<h2 className="break-words text-lg font-semibold">{activeCase.title || activeCase.label}</h2>
) : null}
<div className="flex flex-wrap gap-x-4 gap-y-2">
<div className="flex flex-wrap gap-x-4 gap-y-2">
<span>创建于 {activeCase.createdAtLabel}</span>
<span>创建于 {activeCase.createdAtLabel}</span>
<span>最近更新 {activeCase.updatedAtLabel}</span>
<span>最近更新 {activeCase.updatedAtLabel}</span>
</div>
</div>
{visibleDetailViews.length > 1 ? (
<Tabs selectedKey={activeDetailView?.name} onSelectionChange={(key) => setActiveDetailViewName(String(key))}>
<Tabs.ListContainer className="w-fit max-w-full min-w-0">
<Tabs.List aria-label="详情视图" className="w-max min-w-max">
{visibleDetailViews.map((view) => (
<Tabs.Tab className="w-auto min-w-max flex-none whitespace-nowrap" id={view.name} key={view.name}>
{view.title || view.name}<Tabs.Indicator />
</Tabs.Tab>
))}
</Tabs.List>
</Tabs.ListContainer>
</Tabs>
) : null}
{!isDetailEditing ? (
{!isDetailEditing ? (
<Surface
<Surface
className="px-5 py-2 sm:px-6 sm:py-3"
className="px-5 py-2 sm:px-6 sm:py-3"
variant="secondary"
variant="secondary"
>
>
<div className="grid min-w-0 grid-cols-1 gap-x-8 gap-y-2 md:grid-cols-2">
<div className="grid min-w-0 grid-cols-1 gap-x-8 gap-y-2 md:grid-cols-2">
<CaseDetailField fullWidth label="标题" value={activeCase.label} />
{activeDetailColumns.map((column) => {
<CaseDetailField label="分类" value={activeCase.category} />
const value = getFieldValue(activeCase.rawRecord, column.field);
<CaseDetailField label="标签" value={activeCase.tag} />
const fieldType = String(column.fieldDef?.type || '').toLowerCase();
<CaseDetailField label="案例类型" value={activeCase.caseType} />
const fullWidth = ['long_text', 'rich_text', 'object', 'subject', 'file'].includes(fieldType);
<CaseDetailField label="案例属性" value={activeCase.caseAttribute} />
return (
<CaseDetailField fullWidth label="关键字" value={activeCase.keywordList
}>
<CaseDetailField fullWidth={fullWidth} key={column.field} label={column.label} value={value
}>
<KeywordChips value={activeCase.keywordList} />
{formatFieldValue(value, column.fieldDef) || '暂无内容'}
</CaseDetailField>
</CaseDetailField>
<CaseDetailField label="案例来源" value={activeCase.caseSource} />
);
<CaseDetailField label="案例等级" value={activeCase.caseLevel} />
})}
<CaseDetailField label="案例年份" value={activeCase.caseYear} />
<CaseDetailField fullWidth label="案例详情" value={activeCase.caseDetails} />
<CaseDetailField fullWidth label="案例警示" value={activeCase.caseWarning} />
</div>
</div>
</Surface>
</Surface>
) : (
) : (
<div className="grid min-w-0 gap-4 md:grid-cols-2">
<div className="grid min-w-0 gap-4 md:grid-cols-2">
<TextField className="md:col-span-2">
{activeDetailColumns.map((column) => {
<Label>标题</Label>
const fieldType = String(column.fieldDef?.type || 'text').toLowerCase();
<Input
const fieldName = getFieldNameFromPath(column.field);
fullWidth
const value = getDetailFormValue(detailForm, column.field);
value={detailForm.label}
const disabled = Boolean(column.fieldDef?.isPrimaryKey);
variant="secondary"
const fullWidth = ['long_text', 'rich_text', 'object', 'subject', 'file'].includes(fieldType);
onChange={(event) =>
const label = `
$
{
column
.
label
}
$
{
column
.
fieldDef
?.
isRequired
?
' *'
:
''
}
`;
setDetailForm((prev) => ({
if (fieldType === 'file') {
...prev,
return <div className={clsx('min-w-0', fullWidth && 'md:col-span-2')} key={column.field}><Label>{label}</Label><div className="mt-2">{disabled ? <span className="text-sm text-muted">{formatFieldValue(value, column.fieldDef) || '未上传'}</span> : <RiskCaseMaterialField editSessionId={editSessionRef.current} fieldPath={column.field} multiple={Boolean(column.fieldDef?.multiple || column.fieldDef?.settings?.multiple)} value={value} onChange={(nextValue) => setDetailForm((current) => setDetailFormValue(current, column.field, nextValue))} onPendingLocalMaterialChange={handlePendingLocalMaterialChange} onValidationChange={handleMaterialValidation} />}</div></div>;
label: event.target.value,
}
}))
if (fieldName === 'keyword') {
}
return <TextField className={fullWidth ? 'md:col-span-2' : undefined} isDisabled={disabled} key={column.field}><Label>{label}</Label><KeywordEditor value={value} onChange={(nextValue) => setDetailForm((current) => setDetailFormValue(current, column.field, nextValue))} /></TextField>;
/>
}
</TextField>
if (fieldType === 'boolean') {
<TextField>
return <Checkbox isDisabled={disabled} isSelected={Boolean(value)} key={column.field} onChange={(selected) => setDetailForm((current) => setDetailFormValue(current, column.field, selected))}><Checkbox.Content><Checkbox.Control><Checkbox.Indicator /></Checkbox.Control>{label}</Checkbox.Content></Checkbox>;
<Label>分类</Label>
}
<Input
if (['long_text', 'rich_text'].includes(fieldType) || ['case_details', 'case_warning'].includes(fieldName)) {
fullWidth
return <TextField className="md:col-span-2" isDisabled={disabled} key={column.field}><Label>{label}</Label><TextArea fullWidth rows={fieldName === 'case_details' ? 16 : 4} variant="secondary" value={value || ''} onChange={(event) => setDetailForm((current) => setDetailFormValue(current, column.field, event.target.value))} /></TextField>;
value={detailForm.category}
}
variant="secondary"
return <TextField isDisabled={disabled} key={column.field}><Label>{label}</Label><Input fullWidth type={fieldType === 'number' ? 'number' : 'text'} variant="secondary" value={value ?? ''} onChange={(event) => setDetailForm((current) => setDetailFormValue(current, column.field, event.target.value))} /></TextField>;
onChange={(event) =>
})}
setDetailForm((prev) => ({
...prev,
category: event.target.value,
}))
}
/>
</TextField>
<TextField>
<Label>标签</Label>
<Input
fullWidth
value={detailForm.tag}
variant="secondary"
onChange={(event) =>
setDetailForm((prev) => ({
...prev,
tag: event.target.value,
}))
}
/>
</TextField>
<TextField>
<Label>案例类型</Label>
<Input
fullWidth
value={detailForm.caseType}
variant="secondary"
onChange={(event) =>
setDetailForm((prev) => ({
...prev,
caseType: event.target.value,
}))
}
/>
</TextField>
<TextField>
<Label>案例属性</Label>
<Input
fullWidth
value={detailForm.caseAttribute}
variant="secondary"
onChange={(event) =>
setDetailForm((prev) => ({
...prev,
caseAttribute: event.target.value,
}))
}
/>
</TextField>
<TextField>
<Label>关键字</Label>
<KeywordEditor
value={detailForm.keyword}
onChange={(nextKeywords) =>
setDetailForm((prev) => ({
...prev,
keyword: nextKeywords,
}))
}
placeholder="输入关键字后按 Enter 新增"
/>
</TextField>
<TextField>
<Label>案例来源</Label>
<Input
fullWidth
value={detailForm.caseSource}
variant="secondary"
onChange={(event) =>
setDetailForm((prev) => ({
...prev,
caseSource: event.target.value,
}))
}
/>
</TextField>
<TextField>
<Label>案例等级</Label>
<Input
fullWidth
value={detailForm.caseLevel}
variant="secondary"
onChange={(event) =>
setDetailForm((prev) => ({
...prev,
caseLevel: event.target.value,
}))
}
/>
</TextField>
<TextField>
<Label>案例年份</Label>
<Input
fullWidth
type="number"
value={detailForm.caseYear}
variant="secondary"
onChange={(event) =>
setDetailForm((prev) => ({
...prev,
caseYear: event.target.value,
}))
}
/>
</TextField>
<TextField className="md:col-span-2">
<div className="mb-2 flex items-center justify-between gap-3">
<Label>案例详情</Label>
<span>
当前 {String(detailForm.caseDetails || '').replace(/\s+/g, '').length} 字
</span>
</div>
<TextArea
fullWidth
rows={16}
variant="secondary"
value={detailForm.caseDetails}
onChange={(event) =>
setDetailForm((prev) => ({
...prev,
caseDetails: event.target.value,
}))
}
/>
</TextField>
<TextField className="md:col-span-2">
<Label>案例警示</Label>
<TextArea
fullWidth
rows={4}
variant="secondary"
value={detailForm.caseWarning}
onChange={(event) =>
setDetailForm((prev) => ({
...prev,
caseWarning: event.target.value,
}))
}
/>
</TextField>
</div>
</div>
)}
)}
</div>
</div>
...
@@ -1797,7 +1837,7 @@ export default function ResourceBookingPage() {
...
@@ -1797,7 +1837,7 @@ export default function ResourceBookingPage() {
取消
取消
</Button>
</Button>
<Button
<Button
isDisabled={saving || !activeCase || !isDetailDirty}
isDisabled={saving || !activeCase || !isDetailDirty
|| Object.keys(materialValidationErrors).length > 0
}
size="sm"
size="sm"
variant="primary"
variant="primary"
onPress={handleSaveDetail}
onPress={handleSaveDetail}
...
...
src/pages/project_manager_xy/riskCaseMaterialService.js
0 → 100644
View file @
2078e013
import
dayjs
from
'dayjs'
;
import
{
APP_ROUTE_NAME
}
from
'@/constants/app'
;
import
{
createRecord
,
queryRecords
,
uploadFile
}
from
'@/services/DataService'
;
export
const
AUDIT_MATERIAL_SUBJECT
=
'am_material'
;
export
const
RISK_CASE_SOURCE_APP
=
APP_ROUTE_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
0 → 100644
View file @
2078e013
import
{
getSubjectView
,
getSubjectViewDetail
,
loadSubject
,
}
from
'@/services/DataService'
;
import
{
toArray
}
from
'@/utils/subjectView'
;
import
{
CASE_SUBJECT
}
from
'./materialService'
;
export
async
function
loadRiskCaseViewResources
()
{
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'
),
detailViews
:
views
.
filter
((
view
)
=>
view
?.
type
===
'detail'
),
};
}
src/utils/subjectView.js
0 → 100644
View file @
2078e013
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
];
}
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
,
};
});
}
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