Skip to content
Projects
Groups
Snippets
Help
Loading...
Sign in / Register
Toggle navigation
I
incorrupt_party_build
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
incorrupt_party_build
Commits
31252990
Commit
31252990
authored
Sep 30, 2026
by
drigle
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix: refine ledger workflows and dashboard year ordering
parent
b202a652
Show whitespace changes
Inline
Side-by-side
Showing
8 changed files
with
375 additions
and
50 deletions
+375
-50
manifest.json
public/manifest.json
+1
-1
XinyuanAutocomplete.jsx
src/components/XinyuanAutocomplete.jsx
+3
-3
XinyuanModal.jsx
src/components/XinyuanModal.jsx
+9
-1
integrity-party.business-flow.md
...pages/project_manager_xy/integrity-party.business-flow.md
+9
-8
index.jsx
src/pages/project_manager_xy/integrity-party/index.jsx
+188
-26
integrityPartyService.js
src/pages/project_manager_xy/integrityPartyService.js
+107
-10
index.jsx
src/pages/project_manager_xy/resource-stat/index.jsx
+19
-1
resource-stat.business-flow.md
...t_manager_xy/resource-stat/resource-stat.business-flow.md
+39
-0
No files found.
public/manifest.json
View file @
31252990
{
"name"
:
"incorrupt_party_build"
,
"version"
:
"1.0.
6
"
,
"version"
:
"1.0.
3
"
,
"build"
:
"abc"
,
"description"
:
"党风廉政建设"
,
"date"
:
"2025-07-14 19:27:20"
,
...
...
src/components/XinyuanAutocomplete.jsx
View file @
31252990
...
...
@@ -10,8 +10,8 @@ const optionKey = (option) => option?.id ?? option?.value;
const
toSelectionKey
=
(
value
)
=>
String
(
value
??
''
);
export
default
function
XinyuanAutocomplete
({
'aria-label'
:
ariaLabelProp
,
ariaLabel
,
className
,
emptyText
=
'暂无可选项'
,
errorText
=
''
,
filter
,
fullWidth
=
true
,
i
nputValue
,
isDisable
d
=
false
,
isLoading
=
false
,
isOpen
,
isRequired
=
false
,
name
,
'aria-
describedby'
:
ariaDescribedBy
,
'aria-
label'
:
ariaLabelProp
,
ariaLabel
,
className
,
emptyText
=
'暂无可选项'
,
errorText
=
''
,
filter
,
fullWidth
=
true
,
i
d
,
inputValue
,
isDisabled
=
false
,
isInvali
d
=
false
,
isLoading
=
false
,
isOpen
,
isRequired
=
false
,
name
,
onInputChange
,
onOpenChange
,
onSelectionChange
,
options
=
[],
placeholder
=
'请选择'
,
placement
=
'bottom start'
,
popoverHeader
,
renderOption
,
searchPlaceholder
=
'输入关键词搜索'
,
selectedKey
,
selectedKeys
,
selectionMode
=
'single'
,
shouldFilter
=
true
,
valueText
,
variant
=
'secondary'
,
...
...
@@ -44,7 +44,7 @@ export default function XinyuanAutocomplete({
};
return
(
<
Autocomplete
{
...
rootSelectionProps
}
aria
-
label=
{
effectiveAriaLabel
}
className=
{
className
}
fullWidth=
{
fullWidth
}
isDisabled=
{
isDisable
d
}
isOpen=
{
isOpen
}
isRequired=
{
isRequired
}
name=
{
name
}
onClear=
{
onClear
}
selectionMode=
{
selectionMode
}
variant=
{
variant
}
onOpenChange=
{
onOpenChange
}
onSelectionChange=
{
onSelectionChange
}
>
<
Autocomplete
{
...
rootSelectionProps
}
aria
-
describedby=
{
ariaDescribedBy
}
aria
-
label=
{
effectiveAriaLabel
}
className=
{
className
}
fullWidth=
{
fullWidth
}
id=
{
id
}
isDisabled=
{
isDisabled
}
isInvalid=
{
isInvali
d
}
isOpen=
{
isOpen
}
isRequired=
{
isRequired
}
name=
{
name
}
onClear=
{
onClear
}
selectionMode=
{
selectionMode
}
variant=
{
variant
}
onOpenChange=
{
onOpenChange
}
onSelectionChange=
{
onSelectionChange
}
>
<
Autocomplete
.
Trigger
>
<
Autocomplete
.
Value
><
span
className=
"block min-w-0 truncate"
title=
{
resolvedValueText
}
>
{
resolvedValueText
}
</
span
></
Autocomplete
.
Value
>
{
isClearable
&&
selectionMode
===
'single'
&&
selectedKey
!==
null
&&
selectedKey
!==
undefined
&&
selectedKey
!==
''
?
<
Autocomplete
.
ClearButton
aria
-
label=
"清空选择"
/>
:
null
}
...
...
src/components/XinyuanModal.jsx
View file @
31252990
import
{
Modal
}
from
'@heroui/react'
;
import
{
Alert
,
Modal
}
from
'@heroui/react'
;
import
clsx
from
'clsx'
;
const
DIALOG_WIDTH_CLASS
=
{
...
...
@@ -15,6 +15,7 @@ export default function XinyuanModal({
isDismissable
,
title
,
description
,
error
,
size
=
'lg'
,
dialogClassName
,
bodyClassName
,
...
...
@@ -50,6 +51,13 @@ export default function XinyuanModal({
{
description
?
<
div
className=
"text-sm text-muted"
>
{
description
}
</
div
>
:
null
}
</
Modal
.
Header
>
<
Modal
.
Body
className=
{
bodyClassName
}
style=
{
{
marginTop
:
'0.75rem'
}
}
>
{
error
?
(
<
Alert
className=
"sticky top-0 z-10 mb-5"
role=
"alert"
status=
"danger"
>
<
Alert
.
Content
>
<
Alert
.
Description
>
{
error
}
</
Alert
.
Description
>
</
Alert
.
Content
>
</
Alert
>
)
:
null
}
{
children
}
</
Modal
.
Body
>
{
footer
?
<
Modal
.
Footer
style=
{
{
marginTop
:
'1.5rem'
}
}
>
{
footer
}
</
Modal
.
Footer
>
:
null
}
...
...
src/pages/project_manager_xy/integrity-party.business-flow.md
View file @
31252990
...
...
@@ -42,14 +42,14 @@
1.
用户进入
`党风廉政建设`
2.
通过顶部 Tab 选择台账分类
3.
页面展示该分类的表格台账
4.
筛选区使用可展开的 Surface,统一包含关键词、年份和当前分类关键字段;谈话类额外支持是否党员、是否领导干部、是否投资序列筛选
5.
选择类筛选通过 Autocomplete 完成;
页面不显示刷新或查询按钮,筛选条件变化后自动调用
`POST /record/query`
,关键词输入采用约 300ms 延迟以减少重复请求
;筛选 Surface 收起时只占主筛选行高度,点击展开后才增加溢出字段区域
6.
列表
按
`updated_at`
、
`created_at`
倒序展示
4.
筛选区使用可展开的 Surface,统一包含关键词、年份和当前分类关键字段;谈话类额外支持是否党员、是否领导干部、是否投资序列筛选
。年份字段为数字时按
`metadata.year`
精确匹配;年份筛选映射到日期字段时,按所选年份首日至下一年首日构造 ISO 8601 的
`$gte / $lt`
半开区间查询
5.
选择类筛选通过 Autocomplete 完成;
填写筛选条件后点击
`查询`
调用
`POST /record/query`
,
`重置筛选`
清空当前分类筛选草稿
;筛选 Surface 收起时只占主筛选行高度,点击展开后才增加溢出字段区域
6.
当前表格包含
`sequence_no`
序号列时,请求按
`metadata.sequence_no`
升序排列,并以
`updated_at`
、
`created_at`
倒序作为同序号记录的稳定排序;不含序号列时
按
`updated_at`
、
`created_at`
倒序展示
-
列表标题仅展示业务主题,不在标题末尾拼接日期;历史记录若标题带有自动生成的日期后缀,仅在列表展示时去除,原始字段保持不变
7.
表格仅展示核心字段和操作入口,6 类台账统一不展示“附件状态”和“更新时间”列;附件完整清单在编辑弹窗中维护,更新时间继续作为后台排序字段使用
8.
表格采用固定列宽布局:日期、状态类字段使用紧凑宽度,主题和长文本字段使用较宽列并单行省略,完整内容通过悬停提示查看;列数较多时允许在表格区域横向滚动,避免内容覆盖操作入口
9.
完整字段在编辑弹窗中维护
10.
DataGrid
固定每页展示 10 条,不提供页容量切换
;通过底部 Pagination 翻页
10.
DataGrid
默认每页展示 20 条,支持切换为 50 或 100 条
;通过底部 Pagination 翻页
11.
勾选记录后显示 ActionBar,集中提供导出勾选、批量修改和批量删除入口;清空选择后 ActionBar 关闭
12.
Grid View
`shortTitle=true`
时只隐藏标题列,标题数据和操作列入口保持不变;只有一个可访问 View 时不显示二级 View Tabs
13.
存在真实 Grid View 时,普通用户仅可访问明确授权的 View;未配置
`viewAuth`
的历史 View 保持可见。Subject 完全没有 Grid View 时使用 legacy compatibility view 保持固定台账可用;系统管理员及本应用“管理”角色按规则旁路
...
...
@@ -60,7 +60,7 @@
1.
用户点击
`新建记录`
2.
前端根据当前 Tab 字段配置在 HeroUI Modal 中生成表单
3.
用户填写最小必填字段
3.
用户填写最小必填字段
;点击保存时前端先校验必填项,未填写时弹窗保持打开,在弹窗顶部展示错误摘要、在对应字段下方展示错误原因,并自动定位到第一个错误字段
4.
附件字段按运行时
`multiple`
支持“材料中心 / 本地上传”;材料中心查询
`am_material`
最近 20 条并搜索标题或文件名
5.
材料中心选择只写入
`{name,type,size,url}`
快照;本地文件调用
`/upload`
后仅进入详情草稿
6.
用户保存时先逐文件创建
`am_material`
,
`source_app`
为
`incorrupt_party_build`
;全部成功后再调用
`POST /record`
...
...
@@ -79,7 +79,7 @@
### 4.3 编辑台账
1.
用户在表格中点击
`编辑`
2.
页面打开 HeroUI 详情弹窗,展示当前分类全部字段;必填、日期、布尔和选择字段沿用新建表单规则
2.
页面打开 HeroUI 详情弹窗,展示当前分类全部字段;必填、日期、布尔和选择字段沿用新建表单规则
,校验错误直接在当前弹窗内展示
3.
用户修改字段或上传、移除附件
4.
前端调用
`PUT /record/:name`
5.
保存成功后刷新当前列表
...
...
@@ -105,7 +105,7 @@
### 4.6 Excel 导入
1.
用户在当前 Tab 点击
`下载模板`
2.
前端导出当前分类的独立模板
2.
前端导出当前分类的独立模板
;工作簿首个 Sheet 为数据录入页,第二个
`选项说明`
Sheet 从 Subject 字段的
`settings.options`
读取并逐项列出选择类型的选项名称与写入值,没有选择类型字段时显示对应提示
3.
用户维护 Excel 后点击
`Excel 导入`
,在导入弹窗的 DropZone 选择或拖入文件
4.
前端读取首个工作表并按当前 Tab 字段映射解析,并在弹窗中展示只读预览
5.
附件字段不参与导入
...
...
@@ -146,7 +146,8 @@
## 6. 异常处理与限制
-
保存失败时保留当前表单输入并提示用户
-
保存失败时保留当前表单输入,弹窗保持打开,并在弹窗内提示用户;必填校验同时标记对应字段并定位到第一个错误字段
-
Excel 导入、批量修改和删除确认在弹窗内失败时,错误信息在当前弹窗内展示,不要求用户先关闭弹窗
-
Excel 解析失败时不发起创建请求
-
Excel 批量导入单条失败不影响其它条目继续导入
-
批量删除和单条删除均需要二次确认
...
...
src/pages/project_manager_xy/integrity-party/index.jsx
View file @
31252990
...
...
@@ -82,6 +82,33 @@ function FieldLabel({ field }) {
);
}
function
getRequiredFieldErrors
(
fields
,
form
)
{
return
fields
.
reduce
((
errors
,
field
)
=>
{
if
(
!
field
.
required
)
return
errors
;
const
value
=
form
[
field
.
name
];
const
isMissing
=
Array
.
isArray
(
value
)
?
value
.
length
===
0
:
value
===
null
||
value
===
undefined
||
(
typeof
value
===
'string'
&&
value
.
trim
()
===
''
);
if
(
isMissing
)
errors
[
field
.
name
]
=
`请填写
${
field
.
label
}
`
;
return
errors
;
},
{});
}
function
focusFirstInvalidField
(
fieldName
)
{
window
.
requestAnimationFrame
(()
=>
{
const
fieldElement
=
document
.
querySelector
(
`[data-ledger-field="
${
fieldName
}
"]`
);
if
(
!
fieldElement
)
return
;
fieldElement
.
scrollIntoView
({
block
:
'center'
});
const
control
=
fieldElement
.
querySelector
(
'input, textarea, button, [role="combobox"], [tabindex]:not([tabindex="-1"])'
,
);
(
control
||
fieldElement
).
focus
({
preventScroll
:
true
});
});
}
function
FilterField
({
label
,
children
,
className
})
{
return
(
<
div
className=
{
clsx
(
'w-full min-w-0'
,
className
)
}
>
...
...
@@ -201,7 +228,7 @@ const TABLE_COLUMN_WIDTHS = {
const
DEFAULT_TABLE_COLUMN_WIDTH
=
156
;
function
FieldControl
({
field
,
value
,
onChange
,
compact
=
false
,
disabled
=
false
,
readOnly
=
false
,
editSessionId
,
onPendingMaterialChange
})
{
function
FieldControl
({
field
,
value
,
onChange
,
compact
=
false
,
disabled
=
false
,
readOnly
=
false
,
editSessionId
,
onPendingMaterialChange
,
controlId
,
errorMessageId
,
isInvalid
=
false
})
{
if
(
readOnly
||
disabled
)
{
return
<
ReadonlyFieldValue
field=
{
field
}
value=
{
value
}
/>;
}
...
...
@@ -209,7 +236,10 @@ function FieldControl({ field, value, onChange, compact = false, disabled = fals
if
(
field
.
type
===
'select'
)
{
return
(
<
XinyuanAutocomplete
aria
-
describedby=
{
errorMessageId
}
ariaLabel=
{
field
.
label
}
id=
{
controlId
}
isInvalid=
{
isInvalid
}
isRequired=
{
field
.
required
}
isDisabled=
{
disabled
}
options=
{
(
field
.
options
||
[]).
map
((
option
)
=>
({
...
...
@@ -227,8 +257,12 @@ function FieldControl({ field, value, onChange, compact = false, disabled = fals
if
(
field
.
type
===
'boolean'
)
{
return
(
<
XinyuanAutocomplete
aria
-
describedby=
{
errorMessageId
}
ariaLabel=
{
field
.
label
}
id=
{
controlId
}
isDisabled=
{
disabled
}
isInvalid=
{
isInvalid
}
isRequired=
{
field
.
required
}
options=
{
[
{
id
:
'true'
,
label
:
'是'
,
value
:
'true'
},
{
id
:
'false'
,
label
:
'否'
,
value
:
'false'
},
...
...
@@ -243,6 +277,11 @@ function FieldControl({ field, value, onChange, compact = false, disabled = fals
if
(
field
.
type
===
'long_text'
)
{
return
(
<
TextArea
aria
-
describedby=
{
errorMessageId
}
aria
-
invalid=
{
isInvalid
}
data
-
invalid=
{
isInvalid
||
undefined
}
id=
{
controlId
}
required=
{
field
.
required
}
value=
{
value
??
''
}
disabled=
{
disabled
}
onChange=
{
(
event
)
=>
onChange
(
event
.
target
.
value
)
}
...
...
@@ -269,13 +308,30 @@ function FieldControl({ field, value, onChange, compact = false, disabled = fals
}
if
(
field
.
type
===
'date'
)
{
return
<
FormDatePicker
disabled=
{
disabled
}
value=
{
value
??
''
}
onChange=
{
onChange
}
type=
"date"
/>;
return
(
<
FormDatePicker
aria
-
describedby=
{
errorMessageId
}
aria
-
label=
{
field
.
label
}
disabled=
{
disabled
}
id=
{
controlId
}
isInvalid=
{
isInvalid
}
required=
{
field
.
required
}
value=
{
value
??
''
}
onChange=
{
onChange
}
type=
"date"
/>
);
}
return
(
<
Input
aria
-
describedby=
{
errorMessageId
}
aria
-
invalid=
{
isInvalid
}
data
-
invalid=
{
isInvalid
||
undefined
}
fullWidth
disabled=
{
disabled
}
id=
{
controlId
}
required=
{
field
.
required
}
type=
{
field
.
type
===
'number'
?
'number'
:
'text'
}
value=
{
value
??
''
}
variant=
"secondary"
...
...
@@ -285,7 +341,7 @@ function FieldControl({ field, value, onChange, compact = false, disabled = fals
);
}
function
LedgerForm
({
config
,
form
,
setForm
,
fields
=
null
,
compact
=
false
,
readOnly
=
false
,
recordTitle
=
''
,
showRecordTitle
=
false
,
isRecordTitleDisabled
=
false
,
onRecordTitleChange
,
isFieldDisabled
=
()
=>
false
,
editSessionId
,
onPendingMaterialChange
})
{
function
LedgerForm
({
config
,
form
,
setForm
,
fields
=
null
,
compact
=
false
,
readOnly
=
false
,
recordTitle
=
''
,
showRecordTitle
=
false
,
isRecordTitleDisabled
=
false
,
onRecordTitleChange
,
isFieldDisabled
=
()
=>
false
,
editSessionId
,
onPendingMaterialChange
,
validationErrors
=
{},
onFieldChange
})
{
const
visibleFields
=
fields
||
config
.
fields
;
return
(
...
...
@@ -307,7 +363,12 @@ function LedgerForm({ config, form, setForm, fields = null, compact = false, rea
)
}
</
div
>
)
:
null
}
{
visibleFields
.
map
((
field
)
=>
(
{
visibleFields
.
map
((
field
)
=>
{
const
controlId
=
`ledger-field-${field.name}`
;
const
errorMessage
=
validationErrors
[
field
.
name
];
const
errorMessageId
=
errorMessage
?
`${controlId}-error`
:
undefined
;
return
(
<
div
key=
{
field
.
name
}
className=
{
clsx
(
...
...
@@ -315,25 +376,37 @@ function LedgerForm({ config, form, setForm, fields = null, compact = false, rea
?
'md:col-span-2'
:
''
,
)
}
data
-
ledger
-
field=
{
field
.
name
}
tabIndex=
{
-
1
}
>
<
FieldLabel
field=
{
field
}
/>
<
FieldControl
field=
{
field
}
value=
{
form
[
field
.
name
]
}
compact=
{
compact
}
controlId=
{
controlId
}
disabled=
{
isFieldDisabled
(
field
)
}
errorMessageId=
{
errorMessageId
}
isInvalid=
{
Boolean
(
errorMessage
)
}
readOnly=
{
readOnly
}
editSessionId=
{
editSessionId
}
onPendingMaterialChange=
{
onPendingMaterialChange
}
onChange=
{
(
nextValue
)
=>
onChange=
{
(
nextValue
)
=>
{
setForm
((
current
)
=>
({
...
current
,
[
field
.
name
]:
nextValue
,
}))
}
}));
onFieldChange
?.(
field
.
name
);
}
}
/>
{
errorMessage
?
(
<
p
className=
"mt-1 px-1 text-xs text-danger"
id=
{
errorMessageId
}
>
{
errorMessage
}
</
p
>
)
:
null
}
</
div
>
))
}
);
})
}
</
div
>
);
}
...
...
@@ -375,6 +448,8 @@ export default function IntegrityPartyPage() {
const
[
activeRecord
,
setActiveRecord
]
=
useState
(
null
);
const
[
detailTitle
,
setDetailTitle
]
=
useState
(
''
);
const
[
detailForm
,
setDetailForm
]
=
useState
(()
=>
createEmptyLedgerForm
(
activeKey
));
const
[
detailValidationErrors
,
setDetailValidationErrors
]
=
useState
({});
const
[
dialogErrors
,
setDialogErrors
]
=
useState
({});
const
[
saving
,
setSaving
]
=
useState
(
false
);
const
[
importOpen
,
setImportOpen
]
=
useState
(
false
);
const
[
importFile
,
setImportFile
]
=
useState
(
null
);
...
...
@@ -394,6 +469,10 @@ export default function IntegrityPartyPage() {
const
pendingLocalMaterialsRef
=
useRef
({});
const
[
detailSessionId
,
setDetailSessionId
]
=
useState
(
0
);
function
setDialogError
(
dialog
,
message
=
''
)
{
setDialogErrors
((
current
)
=>
({
...
current
,
[
dialog
]:
message
}));
}
useEffect
(()
=>
{
if
(
!
notice
)
{
return
undefined
;
...
...
@@ -513,6 +592,8 @@ export default function IntegrityPartyPage() {
setActiveRecord
(
null
);
setDetailTitle
(
''
);
setDetailForm
(
createEmptyLedgerForm
(
activeKey
));
setDetailValidationErrors
({});
setDialogError
(
'detail'
);
setDetailOpen
(
true
);
}
...
...
@@ -526,6 +607,8 @@ export default function IntegrityPartyPage() {
...
createEmptyLedgerForm
(
activeKey
),
...
record
.
metadata
,
});
setDetailValidationErrors
({});
setDialogError
(
'detail'
);
setDetailOpen
(
true
);
}
...
...
@@ -539,17 +622,36 @@ export default function IntegrityPartyPage() {
...
createEmptyLedgerForm
(
activeKey
),
...
record
.
metadata
,
});
setDetailValidationErrors
({});
setDialogError
(
'detail'
);
setDetailOpen
(
true
);
}
function
openDeleteDialog
(
record
)
{
setActiveRecord
(
record
);
setDialogError
(
'delete'
);
setDeleteConfirm
({
open
:
true
,
mode
:
'single'
});
}
async
function
saveDetail
()
{
const
requiredFieldErrors
=
getRequiredFieldErrors
(
activeConfig
.
fields
,
detailForm
);
const
invalidFieldNames
=
Object
.
keys
(
requiredFieldErrors
);
if
(
invalidFieldNames
.
length
>
0
)
{
setDetailValidationErrors
(
requiredFieldErrors
);
setDialogError
(
'detail'
,
`请填写必填字段:
${
activeConfig
.
fields
.
filter
((
field
)
=>
requiredFieldErrors
[
field
.
name
])
.
map
((
field
)
=>
field
.
label
)
.
join
(
'、'
)}
`
,
);
focusFirstInvalidField
(
invalidFieldNames
[
0
]);
return
;
}
setSaving
(
true
);
setError
(
''
);
setDetailValidationErrors
({});
setDialogError
(
'detail'
);
try
{
for
(
const
materials
of
Object
.
values
(
pendingLocalMaterialsRef
.
current
))
{
for
(
const
material
of
materials
)
{
...
...
@@ -569,7 +671,7 @@ export default function IntegrityPartyPage() {
pendingLocalMaterialsRef
.
current
=
{};
await
reload
(
page
);
}
catch
(
saveError
)
{
set
Error
(
saveError
?.
message
||
'保存失败,请稍后重试。'
);
set
DialogError
(
'detail'
,
saveError
?.
message
||
'保存失败,请稍后重试。'
);
}
finally
{
setSaving
(
false
);
}
...
...
@@ -590,14 +692,30 @@ export default function IntegrityPartyPage() {
function
handleDetailOpenChange
(
nextOpen
)
{
setDetailOpen
(
nextOpen
);
if
(
!
nextOpen
)
{
setDetailValidationErrors
({});
setDialogError
(
'detail'
);
pendingLocalMaterialsRef
.
current
=
{};
setDetailSessionId
((
value
)
=>
value
+
1
);
}
}
function
handleDetailFieldChange
(
fieldName
)
{
const
nextValidationErrors
=
{
...
detailValidationErrors
};
delete
nextValidationErrors
[
fieldName
];
setDetailValidationErrors
(
nextValidationErrors
);
const
remainingLabels
=
activeConfig
.
fields
.
filter
((
field
)
=>
nextValidationErrors
[
field
.
name
])
.
map
((
field
)
=>
field
.
label
);
setDialogError
(
'detail'
,
remainingLabels
.
length
>
0
?
`请填写必填字段:
${
remainingLabels
.
join
(
'、'
)}
`
:
''
,
);
}
async
function
confirmDelete
()
{
setSaving
(
true
);
set
Error
(
'
'
);
set
DialogError
(
'delete
'
);
try
{
if
(
deleteConfirm
.
mode
===
'batch'
)
{
const
result
=
await
deleteLedgerRecords
(
selectedRecords
);
...
...
@@ -614,18 +732,20 @@ export default function IntegrityPartyPage() {
setDeleteConfirm
({
open
:
false
,
mode
:
'single'
});
await
reload
(
0
);
}
catch
(
deleteError
)
{
set
Error
(
deleteError
?.
message
||
'删除失败,请稍后重试。'
);
set
DialogError
(
'delete'
,
deleteError
?.
message
||
'删除失败,请稍后重试。'
);
}
finally
{
setSaving
(
false
);
}
}
async
function
handleTemplateExport
()
{
async
function
handleTemplateExport
(
errorTarget
=
'page'
)
{
try
{
const
result
=
await
exportLedgerTemplateWorkbook
(
activeKey
);
setNotice
(
`导入模板已导出:
${
result
.
fileName
}
`
);
}
catch
(
exportError
)
{
setError
(
exportError
?.
message
||
'模板导出失败,请稍后重试。'
);
const
message
=
exportError
?.
message
||
'模板导出失败,请稍后重试。'
;
if
(
errorTarget
===
'import'
)
setDialogError
(
'import'
,
message
);
else
setError
(
message
);
}
}
...
...
@@ -654,6 +774,7 @@ export default function IntegrityPartyPage() {
async
function
handlePreviewImport
(
file
)
{
setImportFile
(
file
);
setImportPreview
(
null
);
setDialogError
(
'import'
);
if
(
!
file
)
{
return
;
}
...
...
@@ -661,17 +782,18 @@ export default function IntegrityPartyPage() {
const
preview
=
await
previewLedgerWorkbook
(
activeKey
,
file
);
setImportPreview
(
preview
);
}
catch
(
previewError
)
{
set
Error
(
previewError
?.
message
||
'Excel 预览失败,请检查文件。'
);
set
DialogError
(
'import'
,
previewError
?.
message
||
'Excel 预览失败,请检查文件。'
);
}
}
async
function
handleImportWorkbook
()
{
if
(
!
importFile
)
{
set
Error
(
'请先选择 Excel 文件。'
);
set
DialogError
(
'import'
,
'请先选择 Excel 文件。'
);
return
;
}
setImporting
(
true
);
setDialogError
(
'import'
);
try
{
const
result
=
await
importLedgerWorkbook
(
activeKey
,
importFile
);
setNotice
(
...
...
@@ -684,7 +806,7 @@ export default function IntegrityPartyPage() {
setImportPreview
(
null
);
await
reload
(
0
);
}
catch
(
importError
)
{
set
Error
(
importError
?.
message
||
'导入失败,请稍后重试。'
);
set
DialogError
(
'import'
,
importError
?.
message
||
'导入失败,请稍后重试。'
);
}
finally
{
setImporting
(
false
);
}
...
...
@@ -696,11 +818,13 @@ export default function IntegrityPartyPage() {
return
;
}
setBatchForm
(
createEmptyLedgerForm
(
activeKey
));
setDialogError
(
'batch'
);
setBatchOpen
(
true
);
}
async
function
handleBatchSave
()
{
setBatchSaving
(
true
);
setDialogError
(
'batch'
);
try
{
const
result
=
await
updateLedgerRecords
(
activeKey
,
selectedRecords
,
batchForm
);
setNotice
(
...
...
@@ -711,7 +835,7 @@ export default function IntegrityPartyPage() {
setBatchOpen
(
false
);
await
reload
(
page
);
}
catch
(
batchError
)
{
set
Error
(
batchError
?.
message
||
'批量修改失败,请稍后重试。'
);
set
DialogError
(
'batch'
,
batchError
?.
message
||
'批量修改失败,请稍后重试。'
);
}
finally
{
setBatchSaving
(
false
);
}
...
...
@@ -995,6 +1119,7 @@ export default function IntegrityPartyPage() {
size=
"sm"
variant=
"secondary"
onPress=
{
()
=>
{
setDialogError
(
'import'
);
setImportOpen
(
true
);
setImportFile
(
null
);
setImportPreview
(
null
);
...
...
@@ -1035,7 +1160,10 @@ export default function IntegrityPartyPage() {
type=
"button"
size=
"sm"
variant=
"danger-soft"
onPress=
{
()
=>
setDeleteConfirm
({
open
:
true
,
mode
:
'batch'
})
}
onPress=
{
()
=>
{
setDialogError
(
'delete'
);
setDeleteConfirm
({
open
:
true
,
mode
:
'batch'
});
}
}
>
批量删除
</
Button
>
...
...
@@ -1060,6 +1188,7 @@ export default function IntegrityPartyPage() {
</>
}
<
XinyuanModal
error=
{
dialogErrors
.
detail
}
isOpen=
{
detailOpen
}
onOpenChange=
{
handleDetailOpenChange
}
size=
"4xl"
...
...
@@ -1098,21 +1227,35 @@ export default function IntegrityPartyPage() {
onRecordTitleChange=
{
setDetailTitle
}
editSessionId=
{
detailSessionId
}
onPendingMaterialChange=
{
handlePendingMaterialChange
}
validationErrors=
{
detailValidationErrors
}
onFieldChange=
{
handleDetailFieldChange
}
isFieldDisabled=
{
(
field
)
=>
detailMode
===
'view'
||
isFieldDisabledByView
(
`metadata.${field.name}`
,
detailAuthorizationView
,
userInfo
)
}
/>
</
XinyuanModal
>
<
XinyuanModal
error=
{
dialogErrors
.
import
}
isOpen=
{
importOpen
}
onOpenChange=
{
setImportOpen
}
onOpenChange=
{
(
nextOpen
)
=>
{
setImportOpen
(
nextOpen
);
if
(
!
nextOpen
)
setDialogError
(
'import'
);
}
}
size=
"4xl"
title=
{
`Excel 导入${activeConfig.tabLabel}`
}
footer=
{
<>
<
Button
type=
"button"
size=
"sm"
variant=
"secondary"
onPress=
{
handleTemplateExport
}
>
<
Button
type=
"button"
size=
"sm"
variant=
"secondary"
onPress=
{
()
=>
handleTemplateExport
(
'import'
)
}
>
下载模板
</
Button
>
<
Button
type=
"button"
size=
"sm"
variant=
"secondary"
onPress=
{
()
=>
setImportOpen
(
false
)
}
>
<
Button
type=
"button"
size=
"sm"
variant=
"secondary"
onPress=
{
()
=>
{
setDialogError
(
'import'
);
setImportOpen
(
false
);
}
}
>
取消
</
Button
>
<
Button
...
...
@@ -1163,13 +1306,25 @@ export default function IntegrityPartyPage() {
</
XinyuanModal
>
<
XinyuanModal
error=
{
dialogErrors
.
batch
}
isOpen=
{
batchOpen
}
onOpenChange=
{
setBatchOpen
}
onOpenChange=
{
(
nextOpen
)
=>
{
setBatchOpen
(
nextOpen
);
if
(
!
nextOpen
)
setDialogError
(
'batch'
);
}
}
size=
"3xl"
title=
{
`批量修改${activeConfig.tabLabel}`
}
footer=
{
<>
<
Button
type=
"button"
size=
"sm"
variant=
"secondary"
onPress=
{
()
=>
setBatchOpen
(
false
)
}
>
<
Button
type=
"button"
size=
"sm"
variant=
"secondary"
onPress=
{
()
=>
{
setDialogError
(
'batch'
);
setBatchOpen
(
false
);
}
}
>
取消
</
Button
>
<
Button
type=
"button"
isPending=
{
batchSaving
}
size=
"sm"
variant=
"primary"
onPress=
{
handleBatchSave
}
>
...
...
@@ -1196,8 +1351,12 @@ export default function IntegrityPartyPage() {
</
XinyuanModal
>
<
XinyuanModal
error=
{
dialogErrors
.
delete
}
isOpen=
{
deleteConfirm
.
open
}
onOpenChange=
{
(
open
)
=>
setDeleteConfirm
((
current
)
=>
({
...
current
,
open
}))
}
onOpenChange=
{
(
open
)
=>
{
setDeleteConfirm
((
current
)
=>
({
...
current
,
open
}));
if
(
!
open
)
setDialogError
(
'delete'
);
}
}
size=
"md"
title=
{
deleteConfirm
.
mode
===
'batch'
?
'确认批量删除'
:
'确认删除记录'
}
footer=
{
...
...
@@ -1206,7 +1365,10 @@ export default function IntegrityPartyPage() {
type=
"button"
size=
"sm"
variant=
"secondary"
onPress=
{
()
=>
setDeleteConfirm
({
open
:
false
,
mode
:
'single'
})
}
onPress=
{
()
=>
{
setDialogError
(
'delete'
);
setDeleteConfirm
({
open
:
false
,
mode
:
'single'
});
}
}
>
取消
</
Button
>
...
...
src/pages/project_manager_xy/integrityPartyService.js
View file @
31252990
...
...
@@ -828,18 +828,34 @@ function buildYearFilter(config, year = '') {
return
null
;
}
if
(
yearField
.
type
===
FIELD_TYPES
.
NUMBER
)
{
const
numericYear
=
Number
(
normalizedYear
);
return
Number
.
isFinite
(
numericYear
)
?
{
[
`metadata.
${
config
.
yearField
}
`
]:
numericYear
}
:
null
;
if
(
!
Number
.
isInteger
(
numericYear
))
{
return
null
;
}
if
(
yearField
.
type
===
FIELD_TYPES
.
NUMBER
)
{
return
{
[
`metadata.
${
config
.
yearField
}
`
]:
numericYear
};
}
if
(
yearField
.
type
===
FIELD_TYPES
.
DATE
)
{
const
startYear
=
String
(
numericYear
).
padStart
(
4
,
'0'
);
const
endYear
=
String
(
numericYear
+
1
).
padStart
(
4
,
'0'
);
return
{
[
`metadata.
${
config
.
yearField
}
`
]:
{
$regex
:
`^
${
escapeRegExp
(
normalizedYear
)}
`
,
$gte
:
`
${
startYear
}
-01-01T00:00:00.000Z`
,
$lt
:
`
${
endYear
}
-01-01T00:00:00.000Z`
,
},
};
}
return
null
;
}
function
buildLedgerSort
(
config
)
{
return
config
.
tableColumns
.
includes
(
'sequence_no'
)
?
'metadata.sequence_no -updated_at -created_at'
:
'-updated_at -created_at'
;
}
function
buildFocusFilter
(
config
,
value
=
''
)
{
...
...
@@ -922,7 +938,7 @@ export async function loadLedgerPage(configKey, filters = {}) {
page
,
page_size
:
pageSize
,
filter
,
sort
:
'-updated_at -created_at'
,
sort
:
buildLedgerSort
(
config
)
,
});
const
records
=
extractArray
(
response
).
map
((
record
)
=>
normalizeLedgerRecord
(
record
,
config
.
key
),
...
...
@@ -1240,6 +1256,89 @@ function createWorkbookFromRows(config, rows = [], sheetName) {
return
workbook
;
}
function
normalizeSelectOption
(
option
)
{
if
(
option
&&
typeof
option
===
'object'
)
{
const
label
=
String
(
option
.
label
??
option
.
value
??
''
).
trim
();
const
value
=
String
(
option
.
value
??
option
.
label
??
''
).
trim
();
return
{
label
,
value
};
}
const
value
=
String
(
option
??
''
).
trim
();
return
{
label
:
value
,
value
};
}
function
appendSelectOptionsSheet
(
workbook
,
config
)
{
const
selectFields
=
config
.
fields
.
filter
(
(
fieldConfig
)
=>
fieldConfig
.
type
===
FIELD_TYPES
.
SELECT
,
);
const
rows
=
selectFields
.
flatMap
((
fieldConfig
)
=>
{
if
(
fieldConfig
.
options
.
length
===
0
)
{
return
[{
字段名称
:
fieldConfig
.
label
,
字段别名
:
fieldConfig
.
name
,
选项名称
:
'未配置可选值'
,
选项值
:
''
,
}];
}
return
fieldConfig
.
options
.
map
((
option
,
index
)
=>
{
const
normalizedOption
=
normalizeSelectOption
(
option
);
return
{
字段名称
:
index
===
0
?
fieldConfig
.
label
:
''
,
字段别名
:
index
===
0
?
fieldConfig
.
name
:
''
,
选项名称
:
normalizedOption
.
label
,
选项值
:
normalizedOption
.
value
,
};
});
});
const
worksheet
=
XLSX
.
utils
.
json_to_sheet
(
rows
.
length
>
0
?
rows
:
[{
字段名称
:
'当前模板无选择类型字段'
,
字段别名
:
''
,
选项名称
:
''
,
选项值
:
''
,
}],
{
header
:
[
'字段名称'
,
'字段别名'
,
'选项名称'
,
'选项值'
]
},
);
worksheet
[
'!cols'
]
=
[
{
wch
:
24
},
{
wch
:
32
},
{
wch
:
36
},
{
wch
:
36
},
];
XLSX
.
utils
.
book_append_sheet
(
workbook
,
worksheet
,
'选项说明'
);
}
async
function
buildTemplateImportConfig
(
config
)
{
const
subject
=
await
loadSubject
(
config
.
subject
);
const
subjectFieldMap
=
new
Map
(
(
Array
.
isArray
(
subject
?.
fields
)
?
subject
.
fields
:
[]).
map
((
subjectField
)
=>
[
subjectField
.
name
,
subjectField
,
]),
);
return
{
...
config
,
fields
:
config
.
fields
.
filter
((
fieldConfig
)
=>
fieldConfig
.
type
!==
FIELD_TYPES
.
FILE
)
.
map
((
fieldConfig
)
=>
{
const
subjectField
=
subjectFieldMap
.
get
(
fieldConfig
.
name
);
const
type
=
subjectField
?.
type
||
fieldConfig
.
type
;
return
{
...
fieldConfig
,
type
,
options
:
type
===
FIELD_TYPES
.
SELECT
&&
Array
.
isArray
(
subjectField
?.
settings
?.
options
)
?
subjectField
.
settings
.
options
:
[],
};
}),
};
}
export
async
function
exportLedgerWorkbook
(
configKey
,
filters
=
{})
{
const
records
=
await
loadAllLedgerRecords
(
configKey
,
filters
);
return
exportSelectedLedgerWorkbook
(
configKey
,
records
);
...
...
@@ -1261,11 +1360,9 @@ export async function exportSelectedLedgerWorkbook(configKey, records = []) {
export
async
function
exportLedgerTemplateWorkbook
(
configKey
)
{
const
config
=
getConfig
(
configKey
);
const
importFields
=
{
...
config
,
fields
:
config
.
fields
.
filter
((
fieldConfig
)
=>
fieldConfig
.
type
!==
FIELD_TYPES
.
FILE
),
};
const
importFields
=
await
buildTemplateImportConfig
(
config
);
const
workbook
=
createWorkbookFromRows
(
importFields
,
[],
`
${
config
.
tabLabel
}
导入模板`
);
appendSelectOptionsSheet
(
workbook
,
importFields
);
const
fileName
=
`
${
config
.
filePrefix
}
_导入模板_
${
dayjs
().
format
(
'YYYYMMDD_HHmmss'
)}
.xlsx`
;
XLSX
.
writeFile
(
workbook
,
fileName
);
...
...
src/pages/project_manager_xy/resource-stat/index.jsx
View file @
31252990
...
...
@@ -66,6 +66,22 @@ function EmptyBlock({ title }) {
);
}
function
compareYearGroups
(
left
,
right
)
{
const
leftYear
=
Number
.
parseInt
(
String
(
left
?.
label
??
''
),
10
);
const
rightYear
=
Number
.
parseInt
(
String
(
right
?.
label
??
''
),
10
);
const
leftHasYear
=
Number
.
isFinite
(
leftYear
);
const
rightHasYear
=
Number
.
isFinite
(
rightYear
);
if
(
leftHasYear
&&
rightHasYear
)
{
return
leftYear
-
rightYear
;
}
if
(
leftHasYear
)
return
-
1
;
if
(
rightHasYear
)
return
1
;
return
String
(
left
?.
label
??
''
).
localeCompare
(
String
(
right
?.
label
??
''
),
'zh-CN'
);
}
function
LedgerDistributionChart
({
groups
})
{
const
data
=
groups
.
map
((
item
)
=>
({
name
:
item
.
label
,
...
...
@@ -81,7 +97,9 @@ function LedgerDistributionChart({ groups }) {
}
function
YearDistributionChart
({
groups
})
{
const
data
=
groups
.
map
((
item
)
=>
({
const
data
=
[...
groups
]
.
sort
(
compareYearGroups
)
.
map
((
item
)
=>
({
name
:
item
.
label
,
count
:
Number
(
item
.
count
||
0
),
}));
...
...
src/pages/project_manager_xy/resource-stat/resource-stat.business-flow.md
0 → 100644
View file @
31252990
# 驾驶舱流程文档
## 1. 功能入口
-
左侧菜单进入
`驾驶舱`
-
页面展示党风廉政 6 类台账的汇总统计,不提供编辑入口或筛选条件
## 2. 关键交互与状态
1.
页面进入后并行查询培训、谈话、调研、专题会议、廉洁主题活动、节前廉洁提醒 6 类台账
2.
加载期间展示加载状态;全部请求结束后展示指标卡、台账分类分布、年度分布和本年度模块概览
3.
部分或全部请求失败时,在页面顶部展示错误信息;成功返回的数据仍按现有结果展示
4.
查询结果为空时,对应图表或概览区域展示空状态
## 3. 年度分布规则
-
年份取自各台账配置的年度字段:日期字段截取年份,数字年份字段直接转为文本
-
横轴按年份从小到大排列,例如
`2025、2026`
,不按各年份记录数量排序
-
无法识别为年份的分组排在有效年份之后,并保留其分组标签
-
柱顶直接显示记录数,悬停提示展示年份和对应记录数量
## 4. 数据对象与统计口径
-
数据通过
`POST /record/query`
读取,Subject 为:
-
`integrity_party_training`
-
`integrity_party_talk`
-
`integrity_party_research`
-
`integrity_party_meeting`
-
`integrity_party_activity`
-
`integrity_party_holiday_reminder`
-
页面只读取台账数据,不修改记录状态或业务字段
-
单类台账当前最多读取 500 条;年度分布基于已读取记录计算
-
本年度模块概览的详细统计口径沿用
`integrity-party.business-flow.md`
## 5. 异常与限制
-
API 错误信息统一展示在页面顶部,不隐藏已成功加载的统计结果
-
单类台账超过 500 条时,当前年度分布可能不包含第 501 条及之后的记录
-
年度字段为空时归入非年份分组,并排在有效年份之后
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