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
dd0aab32
Commit
dd0aab32
authored
Jun 08, 2026
by
David Yang
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: 廉洁党建建设管理
parent
06f0d7dd
Hide whitespace changes
Inline
Side-by-side
Showing
8 changed files
with
2858 additions
and
54 deletions
+2858
-54
.umirc.ts
.umirc.ts
+5
-0
Layout.jsx
src/pages/project_manager_xy/Layout.jsx
+6
-0
integrity-party.business-flow.md
...pages/project_manager_xy/integrity-party.business-flow.md
+127
-0
index.jsx
src/pages/project_manager_xy/integrity-party/index.jsx
+1020
-0
integrityPartyService.js
src/pages/project_manager_xy/integrityPartyService.js
+1311
-0
index.jsx
src/pages/project_manager_xy/resource-stat/index.jsx
+185
-54
record-api.md
src/services/record-api.md
+86
-0
subjectsDefinition.md
src/services/subjectsDefinition.md
+118
-0
No files found.
.umirc.ts
View file @
dd0aab32
...
...
@@ -9,6 +9,7 @@ const ROUTE_PATHS = {
login
:
'/login'
,
resourceStat
:
'/resource/stat'
,
resourceBooking
:
'/resource/booking'
,
integrityParty
:
'/integrity-party'
,
}
as
const
;
export
default
defineConfig
({
...
...
@@ -47,6 +48,10 @@ export default defineConfig({
path
:
ROUTE_PATHS
.
resourceBooking
,
component
:
'./project_manager_xy/resource-booking/index'
,
},
{
path
:
ROUTE_PATHS
.
integrityParty
,
component
:
'./project_manager_xy/integrity-party/index'
,
},
],
},
],
...
...
src/pages/project_manager_xy/Layout.jsx
View file @
dd0aab32
...
...
@@ -3,6 +3,7 @@ import { UserContext } from '@/wrapper/Auth';
import
{
ArchiveBoxIcon
,
Bars3Icon
,
ClipboardDocumentListIcon
,
Squares2X2Icon
,
XMarkIcon
,
}
from
'@heroicons/react/24/outline'
;
...
...
@@ -26,6 +27,11 @@ const menuGroups = [
path
:
'/resource/booking'
,
icon
:
ArchiveBoxIcon
,
},
{
name
:
'廉洁党建建设管理'
,
path
:
'/integrity-party'
,
icon
:
ClipboardDocumentListIcon
,
},
],
},
];
...
...
src/pages/project_manager_xy/integrity-party.business-flow.md
0 → 100644
View file @
dd0aab32
# 廉洁党建建设管理流程文档
## 1. 模块目标
本模块用于维护廉洁党建建设相关台账,覆盖培训、谈话、调研、专题会议、廉洁主题活动、节前廉洁提醒 6 类记录。
核心原则:
-
6 类台账分别使用独立 Subject
-
每条台账对应一条
`Record`
-
所有业务字段均写入
`metadata`
-
附件字段均按多附件数组保存,由系统内上传控件维护
-
Excel 导入不处理附件,导出时输出已上传附件的文件名和 URL
## 2. 功能入口
-
左侧菜单:
`驾驶舱`
-
页面内可切换
`案例库统计 / 廉洁党建统计`
-
廉洁党建统计展示台账总数、本年度记录、附件完整率、最近 30 天更新、台账分类分布、年度分布和最近更新台账
-
左侧菜单:
`廉洁党建建设管理`
-
页面内通过 Tab 切换
`培训类 / 谈话类 / 调研类 / 专题会议类 / 廉洁主题活动 / 节前廉洁提醒`
-
每个 Tab 对应独立台账对象和独立 Excel 模板
## 3. 核心业务对象
| 台账分类 | Subject |
| --- | --- |
| 培训类 |
`integrity_party_training`
|
| 谈话类 |
`integrity_party_talk`
|
| 调研类 |
`integrity_party_research`
|
| 专题会议类 |
`integrity_party_meeting`
|
| 廉洁主题活动 |
`integrity_party_activity`
|
| 节前廉洁提醒 |
`integrity_party_holiday_reminder`
|
## 4. 业务流程
### 4.1 浏览与检索台账
1.
用户进入
`廉洁党建建设管理`
2.
通过顶部 Tab 选择台账分类
3.
页面展示该分类的表格台账
4.
筛选区统一包含关键词、年份和当前分类关键字段
5.
用户点击
`查询`
后,前端调用
`POST /record/query`
6.
列表按
`updated_at`
、
`created_at`
倒序展示
7.
表格仅展示核心字段、附件状态、更新时间和操作入口
8.
完整字段在编辑弹窗中维护
### 4.2 手动新建台账
1.
用户点击
`新建记录`
2.
前端根据当前 Tab 字段配置生成表单
3.
用户填写最小必填字段
4.
附件字段通过系统上传控件上传,一个字段可保存多个附件
5.
前端自动生成
`title`
6.
前端调用
`POST /record`
7.
保存成功后提示并刷新当前列表
最小必填字段:
-
培训类:培训类别、时间、授课主题
-
谈话类:年份、姓名
-
调研类:调研时间、调研单位
-
专题会议类:会议时间、会议议题
-
廉洁主题活动:开展年份、活动主题
-
节前廉洁提醒:年份、重大节假日
### 4.3 编辑台账
1.
用户在表格中点击
`编辑`
2.
页面打开详情弹窗,展示当前分类全部字段
3.
用户修改字段或上传、移除附件
4.
前端调用
`PUT /record/:name`
5.
保存成功后刷新当前列表
### 4.4 删除台账
1.
用户在编辑弹窗中点击删除,或在表格勾选后点击批量删除
2.
页面弹出二次确认
3.
前端调用
`DELETE /record/:name`
4.
删除后刷新当前列表
5.
删除默认走软删除
### 4.5 Excel 导入
1.
用户在当前 Tab 点击
`下载模板`
2.
前端导出当前分类的独立模板
3.
用户维护 Excel 后点击
`Excel 导入`
4.
前端读取首个工作表并按当前 Tab 字段映射解析
5.
附件字段不参与导入
6.
前端逐条调用
`POST /record`
7.
当前阶段不做重复判断、不自动覆盖,导入全部新增
8.
导入完成后提示成功条数和失败条数
### 4.6 Excel 导出
1.
用户点击
`Excel 导出`
2.
前端按当前筛选条件分页读取全部记录
3.
导出当前分类全部字段
4.
附件字段导出为文件名和 URL,多附件用换行分隔
5.
用户也可以先勾选记录,再点击
`导出勾选`
### 4.7 批量修改
1.
用户勾选一条或多条记录
2.
点击
`批量修改`
3.
弹窗只展示当前分类允许批量覆盖的简单字段
4.
未填写字段保持原值不变
5.
前端逐条调用
`PUT /record/:name`
6.
附件、长文本主要内容等字段一期不参与批量修改
## 5. 字段与数据规则
-
日期字段页面和 Excel 统一展示为
`YYYY-MM-DD`
-
保存时日期字段转换为 ISO 8601 字符串
-
布尔字段使用三态 UI:是、否、未填写,对应
`true / false / null`
-
培训类别固定为:廉政党课讲授、廉洁合规培训、专题廉洁合规教育培训、新员工廉洁教育培训
-
重大节假日固定为:元旦、春节、清明、五一、端午、中秋、国庆、其他
-
选择重大节假日
`其他`
时,可填写
`holiday_other`
-
人员、部门、岗位、单位等字段一期全部使用普通文本输入,不做系统引用
## 6. 异常处理与限制
-
保存失败时保留当前表单输入并提示用户
-
Excel 解析失败时不发起创建请求
-
Excel 批量导入单条失败不影响其它条目继续导入
-
批量删除和单条删除均需要二次确认
-
当前阶段不做导入重复校验,后续可根据 Subject 主键配置扩展覆盖或拦截逻辑
src/pages/project_manager_xy/integrity-party/index.jsx
0 → 100644
View file @
dd0aab32
import
{
ArrowDownTrayIcon
,
ArrowPathIcon
,
CheckIcon
,
DocumentArrowDownIcon
,
DocumentArrowUpIcon
,
PencilSquareIcon
,
PlusIcon
,
TrashIcon
,
}
from
'@heroicons/react/24/outline'
;
import
clsx
from
'clsx'
;
import
{
useEffect
,
useMemo
,
useRef
,
useState
}
from
'react'
;
import
{
Button
}
from
'../../../catalyst-ui-kit/javascript/button'
;
import
{
Dialog
,
DialogActions
,
DialogBody
,
DialogDescription
,
DialogTitle
,
}
from
'../../../catalyst-ui-kit/javascript/dialog'
;
import
{
Input
}
from
'../../../catalyst-ui-kit/javascript/input'
;
import
{
Select
}
from
'../../../catalyst-ui-kit/javascript/select'
;
import
{
Table
,
TableBody
,
TableCell
,
TableHead
,
TableHeader
,
TableRow
,
}
from
'../../../catalyst-ui-kit/javascript/table'
;
import
{
Textarea
}
from
'../../../catalyst-ui-kit/javascript/textarea'
;
import
EmptyStatePage
from
'../EmptyStatePage'
;
import
{
DEFAULT_INTEGRITY_PAGE_SIZE
,
INTEGRITY_LEDGER_CONFIG_MAP
,
INTEGRITY_LEDGER_CONFIGS
,
INTEGRITY_PAGE_SIZE_OPTIONS
,
createEmptyLedgerForm
,
createLedgerRecord
,
deleteLedgerRecord
,
deleteLedgerRecords
,
exportLedgerTemplateWorkbook
,
exportLedgerWorkbook
,
exportSelectedLedgerWorkbook
,
formatDateTime
,
importLedgerWorkbook
,
loadLedgerPage
,
previewLedgerWorkbook
,
updateLedgerRecord
,
updateLedgerRecords
,
uploadLedgerAttachment
,
}
from
'../integrityPartyService'
;
function
Panel
({
className
,
children
})
{
return
(
<
section
className=
{
clsx
(
'rounded-[24px] border border-white/70 bg-white/92 shadow-[0_18px_60px_rgba(15,23,42,0.07)] backdrop-blur'
,
className
,
)
}
>
{
children
}
</
section
>
);
}
function
FieldLabel
({
field
})
{
return
(
<
label
className=
"mb-2 block text-sm font-medium text-zinc-700"
>
{
field
.
label
}
{
field
.
required
?
<
span
className=
"ml-1 text-red-600"
>
*
</
span
>
:
null
}
</
label
>
);
}
function
formatFileSize
(
size
=
0
)
{
const
numericSize
=
Number
(
size
||
0
);
if
(
!
numericSize
)
{
return
''
;
}
if
(
numericSize
<
1024
)
{
return
`
${
numericSize
}
B`
;
}
if
(
numericSize
<
1024
*
1024
)
{
return
`
${(
numericSize
/
1024
).
toFixed
(
1
)}
KB`
;
}
return
`
${(
numericSize
/
1024
/
1024
).
toFixed
(
1
)}
MB`
;
}
function
formatCellValue
(
field
,
value
)
{
if
(
field
?.
type
===
'boolean'
)
{
if
(
value
===
true
||
value
===
'true'
)
{
return
'是'
;
}
if
(
value
===
false
||
value
===
'false'
)
{
return
'否'
;
}
return
'-'
;
}
if
(
field
?.
type
===
'file'
)
{
return
Array
.
isArray
(
value
)
&&
value
.
length
>
0
?
`
${
value
.
length
}
个附件`
:
'-'
;
}
if
(
field
?.
type
===
'long_text'
)
{
const
text
=
String
(
value
||
''
).
replace
(
/
\s
+/g
,
' '
).
trim
();
return
text
.
length
>
42
?
`
${
text
.
slice
(
0
,
42
)}
...`
:
text
||
'-'
;
}
return
value
===
null
||
value
===
undefined
||
value
===
''
?
'-'
:
String
(
value
);
}
function
AttachmentField
({
field
,
value
=
[],
onChange
,
disabled
})
{
const
inputRef
=
useRef
(
null
);
const
[
uploading
,
setUploading
]
=
useState
(
false
);
const
files
=
Array
.
isArray
(
value
)
?
value
:
[];
async
function
handleUpload
(
event
)
{
const
selectedFiles
=
Array
.
from
(
event
.
target
.
files
||
[]);
if
(
selectedFiles
.
length
===
0
)
{
return
;
}
setUploading
(
true
);
try
{
const
uploadedFiles
=
[];
for
(
const
file
of
selectedFiles
)
{
uploadedFiles
.
push
(
await
uploadLedgerAttachment
(
file
));
}
onChange
([...
files
,
...
uploadedFiles
]);
}
finally
{
setUploading
(
false
);
event
.
target
.
value
=
''
;
}
}
return
(
<
div
>
<
div
className=
"flex flex-wrap items-center gap-3"
>
<
input
ref=
{
inputRef
}
type=
"file"
multiple
className=
"hidden"
onChange=
{
handleUpload
}
disabled=
{
disabled
||
uploading
}
/>
<
Button
type=
"button"
outline
onClick=
{
()
=>
inputRef
.
current
?.
click
()
}
disabled=
{
disabled
||
uploading
}
>
<
DocumentArrowUpIcon
data
-
slot=
"icon"
/>
{
uploading
?
'上传中...'
:
`上传${field.label}`
}
</
Button
>
<
span
className=
"text-sm text-zinc-500"
>
已上传
{
files
.
length
}
个
</
span
>
</
div
>
{
files
.
length
>
0
?
(
<
div
className=
"mt-3 space-y-2"
>
{
files
.
map
((
file
,
index
)
=>
(
<
div
key=
{
`${file.name || file.url}-${index}`
}
className=
"flex flex-col gap-2 rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm sm:flex-row sm:items-center sm:justify-between"
>
<
div
className=
"min-w-0"
>
{
file
.
url
?
(
<
a
href=
{
file
.
url
}
target=
"_blank"
rel=
"noreferrer"
className=
"truncate font-medium text-[var(--pm-brand)] hover:underline"
>
{
file
.
name
||
file
.
url
}
</
a
>
)
:
(
<
div
className=
"truncate font-medium text-zinc-800"
>
{
file
.
name
||
'未命名附件'
}
</
div
>
)
}
<
div
className=
"mt-0.5 text-xs text-zinc-500"
>
{
[
file
.
type
,
formatFileSize
(
file
.
size
)].
filter
(
Boolean
).
join
(
' · '
)
||
'系统附件'
}
</
div
>
</
div
>
<
Button
type=
"button"
plain
onClick=
{
()
=>
onChange
(
files
.
filter
((
_
,
fileIndex
)
=>
fileIndex
!==
index
))
}
disabled=
{
disabled
}
>
<
TrashIcon
data
-
slot=
"icon"
/>
移除
</
Button
>
</
div
>
))
}
</
div
>
)
:
null
}
</
div
>
);
}
function
FieldControl
({
field
,
value
,
onChange
,
compact
=
false
})
{
const
commonProps
=
{
value
:
value
??
''
,
onChange
:
(
event
)
=>
onChange
(
event
.
target
.
value
),
};
if
(
field
.
type
===
'select'
)
{
return
(
<
Select
{
...
commonProps
}
>
<
option
value=
""
>
请选择
</
option
>
{
(
field
.
options
||
[]).
map
((
option
)
=>
(
<
option
key=
{
option
}
value=
{
option
}
>
{
option
}
</
option
>
))
}
</
Select
>
);
}
if
(
field
.
type
===
'boolean'
)
{
return
(
<
Select
{
...
commonProps
}
>
<
option
value=
""
>
未填写
</
option
>
<
option
value=
"true"
>
是
</
option
>
<
option
value=
"false"
>
否
</
option
>
</
Select
>
);
}
if
(
field
.
type
===
'long_text'
)
{
return
(
<
Textarea
{
...
commonProps
}
rows=
{
compact
?
3
:
5
}
placeholder=
{
`请输入${field.label}`
}
/>
);
}
if
(
field
.
type
===
'file'
)
{
return
(
<
AttachmentField
field=
{
field
}
value=
{
Array
.
isArray
(
value
)
?
value
:
[]
}
onChange=
{
onChange
}
/>
);
}
return
(
<
Input
{
...
commonProps
}
type=
{
field
.
type
===
'number'
?
'number'
:
field
.
type
===
'date'
?
'date'
:
'text'
}
placeholder=
{
`请输入${field.label}`
}
/>
);
}
function
LedgerForm
({
config
,
form
,
setForm
,
fields
=
null
,
compact
=
false
})
{
const
visibleFields
=
fields
||
config
.
fields
;
return
(
<
div
className=
{
clsx
(
'grid gap-5'
,
compact
?
'md:grid-cols-2'
:
'md:grid-cols-2'
)
}
>
{
visibleFields
.
map
((
field
)
=>
(
<
div
key=
{
field
.
name
}
className=
{
clsx
(
field
.
type
===
'long_text'
||
field
.
type
===
'file'
?
'md:col-span-2'
:
''
,
)
}
>
<
FieldLabel
field=
{
field
}
/>
<
FieldControl
field=
{
field
}
value=
{
form
[
field
.
name
]
}
compact=
{
compact
}
onChange=
{
(
nextValue
)
=>
setForm
((
current
)
=>
({
...
current
,
[
field
.
name
]:
nextValue
,
}))
}
/>
</
div
>
))
}
</
div
>
);
}
function
createInitialFilters
()
{
return
INTEGRITY_LEDGER_CONFIGS
.
reduce
((
accumulator
,
config
)
=>
{
accumulator
[
config
.
key
]
=
{
keyword
:
''
,
year
:
''
,
focusValue
:
''
,
};
return
accumulator
;
},
{});
}
export
default
function
IntegrityPartyPage
()
{
const
[
activeKey
,
setActiveKey
]
=
useState
(
INTEGRITY_LEDGER_CONFIGS
[
0
].
key
);
const
activeConfig
=
INTEGRITY_LEDGER_CONFIG_MAP
[
activeKey
];
const
[
draftFilters
,
setDraftFilters
]
=
useState
(()
=>
createInitialFilters
());
const
[
appliedFilters
,
setAppliedFilters
]
=
useState
(()
=>
createInitialFilters
());
const
[
records
,
setRecords
]
=
useState
([]);
const
[
total
,
setTotal
]
=
useState
(
0
);
const
[
page
,
setPage
]
=
useState
(
0
);
const
[
pageSize
,
setPageSize
]
=
useState
(
DEFAULT_INTEGRITY_PAGE_SIZE
);
const
[
loading
,
setLoading
]
=
useState
(
false
);
const
[
notice
,
setNotice
]
=
useState
(
''
);
const
[
error
,
setError
]
=
useState
(
''
);
const
[
selectedIds
,
setSelectedIds
]
=
useState
([]);
const
[
detailOpen
,
setDetailOpen
]
=
useState
(
false
);
const
[
detailMode
,
setDetailMode
]
=
useState
(
'create'
);
const
[
activeRecord
,
setActiveRecord
]
=
useState
(
null
);
const
[
detailForm
,
setDetailForm
]
=
useState
(()
=>
createEmptyLedgerForm
(
activeKey
));
const
[
saving
,
setSaving
]
=
useState
(
false
);
const
[
importOpen
,
setImportOpen
]
=
useState
(
false
);
const
[
importFile
,
setImportFile
]
=
useState
(
null
);
const
[
importPreview
,
setImportPreview
]
=
useState
(
null
);
const
[
importing
,
setImporting
]
=
useState
(
false
);
const
[
batchOpen
,
setBatchOpen
]
=
useState
(
false
);
const
[
batchForm
,
setBatchForm
]
=
useState
(()
=>
createEmptyLedgerForm
(
activeKey
));
const
[
batchSaving
,
setBatchSaving
]
=
useState
(
false
);
const
[
deleteConfirm
,
setDeleteConfirm
]
=
useState
({
open
:
false
,
mode
:
'single'
});
const
fieldMap
=
useMemo
(()
=>
{
return
activeConfig
.
fields
.
reduce
((
accumulator
,
field
)
=>
{
accumulator
[
field
.
name
]
=
field
;
return
accumulator
;
},
{});
},
[
activeConfig
]);
const
selectedRecords
=
useMemo
(
()
=>
records
.
filter
((
record
)
=>
selectedIds
.
includes
(
record
.
id
)),
[
records
,
selectedIds
],
);
const
totalPages
=
Math
.
max
(
Math
.
ceil
(
total
/
pageSize
),
1
);
const
pageStart
=
total
===
0
?
0
:
page
*
pageSize
+
1
;
const
pageEnd
=
Math
.
min
((
page
+
1
)
*
pageSize
,
total
);
async
function
reload
(
nextPage
=
page
)
{
setLoading
(
true
);
setError
(
''
);
const
result
=
await
loadLedgerPage
(
activeKey
,
{
...
appliedFilters
[
activeKey
],
page
:
nextPage
,
pageSize
,
});
setRecords
(
result
.
records
||
[]);
setTotal
(
result
.
total
||
0
);
setPage
(
result
.
page
||
0
);
setSelectedIds
([]);
if
(
result
.
error
)
{
setError
(
result
.
error
);
}
setLoading
(
false
);
}
useEffect
(()
=>
{
reload
(
0
);
// eslint-disable-next-line react-hooks/exhaustive-deps
},
[
activeKey
,
pageSize
,
appliedFilters
]);
function
updateDraftFilter
(
name
,
value
)
{
setDraftFilters
((
current
)
=>
({
...
current
,
[
activeKey
]:
{
...
current
[
activeKey
],
[
name
]:
value
,
},
}));
}
function
applyFilters
()
{
setAppliedFilters
((
current
)
=>
({
...
current
,
[
activeKey
]:
{
...
draftFilters
[
activeKey
],
},
}));
setPage
(
0
);
}
function
resetFilters
()
{
const
empty
=
{
keyword
:
''
,
year
:
''
,
focusValue
:
''
};
setDraftFilters
((
current
)
=>
({
...
current
,
[
activeKey
]:
empty
,
}));
setAppliedFilters
((
current
)
=>
({
...
current
,
[
activeKey
]:
empty
,
}));
setPage
(
0
);
}
function
openCreateDialog
()
{
setDetailMode
(
'create'
);
setActiveRecord
(
null
);
setDetailForm
(
createEmptyLedgerForm
(
activeKey
));
setDetailOpen
(
true
);
}
function
openEditDialog
(
record
)
{
setDetailMode
(
'edit'
);
setActiveRecord
(
record
);
setDetailForm
({
...
createEmptyLedgerForm
(
activeKey
),
...
record
.
metadata
,
});
setDetailOpen
(
true
);
}
async
function
saveDetail
()
{
setSaving
(
true
);
setError
(
''
);
try
{
if
(
detailMode
===
'create'
)
{
const
created
=
await
createLedgerRecord
(
activeKey
,
detailForm
);
setNotice
(
`记录“
${
created
.
title
}
”已创建。`
);
}
else
{
const
updated
=
await
updateLedgerRecord
(
activeKey
,
activeRecord
,
detailForm
);
setNotice
(
`记录“
${
updated
.
title
}
”已保存。`
);
}
setDetailOpen
(
false
);
await
reload
(
page
);
}
catch
(
saveError
)
{
setError
(
saveError
?.
message
||
'保存失败,请稍后重试。'
);
}
finally
{
setSaving
(
false
);
}
}
async
function
confirmDelete
()
{
setSaving
(
true
);
setError
(
''
);
try
{
if
(
deleteConfirm
.
mode
===
'batch'
)
{
const
result
=
await
deleteLedgerRecords
(
selectedRecords
);
setNotice
(
result
.
failedCount
?
`已删除
${
result
.
deletedCount
}
条,
${
result
.
failedCount
}
条删除失败。`
:
`已批量删除
${
result
.
deletedCount
}
条记录。`
,
);
}
else
if
(
activeRecord
)
{
await
deleteLedgerRecord
(
activeRecord
);
setNotice
(
`记录“
${
activeRecord
.
title
}
”已删除。`
);
setDetailOpen
(
false
);
}
setDeleteConfirm
({
open
:
false
,
mode
:
'single'
});
await
reload
(
0
);
}
catch
(
deleteError
)
{
setError
(
deleteError
?.
message
||
'删除失败,请稍后重试。'
);
}
finally
{
setSaving
(
false
);
}
}
async
function
handleTemplateExport
()
{
try
{
const
result
=
await
exportLedgerTemplateWorkbook
(
activeKey
);
setNotice
(
`导入模板已导出:
${
result
.
fileName
}
`
);
}
catch
(
exportError
)
{
setError
(
exportError
?.
message
||
'模板导出失败,请稍后重试。'
);
}
}
async
function
handleWorkbookExport
()
{
try
{
const
result
=
await
exportLedgerWorkbook
(
activeKey
,
appliedFilters
[
activeKey
]);
setNotice
(
`已导出
${
result
.
count
}
条记录到
${
result
.
fileName
}
。`
);
}
catch
(
exportError
)
{
setError
(
exportError
?.
message
||
'导出失败,请稍后重试。'
);
}
}
async
function
handleSelectedExport
()
{
if
(
selectedRecords
.
length
===
0
)
{
setError
(
'请先勾选需要导出的记录。'
);
return
;
}
try
{
const
result
=
await
exportSelectedLedgerWorkbook
(
activeKey
,
selectedRecords
);
setNotice
(
`已导出
${
result
.
count
}
条勾选记录到
${
result
.
fileName
}
。`
);
}
catch
(
exportError
)
{
setError
(
exportError
?.
message
||
'勾选导出失败,请稍后重试。'
);
}
}
async
function
handlePreviewImport
(
file
)
{
setImportFile
(
file
);
setImportPreview
(
null
);
if
(
!
file
)
{
return
;
}
try
{
const
preview
=
await
previewLedgerWorkbook
(
activeKey
,
file
);
setImportPreview
(
preview
);
}
catch
(
previewError
)
{
setError
(
previewError
?.
message
||
'Excel 预览失败,请检查文件。'
);
}
}
async
function
handleImportWorkbook
()
{
if
(
!
importFile
)
{
setError
(
'请先选择 Excel 文件。'
);
return
;
}
setImporting
(
true
);
try
{
const
result
=
await
importLedgerWorkbook
(
activeKey
,
importFile
);
setNotice
(
result
.
failedCount
?
`已导入
${
result
.
createdCount
}
条,
${
result
.
failedCount
}
条失败。`
:
`已从
${
result
.
sheetName
}
导入
${
result
.
createdCount
}
条记录。`
,
);
setImportOpen
(
false
);
setImportFile
(
null
);
setImportPreview
(
null
);
await
reload
(
0
);
}
catch
(
importError
)
{
setError
(
importError
?.
message
||
'导入失败,请稍后重试。'
);
}
finally
{
setImporting
(
false
);
}
}
function
openBatchDialog
()
{
if
(
selectedRecords
.
length
===
0
)
{
setError
(
'请先勾选需要批量修改的记录。'
);
return
;
}
setBatchForm
(
createEmptyLedgerForm
(
activeKey
));
setBatchOpen
(
true
);
}
async
function
handleBatchSave
()
{
setBatchSaving
(
true
);
try
{
const
result
=
await
updateLedgerRecords
(
activeKey
,
selectedRecords
,
batchForm
);
setNotice
(
result
.
failedCount
?
`已批量更新
${
result
.
updatedCount
}
条,
${
result
.
failedCount
}
条失败。`
:
`已批量更新
${
result
.
updatedCount
}
条记录。`
,
);
setBatchOpen
(
false
);
await
reload
(
page
);
}
catch
(
batchError
)
{
setError
(
batchError
?.
message
||
'批量修改失败,请稍后重试。'
);
}
finally
{
setBatchSaving
(
false
);
}
}
const
focusFilter
=
activeConfig
.
focusFilter
;
const
focusField
=
focusFilter
?.
type
===
'select'
?
{
label
:
focusFilter
.
label
,
name
:
focusFilter
.
name
,
type
:
'select'
,
options
:
focusFilter
.
options
||
[],
}
:
null
;
return
(
<
div
className=
"space-y-5"
>
<
Panel
className=
"p-6"
>
<
div
className=
"flex flex-col gap-5 xl:flex-row xl:items-end xl:justify-between"
>
<
div
>
<
div
className=
"text-sm font-medium text-[var(--pm-brand)]"
>
廉洁党建建设管理
</
div
>
<
h1
className=
"mt-2 text-2xl font-semibold tracking-tight text-zinc-950 sm:text-3xl"
>
台账管理
</
h1
>
</
div
>
<
div
className=
"flex flex-wrap gap-2"
>
<
Button
type=
"button"
outline
onClick=
{
handleTemplateExport
}
>
<
DocumentArrowDownIcon
data
-
slot=
"icon"
/>
下载模板
</
Button
>
<
Button
type=
"button"
outline
onClick=
{
()
=>
{
setImportOpen
(
true
);
setImportFile
(
null
);
setImportPreview
(
null
);
}
}
>
<
DocumentArrowUpIcon
data
-
slot=
"icon"
/>
Excel 导入
</
Button
>
<
Button
type=
"button"
outline
onClick=
{
handleWorkbookExport
}
>
<
ArrowDownTrayIcon
data
-
slot=
"icon"
/>
Excel 导出
</
Button
>
<
Button
type=
"button"
color=
"blue"
onClick=
{
openCreateDialog
}
>
<
PlusIcon
data
-
slot=
"icon"
/>
新建记录
</
Button
>
</
div
>
</
div
>
<
div
className=
"mt-6 flex gap-2 overflow-x-auto pb-1"
>
{
INTEGRITY_LEDGER_CONFIGS
.
map
((
config
)
=>
(
<
button
key=
{
config
.
key
}
type=
"button"
onClick=
{
()
=>
{
setActiveKey
(
config
.
key
);
setPage
(
0
);
setSelectedIds
([]);
}
}
className=
{
clsx
(
'shrink-0 rounded-lg px-4 py-2 text-sm font-medium transition'
,
activeKey
===
config
.
key
?
'bg-[var(--pm-brand)] text-white shadow-sm'
:
'bg-white text-zinc-600 ring-1 ring-zinc-200 hover:bg-zinc-50 hover:text-zinc-950'
,
)
}
>
{
config
.
tabLabel
}
</
button
>
))
}
</
div
>
</
Panel
>
{
notice
?
(
<
div
className=
"rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700"
>
{
notice
}
</
div
>
)
:
null
}
{
error
?
(
<
div
className=
"rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700"
>
{
error
}
</
div
>
)
:
null
}
<
Panel
className=
"p-5"
>
<
div
className=
"grid gap-4 lg:grid-cols-[1.1fr_0.55fr_0.85fr_auto_auto] lg:items-end"
>
<
div
>
<
div
className=
"mb-2 text-sm font-medium text-zinc-600"
>
关键词
</
div
>
<
Input
value=
{
draftFilters
[
activeKey
]?.
keyword
||
''
}
onChange=
{
(
event
)
=>
updateDraftFilter
(
'keyword'
,
event
.
target
.
value
)
}
placeholder=
"搜索标题、主要内容、部门、人员等"
/>
</
div
>
<
div
>
<
div
className=
"mb-2 text-sm font-medium text-zinc-600"
>
年份
</
div
>
<
Input
type=
"number"
value=
{
draftFilters
[
activeKey
]?.
year
||
''
}
onChange=
{
(
event
)
=>
updateDraftFilter
(
'year'
,
event
.
target
.
value
)
}
placeholder=
"YYYY"
/>
</
div
>
<
div
>
<
div
className=
"mb-2 text-sm font-medium text-zinc-600"
>
{
focusFilter
.
label
}
</
div
>
{
focusField
?
(
<
FieldControl
field=
{
focusField
}
value=
{
draftFilters
[
activeKey
]?.
focusValue
||
''
}
onChange=
{
(
value
)
=>
updateDraftFilter
(
'focusValue'
,
value
)
}
/>
)
:
(
<
Input
value=
{
draftFilters
[
activeKey
]?.
focusValue
||
''
}
onChange=
{
(
event
)
=>
updateDraftFilter
(
'focusValue'
,
event
.
target
.
value
)
}
placeholder=
{
`输入${focusFilter.label}`
}
/>
)
}
</
div
>
<
Button
type=
"button"
color=
"blue"
onClick=
{
applyFilters
}
>
<
CheckIcon
data
-
slot=
"icon"
/>
查询
</
Button
>
<
Button
type=
"button"
outline
onClick=
{
resetFilters
}
>
<
ArrowPathIcon
data
-
slot=
"icon"
/>
重置
</
Button
>
</
div
>
</
Panel
>
<
Panel
className=
"overflow-hidden"
>
<
div
className=
"flex flex-col gap-3 border-b border-zinc-200 px-5 py-4 lg:flex-row lg:items-center lg:justify-between"
>
<
div
>
<
div
className=
"font-semibold text-zinc-950"
>
{
activeConfig
.
label
}
</
div
>
<
div
className=
"mt-1 text-sm text-zinc-500"
>
共
{
total
}
条,当前显示
{
pageStart
}
-
{
pageEnd
}
条
</
div
>
</
div
>
<
div
className=
"flex flex-wrap gap-2"
>
<
Button
type=
"button"
outline
onClick=
{
handleSelectedExport
}
>
<
ArrowDownTrayIcon
data
-
slot=
"icon"
/>
导出勾选
</
Button
>
<
Button
type=
"button"
outline
onClick=
{
openBatchDialog
}
>
<
PencilSquareIcon
data
-
slot=
"icon"
/>
批量修改
</
Button
>
<
Button
type=
"button"
outline
onClick=
{
()
=>
{
if
(
selectedRecords
.
length
===
0
)
{
setError
(
'请先勾选需要批量删除的记录。'
);
return
;
}
setDeleteConfirm
({
open
:
true
,
mode
:
'batch'
});
}
}
>
<
TrashIcon
data
-
slot=
"icon"
/>
批量删除
</
Button
>
</
div
>
</
div
>
{
loading
?
(
<
div
className=
"px-5 py-8 text-sm text-[var(--pm-brand)]"
>
正在加载台账...
</
div
>
)
:
records
.
length
===
0
?
(
<
EmptyStatePage
title=
"当前筛选条件下还没有台账记录"
/>
)
:
(
<
Table
dense
striped
className=
"[--gutter:--spacing(5)]"
>
<
TableHead
>
<
TableRow
>
<
TableHeader
className=
"w-12"
>
<
input
type=
"checkbox"
aria
-
label=
"全选当前页"
checked=
{
records
.
length
>
0
&&
records
.
every
((
record
)
=>
selectedIds
.
includes
(
record
.
id
))
}
onChange=
{
(
event
)
=>
setSelectedIds
(
event
.
target
.
checked
?
records
.
map
((
record
)
=>
record
.
id
)
:
[],
)
}
className=
"size-4 rounded border-zinc-300 text-[var(--pm-brand)]"
/>
</
TableHeader
>
{
activeConfig
.
tableColumns
.
map
((
fieldName
)
=>
(
<
TableHeader
key=
{
fieldName
}
>
{
fieldMap
[
fieldName
]?.
label
||
fieldName
}
</
TableHeader
>
))
}
<
TableHeader
>
附件状态
</
TableHeader
>
<
TableHeader
>
更新时间
</
TableHeader
>
<
TableHeader
className=
"text-right"
>
操作
</
TableHeader
>
</
TableRow
>
</
TableHead
>
<
TableBody
>
{
records
.
map
((
record
)
=>
(
<
TableRow
key=
{
record
.
id
}
>
<
TableCell
>
<
input
type=
"checkbox"
aria
-
label=
{
`勾选记录 ${record.title}`
}
checked=
{
selectedIds
.
includes
(
record
.
id
)
}
onChange=
{
(
event
)
=>
{
setSelectedIds
((
current
)
=>
event
.
target
.
checked
?
[...
new
Set
([...
current
,
record
.
id
])]
:
current
.
filter
((
id
)
=>
id
!==
record
.
id
),
);
}
}
className=
"size-4 rounded border-zinc-300 text-[var(--pm-brand)]"
/>
</
TableCell
>
{
activeConfig
.
tableColumns
.
map
((
fieldName
)
=>
(
<
TableCell
key=
{
fieldName
}
className=
"max-w-[260px]"
>
<
span
title=
{
formatCellValue
(
fieldMap
[
fieldName
],
record
.
metadata
[
fieldName
])
}
>
{
formatCellValue
(
fieldMap
[
fieldName
],
record
.
metadata
[
fieldName
])
}
</
span
>
</
TableCell
>
))
}
<
TableCell
>
<
span
className=
{
clsx
(
'inline-flex rounded-full px-2.5 py-1 text-xs font-medium'
,
record
.
attachmentCount
>
0
?
'bg-emerald-50 text-emerald-700 ring-1 ring-emerald-200'
:
'bg-zinc-100 text-zinc-600 ring-1 ring-zinc-200'
,
)
}
>
{
record
.
attachmentStatus
}
</
span
>
</
TableCell
>
<
TableCell
>
{
formatDateTime
(
record
.
updatedAt
)
}
</
TableCell
>
<
TableCell
className=
"text-right"
>
<
Button
type=
"button"
plain
onClick=
{
()
=>
openEditDialog
(
record
)
}
>
<
PencilSquareIcon
data
-
slot=
"icon"
/>
编辑
</
Button
>
</
TableCell
>
</
TableRow
>
))
}
</
TableBody
>
</
Table
>
)
}
<
div
className=
"flex flex-col gap-3 border-t border-zinc-200 px-5 py-4 sm:flex-row sm:items-center sm:justify-between"
>
<
div
className=
"text-sm text-zinc-500"
>
已勾选
{
selectedRecords
.
length
}
条
</
div
>
<
div
className=
"flex flex-wrap items-center gap-2"
>
<
Select
aria
-
label=
"选择每页数量"
value=
{
String
(
pageSize
)
}
onChange=
{
(
event
)
=>
{
setPageSize
(
Number
(
event
.
target
.
value
));
setPage
(
0
);
}
}
className=
"w-28"
>
{
INTEGRITY_PAGE_SIZE_OPTIONS
.
map
((
option
)
=>
(
<
option
key=
{
option
}
value=
{
option
}
>
{
option
}
条/页
</
option
>
))
}
</
Select
>
<
Button
type=
"button"
outline
disabled=
{
page
<=
0
}
onClick=
{
()
=>
reload
(
Math
.
max
(
page
-
1
,
0
))
}
>
上一页
</
Button
>
<
span
className=
"px-2 text-sm text-zinc-500"
>
{
page
+
1
}
/
{
totalPages
}
</
span
>
<
Button
type=
"button"
outline
disabled=
{
page
+
1
>=
totalPages
}
onClick=
{
()
=>
reload
(
page
+
1
)
}
>
下一页
</
Button
>
</
div
>
</
div
>
</
Panel
>
<
Dialog
open=
{
detailOpen
}
onClose=
{
setDetailOpen
}
size=
"5xl"
>
<
DialogTitle
>
{
detailMode
===
'create'
?
`新建${activeConfig.tabLabel}记录`
:
'编辑台账记录'
}
</
DialogTitle
>
<
DialogDescription
>
业务字段统一保存到 Record 的 metadata 中,附件为系统内上传材料。
</
DialogDescription
>
<
DialogBody
>
<
LedgerForm
config=
{
activeConfig
}
form=
{
detailForm
}
setForm=
{
setDetailForm
}
/>
</
DialogBody
>
<
DialogActions
>
{
detailMode
===
'edit'
?
(
<
Button
type=
"button"
outline
onClick=
{
()
=>
setDeleteConfirm
({
open
:
true
,
mode
:
'single'
})
}
>
<
TrashIcon
data
-
slot=
"icon"
/>
删除
</
Button
>
)
:
null
}
<
Button
type=
"button"
plain
onClick=
{
()
=>
setDetailOpen
(
false
)
}
>
取消
</
Button
>
<
Button
type=
"button"
color=
"blue"
onClick=
{
saveDetail
}
disabled=
{
saving
}
>
{
saving
?
'保存中...'
:
'保存'
}
</
Button
>
</
DialogActions
>
</
Dialog
>
<
Dialog
open=
{
importOpen
}
onClose=
{
setImportOpen
}
size=
"4xl"
>
<
DialogTitle
>
Excel 导入
{
activeConfig
.
tabLabel
}
</
DialogTitle
>
<
DialogDescription
>
当前导入只处理台账字段,附件请在系统内上传维护。
</
DialogDescription
>
<
DialogBody
>
<
div
className=
"rounded-2xl border border-dashed border-zinc-300 bg-zinc-50 p-5"
>
<
input
type=
"file"
accept=
".xlsx,.xls,.csv"
onChange=
{
(
event
)
=>
handlePreviewImport
(
event
.
target
.
files
?.[
0
]
||
null
)
}
className=
"block w-full text-sm text-zinc-600 file:mr-4 file:rounded-lg file:border-0 file:bg-[var(--pm-brand)] file:px-4 file:py-2 file:text-sm file:font-medium file:text-white"
/>
</
div
>
{
importPreview
?
(
<
div
className=
"mt-5"
>
<
div
className=
"mb-3 text-sm text-zinc-600"
>
工作表:
{
importPreview
.
sheetName
}
,识别到
{
importPreview
.
totalRows
}
行
</
div
>
<
div
className=
"overflow-x-auto rounded-2xl border border-zinc-200"
>
<
table
className=
"min-w-full text-left text-sm"
>
<
thead
className=
"bg-zinc-50 text-zinc-500"
>
<
tr
>
<
th
className=
"px-4 py-3"
>
行号
</
th
>
{
activeConfig
.
fields
.
filter
((
field
)
=>
field
.
type
!==
'file'
)
.
slice
(
0
,
6
)
.
map
((
field
)
=>
(
<
th
key=
{
field
.
name
}
className=
"px-4 py-3"
>
{
field
.
label
}
</
th
>
))
}
</
tr
>
</
thead
>
<
tbody
>
{
importPreview
.
previewRows
.
map
((
row
)
=>
(
<
tr
key=
{
row
.
rowNumber
}
className=
"border-t border-zinc-100"
>
<
td
className=
"px-4 py-3 text-zinc-500"
>
{
row
.
rowNumber
}
</
td
>
{
activeConfig
.
fields
.
filter
((
field
)
=>
field
.
type
!==
'file'
)
.
slice
(
0
,
6
)
.
map
((
field
)
=>
(
<
td
key=
{
field
.
name
}
className=
"px-4 py-3"
>
{
row
.
metadata
[
field
.
name
]
||
'-'
}
</
td
>
))
}
</
tr
>
))
}
</
tbody
>
</
table
>
</
div
>
</
div
>
)
:
null
}
</
DialogBody
>
<
DialogActions
>
<
Button
type=
"button"
outline
onClick=
{
handleTemplateExport
}
>
下载模板
</
Button
>
<
Button
type=
"button"
plain
onClick=
{
()
=>
setImportOpen
(
false
)
}
>
取消
</
Button
>
<
Button
type=
"button"
color=
"blue"
onClick=
{
handleImportWorkbook
}
disabled=
{
importing
||
!
importFile
}
>
{
importing
?
'导入中...'
:
'确认导入'
}
</
Button
>
</
DialogActions
>
</
Dialog
>
<
Dialog
open=
{
batchOpen
}
onClose=
{
setBatchOpen
}
size=
"3xl"
>
<
DialogTitle
>
批量修改
{
activeConfig
.
tabLabel
}
</
DialogTitle
>
<
DialogDescription
>
当前已勾选
{
selectedRecords
.
length
}
条记录。仅覆盖你填写的字段,留空字段保持原值。
</
DialogDescription
>
<
DialogBody
>
<
LedgerForm
config=
{
activeConfig
}
form=
{
batchForm
}
setForm=
{
setBatchForm
}
fields=
{
activeConfig
.
batchFields
.
map
((
fieldName
)
=>
fieldMap
[
fieldName
]).
filter
(
Boolean
)
}
compact
/>
</
DialogBody
>
<
DialogActions
>
<
Button
type=
"button"
plain
onClick=
{
()
=>
setBatchOpen
(
false
)
}
>
取消
</
Button
>
<
Button
type=
"button"
color=
"blue"
onClick=
{
handleBatchSave
}
disabled=
{
batchSaving
}
>
{
batchSaving
?
'更新中...'
:
'批量保存'
}
</
Button
>
</
DialogActions
>
</
Dialog
>
<
Dialog
open=
{
deleteConfirm
.
open
}
onClose=
{
(
open
)
=>
setDeleteConfirm
((
current
)
=>
({
...
current
,
open
}))
}
size=
"md"
>
<
DialogTitle
>
{
deleteConfirm
.
mode
===
'batch'
?
'确认批量删除'
:
'确认删除记录'
}
</
DialogTitle
>
<
DialogDescription
>
{
deleteConfirm
.
mode
===
'batch'
?
`即将删除 ${selectedRecords.length} 条勾选记录,请确认是否继续。`
:
activeRecord
?
`即将删除:${activeRecord.title}`
:
'未找到当前记录。'
}
</
DialogDescription
>
<
DialogActions
>
<
Button
type=
"button"
plain
onClick=
{
()
=>
setDeleteConfirm
({
open
:
false
,
mode
:
'single'
})
}
>
取消
</
Button
>
<
Button
type=
"button"
color=
"red"
onClick=
{
confirmDelete
}
disabled=
{
saving
}
>
确认删除
</
Button
>
</
DialogActions
>
</
Dialog
>
</
div
>
);
}
src/pages/project_manager_xy/integrityPartyService.js
0 → 100644
View file @
dd0aab32
import
{
countRecords
,
createRecord
,
queryRecords
,
removeRecord
,
updateRecord
,
uploadFile
,
}
from
'@/services/DataService'
;
import
dayjs
from
'dayjs'
;
import
*
as
XLSX
from
'xlsx'
;
export
const
INTEGRITY_PAGE_SIZE_OPTIONS
=
[
20
,
50
,
100
];
export
const
DEFAULT_INTEGRITY_PAGE_SIZE
=
20
;
export
const
TRAINING_CATEGORY_OPTIONS
=
[
'廉政党课讲授'
,
'廉洁合规培训'
,
'专题廉洁合规教育培训'
,
'新员工廉洁教育培训'
,
];
export
const
HOLIDAY_OPTIONS
=
[
'元旦'
,
'春节'
,
'清明'
,
'五一'
,
'端午'
,
'中秋'
,
'国庆'
,
'其他'
,
];
const
FIELD_TYPES
=
{
TEXT
:
'text'
,
LONG_TEXT
:
'long_text'
,
NUMBER
:
'number'
,
DATE
:
'date'
,
SELECT
:
'select'
,
BOOLEAN
:
'boolean'
,
FILE
:
'file'
,
};
export
const
INTEGRITY_LEDGER_CONFIGS
=
[
{
key
:
'training'
,
subject
:
'integrity_party_training'
,
label
:
'廉洁党建培训记录'
,
tabLabel
:
'培训类'
,
filePrefix
:
'廉洁党建培训记录'
,
yearField
:
'training_date'
,
titleFallback
:
'未命名培训记录'
,
buildTitle
:
(
metadata
)
=>
compactTitle
([
metadata
.
training_category
,
metadata
.
lecture_topic
,
formatDate
(
metadata
.
training_date
),
])
||
'未命名培训记录'
,
focusFilter
:
{
name
:
'training_category'
,
label
:
'培训类别'
,
type
:
FIELD_TYPES
.
SELECT
,
options
:
TRAINING_CATEGORY_OPTIONS
,
},
tableColumns
:
[
'sequence_no'
,
'training_category'
,
'training_date'
,
'lecture_topic'
,
'training_target'
,
'lead_department'
,
],
batchFields
:
[
'training_category'
,
'training_date'
,
'lead_department'
],
fields
:
[
field
(
'序号'
,
'sequence_no'
,
FIELD_TYPES
.
NUMBER
),
field
(
'培训类别'
,
'training_category'
,
FIELD_TYPES
.
SELECT
,
{
required
:
true
,
options
:
TRAINING_CATEGORY_OPTIONS
,
}),
field
(
'时间'
,
'training_date'
,
FIELD_TYPES
.
DATE
,
{
required
:
true
}),
field
(
'培训对象'
,
'training_target'
,
FIELD_TYPES
.
TEXT
),
field
(
'培训形式'
,
'training_format'
,
FIELD_TYPES
.
TEXT
),
field
(
'牵头部门'
,
'lead_department'
,
FIELD_TYPES
.
TEXT
),
field
(
'授课人'
,
'lecturer'
,
FIELD_TYPES
.
TEXT
),
field
(
'授课主题'
,
'lecture_topic'
,
FIELD_TYPES
.
TEXT
,
{
required
:
true
}),
field
(
'主要内容'
,
'main_content'
,
FIELD_TYPES
.
LONG_TEXT
),
field
(
'参加人数'
,
'participant_count'
,
FIELD_TYPES
.
NUMBER
),
field
(
'课件材料'
,
'courseware_materials'
,
FIELD_TYPES
.
FILE
,
{
multiple
:
true
,
}),
field
(
'签到表'
,
'sign_in_sheets'
,
FIELD_TYPES
.
FILE
,
{
multiple
:
true
}),
],
},
{
key
:
'talk'
,
subject
:
'integrity_party_talk'
,
label
:
'廉洁党建谈话记录'
,
tabLabel
:
'谈话类'
,
filePrefix
:
'廉洁党建谈话记录'
,
yearField
:
'year'
,
titleFallback
:
'未命名谈话记录'
,
buildTitle
:
(
metadata
)
=>
compactTitle
([
metadata
.
person_name
,
metadata
.
year
?
`
${
metadata
.
year
}
年`
:
''
,
formatDate
(
metadata
.
planned_talk_date
),
])
||
'未命名谈话记录'
,
focusFilter
:
{
name
:
'person_or_department'
,
label
:
'姓名 / 部门'
,
matchFields
:
[
'person_name'
,
'primary_department'
],
},
tableColumns
:
[
'sequence_no'
,
'year'
,
'person_name'
,
'primary_department'
,
'position_name'
,
'planned_talk_date'
,
'actual_talk_date'
,
],
batchFields
:
[
'year'
,
'primary_department'
,
'position_name'
],
fields
:
[
field
(
'序号'
,
'sequence_no'
,
FIELD_TYPES
.
NUMBER
),
field
(
'年份'
,
'year'
,
FIELD_TYPES
.
NUMBER
,
{
required
:
true
}),
field
(
'姓名'
,
'person_name'
,
FIELD_TYPES
.
TEXT
,
{
required
:
true
}),
field
(
'所属一级部门'
,
'primary_department'
,
FIELD_TYPES
.
TEXT
),
field
(
'岗位名称'
,
'position_name'
,
FIELD_TYPES
.
TEXT
),
field
(
'岗位晋升时间'
,
'promotion_date'
,
FIELD_TYPES
.
DATE
),
field
(
'是否党员'
,
'is_party_member'
,
FIELD_TYPES
.
BOOLEAN
),
field
(
'是否领导干部'
,
'is_leader'
,
FIELD_TYPES
.
BOOLEAN
),
field
(
'是否投资序列'
,
'is_investment_sequence'
,
FIELD_TYPES
.
BOOLEAN
),
field
(
'最近一期谈话时间'
,
'latest_talk_date'
,
FIELD_TYPES
.
DATE
),
field
(
'本年度计划谈话时间'
,
'planned_talk_date'
,
FIELD_TYPES
.
DATE
),
field
(
'是否应做任前谈话'
,
'should_pre_appointment_talk'
,
FIELD_TYPES
.
BOOLEAN
),
field
(
'是否已做任前谈话'
,
'has_pre_appointment_talk'
,
FIELD_TYPES
.
BOOLEAN
),
field
(
'是否纪委书记主动约谈'
,
'is_discipline_secretary_interview'
,
FIELD_TYPES
.
BOOLEAN
,
),
field
(
'本年度实际谈话时间'
,
'actual_talk_date'
,
FIELD_TYPES
.
DATE
),
field
(
'谈话支持材料'
,
'talk_materials'
,
FIELD_TYPES
.
FILE
,
{
multiple
:
true
,
}),
],
},
{
key
:
'research'
,
subject
:
'integrity_party_research'
,
label
:
'廉洁党建调研记录'
,
tabLabel
:
'调研类'
,
filePrefix
:
'廉洁党建调研记录'
,
yearField
:
'research_date'
,
titleFallback
:
'未命名调研记录'
,
buildTitle
:
(
metadata
)
=>
compactTitle
([
metadata
.
research_unit
,
formatDate
(
metadata
.
research_date
)])
||
'未命名调研记录'
,
focusFilter
:
{
name
:
'research_unit'
,
label
:
'调研单位'
,
matchFields
:
[
'research_unit'
],
},
tableColumns
:
[
'sequence_no'
,
'research_date'
,
'research_unit'
,
'research_staff_and_position'
,
'research_content'
,
],
batchFields
:
[
'research_date'
,
'research_unit'
,
'remarks'
],
fields
:
[
field
(
'序号'
,
'sequence_no'
,
FIELD_TYPES
.
NUMBER
),
field
(
'调研时间'
,
'research_date'
,
FIELD_TYPES
.
DATE
,
{
required
:
true
}),
field
(
'调研人员及职务'
,
'research_staff_and_position'
,
FIELD_TYPES
.
TEXT
),
field
(
'调研单位'
,
'research_unit'
,
FIELD_TYPES
.
TEXT
,
{
required
:
true
}),
field
(
'调研内容'
,
'research_content'
,
FIELD_TYPES
.
LONG_TEXT
),
field
(
'调研方案或调研报告'
,
'research_report_materials'
,
FIELD_TYPES
.
FILE
,
{
multiple
:
true
,
}),
field
(
'备注'
,
'remarks'
,
FIELD_TYPES
.
LONG_TEXT
),
],
},
{
key
:
'meeting'
,
subject
:
'integrity_party_meeting'
,
label
:
'廉洁党建专题会议记录'
,
tabLabel
:
'专题会议类'
,
filePrefix
:
'廉洁党建专题会议记录'
,
yearField
:
'meeting_date'
,
titleFallback
:
'未命名会议记录'
,
buildTitle
:
(
metadata
)
=>
compactTitle
([
metadata
.
meeting_topic
,
formatDate
(
metadata
.
meeting_date
)])
||
'未命名会议记录'
,
focusFilter
:
{
name
:
'meeting_topic'
,
label
:
'会议议题'
,
matchFields
:
[
'meeting_topic'
],
},
tableColumns
:
[
'sequence_no'
,
'meeting_date'
,
'meeting_topic'
,
'main_content'
],
batchFields
:
[
'meeting_date'
,
'meeting_topic'
],
fields
:
[
field
(
'序号'
,
'sequence_no'
,
FIELD_TYPES
.
NUMBER
),
field
(
'会议时间'
,
'meeting_date'
,
FIELD_TYPES
.
DATE
,
{
required
:
true
}),
field
(
'会议议题'
,
'meeting_topic'
,
FIELD_TYPES
.
TEXT
,
{
required
:
true
}),
field
(
'会议主要内容'
,
'main_content'
,
FIELD_TYPES
.
LONG_TEXT
),
field
(
'支持材料'
,
'supporting_materials'
,
FIELD_TYPES
.
FILE
,
{
multiple
:
true
,
}),
],
},
{
key
:
'activity'
,
subject
:
'integrity_party_activity'
,
label
:
'廉洁主题活动记录'
,
tabLabel
:
'廉洁主题活动'
,
filePrefix
:
'廉洁主题活动记录'
,
yearField
:
'year'
,
titleFallback
:
'未命名活动记录'
,
buildTitle
:
(
metadata
)
=>
compactTitle
([
metadata
.
activity_theme
,
metadata
.
year
?
`
${
metadata
.
year
}
年`
:
''
,
])
||
'未命名活动记录'
,
focusFilter
:
{
name
:
'activity_theme'
,
label
:
'活动主题'
,
matchFields
:
[
'activity_theme'
],
},
tableColumns
:
[
'sequence_no'
,
'year'
,
'activity_theme'
,
'activity_format'
],
batchFields
:
[
'year'
,
'activity_format'
],
fields
:
[
field
(
'序号'
,
'sequence_no'
,
FIELD_TYPES
.
NUMBER
),
field
(
'开展年份'
,
'year'
,
FIELD_TYPES
.
NUMBER
,
{
required
:
true
}),
field
(
'活动主题'
,
'activity_theme'
,
FIELD_TYPES
.
TEXT
,
{
required
:
true
}),
field
(
'活动形式'
,
'activity_format'
,
FIELD_TYPES
.
TEXT
),
field
(
'活动成果'
,
'activity_result'
,
FIELD_TYPES
.
LONG_TEXT
),
field
(
'支持材料'
,
'supporting_materials'
,
FIELD_TYPES
.
FILE
,
{
multiple
:
true
,
}),
],
},
{
key
:
'holiday_reminder'
,
subject
:
'integrity_party_holiday_reminder'
,
label
:
'节前廉洁提醒记录'
,
tabLabel
:
'节前廉洁提醒'
,
filePrefix
:
'节前廉洁提醒记录'
,
yearField
:
'year'
,
titleFallback
:
'未命名节前提醒'
,
buildTitle
:
(
metadata
)
=>
compactTitle
([
metadata
.
year
?
`
${
metadata
.
year
}
年`
:
''
,
metadata
.
holiday_name
===
'其他'
?
metadata
.
holiday_other
||
metadata
.
holiday_name
:
metadata
.
holiday_name
,
])
||
'未命名节前提醒'
,
focusFilter
:
{
name
:
'holiday_name'
,
label
:
'重大节假日'
,
type
:
FIELD_TYPES
.
SELECT
,
options
:
HOLIDAY_OPTIONS
,
},
tableColumns
:
[
'year'
,
'holiday_name'
,
'holiday_other'
,
'other_measures'
],
batchFields
:
[
'year'
,
'holiday_name'
,
'holiday_other'
],
fields
:
[
field
(
'年份'
,
'year'
,
FIELD_TYPES
.
NUMBER
,
{
required
:
true
}),
field
(
'重大节假日'
,
'holiday_name'
,
FIELD_TYPES
.
SELECT
,
{
required
:
true
,
options
:
HOLIDAY_OPTIONS
,
}),
field
(
'其他节假日说明'
,
'holiday_other'
,
FIELD_TYPES
.
TEXT
),
field
(
'廉洁谈话提纲上传'
,
'talk_outline_materials'
,
FIELD_TYPES
.
FILE
,
{
multiple
:
true
,
}),
field
(
'廉洁提醒支持材料上传'
,
'reminder_materials'
,
FIELD_TYPES
.
FILE
,
{
multiple
:
true
,
}),
field
(
'其他活动举措'
,
'other_measures'
,
FIELD_TYPES
.
LONG_TEXT
),
],
},
];
export
const
INTEGRITY_LEDGER_CONFIG_MAP
=
INTEGRITY_LEDGER_CONFIGS
.
reduce
(
(
accumulator
,
config
)
=>
{
accumulator
[
config
.
key
]
=
config
;
return
accumulator
;
},
{},
);
function
field
(
label
,
name
,
type
,
options
=
{})
{
return
{
label
,
name
,
type
,
required
:
Boolean
(
options
.
required
),
multiple
:
Boolean
(
options
.
multiple
),
options
:
options
.
options
||
[],
aliases
:
[
label
,
name
,
...(
options
.
aliases
||
[])],
};
}
function
compactTitle
(
parts
=
[])
{
return
parts
.
map
((
item
)
=>
String
(
item
||
''
).
trim
())
.
filter
(
Boolean
)
.
join
(
' · '
);
}
function
escapeRegExp
(
value
=
''
)
{
return
String
(
value
).
replace
(
/
[
.*+?^${}()|[
\]\\]
/g
,
'
\\
$&'
);
}
function
extractArray
(
response
)
{
const
candidates
=
[
response
,
response
?.
data
,
response
?.
data
?.
list
,
response
?.
data
?.
records
,
response
?.
list
,
response
?.
records
,
response
?.
items
,
response
?.
result
,
];
for
(
const
candidate
of
candidates
)
{
if
(
Array
.
isArray
(
candidate
))
{
return
candidate
;
}
}
return
[];
}
function
extractCount
(
response
)
{
const
candidates
=
[
response
,
response
?.
data
,
response
?.
result
,
response
?.
total_records
,
response
?.
data
?.
total_records
,
response
?.
result
?.
total_records
,
response
?.
count
,
response
?.
data
?.
count
,
response
?.
data
?.
total
,
response
?.
total
,
response
?.
result
?.
count
,
response
?.
result
?.
total
,
];
for
(
const
candidate
of
candidates
)
{
const
numericCandidate
=
Number
(
candidate
);
if
(
Number
.
isFinite
(
numericCandidate
))
{
return
numericCandidate
;
}
}
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
=
''
)
{
return
String
(
value
)
.
trim
()
.
toLowerCase
()
.
replace
(
/
[\s
_-
]
+/g
,
''
);
}
function
getConfig
(
configKey
)
{
const
config
=
INTEGRITY_LEDGER_CONFIG_MAP
[
configKey
];
if
(
!
config
)
{
throw
new
Error
(
'未找到对应的廉洁党建台账配置'
);
}
return
config
;
}
function
getFieldMap
(
config
)
{
return
config
.
fields
.
reduce
((
accumulator
,
fieldConfig
)
=>
{
accumulator
[
fieldConfig
.
name
]
=
fieldConfig
;
return
accumulator
;
},
{});
}
function
normalizeBoolean
(
value
)
{
if
(
value
===
true
||
value
===
false
||
value
===
null
)
{
return
value
;
}
const
text
=
String
(
value
??
''
).
trim
();
if
(
!
text
)
{
return
null
;
}
if
([
'true'
,
'1'
,
'是'
,
'Y'
,
'y'
,
'yes'
,
'YES'
].
includes
(
text
))
{
return
true
;
}
if
([
'false'
,
'0'
,
'否'
,
'N'
,
'n'
,
'no'
,
'NO'
].
includes
(
text
))
{
return
false
;
}
return
null
;
}
function
normalizeNumber
(
value
)
{
const
text
=
String
(
value
??
''
).
trim
();
if
(
!
text
)
{
return
null
;
}
const
numericValue
=
Number
(
text
);
return
Number
.
isFinite
(
numericValue
)
?
numericValue
:
null
;
}
export
function
formatDate
(
value
)
{
if
(
!
value
)
{
return
''
;
}
const
parsed
=
dayjs
(
value
);
if
(
parsed
.
isValid
())
{
return
parsed
.
format
(
'YYYY-MM-DD'
);
}
const
text
=
String
(
value
).
trim
();
const
matched
=
text
.
match
(
/^
(\d{4})[
-
/
年.
](\d{1,2})[
-
/
月.
](\d{1,2})
/
);
if
(
!
matched
)
{
return
text
;
}
return
[
matched
[
1
],
String
(
matched
[
2
]).
padStart
(
2
,
'0'
),
String
(
matched
[
3
]).
padStart
(
2
,
'0'
),
].
join
(
'-'
);
}
function
normalizeDateValue
(
value
)
{
const
dateText
=
formatDate
(
value
);
if
(
!
dateText
)
{
return
null
;
}
if
(
/^
\d{4}
-
\d{2}
-
\d{2}
$/
.
test
(
dateText
))
{
return
`
${
dateText
}
T00:00:00.000Z`
;
}
return
dateText
;
}
function
normalizeFileList
(
value
)
{
const
list
=
Array
.
isArray
(
value
)
?
value
:
value
?
[
value
]
:
[];
return
list
.
map
((
item
)
=>
{
if
(
!
item
)
{
return
null
;
}
if
(
typeof
item
===
'string'
)
{
return
{
name
:
item
,
type
:
''
,
size
:
0
,
url
:
item
,
};
}
return
{
name
:
item
.
name
||
item
.
filename
||
item
.
title
||
item
.
url
||
'未命名附件'
,
type
:
item
.
type
||
item
.
mime_type
||
''
,
size
:
Number
(
item
.
size
||
0
),
url
:
item
.
url
||
item
.
path
||
item
.
href
||
item
.
src
||
''
,
};
})
.
filter
((
item
)
=>
item
?.
name
||
item
?.
url
);
}
function
normalizeFieldValue
(
fieldConfig
,
value
,
{
forForm
=
false
}
=
{})
{
if
(
fieldConfig
.
type
===
FIELD_TYPES
.
NUMBER
)
{
return
forForm
?
value
===
null
||
value
===
undefined
?
''
:
String
(
value
)
:
normalizeNumber
(
value
);
}
if
(
fieldConfig
.
type
===
FIELD_TYPES
.
DATE
)
{
return
forForm
?
formatDate
(
value
)
:
normalizeDateValue
(
value
);
}
if
(
fieldConfig
.
type
===
FIELD_TYPES
.
BOOLEAN
)
{
if
(
forForm
)
{
const
booleanValue
=
normalizeBoolean
(
value
);
if
(
booleanValue
===
true
)
{
return
'true'
;
}
if
(
booleanValue
===
false
)
{
return
'false'
;
}
return
''
;
}
return
normalizeBoolean
(
value
);
}
if
(
fieldConfig
.
type
===
FIELD_TYPES
.
FILE
)
{
return
normalizeFileList
(
value
);
}
return
String
(
value
??
''
).
trim
();
}
function
isEmptyMetadataRow
(
metadata
=
{},
config
)
{
return
config
.
fields
.
filter
((
fieldConfig
)
=>
fieldConfig
.
type
!==
FIELD_TYPES
.
FILE
)
.
every
((
fieldConfig
)
=>
{
const
value
=
metadata
[
fieldConfig
.
name
];
return
value
===
''
||
value
===
null
||
value
===
undefined
;
});
}
export
function
createEmptyLedgerForm
(
configKey
)
{
const
config
=
getConfig
(
configKey
);
return
config
.
fields
.
reduce
((
accumulator
,
fieldConfig
)
=>
{
accumulator
[
fieldConfig
.
name
]
=
fieldConfig
.
type
===
FIELD_TYPES
.
FILE
?
[]
:
''
;
return
accumulator
;
},
{});
}
export
function
normalizeLedgerRecord
(
record
=
{},
configKey
)
{
const
config
=
getConfig
(
configKey
);
const
fieldMap
=
getFieldMap
(
config
);
const
metadata
=
record
?.
metadata
||
{};
const
normalizedMetadata
=
config
.
fields
.
reduce
((
accumulator
,
fieldConfig
)
=>
{
accumulator
[
fieldConfig
.
name
]
=
normalizeFieldValue
(
fieldConfig
,
metadata
[
fieldConfig
.
name
],
{
forForm
:
true
},
);
return
accumulator
;
},
{});
const
attachmentCount
=
config
.
fields
.
filter
((
fieldConfig
)
=>
fieldConfig
.
type
===
FIELD_TYPES
.
FILE
)
.
reduce
((
sum
,
fieldConfig
)
=>
{
return
sum
+
normalizeFileList
(
metadata
[
fieldConfig
.
name
]).
length
;
},
0
);
const
requiredAttachmentFields
=
config
.
fields
.
filter
(
(
fieldConfig
)
=>
fieldConfig
.
type
===
FIELD_TYPES
.
FILE
,
);
const
filledAttachmentFields
=
requiredAttachmentFields
.
filter
(
(
fieldConfig
)
=>
normalizeFileList
(
metadata
[
fieldConfig
.
name
]).
length
>
0
,
);
const
title
=
record
.
title
||
config
.
buildTitle
(
normalizedMetadata
);
return
{
id
:
record
.
_id
||
record
.
id
||
record
.
name
||
''
,
recordName
:
record
.
name
||
record
.
_id
||
record
.
id
||
''
,
title
:
title
||
config
.
titleFallback
,
subject
:
config
.
subject
,
ledgerKey
:
config
.
key
,
ledgerLabel
:
config
.
label
,
metadata
:
normalizedMetadata
,
rawMetadata
:
metadata
,
rawRecord
:
record
,
attachmentCount
,
attachmentStatus
:
requiredAttachmentFields
.
length
===
0
?
'无附件字段'
:
`
${
filledAttachmentFields
.
length
}
/
${
requiredAttachmentFields
.
length
}
`
,
createdAt
:
record
.
created_at
||
''
,
updatedAt
:
record
.
updated_at
||
record
.
created_at
||
''
,
updatedAtLabel
:
formatDateTime
(
record
.
updated_at
||
record
.
created_at
),
fieldMap
,
};
}
export
function
formatDateTime
(
value
)
{
if
(
!
value
)
{
return
'-'
;
}
const
parsed
=
dayjs
(
value
);
return
parsed
.
isValid
()
?
parsed
.
format
(
'YYYY-MM-DD HH:mm'
)
:
String
(
value
);
}
function
buildLedgerMetadata
(
config
,
values
=
{})
{
return
config
.
fields
.
reduce
((
metadata
,
fieldConfig
)
=>
{
metadata
[
fieldConfig
.
name
]
=
normalizeFieldValue
(
fieldConfig
,
values
[
fieldConfig
.
name
],
);
return
metadata
;
},
{});
}
function
validateLedgerMetadata
(
config
,
metadata
=
{})
{
const
missingFields
=
config
.
fields
.
filter
((
fieldConfig
)
=>
{
if
(
!
fieldConfig
.
required
)
{
return
false
;
}
const
value
=
metadata
[
fieldConfig
.
name
];
return
value
===
''
||
value
===
null
||
value
===
undefined
;
});
if
(
missingFields
.
length
>
0
)
{
throw
new
Error
(
`请填写必填字段:
${
missingFields
.
map
((
item
)
=>
item
.
label
).
join
(
'、'
)}
`
);
}
}
export
function
buildLedgerPayload
(
configKey
,
values
=
{})
{
const
config
=
getConfig
(
configKey
);
const
metadata
=
buildLedgerMetadata
(
config
,
values
);
validateLedgerMetadata
(
config
,
metadata
);
const
displayMetadata
=
config
.
fields
.
reduce
((
accumulator
,
fieldConfig
)
=>
{
accumulator
[
fieldConfig
.
name
]
=
normalizeFieldValue
(
fieldConfig
,
metadata
[
fieldConfig
.
name
],
{
forForm
:
true
},
);
return
accumulator
;
},
{});
const
title
=
config
.
buildTitle
(
displayMetadata
);
return
{
subject
:
config
.
subject
,
title
,
content_type
:
'text'
,
content
:
title
,
metadata
,
};
}
export
async
function
createLedgerRecord
(
configKey
,
values
=
{})
{
const
payload
=
buildLedgerPayload
(
configKey
,
values
);
const
created
=
await
createRecord
(
payload
);
return
normalizeLedgerRecord
(
created
?.
data
||
created
?.
result
||
payload
,
configKey
);
}
export
async
function
updateLedgerRecord
(
configKey
,
ledgerRecord
,
values
=
{})
{
const
config
=
getConfig
(
configKey
);
const
recordName
=
ledgerRecord
?.
recordName
||
ledgerRecord
?.
id
;
if
(
!
recordName
)
{
throw
new
Error
(
'未找到可更新的台账记录标识'
);
}
const
payload
=
buildLedgerPayload
(
configKey
,
values
);
const
nextMetadata
=
{
...
ledgerRecord
.
rawMetadata
,
...
payload
.
metadata
,
};
const
updated
=
await
updateRecord
(
recordName
,
{
title
:
payload
.
title
,
content_type
:
'text'
,
content
:
payload
.
content
,
metadata
:
nextMetadata
,
});
return
normalizeLedgerRecord
(
updated
?.
data
||
updated
?.
result
||
{
...
ledgerRecord
.
rawRecord
,
title
:
payload
.
title
,
content
:
payload
.
content
,
metadata
:
nextMetadata
,
},
config
.
key
,
);
}
export
async
function
deleteLedgerRecord
(
ledgerRecord
)
{
const
recordName
=
ledgerRecord
?.
recordName
||
ledgerRecord
?.
id
;
if
(
!
recordName
)
{
throw
new
Error
(
'未找到可删除的台账记录标识'
);
}
await
removeRecord
(
recordName
);
}
function
buildKeywordFilter
(
config
,
keyword
=
''
)
{
const
normalizedKeyword
=
String
(
keyword
||
''
).
trim
();
if
(
!
normalizedKeyword
)
{
return
null
;
}
const
regex
=
{
$regex
:
escapeRegExp
(
normalizedKeyword
),
$options
:
'i'
,
};
const
textFields
=
config
.
fields
.
filter
(
(
fieldConfig
)
=>
fieldConfig
.
type
!==
FIELD_TYPES
.
FILE
,
);
return
{
$or
:
[{
title
:
regex
},
...
textFields
.
map
((
fieldConfig
)
=>
({
[
`metadata.
${
fieldConfig
.
name
}
`
]:
regex
,
}))],
};
}
function
buildYearFilter
(
config
,
year
=
''
)
{
const
normalizedYear
=
String
(
year
||
''
).
trim
();
if
(
!
normalizedYear
)
{
return
null
;
}
const
yearField
=
config
.
fields
.
find
(
(
fieldConfig
)
=>
fieldConfig
.
name
===
config
.
yearField
,
);
if
(
!
yearField
)
{
return
null
;
}
if
(
yearField
.
type
===
FIELD_TYPES
.
NUMBER
)
{
const
numericYear
=
Number
(
normalizedYear
);
return
Number
.
isFinite
(
numericYear
)
?
{
[
`metadata.
${
config
.
yearField
}
`
]:
numericYear
}
:
null
;
}
return
{
[
`metadata.
${
config
.
yearField
}
`
]:
{
$regex
:
`^
${
escapeRegExp
(
normalizedYear
)}
`
,
},
};
}
function
buildFocusFilter
(
config
,
value
=
''
)
{
const
normalizedValue
=
String
(
value
||
''
).
trim
();
if
(
!
normalizedValue
||
!
config
.
focusFilter
)
{
return
null
;
}
if
(
config
.
focusFilter
.
type
===
FIELD_TYPES
.
SELECT
)
{
return
{
[
`metadata.
${
config
.
focusFilter
.
name
}
`
]:
normalizedValue
,
};
}
const
regex
=
{
$regex
:
escapeRegExp
(
normalizedValue
),
$options
:
'i'
,
};
return
{
$or
:
(
config
.
focusFilter
.
matchFields
||
[
config
.
focusFilter
.
name
]).
map
(
(
fieldName
)
=>
({
[
`metadata.
${
fieldName
}
`
]:
regex
,
}),
),
};
}
function
buildLedgerFilter
(
config
,
filters
=
{})
{
const
clauses
=
[
buildKeywordFilter
(
config
,
filters
.
keyword
),
buildYearFilter
(
config
,
filters
.
year
),
buildFocusFilter
(
config
,
filters
.
focusValue
),
].
filter
(
Boolean
);
if
(
clauses
.
length
===
0
)
{
return
{};
}
if
(
clauses
.
length
===
1
)
{
return
clauses
[
0
];
}
return
{
$and
:
clauses
,
};
}
export
async
function
loadLedgerPage
(
configKey
,
filters
=
{})
{
const
config
=
getConfig
(
configKey
);
const
page
=
Number
(
filters
.
page
||
0
);
const
pageSize
=
Number
(
filters
.
pageSize
||
DEFAULT_INTEGRITY_PAGE_SIZE
);
const
filter
=
buildLedgerFilter
(
config
,
filters
);
try
{
const
response
=
await
queryRecords
({
subject
:
config
.
subject
,
page
,
page_size
:
pageSize
,
filter
,
sort
:
'-updated_at -created_at'
,
});
const
records
=
extractArray
(
response
).
map
((
record
)
=>
normalizeLedgerRecord
(
record
,
config
.
key
),
);
const
total
=
extractTotalRecords
(
response
)
??
extractCount
(
await
countRecords
({
subject
:
config
.
subject
,
filter
,
}),
);
return
{
records
,
total
,
page
,
pageSize
,
hasMore
:
(
page
+
1
)
*
pageSize
<
total
,
error
:
''
,
};
}
catch
(
error
)
{
return
{
records
:
[],
total
:
0
,
page
,
pageSize
,
hasMore
:
false
,
error
:
error
?.
message
||
'台账数据加载失败,请稍后重试。'
,
};
}
}
async
function
loadAllLedgerRecords
(
configKey
,
filters
=
{})
{
const
pageSize
=
200
;
const
mergedRecords
=
[];
let
page
=
0
;
let
hasMore
=
true
;
while
(
hasMore
)
{
const
result
=
await
loadLedgerPage
(
configKey
,
{
...
filters
,
page
,
pageSize
,
});
if
(
result
.
error
)
{
throw
new
Error
(
result
.
error
);
}
mergedRecords
.
push
(...(
result
.
records
||
[]));
hasMore
=
Boolean
(
result
.
hasMore
);
page
+=
1
;
}
return
mergedRecords
;
}
async
function
runMutationInBatches
(
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
?.
title
||
entry
?.
label
||
'未命名记录'
,
rowNumber
:
entry
?.
rowNumber
,
message
:
result
.
reason
?.
message
||
'操作失败'
,
});
});
}
return
{
successes
,
failures
,
};
}
export
async
function
deleteLedgerRecords
(
records
=
[])
{
const
{
successes
,
failures
}
=
await
runMutationInBatches
(
records
.
filter
(
Boolean
),
(
record
)
=>
deleteLedgerRecord
(
record
),
);
return
{
deletedCount
:
successes
.
length
,
failedCount
:
failures
.
length
,
failures
,
};
}
export
async
function
updateLedgerRecords
(
configKey
,
records
=
[],
values
=
{})
{
const
config
=
getConfig
(
configKey
);
const
fieldMap
=
getFieldMap
(
config
);
const
metadataPatch
=
{};
config
.
batchFields
.
forEach
((
fieldName
)
=>
{
const
fieldConfig
=
fieldMap
[
fieldName
];
const
rawValue
=
values
[
fieldName
];
const
hasPatchValue
=
fieldConfig
?.
type
===
FIELD_TYPES
.
BOOLEAN
?
rawValue
===
'true'
||
rawValue
===
'false'
:
String
(
rawValue
??
''
).
trim
()
!==
''
;
if
(
fieldConfig
&&
hasPatchValue
)
{
metadataPatch
[
fieldName
]
=
normalizeFieldValue
(
fieldConfig
,
rawValue
);
}
});
if
(
Object
.
keys
(
metadataPatch
).
length
===
0
)
{
throw
new
Error
(
'请至少填写一个需要批量覆盖的字段'
);
}
const
{
successes
,
failures
}
=
await
runMutationInBatches
(
records
.
filter
(
Boolean
),
async
(
record
)
=>
{
const
recordName
=
record
?.
recordName
||
record
?.
id
;
if
(
!
recordName
)
{
throw
new
Error
(
'未找到可更新的台账记录标识'
);
}
const
nextMetadata
=
{
...
record
.
rawMetadata
,
...
metadataPatch
,
};
const
formMetadata
=
config
.
fields
.
reduce
((
accumulator
,
fieldConfig
)
=>
{
accumulator
[
fieldConfig
.
name
]
=
normalizeFieldValue
(
fieldConfig
,
nextMetadata
[
fieldConfig
.
name
],
{
forForm
:
true
},
);
return
accumulator
;
},
{});
const
title
=
config
.
buildTitle
(
formMetadata
);
const
updated
=
await
updateRecord
(
recordName
,
{
title
,
content_type
:
'text'
,
content
:
title
,
metadata
:
nextMetadata
,
});
return
normalizeLedgerRecord
(
updated
?.
data
||
updated
?.
result
||
{
...
record
.
rawRecord
,
title
,
content
:
title
,
metadata
:
nextMetadata
,
},
config
.
key
,
);
},
);
return
{
updatedRecords
:
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
]),
);
for
(
const
alias
of
aliases
)
{
const
matchedValue
=
normalizedAliasMap
.
get
(
normalizeHeaderKey
(
alias
));
if
(
matchedValue
!==
undefined
&&
matchedValue
!==
null
)
{
return
String
(
matchedValue
).
trim
();
}
}
return
''
;
}
function
normalizeWorkbookRow
(
config
,
row
=
{},
rowIndex
=
0
)
{
const
metadata
=
{};
config
.
fields
.
filter
((
fieldConfig
)
=>
fieldConfig
.
type
!==
FIELD_TYPES
.
FILE
)
.
forEach
((
fieldConfig
)
=>
{
metadata
[
fieldConfig
.
name
]
=
getImportFieldValue
(
row
,
fieldConfig
.
aliases
);
});
return
{
rowNumber
:
rowIndex
+
2
,
metadata
,
};
}
export
async
function
previewLedgerWorkbook
(
configKey
,
file
)
{
const
config
=
getConfig
(
configKey
);
if
(
!
file
)
{
throw
new
Error
(
'请先选择 Excel 文件'
);
}
const
workbook
=
XLSX
.
read
(
await
file
.
arrayBuffer
(),
{
type
:
'array'
,
});
const
sheetName
=
workbook
.
SheetNames
[
0
];
if
(
!
sheetName
)
{
throw
new
Error
(
'Excel 中没有可读取的工作表'
);
}
const
sheet
=
workbook
.
Sheets
[
sheetName
];
const
rows
=
XLSX
.
utils
.
sheet_to_json
(
sheet
,
{
defval
:
''
,
raw
:
false
,
})
.
map
((
row
,
index
)
=>
normalizeWorkbookRow
(
config
,
row
,
index
))
.
filter
((
row
)
=>
!
isEmptyMetadataRow
(
row
.
metadata
,
config
));
return
{
sheetName
,
rows
,
totalRows
:
rows
.
length
,
previewRows
:
rows
.
slice
(
0
,
5
),
};
}
export
async
function
importLedgerWorkbook
(
configKey
,
file
)
{
const
config
=
getConfig
(
configKey
);
const
preview
=
await
previewLedgerWorkbook
(
configKey
,
file
);
if
(
preview
.
totalRows
===
0
)
{
throw
new
Error
(
'未识别到可导入的数据行,请检查当前 Tab 对应的模板表头'
);
}
const
entries
=
preview
.
rows
.
map
((
row
)
=>
({
rowNumber
:
row
.
rowNumber
,
payload
:
buildLedgerPayload
(
config
.
key
,
row
.
metadata
),
}));
const
{
successes
,
failures
}
=
await
runMutationInBatches
(
entries
,
async
(
entry
)
=>
{
const
created
=
await
createRecord
(
entry
.
payload
);
return
normalizeLedgerRecord
(
created
?.
data
||
created
?.
result
||
entry
.
payload
,
config
.
key
,
);
});
return
{
sheetName
:
preview
.
sheetName
,
totalRows
:
preview
.
totalRows
,
createdCount
:
successes
.
length
,
failedCount
:
failures
.
length
,
failures
,
createdRecords
:
successes
,
};
}
function
formatBoolean
(
value
)
{
const
booleanValue
=
normalizeBoolean
(
value
);
if
(
booleanValue
===
true
)
{
return
'是'
;
}
if
(
booleanValue
===
false
)
{
return
'否'
;
}
return
''
;
}
function
formatFileList
(
value
)
{
return
normalizeFileList
(
value
)
.
map
((
item
)
=>
[
item
.
name
,
item
.
url
].
filter
(
Boolean
).
join
(
' '
))
.
join
(
'
\
n'
);
}
function
formatExportValue
(
fieldConfig
,
value
)
{
if
(
fieldConfig
.
type
===
FIELD_TYPES
.
DATE
)
{
return
formatDate
(
value
);
}
if
(
fieldConfig
.
type
===
FIELD_TYPES
.
BOOLEAN
)
{
return
formatBoolean
(
value
);
}
if
(
fieldConfig
.
type
===
FIELD_TYPES
.
FILE
)
{
return
formatFileList
(
value
);
}
return
value
??
''
;
}
function
buildExportRow
(
config
,
record
=
{})
{
const
source
=
record
.
rawMetadata
||
record
.
metadata
||
{};
return
config
.
fields
.
reduce
((
row
,
fieldConfig
)
=>
{
row
[
fieldConfig
.
label
]
=
formatExportValue
(
fieldConfig
,
source
[
fieldConfig
.
name
]);
return
row
;
},
{});
}
function
createWorkbookFromRows
(
config
,
rows
=
[],
sheetName
)
{
const
headers
=
config
.
fields
.
map
((
fieldConfig
)
=>
fieldConfig
.
label
);
const
worksheet
=
XLSX
.
utils
.
json_to_sheet
(
rows
,
{
header
:
headers
});
const
workbook
=
XLSX
.
utils
.
book_new
();
XLSX
.
utils
.
book_append_sheet
(
workbook
,
worksheet
,
sheetName
);
return
workbook
;
}
export
async
function
exportLedgerWorkbook
(
configKey
,
filters
=
{})
{
const
records
=
await
loadAllLedgerRecords
(
configKey
,
filters
);
return
exportSelectedLedgerWorkbook
(
configKey
,
records
);
}
export
async
function
exportSelectedLedgerWorkbook
(
configKey
,
records
=
[])
{
const
config
=
getConfig
(
configKey
);
const
rows
=
records
.
map
((
record
)
=>
buildExportRow
(
config
,
record
));
const
workbook
=
createWorkbookFromRows
(
config
,
rows
,
config
.
tabLabel
);
const
fileName
=
`
${
config
.
filePrefix
}
_导出_
${
dayjs
().
format
(
'YYYYMMDD_HHmmss'
)}
.xlsx`
;
XLSX
.
writeFile
(
workbook
,
fileName
);
return
{
fileName
,
count
:
rows
.
length
,
};
}
export
async
function
exportLedgerTemplateWorkbook
(
configKey
)
{
const
config
=
getConfig
(
configKey
);
const
importFields
=
{
...
config
,
fields
:
config
.
fields
.
filter
((
fieldConfig
)
=>
fieldConfig
.
type
!==
FIELD_TYPES
.
FILE
),
};
const
workbook
=
createWorkbookFromRows
(
importFields
,
[],
`
${
config
.
tabLabel
}
导入模板`
);
const
fileName
=
`
${
config
.
filePrefix
}
_导入模板_
${
dayjs
().
format
(
'YYYYMMDD_HHmmss'
)}
.xlsx`
;
XLSX
.
writeFile
(
workbook
,
fileName
);
return
{
fileName
,
};
}
function
findUploadUrl
(
response
)
{
const
candidates
=
[
response
?.
url
,
response
?.
data
?.
url
,
response
?.
result
?.
url
,
response
?.
path
,
response
?.
data
?.
path
,
response
?.
result
?.
path
,
response
?.
file
?.
url
,
response
?.
data
?.
file
?.
url
,
];
return
candidates
.
find
(
Boolean
)
||
''
;
}
export
async
function
uploadLedgerAttachment
(
file
)
{
const
response
=
await
uploadFile
(
file
);
const
url
=
findUploadUrl
(
response
);
return
{
name
:
file
.
name
,
type
:
file
.
type
,
size
:
file
.
size
,
url
,
};
}
function
buildGroup
(
list
,
keyGetter
,
labelGetter
)
{
return
Object
.
values
(
list
.
reduce
((
accumulator
,
item
)
=>
{
const
key
=
keyGetter
(
item
);
if
(
!
key
)
{
return
accumulator
;
}
if
(
!
accumulator
[
key
])
{
accumulator
[
key
]
=
{
key
,
label
:
labelGetter
(
item
),
count
:
0
,
};
}
accumulator
[
key
].
count
+=
1
;
return
accumulator
;
},
{}),
).
sort
((
left
,
right
)
=>
right
.
count
-
left
.
count
);
}
export
async
function
loadIntegrityPartyDashboard
()
{
const
ledgerResults
=
await
Promise
.
all
(
INTEGRITY_LEDGER_CONFIGS
.
map
(
async
(
config
)
=>
{
const
result
=
await
loadLedgerPage
(
config
.
key
,
{
page
:
0
,
pageSize
:
500
,
});
return
{
config
,
records
:
result
.
records
||
[],
total
:
result
.
total
||
0
,
error
:
result
.
error
,
};
}),
);
const
records
=
ledgerResults
.
flatMap
((
result
)
=>
result
.
records
);
const
now
=
dayjs
();
const
currentYear
=
now
.
year
();
const
recentThreshold
=
now
.
subtract
(
30
,
'day'
);
const
totalAttachmentFields
=
records
.
reduce
((
sum
,
record
)
=>
{
const
config
=
getConfig
(
record
.
ledgerKey
);
return
(
sum
+
config
.
fields
.
filter
((
fieldConfig
)
=>
fieldConfig
.
type
===
FIELD_TYPES
.
FILE
)
.
length
);
},
0
);
const
filledAttachmentFields
=
records
.
reduce
((
sum
,
record
)
=>
{
const
config
=
getConfig
(
record
.
ledgerKey
);
return
(
sum
+
config
.
fields
.
filter
((
fieldConfig
)
=>
fieldConfig
.
type
===
FIELD_TYPES
.
FILE
)
.
filter
((
fieldConfig
)
=>
{
return
normalizeFileList
(
record
.
rawMetadata
[
fieldConfig
.
name
]).
length
>
0
;
}).
length
);
},
0
);
const
ledgerGroups
=
ledgerResults
.
map
((
result
)
=>
({
key
:
result
.
config
.
key
,
label
:
result
.
config
.
tabLabel
,
count
:
result
.
total
,
}));
const
yearGroups
=
buildGroup
(
records
.
map
((
record
)
=>
({
year
:
getRecordYear
(
record
),
})),
(
item
)
=>
item
.
year
||
'未填写年份'
,
(
item
)
=>
item
.
year
||
'未填写年份'
,
);
const
recentRecords
=
[...
records
]
.
sort
((
left
,
right
)
=>
String
(
right
.
updatedAt
||
right
.
createdAt
).
localeCompare
(
String
(
left
.
updatedAt
||
left
.
createdAt
),
),
)
.
slice
(
0
,
8
);
return
{
totalCount
:
ledgerResults
.
reduce
((
sum
,
result
)
=>
sum
+
result
.
total
,
0
),
currentYearCount
:
records
.
filter
((
record
)
=>
getRecordYear
(
record
)
===
String
(
currentYear
)).
length
,
recentCount
:
records
.
filter
((
record
)
=>
{
if
(
!
record
.
updatedAt
)
{
return
false
;
}
return
dayjs
(
record
.
updatedAt
).
isAfter
(
recentThreshold
);
}).
length
,
attachmentCompletionRate
:
totalAttachmentFields
>
0
?
Math
.
round
((
filledAttachmentFields
/
totalAttachmentFields
)
*
100
)
:
0
,
ledgerGroups
,
yearGroups
,
recentRecords
,
errors
:
ledgerResults
.
map
((
result
)
=>
result
.
error
).
filter
(
Boolean
),
};
}
function
getRecordYear
(
record
)
{
const
config
=
getConfig
(
record
.
ledgerKey
);
const
yearField
=
config
.
fields
.
find
(
(
fieldConfig
)
=>
fieldConfig
.
name
===
config
.
yearField
,
);
const
value
=
record
.
rawMetadata
?.[
config
.
yearField
]
??
record
.
metadata
?.[
config
.
yearField
];
if
(
yearField
?.
type
===
FIELD_TYPES
.
DATE
)
{
return
formatDate
(
value
).
slice
(
0
,
4
);
}
return
value
===
null
||
value
===
undefined
||
value
===
''
?
''
:
String
(
value
).
trim
();
}
src/pages/project_manager_xy/resource-stat/index.jsx
View file @
dd0aab32
import
{
ArrowTrendingUpIcon
,
CalendarDaysIcon
,
ChartPieIcon
,
ClipboardDocumentListIcon
,
DocumentTextIcon
,
PaperClipIcon
,
RectangleStackIcon
,
TagIcon
,
}
from
'@heroicons/react/24/outline'
;
...
...
@@ -13,6 +16,7 @@ import {
formatDateTime
,
loadCaseRecords
,
}
from
'../materialService'
;
import
{
loadIntegrityPartyDashboard
}
from
'../integrityPartyService'
;
function
Panel
({
className
,
children
})
{
return
(
...
...
@@ -358,8 +362,10 @@ function KeywordCloud({ groups = [] }) {
}
export
default
function
ResourceStatPage
()
{
const
[
activeDashboard
,
setActiveDashboard
]
=
useState
(
'case'
);
const
[
cases
,
setCases
]
=
useState
([]);
const
[
dashboard
,
setDashboard
]
=
useState
(()
=>
buildCaseDashboard
([]));
const
[
integrityDashboard
,
setIntegrityDashboard
]
=
useState
(
null
);
const
[
loading
,
setLoading
]
=
useState
(
true
);
const
[
error
,
setError
]
=
useState
(
''
);
const
[
isDemoMode
,
setIsDemoMode
]
=
useState
(
false
);
...
...
@@ -369,16 +375,24 @@ export default function ResourceStatPage() {
(
async
()
=>
{
setLoading
(
true
);
const
result
=
await
loadCaseRecords
({});
const
[
caseResult
,
integrityResult
]
=
await
Promise
.
all
([
loadCaseRecords
({}),
loadIntegrityPartyDashboard
(),
]);
if
(
cancelled
)
{
return
;
}
setCases
(
result
.
cases
||
[]);
setDashboard
(
result
.
dashboard
||
buildCaseDashboard
([]));
setError
(
result
.
error
||
''
);
setIsDemoMode
(
Boolean
(
result
.
isDemoMode
));
setCases
(
caseResult
.
cases
||
[]);
setDashboard
(
caseResult
.
dashboard
||
buildCaseDashboard
([]));
setIntegrityDashboard
(
integrityResult
);
setError
(
[
caseResult
.
error
,
...(
integrityResult
.
errors
||
[])]
.
filter
(
Boolean
)
.
join
(
';'
),
);
setIsDemoMode
(
Boolean
(
caseResult
.
isDemoMode
));
setLoading
(
false
);
})();
...
...
@@ -397,15 +411,37 @@ export default function ResourceStatPage() {
<
h1
className=
"text-3xl font-semibold tracking-tight text-zinc-950 sm:text-4xl"
>
驾驶舱
</
h1
>
<
div
className=
"mt-5 inline-flex rounded-lg bg-white p-1 shadow-sm ring-1 ring-zinc-200"
>
{
[
[
'case'
,
'案例库统计'
],
[
'integrity'
,
'廉洁党建统计'
],
].
map
(([
key
,
label
])
=>
(
<
button
key=
{
key
}
type=
"button"
onClick=
{
()
=>
setActiveDashboard
(
key
)
}
className=
{
clsx
(
'rounded-md px-4 py-2 text-sm font-medium transition'
,
activeDashboard
===
key
?
'bg-[var(--pm-brand)] text-white shadow-sm'
:
'text-zinc-600 hover:bg-zinc-50 hover:text-zinc-950'
,
)
}
>
{
label
}
</
button
>
))
}
</
div
>
</
div
>
<
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"
>
最近 7 天更新
{
activeDashboard
===
'case'
?
'最近 7 天更新'
:
'最近 30 天更新'
}
</
div
>
<
div
className=
"mt-2 text-2xl font-semibold text-zinc-950"
>
{
dashboard
.
recentCount
}
{
activeDashboard
===
'case'
?
dashboard
.
recentCount
:
integrityDashboard
?.
recentCount
||
0
}
</
div
>
</
div
>
</
div
>
...
...
@@ -432,61 +468,158 @@ export default function ResourceStatPage() {
</
div
>
</
Panel
>
{
activeDashboard
===
'case'
?
(
<>
<
div
className=
"grid gap-4 md:grid-cols-2 xl:grid-cols-4"
>
<
MetricCard
icon=
{
DocumentTextIcon
}
label=
"案例总数"
value=
{
dashboard
.
totalCount
}
tone=
"blue"
/>
<
MetricCard
icon=
{
RectangleStackIcon
}
label=
"分类数量"
value=
{
dashboard
.
uniqueCategoryCount
}
tone=
"green"
/>
<
MetricCard
icon=
{
TagIcon
}
label=
"标签数量"
value=
{
dashboard
.
uniqueTagCount
}
tone=
"amber"
/>
<
MetricCard
icon=
{
ChartPieIcon
}
label=
"案例类型"
value=
{
dashboard
.
caseTypeGroups
.
length
}
tone=
"blue"
/>
<
MetricCard
icon=
{
ArrowTrendingUpIcon
}
label=
"待整理"
value=
{
dashboard
.
attentionCases
.
length
}
tone=
"rose"
/>
</
div
>
<
div
className=
"grid gap-6 xl:grid-cols-[0.95fr_1.05fr]"
>
<
ChartPanel
title=
"分类结构"
groups=
{
dashboard
.
categoryGroups
}
type=
"pie"
/>
<
ChartPanel
title=
"案例类型排行"
groups=
{
dashboard
.
caseTypeGroups
}
/>
</
div
>
<
div
className=
"grid gap-6 xl:grid-cols-[1fr_0.9fr]"
>
<
ChartPanel
title=
"案例来源分布"
groups=
{
dashboard
.
caseSourceGroups
}
type=
"horizontalBar"
/>
<
Panel
className=
"p-6"
>
<
SectionHeader
title=
"关键字词云"
/>
<
KeywordCloud
groups=
{
dashboard
.
keywordGroups
}
/>
</
Panel
>
</
div
>
<
RecentCasePanel
dashboard=
{
dashboard
}
/>
{
!
loading
&&
cases
.
length
===
0
?
(
<
Panel
className=
"p-8 text-center text-sm text-zinc-500"
>
当前没有可展示的案例记录。
</
Panel
>
)
:
null
}
</>
)
:
(
<
IntegrityDashboardView
dashboard=
{
integrityDashboard
}
loading=
{
loading
}
/>
)
}
</
div
>
);
}
function
RecentCasePanel
({
dashboard
})
{
return
(
<
Panel
className=
"p-6"
>
<
SectionHeader
title=
"最近更新"
/>
<
div
className=
"space-y-3"
>
{
dashboard
.
recentCases
.
length
>
0
?
(
dashboard
.
recentCases
.
map
((
item
)
=>
(
<
div
key=
{
item
.
id
}
className=
"flex flex-col gap-3 rounded-[24px] border border-zinc-200 bg-zinc-50/70 px-4 py-4 sm:flex-row sm:items-center sm:justify-between"
>
<
div
className=
"min-w-0"
>
<
div
className=
"flex flex-wrap items-center gap-2"
>
<
div
className=
"truncate text-sm font-semibold text-zinc-950 sm:text-base"
>
{
item
.
label
}
</
div
>
<
Pill
>
{
item
.
category
}
</
Pill
>
{
item
.
tag
?
<
Pill
tone=
"green"
>
{
item
.
tag
}
</
Pill
>
:
null
}
</
div
>
<
div
className=
"mt-2 text-sm text-zinc-500"
>
{
item
.
textLengthLabel
}
·
{
item
.
contentPreview
}
</
div
>
</
div
>
<
div
className=
"text-sm text-zinc-500"
>
{
formatDateTime
(
item
.
updatedAt
)
}
</
div
>
</
div
>
))
)
:
(
<
div
className=
"text-sm text-zinc-400"
>
暂无最近更新案例
</
div
>
)
}
</
div
>
</
Panel
>
);
}
function
IntegrityDashboardView
({
dashboard
,
loading
})
{
const
safeDashboard
=
dashboard
||
{
totalCount
:
0
,
currentYearCount
:
0
,
recentCount
:
0
,
attachmentCompletionRate
:
0
,
ledgerGroups
:
[],
yearGroups
:
[],
recentRecords
:
[],
};
return
(
<>
<
div
className=
"grid gap-4 md:grid-cols-2 xl:grid-cols-4"
>
<
MetricCard
icon=
{
DocumentTex
tIcon
}
label=
"
案例
总数"
value=
{
d
ashboard
.
totalCount
}
icon=
{
ClipboardDocumentLis
tIcon
}
label=
"
台账
总数"
value=
{
safeD
ashboard
.
totalCount
}
tone=
"blue"
/>
<
MetricCard
icon=
{
RectangleStack
Icon
}
label=
"
分类数量
"
value=
{
dashboard
.
uniqueCategory
Count
}
icon=
{
CalendarDays
Icon
}
label=
"
本年度记录
"
value=
{
safeDashboard
.
currentYear
Count
}
tone=
"green"
/>
<
MetricCard
icon=
{
Tag
Icon
}
label=
"
标签数量
"
value=
{
dashboard
.
uniqueTagCount
}
icon=
{
PaperClip
Icon
}
label=
"
附件完整率
"
value=
{
`${safeDashboard.attachmentCompletionRate}%`
}
tone=
"amber"
/>
<
MetricCard
icon=
{
ChartPieIcon
}
label=
"案例类型"
value=
{
dashboard
.
caseTypeGroups
.
length
}
tone=
"blue"
/>
<
MetricCard
icon=
{
ArrowTrendingUpIcon
}
label=
"
待整理
"
value=
{
dashboard
.
attentionCases
.
length
}
label=
"
最近 30 天更新
"
value=
{
safeDashboard
.
recentCount
}
tone=
"rose"
/>
</
div
>
<
div
className=
"grid gap-6 xl:grid-cols-[0.95fr_1.05fr]"
>
<
ChartPanel
title=
"分类结构"
groups=
{
dashboard
.
categoryGroups
}
type=
"pie"
/>
<
ChartPanel
title=
"案例类型排行"
groups=
{
dashboard
.
caseTypeGroups
}
/>
</
div
>
<
div
className=
"grid gap-6 xl:grid-cols-[1fr_0.9fr]"
>
<
ChartPanel
title=
"案例来源分布"
groups=
{
dashboard
.
caseSourceGroups
}
type=
"horizontalBar"
/>
<
Panel
className=
"p-6"
>
<
SectionHeader
title=
"关键字词云"
/>
<
KeywordCloud
groups=
{
dashboard
.
keywordGroups
}
/>
</
Panel
>
<
ChartPanel
title=
"台账分类分布"
groups=
{
safeDashboard
.
ledgerGroups
}
type=
"pie"
/>
<
ChartPanel
title=
"年度分布"
groups=
{
safeDashboard
.
yearGroups
}
/>
</
div
>
<
Panel
className=
"p-6"
>
<
SectionHeader
title=
"最近更新"
/>
<
SectionHeader
title=
"最近更新
台账
"
/>
<
div
className=
"space-y-3"
>
{
dashboard
.
recentCase
s
.
length
>
0
?
(
dashboard
.
recentCase
s
.
map
((
item
)
=>
(
{
safeDashboard
.
recentRecord
s
.
length
>
0
?
(
safeDashboard
.
recentRecord
s
.
map
((
item
)
=>
(
<
div
key=
{
item
.
id
}
className=
"flex flex-col gap-3 rounded-[24px] border border-zinc-200 bg-zinc-50/70 px-4 py-4 sm:flex-row sm:items-center sm:justify-between"
...
...
@@ -494,29 +627,27 @@ export default function ResourceStatPage() {
<
div
className=
"min-w-0"
>
<
div
className=
"flex flex-wrap items-center gap-2"
>
<
div
className=
"truncate text-sm font-semibold text-zinc-950 sm:text-base"
>
{
item
.
label
}
{
item
.
title
}
</
div
>
<
Pill
>
{
item
.
category
}
</
Pill
>
{
item
.
tag
?
<
Pill
tone=
"green"
>
{
item
.
tag
}
</
Pill
>
:
null
}
<
Pill
>
{
item
.
ledgerLabel
}
</
Pill
>
<
Pill
tone=
{
item
.
attachmentCount
>
0
?
'green'
:
'amber'
}
>
附件
{
item
.
attachmentCount
}
</
Pill
>
</
div
>
<
div
className=
"mt-2 text-sm text-zinc-500"
>
{
item
.
textLengthLabel
}
·
{
item
.
contentPreview
}
更新于
{
item
.
updatedAtLabel
}
</
div
>
</
div
>
<
div
className=
"text-sm text-zinc-500"
>
{
formatDateTime
(
item
.
updatedAt
)
}
</
div
>
<
div
className=
"text-sm text-zinc-500"
>
{
item
.
attachmentStatus
}
</
div
>
</
div
>
))
)
:
(
<
div
className=
"text-sm text-zinc-400"
>
暂无最近更新案例
</
div
>
<
div
className=
"text-sm text-zinc-400"
>
{
loading
?
'正在加载廉洁党建统计...'
:
'暂无最近更新台账'
}
</
div
>
)
}
</
div
>
</
Panel
>
{
!
loading
&&
cases
.
length
===
0
?
(
<
Panel
className=
"p-8 text-center text-sm text-zinc-500"
>
当前没有可展示的案例记录。
</
Panel
>
)
:
null
}
</
div
>
</>
);
}
src/services/record-api.md
View file @
dd0aab32
...
...
@@ -244,3 +244,89 @@
-
案例业务字段全部放入
`metadata`
-
`metadata.keyword`
为多值字符串数组;Excel 导入如读取到
`资金管理, 异常调账`
,前端需拆分为
`["资金管理", "异常调账"]`
后保存
-
Excel 导出时,
`metadata.keyword`
数组需恢复为逗号拼接字符串写入
`关键字`
列
## 6. 廉洁党建建设管理接入模式
本模块继续复用统一
`Record`
接口,6 类台账分别使用独立 subject:
-
`integrity_party_training`
:廉洁党建培训记录
-
`integrity_party_talk`
:廉洁党建谈话记录
-
`integrity_party_research`
:廉洁党建调研记录
-
`integrity_party_meeting`
:廉洁党建专题会议记录
-
`integrity_party_activity`
:廉洁主题活动记录
-
`integrity_party_holiday_reminder`
:节前廉洁提醒记录
### 6.1 创建 / 更新建议
调用:
-
`POST /record`
-
`PUT /record/:name`
示例:
```
json
{
"subject"
:
"integrity_party_training"
,
"title"
:
"廉洁合规培训 · 新员工廉洁合规入职培训 · 2026-06-08"
,
"content_type"
:
"text"
,
"content"
:
"廉洁合规培训 · 新员工廉洁合规入职培训 · 2026-06-08"
,
"metadata"
:
{
"sequence_no"
:
1
,
"training_category"
:
"廉洁合规培训"
,
"training_date"
:
"2026-06-08T00:00:00.000Z"
,
"training_target"
:
"新员工"
,
"training_format"
:
"现场培训"
,
"lead_department"
:
"纪检办公室"
,
"lecturer"
:
"张三"
,
"lecture_topic"
:
"新员工廉洁合规入职培训"
,
"main_content"
:
"围绕廉洁从业要求、合规底线和典型案例开展培训。"
,
"participant_count"
:
32
,
"courseware_materials"
:
[
{
"name"
:
"培训课件.pptx"
,
"type"
:
"application/vnd.openxmlformats-officedocument.presentationml.presentation"
,
"size"
:
204800
,
"url"
:
"https://example.com/file.pptx"
}
],
"sign_in_sheets"
:
[]
}
}
```
### 6.2 查询建议
调用:
-
`POST /record/query`
示例:
```
json
{
"subject"
:
"integrity_party_talk"
,
"page"
:
0
,
"page_size"
:
20
,
"filter"
:
{
"$and"
:
[
{
"metadata.year"
:
2026
},
{
"$or"
:
[
{
"metadata.person_name"
:
{
"$regex"
:
"张三"
,
"$options"
:
"i"
}
},
{
"metadata.primary_department"
:
{
"$regex"
:
"张三"
,
"$options"
:
"i"
}
}
]
}
]
},
"sort"
:
"-updated_at -created_at"
}
```
注意事项:
-
业务字段全部写入
`metadata`
-
文件字段统一保存为多附件数组
`{name,type,size,url}[]`
-
日期字段页面和 Excel 展示为
`YYYY-MM-DD`
,保存为 ISO 8601 字符串
-
Excel 导入不处理附件字段,附件仅通过系统上传控件维护
-
当前阶段导入不做重复判断、不覆盖已有记录,全部新增
src/services/subjectsDefinition.md
View file @
dd0aab32
...
...
@@ -7,6 +7,12 @@
当前项目当前阶段以“风险案例库”为目标,核心业务对象如下:
-
`risk_case_library`
:案例库单条案例
-
`integrity_party_training`
:廉洁党建培训记录
-
`integrity_party_talk`
:廉洁党建谈话记录
-
`integrity_party_research`
:廉洁党建调研记录
-
`integrity_party_meeting`
:廉洁党建专题会议记录
-
`integrity_party_activity`
:廉洁主题活动记录
-
`integrity_party_holiday_reminder`
:节前廉洁提醒记录
---
...
...
@@ -155,3 +161,115 @@ type Record = {
"case_year"
:
2024
}
```
## 3.2 廉洁党建建设管理
本模块使用 6 个独立 Subject 承载不同台账类型。每条台账记录对应一条
`Record`
,业务字段全部写入
`metadata`
,附件字段按多文件数组存储,结构为
`{ name, type, size, url }`
。
统一约定:
-
日期字段页面和 Excel 展示为
`YYYY-MM-DD`
,保存时前端转换为 ISO 8601 字符串
-
Excel 导入不处理附件列,附件仅通过系统上传控件维护
-
`title`
由前端按各对象核心字段自动生成,不提供单独标题字段
-
`sequence_no`
仅用于台账顺序和历史 Excel 对照,不作为主键
-
当前阶段不做导入重复判断,Excel 导入全部新增
### 3.2.1 廉洁党建培训记录
-
**subject**
:
`integrity_party_training`
-
**title 生成**
:
`培训类别 + 授课主题 + 时间`
| 字段标签 | 字段别名 | 字段类型 | 默认值 | 是否主键 | 是否多值 | 是否必填 | 搜索 | 聚合 | 说明 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 序号 |
`sequence_no`
|
`number`
|
`null`
| 否 | 否 | 否 | 否 | 否 | 台账顺序号 |
| 培训类别 |
`training_category`
|
`select`
|
`null`
| 否 | 否 | 是 | 否 | 否 | 选项:廉政党课讲授、廉洁合规培训、专题廉洁合规教育培训、新员工廉洁教育培训 |
| 时间 |
`training_date`
|
`date`
|
`null`
| 否 | 否 | 是 | 否 | 否 | 培训日期 |
| 培训对象 |
`training_target`
|
`text`
|
`null`
| 否 | 否 | 否 | 否 | 否 | 培训覆盖对象 |
| 培训形式 |
`training_format`
|
`text`
|
`null`
| 否 | 否 | 否 | 否 | 否 | 培训方式 |
| 牵头部门 |
`lead_department`
|
`text`
|
`null`
| 否 | 否 | 否 | 否 | 否 | 牵头组织部门 |
| 授课人 |
`lecturer`
|
`text`
|
`null`
| 否 | 否 | 否 | 否 | 否 | 授课人员 |
| 授课主题 |
`lecture_topic`
|
`text`
|
`null`
| 否 | 否 | 是 | 否 | 否 | 培训主题 |
| 主要内容 |
`main_content`
|
`long_text`
|
`null`
| 否 | 否 | 否 | 否 | 否 | 培训主要内容 |
| 参加人数 |
`participant_count`
|
`number`
|
`null`
| 否 | 否 | 否 | 否 | 否 | 实际参加人数 |
| 课件材料 |
`courseware_materials`
|
`file`
|
`[]`
| 否 | 是 | 否 | 否 | 否 | 系统内上传的 PPT、PDF 等课件 |
| 签到表 |
`sign_in_sheets`
|
`file`
|
`[]`
| 否 | 是 | 否 | 否 | 否 | 系统内上传签到材料 |
### 3.2.2 廉洁党建谈话记录
-
**subject**
:
`integrity_party_talk`
-
**title 生成**
:
`姓名 + 年份 + 本年度计划谈话时间`
| 字段标签 | 字段别名 | 字段类型 | 默认值 | 是否主键 | 是否多值 | 是否必填 | 搜索 | 聚合 | 说明 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 序号 |
`sequence_no`
|
`number`
|
`null`
| 否 | 否 | 否 | 否 | 否 | 台账顺序号 |
| 年份 |
`year`
|
`number`
|
`null`
| 否 | 否 | 是 | 否 | 否 | 台账年份 |
| 姓名 |
`person_name`
|
`text`
|
`null`
| 否 | 否 | 是 | 否 | 否 | 谈话对象姓名 |
| 所属一级部门 |
`primary_department`
|
`text`
|
`null`
| 否 | 否 | 否 | 否 | 否 | 一级部门名称 |
| 岗位名称 |
`position_name`
|
`text`
|
`null`
| 否 | 否 | 否 | 否 | 否 | 岗位名称 |
| 岗位晋升时间 |
`promotion_date`
|
`date`
|
`null`
| 否 | 否 | 否 | 否 | 否 | 岗位晋升日期 |
| 是否党员 |
`is_party_member`
|
`boolean`
|
`null`
| 否 | 否 | 否 | 否 | 否 | 三态:是、否、未填写 |
| 是否领导干部 |
`is_leader`
|
`boolean`
|
`null`
| 否 | 否 | 否 | 否 | 否 | 三态:是、否、未填写 |
| 是否投资序列 |
`is_investment_sequence`
|
`boolean`
|
`null`
| 否 | 否 | 否 | 否 | 否 | 三态:是、否、未填写 |
| 最近一期谈话时间 |
`latest_talk_date`
|
`date`
|
`null`
| 否 | 否 | 否 | 否 | 否 | 最近一次谈话日期 |
| 本年度计划谈话时间 |
`planned_talk_date`
|
`date`
|
`null`
| 否 | 否 | 否 | 否 | 否 | 本年度计划谈话日期 |
| 是否应做任前谈话 |
`should_pre_appointment_talk`
|
`boolean`
|
`null`
| 否 | 否 | 否 | 否 | 否 | 以 OA 转岗流程或发文为准 |
| 是否已做任前谈话 |
`has_pre_appointment_talk`
|
`boolean`
|
`null`
| 否 | 否 | 否 | 否 | 否 | 三态:是、否、未填写 |
| 是否纪委书记主动约谈 |
`is_discipline_secretary_interview`
|
`boolean`
|
`null`
| 否 | 否 | 否 | 否 | 否 | 报送口径字段 |
| 本年度实际谈话时间 |
`actual_talk_date`
|
`date`
|
`null`
| 否 | 否 | 否 | 否 | 否 | 本年度实际谈话日期 |
| 谈话支持材料 |
`talk_materials`
|
`file`
|
`[]`
| 否 | 是 | 否 | 否 | 否 | 系统内上传谈话材料 |
### 3.2.3 廉洁党建调研记录
-
**subject**
:
`integrity_party_research`
-
**title 生成**
:
`调研单位 + 调研时间`
| 字段标签 | 字段别名 | 字段类型 | 默认值 | 是否主键 | 是否多值 | 是否必填 | 搜索 | 聚合 | 说明 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 序号 |
`sequence_no`
|
`number`
|
`null`
| 否 | 否 | 否 | 否 | 否 | 台账顺序号 |
| 调研时间 |
`research_date`
|
`date`
|
`null`
| 否 | 否 | 是 | 否 | 否 | 调研日期 |
| 调研人员及职务 |
`research_staff_and_position`
|
`text`
|
`null`
| 否 | 否 | 否 | 否 | 否 | 调研人员和职务描述 |
| 调研单位 |
`research_unit`
|
`text`
|
`null`
| 否 | 否 | 是 | 否 | 否 | 被调研单位 |
| 调研内容 |
`research_content`
|
`long_text`
|
`null`
| 否 | 否 | 否 | 否 | 否 | 调研主要内容 |
| 调研方案或调研报告 |
`research_report_materials`
|
`file`
|
`[]`
| 否 | 是 | 否 | 否 | 否 | 系统内上传调研方案或报告 |
| 备注 |
`remarks`
|
`long_text`
|
`null`
| 否 | 否 | 否 | 否 | 否 | 备注信息 |
### 3.2.4 廉洁党建专题会议记录
-
**subject**
:
`integrity_party_meeting`
-
**title 生成**
:
`会议议题 + 会议时间`
| 字段标签 | 字段别名 | 字段类型 | 默认值 | 是否主键 | 是否多值 | 是否必填 | 搜索 | 聚合 | 说明 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 序号 |
`sequence_no`
|
`number`
|
`null`
| 否 | 否 | 否 | 否 | 否 | 台账顺序号 |
| 会议时间 |
`meeting_date`
|
`date`
|
`null`
| 否 | 否 | 是 | 否 | 否 | 会议日期 |
| 会议议题 |
`meeting_topic`
|
`text`
|
`null`
| 否 | 否 | 是 | 否 | 否 | 会议议题 |
| 会议主要内容 |
`main_content`
|
`long_text`
|
`null`
| 否 | 否 | 否 | 否 | 否 | 会议主要内容 |
| 支持材料 |
`supporting_materials`
|
`file`
|
`[]`
| 否 | 是 | 否 | 否 | 否 | 系统内上传会议支持材料 |
### 3.2.5 廉洁主题活动记录
-
**subject**
:
`integrity_party_activity`
-
**title 生成**
:
`活动主题 + 开展年份`
| 字段标签 | 字段别名 | 字段类型 | 默认值 | 是否主键 | 是否多值 | 是否必填 | 搜索 | 聚合 | 说明 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 序号 |
`sequence_no`
|
`number`
|
`null`
| 否 | 否 | 否 | 否 | 否 | 台账顺序号 |
| 开展年份 |
`year`
|
`number`
|
`null`
| 否 | 否 | 是 | 否 | 否 | 活动开展年份 |
| 活动主题 |
`activity_theme`
|
`text`
|
`null`
| 否 | 否 | 是 | 否 | 否 | 活动主题 |
| 活动形式 |
`activity_format`
|
`text`
|
`null`
| 否 | 否 | 否 | 否 | 否 | 活动形式 |
| 活动成果 |
`activity_result`
|
`long_text`
|
`null`
| 否 | 否 | 否 | 否 | 否 | 活动成果描述 |
| 支持材料 |
`supporting_materials`
|
`file`
|
`[]`
| 否 | 是 | 否 | 否 | 否 | 系统内上传活动支持材料 |
### 3.2.6 节前廉洁提醒记录
-
**subject**
:
`integrity_party_holiday_reminder`
-
**title 生成**
:
`年份 + 重大节假日`
| 字段标签 | 字段别名 | 字段类型 | 默认值 | 是否主键 | 是否多值 | 是否必填 | 搜索 | 聚合 | 说明 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 年份 |
`year`
|
`number`
|
`null`
| 否 | 否 | 是 | 否 | 否 | 台账年份 |
| 重大节假日 |
`holiday_name`
|
`select`
|
`null`
| 否 | 否 | 是 | 否 | 否 | 选项:元旦、春节、清明、五一、端午、中秋、国庆、其他 |
| 其他节假日说明 |
`holiday_other`
|
`text`
|
`null`
| 否 | 否 | 否 | 否 | 否 | 选择其他时补充说明 |
| 廉洁谈话提纲上传 |
`talk_outline_materials`
|
`file`
|
`[]`
| 否 | 是 | 否 | 否 | 否 | 系统内上传谈话提纲 |
| 廉洁提醒支持材料上传 |
`reminder_materials`
|
`file`
|
`[]`
| 否 | 是 | 否 | 否 | 否 | 系统内上传提醒材料 |
| 其他活动举措 |
`other_measures`
|
`long_text`
|
`null`
| 否 | 否 | 否 | 否 | 否 | 其他举措说明 |
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