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
ebd95398
Commit
ebd95398
authored
Sep 22, 2026
by
drigle
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix: optimize case category tabs with aggregation
parent
4ca8494c
Hide whitespace changes
Inline
Side-by-side
Showing
4 changed files
with
158 additions
and
46 deletions
+158
-46
audit-material-center.business-flow.md
...project_manager_xy/audit-material-center.business-flow.md
+15
-5
materialService.js
src/pages/project_manager_xy/materialService.js
+39
-3
index.jsx
src/pages/project_manager_xy/resource-booking/index.jsx
+67
-38
record-api.md
src/services/record-api.md
+37
-0
No files found.
src/pages/project_manager_xy/audit-material-center.business-flow.md
View file @
ebd95398
...
...
@@ -133,9 +133,9 @@
3.
通过案例来源、案例类型、问题属性、分类标签输入框填写组合筛选条件,输入框仅录入文本条件,不提供下拉候选
4.
全文搜索与各筛选值变化后,前端实时将当前条件提交到
`POST /record/query`
做远程查询,并从第一页展示结果
5.
筛选区使用 HeroUI
`Surface + Disclosure`
:默认展示案例来源、案例类型和问题属性三项,点击
`展开`
后展示分类标签;折叠态不保留隐藏内容的布局高度,筛选区不展示刷新或查询按钮
6.
通过 HeroUI Tabs 的案例性质标签快速切换当前视图
;分类名称保持单行显示,空间不足时在 Tabs 内部横向滚动
6.
案例性质标签先通过
`POST /record/aggregate`
按
`metadata.category`
全库分组生成,不再依赖当前页案例在前端分组;加载时显示进度,失败时保留旧标签并提供重试;返回的
`_id`
映射为标签 key/label,空字符串或缺失分类显示为
`未分类`
;标签顺序固定为
`全部案例`
、
`未分类`
、其余分类(按中文名称排序),成功刷新时替换旧分类标签
;分类名称保持单行显示,空间不足时在 Tabs 内部横向滚动
7.
分类标签筛选优先匹配
`metadata.tag`
,同时兼容多值
`metadata.keyword`
8.
页面不对当前页案例做本地二次搜索
8.
切换案例性质标签后,从第一页通过
`POST /record/query`
的
`filter.metadata.category`
远程查询;
`未分类`
使用
`$in: [null, "", "未分类"]`
兼容缺失及空字符串分类。
页面不对当前页案例做本地二次搜索
9.
页面顶部提供
`Segment`
视图切换,可在“卡片视图 / 表格视图”之间切换
10.
卡片视图用于浏览案例摘要;每条案例使用独立一级业务卡片,分类/等级/标签、标题、类型、摘要和时间信息按层级展示,点击卡片后以弹框方式查看并编辑详情
11.
表格视图展示标题、分类、标签、案例类型、关键字、来源、等级、年份、更新时间等字段;宽表在 DataGrid 内部横向滚动,各字段只在所属单元格内单行截断,完整值通过 HeroUI Tooltip 查看,不允许跨列覆盖;首列标题仅用于展示,不承担点击操作,查看详情统一从右侧操作列进入
...
...
@@ -153,6 +153,8 @@
7.
点击
`保存`
后,前端调用
`PUT /record/:name`
8.
保存成功后刷新列表与统计数据,并返回查看态;失败时保留当前草稿和错误提示
创建、编辑、删除、批量修改、批量删除和 Excel 导入成功后,除刷新当前列表外,还必须重新调用分类 aggregate 刷新案例性质标签;aggregate 失败时保留当前标签并展示重试提示。
### 4.6 批量修改案例
1.
用户切换到表格视图
...
...
@@ -213,6 +215,7 @@
-
筛选统一走远程
`POST /record/query`
;页面不使用本地数据搜索替代远程结果
-
全文搜索、结构化筛选和案例性质变化后实时发起远程查询,并从第一页展示结果
-
筛选输入使用普通文本输入框,不使用
`select`
、
`datalist`
或其它下拉候选控件
-
案例性质标签数据来源统一为
`POST /record/aggregate`
,分组字段为
`metadata.category`
;空分类在 UI 中展示为
`未分类`
,并通过包含
`null`
、空字符串和
`未分类`
的过滤条件查询
---
...
...
@@ -299,16 +302,22 @@
-
`POST /record/query`
-
`subject = risk_case_library`
### 7.2 创建案例
### 7.2 聚合案例分类
-
`POST /record/aggregate`
-
`subject = risk_case_library`
-
`stages = [{ "$group": { "_id": "$metadata.category" } }]`
### 7.3 创建案例
-
`POST /record`
-
`subject = risk_case_library`
### 7.
3
更新案例
### 7.
4
更新案例
-
`PUT /record/:name`
### 7.
4
删除案例
### 7.
5
删除案例
-
`DELETE /record/:name`
...
...
@@ -321,6 +330,7 @@
-
每页固定加载 20 条,不展示或提供每页条数选择器
-
当用户修改全文搜索、任一结构化筛选或切换案例性质时,实时从第一页重新加载
-
搜索与筛选统一发起远程查询,不在当前页结果中做本地搜索;连续请求只采用最新筛选状态对应的结果
-
分类标签使用独立 aggregate 请求;列表仍按页查询,不得先拉取全量案例后再生成标签
-
总数优先读取
`POST /record/query`
返回的
`total_records`
;接口未返回总数时再回退到
`count`
接口
---
...
...
src/pages/project_manager_xy/materialService.js
View file @
ebd95398
import
{
countRecords
,
createRecord
,
aggregateRecords
,
queryRecords
,
removeRecord
,
updateRecord
,
...
...
@@ -10,6 +11,7 @@ import * as XLSX from 'xlsx';
export
const
CASE_SUBJECT
=
'risk_case_library'
;
export
const
DEFAULT_PAGE_SIZE
=
20
;
export
const
UNCATEGORIZED_CATEGORY
=
'未分类'
;
const
CASE_EXPORT_HEADERS
=
[
'标题'
,
...
...
@@ -387,9 +389,13 @@ function buildRecordFilter(filters = {}) {
}
if
(
filters
.
category
)
{
clauses
.
push
({
'metadata.category'
:
String
(
filters
.
category
).
trim
(),
});
const
category
=
String
(
filters
.
category
).
trim
();
clauses
.
push
(
category
===
UNCATEGORIZED_CATEGORY
?
{
'metadata.category'
:
{
$in
:
[
null
,
''
,
UNCATEGORIZED_CATEGORY
]
}
}
:
{
'metadata.category'
:
category
},
);
}
if
(
filters
.
tag
)
{
...
...
@@ -439,6 +445,36 @@ function hasActiveFilters(filters = {}) {
);
}
export
async
function
loadCaseCategoryGroups
()
{
const
response
=
await
aggregateRecords
({
subject
:
CASE_SUBJECT
,
stages
:
[
{
$group
:
{
_id
:
'$metadata.category'
,
},
},
],
});
const
rows
=
[
response
,
response
?.
data
,
response
?.
result
,
].
find
(
Array
.
isArray
)
||
[];
const
groups
=
rows
.
map
((
row
)
=>
String
(
row
?.
_id
??
''
).
trim
())
.
map
((
category
)
=>
category
||
UNCATEGORIZED_CATEGORY
);
return
[...
new
Set
(
groups
)]
.
sort
((
left
,
right
)
=>
{
if
(
left
===
UNCATEGORIZED_CATEGORY
)
return
-
1
;
if
(
right
===
UNCATEGORIZED_CATEGORY
)
return
1
;
return
left
.
localeCompare
(
right
,
'zh-Hans-CN'
);
})
.
map
((
category
)
=>
({
key
:
category
,
label
:
category
}));
}
export
function
normalizeCaseRecord
(
record
=
{})
{
const
metadata
=
record
?.
metadata
||
{};
const
label
=
String
(
metadata
.
label
||
record
.
title
||
''
).
trim
();
...
...
src/pages/project_manager_xy/resource-booking/index.jsx
View file @
ebd95398
...
...
@@ -58,7 +58,6 @@ import RiskCaseMaterialField from '../RiskCaseMaterialField';
import
{
createRiskCaseMaterial
}
from
'../riskCaseMaterialService'
;
import
{
loadRiskCaseViewResources
}
from
'../riskCaseViewService'
;
import
{
buildCaseDashboard
,
createCaseRecord
,
createCaseTitle
,
deleteCaseRecord
,
...
...
@@ -73,6 +72,7 @@ import {
previewCaseWorkbook
,
updateCaseRecord
,
updateCaseRecords
,
loadCaseCategoryGroups
,
}
from
'../materialService'
;
const
ALL_CATEGORY_KEY
=
'__all__'
;
...
...
@@ -516,10 +516,11 @@ export default function ResourceBookingPage() {
const
[
caseAttributeFilter
,
setCaseAttributeFilter
]
=
useState
(
''
);
const
[
activeCategory
,
setActiveCategory
]
=
useState
(
ALL_CATEGORY_KEY
);
const
[
categoryTabItems
,
setCategoryTabItems
]
=
useState
(
DEFAULT_CATEGORY_TABS
);
const
[
categoryTabLoading
,
setCategoryTabLoading
]
=
useState
(
true
);
const
[
categoryTabError
,
setCategoryTabError
]
=
useState
(
''
);
const
[
viewMode
,
setViewMode
]
=
useState
(
CARD_VIEW_MODE
);
const
[
showAdvancedFilters
,
setShowAdvancedFilters
]
=
useState
(
false
);
const
[
cases
,
setCases
]
=
useState
([]);
const
[
dashboard
,
setDashboard
]
=
useState
(()
=>
buildCaseDashboard
([]));
const
[
activeCaseId
,
setActiveCaseId
]
=
useState
(
''
);
const
[
selectedCaseIds
,
setSelectedCaseIds
]
=
useState
([]);
const
[
page
,
setPage
]
=
useState
(
0
);
...
...
@@ -560,6 +561,7 @@ export default function ResourceBookingPage() {
const
[
materialValidationErrors
,
setMaterialValidationErrors
]
=
useState
({});
const
pendingLocalMaterialsRef
=
useRef
({});
const
editSessionRef
=
useRef
(
0
);
const
categoryTabRequestRef
=
useRef
(
0
);
const
activeGridView
=
useMemo
(
()
=>
gridViews
.
find
((
view
)
=>
view
.
name
===
activeGridViewName
)
||
null
,
...
...
@@ -591,12 +593,8 @@ export default function ResourceBookingPage() {
);
const
categoryTabs
=
useMemo
(()
=>
{
return
mergeCategoryTabItems
(
categoryTabItems
,
dashboard
.
categoryGroups
||
[],
activeCategory
,
);
},
[
activeCategory
,
categoryTabItems
,
dashboard
.
categoryGroups
]);
return
mergeCategoryTabItems
(
categoryTabItems
,
[],
activeCategory
);
},
[
activeCategory
,
categoryTabItems
]);
const
visibleCases
=
useMemo
(()
=>
{
return
cases
;
...
...
@@ -704,6 +702,38 @@ export default function ResourceBookingPage() {
}
},
[
activeDetailViewName
,
visibleDetailViews
]);
async
function
loadCategoryTabItems
()
{
const
requestId
=
categoryTabRequestRef
.
current
+
1
;
categoryTabRequestRef
.
current
=
requestId
;
setCategoryTabLoading
(
true
);
setCategoryTabError
(
''
);
try
{
const
groups
=
await
loadCaseCategoryGroups
();
if
(
categoryTabRequestRef
.
current
!==
requestId
)
{
return
;
}
setCategoryTabItems
(
buildCategoryTabItems
(
groups
));
}
catch
(
loadError
)
{
if
(
categoryTabRequestRef
.
current
===
requestId
)
{
setCategoryTabError
(
loadError
?.
message
||
'案例分类加载失败,请稍后重试。'
,
);
}
}
finally
{
if
(
categoryTabRequestRef
.
current
===
requestId
)
{
setCategoryTabLoading
(
false
);
}
}
}
useEffect
(()
=>
{
loadCategoryTabItems
();
},
[]);
useEffect
(()
=>
{
setDetailForm
(
buildCaseForm
(
activeCase
));
},
[
activeCase
]);
...
...
@@ -727,18 +757,6 @@ export default function ResourceBookingPage() {
});
setCases
(
result
.
cases
||
[]);
setDashboard
(
buildCaseDashboard
(
result
.
cases
||
[],
{
totalCount
:
result
.
total
||
0
,
}),
);
setCategoryTabItems
((
currentTabs
)
=>
mergeCategoryTabItems
(
currentTabs
,
buildCaseDashboard
(
result
.
cases
||
[]).
categoryGroups
||
[],
activeCategory
,
),
);
setPage
(
nextPage
);
setTotal
(
result
.
total
||
0
);
setIsDemoMode
(
Boolean
(
result
.
isDemoMode
));
...
...
@@ -776,18 +794,6 @@ export default function ResourceBookingPage() {
}
setCases
(
result
.
cases
||
[]);
setDashboard
(
buildCaseDashboard
(
result
.
cases
||
[],
{
totalCount
:
result
.
total
||
0
,
}),
);
setCategoryTabItems
((
currentTabs
)
=>
mergeCategoryTabItems
(
currentTabs
,
buildCaseDashboard
(
result
.
cases
||
[]).
categoryGroups
||
[],
activeCategory
,
),
);
setPage
(
0
);
setTotal
(
result
.
total
||
0
);
setError
(
result
.
error
||
''
);
...
...
@@ -941,7 +947,7 @@ export default function ResourceBookingPage() {
setIsCreateOpen
(
false
);
setIsDetailOpen
(
false
);
resetCreateForm
();
await
refreshCurrentList
(
0
);
await
Promise
.
all
([
refreshCurrentList
(
0
),
loadCategoryTabItems
()]
);
setActiveCaseId
(
created
.
id
||
''
);
}
catch
(
createError
)
{
setError
(
createError
?.
message
||
'案例创建失败,请稍后重试。'
);
...
...
@@ -990,7 +996,7 @@ export default function ResourceBookingPage() {
}
const
updated
=
await
updateCaseRecord
(
activeCase
,
detailForm
);
setNotice
(
`案例“
${
updated
.
label
}
”已更新。`
);
await
refreshCurrentList
(
);
await
Promise
.
all
([
refreshCurrentList
(),
loadCategoryTabItems
()]
);
setActiveCaseId
(
updated
.
id
||
activeCase
.
id
);
setIsDetailEditing
(
false
);
pendingLocalMaterialsRef
.
current
=
{};
...
...
@@ -1016,7 +1022,7 @@ export default function ResourceBookingPage() {
setNotice
(
`案例“
${
activeCase
.
label
}
”已删除。`
);
setIsDeleteOpen
(
false
);
setIsDetailOpen
(
false
);
await
refreshCurrentList
(
);
await
Promise
.
all
([
refreshCurrentList
(),
loadCategoryTabItems
()]
);
}
catch
(
deleteError
)
{
setError
(
deleteError
?.
message
||
'案例删除失败,请稍后重试。'
);
}
finally
{
...
...
@@ -1041,7 +1047,7 @@ export default function ResourceBookingPage() {
}
setIsBatchDeleteOpen
(
false
);
await
refreshCurrentList
(
);
await
Promise
.
all
([
refreshCurrentList
(),
loadCategoryTabItems
()]
);
const
failedIds
=
result
.
failures
.
map
((
item
)
=>
item
.
id
)
...
...
@@ -1088,7 +1094,7 @@ export default function ResourceBookingPage() {
setIsBatchEditOpen(false);
resetBatchEditForm();
await
refreshCurrentList(
);
await
Promise.all([refreshCurrentList(), loadCategoryTabItems()]
);
const failedIds = result.failures
.map((item) => item.id)
...
...
@@ -1177,7 +1183,7 @@ export default function ResourceBookingPage() {
}
setIsImportOpen(false);
resetImportState();
await
refreshCurrentList(0
);
await
Promise.all([refreshCurrentList(0), loadCategoryTabItems()]
);
} catch (importError) {
setError(importError?.message || 'Excel 导入失败,请稍后重试。');
} finally {
...
...
@@ -1269,8 +1275,31 @@ export default function ResourceBookingPage() {
</Tabs>
) : null}
<div className="min-w-0 max-w-full">
{categoryTabLoading ? (
<div className="mb-2 flex items-center gap-2 text-sm text-muted" role="status">
<Spinner size="sm" />
正在加载案例分类…
</div>
) : null}
{categoryTabError ? (
<Alert className="mb-3" status="warning">
<Alert.Content>
<Alert.Description>{categoryTabError}</Alert.Description>
<Button
className="mt-2"
isDisabled={categoryTabLoading}
size="sm"
variant="secondary"
onPress={loadCategoryTabItems}
>
重试
</Button>
</Alert.Content>
</Alert>
) : null}
<Tabs
className="w-fit max-w-full min-w-0"
aria-busy={categoryTabLoading}
selectedKey={activeCategory}
variant="primary"
onSelectionChange={(key) => setActiveCategory(String(key))}
...
...
src/services/record-api.md
View file @
ebd95398
...
...
@@ -108,6 +108,43 @@
功能:获取单条详情
#### 3.1.3 POST `/record/aggregate`
权限:
`api.record.aggregate`
功能:对同一 subject 的 active 记录执行 Mongo aggregation。前端只用它做轻量分组统计,不返回记录列表。
请求体:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| subject | string | 是 | 主题名 |
| stages | object
[]
| 是 | Mongo aggregation pipeline |
示例:
```
json
{
"subject"
:
"risk_case_library"
,
"stages"
:
[
{
"$group"
:
{
"_id"
:
"$metadata.category"
}
}
]
}
```
响应为数组,每项包含分组键
`_id`
:
```
json
[
{
"_id"
:
"合规风险"
},
{
"_id"
:
"操作风险"
}
]
```
### 3.2 创建 / 更新
-
POST
`/record`
...
...
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