Commit 31252990 authored by drigle's avatar drigle

fix: refine ledger workflows and dashboard year ordering

parent b202a652
{ {
"name": "incorrupt_party_build", "name": "incorrupt_party_build",
"version": "1.0.6", "version": "1.0.3",
"build": "abc", "build": "abc",
"description": "党风廉政建设", "description": "党风廉政建设",
"date": "2025-07-14 19:27:20", "date": "2025-07-14 19:27:20",
......
...@@ -10,8 +10,8 @@ const optionKey = (option) => option?.id ?? option?.value; ...@@ -10,8 +10,8 @@ const optionKey = (option) => option?.id ?? option?.value;
const toSelectionKey = (value) => String(value ?? ''); const toSelectionKey = (value) => String(value ?? '');
export default function XinyuanAutocomplete({ export default function XinyuanAutocomplete({
'aria-label': ariaLabelProp, ariaLabel, className, emptyText = '暂无可选项', errorText = '', filter, 'aria-describedby': ariaDescribedBy, 'aria-label': ariaLabelProp, ariaLabel, className, emptyText = '暂无可选项', errorText = '', filter,
fullWidth = true, inputValue, isDisabled = false, isLoading = false, isOpen, isRequired = false, name, fullWidth = true, id, inputValue, isDisabled = false, isInvalid = false, isLoading = false, isOpen, isRequired = false, name,
onInputChange, onOpenChange, onSelectionChange, options = [], placeholder = '请选择', placement = 'bottom start', onInputChange, onOpenChange, onSelectionChange, options = [], placeholder = '请选择', placement = 'bottom start',
popoverHeader, renderOption, searchPlaceholder = '输入关键词搜索', selectedKey, selectedKeys, popoverHeader, renderOption, searchPlaceholder = '输入关键词搜索', selectedKey, selectedKeys,
selectionMode = 'single', shouldFilter = true, valueText, variant = 'secondary', selectionMode = 'single', shouldFilter = true, valueText, variant = 'secondary',
...@@ -44,7 +44,7 @@ export default function XinyuanAutocomplete({ ...@@ -44,7 +44,7 @@ export default function XinyuanAutocomplete({
}; };
return ( return (
<Autocomplete {...rootSelectionProps} aria-label={effectiveAriaLabel} className={className} fullWidth={fullWidth} isDisabled={isDisabled} 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={isInvalid} isOpen={isOpen} isRequired={isRequired} name={name} onClear={onClear} selectionMode={selectionMode} variant={variant} onOpenChange={onOpenChange} onSelectionChange={onSelectionChange}>
<Autocomplete.Trigger> <Autocomplete.Trigger>
<Autocomplete.Value><span className="block min-w-0 truncate" title={resolvedValueText}>{resolvedValueText}</span></Autocomplete.Value> <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} {isClearable && selectionMode === 'single' && selectedKey !== null && selectedKey !== undefined && selectedKey !== '' ? <Autocomplete.ClearButton aria-label="清空选择" /> : null}
......
import { Modal } from '@heroui/react'; import { Alert, Modal } from '@heroui/react';
import clsx from 'clsx'; import clsx from 'clsx';
const DIALOG_WIDTH_CLASS = { const DIALOG_WIDTH_CLASS = {
...@@ -15,6 +15,7 @@ export default function XinyuanModal({ ...@@ -15,6 +15,7 @@ export default function XinyuanModal({
isDismissable, isDismissable,
title, title,
description, description,
error,
size = 'lg', size = 'lg',
dialogClassName, dialogClassName,
bodyClassName, bodyClassName,
...@@ -50,6 +51,13 @@ export default function XinyuanModal({ ...@@ -50,6 +51,13 @@ export default function XinyuanModal({
{description ? <div className="text-sm text-muted">{description}</div> : null} {description ? <div className="text-sm text-muted">{description}</div> : null}
</Modal.Header> </Modal.Header>
<Modal.Body className={bodyClassName} style={{ marginTop: '0.75rem' }}> <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} {children}
</Modal.Body> </Modal.Body>
{footer ? <Modal.Footer style={{ marginTop: '1.5rem' }}>{footer}</Modal.Footer> : null} {footer ? <Modal.Footer style={{ marginTop: '1.5rem' }}>{footer}</Modal.Footer> : null}
......
...@@ -42,14 +42,14 @@ ...@@ -42,14 +42,14 @@
1. 用户进入 `党风廉政建设` 1. 用户进入 `党风廉政建设`
2. 通过顶部 Tab 选择台账分类 2. 通过顶部 Tab 选择台账分类
3. 页面展示该分类的表格台账 3. 页面展示该分类的表格台账
4. 筛选区使用可展开的 Surface,统一包含关键词、年份和当前分类关键字段;谈话类额外支持是否党员、是否领导干部、是否投资序列筛选 4. 筛选区使用可展开的 Surface,统一包含关键词、年份和当前分类关键字段;谈话类额外支持是否党员、是否领导干部、是否投资序列筛选。年份字段为数字时按 `metadata.year` 精确匹配;年份筛选映射到日期字段时,按所选年份首日至下一年首日构造 ISO 8601 的 `$gte / $lt` 半开区间查询
5. 选择类筛选通过 Autocomplete 完成;页面不显示刷新或查询按钮,筛选条件变化后自动调用 `POST /record/query`,关键词输入采用约 300ms 延迟以减少重复请求;筛选 Surface 收起时只占主筛选行高度,点击展开后才增加溢出字段区域 5. 选择类筛选通过 Autocomplete 完成;填写筛选条件后点击 `查询` 调用 `POST /record/query`,`重置筛选` 清空当前分类筛选草稿;筛选 Surface 收起时只占主筛选行高度,点击展开后才增加溢出字段区域
6. 列表按 `updated_at`、`created_at` 倒序展示 6. 当前表格包含 `sequence_no` 序号列时,请求按 `metadata.sequence_no` 升序排列,并以 `updated_at`、`created_at` 倒序作为同序号记录的稳定排序;不含序号列时按 `updated_at`、`created_at` 倒序展示
- 列表标题仅展示业务主题,不在标题末尾拼接日期;历史记录若标题带有自动生成的日期后缀,仅在列表展示时去除,原始字段保持不变 - 列表标题仅展示业务主题,不在标题末尾拼接日期;历史记录若标题带有自动生成的日期后缀,仅在列表展示时去除,原始字段保持不变
7. 表格仅展示核心字段和操作入口,6 类台账统一不展示“附件状态”和“更新时间”列;附件完整清单在编辑弹窗中维护,更新时间继续作为后台排序字段使用 7. 表格仅展示核心字段和操作入口,6 类台账统一不展示“附件状态”和“更新时间”列;附件完整清单在编辑弹窗中维护,更新时间继续作为后台排序字段使用
8. 表格采用固定列宽布局:日期、状态类字段使用紧凑宽度,主题和长文本字段使用较宽列并单行省略,完整内容通过悬停提示查看;列数较多时允许在表格区域横向滚动,避免内容覆盖操作入口 8. 表格采用固定列宽布局:日期、状态类字段使用紧凑宽度,主题和长文本字段使用较宽列并单行省略,完整内容通过悬停提示查看;列数较多时允许在表格区域横向滚动,避免内容覆盖操作入口
9. 完整字段在编辑弹窗中维护 9. 完整字段在编辑弹窗中维护
10. DataGrid 固定每页展示 10 条,不提供页容量切换;通过底部 Pagination 翻页 10. DataGrid 默认每页展示 20 条,支持切换为 50 或 100 条;通过底部 Pagination 翻页
11. 勾选记录后显示 ActionBar,集中提供导出勾选、批量修改和批量删除入口;清空选择后 ActionBar 关闭 11. 勾选记录后显示 ActionBar,集中提供导出勾选、批量修改和批量删除入口;清空选择后 ActionBar 关闭
12. Grid View `shortTitle=true` 时只隐藏标题列,标题数据和操作列入口保持不变;只有一个可访问 View 时不显示二级 View Tabs 12. Grid View `shortTitle=true` 时只隐藏标题列,标题数据和操作列入口保持不变;只有一个可访问 View 时不显示二级 View Tabs
13. 存在真实 Grid View 时,普通用户仅可访问明确授权的 View;未配置 `viewAuth` 的历史 View 保持可见。Subject 完全没有 Grid View 时使用 legacy compatibility view 保持固定台账可用;系统管理员及本应用“管理”角色按规则旁路 13. 存在真实 Grid View 时,普通用户仅可访问明确授权的 View;未配置 `viewAuth` 的历史 View 保持可见。Subject 完全没有 Grid View 时使用 legacy compatibility view 保持固定台账可用;系统管理员及本应用“管理”角色按规则旁路
...@@ -60,7 +60,7 @@ ...@@ -60,7 +60,7 @@
1. 用户点击 `新建记录` 1. 用户点击 `新建记录`
2. 前端根据当前 Tab 字段配置在 HeroUI Modal 中生成表单 2. 前端根据当前 Tab 字段配置在 HeroUI Modal 中生成表单
3. 用户填写最小必填字段 3. 用户填写最小必填字段;点击保存时前端先校验必填项,未填写时弹窗保持打开,在弹窗顶部展示错误摘要、在对应字段下方展示错误原因,并自动定位到第一个错误字段
4. 附件字段按运行时 `multiple` 支持“材料中心 / 本地上传”;材料中心查询 `am_material` 最近 20 条并搜索标题或文件名 4. 附件字段按运行时 `multiple` 支持“材料中心 / 本地上传”;材料中心查询 `am_material` 最近 20 条并搜索标题或文件名
5. 材料中心选择只写入 `{name,type,size,url}` 快照;本地文件调用 `/upload` 后仅进入详情草稿 5. 材料中心选择只写入 `{name,type,size,url}` 快照;本地文件调用 `/upload` 后仅进入详情草稿
6. 用户保存时先逐文件创建 `am_material`,`source_app` 为 `incorrupt_party_build`;全部成功后再调用 `POST /record` 6. 用户保存时先逐文件创建 `am_material`,`source_app` 为 `incorrupt_party_build`;全部成功后再调用 `POST /record`
...@@ -79,7 +79,7 @@ ...@@ -79,7 +79,7 @@
### 4.3 编辑台账 ### 4.3 编辑台账
1. 用户在表格中点击 `编辑` 1. 用户在表格中点击 `编辑`
2. 页面打开 HeroUI 详情弹窗,展示当前分类全部字段;必填、日期、布尔和选择字段沿用新建表单规则 2. 页面打开 HeroUI 详情弹窗,展示当前分类全部字段;必填、日期、布尔和选择字段沿用新建表单规则,校验错误直接在当前弹窗内展示
3. 用户修改字段或上传、移除附件 3. 用户修改字段或上传、移除附件
4. 前端调用 `PUT /record/:name` 4. 前端调用 `PUT /record/:name`
5. 保存成功后刷新当前列表 5. 保存成功后刷新当前列表
...@@ -105,7 +105,7 @@ ...@@ -105,7 +105,7 @@
### 4.6 Excel 导入 ### 4.6 Excel 导入
1. 用户在当前 Tab 点击 `下载模板` 1. 用户在当前 Tab 点击 `下载模板`
2. 前端导出当前分类的独立模板 2. 前端导出当前分类的独立模板;工作簿首个 Sheet 为数据录入页,第二个 `选项说明` Sheet 从 Subject 字段的 `settings.options` 读取并逐项列出选择类型的选项名称与写入值,没有选择类型字段时显示对应提示
3. 用户维护 Excel 后点击 `Excel 导入`,在导入弹窗的 DropZone 选择或拖入文件 3. 用户维护 Excel 后点击 `Excel 导入`,在导入弹窗的 DropZone 选择或拖入文件
4. 前端读取首个工作表并按当前 Tab 字段映射解析,并在弹窗中展示只读预览 4. 前端读取首个工作表并按当前 Tab 字段映射解析,并在弹窗中展示只读预览
5. 附件字段不参与导入 5. 附件字段不参与导入
...@@ -146,7 +146,8 @@ ...@@ -146,7 +146,8 @@
## 6. 异常处理与限制 ## 6. 异常处理与限制
- 保存失败时保留当前表单输入并提示用户 - 保存失败时保留当前表单输入,弹窗保持打开,并在弹窗内提示用户;必填校验同时标记对应字段并定位到第一个错误字段
- Excel 导入、批量修改和删除确认在弹窗内失败时,错误信息在当前弹窗内展示,不要求用户先关闭弹窗
- Excel 解析失败时不发起创建请求 - Excel 解析失败时不发起创建请求
- Excel 批量导入单条失败不影响其它条目继续导入 - Excel 批量导入单条失败不影响其它条目继续导入
- 批量删除和单条删除均需要二次确认 - 批量删除和单条删除均需要二次确认
......
...@@ -828,18 +828,34 @@ function buildYearFilter(config, year = '') { ...@@ -828,18 +828,34 @@ function buildYearFilter(config, year = '') {
return null; return null;
} }
if (yearField.type === FIELD_TYPES.NUMBER) {
const numericYear = Number(normalizedYear); const numericYear = Number(normalizedYear);
return Number.isFinite(numericYear) if (!Number.isInteger(numericYear)) {
? { [`metadata.${config.yearField}`]: numericYear } return null;
: 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 { return {
[`metadata.${config.yearField}`]: { [`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 = '') { function buildFocusFilter(config, value = '') {
...@@ -922,7 +938,7 @@ export async function loadLedgerPage(configKey, filters = {}) { ...@@ -922,7 +938,7 @@ export async function loadLedgerPage(configKey, filters = {}) {
page, page,
page_size: pageSize, page_size: pageSize,
filter, filter,
sort: '-updated_at -created_at', sort: buildLedgerSort(config),
}); });
const records = extractArray(response).map((record) => const records = extractArray(response).map((record) =>
normalizeLedgerRecord(record, config.key), normalizeLedgerRecord(record, config.key),
...@@ -1240,6 +1256,89 @@ function createWorkbookFromRows(config, rows = [], sheetName) { ...@@ -1240,6 +1256,89 @@ function createWorkbookFromRows(config, rows = [], sheetName) {
return workbook; 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 = {}) { export async function exportLedgerWorkbook(configKey, filters = {}) {
const records = await loadAllLedgerRecords(configKey, filters); const records = await loadAllLedgerRecords(configKey, filters);
return exportSelectedLedgerWorkbook(configKey, records); return exportSelectedLedgerWorkbook(configKey, records);
...@@ -1261,11 +1360,9 @@ export async function exportSelectedLedgerWorkbook(configKey, records = []) { ...@@ -1261,11 +1360,9 @@ export async function exportSelectedLedgerWorkbook(configKey, records = []) {
export async function exportLedgerTemplateWorkbook(configKey) { export async function exportLedgerTemplateWorkbook(configKey) {
const config = getConfig(configKey); const config = getConfig(configKey);
const importFields = { const importFields = await buildTemplateImportConfig(config);
...config,
fields: config.fields.filter((fieldConfig) => fieldConfig.type !== FIELD_TYPES.FILE),
};
const workbook = createWorkbookFromRows(importFields, [], `${config.tabLabel}导入模板`); const workbook = createWorkbookFromRows(importFields, [], `${config.tabLabel}导入模板`);
appendSelectOptionsSheet(workbook, importFields);
const fileName = `${config.filePrefix}_导入模板_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`; const fileName = `${config.filePrefix}_导入模板_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`;
XLSX.writeFile(workbook, fileName); XLSX.writeFile(workbook, fileName);
......
...@@ -66,6 +66,22 @@ function EmptyBlock({ title }) { ...@@ -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 }) { function LedgerDistributionChart({ groups }) {
const data = groups.map((item) => ({ const data = groups.map((item) => ({
name: item.label, name: item.label,
...@@ -81,7 +97,9 @@ function LedgerDistributionChart({ groups }) { ...@@ -81,7 +97,9 @@ function LedgerDistributionChart({ groups }) {
} }
function YearDistributionChart({ groups }) { function YearDistributionChart({ groups }) {
const data = groups.map((item) => ({ const data = [...groups]
.sort(compareYearGroups)
.map((item) => ({
name: item.label, name: item.label,
count: Number(item.count || 0), count: Number(item.count || 0),
})); }));
......
# 驾驶舱流程文档
## 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 条及之后的记录
- 年度字段为空时归入非年份分组,并排在有效年份之后
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment