Skip to content
Projects
Groups
Snippets
Help
Loading...
Sign in / Register
Toggle navigation
R
risk_manager_new
Project
Project
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Registry
Registry
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
David Yang
risk_manager_new
Commits
19c9905e
Commit
19c9905e
authored
Sep 30, 2026
by
drigle
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: add import preflight validation and confirmation
parent
1e1adc14
Expand all
Show whitespace changes
Inline
Side-by-side
Showing
8 changed files
with
250 additions
and
20 deletions
+250
-20
package.json
package.json
+1
-0
manifest.json
public/manifest.json
+1
-1
ImportPreflightDialog.jsx
src/pages/risk_management/ImportPreflightDialog.jsx
+156
-0
RiskManagementListPage.jsx
src/pages/risk_management/RiskManagementListPage.jsx
+68
-8
risk-management.business-flow.md
src/pages/risk_management/risk-management.business-flow.md
+24
-11
RiskManagerService.js
src/services/RiskManagerService.js
+0
-0
riskImportPreflight.js
src/services/riskImportPreflight.js
+0
-0
risk-import-preflight.test.mjs
tests/risk-import-preflight.test.mjs
+0
-0
No files found.
package.json
View file @
19c9905e
...
...
@@ -2,6 +2,7 @@
"private"
:
true
,
"scripts"
:
{
"build"
:
"max build"
,
"test:import"
:
"node --experimental-vm-modules --test tests/risk-import-preflight.test.mjs"
,
"dev"
:
"max dev"
,
"postinstall"
:
"max setup"
,
"setup"
:
"max setup"
,
...
...
public/manifest.json
View file @
19c9905e
{
"name"
:
"risk_manager_new"
,
"version"
:
"1.0.
1
"
,
"version"
:
"1.0.
2
"
,
"build"
:
"abc"
,
"description"
:
"风险管理应用"
,
"date"
:
"2025-07-14 19:27:20"
,
...
...
src/pages/risk_management/ImportPreflightDialog.jsx
0 → 100644
View file @
19c9905e
import
{
Alert
,
Button
,
Modal
,
Pagination
,
Spinner
}
from
'@heroui/react'
;
import
{
DataGrid
}
from
'@heroui-pro/react'
;
import
{
exportImportIssues
}
from
'@/services/riskImportPreflight'
;
import
{
useEffect
,
useState
}
from
'react'
;
const
PAGE_SIZE
=
20
;
const
ISSUE_COLUMNS
=
[
{
id
:
'rowNumber'
,
header
:
'Excel 行号'
,
accessorKey
:
'rowNumber'
,
width
:
85
,
isRowHeader
:
true
},
{
id
:
'cell'
,
header
:
'单元格'
,
accessorKey
:
'cell'
,
width
:
75
},
{
id
:
'field'
,
header
:
'字段'
,
accessorKey
:
'field'
,
width
:
150
},
{
id
:
'value'
,
header
:
'原始值'
,
accessorKey
:
'value'
,
width
:
160
},
{
id
:
'reason'
,
header
:
'问题'
,
accessorKey
:
'reason'
,
width
:
220
},
{
id
:
'suggestion'
,
header
:
'修改建议 / 合法选项'
,
accessorKey
:
'suggestion'
,
width
:
340
},
].
map
((
column
)
=>
({
...
column
,
minWidth
:
column
.
width
,
cell
:
(
issue
)
=>
(
<
div
className=
"whitespace-pre-wrap break-words"
>
{
issue
[
column
.
accessorKey
]
}
</
div
>
),
}));
export
default
function
ImportPreflightDialog
({
isOpen
,
report
,
isChecking
,
isImporting
,
error
,
onClose
,
onReselect
,
onConfirm
,
})
{
const
[
page
,
setPage
]
=
useState
(
1
);
useEffect
(()
=>
{
setPage
(
1
);
},
[
report
]);
const
busy
=
isChecking
||
isImporting
;
const
issues
=
report
?.
issues
||
[];
const
canImport
=
report
?.
canImport
&&
!
busy
&&
!
error
;
const
pages
=
Math
.
ceil
(
issues
.
length
/
PAGE_SIZE
);
return
(
<
Modal
.
Root
>
<
Modal
.
Backdrop
isOpen=
{
isOpen
}
isDismissable=
{
!
busy
}
isKeyboardDismissDisabled=
{
busy
}
onOpenChange=
{
(
open
)
=>
{
if
(
!
open
&&
!
busy
)
onClose
();
}
}
>
<
Modal
.
Container
placement=
"center"
scroll=
"inside"
size=
"lg"
>
<
Modal
.
Dialog
className=
"sm:max-w-[68rem]"
>
<
Modal
.
Header
>
<
Modal
.
Heading
>
导入预检
</
Modal
.
Heading
>
</
Modal
.
Header
>
<
Modal
.
Body
className=
"flex flex-col gap-4"
>
{
busy
?
(
<
div
className=
"flex items-center gap-3"
role=
"status"
>
<
Spinner
size=
"sm"
/>
<
span
>
{
isChecking
?
'正在读取文件并核对字段、选项和引用记录…'
:
'正在导入并更新关联记录…'
}
</
span
>
</
div
>
)
:
null
}
{
error
?
(
<
Alert
status=
"danger"
>
<
Alert
.
Content
>
<
Alert
.
Title
>
操作未完成
</
Alert
.
Title
>
<
Alert
.
Description
>
{
error
}
</
Alert
.
Description
>
</
Alert
.
Content
>
</
Alert
>
)
:
null
}
{
report
?
(
<>
<
p
className=
"text-sm text-muted"
>
文件:
{
report
.
fileName
}
· 工作表:
{
report
.
sheetName
||
'无'
}
· 数据:
{
report
.
totalRows
}
行;错误定位保留原 Excel 行号。
</
p
>
<
Alert
status=
{
report
.
canImport
?
'success'
:
'danger'
}
>
<
Alert
.
Content
>
<
Alert
.
Title
>
{
report
.
canImport
?
'预检通过'
:
`发现 ${issues.length} 处问题,涉及 ${report.errorRows} 行数据`
}
</
Alert
.
Title
>
<
Alert
.
Description
>
{
report
.
canImport
?
`已检查全部 ${report.totalRows} 行,点击“确认导入”后写入。`
:
'请根据下方明细修正文件后重新选择;本次预检尚未写入数据。原始值中的标点应与合法选项一致,可选字段没有内容时请留空。'
}
</
Alert
.
Description
>
</
Alert
.
Content
>
</
Alert
>
{
issues
.
length
?
(
<>
<
DataGrid
aria
-
label=
"导入预检错误明细"
data=
{
issues
.
slice
((
page
-
1
)
*
PAGE_SIZE
,
page
*
PAGE_SIZE
)
}
columns=
{
ISSUE_COLUMNS
}
getRowId=
{
(
issue
)
=>
issue
.
id
}
selectionMode=
"none"
verticalAlign=
"top"
contentClassName=
"min-w-[1030px]"
/>
{
pages
>
1
?
(
<
Pagination
className=
"self-end"
size=
"sm"
>
<
Pagination
.
Summary
>
共
{
issues
.
length
}
处问题
</
Pagination
.
Summary
>
<
Pagination
.
Content
>
<
Pagination
.
Item
>
<
Pagination
.
Previous
aria
-
label=
"上一页"
isDisabled=
{
page
===
1
}
onPress=
{
()
=>
setPage
((
value
)
=>
value
-
1
)
}
>
上一页
</
Pagination
.
Previous
>
</
Pagination
.
Item
>
<
Pagination
.
Item
>
<
Pagination
.
Link
isActive
aria
-
label=
{
`第 ${page} 页,共 ${pages} 页`
}
>
{
page
}
/
{
pages
}
</
Pagination
.
Link
>
</
Pagination
.
Item
>
<
Pagination
.
Item
>
<
Pagination
.
Next
aria
-
label=
"下一页"
isDisabled=
{
page
===
pages
}
onPress=
{
()
=>
setPage
((
value
)
=>
value
+
1
)
}
>
下一页
</
Pagination
.
Next
>
</
Pagination
.
Item
>
</
Pagination
.
Content
>
</
Pagination
>
)
:
null
}
</>
)
:
null
}
</>
)
:
null
}
</
Modal
.
Body
>
<
Modal
.
Footer
className=
"flex-wrap"
>
<
Button
variant=
"secondary"
isDisabled=
{
busy
}
onPress=
{
onClose
}
>
关闭
</
Button
>
{
issues
.
length
?
(
<
Button
variant=
"secondary"
isDisabled=
{
busy
}
onPress=
{
()
=>
exportImportIssues
(
report
)
}
>
下载错误清单
</
Button
>
)
:
null
}
<
Button
variant=
"secondary"
isDisabled=
{
busy
}
onPress=
{
onReselect
}
>
重新选择文件
</
Button
>
<
Button
isDisabled=
{
!
canImport
}
isPending=
{
isImporting
}
onPress=
{
onConfirm
}
>
确认导入
{
report
?.
canImport
?
`(${report.totalRows} 行)`
:
''
}
</
Button
>
</
Modal
.
Footer
>
</
Modal
.
Dialog
>
</
Modal
.
Container
>
</
Modal
.
Backdrop
>
</
Modal
.
Root
>
);
}
src/pages/risk_management/RiskManagementListPage.jsx
View file @
19c9905e
...
...
@@ -2,6 +2,7 @@ import SubjectRecordWorkbench from '@/components/SubjectRecordWorkbench';
import
{
ArrowDownTrayIcon
}
from
'@/components/AppIcons'
;
import
{
ACTION_LABELS
,
RISK_MANAGEMENT_SUBJECTS
}
from
'@/config/riskManagementConfig'
;
import
RiskManagerService
from
'@/services/RiskManagerService'
;
import
ImportPreflightDialog
from
'./ImportPreflightDialog'
;
import
{
Button
,
Tooltip
,
toast
}
from
'@heroui/react'
;
import
{
useCallback
,
useMemo
,
useRef
,
useState
}
from
'react'
;
...
...
@@ -16,7 +17,9 @@ function RiskManagementListPage({ subjectName }) {
const
gridConfig
=
definition
.
gridRecordConfig
||
{};
const
service
=
useMemo
(()
=>
new
RiskManagerService
(),
[]);
const
importInputRef
=
useRef
(
null
);
const
pendingImportRef
=
useRef
(
''
);
const
pendingImportRef
=
useRef
(
null
);
const
importSessionRef
=
useRef
(
null
);
const
[
importState
,
setImportState
]
=
useState
(
null
);
const
[
actionLoading
,
setActionLoading
]
=
useState
(
false
);
const
runAction
=
useCallback
(
async
(
actionKey
,
context
=
{},
file
)
=>
{
...
...
@@ -54,22 +57,65 @@ function RiskManagementListPage({ subjectName }) {
const
runConfiguredAction
=
useCallback
((
actionKey
,
context
)
=>
{
if
(
actionKey
.
startsWith
(
'import-'
))
{
pendingImportRef
.
current
=
actionKey
;
pendingImportRef
.
current
=
{
actionKey
,
context
}
;
importInputRef
.
current
?.
click
();
return
;
}
runAction
(
actionKey
,
context
);
},
[
runAction
]);
const
checkImport
=
useCallback
(
async
(
file
,
session
)
=>
{
importSessionRef
.
current
=
session
;
setActionLoading
(
true
);
session
.
context
?.
setError
?.(
''
);
setImportState
({
isChecking
:
true
,
report
:
null
,
error
:
''
});
try
{
const
report
=
await
service
.
preflightImport
({
file
,
actionKey
:
session
.
actionKey
});
setImportState
({
isChecking
:
false
,
report
,
error
:
''
});
}
catch
(
error
)
{
setImportState
({
isChecking
:
false
,
report
:
null
,
error
:
error
?.
message
||
'预检失败,请稍后重试'
});
}
finally
{
setActionLoading
(
false
);
}
},
[
service
]);
const
confirmImport
=
useCallback
(
async
()
=>
{
if
(
!
importState
?.
report
?.
canImport
||
actionLoading
||
importState
.
error
)
return
;
const
context
=
importSessionRef
.
current
?.
context
||
{};
setActionLoading
(
true
);
setImportState
((
state
)
=>
({
...
state
,
isImporting
:
true
}));
try
{
const
result
=
await
service
.
executePreparedImport
(
importState
.
report
);
setImportState
(
null
);
toast
.
success
(
`成功导入
${
result
.
count
}
行数据`
);
try
{
await
context
.
reload
?.({
keepFeedback
:
true
});
}
catch
(
reloadError
)
{
context
.
setError
?.(
reloadError
?.
message
||
'数据已导入,但列表刷新失败,请手动刷新'
);
}
}
catch
(
error
)
{
const
message
=
error
?.
message
||
'导入失败,请核对系统数据后重试'
;
setImportState
((
state
)
=>
({
...
state
,
isImporting
:
false
,
error
:
message
}));
context
.
setError
?.(
message
);
try
{
await
context
.
reload
?.({
keepFeedback
:
true
});
}
catch
{
// Keep the import error visible even if refreshing the list also fails.
}
}
finally
{
setActionLoading
(
false
);
}
},
[
actionLoading
,
importState
,
service
]);
const
mapAction
=
useCallback
((
action
)
=>
({
...
action
,
disabled
:
(
context
)
=>
actionLoading
||
(
disabled
:
(
context
)
=>
actionLoading
||
Boolean
(
importState
)
||
(
typeof
action
.
disabled
===
'function'
?
action
.
disabled
(
context
)
:
Boolean
(
action
.
disabled
)
),
onClick
:
(
context
)
=>
runConfiguredAction
(
action
.
key
,
context
),
}),
[
actionLoading
,
runConfiguredAction
]);
}),
[
actionLoading
,
importState
,
runConfiguredAction
]);
const
toolbarActions
=
(
gridConfig
.
toolbarActions
||
[])
.
filter
((
action
)
=>
!
(
gridConfig
.
readonly
?.
hideImportBtn
&&
action
.
key
.
startsWith
(
'import-'
)))
...
...
@@ -95,15 +141,29 @@ function RiskManagementListPage({ subjectName }) {
accept=
".xlsx,.xls,.csv"
onChange=
{
(
event
)
=>
{
const
file
=
event
.
target
.
files
?.[
0
];
const
actionKey
=
pendingImportRef
.
current
;
const
session
=
pendingImportRef
.
current
;
event
.
target
.
value
=
''
;
pendingImportRef
.
current
=
''
;
if
(
file
&&
actionKey
)
{
runAction
(
actionKey
,
{},
file
);
pendingImportRef
.
current
=
null
;
if
(
file
&&
session
)
{
checkImport
(
file
,
session
);
}
}
}
/>
<
ImportPreflightDialog
isOpen=
{
Boolean
(
importState
)
}
report=
{
importState
?.
report
}
isChecking=
{
importState
?.
isChecking
}
isImporting=
{
importState
?.
isImporting
}
error=
{
importState
?.
error
}
onClose=
{
()
=>
setImportState
(
null
)
}
onReselect=
{
()
=>
{
pendingImportRef
.
current
=
importSessionRef
.
current
;
importInputRef
.
current
?.
click
();
}
}
onConfirm=
{
confirmImport
}
/>
<
SubjectRecordWorkbench
subjectName=
{
subjectName
}
title=
{
definition
.
title
||
subjectName
}
...
...
src/pages/risk_management/risk-management.business-flow.md
View file @
19c9905e
...
...
@@ -58,17 +58,30 @@ HeroUI Pro `DataGrid` 和 `RecordDetailDrawer`。同一套多维表格组件也
-
“查看投监表最近更新”要求至少两条历史版本,满足条件后打开比较页面;不足时提示
用户先创建新版本。
## 4. 导入与关联
-
基金产品初始化模版从文档
`fund_product_template`
下载;导入前先校验每行的基金名称
均能匹配
`fund_product`
,再按指标 Subject 定义创建
`indicator_library`
记录,并将新记录
`_id`
合并到目标基金的
`contract_terms`
;目标基金缺失时不会开始写入。
-
投监表模版指标导入先按“模板标题 + 模板类别”定位模版,再批量创建指标并更新
`associated_indicators`
;导出反向生成包含模版信息的 Excel。
-
托管人及管理人导入先校验整批数据的“公司名称 + 基金类型”一致,并定位现有托管人记录,
成功后按
`relation_related_party`
Subject 字段动态转换并创建关联方,最后以新记录
`_id[]`
合并更新
`related_parties`
,不会覆盖原有的关联关系。公司不存在、批次混用公司/类型或接口未返回 ID 时在写入前停止
并提示修正导入文件;关联关系更新失败时保留错误提示,便于管理员重试或清理该批数据。
## 4. 导入预检与关联
### 4.1 功能入口与状态
-
适用于基金产品“导入”、投监表模版“导入模板关联指标”、托管人及管理人“导入托管基金关联方清单”三个 Excel/CSV 入口。入口可见性继续遵循应用配置的
`hideImportBtn`
;基金初始化模版从文档
`fund_product_template`
下载。
-
选择文件后打开“导入预检”弹窗,状态依次为:读取文件 / 校验中 → 发现问题或预检通过 → 用户确认导入 → 写入和关联更新 → 成功或失败。校验与写入期间禁用重复操作,弹窗不能通过背景或 Escape 关闭。
-
预检只读取第一张工作表和 Subject/目标/引用记录,整份文件检查结束前不调用写入接口。忽略全空数据行,保留实际 Excel 行号、单元格地址;表头首尾空格自动清理,重复和未知表头、有内容但没有表头的列、Excel 公式错误均反馈为错误。
-
有问题时汇总全部错误,显示工作表、Excel 行号、单元格、字段、原始值、原因、修改建议及合法选项;错误列表每页 20 项,可下载完整的“预检错误”Excel 清单。业务修正文件后点击“重新选择文件”再次预检,有任何问题均不能确认导入。
-
全部通过后显示待导入行数,点击“确认导入”才写入预检所得数据;取消或关闭弹窗不写入。成功后显示实际完成行数,并刷新原导入入口所在的工作台。
### 4.2 字段与标点规则
-
字段定义和选择范围来自
`GET /subject/{name}`
,不在页面硬编码业务选项。按
`required / isRequired / is_required`
检查必填,按
`type / multiple / settings.options`
转换、校验字段。未知表头需修正或删除,以免字段内容被静默忽略。
-
多值字段支持英文逗号、中文逗号、顿号、中英文分号和换行作为分隔符;只有分隔符没有内容时反馈错误。单选仅接受一个合法选项,选项自身的合法标点保留;选择项的显示文案转换为对应存储值。可选字段没有内容时应留空,
`—`
、
`/`
、
`无`
等占位内容不能替代合法选项。
-
自由文本保留正文中的正常标点和换行,不进行全局标点删除。数字必须是有效数值,不能附加单位或占位标点,也不能静默丢弃多值中的非法内容。布尔只接受
`是/否、true/false、1/0、yes/no`
,未知值不能静默转为 false。
-
日期支持 Excel 日期序号(含 1900/1904 日期系统)、
`YYYY-MM-DD`
、
`YYYY/M/D`
和 ISO 8601 时间;检查日期与时间真实有效,提交为 ISO 8601。JSON 检查语法与对象结构,多值对象使用 JSON 数组,不能按逗号拆开 JSON;Subject 子对象先加载其字段定义,再递归检查必填、选项和类型。
-
引用按字段配置的 Subject 查找 active 记录,准确名称或 ID 必须唯一匹配;缺失、重名或配置不足都会反馈,不直接保存名称或整个引用对象。法律法规以该字段实际
`settings.subject`
为准,内部制度兼容制度名称字段。所有业务数据继续封装在
`metadata`
内,引用只写入
`_id`
,子对象直接嵌套。
### 4.3 目标与关联写入
-
基金初始化逐行检查“基金名称”,所有目标必须存在且唯一;允许同一文件包含多只基金。预检全通过后按目标基金分组导入
`indicator_library`
,将新记录
`_id`
合并到各基金的
`contract_terms`
,保留已有指标并避免同批多行覆盖。
-
模板关联指标逐行检查“模板标题 + 模板类别”,整份文件必须属于同一个模板及类别,不能仅使用第一行定位后忽略其余行。确认后导入指标,并沿用原规则更新目标模板的
`associated_indicators`
;导出反向生成含模板信息的 Excel。
-
托管清单逐行检查“公司名称 + 基金类型”,整批数据必须属于同一家公司和基金类型,且能定位唯一现有托管人记录。基金类型优先从“类别”读取,兼容旧文件仅使用“类型”的格式;当“类型”用于基金类型时,不误写到关联关系的管理类型字段。新版文件可同时使用“类别(公募/专户)”和“类型(管理人/托管人)”两列,列名分别保持“类别”和“类型”。按
`relation_related_party`
定义校验后创建关联关系,将新
`_id[]`
合并到公司的
`related_parties`
。
-
确认后先重新读取目标记录,确保目标仍可用;追加关联以确认时的最新引用列表为基础,避免覆盖预检等待期间其他用户新增的关联。正式写入检查接口返回的 ID 数量与待写行数是否一致,再更新关联。记录写入或关联更新失败时停止后续目标组,在弹窗和工作台保留具体 Excel 行号、失败阶段、服务端原因和已完整完成的行数,并刷新列表供核对;不把部分完成显示为成功,不提供同一结果直接重复确认,以免重复导入。服务端写入可能已部分执行,需核对系统数据后修正或清理再重试;预检不代表多接口事务回滚。
## 5. 行操作与异常处理
...
...
src/services/RiskManagerService.js
View file @
19c9905e
This diff is collapsed.
Click to expand it.
src/services/riskImportPreflight.js
0 → 100644
View file @
19c9905e
This diff is collapsed.
Click to expand it.
tests/risk-import-preflight.test.mjs
0 → 100644
View file @
19c9905e
This diff is collapsed.
Click to expand it.
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