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
6f4521a3
Commit
6f4521a3
authored
Jun 08, 2026
by
David Yang
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
update: ui
parent
90f936e8
Hide whitespace changes
Inline
Side-by-side
Showing
20 changed files
with
1554 additions
and
160 deletions
+1554
-160
avatar.jsx
src/catalyst-ui-kit/javascript/avatar.jsx
+1
-1
badge.jsx
src/catalyst-ui-kit/javascript/badge.jsx
+2
-2
checkbox.jsx
src/catalyst-ui-kit/javascript/checkbox.jsx
+2
-2
combobox.jsx
src/catalyst-ui-kit/javascript/combobox.jsx
+2
-2
dropdown.jsx
src/catalyst-ui-kit/javascript/dropdown.jsx
+1
-1
input.jsx
src/catalyst-ui-kit/javascript/input.jsx
+1
-1
listbox.jsx
src/catalyst-ui-kit/javascript/listbox.jsx
+2
-2
radio.jsx
src/catalyst-ui-kit/javascript/radio.jsx
+2
-2
segment.jsx
src/catalyst-ui-kit/javascript/segment.jsx
+39
-0
select.jsx
src/catalyst-ui-kit/javascript/select.jsx
+1
-1
switch.jsx
src/catalyst-ui-kit/javascript/switch.jsx
+3
-3
table.jsx
src/catalyst-ui-kit/javascript/table.jsx
+1
-1
textarea.jsx
src/catalyst-ui-kit/javascript/textarea.jsx
+1
-1
global.less
src/global.less
+7
-4
Layout.jsx
src/pages/project_manager_xy/Layout.jsx
+10
-19
audit-material-center.business-flow.md
...project_manager_xy/audit-material-center.business-flow.md
+55
-19
materialService.js
src/pages/project_manager_xy/materialService.js
+391
-33
index.jsx
src/pages/project_manager_xy/resource-booking/index.jsx
+989
-48
audit-material-subject-build-checklist.md
src/services/audit-material-subject-build-checklist.md
+1
-1
subjectsDefinition.md
src/services/subjectsDefinition.md
+43
-17
No files found.
src/catalyst-ui-kit/javascript/avatar.jsx
View file @
6f4521a3
...
...
@@ -43,7 +43,7 @@ export const AvatarButton = forwardRef(function AvatarButton(
let
classes
=
clsx
(
className
,
square
?
'rounded-[20%]'
:
'rounded-full'
,
'relative inline-grid focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-
blue-500
'
'relative inline-grid focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-
[var(--pm-brand)]
'
)
return
typeof
props
.
href
===
'string'
?
(
...
...
src/catalyst-ui-kit/javascript/badge.jsx
View file @
6f4521a3
...
...
@@ -20,7 +20,7 @@ const colors = {
teal
:
'bg-teal-500/15 text-teal-700 group-data-hover:bg-teal-500/25'
,
cyan
:
'bg-cyan-400/20 text-cyan-700 group-data-hover:bg-cyan-400/30'
,
sky
:
'bg-sky-500/15 text-sky-700 group-data-hover:bg-sky-500/25'
,
blue
:
'bg-
blue-500/15 text-blue-700 group-data-hover:bg-blue-500
/25'
,
blue
:
'bg-
[var(--pm-brand)]/15 text-[var(--pm-brand-strong)] group-data-hover:bg-[var(--pm-brand)]
/25'
,
indigo
:
'bg-indigo-500/15 text-indigo-700 group-data-hover:bg-indigo-500/25'
,
violet
:
...
...
@@ -54,7 +54,7 @@ export const BadgeButton = forwardRef(function BadgeButton(
)
{
let
classes
=
clsx
(
className
,
'group relative inline-flex rounded-md focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-
blue-500
'
'group relative inline-flex rounded-md focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-
[var(--pm-brand)]
'
)
return
typeof
props
.
href
===
'string'
?
(
...
...
src/catalyst-ui-kit/javascript/checkbox.jsx
View file @
6f4521a3
...
...
@@ -57,7 +57,7 @@ const base = [
'after:absolute after:inset-0 after:rounded-[calc(0.3125rem-1px)] after:shadow-[inset_0_1px_--theme(--color-white/15%)]'
,
,
// Focus ring
'group-data-focus:outline-2 group-data-focus:outline-offset-2 group-data-focus:outline-
blue-500
'
,
'group-data-focus:outline-2 group-data-focus:outline-offset-2 group-data-focus:outline-
[var(--pm-brand)]
'
,
// Disabled state
'group-data-disabled:opacity-50'
,
'group-data-disabled:border-zinc-950/25 group-data-disabled:bg-zinc-950/5 group-data-disabled:[--checkbox-check:var(--color-zinc-950)]/50 group-data-disabled:before:bg-transparent'
,
...
...
@@ -95,7 +95,7 @@ const colors = {
teal
:
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-teal-600)] [--checkbox-checked-border:var(--color-teal-700)]/90'
,
cyan
:
'[--checkbox-check:var(--color-cyan-950)] [--checkbox-checked-bg:var(--color-cyan-300)] [--checkbox-checked-border:var(--color-cyan-400)]/80'
,
sky
:
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-sky-500)] [--checkbox-checked-border:var(--color-sky-600)]/80'
,
blue
:
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--
color-blue-600)] [--checkbox-checked-border:var(--color-blue-700)]/90
'
,
blue
:
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--
pm-brand)] [--checkbox-checked-border:var(--pm-brand-strong)]
'
,
indigo
:
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-indigo-500)] [--checkbox-checked-border:var(--color-indigo-600)]/90'
,
violet
:
...
...
src/catalyst-ui-kit/javascript/combobox.jsx
View file @
6f4521a3
...
...
@@ -38,7 +38,7 @@ export function Combobox({
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
,
// Focus ring
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset sm:focus-within:after:ring-2 sm:focus-within:after:ring-
blue-500
'
,
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset sm:focus-within:after:ring-2 sm:focus-within:after:ring-
[var(--pm-brand)]
'
,
// Disabled state
'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none'
,
// Invalid state
...
...
@@ -133,7 +133,7 @@ export function ComboboxOption({ children, className, ...props }) {
// Typography
'text-base/6 text-zinc-950 sm:text-sm/6 forced-colors:text-[CanvasText]'
,
// Focus
'outline-hidden data-focus:bg-
blue-500
data-focus:text-white'
,
'outline-hidden data-focus:bg-
[var(--pm-brand)]
data-focus:text-white'
,
// Forced colors mode
'forced-color-adjust-none forced-colors:data-focus:bg-[Highlight] forced-colors:data-focus:text-[HighlightText]'
,
// Disabled
...
...
src/catalyst-ui-kit/javascript/dropdown.jsx
View file @
6f4521a3
...
...
@@ -51,7 +51,7 @@ export function DropdownItem({ className, ...props }) {
// Text styles
'text-left text-base/6 text-zinc-950 sm:text-sm/6 forced-colors:text-[CanvasText]'
,
// Focus
'data-focus:bg-
blue-500
data-focus:text-white'
,
'data-focus:bg-
[var(--pm-brand)]
data-focus:text-white'
,
// Disabled state
'data-disabled:opacity-50'
,
// Forced colors mode
...
...
src/catalyst-ui-kit/javascript/input.jsx
View file @
6f4521a3
...
...
@@ -38,7 +38,7 @@ export const Input = forwardRef(function Input(
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
,
// Focus ring
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset sm:focus-within:after:ring-2 sm:focus-within:after:ring-
blue-500
'
,
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset sm:focus-within:after:ring-2 sm:focus-within:after:ring-
[var(--pm-brand)]
'
,
// Disabled state
'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none'
,
])
}
...
...
src/catalyst-ui-kit/javascript/listbox.jsx
View file @
6f4521a3
...
...
@@ -22,7 +22,7 @@ export function Listbox({ className, placeholder, autoFocus, 'aria-label': ariaL
// Hide default focus styles
'focus:outline-hidden'
,
// Focus ring
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset data-focus:after:ring-2 data-focus:after:ring-
blue-500
'
,
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset data-focus:after:ring-2 data-focus:after:ring-
[var(--pm-brand)]
'
,
// Disabled state
'data-disabled:opacity-50 data-disabled:before:bg-zinc-950/5 data-disabled:before:shadow-none'
,
])
}
...
...
@@ -115,7 +115,7 @@ export function ListboxOption({ children, className, ...props }) {
// Typography
'text-base/6 text-zinc-950 sm:text-sm/6 forced-colors:text-[CanvasText]'
,
// Focus
'outline-hidden data-focus:bg-
blue-500
data-focus:text-white'
,
'outline-hidden data-focus:bg-
[var(--pm-brand)]
data-focus:text-white'
,
// Forced colors mode
'forced-color-adjust-none forced-colors:data-focus:bg-[Highlight] forced-colors:data-focus:text-[HighlightText]'
,
// Disabled
...
...
src/catalyst-ui-kit/javascript/radio.jsx
View file @
6f4521a3
...
...
@@ -61,7 +61,7 @@ const base = [
// Indicator color (dark mode)
,
// Focus ring
'group-data-focus:outline group-data-focus:outline-2 group-data-focus:outline-offset-2 group-data-focus:outline-
blue-500
'
,
'group-data-focus:outline group-data-focus:outline-2 group-data-focus:outline-offset-2 group-data-focus:outline-
[var(--pm-brand)]
'
,
// Disabled state
'group-data-disabled:opacity-50'
,
'group-data-disabled:border-zinc-950/25 group-data-disabled:bg-zinc-950/5 group-data-disabled:[--radio-checked-indicator:var(--color-zinc-950)]/50 group-data-disabled:before:bg-transparent'
,
...
...
@@ -96,7 +96,7 @@ const colors = {
teal
:
'[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-teal-600)] [--radio-checked-border:var(--color-teal-700)]/90'
,
cyan
:
'[--radio-checked-bg:var(--color-cyan-300)] [--radio-checked-border:var(--color-cyan-400)]/80 [--radio-checked-indicator:var(--color-cyan-950)]'
,
sky
:
'[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-sky-500)] [--radio-checked-border:var(--color-sky-600)]/80'
,
blue
:
'[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--
color-blue-600)] [--radio-checked-border:var(--color-blue-700)]/90
'
,
blue
:
'[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--
pm-brand)] [--radio-checked-border:var(--pm-brand-strong)]
'
,
indigo
:
'[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-indigo-500)] [--radio-checked-border:var(--color-indigo-600)]/90'
,
violet
:
...
...
src/catalyst-ui-kit/javascript/segment.jsx
0 → 100644
View file @
6f4521a3
'use client'
;
import
clsx
from
'clsx'
;
export
function
Segment
({
className
,
children
})
{
return
(
<
div
className=
{
clsx
(
'inline-flex items-center gap-1 rounded-full border border-zinc-200 bg-zinc-100/80 p-1'
,
className
,
)
}
>
{
children
}
</
div
>
);
}
export
function
SegmentOption
({
active
=
false
,
className
,
children
,
...
props
})
{
return
(
<
button
type=
"button"
{
...
props
}
className=
{
clsx
(
'inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition-all'
,
active
?
'bg-white text-sky-700 shadow-sm ring-1 ring-sky-200'
:
'text-zinc-600 hover:bg-white/80 hover:text-zinc-900'
,
className
,
)
}
>
{
children
}
</
button
>
);
}
src/catalyst-ui-kit/javascript/select.jsx
View file @
6f4521a3
...
...
@@ -15,7 +15,7 @@ export const Select = forwardRef(function Select({ className, multiple, ...props
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
,
// Focus ring
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset has-data-focus:after:ring-2 has-data-focus:after:ring-
blue-500
'
,
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset has-data-focus:after:ring-2 has-data-focus:after:ring-
[var(--pm-brand)]
'
,
// Disabled state
'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none'
,
])
}
...
...
src/catalyst-ui-kit/javascript/switch.jsx
View file @
6f4521a3
...
...
@@ -101,8 +101,8 @@ const colors = {
'[--switch:white] [--switch-ring:var(--color-sky-600)]/80 [--switch-shadow:var(--color-sky-900)]/20'
,
],
blue
:
[
'[--switch-bg-ring:var(--
color-blue-700)]/90 [--switch-bg:var(--color-blue-600
)]'
,
'[--switch:white] [--switch-ring:var(--
color-blue-700)]/90 [--switch-shadow:var(--color-blue-900
)]/20'
,
'[--switch-bg-ring:var(--
pm-brand-strong)] [--switch-bg:var(--pm-brand
)]'
,
'[--switch:white] [--switch-ring:var(--
pm-brand-strong)] [--switch-shadow:var(--pm-brand-strong
)]/20'
,
],
indigo
:
[
'[--switch-bg-ring:var(--color-indigo-600)]/90 [--switch-bg:var(--color-indigo-500)]'
,
...
...
@@ -148,7 +148,7 @@ export function Switch({ color = 'dark/zinc', className, ...props }) {
// Checked
'data-checked:bg-(--switch-bg) data-checked:ring-(--switch-bg-ring)'
,
// Focus
'focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-
blue-500
'
,
'focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-
[var(--pm-brand)]
'
,
// Hover
'data-hover:ring-black/15 data-hover:data-checked:ring-(--switch-bg-ring)'
,
,
...
...
src/catalyst-ui-kit/javascript/table.jsx
View file @
6f4521a3
...
...
@@ -70,7 +70,7 @@ export function TableRow({ href, target, title, className, ...props }) {
className=
{
clsx
(
className
,
href
&&
'has-[[data-row-link][data-focus]]:outline-2 has-[[data-row-link][data-focus]]:-outline-offset-2 has-[[data-row-link][data-focus]]:outline-
blue-500
'
,
'has-[[data-row-link][data-focus]]:outline-2 has-[[data-row-link][data-focus]]:-outline-offset-2 has-[[data-row-link][data-focus]]:outline-
[var(--pm-brand)]
'
,
striped
&&
'even:bg-zinc-950/2.5'
,
href
&&
striped
&&
'hover:bg-zinc-950/5'
,
href
&&
!
striped
&&
'hover:bg-zinc-950/2.5'
,
...
...
src/catalyst-ui-kit/javascript/textarea.jsx
View file @
6f4521a3
...
...
@@ -15,7 +15,7 @@ export const Textarea = forwardRef(function Textarea({ className, resizable = tr
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
,
// Focus ring
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset sm:focus-within:after:ring-2 sm:focus-within:after:ring-
blue-500
'
,
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset sm:focus-within:after:ring-2 sm:focus-within:after:ring-
[var(--pm-brand)]
'
,
// Disabled state
'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none'
,
])
}
...
...
src/global.less
View file @
6f4521a3
:root {
--pm-brand: #
009cd9
;
--pm-brand-strong: #
007bb8
;
--pm-brand: #
b42318
;
--pm-brand-strong: #
991b1b
;
--pm-surface: #ffffff;
--pm-bg: #
eef5f9
;
--pm-bg: #
f8fafc
;
--pm-sidebar: #0f172a;
--pm-sidebar-soft: #1e293b;
--pm-text: #0f172a;
...
...
@@ -17,7 +17,10 @@ body,
body {
margin: 0;
background: linear-gradient(180deg, #f8fbfd 0%, #eef5f9 100%);
background:
radial-gradient(circle at top left, rgba(251, 191, 188, 0.22), transparent 26%),
radial-gradient(circle at bottom right, rgba(251, 207, 232, 0.2), transparent 30%),
#f8fafc;
color: var(--pm-text);
font-family:
"PingFang SC",
...
...
src/pages/project_manager_xy/Layout.jsx
View file @
6f4521a3
import
xyLogo
from
'@/assets/xy-logo.png'
;
import
xyLogoSmall
from
'@/assets/xy-logo_small.png'
;
import
{
UserContext
}
from
'@/wrapper/Auth'
;
import
{
...
...
@@ -220,7 +219,7 @@ export default function ProjectManagerLayout() {
},
[
location
.
pathname
]);
return
(
<
div
className=
"flex h-screen overflow-hidden bg-
zinc-50
"
>
<
div
className=
"flex h-screen overflow-hidden bg-
[radial-gradient(circle_at_top_left,_rgba(251,191,188,0.22),_transparent_26%),radial-gradient(circle_at_bottom_right,_rgba(251,207,232,0.2),_transparent_30%),#f8fafc]
"
>
<
aside
className=
{
clsx
(
'relative z-30 hidden shrink-0 flex-col border-r border-zinc-800 bg-zinc-900 p-5 lg:flex'
,
...
...
@@ -230,19 +229,16 @@ export default function ProjectManagerLayout() {
<
div
className=
{
clsx
(
'mb-7 flex items-center'
,
sidebarCollapsed
?
'justify-center'
:
'
gap-3
px-1'
,
sidebarCollapsed
?
'justify-center'
:
'px-1'
,
)
}
>
<
img
src=
{
sidebarCollapsed
?
xyLogoSmall
:
xyLogo
}
alt=
"XY Logo"
className=
{
clsx
(
'shrink-0 object-contain'
,
sidebarCollapsed
?
'h-9 w-auto max-w-[52px]'
:
'h-5 w-auto max-w-[128px]'
,
)
}
/>
{
sidebarCollapsed
?
(
<
img
src=
{
xyLogoSmall
}
alt=
"XY Logo"
className=
"h-9 w-auto max-w-[52px] shrink-0 object-contain"
/>
)
:
null
}
{
!
sidebarCollapsed
?
(
<
h1
className=
"truncate text-sm font-semibold tracking-[0.02em] text-white/95"
>
{
APP_NAME
}
...
...
@@ -300,12 +296,7 @@ export default function ProjectManagerLayout() {
close=
{
()
=>
setIsMobileSidebarOpen
(
false
)
}
>
<
div
className=
"px-5 pt-6"
>
<
div
className=
"flex items-center gap-2"
>
<
img
src=
{
xyLogo
}
alt=
"XY Logo"
className=
"h-5 w-auto max-w-[128px] shrink-0 object-contain"
/>
<
div
className=
"flex items-center"
>
<
h1
className=
"text-base font-semibold tracking-[0.02em] text-white"
>
{
APP_NAME
}
</
h1
>
...
...
src/pages/project_manager_xy/audit-material-center.business-flow.md
View file @
6f4521a3
...
...
@@ -49,11 +49,12 @@
关键字段:
-
`label`
:案例标题
-
`category`
:分类与案例性质主维度
-
`tag`
:标签
-
`case_type`
:案例类型,列表与导入模板的主展示字段
-
`case_attribute`
:案例属性
-
`category`
:案例性质
-
`case_details`
:案例详情正文
-
`case_warning`
:案
件
警示
-
`case_warning`
:案
例
警示
-
`keyword`
:关键字,用于检索和热度统计
-
`case_source`
:案例来源
-
`case_level`
:案例等级
...
...
@@ -67,7 +68,7 @@
1.
用户进入
`案例管理`
2.
点击
`手动新建`
3.
填写
案例类型、案例属性、案例性质、案例详情、案件
警示、关键字、案例来源、案例等级、案例年份
3.
填写
标题、分类、标签、案例类型、案例属性、案例详情、案例
警示、关键字、案例来源、案例等级、案例年份
4.
前端调用
`POST /record`
5.
成功后生成一条
`risk_case_library`
记录
6.
页面顶部提示创建成功,并刷新当前列表
...
...
@@ -77,19 +78,21 @@
1.
用户进入
`案例管理`
2.
点击
`Excel 导入`
3.
上传
`.xlsx`
、
`.xls`
或
`.csv`
3.
在导入弹框中可先点击
`导出模板`
下载标准模板,再
上传
`.xlsx`
、
`.xls`
或
`.csv`
4.
前端读取首个工作表并解析表头
5.
Excel 表头按以下模板解析:
-
`案例类型`
→
`metadata.case_type`
,同时作为
`title`
与
`metadata.label`
-
`标题`
→
`metadata.label`
,同时作为
`title`
-
`分类`
→
`metadata.category`
-
`标签`
→
`metadata.tag`
-
`案例类型`
→
`metadata.case_type`
;若
`标题`
为空,可退化为
`title`
-
`案例属性`
→
`metadata.case_attribute`
-
`案例性质`
→
`metadata.category`
-
`案例详情`
→
`content`
、
`metadata.case_details`
-
`案
件
警示`
→
`metadata.case_warning`
-
`案
例
警示`
→
`metadata.case_warning`
-
`关键字`
→
`metadata.keyword`
,同时回填
`metadata.tag`
以兼容现有检索
-
`案例来源`
→
`metadata.case_source`
-
`案例等级`
→
`metadata.case_level`
-
`案例年份`
→
`metadata.case_year`
6.
前端兼容旧模板字段名,但新导入导出统一使用上述
9
列模板
6.
前端兼容旧模板字段名,但新导入导出统一使用上述
11
列模板
7.
前端逐条调用
`POST /record`
批量落库
8.
导入完成后反馈成功条数和失败条数
...
...
@@ -98,12 +101,14 @@
1.
用户进入
`案例管理`
2.
点击
`Excel 批量导出`
3.
前端按当前搜索词、案例性质与关键字筛选条件查询全部案例
4.
导出文件统一输出以下 9 列:
4.
导出文件统一输出以下 11 列:
-
`标题`
-
`分类`
-
`标签`
-
`案例类型`
-
`案例属性`
-
`案例性质`
-
`案例详情`
-
`案
件
警示`
-
`案
例
警示`
-
`关键字`
-
`案例来源`
-
`案例等级`
...
...
@@ -113,20 +118,32 @@
### 4.4 浏览与检索案例
1.
用户进入
`案例管理`
2.
通过搜索框检索
案例类型、案例属性、案例详情、案件
警示、关键字、案例来源、案例等级
2.
通过搜索框检索
标题、案例类型、案例属性、案例详情、案例
警示、关键字、案例来源、案例等级
3.
通过案例性质标签快速切换当前视图
4.
通过关键字输入框进一步缩小范围,前端同时兼容历史
`metadata.tag`
5.
页面以卡片方式展示案例摘要
6.
点击卡片后,以弹框方式查看并编辑详情
5.
页面顶部提供
`Segment`
视图切换,可在“卡片视图 / 表格视图”之间切换
6.
卡片视图用于浏览案例摘要,点击卡片后以弹框方式查看并编辑详情
7.
表格视图展示标题、分类、标签、案例类型、关键字、来源、等级、年份、更新时间等字段
8.
表格视图支持单选、全选当前已加载案例,并显示当前勾选数量
### 4.5 编辑案例
1.
用户在列表中打开某个案例详情
2.
修改
案例类型、案例属性、案例性质、案例详情、案件
警示、关键字、案例来源、案例等级、案例年份
2.
修改
标题、分类、标签、案例类型、案例属性、案例详情、案例
警示、关键字、案例来源、案例等级、案例年份
3.
前端调用
`PUT /record/:name`
4.
保存成功后刷新列表与统计数据
### 4.6 删除案例
### 4.6 批量修改案例
1.
用户切换到表格视图
2.
勾选一个或多个案例
3.
点击
`批量修改`
4.
在弹框中填写需要统一覆盖的字段
5.
未填写字段保持原值不变
6.
前端逐条调用
`PUT /record/:name`
7.
保存成功后刷新列表;若部分失败,反馈失败条目
### 4.7 删除案例
1.
用户在案例详情内点击删除
2.
页面弹出二次确认
...
...
@@ -134,6 +151,15 @@
4.
删除后从当前列表中移除
5.
默认走软删除,不直接做物理删除
### 4.8 表格批量删除与勾选导出
1.
用户切换到表格视图
2.
勾选一个或多个案例
3.
可点击
`导出勾选`
,前端直接将当前勾选记录导出为
`.xlsx`
4.
可点击
`批量删除`
,页面弹出二次确认
5.
前端逐条调用
`DELETE /record/:name`
6.
删除后刷新列表,并保留失败项提示
---
## 5. 联动规则
...
...
@@ -143,15 +169,16 @@
-
案例库统一读取
`risk_case_library`
-
所有业务字段均放在
`metadata`
中
-
`title`
与
`metadata.label`
保持同步,便于复用统一查询能力
-
`title`
为空时可回退使用
`metadata.case_type`
,再退化为
`metadata.case_details`
摘要
### 5.2 Excel 导入字段规则
-
未识别到的列忽略,不中断导入
-
空行直接跳过
-
`
案例类型`
为空时,使用
`标题`
或
`案例详情`
摘要自动生成
`title`
-
`
标题`
为空时,优先使用
`案例类型`
,再回退为
`案例详情`
摘要自动生成
`title`
-
所有文本字段统一按字符串保存
-
`案例年份`
优先按数字写入,不可解析时保留原字符串
-
导出模板固定为
9
列标准表头,便于线下统一维护
-
导出模板固定为
11
列标准表头,便于线下统一维护
### 5.3 筛选规则
...
...
@@ -191,10 +218,17 @@
-
搜索框
-
案例性质切换
-
关键字筛选
-
`Segment`
视图切换(卡片 / 表格)
-
手动新建按钮
-
Excel 导入按钮
-
导入弹框中的导出模板按钮
-
Excel 导出按钮
-
案例卡片列表
-
案例表格列表
-
表格勾选与全选
-
批量修改按钮
-
勾选导出按钮
-
批量删除按钮
-
详情编辑弹框
-
删除确认弹框
...
...
@@ -203,11 +237,13 @@
建议展示:
-
标题
-
分类 / 标签
-
案例类型
-
案例属性
-
案例详情全文
-
案例性质
-
关键字
-
案
件
警示
-
案
例
警示
-
案例来源 / 案例等级 / 案例年份
-
案例详情字数
-
创建时间 / 更新时间
...
...
src/pages/project_manager_xy/materialService.js
View file @
6f4521a3
...
...
@@ -11,6 +11,20 @@ import * as XLSX from 'xlsx';
export
const
CASE_SUBJECT
=
'risk_case_library'
;
export
const
DEFAULT_PAGE_SIZE
=
24
;
const
CASE_EXPORT_HEADERS
=
[
'标题'
,
'分类'
,
'标签'
,
'案例类型'
,
'案例属性'
,
'案例详情'
,
'案例警示'
,
'关键字'
,
'案例来源'
,
'案例等级'
,
'案例年份'
,
];
const
DEMO_CASES
=
[
{
_id
:
'demo-case-1'
,
...
...
@@ -23,10 +37,17 @@ const DEMO_CASES = [
'某项目在高峰采购期内,多次通过拆分订单绕过审批阈值,导致同一供应商在短期内累计发生异常采购金额。复盘发现,审批节点设置与例外授权规则未同步更新,最终形成流程漏洞。'
,
metadata
:
{
label
:
'采购审批绕行典型案例'
,
content
:
'某项目在高峰采购期内,多次通过拆分订单绕过审批阈值,导致同一供应商在短期内累计发生异常采购金额。复盘发现,审批节点设置与例外授权规则未同步更新,最终形成流程漏洞。'
,
category
:
'合规风险'
,
tag
:
'采购审批'
,
case_type
:
'审批绕行'
,
case_attribute
:
'流程绕行'
,
case_details
:
'某项目在高峰采购期内,多次通过拆分订单绕过审批阈值,导致同一供应商在短期内累计发生异常采购金额。复盘发现,审批节点设置与例外授权规则未同步更新,最终形成流程漏洞。'
,
case_warning
:
'涉及审批阈值、例外授权和供应商集中度的场景应设置联合预警。'
,
keyword
:
'采购审批, 拆单'
,
case_source
:
'内部审计复盘'
,
case_level
:
'高'
,
case_year
:
2024
,
},
},
{
...
...
@@ -40,10 +61,17 @@ const DEMO_CASES = [
'部门负责人以“紧急签约”为由临时借出印章,未在台账中完整登记用途和归还时间,后续合同版本与审批留痕不一致。最终在复盘中确认,印章借用、复核与回收链条均缺少双人校验。'
,
metadata
:
{
label
:
'印章借用失控案例'
,
content
:
'部门负责人以“紧急签约”为由临时借出印章,未在台账中完整登记用途和归还时间,后续合同版本与审批留痕不一致。最终在复盘中确认,印章借用、复核与回收链条均缺少双人校验。'
,
category
:
'操作风险'
,
tag
:
'印章管理'
,
case_type
:
'印章借用失控'
,
case_attribute
:
'授权管理'
,
case_details
:
'部门负责人以“紧急签约”为由临时借出印章,未在台账中完整登记用途和归还时间,后续合同版本与审批留痕不一致。最终在复盘中确认,印章借用、复核与回收链条均缺少双人校验。'
,
case_warning
:
'印章借用、回收与留痕链路必须双人复核并形成闭环。'
,
keyword
:
'印章, 台账, 紧急签约'
,
case_source
:
'合规检查复盘'
,
case_level
:
'中'
,
case_year
:
2025
,
},
},
{
...
...
@@ -57,10 +85,17 @@ const DEMO_CASES = [
'财务负责人通过虚构往来科目和短期借款名义转移资金,月末再以内部调账方式冲回,导致多轮抽样测试均未及时发现异常。案件暴露出资金支付复核与异常波动分析两道防线同时失效。'
,
metadata
:
{
label
:
'资金挪用预警案例'
,
content
:
'财务负责人通过虚构往来科目和短期借款名义转移资金,月末再以内部调账方式冲回,导致多轮抽样测试均未及时发现异常。案件暴露出资金支付复核与异常波动分析两道防线同时失效。'
,
category
:
'舞弊风险'
,
tag
:
'资金管理'
,
case_type
:
'资金挪用'
,
case_attribute
:
'舞弊预警'
,
case_details
:
'财务负责人通过虚构往来科目和短期借款名义转移资金,月末再以内部调账方式冲回,导致多轮抽样测试均未及时发现异常。案件暴露出资金支付复核与异常波动分析两道防线同时失效。'
,
case_warning
:
'资金支付复核应与月末异常波动分析交叉验证。'
,
keyword
:
'资金管理, 异常调账'
,
case_source
:
'内部通报'
,
case_level
:
'高'
,
case_year
:
2025
,
},
},
{
...
...
@@ -74,10 +109,17 @@ const DEMO_CASES = [
'项目团队为赶进度直接沿用历史供应商名单,新签供应商未重新完成资质核验,导致营业执照、关联关系与受益所有人信息长期缺失。后续排查时已无法还原当时的准入审批依据。'
,
metadata
:
{
label
:
'供应商准入资料缺失案例'
,
content
:
'项目团队为赶进度直接沿用历史供应商名单,新签供应商未重新完成资质核验,导致营业执照、关联关系与受益所有人信息长期缺失。后续排查时已无法还原当时的准入审批依据。'
,
category
:
'尽职调查'
,
tag
:
'供应商准入'
,
case_type
:
'准入资料缺失'
,
case_attribute
:
'供应商管理'
,
case_details
:
'项目团队为赶进度直接沿用历史供应商名单,新签供应商未重新完成资质核验,导致营业执照、关联关系与受益所有人信息长期缺失。后续排查时已无法还原当时的准入审批依据。'
,
case_warning
:
'供应商准入资料缺失会直接削弱后续尽调和责任追溯能力。'
,
keyword
:
'供应商, 尽调, 资质核验'
,
case_source
:
'专项排查'
,
case_level
:
'中'
,
case_year
:
2024
,
},
},
{
...
...
@@ -91,19 +133,33 @@ const DEMO_CASES = [
'运维团队在夜间批量发布期间共享高权限账号密码,虽然缩短了操作路径,但审计日志无法区分责任人。问题暴露出特权账号借用、审批、回收与日志关联机制均不完整。'
,
metadata
:
{
label
:
'高权限账号共享案例'
,
content
:
'运维团队在夜间批量发布期间共享高权限账号密码,虽然缩短了操作路径,但审计日志无法区分责任人。问题暴露出特权账号借用、审批、回收与日志关联机制均不完整。'
,
category
:
'信息安全'
,
tag
:
'权限控制'
,
case_type
:
'高权限账号共享'
,
case_attribute
:
'账号治理'
,
case_details
:
'运维团队在夜间批量发布期间共享高权限账号密码,虽然缩短了操作路径,但审计日志无法区分责任人。问题暴露出特权账号借用、审批、回收与日志关联机制均不完整。'
,
case_warning
:
'高权限账号必须做到实名借用、短时授权和日志可追溯。'
,
keyword
:
'特权账号, 权限控制, 审计日志'
,
case_source
:
'安全审计'
,
case_level
:
'高'
,
case_year
:
2023
,
},
},
];
const
IMPORT_FIELD_ALIASES
=
{
label
:
[
'label'
,
'title'
,
'标题'
,
'案例标题'
,
'名称'
],
content
:
[
'content'
,
'内容'
,
'正文'
,
'案例内容'
,
'描述'
,
'详情'
],
category
:
[
'category'
,
'分类'
,
'类别'
],
tag
:
[
'tag'
,
'标签'
,
'关键词'
,
'标记'
],
category
:
[
'category'
,
'分类'
,
'案例性质'
,
'类别'
],
tag
:
[
'tag'
,
'标签'
,
'标记'
],
caseType
:
[
'case_type'
,
'案例类型'
,
'类型'
],
caseAttribute
:
[
'case_attribute'
,
'案例属性'
,
'属性'
],
caseDetails
:
[
'case_details'
,
'案例详情'
,
'内容'
,
'正文'
,
'案例内容'
,
'描述'
,
'详情'
],
caseWarning
:
[
'case_warning'
,
'案例警示'
,
'案件警示'
,
'警示'
],
keyword
:
[
'keyword'
,
'关键字'
,
'关键词'
],
caseSource
:
[
'case_source'
,
'案例来源'
,
'来源'
],
caseLevel
:
[
'case_level'
,
'案例等级'
,
'等级'
],
caseYear
:
[
'case_year'
,
'案例年份'
,
'年份'
],
};
function
escapeRegExp
(
value
=
''
)
{
...
...
@@ -215,9 +271,16 @@ export function createCaseTitle(values = {}) {
return
label
;
}
const
content
=
String
(
values
.
content
||
''
).
replace
(
/
\s
+/g
,
' '
).
trim
();
if
(
content
)
{
return
content
.
slice
(
0
,
28
);
const
caseType
=
String
(
values
.
caseType
||
values
.
case_type
||
''
).
trim
();
if
(
caseType
)
{
return
caseType
;
}
const
caseDetails
=
String
(
values
.
caseDetails
||
values
.
case_details
||
values
.
content
||
''
)
.
replace
(
/
\s
+/g
,
' '
)
.
trim
();
if
(
caseDetails
)
{
return
caseDetails
.
slice
(
0
,
28
);
}
return
'未命名案例'
;
...
...
@@ -238,7 +301,13 @@ function buildKeywordFilter(keyword = '') {
$or
:
[
{
title
:
regex
},
{
'metadata.label'
:
regex
},
{
'metadata.content'
:
regex
},
{
'metadata.case_type'
:
regex
},
{
'metadata.case_attribute'
:
regex
},
{
'metadata.case_details'
:
regex
},
{
'metadata.case_warning'
:
regex
},
{
'metadata.keyword'
:
regex
},
{
'metadata.case_source'
:
regex
},
{
'metadata.case_level'
:
regex
},
{
'metadata.category'
:
regex
},
{
'metadata.tag'
:
regex
},
],
...
...
@@ -292,20 +361,40 @@ function hasActiveFilters(filters = {}) {
export
function
normalizeCaseRecord
(
record
=
{})
{
const
metadata
=
record
?.
metadata
||
{};
const
label
=
String
(
metadata
.
label
||
record
.
title
||
''
).
trim
();
const
content
=
String
(
metadata
.
content
||
record
.
content
||
''
).
trim
();
const
caseType
=
String
(
metadata
.
case_type
||
''
).
trim
();
const
caseAttribute
=
String
(
metadata
.
case_attribute
||
''
).
trim
();
const
caseDetails
=
String
(
metadata
.
case_details
||
metadata
.
content
||
record
.
content
||
''
).
trim
();
const
caseWarning
=
String
(
metadata
.
case_warning
||
''
).
trim
();
const
keyword
=
String
(
metadata
.
keyword
||
''
).
trim
();
const
caseSource
=
String
(
metadata
.
case_source
||
''
).
trim
();
const
caseLevel
=
String
(
metadata
.
case_level
||
''
).
trim
();
const
category
=
String
(
metadata
.
category
||
''
).
trim
();
const
tag
=
String
(
metadata
.
tag
||
''
).
trim
();
const
caseYearValue
=
metadata
.
case_year
;
const
caseYear
=
caseYearValue
===
null
||
caseYearValue
===
undefined
||
caseYearValue
===
''
?
''
:
String
(
caseYearValue
).
trim
();
const
createdAt
=
record
.
created_at
||
''
;
const
updatedAt
=
record
.
updated_at
||
createdAt
||
''
;
const
textLength
=
String
(
c
ontent
).
replace
(
/
\s
+/g
,
''
).
length
;
const
textLength
=
String
(
c
aseDetails
).
replace
(
/
\s
+/g
,
''
).
length
;
return
{
id
:
record
.
_id
||
record
.
id
||
record
.
name
||
''
,
recordName
:
record
.
name
||
record
.
_id
||
record
.
id
||
''
,
title
:
createCaseTitle
({
label
,
content
}),
label
:
label
||
createCaseTitle
({
content
}),
content
,
contentPreview
:
createSummary
(
content
),
title
:
createCaseTitle
({
label
,
caseType
,
caseDetails
}),
label
:
label
||
createCaseTitle
({
caseType
,
caseDetails
}),
caseType
,
caseAttribute
,
caseDetails
,
caseWarning
,
keyword
,
caseSource
,
caseLevel
,
caseYear
,
caseYearLabel
:
caseYear
||
'-'
,
content
:
caseDetails
,
contentPreview
:
createSummary
(
caseDetails
),
category
:
category
||
'未分类'
,
tag
,
textLength
,
...
...
@@ -327,7 +416,18 @@ function applyClientFilters(cases, filters = {}) {
return
cases
.
filter
((
item
)
=>
{
const
matchesKeyword
=
!
keyword
||
[
item
.
label
,
item
.
content
,
item
.
category
,
item
.
tag
]
[
item
.
label
,
item
.
caseType
,
item
.
caseAttribute
,
item
.
caseDetails
,
item
.
caseWarning
,
item
.
keyword
,
item
.
caseSource
,
item
.
caseLevel
,
item
.
category
,
item
.
tag
,
]
.
filter
(
Boolean
)
.
some
((
value
)
=>
String
(
value
).
toLowerCase
().
includes
(
keyword
));
...
...
@@ -378,7 +478,10 @@ export function buildCaseDashboard(cases = [], options = {}) {
.
sort
((
left
,
right
)
=>
Number
(
right
.
textLength
||
0
)
-
Number
(
left
.
textLength
||
0
))
.
slice
(
0
,
5
);
const
attentionCases
=
cases
.
filter
((
item
)
=>
!
item
.
tag
||
!
item
.
category
||
Number
(
item
.
textLength
||
0
)
<
80
)
.
filter
(
(
item
)
=>
!
item
.
keyword
||
!
item
.
category
||
Number
(
item
.
textLength
||
0
)
<
80
,
)
.
slice
(
0
,
5
);
return
{
...
...
@@ -504,21 +607,42 @@ export async function loadCaseRecords(filters = {}) {
export
function
buildCasePayload
(
values
=
{})
{
const
label
=
String
(
values
.
label
||
''
).
trim
();
const
content
=
String
(
values
.
content
||
''
).
trim
();
const
caseType
=
String
(
values
.
caseType
||
values
.
case_type
||
''
).
trim
();
const
caseAttribute
=
String
(
values
.
caseAttribute
||
values
.
case_attribute
||
''
).
trim
();
const
caseDetails
=
String
(
values
.
caseDetails
||
values
.
case_details
||
values
.
content
||
''
).
trim
();
const
caseWarning
=
String
(
values
.
caseWarning
||
values
.
case_warning
||
''
).
trim
();
const
keyword
=
String
(
values
.
keyword
||
''
).
trim
();
const
caseSource
=
String
(
values
.
caseSource
||
values
.
case_source
||
''
).
trim
();
const
caseLevel
=
String
(
values
.
caseLevel
||
values
.
case_level
||
''
).
trim
();
const
category
=
String
(
values
.
category
||
''
).
trim
();
const
tag
=
String
(
values
.
tag
||
''
).
trim
();
const
title
=
createCaseTitle
({
label
,
content
});
const
caseYearRaw
=
values
.
caseYear
??
values
.
case_year
??
''
;
const
normalizedCaseYearText
=
String
(
caseYearRaw
??
''
).
trim
();
const
caseYear
=
normalizedCaseYearText
===
''
?
null
:
Number
.
isFinite
(
Number
(
normalizedCaseYearText
))
?
Number
(
normalizedCaseYearText
)
:
null
;
const
title
=
createCaseTitle
({
label
,
caseType
,
caseDetails
});
return
{
subject
:
CASE_SUBJECT
,
title
,
content_type
:
'text'
,
content
,
content
:
caseDetails
,
metadata
:
{
label
,
content
,
category
,
tag
,
case_type
:
caseType
,
case_attribute
:
caseAttribute
,
case_details
:
caseDetails
,
case_warning
:
caseWarning
,
keyword
,
case_source
:
caseSource
,
case_level
:
caseLevel
,
case_year
:
caseYear
,
},
};
}
...
...
@@ -569,6 +693,137 @@ export async function deleteCaseRecord(caseItem) {
await
removeRecord
(
recordName
);
}
async
function
runCaseMutationInBatches
(
entries
=
[],
worker
,
batchSize
=
5
)
{
const
successes
=
[];
const
failures
=
[];
for
(
let
index
=
0
;
index
<
entries
.
length
;
index
+=
batchSize
)
{
const
batch
=
entries
.
slice
(
index
,
index
+
batchSize
);
const
results
=
await
Promise
.
allSettled
(
batch
.
map
((
entry
)
=>
worker
(
entry
)));
results
.
forEach
((
result
,
batchIndex
)
=>
{
const
entry
=
batch
[
batchIndex
];
if
(
result
.
status
===
'fulfilled'
)
{
successes
.
push
(
result
.
value
??
entry
);
return
;
}
failures
.
push
({
id
:
entry
?.
id
||
entry
?.
recordName
||
''
,
label
:
entry
?.
label
||
entry
?.
title
||
'未命名案例'
,
message
:
result
.
reason
?.
message
||
'操作失败'
,
});
});
}
return
{
successes
,
failures
,
};
}
export
async
function
deleteCaseRecords
(
caseItems
=
[])
{
const
normalizedItems
=
caseItems
.
filter
(
Boolean
);
const
{
successes
,
failures
}
=
await
runCaseMutationInBatches
(
normalizedItems
,
(
caseItem
)
=>
deleteCaseRecord
(
caseItem
),
);
return
{
deletedCount
:
successes
.
length
,
failedCount
:
failures
.
length
,
failures
,
};
}
export
async
function
updateCaseRecords
(
caseItems
=
[],
values
=
{})
{
const
normalizedItems
=
caseItems
.
filter
(
Boolean
);
const
metadataPatch
=
{};
const
textPatchFields
=
[
[
'label'
,
values
.
label
],
[
'category'
,
values
.
category
],
[
'tag'
,
values
.
tag
],
[
'case_type'
,
values
.
caseType
??
values
.
case_type
],
[
'case_attribute'
,
values
.
caseAttribute
??
values
.
case_attribute
],
[
'case_details'
,
values
.
caseDetails
??
values
.
case_details
],
[
'case_warning'
,
values
.
caseWarning
??
values
.
case_warning
],
[
'keyword'
,
values
.
keyword
],
[
'case_source'
,
values
.
caseSource
??
values
.
case_source
],
[
'case_level'
,
values
.
caseLevel
??
values
.
case_level
],
];
textPatchFields
.
forEach
(([
fieldName
,
fieldValue
])
=>
{
const
normalizedValue
=
String
(
fieldValue
??
''
).
trim
();
if
(
normalizedValue
!==
''
)
{
metadataPatch
[
fieldName
]
=
normalizedValue
;
}
});
const
caseYearRaw
=
values
.
caseYear
??
values
.
case_year
;
const
normalizedCaseYear
=
String
(
caseYearRaw
??
''
).
trim
();
if
(
normalizedCaseYear
!==
''
&&
Number
.
isFinite
(
Number
(
normalizedCaseYear
))
)
{
metadataPatch
.
case_year
=
Number
(
normalizedCaseYear
);
}
const
{
successes
,
failures
}
=
await
runCaseMutationInBatches
(
normalizedItems
,
async
(
caseItem
)
=>
{
const
recordName
=
caseItem
?.
recordName
||
caseItem
?.
id
;
if
(
!
recordName
)
{
throw
new
Error
(
'未找到可更新的案例记录标识'
);
}
const
nextMetadata
=
{
...
caseItem
.
rawMetadata
,
...
metadataPatch
,
};
const
nextCaseDetails
=
String
(
nextMetadata
.
case_details
||
nextMetadata
.
content
||
caseItem
.
rawRecord
?.
content
||
''
,
).
trim
();
const
nextLabel
=
String
(
nextMetadata
.
label
||
''
).
trim
();
const
nextCaseType
=
String
(
nextMetadata
.
case_type
||
''
).
trim
();
const
nextTitle
=
createCaseTitle
({
label
:
nextLabel
,
caseType
:
nextCaseType
,
caseDetails
:
nextCaseDetails
,
});
const
updated
=
await
updateRecord
(
recordName
,
{
title
:
nextTitle
,
content_type
:
'text'
,
content
:
nextCaseDetails
,
metadata
:
nextMetadata
,
});
return
normalizeCaseRecord
(
updated
?.
data
||
updated
?.
result
||
{
...
caseItem
.
rawRecord
,
title
:
nextTitle
,
content
:
nextCaseDetails
,
metadata
:
nextMetadata
,
},
);
},
);
return
{
updatedCases
:
successes
,
updatedCount
:
successes
.
length
,
failedCount
:
failures
.
length
,
failures
,
};
}
function
getImportFieldValue
(
row
=
{},
aliases
=
[])
{
const
normalizedAliasMap
=
new
Map
(
Object
.
entries
(
row
).
map
(([
key
,
value
])
=>
[
normalizeHeaderKey
(
key
),
value
]),
...
...
@@ -588,9 +843,16 @@ function normalizeWorkbookRow(row = {}, rowIndex = 0) {
return
{
rowNumber
:
rowIndex
+
2
,
label
:
getImportFieldValue
(
row
,
IMPORT_FIELD_ALIASES
.
label
),
content
:
getImportFieldValue
(
row
,
IMPORT_FIELD_ALIASES
.
content
),
category
:
getImportFieldValue
(
row
,
IMPORT_FIELD_ALIASES
.
category
),
tag
:
getImportFieldValue
(
row
,
IMPORT_FIELD_ALIASES
.
tag
),
caseType
:
getImportFieldValue
(
row
,
IMPORT_FIELD_ALIASES
.
caseType
),
caseAttribute
:
getImportFieldValue
(
row
,
IMPORT_FIELD_ALIASES
.
caseAttribute
),
caseDetails
:
getImportFieldValue
(
row
,
IMPORT_FIELD_ALIASES
.
caseDetails
),
caseWarning
:
getImportFieldValue
(
row
,
IMPORT_FIELD_ALIASES
.
caseWarning
),
keyword
:
getImportFieldValue
(
row
,
IMPORT_FIELD_ALIASES
.
keyword
),
caseSource
:
getImportFieldValue
(
row
,
IMPORT_FIELD_ALIASES
.
caseSource
),
caseLevel
:
getImportFieldValue
(
row
,
IMPORT_FIELD_ALIASES
.
caseLevel
),
caseYear
:
getImportFieldValue
(
row
,
IMPORT_FIELD_ALIASES
.
caseYear
),
};
}
...
...
@@ -615,7 +877,21 @@ export async function previewCaseWorkbook(file) {
raw
:
false
,
})
.
map
((
row
,
index
)
=>
normalizeWorkbookRow
(
row
,
index
))
.
filter
((
row
)
=>
row
.
label
||
row
.
content
||
row
.
category
||
row
.
tag
);
.
filter
((
row
)
=>
[
row
.
label
,
row
.
category
,
row
.
tag
,
row
.
caseType
,
row
.
caseAttribute
,
row
.
caseDetails
,
row
.
caseWarning
,
row
.
keyword
,
row
.
caseSource
,
row
.
caseLevel
,
row
.
caseYear
,
].
some
(
Boolean
),
);
return
{
sheetName
,
...
...
@@ -665,12 +941,17 @@ export async function importCaseWorkbook(file) {
const
preview
=
await
previewCaseWorkbook
(
file
);
if
(
preview
.
totalRows
===
0
)
{
throw
new
Error
(
'未识别到可导入的数据行,请检查表头是否包含标题、内容、分类、标签'
);
throw
new
Error
(
'未识别到可导入的数据行,请检查表头是否包含标题、分类、标签、案例类型、案例详情等字段'
,
);
}
const
entries
=
preview
.
rows
.
map
((
row
)
=>
({
rowNumber
:
row
.
rowNumber
,
payload
:
buildCasePayload
(
row
),
payload
:
buildCasePayload
({
...
row
,
tag
:
row
.
tag
||
row
.
keyword
,
}),
}));
const
{
createdCases
,
failures
}
=
await
createRecordsInBatches
(
entries
);
...
...
@@ -683,3 +964,80 @@ export async function importCaseWorkbook(file) {
createdCases
,
};
}
async
function
loadAllCaseRecords
(
filters
=
{})
{
const
pageSize
=
200
;
const
mergedCases
=
[];
let
page
=
0
;
let
hasMore
=
true
;
while
(
hasMore
)
{
const
result
=
await
loadCasePage
({
...
filters
,
page
,
pageSize
,
});
mergedCases
.
push
(...(
result
.
cases
||
[]));
hasMore
=
Boolean
(
result
.
hasMore
);
page
+=
1
;
}
return
mergedCases
;
}
function
buildExportRow
(
caseItem
=
{})
{
return
{
标题
:
caseItem
.
label
||
createCaseTitle
(
caseItem
),
分类
:
caseItem
.
category
||
''
,
标签
:
caseItem
.
tag
||
''
,
案例类型
:
caseItem
.
caseType
||
''
,
案例属性
:
caseItem
.
caseAttribute
||
''
,
案例详情
:
caseItem
.
caseDetails
||
caseItem
.
content
||
''
,
案例警示
:
caseItem
.
caseWarning
||
''
,
关键字
:
caseItem
.
keyword
||
''
,
案例来源
:
caseItem
.
caseSource
||
''
,
案例等级
:
caseItem
.
caseLevel
||
''
,
案例年份
:
caseItem
.
caseYear
||
''
,
};
}
function
createWorkbookFromRows
(
rows
=
[],
sheetName
=
'案例库'
)
{
const
worksheet
=
XLSX
.
utils
.
json_to_sheet
(
rows
,
{
header
:
CASE_EXPORT_HEADERS
,
});
const
workbook
=
XLSX
.
utils
.
book_new
();
XLSX
.
utils
.
book_append_sheet
(
workbook
,
worksheet
,
sheetName
);
return
workbook
;
}
export
async
function
exportCaseWorkbook
(
filters
=
{})
{
const
cases
=
await
loadAllCaseRecords
(
filters
);
return
exportSelectedCaseWorkbook
(
cases
);
}
export
async
function
exportSelectedCaseWorkbook
(
caseItems
=
[])
{
const
rows
=
(
caseItems
||
[]).
map
((
item
)
=>
buildExportRow
(
item
));
const
workbook
=
createWorkbookFromRows
(
rows
,
'案例库'
);
const
fileName
=
`案例库批量导出_
${
dayjs
().
format
(
'YYYYMMDD_HHmmss'
)}
.xlsx`
;
XLSX
.
writeFile
(
workbook
,
fileName
);
return
{
fileName
,
count
:
rows
.
length
,
};
}
export
async
function
exportCaseTemplateWorkbook
()
{
const
workbook
=
createWorkbookFromRows
([],
'案例导入模板'
);
const
fileName
=
`案例库导入模板_
${
dayjs
().
format
(
'YYYYMMDD_HHmmss'
)}
.xlsx`
;
XLSX
.
writeFile
(
workbook
,
fileName
);
return
{
fileName
,
};
}
src/pages/project_manager_xy/resource-booking/index.jsx
View file @
6f4521a3
import
{
Button
}
from
'@/catalyst-ui-kit/javascript/button'
;
import
{
Checkbox
}
from
'@/catalyst-ui-kit/javascript/checkbox'
;
import
{
Dialog
,
DialogActions
,
...
...
@@ -6,6 +7,18 @@ import {
DialogTitle
,
}
from
'@/catalyst-ui-kit/javascript/dialog'
;
import
{
Input
,
InputGroup
}
from
'@/catalyst-ui-kit/javascript/input'
;
import
{
Segment
,
SegmentOption
,
}
from
'@/catalyst-ui-kit/javascript/segment'
;
import
{
Table
,
TableBody
,
TableCell
,
TableHead
,
TableHeader
,
TableRow
,
}
from
'@/catalyst-ui-kit/javascript/table'
;
import
{
Textarea
}
from
'@/catalyst-ui-kit/javascript/textarea'
;
import
{
ArrowDownTrayIcon
,
...
...
@@ -14,8 +27,9 @@ import {
BookOpenIcon
,
ClipboardDocumentIcon
,
MagnifyingGlassIcon
,
PencilSquareIcon
,
Squares2X2Icon
,
Ta
g
Icon
,
Ta
bleCells
Icon
,
TrashIcon
,
}
from
'@heroicons/react/24/outline'
;
import
clsx
from
'clsx'
;
...
...
@@ -33,15 +47,23 @@ import {
createCaseRecord
,
createCaseTitle
,
deleteCaseRecord
,
deleteCaseRecords
,
DEFAULT_PAGE_SIZE
,
formatDateTime
,
exportCaseTemplateWorkbook
,
exportCaseWorkbook
,
exportSelectedCaseWorkbook
,
importCaseWorkbook
,
loadCasePage
,
previewCaseWorkbook
,
updateCaseRecord
,
updateCaseRecords
,
}
from
'../materialService'
;
const
ALL_CATEGORY_KEY
=
'__all__'
;
const
CARD_VIEW_MODE
=
'card'
;
const
TABLE_VIEW_MODE
=
'table'
;
const
STICKY_TABLE_HEADER_CLASS_NAME
=
'sticky top-0 z-10 bg-white/95 backdrop-blur supports-[backdrop-filter]:bg-white/85'
;
function
Panel
({
className
,
children
})
{
return
(
...
...
@@ -59,7 +81,7 @@ function Panel({ className, children }) {
function
Badge
({
children
,
tone
=
'default'
})
{
const
toneClass
=
tone
===
'sky'
?
'border-
sky-200 bg-sky-50 text-sky-700
'
?
'border-
[color:color-mix(in_srgb,var(--pm-brand)_18%,white)] bg-[color:color-mix(in_srgb,var(--pm-brand)_10%,white)] text-[var(--pm-brand)]
'
:
tone
===
'emerald'
?
'border-emerald-200 bg-emerald-50 text-emerald-700'
:
tone
===
'amber'
...
...
@@ -88,7 +110,7 @@ function CategoryTab({ active, label, count, onClick }) {
className=
{
clsx
(
'inline-flex items-center gap-2 rounded-full border px-4 py-2 text-sm font-medium transition-all'
,
active
?
'border-
sky-300 bg-sky-50 text-sky-700
shadow-sm'
?
'border-
[color:color-mix(in_srgb,var(--pm-brand)_26%,white)] bg-[color:color-mix(in_srgb,var(--pm-brand)_10%,white)] text-[var(--pm-brand)]
shadow-sm'
:
'border-zinc-200 bg-white text-zinc-600 hover:border-zinc-300 hover:bg-zinc-50'
,
)
}
>
...
...
@@ -96,7 +118,9 @@ function CategoryTab({ active, label, count, onClick }) {
<
span
className=
{
clsx
(
'rounded-full px-2 py-0.5 text-xs'
,
active
?
'bg-sky-100 text-sky-700'
:
'bg-zinc-100 text-zinc-500'
,
active
?
'bg-[color:color-mix(in_srgb,var(--pm-brand)_12%,white)] text-[var(--pm-brand)]'
:
'bg-zinc-100 text-zinc-500'
,
)
}
>
{
count
}
...
...
@@ -120,19 +144,23 @@ function CaseCard({ item, onOpen }) {
<
button
type=
"button"
onClick=
{
()
=>
onOpen
(
item
)
}
className=
"group flex h-full w-full flex-col rounded-[28px] border border-zinc-200 bg-white p-5 text-left shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:border-
sky-300 hover:shadow-[0_18px_45px_rgba(14,165,233,0.12
)]"
className=
"group flex h-full w-full flex-col rounded-[28px] border border-zinc-200 bg-white p-5 text-left shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:border-
[color:color-mix(in_srgb,var(--pm-brand)_26%,white)] hover:shadow-[0_18px_45px_rgba(180,35,24,0.14
)]"
>
<
div
className=
"flex items-start justify-between gap-3"
>
<
div
className=
"space-y-2"
>
<
div
className=
"flex flex-wrap items-center gap-2"
>
<
Badge
tone=
"sky"
>
{
item
.
category
}
</
Badge
>
{
item
.
caseLevel
?
<
Badge
tone=
"amber"
>
{
item
.
caseLevel
}
</
Badge
>
:
null
}
{
item
.
tag
?
<
Badge
tone=
"emerald"
>
{
item
.
tag
}
</
Badge
>
:
null
}
</
div
>
<
div
className=
"text-lg font-semibold tracking-tight text-zinc-950"
>
{
item
.
label
||
'未命名案例'
}
</
div
>
{
item
.
caseType
?
(
<
div
className=
"text-sm font-medium text-zinc-500"
>
{
item
.
caseType
}
</
div
>
)
:
null
}
</
div
>
<
div
className=
"flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-[linear-gradient(135deg,rgba(1
4,165,233,0.12),rgba(16,185,129,0.12))] text-sky-700
"
>
<
div
className=
"flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-[linear-gradient(135deg,rgba(1
80,35,24,0.12),rgba(244,63,94,0.12))] text-[var(--pm-brand)]
"
>
<
BookOpenIcon
className=
"size-5"
/>
</
div
>
</
div
>
...
...
@@ -143,6 +171,7 @@ function CaseCard({ item, onOpen }) {
<
div
className=
"mt-5 flex flex-wrap items-center gap-3 text-xs text-zinc-500"
>
<
span
>
{
item
.
textLengthLabel
}
</
span
>
{
item
.
caseYear
?
<
span
>
{
item
.
caseYear
}
年
</
span
>
:
null
}
<
span
>
更新于
{
item
.
updatedAtLabel
}
</
span
>
</
div
>
</
button
>
...
...
@@ -152,9 +181,16 @@ function CaseCard({ item, onOpen }) {
function
buildEmptyCaseForm
()
{
return
{
label
:
''
,
content
:
''
,
category
:
''
,
tag
:
''
,
caseType
:
''
,
caseAttribute
:
''
,
caseDetails
:
''
,
caseWarning
:
''
,
keyword
:
''
,
caseSource
:
''
,
caseLevel
:
''
,
caseYear
:
''
,
};
}
...
...
@@ -165,12 +201,49 @@ function buildCaseForm(caseItem) {
return
{
label
:
caseItem
.
label
||
''
,
content
:
caseItem
.
content
||
''
,
category
:
caseItem
.
category
===
'未分类'
?
''
:
caseItem
.
category
||
''
,
tag
:
caseItem
.
tag
||
''
,
caseType
:
caseItem
.
caseType
||
''
,
caseAttribute
:
caseItem
.
caseAttribute
||
''
,
caseDetails
:
caseItem
.
caseDetails
||
''
,
caseWarning
:
caseItem
.
caseWarning
||
''
,
keyword
:
caseItem
.
keyword
||
''
,
caseSource
:
caseItem
.
caseSource
||
''
,
caseLevel
:
caseItem
.
caseLevel
||
''
,
caseYear
:
caseItem
.
caseYear
||
''
,
};
}
function
buildEmptyBatchEditForm
()
{
return
{
category
:
''
,
tag
:
''
,
caseType
:
''
,
caseAttribute
:
''
,
keyword
:
''
,
caseSource
:
''
,
caseLevel
:
''
,
caseYear
:
''
,
caseWarning
:
''
,
};
}
function
hasBatchEditValues
(
values
=
{})
{
return
Object
.
values
(
values
).
some
((
value
)
=>
String
(
value
??
''
).
trim
()
!==
''
);
}
function
buildBatchEditPayload
(
values
=
{})
{
return
Object
.
entries
(
values
).
reduce
((
accumulator
,
[
key
,
value
])
=>
{
const
normalizedValue
=
String
(
value
??
''
).
trim
();
if
(
normalizedValue
!==
''
)
{
accumulator
[
key
]
=
normalizedValue
;
}
return
accumulator
;
},
{});
}
function
buildImportPreviewRows
(
preview
)
{
return
preview
?.
previewRows
||
[];
}
...
...
@@ -182,9 +255,11 @@ export default function ResourceBookingPage() {
const
deferredQuery
=
useDeferredValue
(
query
);
const
[
tagFilter
,
setTagFilter
]
=
useState
(
''
);
const
[
activeCategory
,
setActiveCategory
]
=
useState
(
ALL_CATEGORY_KEY
);
const
[
viewMode
,
setViewMode
]
=
useState
(
CARD_VIEW_MODE
);
const
[
cases
,
setCases
]
=
useState
([]);
const
[
dashboard
,
setDashboard
]
=
useState
(()
=>
buildCaseDashboard
([]));
const
[
activeCaseId
,
setActiveCaseId
]
=
useState
(
''
);
const
[
selectedCaseIds
,
setSelectedCaseIds
]
=
useState
([]);
const
[
page
,
setPage
]
=
useState
(
0
);
const
[
total
,
setTotal
]
=
useState
(
0
);
const
[
hasMore
,
setHasMore
]
=
useState
(
false
);
...
...
@@ -193,17 +268,25 @@ export default function ResourceBookingPage() {
const
[
saving
,
setSaving
]
=
useState
(
false
);
const
[
creating
,
setCreating
]
=
useState
(
false
);
const
[
deleting
,
setDeleting
]
=
useState
(
false
);
const
[
batchSaving
,
setBatchSaving
]
=
useState
(
false
);
const
[
batchDeleting
,
setBatchDeleting
]
=
useState
(
false
);
const
[
previewingImport
,
setPreviewingImport
]
=
useState
(
false
);
const
[
importing
,
setImporting
]
=
useState
(
false
);
const
[
exporting
,
setExporting
]
=
useState
(
false
);
const
[
exportingSelected
,
setExportingSelected
]
=
useState
(
false
);
const
[
exportingTemplate
,
setExportingTemplate
]
=
useState
(
false
);
const
[
error
,
setError
]
=
useState
(
''
);
const
[
notice
,
setNotice
]
=
useState
(
''
);
const
[
isDemoMode
,
setIsDemoMode
]
=
useState
(
false
);
const
[
isCreateOpen
,
setIsCreateOpen
]
=
useState
(
false
);
const
[
isDetailOpen
,
setIsDetailOpen
]
=
useState
(
false
);
const
[
isDeleteOpen
,
setIsDeleteOpen
]
=
useState
(
false
);
const
[
isBatchDeleteOpen
,
setIsBatchDeleteOpen
]
=
useState
(
false
);
const
[
isBatchEditOpen
,
setIsBatchEditOpen
]
=
useState
(
false
);
const
[
isImportOpen
,
setIsImportOpen
]
=
useState
(
false
);
const
[
createForm
,
setCreateForm
]
=
useState
(
buildEmptyCaseForm
);
const
[
detailForm
,
setDetailForm
]
=
useState
(
buildEmptyCaseForm
);
const
[
batchEditForm
,
setBatchEditForm
]
=
useState
(
buildEmptyBatchEditForm
);
const
[
importFile
,
setImportFile
]
=
useState
(
null
);
const
[
importPreview
,
setImportPreview
]
=
useState
(
null
);
...
...
@@ -234,6 +317,22 @@ export default function ResourceBookingPage() {
return
cases
.
filter
((
item
)
=>
item
.
category
===
activeCategory
);
},
[
activeCategory
,
cases
]);
const
selectedCaseIdSet
=
useMemo
(
()
=>
new
Set
(
selectedCaseIds
),
[
selectedCaseIds
],
);
const
selectedCases
=
useMemo
(()
=>
{
return
visibleCases
.
filter
((
item
)
=>
selectedCaseIdSet
.
has
(
item
.
id
));
},
[
selectedCaseIdSet
,
visibleCases
]);
const
allVisibleSelected
=
visibleCases
.
length
>
0
&&
visibleCases
.
every
((
item
)
=>
selectedCaseIdSet
.
has
(
item
.
id
));
const
partiallyVisibleSelected
=
visibleCases
.
some
((
item
)
=>
selectedCaseIdSet
.
has
(
item
.
id
))
&&
!
allVisibleSelected
;
const
previewRows
=
useMemo
(
()
=>
buildImportPreviewRows
(
importPreview
),
[
importPreview
],
...
...
@@ -252,6 +351,14 @@ export default function ResourceBookingPage() {
}
},
[
activeCategory
,
categoryTabs
]);
useEffect
(()
=>
{
const
visibleIds
=
new
Set
(
visibleCases
.
map
((
item
)
=>
item
.
id
));
setSelectedCaseIds
((
current
)
=>
current
.
filter
((
caseId
)
=>
visibleIds
.
has
(
caseId
)),
);
},
[
visibleCases
]);
useEffect
(()
=>
{
let
cancelled
=
false
;
...
...
@@ -402,6 +509,10 @@ export default function ResourceBookingPage() {
setCreateForm
(
buildEmptyCaseForm
());
}
function
resetBatchEditForm
()
{
setBatchEditForm
(
buildEmptyBatchEditForm
());
}
function
resetImportState
()
{
setImportFile
(
null
);
setImportPreview
(
null
);
...
...
@@ -417,9 +528,32 @@ export default function ResourceBookingPage() {
setNotice
(
''
);
}
function
handleToggleCaseSelection
(
caseId
,
checked
)
{
setSelectedCaseIds
((
current
)
=>
{
if
(
checked
)
{
return
current
.
includes
(
caseId
)
?
current
:
[...
current
,
caseId
];
}
return
current
.
filter
((
item
)
=>
item
!==
caseId
);
});
}
function
handleToggleSelectAll
(
checked
)
{
if
(
checked
)
{
setSelectedCaseIds
(
visibleCases
.
map
((
item
)
=>
item
.
id
));
return
;
}
setSelectedCaseIds
([]);
}
async
function
handleCreateSubmit
()
{
if
(
!
String
(
createForm
.
label
||
''
).
trim
()
&&
!
String
(
createForm
.
content
||
''
).
trim
())
{
setError
(
'请至少填写标题或内容。'
);
if
(
!
String
(
createForm
.
label
||
''
).
trim
()
&&
!
String
(
createForm
.
caseType
||
''
).
trim
()
&&
!
String
(
createForm
.
caseDetails
||
''
).
trim
()
)
{
setError
(
'请至少填写标题、案例类型或案例详情中的一项。'
);
return
;
}
...
...
@@ -447,8 +581,12 @@ export default function ResourceBookingPage() {
return
;
}
if
(
!
String
(
detailForm
.
label
||
''
).
trim
()
&&
!
String
(
detailForm
.
content
||
''
).
trim
())
{
setError
(
'请至少填写标题或内容。'
);
if
(
!
String
(
detailForm
.
label
||
''
).
trim
()
&&
!
String
(
detailForm
.
caseType
||
''
).
trim
()
&&
!
String
(
detailForm
.
caseDetails
||
''
).
trim
()
)
{
setError
(
'请至少填写标题、案例类型或案例详情中的一项。'
);
return
;
}
...
...
@@ -490,6 +628,95 @@ export default function ResourceBookingPage() {
}
}
async
function
handleBatchDeleteCases
()
{
if
(
selectedCases
.
length
===
0
)
{
return
;
}
setBatchDeleting
(
true
);
setError
(
''
);
setNotice
(
''
);
try
{
const
result
=
await
deleteCaseRecords
(
selectedCases
);
if
(
selectedCaseIdSet
.
has
(
activeCaseId
))
{
setIsDetailOpen
(
false
);
}
setIsBatchDeleteOpen
(
false
);
await
refreshCurrentList
();
const
failedIds
=
result
.
failures
.
map
((
item
)
=>
item
.
id
)
.
filter
(
Boolean
);
setSelectedCaseIds
(
failedIds
);
setNotice
(
result
.
failedCount
>
0
?
`已删除
${
result
.
deletedCount
}
条案例,
${
result
.
failedCount
}
条删除失败。`
:
`已批量删除
${
result
.
deletedCount
}
条案例。`
,
);
if
(
result
.
failedCount
>
0
)
{
setError
(
`删除失败:
${
result
.
failures
[
0
]?.
label
||
'部分案例'
}
,
$
{
result
.
failures
[
0
]?.
message
||
'请稍后重试。'
}
`,
);
}
} catch (deleteError) {
setError(deleteError?.message || '批量删除失败,请稍后重试。');
} finally {
setBatchDeleting(false);
}
}
async function handleBatchEditSubmit() {
if (selectedCases.length === 0) {
return;
}
if (!hasBatchEditValues(batchEditForm)) {
setError('请至少填写一个要批量覆盖的字段。');
return;
}
setBatchSaving(true);
setError('');
setNotice('');
try {
const result = await updateCaseRecords(
selectedCases,
buildBatchEditPayload(batchEditForm),
);
setIsBatchEditOpen(false);
resetBatchEditForm();
await refreshCurrentList();
const failedIds = result.failures
.map((item) => item.id)
.filter(Boolean);
setSelectedCaseIds(failedIds);
setNotice(
result.failedCount > 0
? `
已批量更新
$
{
result
.
updatedCount
}
条案例,
$
{
result
.
failedCount
}
条更新失败。
`
: `
已批量更新
$
{
result
.
updatedCount
}
条案例。
`,
);
if (result.failedCount > 0) {
setError(
`
更新失败:
$
{
result
.
failures
[
0
]?.
label
||
'部分案例'
}
,
$
{
result
.
failures
[
0
]?.
message
||
'请稍后重试。'
}
`,
);
}
} catch (updateError) {
setError(updateError?.message || '批量修改失败,请稍后重试。');
} finally {
setBatchSaving(false);
}
}
async function handleCopyCaseContent() {
if (!activeCase) {
return;
...
...
@@ -497,9 +724,9 @@ export default function ResourceBookingPage() {
try {
await navigator.clipboard.writeText(
`
${
activeCase
.
label
}
\n\n
${
activeCase
.
c
ontent
}
`
,
`
$
{
activeCase
.
label
}
\
n
\
n$
{
activeCase
.
c
aseDetails
||
''
}
`,
);
setNotice
(
'案例标题和
正文
已复制。'
);
setNotice('案例标题和
详情
已复制。');
} catch (_error) {
setError('复制失败,请检查浏览器权限。');
}
...
...
@@ -562,10 +789,63 @@ export default function ResourceBookingPage() {
}
}
async function handleExportCases() {
setExporting(true);
setError('');
setNotice('');
try {
const result = await exportCaseWorkbook({
keyword: deferredQuery,
category: activeCategory === ALL_CATEGORY_KEY ? '' : activeCategory,
tag: tagFilter,
});
setNotice(`
已导出
$
{
result
.
count
}
条案例到
$
{
result
.
fileName
}
。
`);
} catch (exportError) {
setError(exportError?.message || '案例导出失败,请稍后重试。');
} finally {
setExporting(false);
}
}
async function handleExportSelectedCases() {
if (selectedCases.length === 0) {
return;
}
setExportingSelected(true);
setError('');
setNotice('');
try {
const result = await exportSelectedCaseWorkbook(selectedCases);
setNotice(`
已导出
$
{
result
.
count
}
条勾选案例到
$
{
result
.
fileName
}
。
`);
} catch (exportError) {
setError(exportError?.message || '勾选案例导出失败,请稍后重试。');
} finally {
setExportingSelected(false);
}
}
async function handleExportTemplate() {
setExportingTemplate(true);
setError('');
setNotice('');
try {
const result = await exportCaseTemplateWorkbook();
setNotice(`
导入模板已导出:
$
{
result
.
fileName
}
。
`);
} catch (templateError) {
setError(templateError?.message || '导出模板失败,请稍后重试。');
} finally {
setExportingTemplate(false);
}
}
return (
<div className="space-y-6">
<Panel className="relative overflow-hidden p-6 sm:p-8">
<
div
className=
"absolute inset-0 bg-[linear-gradient(1
05deg,rgba(255,251,235,0.95)_0%,rgba(255,255,255,0.96)_42%,rgba(239,246,255,0.94)_100%),radial-gradient(circle_at_top_left,rgba(245,158,11,0.14),transparent_36%),radial-gradient(circle_at_top_right,rgba(14,165,2
33,0.16),transparent_34%)]"
/>
<div className="absolute inset-0 bg-[linear-gradient(1
35deg,rgba(255,255,255,0.96),rgba(255,241,242,0.94)),radial-gradient(circle_at_top_left,rgba(251,191,188,0.2),transparent_36%),radial-gradient(circle_at_top_right,rgba(251,113,1
33,0.16),transparent_34%)]" />
<div className="relative">
<div className="mt-5 flex flex-col gap-6 xl:flex-row xl:items-center xl:justify-between">
<div className="max-w-3xl">
...
...
@@ -582,7 +862,7 @@ export default function ResourceBookingPage() {
快捷操作
</div>
</div>
<
div
className=
"flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-[linear-gradient(145deg,rgba(1
4,165,233,0.14),rgba(245,158,11,0.14))] text-sky-700
"
>
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-[linear-gradient(145deg,rgba(1
80,35,24,0.14),rgba(244,63,94,0.14))] text-[var(--pm-brand)]
">
<BookOpenIcon className="size-5" />
</div>
</div>
...
...
@@ -612,6 +892,15 @@ export default function ResourceBookingPage() {
<ArrowUpTrayIcon className="size-4" />
Excel 批量导入
</Button>
<Button
outline
className="w-full justify-center whitespace-nowrap bg-white/80 cursor-pointer"
onClick={handleExportCases}
disabled={exporting || loading}
>
<ArrowDownTrayIcon className="size-4" />
{exporting ? '导出中...' : 'Excel 批量导出'}
</Button>
<Button
plain
className="w-full justify-center whitespace-nowrap text-zinc-600 cursor-pointer"
...
...
@@ -659,7 +948,7 @@ export default function ResourceBookingPage() {
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder=
"按标题、
内容、分类或标签
搜索"
placeholder="按标题、
案例类型、详情、警示、关键字等
搜索"
/>
</InputGroup>
</div>
...
...
@@ -699,30 +988,252 @@ export default function ResourceBookingPage() {
</Panel>
{loading ? (
<
Panel
className=
"p-6 text-sm text-
sky-700
"
>
正在加载案例工作台...
</
Panel
>
<Panel className="p-6 text-sm text-
[var(--pm-brand)]
">正在加载案例工作台...</Panel>
) : visibleCases.length === 0 ? (
<EmptyStatePage title="当前筛选条件下还没有案例" />
) : (
<div className="space-y-6">
<Panel className="p-5 sm:p-6">
<
div
className=
"mb-5 flex flex-col gap-
3 sm:flex-row sm:items-end sm
:justify-between"
>
<div className="mb-5 flex flex-col gap-
4 xl:flex-row xl:items-start xl
:justify-between">
<div>
<div className="flex items-center gap-2">
<
Squares2X2Icon
className=
"size-5 text-sky-600"
/>
{viewMode === CARD_VIEW_MODE ? (
<Squares2X2Icon className="size-5 text-[var(--pm-brand)]" />
) : (
<TableCellsIcon className="size-5 text-[var(--pm-brand)]" />
)}
<div className="text-xl font-semibold tracking-tight text-zinc-950">
{activeCategory === ALL_CATEGORY_KEY ? '全部案例' : activeCategory}
</div>
<Badge tone="sky">{visibleCases.length} 条</Badge>
{isDemoMode ? <Badge tone="amber">演示数据</Badge> : null}
</div>
{viewMode === TABLE_VIEW_MODE ? (
<div className="mt-2 text-sm text-zinc-500">
已勾选 {selectedCases.length} 条,支持批量修改、导出和删除。
</div>
) : (
<div className="mt-2 text-sm text-zinc-500">
卡片视图用于快速浏览,切换到表格视图后可勾选批量操作。
</div>
)}
</div>
</
div
>
<
div
className=
"grid gap-4 md:grid-cols-2 xl:grid-cols-3"
>
{
visibleCases
.
map
((
item
)
=>
(
<
CaseCard
key=
{
item
.
id
}
item=
{
item
}
onOpen=
{
openCaseDetail
}
/>
))
}
<div className="flex flex-col gap-3 xl:items-end">
<Segment>
<SegmentOption
active={viewMode === CARD_VIEW_MODE}
onClick={() => setViewMode(CARD_VIEW_MODE)}
>
<Squares2X2Icon className="size-4" />
卡片视图
</SegmentOption>
<SegmentOption
active={viewMode === TABLE_VIEW_MODE}
onClick={() => setViewMode(TABLE_VIEW_MODE)}
>
<TableCellsIcon className="size-4" />
表格视图
</SegmentOption>
</Segment>
{viewMode === TABLE_VIEW_MODE ? (
<div className="flex flex-wrap gap-2 xl:justify-end">
<Button
outline
className="cursor-pointer whitespace-nowrap bg-white/90"
onClick={() => {
setError('');
setNotice('');
resetBatchEditForm();
setIsBatchEditOpen(true);
}}
disabled={selectedCases.length === 0 || batchSaving}
>
<PencilSquareIcon className="size-4" />
批量修改
</Button>
<Button
outline
className="cursor-pointer whitespace-nowrap bg-white/90"
onClick={handleExportSelectedCases}
disabled={selectedCases.length === 0 || exportingSelected}
>
<ArrowDownTrayIcon className="size-4" />
{exportingSelected ? '导出中...' : '导出勾选'}
</Button>
<Button
color="red"
className="cursor-pointer whitespace-nowrap"
onClick={() => {
setError('');
setNotice('');
setIsBatchDeleteOpen(true);
}}
disabled={selectedCases.length === 0 || batchDeleting}
>
<TrashIcon className="size-4" />
批量删除
</Button>
<Button
plain
className="cursor-pointer whitespace-nowrap text-zinc-600"
onClick={() => setSelectedCaseIds([])}
disabled={selectedCases.length === 0}
>
清空勾选
</Button>
</div>
) : null}
</div>
</div>
{viewMode === CARD_VIEW_MODE ? (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{visibleCases.map((item) => (
<CaseCard key={item.id} item={item} onOpen={openCaseDetail} />
))}
</div>
) : (
<div className="overflow-hidden rounded-[24px] border border-zinc-200 bg-white">
<Table
bleed
dense
grid
striped
className="max-h-[36rem] overflow-auto"
>
<TableHead>
<TableRow>
<TableHeader
className={clsx(
'w-14',
STICKY_TABLE_HEADER_CLASS_NAME,
)}
>
<div className="flex items-center justify-center">
<Checkbox
checked={allVisibleSelected}
indeterminate={partiallyVisibleSelected}
onChange={handleToggleSelectAll}
aria-label="全选当前表格案例"
/>
</div>
</TableHeader>
<TableHeader className={STICKY_TABLE_HEADER_CLASS_NAME}>
标题
</TableHeader>
<TableHeader className={STICKY_TABLE_HEADER_CLASS_NAME}>
分类
</TableHeader>
<TableHeader className={STICKY_TABLE_HEADER_CLASS_NAME}>
标签
</TableHeader>
<TableHeader className={STICKY_TABLE_HEADER_CLASS_NAME}>
案例类型
</TableHeader>
<TableHeader className={STICKY_TABLE_HEADER_CLASS_NAME}>
关键字
</TableHeader>
<TableHeader className={STICKY_TABLE_HEADER_CLASS_NAME}>
案例来源
</TableHeader>
<TableHeader className={STICKY_TABLE_HEADER_CLASS_NAME}>
等级 / 年份
</TableHeader>
<TableHeader className={STICKY_TABLE_HEADER_CLASS_NAME}>
更新时间
</TableHeader>
<TableHeader
className={clsx(
'w-28',
STICKY_TABLE_HEADER_CLASS_NAME,
)}
>
操作
</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{visibleCases.map((item) => {
const checked = selectedCaseIdSet.has(item.id);
return (
<TableRow
key={item.id}
className={clsx(
checked &&
'bg-[color:color-mix(in_srgb,var(--pm-brand)_8%,white)]',
)}
>
<TableCell>
<div className="flex items-center justify-center">
<Checkbox
checked={checked}
onChange={(nextChecked) =>
handleToggleCaseSelection(item.id, nextChecked)
}
aria-label={`
勾选案例
$
{
item
.
label
||
'未命名案例'
}
`}
/>
</div>
</TableCell>
<TableCell className="align-top whitespace-normal">
<button
type="button"
onClick={() => openCaseDetail(item)}
className="text-left"
>
<div className="font-medium text-zinc-950 transition hover:text-[var(--pm-brand)]">
{item.label || '未命名案例'}
</div>
<div className="mt-1 max-w-xl text-sm leading-6 text-zinc-500 line-clamp-2">
{item.contentPreview}
</div>
</button>
</TableCell>
<TableCell>{item.category || '-'}</TableCell>
<TableCell className="whitespace-normal">
{item.tag || '-'}
</TableCell>
<TableCell className="whitespace-normal">
<div>{item.caseType || '-'}</div>
{item.caseAttribute ? (
<div className="mt-1 text-xs text-zinc-500">
{item.caseAttribute}
</div>
) : null}
</TableCell>
<TableCell className="max-w-52 whitespace-normal text-zinc-600">
{item.keyword || '-'}
</TableCell>
<TableCell className="whitespace-normal text-zinc-600">
{item.caseSource || '-'}
</TableCell>
<TableCell className="text-zinc-600">
<div>{item.caseLevel || '-'}</div>
<div className="mt-1 text-xs text-zinc-500">
{item.caseYear ? `
$
{
item
.
caseYear
}
年
` : '-'}
</div>
</TableCell>
<TableCell className="text-zinc-600">
{item.updatedAtLabel}
</TableCell>
<TableCell>
<Button
plain
className="cursor-pointer whitespace-nowrap text-[var(--pm-brand)]"
onClick={() => openCaseDetail(item)}
>
查看详情
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</Panel>
<div
...
...
@@ -738,7 +1249,7 @@ export default function ResourceBookingPage() {
: ' 已全部加载完成。'}
</div>
{loadingMore ? (
<
div
className=
"text-sm font-medium text-
sky-700
"
>
正在加载更多...
</
div
>
<div className="text-sm font-medium text-
[var(--pm-brand)]
">正在加载更多...</div>
) : null}
{!hasMore ? (
<div className="text-sm font-medium text-emerald-700">已全部加载</div>
...
...
@@ -803,23 +1314,116 @@ export default function ResourceBookingPage() {
placeholder="例如:采购审批"
/>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">案例类型</div>
<Input
value={createForm.caseType}
onChange={(event) =>
setCreateForm((prev) => ({
...prev,
caseType: event.target.value,
}))
}
placeholder="例如:审批绕行"
/>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">案例属性</div>
<Input
value={createForm.caseAttribute}
onChange={(event) =>
setCreateForm((prev) => ({
...prev,
caseAttribute: event.target.value,
}))
}
placeholder="例如:流程绕行"
/>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">关键字</div>
<Input
value={createForm.keyword}
onChange={(event) =>
setCreateForm((prev) => ({
...prev,
keyword: event.target.value,
}))
}
placeholder="例如:采购审批, 拆单"
/>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">案例来源</div>
<Input
value={createForm.caseSource}
onChange={(event) =>
setCreateForm((prev) => ({
...prev,
caseSource: event.target.value,
}))
}
placeholder="例如:内部审计复盘"
/>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">案例等级</div>
<Input
value={createForm.caseLevel}
onChange={(event) =>
setCreateForm((prev) => ({
...prev,
caseLevel: event.target.value,
}))
}
placeholder="例如:高"
/>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">案例年份</div>
<Input
type="number"
value={createForm.caseYear}
onChange={(event) =>
setCreateForm((prev) => ({
...prev,
caseYear: event.target.value,
}))
}
placeholder="例如:2024"
/>
</div>
<div className="sm:col-span-2">
<div className="mb-2 flex items-center justify-between text-sm font-medium text-zinc-600">
<
span
>
内容
</
span
>
<span>
案例详情
</span>
<span className="text-xs text-zinc-400">
预估主标题:{createCaseTitle(createForm)}
</span>
</div>
<Textarea
rows={12}
value=
{
createForm
.
c
ontent
}
value={createForm.c
aseDetails
}
onChange={(event) =>
setCreateForm((prev) => ({
...prev,
c
ontent
:
event
.
target
.
value
,
c
aseDetails
: event.target.value,
}))
}
placeholder=
"粘贴或录入完整案例正文"
placeholder="粘贴或录入完整案例详情"
/>
</div>
<div className="sm:col-span-2">
<div className="mb-2 text-sm font-medium text-zinc-600">案例警示</div>
<Textarea
rows={4}
value={createForm.caseWarning}
onChange={(event) =>
setCreateForm((prev) => ({
...prev,
caseWarning: event.target.value,
}))
}
placeholder="提炼本案例的风险警示或控制建议"
/>
</div>
</div>
...
...
@@ -854,17 +1458,32 @@ export default function ResourceBookingPage() {
<div className="space-y-6">
<div className="grid gap-6 xl:grid-cols-[0.8fr_1.2fr]">
<div className="space-y-4">
<
div
className=
"rounded-[26px] border border-zinc-200 bg-[linear-gradient(145deg,rgba(255,25
1,235,0.9),rgba(239,246,255
,0.9))] p-5"
>
<div className="rounded-[26px] border border-zinc-200 bg-[linear-gradient(145deg,rgba(255,25
5,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">
...
...
@@ -876,6 +1495,28 @@ export default function ResourceBookingPage() {
{activeCase.textLengthLabel}
</div>
</div>
<div>
<div className="text-xs font-semibold tracking-[0.18em] text-zinc-500">
标签与关键字
</div>
<div className="mt-2 text-sm text-zinc-700">
标签:{activeCase.tag || '-'}
</div>
<div className="mt-1 text-sm text-zinc-500">
关键字:{activeCase.keyword || '-'}
</div>
</div>
<div>
<div className="text-xs font-semibold tracking-[0.18em] text-zinc-500">
来源与等级
</div>
<div className="mt-2 text-sm text-zinc-700">
来源:{activeCase.caseSource || '-'}
</div>
<div className="mt-1 text-sm text-zinc-500">
等级:{activeCase.caseLevel || '-'} / 年份:{activeCase.caseYear || '-'}
</div>
</div>
<div>
<div className="text-xs font-semibold tracking-[0.18em] text-zinc-500">
时间信息
...
...
@@ -894,7 +1535,7 @@ export default function ResourceBookingPage() {
className="inline-flex items-center gap-2 rounded-full border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-700 shadow-sm transition hover:border-zinc-300 hover:bg-zinc-50"
>
<ClipboardDocumentIcon className="size-4" />
复制
正文
复制
详情
</button>
<button
type="button"
...
...
@@ -947,20 +1588,106 @@ export default function ResourceBookingPage() {
}
/>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">案例类型</div>
<Input
value={detailForm.caseType}
onChange={(event) =>
setDetailForm((prev) => ({
...prev,
caseType: event.target.value,
}))
}
/>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">案例属性</div>
<Input
value={detailForm.caseAttribute}
onChange={(event) =>
setDetailForm((prev) => ({
...prev,
caseAttribute: event.target.value,
}))
}
/>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">关键字</div>
<Input
value={detailForm.keyword}
onChange={(event) =>
setDetailForm((prev) => ({
...prev,
keyword: event.target.value,
}))
}
/>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">案例来源</div>
<Input
value={detailForm.caseSource}
onChange={(event) =>
setDetailForm((prev) => ({
...prev,
caseSource: event.target.value,
}))
}
/>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">案例等级</div>
<Input
value={detailForm.caseLevel}
onChange={(event) =>
setDetailForm((prev) => ({
...prev,
caseLevel: event.target.value,
}))
}
/>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">案例年份</div>
<Input
type="number"
value={detailForm.caseYear}
onChange={(event) =>
setDetailForm((prev) => ({
...prev,
caseYear: event.target.value,
}))
}
/>
</div>
<div className="sm:col-span-2">
<div className="mb-2 flex items-center justify-between text-sm font-medium text-zinc-600">
<
span
>
内容
</
span
>
<span>
案例详情
</span>
<span className="text-xs text-zinc-400">
当前
{
String
(
detailForm
.
c
ontent
||
''
).
replace
(
/
\s
+/g
,
''
).
length
}
字
当前 {String(detailForm.c
aseDetails
|| '').replace(/\s+/g, '').length} 字
</span>
</div>
<Textarea
rows={16}
value=
{
detailForm
.
content
}
value={detailForm.caseDetails}
onChange={(event) =>
setDetailForm((prev) => ({
...prev,
caseDetails: event.target.value,
}))
}
/>
</div>
<div className="sm:col-span-2">
<div className="mb-2 text-sm font-medium text-zinc-600">案例警示</div>
<Textarea
rows={4}
value={detailForm.caseWarning}
onChange={(event) =>
setDetailForm((prev) => ({
...prev,
c
ontent
:
event
.
target
.
value
,
c
aseWarning
: event.target.value,
}))
}
/>
...
...
@@ -992,6 +1719,197 @@ export default function ResourceBookingPage() {
</DialogActions>
</Dialog>
<Dialog
open={isBatchEditOpen}
onClose={setIsBatchEditOpen}
size="4xl"
>
<DialogTitle>批量修改案例</DialogTitle>
<DialogBody>
<div className="space-y-5">
<div className="rounded-[22px] border border-[color:color-mix(in_srgb,var(--pm-brand)_18%,white)] bg-[color:color-mix(in_srgb,var(--pm-brand)_8%,white)] px-4 py-4 text-sm text-[var(--pm-brand-strong)]">
当前已勾选 {selectedCases.length} 条案例。仅会覆盖你填写的字段,留空字段保持原值不变。
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">分类</div>
<Input
list="case-category-suggestions"
value={batchEditForm.category}
onChange={(event) =>
setBatchEditForm((prev) => ({
...prev,
category: event.target.value,
}))
}
placeholder="填写后将批量覆盖分类"
/>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">标签</div>
<Input
list="case-tag-suggestions"
value={batchEditForm.tag}
onChange={(event) =>
setBatchEditForm((prev) => ({
...prev,
tag: event.target.value,
}))
}
placeholder="填写后将批量覆盖标签"
/>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">案例类型</div>
<Input
value={batchEditForm.caseType}
onChange={(event) =>
setBatchEditForm((prev) => ({
...prev,
caseType: event.target.value,
}))
}
placeholder="填写后将批量覆盖案例类型"
/>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">案例属性</div>
<Input
value={batchEditForm.caseAttribute}
onChange={(event) =>
setBatchEditForm((prev) => ({
...prev,
caseAttribute: event.target.value,
}))
}
placeholder="填写后将批量覆盖案例属性"
/>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">关键字</div>
<Input
value={batchEditForm.keyword}
onChange={(event) =>
setBatchEditForm((prev) => ({
...prev,
keyword: event.target.value,
}))
}
placeholder="填写后将批量覆盖关键字"
/>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">案例来源</div>
<Input
value={batchEditForm.caseSource}
onChange={(event) =>
setBatchEditForm((prev) => ({
...prev,
caseSource: event.target.value,
}))
}
placeholder="填写后将批量覆盖案例来源"
/>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">案例等级</div>
<Input
value={batchEditForm.caseLevel}
onChange={(event) =>
setBatchEditForm((prev) => ({
...prev,
caseLevel: event.target.value,
}))
}
placeholder="填写后将批量覆盖案例等级"
/>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">案例年份</div>
<Input
type="number"
value={batchEditForm.caseYear}
onChange={(event) =>
setBatchEditForm((prev) => ({
...prev,
caseYear: event.target.value,
}))
}
placeholder="例如:2026"
/>
</div>
<div className="sm:col-span-2">
<div className="mb-2 text-sm font-medium text-zinc-600">案例警示</div>
<Textarea
rows={4}
value={batchEditForm.caseWarning}
onChange={(event) =>
setBatchEditForm((prev) => ({
...prev,
caseWarning: event.target.value,
}))
}
placeholder="填写后将批量覆盖案例警示"
/>
</div>
</div>
</div>
</DialogBody>
<DialogActions>
<Button
plain
className="cursor-pointer"
onClick={() => {
setIsBatchEditOpen(false);
resetBatchEditForm();
}}
disabled={batchSaving}
>
取消
</Button>
<Button
color="blue"
className="cursor-pointer"
onClick={handleBatchEditSubmit}
disabled={batchSaving || selectedCases.length === 0}
>
{batchSaving ? '批量修改中...' : '确认批量修改'}
</Button>
</DialogActions>
</Dialog>
<Dialog
open={isBatchDeleteOpen}
onClose={setIsBatchDeleteOpen}
size="lg"
>
<DialogTitle>确认批量删除案例</DialogTitle>
<DialogBody>
<div className="rounded-[22px] border border-rose-200 bg-rose-50 px-4 py-4 text-sm text-rose-800">
即将删除 {selectedCases.length} 条勾选案例。删除后会从当前列表移除,请确认是否继续。
</div>
</DialogBody>
<DialogActions>
<Button
plain
className="cursor-pointer"
onClick={() => setIsBatchDeleteOpen(false)}
disabled={batchDeleting}
>
取消
</Button>
<Button
color="red"
className="cursor-pointer"
onClick={handleBatchDeleteCases}
disabled={batchDeleting || selectedCases.length === 0}
>
{batchDeleting ? '删除中...' : '确认批量删除'}
</Button>
</DialogActions>
</Dialog>
<Dialog
open={isDeleteOpen}
onClose={setIsDeleteOpen}
...
...
@@ -1027,7 +1945,7 @@ export default function ResourceBookingPage() {
<DialogTitle>Excel 导入案例</DialogTitle>
<DialogBody>
<div className="space-y-6">
<
div
className=
"rounded-[28px] border border-dashed border-
sky-200 bg-sky-50/60
p-5"
>
<div className="rounded-[28px] border border-dashed border-
[color:color-mix(in_srgb,var(--pm-brand)_16%,white)] bg-[color:color-mix(in_srgb,var(--pm-brand)_6%,white)]
p-5">
<input
ref={importFileInputRef}
type="file"
...
...
@@ -1050,15 +1968,34 @@ export default function ResourceBookingPage() {
: `
已识别工作表
$
{
importPreview
.
sheetName
}
,共
$
{
importPreview
.
totalRows
}
行可导入数据
`}
</div>
) : null}
<div className="mt-2 text-xs leading-6 text-zinc-500">
支持先导出标准模板,再按模板回填 Excel 后导入。
</div>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={handleExportTemplate}
disabled={exportingTemplate}
className={clsx(
'inline-flex items-center justify-center rounded-full border px-4 py-2 text-sm font-medium shadow-sm transition',
exportingTemplate
? 'cursor-not-allowed border-zinc-200 bg-zinc-100 text-zinc-400'
: 'border-emerald-200 bg-white text-emerald-700 hover:border-emerald-300 hover:bg-emerald-50',
)}
>
<ArrowDownTrayIcon className="mr-2 size-4" />
{exportingTemplate ? '导出中...' : '导出模板'}
</button>
<button
type="button"
onClick={() => importFileInputRef.current?.click()}
className="inline-flex items-center justify-center rounded-full border border-[color:color-mix(in_srgb,var(--pm-brand)_16%,white)] bg-white px-4 py-2 text-sm font-medium text-[var(--pm-brand)] shadow-sm transition hover:border-[color:color-mix(in_srgb,var(--pm-brand)_24%,white)] hover:bg-[color:color-mix(in_srgb,var(--pm-brand)_6%,white)]"
>
<ArrowUpTrayIcon className="mr-2 size-4" />
选择文件
</button>
</div>
<
button
type=
"button"
onClick=
{
()
=>
importFileInputRef
.
current
?.
click
()
}
className=
"inline-flex items-center justify-center rounded-full border border-sky-200 bg-white px-4 py-2 text-sm font-medium text-sky-700 shadow-sm transition hover:border-sky-300 hover:bg-sky-50"
>
<
ArrowDownTrayIcon
className=
"mr-2 size-4"
/>
选择文件
</
button
>
</div>
</div>
...
...
@@ -1077,9 +2014,10 @@ export default function ResourceBookingPage() {
<tr>
<th className="px-4 py-3 font-medium">行号</th>
<th className="px-4 py-3 font-medium">标题</th>
<th className="px-4 py-3 font-medium">案例类型</th>
<th className="px-4 py-3 font-medium">分类</th>
<th className="px-4 py-3 font-medium">标签</th>
<
th
className=
"px-4 py-3 font-medium"
>
内容
预览
</
th
>
<th className="px-4 py-3 font-medium">
案例详情
预览</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100">
...
...
@@ -1089,12 +2027,15 @@ export default function ResourceBookingPage() {
<td className="px-4 py-3 font-medium text-zinc-900">
{row.label || '未填写标题'}
</td>
<td className="px-4 py-3 text-zinc-600">
{row.caseType || '-'}
</td>
<td className="px-4 py-3 text-zinc-600">
{row.category || '-'}
</td>
<td className="px-4 py-3 text-zinc-600">{row.tag || '-'}</td>
<td className="px-4 py-3 text-zinc-500">
<
div
className=
"line-clamp-2"
>
{
row
.
c
ontent
||
'-'
}
</
div
>
<div className="line-clamp-2">{row.c
aseDetails
|| '-'}</div>
</td>
</tr>
))}
...
...
src/services/audit-material-subject-build-checklist.md
View file @
6f4521a3
...
...
@@ -34,7 +34,7 @@
建议说明:
-
一条记录代表一个案例
-
主要承载
案例类型、案例属性、案例性质、案例详情、案件
警示、关键字、案例来源、案例等级、案例年份
-
主要承载
标题、分类、标签、案例类型、案例属性、案例详情、案例
警示、关键字、案例来源、案例等级、案例年份
## 3.2 字段清单
...
...
src/services/subjectsDefinition.md
View file @
6f4521a3
...
...
@@ -68,7 +68,8 @@ type Record = {
### 2.2 `Record.title`
-
建议优先取
`metadata.label`
-
若
`label`
为空,可退化为
`metadata.case_details`
前 24~32 个字符摘要
-
若
`label`
为空,可回退为
`metadata.case_type`
-
若仍为空,可退化为
`metadata.case_details`
前 24~32 个字符摘要
-
再兜底为
`未命名案例`
### 2.3 搜索与展示建议
...
...
@@ -76,6 +77,7 @@ type Record = {
-
列表主标题使用
`label`
-
卡片或详情摘要使用
`case_details`
-
案例性质筛选读取
`category`
-
标签筛选读取
`tag`
-
关键字筛选读取
`keyword`
,历史数据可兼容读取
`tag`
-
关键词检索优先覆盖
`label`
、
`keyword`
、
`case_details`
、
`case_warning`
...
...
@@ -93,25 +95,49 @@ type Record = {
-
`label`
用作主展示标题
-
`category`
用于主题归类
-
`tag`
用于更灵活的检索和二次分组
-
`tag`
用于标签化管理和快速筛选
-
`case_type`
用于案例主分类或类型归纳
-
`case_attribute`
用于补充案例属性、归属维度或风险特征
-
`case_details`
为案例详情正文,通常为几百到上千字
-
`case_warning`
用于提炼关键警示信息
-
`keyword`
用于检索和热度统计,可与历史
`tag`
并存
-
`case_year`
建议存储为数字类型年份,便于筛选和排序
| 字段标签 | 字段别名 | 字段类型 | 是否主键 | 是否多值 | 是否必填 | 说明 |
| --- | --- | --- | --- | --- | --- | --- |
| 标题 |
`label`
| text | 否 | 否 | 否 | 案例展示主标题 |
| 分类 |
`category`
| text | 否 | 否 | 否 | 案例分类,如舞弊风险、合规风险 |
| 标签 |
`tag`
| text | 否 | 否 | 否 | 标签或关键词,如资金管理、采购审批 |
| 案例类型 |
`case_type`
| text | 否 | 否 | 否 | 案例类型,如违规操作、警示通报 |
| 案例属性 |
`case_attribute`
| text | 否 | 否 | 否 | 案例属性或归属维度 |
| 案例详情 |
`case_details`
| long_text | 否 | 否 | 否 | 案例详细内容正文 |
| 案例警示 |
`case_warning`
| text | 否 | 否 | 否 | 案例提炼出的警示或风险提示 |
| 关键字 |
`keyword`
| text | 否 | 否 | 否 | 检索关键字,支持人工维护 |
| 案例来源 |
`case_source`
| text | 否 | 否 | 否 | 案例来源,如监管通报、内部复盘 |
| 案例等级 |
`case_level`
| text | 否 | 否 | 否 | 案例风险等级或分级 |
| 案例年份 |
`case_year`
| number | 否 | 否 | 否 | 案例发生或归档年份 |
### 3.1.1 metadata 示例
| 字段标签 | 字段别名 | 字段类型 | 默认值 | 是否主键 | 是否多值 | 是否必填 | 搜索 | 聚合 | 说明 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 标题 |
`label`
|
`text`
|
`null`
| 否 | 否 | 否 | 否 | 否 | 案例展示主标题 |
| 分类 |
`category`
|
`text`
|
`null`
| 否 | 否 | 否 | 否 | 否 | 案例分类,如舞弊风险、合规风险 |
| 标签 |
`tag`
|
`text`
|
`null`
| 否 | 否 | 否 | 否 | 否 | 标签字段,用于快速筛选或兼容旧数据 |
| 案例类型 |
`case_type`
|
`text`
|
`null`
| 否 | 否 | 否 | 否 | 否 | 案例类型,如审批绕行、权限滥用 |
| 案例属性 |
`case_attribute`
|
`text`
|
`null`
| 否 | 否 | 否 | 否 | 否 | 案例属性或归属维度 |
| 案例详情 |
`case_details`
|
`long_text`
|
`null`
| 否 | 否 | 否 | 否 | 否 | 案例详细内容正文 |
| 案例警示 |
`case_warning`
|
`text`
|
`null`
| 否 | 否 | 否 | 否 | 否 | 案例提炼出的警示或风险提示 |
| 关键字 |
`keyword`
|
`text`
|
`null`
| 否 | 否 | 否 | 否 | 否 | 检索关键字,支持人工维护 |
| 案例来源 |
`case_source`
|
`text`
|
`null`
| 否 | 否 | 否 | 否 | 否 | 案例来源,如监管通报、内部复盘 |
| 案例等级 |
`case_level`
|
`text`
|
`null`
| 否 | 否 | 否 | 否 | 否 | 案例风险等级或分级 |
| 案例年份 |
`case_year`
|
`number`
|
`null`
| 否 | 否 | 否 | 否 | 否 | 案例发生或归档年份 |
### 3.1.1 原始字段结构
以下结构与当前前端表单、保存 payload 和动态字段定义保持一致:
```
json
[
{
"name"
:
"label"
,
"label"
:
"标题"
,
"type"
:
"text"
,
"default"
:
null
,
"multiple"
:
false
,
"required"
:
false
,
"primary"
:
false
,
"search"
:
{
"enabled"
:
false
},
"aggregation"
:
{
"enabled"
:
false
},
"settings"
:
{}
},
{
"name"
:
"category"
,
"label"
:
"分类"
,
"type"
:
"text"
,
"default"
:
null
,
"multiple"
:
false
,
"required"
:
false
,
"primary"
:
false
,
"search"
:
{
"enabled"
:
false
},
"aggregation"
:
{
"enabled"
:
false
},
"settings"
:
{}
},
{
"name"
:
"tag"
,
"label"
:
"标签"
,
"type"
:
"text"
,
"default"
:
null
,
"multiple"
:
false
,
"required"
:
false
,
"primary"
:
false
,
"search"
:
{
"enabled"
:
false
},
"aggregation"
:
{
"enabled"
:
false
},
"settings"
:
{}
},
{
"name"
:
"case_type"
,
"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_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"
:
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_year"
,
"label"
:
"案例年份"
,
"type"
:
"number"
,
"default"
:
null
,
"multiple"
:
false
,
"required"
:
false
,
"primary"
:
false
,
"search"
:
{
"enabled"
:
false
},
"aggregation"
:
{
"enabled"
:
false
},
"settings"
:
{}
}
]
```
### 3.1.2 metadata 示例
```
json
{
...
...
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