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
7b7c281e
Commit
7b7c281e
authored
Jun 08, 2026
by
David Yang
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
update: ui
parent
6f4521a3
Show whitespace changes
Inline
Side-by-side
Showing
11 changed files
with
696 additions
and
330 deletions
+696
-330
.umirc.ts
.umirc.ts
+2
-0
favicon.ico
public/favicon.ico
+0
-0
manifest.json
public/manifest.json
+8
-0
Layout.jsx
src/pages/project_manager_xy/Layout.jsx
+1
-1
audit-material-center.business-flow.md
...project_manager_xy/audit-material-center.business-flow.md
+35
-25
materialService.js
src/pages/project_manager_xy/materialService.js
+124
-22
index.jsx
src/pages/project_manager_xy/resource-booking/index.jsx
+229
-145
index.jsx
src/pages/project_manager_xy/resource-stat/index.jsx
+283
-126
audit-material-subject-build-checklist.md
src/services/audit-material-subject-build-checklist.md
+3
-3
record-api.md
src/services/record-api.md
+4
-2
subjectsDefinition.md
src/services/subjectsDefinition.md
+7
-6
No files found.
.umirc.ts
View file @
7b7c281e
...
@@ -14,6 +14,8 @@ const ROUTE_PATHS = {
...
@@ -14,6 +14,8 @@ const ROUTE_PATHS = {
export
default
defineConfig
({
export
default
defineConfig
({
layout
:
false
,
layout
:
false
,
fastRefresh
:
true
,
fastRefresh
:
true
,
base
:
'/risk_case_library/'
,
publicPath
:
'/risk_case_library/'
,
alias
:
{
alias
:
{
react
:
resolvePackageRoot
(
'react'
),
react
:
resolvePackageRoot
(
'react'
),
'react-dom'
:
resolvePackageRoot
(
'react-dom'
),
'react-dom'
:
resolvePackageRoot
(
'react-dom'
),
...
...
public/favicon.ico
0 → 100644
View file @
7b7c281e
15 KB
public/manifest.json
0 → 100644
View file @
7b7c281e
{
"name"
:
"project_manager_new"
,
"version"
:
"1.0.1"
,
"build"
:
"abc"
,
"description"
:
"merge master"
,
"date"
:
"2025-07-14 19:27:20"
,
"author"
:
"yang"
}
src/pages/project_manager_xy/Layout.jsx
View file @
7b7c281e
...
@@ -17,7 +17,7 @@ const menuGroups = [
...
@@ -17,7 +17,7 @@ const menuGroups = [
title
:
'案例库工作台'
,
title
:
'案例库工作台'
,
items
:
[
items
:
[
{
{
name
:
'
案例总览
'
,
name
:
'
驾驶舱
'
,
path
:
'/resource/stat'
,
path
:
'/resource/stat'
,
icon
:
Squares2X2Icon
,
icon
:
Squares2X2Icon
,
},
},
...
...
src/pages/project_manager_xy/audit-material-center.business-flow.md
View file @
7b7c281e
...
@@ -22,14 +22,15 @@
...
@@ -22,14 +22,15 @@
保留现有两级菜单框架,但替换业务语义:
保留现有两级菜单框架,但替换业务语义:
-
左侧菜单:
`
案例总览
`
-
左侧菜单:
`
驾驶舱
`
-
左侧菜单:
`案例管理`
-
左侧菜单:
`案例管理`
推荐职责划分:
推荐职责划分:
-
`案例总览`
-
`驾驶舱`
-
展示案例总数、案例性质分布、关键字热度、最近更新
-
展示案例总数、分类数量、标签数量、案例类型数量、待整理数量、最近 7 天更新
-
通过图表展示案例性质占比、案例年份分布、近 7 日更新活跃度
-
通过图表展示分类结构、案例类型排行、案例来源分布
-
展示关键字词云与最近更新案例
-
提供对库内内容密度和结构完整度的快速判断
-
提供对库内内容密度和结构完整度的快速判断
-
`案例管理`
-
`案例管理`
-
搜索、筛选、浏览案例卡片
-
搜索、筛选、浏览案例卡片
...
@@ -55,7 +56,7 @@
...
@@ -55,7 +56,7 @@
-
`case_attribute`
:案例属性
-
`case_attribute`
:案例属性
-
`case_details`
:案例详情正文
-
`case_details`
:案例详情正文
-
`case_warning`
:案例警示
-
`case_warning`
:案例警示
-
`keyword`
:关键字,用于检索和热度统计
-
`keyword`
:关键字,
多值字符串数组,
用于检索和热度统计
-
`case_source`
:案例来源
-
`case_source`
:案例来源
-
`case_level`
:案例等级
-
`case_level`
:案例等级
-
`case_year`
:案例年份
-
`case_year`
:案例年份
...
@@ -88,7 +89,7 @@
...
@@ -88,7 +89,7 @@
-
`案例属性`
→
`metadata.case_attribute`
-
`案例属性`
→
`metadata.case_attribute`
-
`案例详情`
→
`content`
、
`metadata.case_details`
-
`案例详情`
→
`content`
、
`metadata.case_details`
-
`案例警示`
→
`metadata.case_warning`
-
`案例警示`
→
`metadata.case_warning`
-
`关键字`
→
`metadata.keyword`
,同时回填
`metadata.tag`
以兼容现有检索
-
`关键字`
→
按英文逗号或中文逗号拆分、去空、去重后写入
`metadata.keyword`
字符串数组
,同时回填
`metadata.tag`
以兼容现有检索
-
`案例来源`
→
`metadata.case_source`
-
`案例来源`
→
`metadata.case_source`
-
`案例等级`
→
`metadata.case_level`
-
`案例等级`
→
`metadata.case_level`
-
`案例年份`
→
`metadata.case_year`
-
`案例年份`
→
`metadata.case_year`
...
@@ -114,6 +115,7 @@
...
@@ -114,6 +115,7 @@
-
`案例等级`
-
`案例等级`
-
`案例年份`
-
`案例年份`
5.
浏览器直接下载
`.xlsx`
文件
5.
浏览器直接下载
`.xlsx`
文件
6.
`metadata.keyword`
导出到
`关键字`
列时恢复为逗号拼接字符串,便于线下继续维护
### 4.4 浏览与检索案例
### 4.4 浏览与检索案例
...
@@ -124,14 +126,16 @@
...
@@ -124,14 +126,16 @@
5.
页面顶部提供
`Segment`
视图切换,可在“卡片视图 / 表格视图”之间切换
5.
页面顶部提供
`Segment`
视图切换,可在“卡片视图 / 表格视图”之间切换
6.
卡片视图用于浏览案例摘要,点击卡片后以弹框方式查看并编辑详情
6.
卡片视图用于浏览案例摘要,点击卡片后以弹框方式查看并编辑详情
7.
表格视图展示标题、分类、标签、案例类型、关键字、来源、等级、年份、更新时间等字段
7.
表格视图展示标题、分类、标签、案例类型、关键字、来源、等级、年份、更新时间等字段
8.
表格视图支持单选、全选当前已加载案例,并显示当前勾选数量
8.
表格视图按页加载案例,支持选择每页 20、50、100 条,并显示页码、当前页范围和匹配总数
9.
表格视图支持单选、全选当前页案例,并显示当前勾选数量
### 4.5 编辑案例
### 4.5 编辑案例
1.
用户在列表中打开某个案例详情
1.
用户在列表中打开某个案例详情
2.
修改标题、分类、标签、案例类型、案例属性、案例详情、案例警示、关键字、案例来源、案例等级、案例年份
2.
修改标题、分类、标签、案例类型、案例属性、案例详情、案例警示、关键字、案例来源、案例等级、案例年份
3.
前端调用
`PUT /record/:name`
3.
关键字编辑区支持逗号输入,并实时预览为关键字标签;保存时拆分为
`metadata.keyword`
字符串数组
4.
保存成功后刷新列表与统计数据
4.
前端调用
`PUT /record/:name`
5.
保存成功后刷新列表与统计数据
### 4.6 批量修改案例
### 4.6 批量修改案例
...
@@ -176,40 +180,44 @@
...
@@ -176,40 +180,44 @@
-
未识别到的列忽略,不中断导入
-
未识别到的列忽略,不中断导入
-
空行直接跳过
-
空行直接跳过
-
`标题`
为空时,优先使用
`案例类型`
,再回退为
`案例详情`
摘要自动生成
`title`
-
`标题`
为空时,优先使用
`案例类型`
,再回退为
`案例详情`
摘要自动生成
`title`
-
所有文本字段统一按字符串保存
-
普通文本字段统一按字符串保存
-
`关键字`
是多值字段,导入时将逗号拼接文本拆分为字符串数组保存
-
`案例年份`
优先按数字写入,不可解析时保留原字符串
-
`案例年份`
优先按数字写入,不可解析时保留原字符串
-
导出模板固定为 11 列标准表头,便于线下统一维护
-
导出模板固定为 11 列标准表头,便于线下统一维护
### 5.3 筛选规则
### 5.3 筛选规则
-
案例性质筛选使用
`metadata.category`
-
案例性质筛选使用
`metadata.category`
-
关键字筛选优先使用
`metadata.keyword`
,同时兼容历史
`metadata.tag`
-
关键字筛选优先使用
多值数组
`metadata.keyword`
,同时兼容历史字符串型
`metadata.keyword`
与
`metadata.tag`
-
关键词检索同时覆盖
`title`
、
`metadata.label`
、
`metadata.case_type`
、
`metadata.case_attribute`
、
`metadata.case_details`
、
`metadata.case_warning`
、
`metadata.keyword`
、
`metadata.case_source`
、
`metadata.case_level`
、
`metadata.category`
、
`metadata.tag`
-
关键词检索同时覆盖
`title`
、
`metadata.label`
、
`metadata.case_type`
、
`metadata.case_attribute`
、
`metadata.case_details`
、
`metadata.case_warning`
、
`metadata.keyword`
、
`metadata.case_source`
、
`metadata.case_level`
、
`metadata.category`
、
`metadata.tag`
---
---
## 6. 页面与交互要求
## 6. 页面与交互要求
### 6.1
案例总览
### 6.1
驾驶舱
建议至少展示:
建议至少展示:
-
案例总数
-
案例总数
-
分类数量
-
标签数量
-
案例类型数量
-
最近 7 天更新数
-
最近 7 天更新数
-
案例性质分布
-
分类结构饼图
-
关键字热度
-
案例类型排行柱状图
-
案例
年份分布趋势
图
-
案例
来源分布横向柱状
图
-
近 7 日更新活跃度图
-
关键字词云
-
最近更新案例
-
最近更新案例
-
重点长文本案例或待整理案例,待整理规则关注缺少案例性质、关键字、案例详情或详情偏短
-
数据洞察卡片,例如分类最集中主题、最高频关键字、结构完整度
-
数据洞察卡片,例如分类最集中主题、最高频关键字、结构完整度
图表口径说明:
图表口径说明:
-
案例性质分布:按
`metadata.category`
聚合
-
分类结构:按
`metadata.category`
聚合,缺失时归入
`未分类`
-
关键字热度:按
`metadata.keyword`
,缺失时兼容
`metadata.tag`
-
案例类型排行:按
`metadata.case_type`
聚合,缺失时归入
`未填写类型`
-
案例年份分布:按
`metadata.case_year`
聚合
-
案例来源分布:按
`metadata.case_source`
聚合,缺失时归入
`未填写来源`
-
近 7 日更新活跃度:按
`updated_at`
汇总最近 7 天更新次数
-
关键字词云:按
`metadata.keyword`
聚合并按热度调整字号,缺失时兼容
`metadata.tag`
-
最近 7 天更新:按
`updated_at`
统计近 7 天更新的案例数量
### 6.2 案例管理
### 6.2 案例管理
...
@@ -243,6 +251,7 @@
...
@@ -243,6 +251,7 @@
-
案例详情全文
-
案例详情全文
-
案例性质
-
案例性质
-
关键字
-
关键字
-
关键字以标签形式展示;编辑时保留逗号输入并即时预览拆分后的标签
-
案例警示
-
案例警示
-
案例来源 / 案例等级 / 案例年份
-
案例来源 / 案例等级 / 案例年份
-
案例详情字数
-
案例详情字数
...
@@ -274,10 +283,11 @@
...
@@ -274,10 +283,11 @@
## 8. 分页与性能建议
## 8. 分页与性能建议
-
首屏只加载一页,建议每页 24 条
-
首屏只加载第一页,默认每页 20 条
-
列表滚动到底部时自动加载下一页
-
表格与卡片列表通过页码分页加载,不使用下滑自动加载
-
用户可选择每页 20、50、100 条;切换每页数量时从第一页重新加载
-
当用户切换分类或搜索条件时,从第一页重新加载
-
当用户切换分类或搜索条件时,从第一页重新加载
-
总数
通过
`count`
接口单独获取
-
总数
优先读取
`POST /record/query`
返回的
`total_records`
;接口未返回总数时再回退到
`count`
接口
---
---
...
@@ -297,4 +307,4 @@
...
@@ -297,4 +307,4 @@
1.
`risk_case_library`
1.
`risk_case_library`
当前不拆分独立关键字表、案例性质表或附件表。
当前不拆分独立关键字表、案例性质表或附件表。
案例性质
与关键字先通过文本字段
实现,后续如有需要再演进。
案例性质
先通过文本字段实现,关键字通过多值文本数组
实现,后续如有需要再演进。
src/pages/project_manager_xy/materialService.js
View file @
7b7c281e
...
@@ -9,7 +9,8 @@ import dayjs from 'dayjs';
...
@@ -9,7 +9,8 @@ import dayjs from 'dayjs';
import
*
as
XLSX
from
'xlsx'
;
import
*
as
XLSX
from
'xlsx'
;
export
const
CASE_SUBJECT
=
'risk_case_library'
;
export
const
CASE_SUBJECT
=
'risk_case_library'
;
export
const
DEFAULT_PAGE_SIZE
=
24
;
export
const
CASE_PAGE_SIZE_OPTIONS
=
[
20
,
50
,
100
];
export
const
DEFAULT_PAGE_SIZE
=
20
;
const
CASE_EXPORT_HEADERS
=
[
const
CASE_EXPORT_HEADERS
=
[
'标题'
,
'标题'
,
...
@@ -44,7 +45,7 @@ const DEMO_CASES = [
...
@@ -44,7 +45,7 @@ const DEMO_CASES = [
case_details
:
case_details
:
'某项目在高峰采购期内,多次通过拆分订单绕过审批阈值,导致同一供应商在短期内累计发生异常采购金额。复盘发现,审批节点设置与例外授权规则未同步更新,最终形成流程漏洞。'
,
'某项目在高峰采购期内,多次通过拆分订单绕过审批阈值,导致同一供应商在短期内累计发生异常采购金额。复盘发现,审批节点设置与例外授权规则未同步更新,最终形成流程漏洞。'
,
case_warning
:
'涉及审批阈值、例外授权和供应商集中度的场景应设置联合预警。'
,
case_warning
:
'涉及审批阈值、例外授权和供应商集中度的场景应设置联合预警。'
,
keyword
:
'采购审批, 拆单'
,
keyword
:
[
'采购审批'
,
'拆单'
]
,
case_source
:
'内部审计复盘'
,
case_source
:
'内部审计复盘'
,
case_level
:
'高'
,
case_level
:
'高'
,
case_year
:
2024
,
case_year
:
2024
,
...
@@ -68,7 +69,7 @@ const DEMO_CASES = [
...
@@ -68,7 +69,7 @@ const DEMO_CASES = [
case_details
:
case_details
:
'部门负责人以“紧急签约”为由临时借出印章,未在台账中完整登记用途和归还时间,后续合同版本与审批留痕不一致。最终在复盘中确认,印章借用、复核与回收链条均缺少双人校验。'
,
'部门负责人以“紧急签约”为由临时借出印章,未在台账中完整登记用途和归还时间,后续合同版本与审批留痕不一致。最终在复盘中确认,印章借用、复核与回收链条均缺少双人校验。'
,
case_warning
:
'印章借用、回收与留痕链路必须双人复核并形成闭环。'
,
case_warning
:
'印章借用、回收与留痕链路必须双人复核并形成闭环。'
,
keyword
:
'印章, 台账, 紧急签约'
,
keyword
:
[
'印章'
,
'台账'
,
'紧急签约'
]
,
case_source
:
'合规检查复盘'
,
case_source
:
'合规检查复盘'
,
case_level
:
'中'
,
case_level
:
'中'
,
case_year
:
2025
,
case_year
:
2025
,
...
@@ -92,7 +93,7 @@ const DEMO_CASES = [
...
@@ -92,7 +93,7 @@ const DEMO_CASES = [
case_details
:
case_details
:
'财务负责人通过虚构往来科目和短期借款名义转移资金,月末再以内部调账方式冲回,导致多轮抽样测试均未及时发现异常。案件暴露出资金支付复核与异常波动分析两道防线同时失效。'
,
'财务负责人通过虚构往来科目和短期借款名义转移资金,月末再以内部调账方式冲回,导致多轮抽样测试均未及时发现异常。案件暴露出资金支付复核与异常波动分析两道防线同时失效。'
,
case_warning
:
'资金支付复核应与月末异常波动分析交叉验证。'
,
case_warning
:
'资金支付复核应与月末异常波动分析交叉验证。'
,
keyword
:
'资金管理, 异常调账'
,
keyword
:
[
'资金管理'
,
'异常调账'
]
,
case_source
:
'内部通报'
,
case_source
:
'内部通报'
,
case_level
:
'高'
,
case_level
:
'高'
,
case_year
:
2025
,
case_year
:
2025
,
...
@@ -116,7 +117,7 @@ const DEMO_CASES = [
...
@@ -116,7 +117,7 @@ const DEMO_CASES = [
case_details
:
case_details
:
'项目团队为赶进度直接沿用历史供应商名单,新签供应商未重新完成资质核验,导致营业执照、关联关系与受益所有人信息长期缺失。后续排查时已无法还原当时的准入审批依据。'
,
'项目团队为赶进度直接沿用历史供应商名单,新签供应商未重新完成资质核验,导致营业执照、关联关系与受益所有人信息长期缺失。后续排查时已无法还原当时的准入审批依据。'
,
case_warning
:
'供应商准入资料缺失会直接削弱后续尽调和责任追溯能力。'
,
case_warning
:
'供应商准入资料缺失会直接削弱后续尽调和责任追溯能力。'
,
keyword
:
'供应商, 尽调, 资质核验'
,
keyword
:
[
'供应商'
,
'尽调'
,
'资质核验'
]
,
case_source
:
'专项排查'
,
case_source
:
'专项排查'
,
case_level
:
'中'
,
case_level
:
'中'
,
case_year
:
2024
,
case_year
:
2024
,
...
@@ -140,7 +141,7 @@ const DEMO_CASES = [
...
@@ -140,7 +141,7 @@ const DEMO_CASES = [
case_details
:
case_details
:
'运维团队在夜间批量发布期间共享高权限账号密码,虽然缩短了操作路径,但审计日志无法区分责任人。问题暴露出特权账号借用、审批、回收与日志关联机制均不完整。'
,
'运维团队在夜间批量发布期间共享高权限账号密码,虽然缩短了操作路径,但审计日志无法区分责任人。问题暴露出特权账号借用、审批、回收与日志关联机制均不完整。'
,
case_warning
:
'高权限账号必须做到实名借用、短时授权和日志可追溯。'
,
case_warning
:
'高权限账号必须做到实名借用、短时授权和日志可追溯。'
,
keyword
:
'特权账号, 权限控制, 审计日志'
,
keyword
:
[
'特权账号'
,
'权限控制'
,
'审计日志'
]
,
case_source
:
'安全审计'
,
case_source
:
'安全审计'
,
case_level
:
'高'
,
case_level
:
'高'
,
case_year
:
2023
,
case_year
:
2023
,
...
@@ -192,6 +193,9 @@ function extractCount(response) {
...
@@ -192,6 +193,9 @@ function extractCount(response) {
response
,
response
,
response
?.
data
,
response
?.
data
,
response
?.
result
,
response
?.
result
,
response
?.
total_records
,
response
?.
data
?.
total_records
,
response
?.
result
?.
total_records
,
response
?.
count
,
response
?.
count
,
response
?.
data
?.
count
,
response
?.
data
?.
count
,
response
?.
data
?.
total
,
response
?.
data
?.
total
,
...
@@ -209,6 +213,29 @@ function extractCount(response) {
...
@@ -209,6 +213,29 @@ function extractCount(response) {
return
0
;
return
0
;
}
}
function
extractTotalRecords
(
response
)
{
const
candidates
=
[
response
?.
total_records
,
response
?.
data
?.
total_records
,
response
?.
result
?.
total_records
,
response
?.
total
,
response
?.
data
?.
total
,
response
?.
result
?.
total
,
response
?.
count
,
response
?.
data
?.
count
,
response
?.
result
?.
count
,
];
for
(
const
candidate
of
candidates
)
{
const
numericCandidate
=
Number
(
candidate
);
if
(
Number
.
isFinite
(
numericCandidate
))
{
return
numericCandidate
;
}
}
return
null
;
}
function
normalizeHeaderKey
(
value
=
''
)
{
function
normalizeHeaderKey
(
value
=
''
)
{
return
String
(
value
)
return
String
(
value
)
.
trim
()
.
trim
()
...
@@ -252,6 +279,30 @@ function createSummary(content = '', maxLength = 140) {
...
@@ -252,6 +279,30 @@ function createSummary(content = '', maxLength = 140) {
return
`
${
normalized
.
slice
(
0
,
maxLength
).
trim
()}
...`
;
return
`
${
normalized
.
slice
(
0
,
maxLength
).
trim
()}
...`
;
}
}
export
function
normalizeKeywordList
(
value
)
{
const
sourceValues
=
(
Array
.
isArray
(
value
)
?
value
:
[
value
]).
flatMap
((
item
)
=>
String
(
item
||
''
).
split
(
/
[
,,
]
/
),
);
const
seenKeywords
=
new
Set
();
return
sourceValues
.
reduce
((
keywords
,
item
)
=>
{
const
keyword
=
String
(
item
||
''
).
trim
();
const
dedupeKey
=
keyword
.
toLowerCase
();
if
(
!
keyword
||
seenKeywords
.
has
(
dedupeKey
))
{
return
keywords
;
}
seenKeywords
.
add
(
dedupeKey
);
keywords
.
push
(
keyword
);
return
keywords
;
},
[]);
}
export
function
formatKeywordList
(
value
)
{
return
normalizeKeywordList
(
value
).
join
(
', '
);
}
export
function
formatDateTime
(
value
)
{
export
function
formatDateTime
(
value
)
{
if
(
!
value
)
{
if
(
!
value
)
{
return
'-'
;
return
'-'
;
...
@@ -365,7 +416,8 @@ export function normalizeCaseRecord(record = {}) {
...
@@ -365,7 +416,8 @@ export function normalizeCaseRecord(record = {}) {
const
caseAttribute
=
String
(
metadata
.
case_attribute
||
''
).
trim
();
const
caseAttribute
=
String
(
metadata
.
case_attribute
||
''
).
trim
();
const
caseDetails
=
String
(
metadata
.
case_details
||
metadata
.
content
||
record
.
content
||
''
).
trim
();
const
caseDetails
=
String
(
metadata
.
case_details
||
metadata
.
content
||
record
.
content
||
''
).
trim
();
const
caseWarning
=
String
(
metadata
.
case_warning
||
''
).
trim
();
const
caseWarning
=
String
(
metadata
.
case_warning
||
''
).
trim
();
const
keyword
=
String
(
metadata
.
keyword
||
''
).
trim
();
const
keywordList
=
normalizeKeywordList
(
metadata
.
keyword
);
const
keyword
=
formatKeywordList
(
keywordList
);
const
caseSource
=
String
(
metadata
.
case_source
||
''
).
trim
();
const
caseSource
=
String
(
metadata
.
case_source
||
''
).
trim
();
const
caseLevel
=
String
(
metadata
.
case_level
||
''
).
trim
();
const
caseLevel
=
String
(
metadata
.
case_level
||
''
).
trim
();
const
category
=
String
(
metadata
.
category
||
''
).
trim
();
const
category
=
String
(
metadata
.
category
||
''
).
trim
();
...
@@ -389,6 +441,7 @@ export function normalizeCaseRecord(record = {}) {
...
@@ -389,6 +441,7 @@ export function normalizeCaseRecord(record = {}) {
caseDetails
,
caseDetails
,
caseWarning
,
caseWarning
,
keyword
,
keyword
,
keywordList
,
caseSource
,
caseSource
,
caseLevel
,
caseLevel
,
caseYear
,
caseYear
,
...
@@ -467,6 +520,28 @@ export function buildCaseDashboard(cases = [], options = {}) {
...
@@ -467,6 +520,28 @@ export function buildCaseDashboard(cases = [], options = {}) {
(
item
)
=>
item
.
tag
,
(
item
)
=>
item
.
tag
,
(
item
)
=>
item
.
tag
,
(
item
)
=>
item
.
tag
,
);
);
const
keywordGroups
=
buildGroup
(
cases
.
flatMap
((
item
)
=>
{
const
keywords
=
normalizeKeywordList
(
item
.
keywordList
||
item
.
keyword
);
const
fallbackKeywords
=
keywords
.
length
>
0
?
keywords
:
normalizeKeywordList
(
item
.
tag
);
return
fallbackKeywords
.
map
((
keywordLabel
)
=>
({
keywordLabel
,
}));
}),
(
item
)
=>
item
.
keywordLabel
,
(
item
)
=>
item
.
keywordLabel
,
);
const
caseTypeGroups
=
buildGroup
(
cases
,
(
item
)
=>
item
.
caseType
||
'未填写类型'
,
(
item
)
=>
item
.
caseType
||
'未填写类型'
,
);
const
caseSourceGroups
=
buildGroup
(
cases
,
(
item
)
=>
item
.
caseSource
||
'未填写来源'
,
(
item
)
=>
item
.
caseSource
||
'未填写来源'
,
);
const
recentCases
=
[...
cases
]
const
recentCases
=
[...
cases
]
.
sort
((
left
,
right
)
=>
.
sort
((
left
,
right
)
=>
String
(
right
.
updatedAt
||
right
.
createdAt
).
localeCompare
(
String
(
right
.
updatedAt
||
right
.
createdAt
).
localeCompare
(
...
@@ -474,9 +549,6 @@ export function buildCaseDashboard(cases = [], options = {}) {
...
@@ -474,9 +549,6 @@ export function buildCaseDashboard(cases = [], options = {}) {
),
),
)
)
.
slice
(
0
,
6
);
.
slice
(
0
,
6
);
const
longCases
=
[...
cases
]
.
sort
((
left
,
right
)
=>
Number
(
right
.
textLength
||
0
)
-
Number
(
left
.
textLength
||
0
))
.
slice
(
0
,
5
);
const
attentionCases
=
cases
const
attentionCases
=
cases
.
filter
(
.
filter
(
(
item
)
=>
(
item
)
=>
...
@@ -495,15 +567,17 @@ export function buildCaseDashboard(cases = [], options = {}) {
...
@@ -495,15 +567,17 @@ export function buildCaseDashboard(cases = [], options = {}) {
uncategorizedCount
:
cases
.
filter
((
item
)
=>
item
.
category
===
'未分类'
).
length
,
uncategorizedCount
:
cases
.
filter
((
item
)
=>
item
.
category
===
'未分类'
).
length
,
categoryGroups
,
categoryGroups
,
tagGroups
,
tagGroups
,
keywordGroups
,
caseTypeGroups
,
caseSourceGroups
,
recentCases
,
recentCases
,
longCases
,
attentionCases
,
attentionCases
,
categorySuggestions
:
categoryGroups
.
map
((
item
)
=>
item
.
label
),
categorySuggestions
:
categoryGroups
.
map
((
item
)
=>
item
.
label
),
tagSuggestions
:
tagGroups
.
map
((
item
)
=>
item
.
label
),
tagSuggestions
:
tagGroups
.
map
((
item
)
=>
item
.
label
),
};
};
}
}
async
function
queryCaseRecord
s
(
filters
=
{})
{
async
function
queryCaseRecord
Page
(
filters
=
{})
{
const
response
=
await
queryRecords
({
const
response
=
await
queryRecords
({
subject
:
CASE_SUBJECT
,
subject
:
CASE_SUBJECT
,
page
:
Number
(
filters
.
page
||
0
),
page
:
Number
(
filters
.
page
||
0
),
...
@@ -512,7 +586,12 @@ async function queryCaseRecords(filters = {}) {
...
@@ -512,7 +586,12 @@ async function queryCaseRecords(filters = {}) {
sort
:
'-updated_at -created_at'
,
sort
:
'-updated_at -created_at'
,
});
});
return
extractArray
(
response
).
map
((
item
)
=>
normalizeCaseRecord
(
item
));
const
records
=
extractArray
(
response
);
return
{
cases
:
records
.
map
((
item
)
=>
normalizeCaseRecord
(
item
)),
total
:
extractTotalRecords
(
response
),
};
}
}
export
async
function
loadCasePage
(
filters
=
{})
{
export
async
function
loadCasePage
(
filters
=
{})
{
...
@@ -521,19 +600,22 @@ export async function loadCasePage(filters = {}) {
...
@@ -521,19 +600,22 @@ export async function loadCasePage(filters = {}) {
const
recordFilter
=
buildRecordFilter
(
filters
);
const
recordFilter
=
buildRecordFilter
(
filters
);
try
{
try
{
const
[
cases
,
totalResponse
]
=
await
Promise
.
all
([
const
pageResult
=
await
queryCaseRecordPage
({
queryCaseRecords
({
...
filters
,
...
filters
,
page
,
page
,
pageSize
,
pageSize
,
}),
});
countRecords
({
const
total
=
Number
.
isFinite
(
pageResult
.
total
)
?
pageResult
.
total
:
extractCount
(
await
countRecords
({
subject
:
CASE_SUBJECT
,
subject
:
CASE_SUBJECT
,
filter
:
recordFilter
,
filter
:
recordFilter
,
}),
}),
]);
);
const
cases
=
pageResult
.
cases
;
const
total
=
extractCount
(
totalResponse
);
const
shouldUseDemo
=
const
shouldUseDemo
=
page
===
0
&&
!
hasActiveFilters
(
filters
)
&&
total
===
0
&&
cases
.
length
===
0
;
page
===
0
&&
!
hasActiveFilters
(
filters
)
&&
total
===
0
&&
cases
.
length
===
0
;
...
@@ -567,6 +649,19 @@ export async function loadCasePage(filters = {}) {
...
@@ -567,6 +649,19 @@ export async function loadCasePage(filters = {}) {
error
:
''
,
error
:
''
,
};
};
}
catch
(
error
)
{
}
catch
(
error
)
{
let
total
=
0
;
try
{
total
=
extractCount
(
await
countRecords
({
subject
:
CASE_SUBJECT
,
filter
:
recordFilter
,
}),
);
}
catch
(
_countError
)
{
total
=
0
;
}
const
demoCases
=
applyClientFilters
(
const
demoCases
=
applyClientFilters
(
DEMO_CASES
.
map
((
item
)
=>
normalizeCaseRecord
(
item
)),
DEMO_CASES
.
map
((
item
)
=>
normalizeCaseRecord
(
item
)),
filters
,
filters
,
...
@@ -611,7 +706,7 @@ export function buildCasePayload(values = {}) {
...
@@ -611,7 +706,7 @@ export function buildCasePayload(values = {}) {
const
caseAttribute
=
String
(
values
.
caseAttribute
||
values
.
case_attribute
||
''
).
trim
();
const
caseAttribute
=
String
(
values
.
caseAttribute
||
values
.
case_attribute
||
''
).
trim
();
const
caseDetails
=
String
(
values
.
caseDetails
||
values
.
case_details
||
values
.
content
||
''
).
trim
();
const
caseDetails
=
String
(
values
.
caseDetails
||
values
.
case_details
||
values
.
content
||
''
).
trim
();
const
caseWarning
=
String
(
values
.
caseWarning
||
values
.
case_warning
||
''
).
trim
();
const
caseWarning
=
String
(
values
.
caseWarning
||
values
.
case_warning
||
''
).
trim
();
const
keyword
=
String
(
values
.
keyword
||
''
).
trim
(
);
const
keyword
=
normalizeKeywordList
(
values
.
keyword
);
const
caseSource
=
String
(
values
.
caseSource
||
values
.
case_source
||
''
).
trim
();
const
caseSource
=
String
(
values
.
caseSource
||
values
.
case_source
||
''
).
trim
();
const
caseLevel
=
String
(
values
.
caseLevel
||
values
.
case_level
||
''
).
trim
();
const
caseLevel
=
String
(
values
.
caseLevel
||
values
.
case_level
||
''
).
trim
();
const
category
=
String
(
values
.
category
||
''
).
trim
();
const
category
=
String
(
values
.
category
||
''
).
trim
();
...
@@ -749,7 +844,6 @@ export async function updateCaseRecords(caseItems = [], values = {}) {
...
@@ -749,7 +844,6 @@ export async function updateCaseRecords(caseItems = [], values = {}) {
[
'case_attribute'
,
values
.
caseAttribute
??
values
.
case_attribute
],
[
'case_attribute'
,
values
.
caseAttribute
??
values
.
case_attribute
],
[
'case_details'
,
values
.
caseDetails
??
values
.
case_details
],
[
'case_details'
,
values
.
caseDetails
??
values
.
case_details
],
[
'case_warning'
,
values
.
caseWarning
??
values
.
case_warning
],
[
'case_warning'
,
values
.
caseWarning
??
values
.
case_warning
],
[
'keyword'
,
values
.
keyword
],
[
'case_source'
,
values
.
caseSource
??
values
.
case_source
],
[
'case_source'
,
values
.
caseSource
??
values
.
case_source
],
[
'case_level'
,
values
.
caseLevel
??
values
.
case_level
],
[
'case_level'
,
values
.
caseLevel
??
values
.
case_level
],
];
];
...
@@ -762,6 +856,14 @@ export async function updateCaseRecords(caseItems = [], values = {}) {
...
@@ -762,6 +856,14 @@ export async function updateCaseRecords(caseItems = [], values = {}) {
}
}
});
});
if
(
values
.
keyword
!==
undefined
&&
values
.
keyword
!==
null
)
{
const
keywordPatch
=
normalizeKeywordList
(
values
.
keyword
);
if
(
keywordPatch
.
length
>
0
)
{
metadataPatch
.
keyword
=
keywordPatch
;
}
}
const
caseYearRaw
=
values
.
caseYear
??
values
.
case_year
;
const
caseYearRaw
=
values
.
caseYear
??
values
.
case_year
;
const
normalizedCaseYear
=
String
(
caseYearRaw
??
''
).
trim
();
const
normalizedCaseYear
=
String
(
caseYearRaw
??
''
).
trim
();
if
(
if
(
...
@@ -995,7 +1097,7 @@ function buildExportRow(caseItem = {}) {
...
@@ -995,7 +1097,7 @@ function buildExportRow(caseItem = {}) {
案例属性
:
caseItem
.
caseAttribute
||
''
,
案例属性
:
caseItem
.
caseAttribute
||
''
,
案例详情
:
caseItem
.
caseDetails
||
caseItem
.
content
||
''
,
案例详情
:
caseItem
.
caseDetails
||
caseItem
.
content
||
''
,
案例警示
:
caseItem
.
caseWarning
||
''
,
案例警示
:
caseItem
.
caseWarning
||
''
,
关键字
:
caseItem
.
keyword
||
''
,
关键字
:
formatKeywordList
(
caseItem
.
keywordList
||
caseItem
.
keyword
||
''
)
,
案例来源
:
caseItem
.
caseSource
||
''
,
案例来源
:
caseItem
.
caseSource
||
''
,
案例等级
:
caseItem
.
caseLevel
||
''
,
案例等级
:
caseItem
.
caseLevel
||
''
,
案例年份
:
caseItem
.
caseYear
||
''
,
案例年份
:
caseItem
.
caseYear
||
''
,
...
...
src/pages/project_manager_xy/resource-booking/index.jsx
View file @
7b7c281e
...
@@ -7,10 +7,16 @@ import {
...
@@ -7,10 +7,16 @@ import {
DialogTitle
,
DialogTitle
,
}
from
'@/catalyst-ui-kit/javascript/dialog'
;
}
from
'@/catalyst-ui-kit/javascript/dialog'
;
import
{
Input
,
InputGroup
}
from
'@/catalyst-ui-kit/javascript/input'
;
import
{
Input
,
InputGroup
}
from
'@/catalyst-ui-kit/javascript/input'
;
import
{
Pagination
,
PaginationGap
,
PaginationList
,
}
from
'@/catalyst-ui-kit/javascript/pagination'
;
import
{
import
{
Segment
,
Segment
,
SegmentOption
,
SegmentOption
,
}
from
'@/catalyst-ui-kit/javascript/segment'
;
}
from
'@/catalyst-ui-kit/javascript/segment'
;
import
{
Select
}
from
'@/catalyst-ui-kit/javascript/select'
;
import
{
import
{
Table
,
Table
,
TableBody
,
TableBody
,
...
@@ -44,6 +50,7 @@ import {
...
@@ -44,6 +50,7 @@ import {
import
EmptyStatePage
from
'../EmptyStatePage'
;
import
EmptyStatePage
from
'../EmptyStatePage'
;
import
{
import
{
buildCaseDashboard
,
buildCaseDashboard
,
CASE_PAGE_SIZE_OPTIONS
,
createCaseRecord
,
createCaseRecord
,
createCaseTitle
,
createCaseTitle
,
deleteCaseRecord
,
deleteCaseRecord
,
...
@@ -54,6 +61,7 @@ import {
...
@@ -54,6 +61,7 @@ import {
exportSelectedCaseWorkbook
,
exportSelectedCaseWorkbook
,
importCaseWorkbook
,
importCaseWorkbook
,
loadCasePage
,
loadCasePage
,
normalizeKeywordList
,
previewCaseWorkbook
,
previewCaseWorkbook
,
updateCaseRecord
,
updateCaseRecord
,
updateCaseRecords
,
updateCaseRecords
,
...
@@ -65,6 +73,47 @@ const TABLE_VIEW_MODE = 'table';
...
@@ -65,6 +73,47 @@ const TABLE_VIEW_MODE = 'table';
const
STICKY_TABLE_HEADER_CLASS_NAME
=
const
STICKY_TABLE_HEADER_CLASS_NAME
=
'sticky top-0 z-10 bg-white/95 backdrop-blur supports-[backdrop-filter]:bg-white/85'
;
'sticky top-0 z-10 bg-white/95 backdrop-blur supports-[backdrop-filter]:bg-white/85'
;
function
buildPaginationItems
(
currentPage
,
totalPages
)
{
if
(
totalPages
<=
7
)
{
return
Array
.
from
({
length
:
totalPages
},
(
_item
,
index
)
=>
index
);
}
const
pages
=
new
Set
([
0
,
totalPages
-
1
,
currentPage
]);
if
(
currentPage
>
0
)
{
pages
.
add
(
currentPage
-
1
);
}
if
(
currentPage
<
totalPages
-
1
)
{
pages
.
add
(
currentPage
+
1
);
}
if
(
currentPage
<=
2
)
{
pages
.
add
(
1
);
pages
.
add
(
2
);
pages
.
add
(
3
);
}
if
(
currentPage
>=
totalPages
-
3
)
{
pages
.
add
(
totalPages
-
2
);
pages
.
add
(
totalPages
-
3
);
pages
.
add
(
totalPages
-
4
);
}
const
sortedPages
=
[...
pages
]
.
filter
((
item
)
=>
item
>=
0
&&
item
<
totalPages
)
.
sort
((
left
,
right
)
=>
left
-
right
);
return
sortedPages
.
reduce
((
items
,
item
,
index
)
=>
{
if
(
index
>
0
&&
item
-
sortedPages
[
index
-
1
]
>
1
)
{
items
.
push
(
`gap-
${
sortedPages
[
index
-
1
]}
-
${
item
}
`
);
}
items
.
push
(
item
);
return
items
;
},
[]);
}
function
Panel
({
className
,
children
})
{
function
Panel
({
className
,
children
})
{
return
(
return
(
<
section
<
section
...
@@ -102,6 +151,35 @@ function Badge({ children, tone = 'default' }) {
...
@@ -102,6 +151,35 @@ function Badge({ children, tone = 'default' }) {
);
);
}
}
function
KeywordChips
({
value
,
emptyText
=
'暂无关键字'
,
limit
=
0
})
{
const
keywords
=
normalizeKeywordList
(
value
);
const
visibleKeywords
=
limit
>
0
?
keywords
.
slice
(
0
,
limit
)
:
keywords
;
const
remainingCount
=
limit
>
0
?
Math
.
max
(
keywords
.
length
-
limit
,
0
)
:
0
;
if
(
keywords
.
length
===
0
)
{
return
<
span
className=
"text-sm text-zinc-400"
>
{
emptyText
}
</
span
>;
}
return
(
<
div
className=
"flex flex-wrap gap-2"
>
{
visibleKeywords
.
map
((
keyword
)
=>
(
<
span
key=
{
keyword
}
className=
"inline-flex max-w-full items-center rounded-full border border-[color:color-mix(in_srgb,var(--pm-brand)_18%,white)] bg-[color:color-mix(in_srgb,var(--pm-brand)_7%,white)] px-2.5 py-1 text-xs font-medium text-[var(--pm-brand-strong)] shadow-sm"
title=
{
keyword
}
>
<
span
className=
"truncate"
>
{
keyword
}
</
span
>
</
span
>
))
}
{
remainingCount
>
0
?
(
<
span
className=
"inline-flex items-center rounded-full border border-zinc-200 bg-zinc-50 px-2.5 py-1 text-xs font-medium text-zinc-500"
>
+
{
remainingCount
}
</
span
>
)
:
null
}
</
div
>
);
}
function
CategoryTab
({
active
,
label
,
count
,
onClick
})
{
function
CategoryTab
({
active
,
label
,
count
,
onClick
})
{
return
(
return
(
<
button
<
button
...
@@ -249,7 +327,6 @@ function buildImportPreviewRows(preview) {
...
@@ -249,7 +327,6 @@ function buildImportPreviewRows(preview) {
}
}
export
default
function
ResourceBookingPage
()
{
export
default
function
ResourceBookingPage
()
{
const
loadMoreRef
=
useRef
(
null
);
const
importFileInputRef
=
useRef
(
null
);
const
importFileInputRef
=
useRef
(
null
);
const
[
query
,
setQuery
]
=
useState
(
''
);
const
[
query
,
setQuery
]
=
useState
(
''
);
const
deferredQuery
=
useDeferredValue
(
query
);
const
deferredQuery
=
useDeferredValue
(
query
);
...
@@ -261,10 +338,9 @@ export default function ResourceBookingPage() {
...
@@ -261,10 +338,9 @@ export default function ResourceBookingPage() {
const
[
activeCaseId
,
setActiveCaseId
]
=
useState
(
''
);
const
[
activeCaseId
,
setActiveCaseId
]
=
useState
(
''
);
const
[
selectedCaseIds
,
setSelectedCaseIds
]
=
useState
([]);
const
[
selectedCaseIds
,
setSelectedCaseIds
]
=
useState
([]);
const
[
page
,
setPage
]
=
useState
(
0
);
const
[
page
,
setPage
]
=
useState
(
0
);
const
[
pageSize
,
setPageSize
]
=
useState
(
DEFAULT_PAGE_SIZE
);
const
[
total
,
setTotal
]
=
useState
(
0
);
const
[
total
,
setTotal
]
=
useState
(
0
);
const
[
hasMore
,
setHasMore
]
=
useState
(
false
);
const
[
loading
,
setLoading
]
=
useState
(
true
);
const
[
loading
,
setLoading
]
=
useState
(
true
);
const
[
loadingMore
,
setLoadingMore
]
=
useState
(
false
);
const
[
saving
,
setSaving
]
=
useState
(
false
);
const
[
saving
,
setSaving
]
=
useState
(
false
);
const
[
creating
,
setCreating
]
=
useState
(
false
);
const
[
creating
,
setCreating
]
=
useState
(
false
);
const
[
deleting
,
setDeleting
]
=
useState
(
false
);
const
[
deleting
,
setDeleting
]
=
useState
(
false
);
...
@@ -337,6 +413,13 @@ export default function ResourceBookingPage() {
...
@@ -337,6 +413,13 @@ export default function ResourceBookingPage() {
()
=>
buildImportPreviewRows
(
importPreview
),
()
=>
buildImportPreviewRows
(
importPreview
),
[
importPreview
],
[
importPreview
],
);
);
const
totalPages
=
Math
.
max
(
1
,
Math
.
ceil
(
total
/
pageSize
));
const
paginationItems
=
useMemo
(
()
=>
buildPaginationItems
(
page
,
totalPages
),
[
page
,
totalPages
],
);
const
pageStart
=
total
===
0
?
0
:
page
*
pageSize
+
1
;
const
pageEnd
=
Math
.
min
(
total
,
page
*
pageSize
+
visibleCases
.
length
);
useEffect
(()
=>
{
useEffect
(()
=>
{
setDetailForm
(
buildCaseForm
(
activeCase
));
setDetailForm
(
buildCaseForm
(
activeCase
));
...
@@ -359,34 +442,28 @@ export default function ResourceBookingPage() {
...
@@ -359,34 +442,28 @@ export default function ResourceBookingPage() {
);
);
},
[
visibleCases
]);
},
[
visibleCases
]);
useEffect
(()
=>
{
async
function
loadCaseList
(
nextPage
=
0
,
nextPageSize
=
pageSize
)
{
let
cancelled
=
false
;
async
function
runFirstPage
()
{
setLoading
(
true
);
setLoading
(
true
);
setError
(
''
);
const
result
=
await
loadCasePage
({
const
result
=
await
loadCasePage
({
keyword
:
deferredQuery
,
keyword
:
deferredQuery
,
category
:
activeCategory
===
ALL_CATEGORY_KEY
?
''
:
activeCategory
,
category
:
activeCategory
===
ALL_CATEGORY_KEY
?
''
:
activeCategory
,
tag
:
tagFilter
,
tag
:
tagFilter
,
page
:
0
,
page
:
nextPage
,
pageSize
:
DEFAULT_PAGE_SIZE
,
pageSize
:
nextPageSize
,
});
});
if
(
cancelled
)
{
return
;
}
setCases
(
result
.
cases
||
[]);
setCases
(
result
.
cases
||
[]);
setDashboard
(
setDashboard
(
buildCaseDashboard
(
result
.
cases
||
[],
{
buildCaseDashboard
(
result
.
cases
||
[],
{
totalCount
:
result
.
total
||
0
,
totalCount
:
result
.
total
||
0
,
}),
}),
);
);
setPage
(
0
);
setPage
(
nextPage
);
setTotal
(
result
.
total
||
0
);
setTotal
(
result
.
total
||
0
);
setHasMore
(
Boolean
(
result
.
hasMore
));
setError
(
result
.
error
||
''
);
setIsDemoMode
(
Boolean
(
result
.
isDemoMode
));
setIsDemoMode
(
Boolean
(
result
.
isDemoMode
));
setError
(
result
.
error
||
''
);
setLoading
(
false
);
setLoading
(
false
);
startTransition
(()
=>
{
startTransition
(()
=>
{
...
@@ -398,24 +475,29 @@ export default function ResourceBookingPage() {
...
@@ -398,24 +475,29 @@ export default function ResourceBookingPage() {
return
result
.
cases
?.[
0
]?.
id
||
''
;
return
result
.
cases
?.[
0
]?.
id
||
''
;
});
});
});
});
return
result
;
}
}
runFirstPage
();
useEffect
(()
=>
{
let
cancelled
=
false
;
return
()
=>
{
async
function
runFirstPage
()
{
cancelled
=
true
;
setLoading
(
true
);
};
setError
(
''
);
},
[
activeCategory
,
deferredQuery
,
tagFilter
]);
async
function
refreshCurrentList
()
{
const
result
=
await
loadCasePage
({
const
result
=
await
loadCasePage
({
keyword
:
deferredQuery
,
keyword
:
deferredQuery
,
category
:
activeCategory
===
ALL_CATEGORY_KEY
?
''
:
activeCategory
,
category
:
activeCategory
===
ALL_CATEGORY_KEY
?
''
:
activeCategory
,
tag
:
tagFilter
,
tag
:
tagFilter
,
page
:
0
,
page
:
0
,
pageSize
:
DEFAULT_PAGE_SIZE
,
pageSize
,
});
});
if
(
cancelled
)
{
return
;
}
setCases
(
result
.
cases
||
[]);
setCases
(
result
.
cases
||
[]);
setDashboard
(
setDashboard
(
buildCaseDashboard
(
result
.
cases
||
[],
{
buildCaseDashboard
(
result
.
cases
||
[],
{
...
@@ -424,86 +506,57 @@ export default function ResourceBookingPage() {
...
@@ -424,86 +506,57 @@ export default function ResourceBookingPage() {
);
);
setPage
(
0
);
setPage
(
0
);
setTotal
(
result
.
total
||
0
);
setTotal
(
result
.
total
||
0
);
setHasMore
(
Boolean
(
result
.
hasMore
));
setIsDemoMode
(
Boolean
(
result
.
isDemoMode
));
setError
(
result
.
error
||
''
);
setError
(
result
.
error
||
''
);
}
setIsDemoMode
(
Boolean
(
result
.
isDemoMode
));
setLoading
(
false
);
async
function
handleLoadMore
()
{
startTransition
(()
=>
{
if
(
loadingMore
||
!
hasMore
)
{
setActiveCaseId
((
current
)
=>
{
return
;
if
((
result
.
cases
||
[]).
some
((
item
)
=>
item
.
id
===
current
))
{
return
current
;
}
}
setLoadingMore
(
true
);
return
result
.
cases
?.[
0
]?.
id
||
''
;
try
{
const
nextPage
=
page
+
1
;
const
result
=
await
loadCasePage
({
keyword
:
deferredQuery
,
category
:
activeCategory
===
ALL_CATEGORY_KEY
?
''
:
activeCategory
,
tag
:
tagFilter
,
page
:
nextPage
,
pageSize
:
DEFAULT_PAGE_SIZE
,
});
});
});
}
setCases
((
prev
)
=>
{
runFirstPage
();
const
existingIds
=
new
Set
(
prev
.
map
((
item
)
=>
item
.
id
));
const
merged
=
[
...
prev
,
...(
result
.
cases
||
[]).
filter
((
item
)
=>
!
existingIds
.
has
(
item
.
id
)),
];
setDashboard
(
return
()
=>
{
buildCaseDashboard
(
merged
,
{
cancelled
=
true
;
totalCount
:
result
.
total
||
merged
.
length
,
};
}),
},
[
activeCategory
,
deferredQuery
,
pageSize
,
tagFilter
]);
);
return
merged
;
async
function
refreshCurrentList
(
preferredPage
=
page
)
{
});
const
totalPages
=
Math
.
max
(
1
,
Math
.
ceil
(
total
/
pageSize
));
setPage
(
nextPage
);
const
safePage
=
Math
.
min
(
Math
.
max
(
Number
(
preferredPage
)
||
0
,
0
),
totalPages
-
1
);
setTotal
(
result
.
total
||
total
);
const
result
=
await
loadCaseList
(
safePage
,
pageSize
);
setHasMore
(
Boolean
(
result
.
hasMore
));
setIsDemoMode
(
Boolean
(
result
.
isDemoMode
));
if
((
result
.
cases
||
[]).
length
===
0
&&
result
.
total
>
0
&&
safePage
>
0
)
{
if
(
result
.
error
)
{
await
loadCaseList
(
safePage
-
1
,
pageSize
);
setError
(
result
.
error
);
}
}
catch
(
loadMoreError
)
{
setError
(
loadMoreError
?.
message
||
'加载更多案例失败,请稍后重试。'
);
}
finally
{
setLoadingMore
(
false
);
}
}
}
}
useEffect
(()
=>
{
function
handlePageChange
(
nextPage
)
{
const
node
=
loadMoreRef
.
current
;
if
(
nextPage
===
page
||
nextPage
<
0
)
{
return
;
if
(
!
node
||
!
hasMore
||
loading
||
loadingMore
)
{
return
undefined
;
}
}
const
observer
=
new
IntersectionObserver
(
const
totalPages
=
Math
.
max
(
1
,
Math
.
ceil
(
total
/
pageSize
));
(
entries
)
=>
{
if
(
nextPage
>=
totalPages
)
{
const
[
entry
]
=
entries
;
if
(
!
entry
?.
isIntersecting
)
{
return
;
return
;
}
}
handleLoadMore
();
loadCaseList
(
nextPage
,
pageSize
);
},
}
{
root
:
null
,
rootMargin
:
'280px 0px'
,
threshold
:
0.1
,
},
);
observer
.
observe
(
node
);
return
()
=>
{
function
handlePageSizeChange
(
event
)
{
observer
.
disconnect
();
const
nextPageSize
=
Number
(
event
.
target
.
value
)
||
DEFAULT_PAGE_SIZE
;
};
setPageSize
(
nextPageSize
);
},
[
hasMore
,
loading
,
loadingMore
,
page
,
deferredQuery
,
activeCategory
,
tagFilter
]);
setPage
(
0
);
setSelectedCaseIds
([]);
}
function
resetCreateForm
()
{
function
resetCreateForm
()
{
setCreateForm
(
buildEmptyCaseForm
());
setCreateForm
(
buildEmptyCaseForm
());
...
@@ -567,7 +620,7 @@ export default function ResourceBookingPage() {
...
@@ -567,7 +620,7 @@ export default function ResourceBookingPage() {
setIsCreateOpen
(
false
);
setIsCreateOpen
(
false
);
setIsDetailOpen
(
false
);
setIsDetailOpen
(
false
);
resetCreateForm
();
resetCreateForm
();
await
refreshCurrentList
();
await
refreshCurrentList
(
0
);
setActiveCaseId
(
created
.
id
||
''
);
setActiveCaseId
(
created
.
id
||
''
);
}
catch
(
createError
)
{
}
catch
(
createError
)
{
setError
(
createError
?.
message
||
'案例创建失败,请稍后重试。'
);
setError
(
createError
?.
message
||
'案例创建失败,请稍后重试。'
);
...
@@ -781,7 +834,7 @@ export default function ResourceBookingPage() {
...
@@ -781,7 +834,7 @@ export default function ResourceBookingPage() {
}
}
setIsImportOpen(false);
setIsImportOpen(false);
resetImportState();
resetImportState();
await refreshCurrentList();
await refreshCurrentList(
0
);
} catch (importError) {
} catch (importError) {
setError(importError?.message || 'Excel 导入失败,请稍后重试。');
setError(importError?.message || 'Excel 导入失败,请稍后重试。');
} finally {
} finally {
...
@@ -976,14 +1029,9 @@ export default function ResourceBookingPage() {
...
@@ -976,14 +1029,9 @@ export default function ResourceBookingPage() {
))}
))}
</div>
</div>
<div className="mt-5 grid gap-3 sm:grid-cols-
3
">
<div className="mt-5 grid gap-3 sm:grid-cols-
2
">
<MetricCard label="匹配案例数" value={total} />
<MetricCard label="匹配案例数" value={total} />
<MetricCard label="最近 7 天更新" value={dashboard.recentCount} />
<MetricCard label="最近 7 天更新" value={dashboard.recentCount} />
<MetricCard
label="平均篇幅"
value={dashboard.averageTextLength || 0}
hint={dashboard.averageTextLengthLabel}
/>
</div>
</div>
</Panel>
</Panel>
...
@@ -1204,7 +1252,11 @@ export default function ResourceBookingPage() {
...
@@ -1204,7 +1252,11 @@ export default function ResourceBookingPage() {
) : null}
) : null}
</TableCell>
</TableCell>
<TableCell className="max-w-52 whitespace-normal text-zinc-600">
<TableCell className="max-w-52 whitespace-normal text-zinc-600">
{item.keyword || '-'}
<KeywordChips
value={item.keywordList || item.keyword}
emptyText="-"
limit={3}
/>
</TableCell>
</TableCell>
<TableCell className="whitespace-normal text-zinc-600">
<TableCell className="whitespace-normal text-zinc-600">
{item.caseSource || '-'}
{item.caseSource || '-'}
...
@@ -1236,24 +1288,74 @@ export default function ResourceBookingPage() {
...
@@ -1236,24 +1288,74 @@ export default function ResourceBookingPage() {
)}
)}
</Panel>
</Panel>
<div
<div className="flex flex-col gap-4 rounded-[24px] border border-zinc-200 bg-white/80 px-4 py-4 shadow-sm sm:flex-row sm:items-center sm:justify-between">
ref={loadMoreRef}
className="flex min-h-24 flex-col items-center justify-center gap-3 rounded-[24px] border border-dashed border-zinc-200 bg-white/70 px-4 py-5"
>
<div className="text-sm text-zinc-500">
<div className="text-sm text-zinc-500">
当前已加载 {cases.length} 条,匹配总数 {total} 条。
显示第 {pageStart}-{pageEnd} 条,共 {total} 条
{hasMore
</div>
? loadingMore
? ' 正在自动加载下一页...'
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
: ' 继续向下滚动会自动加载更多。'
<label className="flex items-center gap-2 text-sm text-zinc-500">
: ' 已全部加载完成。'}
<span className="shrink-0">每页</span>
</div>
<Select
{loadingMore ? (
className="w-24"
<div className="text-sm font-medium text-[var(--pm-brand)]">正在加载更多...</div>
value={pageSize}
) : null}
onChange={handlePageSizeChange}
{!hasMore ? (
aria-label="选择每页案例数量"
<div className="text-sm font-medium text-emerald-700">已全部加载</div>
>
) : null}
{CASE_PAGE_SIZE_OPTIONS.map((option) => (
<option key={option} value={option}>
{option} 条
</option>
))}
</Select>
</label>
<Pagination aria-label="案例分页" className="items-center">
<span className="grow basis-0">
<Button
plain
aria-label="上一页"
onClick={() => handlePageChange(page - 1)}
disabled={page === 0 || loading}
>
上一页
</Button>
</span>
<PaginationList>
{paginationItems.map((item) =>
typeof item === 'string' ? (
<PaginationGap key={item} />
) : (
<Button
plain
key={item}
aria-current={item === page ? 'page' : undefined}
aria-label={`
第
$
{
item
+
1
}
页
`}
className={clsx(
'min-w-9',
item === page &&
'bg-zinc-950/5 text-[var(--pm-brand)]',
)}
onClick={() => handlePageChange(item)}
disabled={loading}
>
{item + 1}
</Button>
),
)}
</PaginationList>
<span className="flex grow basis-0 justify-end">
<Button
plain
aria-label="下一页"
onClick={() => handlePageChange(page + 1)}
disabled={page >= totalPages - 1 || loading}
>
下一页
</Button>
</span>
</Pagination>
</div>
</div>
</div>
</div>
</div>
)}
)}
...
@@ -1458,34 +1560,6 @@ export default function ResourceBookingPage() {
...
@@ -1458,34 +1560,6 @@ export default function ResourceBookingPage() {
<div className="space-y-6">
<div className="space-y-6">
<div className="grid gap-6 xl:grid-cols-[0.8fr_1.2fr]">
<div className="grid gap-6 xl:grid-cols-[0.8fr_1.2fr]">
<div className="space-y-4">
<div className="space-y-4">
<div className="rounded-[26px] border border-zinc-200 bg-[linear-gradient(145deg,rgba(255,255,255,0.96),rgba(255,241,242,0.9))] p-5">
<div className="flex flex-wrap items-center gap-2">
<Badge tone="sky">{activeCase.category}</Badge>
{activeCase.caseLevel ? <Badge tone="amber">{activeCase.caseLevel}</Badge> : null}
{activeCase.tag ? <Badge tone="emerald">{activeCase.tag}</Badge> : null}
</div>
<div className="mt-4 text-2xl font-semibold tracking-tight text-zinc-950">
{activeCase.label}
</div>
{activeCase.caseType ? (
<div className="mt-3 text-sm font-medium text-zinc-500">
{activeCase.caseType}
{activeCase.caseAttribute
? `
/
$
{
activeCase
.
caseAttribute
}
`
: ''}
</div>
) : null}
<div className="mt-4 text-sm leading-7 text-zinc-600">
{activeCase.contentPreview}
</div>
{activeCase.caseWarning ? (
<div className="mt-4 rounded-[20px] border border-amber-200 bg-amber-50/80 px-4 py-3 text-sm leading-6 text-amber-900">
<div className="font-medium">案例警示</div>
<div className="mt-1">{activeCase.caseWarning}</div>
</div>
) : null}
</div>
<div className="grid gap-3 rounded-[24px] border border-zinc-200 bg-zinc-50/80 p-4">
<div className="grid gap-3 rounded-[24px] border border-zinc-200 bg-zinc-50/80 p-4">
<div>
<div>
<div className="text-xs font-semibold tracking-[0.18em] text-zinc-500">
<div className="text-xs font-semibold tracking-[0.18em] text-zinc-500">
...
@@ -1499,11 +1573,12 @@ export default function ResourceBookingPage() {
...
@@ -1499,11 +1573,12 @@ export default function ResourceBookingPage() {
<div className="text-xs font-semibold tracking-[0.18em] text-zinc-500">
<div className="text-xs font-semibold tracking-[0.18em] text-zinc-500">
标签与关键字
标签与关键字
</div>
</div>
<div className="mt-2 text-sm text-zinc-700">
<div className="mt-3 flex flex-wrap gap-2">
标签:{activeCase.tag || '-'}
{activeCase.tag ? <Badge tone="emerald">{activeCase.tag}</Badge> : null}
</div>
<KeywordChips
<div className="mt-1 text-sm text-zinc-500">
value={activeCase.keywordList || activeCase.keyword}
关键字:{activeCase.keyword || '-'}
emptyText="暂无关键字"
/>
</div>
</div>
</div>
</div>
<div>
<div>
...
@@ -1614,6 +1689,7 @@ export default function ResourceBookingPage() {
...
@@ -1614,6 +1689,7 @@ export default function ResourceBookingPage() {
</div>
</div>
<div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">关键字</div>
<div className="mb-2 text-sm font-medium text-zinc-600">关键字</div>
<div className="rounded-[22px] border border-zinc-200 bg-white p-3 shadow-sm">
<Input
<Input
value={detailForm.keyword}
value={detailForm.keyword}
onChange={(event) =>
onChange={(event) =>
...
@@ -1622,8 +1698,16 @@ export default function ResourceBookingPage() {
...
@@ -1622,8 +1698,16 @@ export default function ResourceBookingPage() {
keyword: event.target.value,
keyword: event.target.value,
}))
}))
}
}
placeholder="例如:采购审批, 拆单"
/>
<div className="mt-3 min-h-8">
<KeywordChips
value={detailForm.keyword}
emptyText="暂无关键字"
/>
/>
</div>
</div>
</div>
</div>
<div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">案例来源</div>
<div className="mb-2 text-sm font-medium text-zinc-600">案例来源</div>
<Input
<Input
...
...
src/pages/project_manager_xy/resource-stat/index.jsx
View file @
7b7c281e
import
{
import
{
ArrowTrendingUpIcon
,
ArrowTrendingUpIcon
,
BookOpen
Icon
,
ChartPie
Icon
,
DocumentTextIcon
,
DocumentTextIcon
,
RectangleStackIcon
,
RectangleStackIcon
,
TagIcon
,
TagIcon
,
}
from
'@heroicons/react/24/outline'
;
}
from
'@heroicons/react/24/outline'
;
import
clsx
from
'clsx'
;
import
clsx
from
'clsx'
;
import
ReactECharts
from
'echarts-for-react'
;
import
{
useEffect
,
useState
}
from
'react'
;
import
{
useEffect
,
useState
}
from
'react'
;
import
{
import
{
buildCaseDashboard
,
buildCaseDashboard
,
...
@@ -68,22 +69,190 @@ function SectionHeader({ title }) {
...
@@ -68,22 +69,190 @@ function SectionHeader({ title }) {
);
);
}
}
function
DistributionRow
({
label
,
value
,
total
,
accent
=
'bg-sky-500'
})
{
const
CHART_COLORS
=
[
const
percentage
=
total
>
0
?
Math
.
max
(
8
,
Math
.
round
((
value
/
total
)
*
100
))
:
0
;
'#0ea5e9'
,
'#10b981'
,
'#f59e0b'
,
'#6366f1'
,
'#ef4444'
,
'#14b8a6'
,
];
function
getPieOption
(
groups
=
[])
{
return
{
color
:
CHART_COLORS
,
tooltip
:
{
trigger
:
'item'
,
formatter
:
'{b}<br/>{c} 条 · {d}%'
,
},
legend
:
{
bottom
:
0
,
icon
:
'circle'
,
itemWidth
:
8
,
itemHeight
:
8
,
textStyle
:
{
color
:
'#52525b'
,
fontSize
:
12
,
},
},
series
:
[
{
name
:
'分类'
,
type
:
'pie'
,
radius
:
[
'46%'
,
'70%'
],
center
:
[
'50%'
,
'42%'
],
avoidLabelOverlap
:
true
,
label
:
{
color
:
'#3f3f46'
,
formatter
:
'{b}
\
n{c} 条'
,
lineHeight
:
18
,
},
labelLine
:
{
length
:
10
,
length2
:
8
,
},
itemStyle
:
{
borderColor
:
'#ffffff'
,
borderWidth
:
3
,
},
data
:
groups
.
map
((
item
)
=>
({
name
:
item
.
label
,
value
:
item
.
count
,
})),
},
],
};
}
function
getBarOption
(
groups
=
[],
{
horizontal
=
false
}
=
{})
{
const
visibleGroups
=
groups
.
slice
(
0
,
8
);
const
labels
=
visibleGroups
.
map
((
item
)
=>
item
.
label
);
const
values
=
visibleGroups
.
map
((
item
)
=>
item
.
count
);
return
{
color
:
CHART_COLORS
,
tooltip
:
{
trigger
:
'axis'
,
axisPointer
:
{
type
:
'shadow'
,
},
formatter
:
(
params
=
[])
=>
{
const
item
=
params
[
0
];
return
item
?
`
${
item
.
name
}
<br/>
${
item
.
value
}
条`
:
''
;
},
},
grid
:
{
top
:
18
,
right
:
14
,
bottom
:
horizontal
?
18
:
48
,
left
:
horizontal
?
84
:
36
,
containLabel
:
true
,
},
xAxis
:
horizontal
?
{
type
:
'value'
,
splitLine
:
{
lineStyle
:
{
color
:
'#e4e4e7'
,
type
:
'dashed'
,
},
},
axisLabel
:
{
color
:
'#71717a'
,
},
}
:
{
type
:
'category'
,
data
:
labels
,
axisTick
:
{
show
:
false
,
},
axisLine
:
{
lineStyle
:
{
color
:
'#e4e4e7'
,
},
},
axisLabel
:
{
color
:
'#71717a'
,
interval
:
0
,
rotate
:
labels
.
some
((
label
)
=>
label
.
length
>
5
)
?
28
:
0
,
},
},
yAxis
:
horizontal
?
{
type
:
'category'
,
data
:
labels
,
axisTick
:
{
show
:
false
,
},
axisLine
:
{
show
:
false
,
},
axisLabel
:
{
color
:
'#52525b'
,
width
:
74
,
overflow
:
'truncate'
,
},
}
:
{
type
:
'value'
,
splitLine
:
{
lineStyle
:
{
color
:
'#e4e4e7'
,
type
:
'dashed'
,
},
},
axisLabel
:
{
color
:
'#71717a'
,
},
},
series
:
[
{
type
:
'bar'
,
data
:
values
,
barMaxWidth
:
horizontal
?
18
:
34
,
itemStyle
:
{
borderRadius
:
horizontal
?
[
0
,
8
,
8
,
0
]
:
[
8
,
8
,
0
,
0
],
color
:
{
type
:
'linear'
,
x
:
0
,
y
:
0
,
x2
:
horizontal
?
1
:
0
,
y2
:
horizontal
?
0
:
1
,
colorStops
:
[
{
offset
:
0
,
color
:
'#0ea5e9'
},
{
offset
:
1
,
color
:
'#10b981'
},
],
},
},
},
],
};
}
function
ChartPanel
({
title
,
groups
,
type
=
'bar'
})
{
const
hasData
=
groups
.
length
>
0
;
const
option
=
type
===
'pie'
?
getPieOption
(
groups
)
:
getBarOption
(
groups
,
{
horizontal
:
type
===
'horizontalBar'
});
return
(
return
(
<
div
>
<
Panel
className=
"p-6"
>
<
div
className=
"mb-2 flex items-center justify-between text-sm text-zinc-600"
>
<
SectionHeader
title=
{
title
}
/>
<
span
>
{
label
}
</
span
>
{
hasData
?
(
<
span
>
{
value
}
条
</
span
>
<
ReactECharts
</
div
>
option=
{
option
}
<
div
className=
"h-2.5 rounded-full bg-zinc-100"
>
notMerge
<
div
lazyUpdate
className=
{
clsx
(
'h-2.5 rounded-full'
,
accent
)
}
style=
{
{
height
:
type
===
'pie'
?
320
:
300
,
width
:
'100%'
}
}
style=
{
{
width
:
`${percentage}%`
}
}
/>
/>
)
:
(
<
div
className=
"flex h-[300px] items-center justify-center rounded-[22px] border border-dashed border-zinc-200 bg-zinc-50 text-sm text-zinc-400"
>
暂无可展示的数据
</
div
>
</
div
>
</
div
>
)
}
</
Panel
>
);
);
}
}
...
@@ -107,6 +276,87 @@ function Pill({ children, tone = 'blue' }) {
...
@@ -107,6 +276,87 @@ function Pill({ children, tone = 'blue' }) {
);
);
}
}
const
WORD_CLOUD_TONES
=
[
{
className
:
'border-sky-200 bg-sky-50 text-sky-700 shadow-[0_12px_28px_rgba(14,165,233,0.14)]'
,
countClassName
:
'bg-sky-100 text-sky-700'
,
},
{
className
:
'border-emerald-200 bg-emerald-50 text-emerald-700 shadow-[0_12px_28px_rgba(16,185,129,0.14)]'
,
countClassName
:
'bg-emerald-100 text-emerald-700'
,
},
{
className
:
'border-amber-200 bg-amber-50 text-amber-700 shadow-[0_12px_28px_rgba(245,158,11,0.14)]'
,
countClassName
:
'bg-amber-100 text-amber-700'
,
},
{
className
:
'border-indigo-200 bg-indigo-50 text-indigo-700 shadow-[0_12px_28px_rgba(99,102,241,0.14)]'
,
countClassName
:
'bg-indigo-100 text-indigo-700'
,
},
{
className
:
'border-rose-200 bg-rose-50 text-rose-700 shadow-[0_12px_28px_rgba(244,63,94,0.14)]'
,
countClassName
:
'bg-rose-100 text-rose-700'
,
},
];
function
KeywordCloud
({
groups
=
[]
})
{
const
visibleGroups
=
groups
.
slice
(
0
,
24
);
const
maxCount
=
Math
.
max
(...
visibleGroups
.
map
((
item
)
=>
item
.
count
),
1
);
const
minCount
=
Math
.
min
(...
visibleGroups
.
map
((
item
)
=>
item
.
count
),
maxCount
);
const
range
=
Math
.
max
(
maxCount
-
minCount
,
1
);
if
(
visibleGroups
.
length
===
0
)
{
return
(
<
div
className=
"flex min-h-[300px] items-center justify-center rounded-[24px] border border-dashed border-zinc-200 bg-zinc-50 text-sm text-zinc-400"
>
当前还没有可展示的关键字
</
div
>
);
}
return
(
<
div
className=
"flex min-h-[300px] content-center items-center justify-center rounded-[28px] border border-zinc-200 bg-[linear-gradient(145deg,rgba(255,255,255,0.98),rgba(240,249,255,0.78),rgba(236,253,245,0.7))] px-5 py-6"
>
<
div
className=
"flex flex-wrap items-center justify-center gap-3"
>
{
visibleGroups
.
map
((
item
,
index
)
=>
{
const
heat
=
(
item
.
count
-
minCount
)
/
range
;
const
fontSize
=
Math
.
round
(
13
+
heat
*
15
+
(
index
===
0
?
4
:
0
));
const
tone
=
WORD_CLOUD_TONES
[
index
%
WORD_CLOUD_TONES
.
length
];
return
(
<
span
key=
{
item
.
key
}
className=
{
clsx
(
'inline-flex max-w-full items-center gap-2 rounded-full border px-3.5 py-2 font-semibold transition duration-200 hover:-translate-y-0.5 hover:shadow-lg'
,
tone
.
className
,
)
}
style=
{
{
fontSize
,
lineHeight
:
1.1
,
}
}
title=
{
`${item.label} · ${item.count} 条`
}
aria
-
label=
{
`${item.label},${item.count} 条`
}
>
<
span
className=
"truncate"
>
{
item
.
label
}
</
span
>
<
span
className=
{
clsx
(
'inline-flex h-5 min-w-5 items-center justify-center rounded-full px-1.5 text-[11px] font-semibold'
,
tone
.
countClassName
,
)
}
>
{
item
.
count
}
</
span
>
</
span
>
);
})
}
</
div
>
</
div
>
);
}
export
default
function
ResourceStatPage
()
{
export
default
function
ResourceStatPage
()
{
const
[
cases
,
setCases
]
=
useState
([]);
const
[
cases
,
setCases
]
=
useState
([]);
const
[
dashboard
,
setDashboard
]
=
useState
(()
=>
buildCaseDashboard
([]));
const
[
dashboard
,
setDashboard
]
=
useState
(()
=>
buildCaseDashboard
([]));
...
@@ -145,22 +395,11 @@ export default function ResourceStatPage() {
...
@@ -145,22 +395,11 @@ export default function ResourceStatPage() {
<
div
className=
"mt-5 flex flex-col gap-6 xl:flex-row xl:items-end xl:justify-between"
>
<
div
className=
"mt-5 flex flex-col gap-6 xl:flex-row xl:items-end xl:justify-between"
>
<
div
className=
"max-w-3xl"
>
<
div
className=
"max-w-3xl"
>
<
h1
className=
"text-3xl font-semibold tracking-tight text-zinc-950 sm:text-4xl"
>
<
h1
className=
"text-3xl font-semibold tracking-tight text-zinc-950 sm:text-4xl"
>
案例总览
驾驶舱
</
h1
>
</
h1
>
</
div
>
</
div
>
<
div
className=
"grid gap-3 sm:grid-cols-2 xl:w-[26rem]"
>
<
div
className=
"grid gap-3 sm:w-56"
>
<
div
className=
"rounded-[24px] border border-white/70 bg-white/80 p-4 shadow-sm"
>
<
div
className=
"text-xs font-semibold tracking-[0.18em] text-zinc-500"
>
平均篇幅
</
div
>
<
div
className=
"mt-2 text-2xl font-semibold text-zinc-950"
>
{
dashboard
.
averageTextLength
||
0
}
</
div
>
<
div
className=
"mt-1 text-sm text-zinc-500"
>
{
dashboard
.
averageTextLengthLabel
}
</
div
>
</
div
>
<
div
className=
"rounded-[24px] border border-white/70 bg-white/80 p-4 shadow-sm"
>
<
div
className=
"rounded-[24px] border border-white/70 bg-white/80 p-4 shadow-sm"
>
<
div
className=
"text-xs font-semibold tracking-[0.18em] text-zinc-500"
>
<
div
className=
"text-xs font-semibold tracking-[0.18em] text-zinc-500"
>
最近 7 天更新
最近 7 天更新
...
@@ -187,7 +426,7 @@ export default function ResourceStatPage() {
...
@@ -187,7 +426,7 @@ export default function ResourceStatPage() {
{
loading
?
(
{
loading
?
(
<
div
className=
"mt-6 rounded-2xl border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-700"
>
<
div
className=
"mt-6 rounded-2xl border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-700"
>
正在加载
案例总览
...
正在加载
驾驶舱
...
</
div
>
</
div
>
)
:
null
}
)
:
null
}
</
div
>
</
div
>
...
@@ -212,6 +451,12 @@ export default function ResourceStatPage() {
...
@@ -212,6 +451,12 @@ export default function ResourceStatPage() {
value=
{
dashboard
.
uniqueTagCount
}
value=
{
dashboard
.
uniqueTagCount
}
tone=
"amber"
tone=
"amber"
/>
/>
<
MetricCard
icon=
{
ChartPieIcon
}
label=
"案例类型"
value=
{
dashboard
.
caseTypeGroups
.
length
}
tone=
"blue"
/>
<
MetricCard
<
MetricCard
icon=
{
ArrowTrendingUpIcon
}
icon=
{
ArrowTrendingUpIcon
}
label=
"待整理"
label=
"待整理"
...
@@ -220,52 +465,23 @@ export default function ResourceStatPage() {
...
@@ -220,52 +465,23 @@ export default function ResourceStatPage() {
/>
/>
</
div
>
</
div
>
<
div
className=
"grid gap-6 xl:grid-cols-[1.05fr_0.95fr]"
>
<
div
className=
"grid gap-6 xl:grid-cols-[0.95fr_1.05fr]"
>
<
Panel
className=
"p-6"
>
<
ChartPanel
title=
"分类结构"
groups=
{
dashboard
.
categoryGroups
}
type=
"pie"
/>
<
SectionHeader
title=
"分类分布"
/>
<
ChartPanel
title=
"案例类型排行"
groups=
{
dashboard
.
caseTypeGroups
}
/>
<
div
className=
"space-y-4"
>
{
dashboard
.
categoryGroups
.
length
>
0
?
(
dashboard
.
categoryGroups
.
map
((
item
,
index
)
=>
(
<
DistributionRow
key=
{
item
.
key
}
label=
{
item
.
label
}
value=
{
item
.
count
}
total=
{
dashboard
.
totalCount
}
accent=
{
index
%
3
===
1
?
'bg-emerald-500'
:
index
%
3
===
2
?
'bg-amber-500'
:
'bg-sky-500'
}
/>
))
)
:
(
<
div
className=
"text-sm text-zinc-400"
>
暂无分类数据
</
div
>
)
}
</
div
>
</
div
>
</
Panel
>
<
div
className=
"grid gap-6 xl:grid-cols-[1fr_0.9fr]"
>
<
ChartPanel
title=
"案例来源分布"
groups=
{
dashboard
.
caseSourceGroups
}
type=
"horizontalBar"
/>
<
Panel
className=
"p-6"
>
<
Panel
className=
"p-6"
>
<
SectionHeader
title=
"标签热度"
/>
<
SectionHeader
title=
"关键字词云"
/>
<
div
className=
"flex flex-wrap gap-2"
>
<
KeywordCloud
groups=
{
dashboard
.
keywordGroups
}
/>
{
dashboard
.
tagGroups
.
length
>
0
?
(
dashboard
.
tagGroups
.
map
((
item
,
index
)
=>
(
<
Pill
key=
{
item
.
key
}
tone=
{
index
%
3
===
1
?
'green'
:
index
%
3
===
2
?
'amber'
:
'blue'
}
>
{
item
.
label
}
·
{
item
.
count
}
</
Pill
>
))
)
:
(
<
div
className=
"text-sm text-zinc-400"
>
当前还没有可展示的标签
</
div
>
)
}
</
div
>
</
Panel
>
</
Panel
>
</
div
>
</
div
>
<
div
className=
"grid gap-6 xl:grid-cols-[1.1fr_0.9fr]"
>
<
Panel
className=
"p-6"
>
<
Panel
className=
"p-6"
>
<
SectionHeader
title=
"最近更新"
/>
<
SectionHeader
title=
"最近更新"
/>
<
div
className=
"space-y-3"
>
<
div
className=
"space-y-3"
>
...
@@ -296,65 +512,6 @@ export default function ResourceStatPage() {
...
@@ -296,65 +512,6 @@ export default function ResourceStatPage() {
</
div
>
</
div
>
</
Panel
>
</
Panel
>
<
div
className=
"space-y-6"
>
<
Panel
className=
"p-6"
>
<
SectionHeader
title=
"长篇案例"
/>
<
div
className=
"space-y-3"
>
{
dashboard
.
longCases
.
length
>
0
?
(
dashboard
.
longCases
.
map
((
item
)
=>
(
<
div
key=
{
item
.
id
}
className=
"rounded-[22px] border border-zinc-200 bg-white px-4 py-4 shadow-sm"
>
<
div
className=
"flex items-center gap-2"
>
<
BookOpenIcon
className=
"size-4 text-sky-600"
/>
<
div
className=
"text-sm font-semibold text-zinc-900"
>
{
item
.
label
}
</
div
>
</
div
>
<
div
className=
"mt-2 text-xs text-zinc-500"
>
{
item
.
category
}
·
{
item
.
textLengthLabel
}
</
div
>
</
div
>
))
)
:
(
<
div
className=
"text-sm text-zinc-400"
>
暂无长篇案例
</
div
>
)
}
</
div
>
</
Panel
>
<
Panel
className=
"p-6"
>
<
SectionHeader
title=
"待整理清单"
/>
<
div
className=
"space-y-3"
>
{
dashboard
.
attentionCases
.
length
>
0
?
(
dashboard
.
attentionCases
.
map
((
item
)
=>
(
<
div
key=
{
item
.
id
}
className=
"rounded-[22px] border border-amber-200 bg-amber-50 px-4 py-4"
>
<
div
className=
"text-sm font-semibold text-zinc-900"
>
{
item
.
label
}
</
div
>
<
div
className=
"mt-2 text-xs leading-6 text-amber-800"
>
{
item
.
category
===
'未分类'
?
'缺少分类'
:
''
}
{
item
.
category
===
'未分类'
&&
!
item
.
tag
?
' · '
:
''
}
{
!
item
.
tag
?
'缺少标签'
:
''
}
{
(
item
.
category
===
'未分类'
||
!
item
.
tag
)
&&
item
.
textLength
<
80
?
' · '
:
''
}
{
item
.
textLength
<
80
?
'正文偏短'
:
''
}
</
div
>
</
div
>
))
)
:
(
<
div
className=
"text-sm text-zinc-400"
>
当前没有待整理案例
</
div
>
)
}
</
div
>
</
Panel
>
</
div
>
</
div
>
{
!
loading
&&
cases
.
length
===
0
?
(
{
!
loading
&&
cases
.
length
===
0
?
(
<
Panel
className=
"p-8 text-center text-sm text-zinc-500"
>
<
Panel
className=
"p-8 text-center text-sm text-zinc-500"
>
当前没有可展示的案例记录。
当前没有可展示的案例记录。
...
...
src/services/audit-material-subject-build-checklist.md
View file @
7b7c281e
...
@@ -15,7 +15,7 @@
...
@@ -15,7 +15,7 @@
-
所有业务字段都放在
`metadata`
中
-
所有业务字段都放在
`metadata`
中
-
案例详情走纯文本,不单独拆分附件对象
-
案例详情走纯文本,不单独拆分附件对象
-
一期先不建独立关键字表、案例性质表
-
一期先不建独立关键字表、案例性质表
-
案例性质
和关键字先通过
文本字段实现
-
案例性质
先通过文本字段实现,关键字通过多值
文本字段实现
---
---
...
@@ -47,7 +47,7 @@
...
@@ -47,7 +47,7 @@
| 案例属性 |
`case_attribute`
|
`text`
| 否 | 否 | 否 | 无 | 案例属性或归属维度 |
| 案例属性 |
`case_attribute`
|
`text`
| 否 | 否 | 否 | 无 | 案例属性或归属维度 |
| 案例详情 |
`case_details`
|
`long_text`
| 否 | 否 | 否 | 无 | 案例详细内容正文 |
| 案例详情 |
`case_details`
|
`long_text`
| 否 | 否 | 否 | 无 | 案例详细内容正文 |
| 案例警示 |
`case_warning`
|
`text`
| 否 | 否 | 否 | 无 | 警示或风险提示 |
| 案例警示 |
`case_warning`
|
`text`
| 否 | 否 | 否 | 无 | 警示或风险提示 |
| 关键字 |
`keyword`
|
`text`
| 否 |
否 | 否 | 无 | 用户自定义检索关键字
|
| 关键字 |
`keyword`
|
`text`
| 否 |
是 | 否 | 无 | 用户自定义检索关键字;导入逗号拼接值需拆分为数组
|
| 案例来源 |
`case_source`
|
`text`
| 否 | 否 | 否 | 无 | 如监管通报、内部复盘 |
| 案例来源 |
`case_source`
|
`text`
| 否 | 否 | 否 | 无 | 如监管通报、内部复盘 |
| 案例等级 |
`case_level`
|
`text`
| 否 | 否 | 否 | 无 | 风险等级或分级 |
| 案例等级 |
`case_level`
|
`text`
| 否 | 否 | 否 | 无 | 风险等级或分级 |
| 案例年份 |
`case_year`
|
`number`
| 否 | 否 | 否 | 无 | 案例发生或归档年份 |
| 案例年份 |
`case_year`
|
`number`
| 否 | 否 | 否 | 无 | 案例发生或归档年份 |
...
@@ -98,7 +98,7 @@
...
@@ -98,7 +98,7 @@
1.
在案例管理页手动创建后,能生成
`risk_case_library`
1.
在案例管理页手动创建后,能生成
`risk_case_library`
2.
通过 Excel 导入后,能批量生成
`risk_case_library`
2.
通过 Excel 导入后,能批量生成
`risk_case_library`
3.
案例总览页能正确读取案例性质
、关键字和更新统计
3.
驾驶舱页能正确读取分类、案例类型、案例来源
、关键字和更新统计
4.
案例详情页能完成编辑与删除
4.
案例详情页能完成编辑与删除
---
---
...
...
src/services/record-api.md
View file @
7b7c281e
...
@@ -154,7 +154,7 @@
...
@@ -154,7 +154,7 @@
"case_attribute"
:
"流程绕行"
,
"case_attribute"
:
"流程绕行"
,
"case_details"
:
"某项目在高峰采购期内,多次通过拆分订单绕过审批阈值……"
,
"case_details"
:
"某项目在高峰采购期内,多次通过拆分订单绕过审批阈值……"
,
"case_warning"
:
"涉及审批阈值、例外授权和供应商集中度的场景应设置联合预警。"
,
"case_warning"
:
"涉及审批阈值、例外授权和供应商集中度的场景应设置联合预警。"
,
"keyword"
:
"采购审批, 拆单"
,
"keyword"
:
[
"采购审批"
,
"拆单"
]
,
"case_source"
:
"内部审计复盘"
,
"case_source"
:
"内部审计复盘"
,
"case_level"
:
"高"
,
"case_level"
:
"高"
,
"case_year"
:
2024
"case_year"
:
2024
...
@@ -229,7 +229,7 @@
...
@@ -229,7 +229,7 @@
"case_attribute"
:
"财务舞弊"
,
"case_attribute"
:
"财务舞弊"
,
"case_details"
:
"财务负责人通过虚构往来科目转移资金……"
,
"case_details"
:
"财务负责人通过虚构往来科目转移资金……"
,
"case_warning"
:
"资金支付复核应与月末异常波动分析交叉验证。"
,
"case_warning"
:
"资金支付复核应与月末异常波动分析交叉验证。"
,
"keyword"
:
"资金管理, 异常调账"
,
"keyword"
:
[
"资金管理"
,
"异常调账"
]
,
"case_source"
:
"内部通报"
,
"case_source"
:
"内部通报"
,
"case_level"
:
"高"
,
"case_level"
:
"高"
,
"case_year"
:
2025
"case_year"
:
2025
...
@@ -242,3 +242,5 @@
...
@@ -242,3 +242,5 @@
-
`title`
、
`content_type`
、
`content`
、
`metadata`
、
`subject`
这一层结构正确
-
`title`
、
`content_type`
、
`content`
、
`metadata`
、
`subject`
这一层结构正确
-
案例业务字段全部放入
`metadata`
-
案例业务字段全部放入
`metadata`
-
`metadata.keyword`
为多值字符串数组;Excel 导入如读取到
`资金管理, 异常调账`
,前端需拆分为
`["资金管理", "异常调账"]`
后保存
-
Excel 导出时,
`metadata.keyword`
数组需恢复为逗号拼接字符串写入
`关键字`
列
src/services/subjectsDefinition.md
View file @
7b7c281e
...
@@ -48,7 +48,7 @@ type Record = {
...
@@ -48,7 +48,7 @@ type Record = {
"case_attribute"
:
"内部控制缺陷"
,
"case_attribute"
:
"内部控制缺陷"
,
"case_details"
:
"项目经理通过虚构供应商合同套取资金……"
,
"case_details"
:
"项目经理通过虚构供应商合同套取资金……"
,
"case_warning"
:
"供应商准入与合同审批需交叉复核"
,
"case_warning"
:
"供应商准入与合同审批需交叉复核"
,
"keyword"
:
"资金挪用, 虚构合同"
,
"keyword"
:
[
"资金挪用"
,
"虚构合同"
]
,
"case_source"
:
"内部通报"
,
"case_source"
:
"内部通报"
,
"case_level"
:
"高"
,
"case_level"
:
"高"
,
"case_year"
:
2025
"case_year"
:
2025
...
@@ -64,6 +64,7 @@ type Record = {
...
@@ -64,6 +64,7 @@ type Record = {
-
统一存储为字符串
-
统一存储为字符串
-
Excel 导入时,空单元格需要转换为空字符串,避免
`undefined`
-
Excel 导入时,空单元格需要转换为空字符串,避免
`undefined`
-
若字段配置
`multiple: true`
,统一存储为字符串数组;Excel 单元格中的英文逗号或中文逗号拼接值需要拆分、去空并去重
### 2.2 `Record.title`
### 2.2 `Record.title`
...
@@ -78,7 +79,7 @@ type Record = {
...
@@ -78,7 +79,7 @@ type Record = {
-
卡片或详情摘要使用
`case_details`
-
卡片或详情摘要使用
`case_details`
-
案例性质筛选读取
`category`
-
案例性质筛选读取
`category`
-
标签筛选读取
`tag`
-
标签筛选读取
`tag`
-
关键字筛选读取
`keyword`
,历史数据可兼容读取
`tag`
-
关键字筛选读取
多值数组
`keyword`
,历史数据可兼容读取字符串型
`keyword`
与
`tag`
-
关键词检索优先覆盖
`label`
、
`keyword`
、
`case_details`
、
`case_warning`
-
关键词检索优先覆盖
`label`
、
`keyword`
、
`case_details`
、
`case_warning`
---
---
...
@@ -100,7 +101,7 @@ type Record = {
...
@@ -100,7 +101,7 @@ type Record = {
-
`case_attribute`
用于补充案例属性、归属维度或风险特征
-
`case_attribute`
用于补充案例属性、归属维度或风险特征
-
`case_details`
为案例详情正文,通常为几百到上千字
-
`case_details`
为案例详情正文,通常为几百到上千字
-
`case_warning`
用于提炼关键警示信息
-
`case_warning`
用于提炼关键警示信息
-
`keyword`
用于检索和热度统计,可与历史
`tag`
并存
-
`keyword`
用于检索和热度统计,
按多值字符串数组存储,
可与历史
`tag`
并存
-
`case_year`
建议存储为数字类型年份,便于筛选和排序
-
`case_year`
建议存储为数字类型年份,便于筛选和排序
| 字段标签 | 字段别名 | 字段类型 | 默认值 | 是否主键 | 是否多值 | 是否必填 | 搜索 | 聚合 | 说明 |
| 字段标签 | 字段别名 | 字段类型 | 默认值 | 是否主键 | 是否多值 | 是否必填 | 搜索 | 聚合 | 说明 |
...
@@ -112,7 +113,7 @@ type Record = {
...
@@ -112,7 +113,7 @@ type Record = {
| 案例属性 |
`case_attribute`
|
`text`
|
`null`
| 否 | 否 | 否 | 否 | 否 | 案例属性或归属维度 |
| 案例属性 |
`case_attribute`
|
`text`
|
`null`
| 否 | 否 | 否 | 否 | 否 | 案例属性或归属维度 |
| 案例详情 |
`case_details`
|
`long_text`
|
`null`
| 否 | 否 | 否 | 否 | 否 | 案例详细内容正文 |
| 案例详情 |
`case_details`
|
`long_text`
|
`null`
| 否 | 否 | 否 | 否 | 否 | 案例详细内容正文 |
| 案例警示 |
`case_warning`
|
`text`
|
`null`
| 否 | 否 | 否 | 否 | 否 | 案例提炼出的警示或风险提示 |
| 案例警示 |
`case_warning`
|
`text`
|
`null`
| 否 | 否 | 否 | 否 | 否 | 案例提炼出的警示或风险提示 |
| 关键字 |
`keyword`
|
`text`
|
`null`
| 否 |
否 | 否 | 否 | 否 | 检索关键字,支持人工维护
|
| 关键字 |
`keyword`
|
`text`
|
`null`
| 否 |
是 | 否 | 否 | 否 | 检索关键字,支持人工维护;导入时逗号拼接值需拆分为数组
|
| 案例来源 |
`case_source`
|
`text`
|
`null`
| 否 | 否 | 否 | 否 | 否 | 案例来源,如监管通报、内部复盘 |
| 案例来源 |
`case_source`
|
`text`
|
`null`
| 否 | 否 | 否 | 否 | 否 | 案例来源,如监管通报、内部复盘 |
| 案例等级 |
`case_level`
|
`text`
|
`null`
| 否 | 否 | 否 | 否 | 否 | 案例风险等级或分级 |
| 案例等级 |
`case_level`
|
`text`
|
`null`
| 否 | 否 | 否 | 否 | 否 | 案例风险等级或分级 |
| 案例年份 |
`case_year`
|
`number`
|
`null`
| 否 | 否 | 否 | 否 | 否 | 案例发生或归档年份 |
| 案例年份 |
`case_year`
|
`number`
|
`null`
| 否 | 否 | 否 | 否 | 否 | 案例发生或归档年份 |
...
@@ -130,7 +131,7 @@ type Record = {
...
@@ -130,7 +131,7 @@ type Record = {
{
"name"
:
"case_attribute"
,
"label"
:
"案例属性"
,
"type"
:
"text"
,
"default"
:
null
,
"multiple"
:
false
,
"required"
:
false
,
"primary"
:
false
,
"search"
:
{
"enabled"
:
false
},
"aggregation"
:
{
"enabled"
:
false
},
"settings"
:
{}
},
{
"name"
:
"case_attribute"
,
"label"
:
"案例属性"
,
"type"
:
"text"
,
"default"
:
null
,
"multiple"
:
false
,
"required"
:
false
,
"primary"
:
false
,
"search"
:
{
"enabled"
:
false
},
"aggregation"
:
{
"enabled"
:
false
},
"settings"
:
{}
},
{
"name"
:
"case_details"
,
"label"
:
"案例详情"
,
"type"
:
"long_text"
,
"default"
:
null
,
"multiple"
:
false
,
"required"
:
false
,
"primary"
:
false
,
"search"
:
{
"enabled"
:
false
},
"aggregation"
:
{
"enabled"
:
false
},
"settings"
:
{}
},
{
"name"
:
"case_details"
,
"label"
:
"案例详情"
,
"type"
:
"long_text"
,
"default"
:
null
,
"multiple"
:
false
,
"required"
:
false
,
"primary"
:
false
,
"search"
:
{
"enabled"
:
false
},
"aggregation"
:
{
"enabled"
:
false
},
"settings"
:
{}
},
{
"name"
:
"case_warning"
,
"label"
:
"案例警示"
,
"type"
:
"text"
,
"default"
:
null
,
"multiple"
:
false
,
"required"
:
false
,
"primary"
:
false
,
"search"
:
{
"enabled"
:
false
},
"aggregation"
:
{
"enabled"
:
false
},
"settings"
:
{}
},
{
"name"
:
"case_warning"
,
"label"
:
"案例警示"
,
"type"
:
"text"
,
"default"
:
null
,
"multiple"
:
false
,
"required"
:
false
,
"primary"
:
false
,
"search"
:
{
"enabled"
:
false
},
"aggregation"
:
{
"enabled"
:
false
},
"settings"
:
{}
},
{
"name"
:
"keyword"
,
"label"
:
"关键字"
,
"type"
:
"text"
,
"default"
:
null
,
"multiple"
:
fals
e
,
"required"
:
false
,
"primary"
:
false
,
"search"
:
{
"enabled"
:
false
},
"aggregation"
:
{
"enabled"
:
false
},
"settings"
:
{}
},
{
"name"
:
"keyword"
,
"label"
:
"关键字"
,
"type"
:
"text"
,
"default"
:
null
,
"multiple"
:
tru
e
,
"required"
:
false
,
"primary"
:
false
,
"search"
:
{
"enabled"
:
false
},
"aggregation"
:
{
"enabled"
:
false
},
"settings"
:
{}
},
{
"name"
:
"case_source"
,
"label"
:
"案例来源"
,
"type"
:
"text"
,
"default"
:
null
,
"multiple"
:
false
,
"required"
:
false
,
"primary"
:
false
,
"search"
:
{
"enabled"
:
false
},
"aggregation"
:
{
"enabled"
:
false
},
"settings"
:
{}
},
{
"name"
:
"case_source"
,
"label"
:
"案例来源"
,
"type"
:
"text"
,
"default"
:
null
,
"multiple"
:
false
,
"required"
:
false
,
"primary"
:
false
,
"search"
:
{
"enabled"
:
false
},
"aggregation"
:
{
"enabled"
:
false
},
"settings"
:
{}
},
{
"name"
:
"case_level"
,
"label"
:
"案例等级"
,
"type"
:
"text"
,
"default"
:
null
,
"multiple"
:
false
,
"required"
:
false
,
"primary"
:
false
,
"search"
:
{
"enabled"
:
false
},
"aggregation"
:
{
"enabled"
:
false
},
"settings"
:
{}
},
{
"name"
:
"case_level"
,
"label"
:
"案例等级"
,
"type"
:
"text"
,
"default"
:
null
,
"multiple"
:
false
,
"required"
:
false
,
"primary"
:
false
,
"search"
:
{
"enabled"
:
false
},
"aggregation"
:
{
"enabled"
:
false
},
"settings"
:
{}
},
{
"name"
:
"case_year"
,
"label"
:
"案例年份"
,
"type"
:
"number"
,
"default"
:
null
,
"multiple"
:
false
,
"required"
:
false
,
"primary"
:
false
,
"search"
:
{
"enabled"
:
false
},
"aggregation"
:
{
"enabled"
:
false
},
"settings"
:
{}
}
{
"name"
:
"case_year"
,
"label"
:
"案例年份"
,
"type"
:
"number"
,
"default"
:
null
,
"multiple"
:
false
,
"required"
:
false
,
"primary"
:
false
,
"search"
:
{
"enabled"
:
false
},
"aggregation"
:
{
"enabled"
:
false
},
"settings"
:
{}
}
...
@@ -148,7 +149,7 @@ type Record = {
...
@@ -148,7 +149,7 @@ type Record = {
"case_attribute"
:
"流程绕行"
,
"case_attribute"
:
"流程绕行"
,
"case_details"
:
"某项目在高峰采购期内,多次通过拆分订单绕过审批阈值,导致同一供应商在短期内累计发生异常采购金额。复盘发现,审批节点设置与例外授权规则未同步更新,最终形成流程漏洞。"
,
"case_details"
:
"某项目在高峰采购期内,多次通过拆分订单绕过审批阈值,导致同一供应商在短期内累计发生异常采购金额。复盘发现,审批节点设置与例外授权规则未同步更新,最终形成流程漏洞。"
,
"case_warning"
:
"涉及审批阈值、例外授权和供应商集中度的场景应设置联合预警。"
,
"case_warning"
:
"涉及审批阈值、例外授权和供应商集中度的场景应设置联合预警。"
,
"keyword"
:
"审批, 采购, 拆单"
,
"keyword"
:
[
"审批"
,
"采购"
,
"拆单"
]
,
"case_source"
:
"内部审计复盘"
,
"case_source"
:
"内部审计复盘"
,
"case_level"
:
"高"
,
"case_level"
:
"高"
,
"case_year"
:
2024
"case_year"
:
2024
...
...
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