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 批量导入单条失败不影响其它条目继续导入
- 批量删除和单条删除均需要二次确认 - 批量删除和单条删除均需要二次确认
......
...@@ -82,6 +82,33 @@ function FieldLabel({ field }) { ...@@ -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 }) { function FilterField({ label, children, className }) {
return ( return (
<div className={clsx('w-full min-w-0', className)}> <div className={clsx('w-full min-w-0', className)}>
...@@ -201,7 +228,7 @@ const TABLE_COLUMN_WIDTHS = { ...@@ -201,7 +228,7 @@ const TABLE_COLUMN_WIDTHS = {
const DEFAULT_TABLE_COLUMN_WIDTH = 156; 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) { if (readOnly || disabled) {
return <ReadonlyFieldValue field={field} value={value} />; return <ReadonlyFieldValue field={field} value={value} />;
} }
...@@ -209,7 +236,10 @@ function FieldControl({ field, value, onChange, compact = false, disabled = fals ...@@ -209,7 +236,10 @@ function FieldControl({ field, value, onChange, compact = false, disabled = fals
if (field.type === 'select') { if (field.type === 'select') {
return ( return (
<XinyuanAutocomplete <XinyuanAutocomplete
aria-describedby={errorMessageId}
ariaLabel={field.label} ariaLabel={field.label}
id={controlId}
isInvalid={isInvalid}
isRequired={field.required} isRequired={field.required}
isDisabled={disabled} isDisabled={disabled}
options={(field.options || []).map((option) => ({ options={(field.options || []).map((option) => ({
...@@ -227,8 +257,12 @@ function FieldControl({ field, value, onChange, compact = false, disabled = fals ...@@ -227,8 +257,12 @@ function FieldControl({ field, value, onChange, compact = false, disabled = fals
if (field.type === 'boolean') { if (field.type === 'boolean') {
return ( return (
<XinyuanAutocomplete <XinyuanAutocomplete
aria-describedby={errorMessageId}
ariaLabel={field.label} ariaLabel={field.label}
id={controlId}
isDisabled={disabled} isDisabled={disabled}
isInvalid={isInvalid}
isRequired={field.required}
options={[ options={[
{ id: 'true', label: '是', value: 'true' }, { id: 'true', label: '是', value: 'true' },
{ id: 'false', label: '否', value: 'false' }, { id: 'false', label: '否', value: 'false' },
...@@ -243,6 +277,11 @@ function FieldControl({ field, value, onChange, compact = false, disabled = fals ...@@ -243,6 +277,11 @@ function FieldControl({ field, value, onChange, compact = false, disabled = fals
if (field.type === 'long_text') { if (field.type === 'long_text') {
return ( return (
<TextArea <TextArea
aria-describedby={errorMessageId}
aria-invalid={isInvalid}
data-invalid={isInvalid || undefined}
id={controlId}
required={field.required}
value={value ?? ''} value={value ?? ''}
disabled={disabled} disabled={disabled}
onChange={(event) => onChange(event.target.value)} onChange={(event) => onChange(event.target.value)}
...@@ -269,13 +308,30 @@ function FieldControl({ field, value, onChange, compact = false, disabled = fals ...@@ -269,13 +308,30 @@ function FieldControl({ field, value, onChange, compact = false, disabled = fals
} }
if (field.type === 'date') { 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 ( return (
<Input <Input
aria-describedby={errorMessageId}
aria-invalid={isInvalid}
data-invalid={isInvalid || undefined}
fullWidth fullWidth
disabled={disabled} disabled={disabled}
id={controlId}
required={field.required}
type={field.type === 'number' ? 'number' : 'text'} type={field.type === 'number' ? 'number' : 'text'}
value={value ?? ''} value={value ?? ''}
variant="secondary" variant="secondary"
...@@ -285,7 +341,7 @@ function FieldControl({ field, value, onChange, compact = false, disabled = fals ...@@ -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; const visibleFields = fields || config.fields;
return ( return (
...@@ -307,33 +363,50 @@ function LedgerForm({ config, form, setForm, fields = null, compact = false, rea ...@@ -307,33 +363,50 @@ function LedgerForm({ config, form, setForm, fields = null, compact = false, rea
)} )}
</div> </div>
) : null} ) : null}
{visibleFields.map((field) => ( {visibleFields.map((field) => {
<div const controlId = `ledger-field-${field.name}`;
key={field.name} const errorMessage = validationErrors[field.name];
className={clsx( const errorMessageId = errorMessage ? `${controlId}-error` : undefined;
field.type === 'long_text' || field.type === 'file'
? 'md:col-span-2' return (
: '', <div
)} key={field.name}
> className={clsx(
<FieldLabel field={field} /> field.type === 'long_text' || field.type === 'file'
<FieldControl ? 'md:col-span-2'
field={field} : '',
value={form[field.name]} )}
compact={compact} data-ledger-field={field.name}
disabled={isFieldDisabled(field)} tabIndex={-1}
readOnly={readOnly} >
editSessionId={editSessionId} <FieldLabel field={field} />
onPendingMaterialChange={onPendingMaterialChange} <FieldControl
onChange={(nextValue) => field={field}
setForm((current) => ({ value={form[field.name]}
...current, compact={compact}
[field.name]: nextValue, controlId={controlId}
})) disabled={isFieldDisabled(field)}
} errorMessageId={errorMessageId}
/> isInvalid={Boolean(errorMessage)}
</div> readOnly={readOnly}
))} editSessionId={editSessionId}
onPendingMaterialChange={onPendingMaterialChange}
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> </div>
); );
} }
...@@ -375,6 +448,8 @@ export default function IntegrityPartyPage() { ...@@ -375,6 +448,8 @@ export default function IntegrityPartyPage() {
const [activeRecord, setActiveRecord] = useState(null); const [activeRecord, setActiveRecord] = useState(null);
const [detailTitle, setDetailTitle] = useState(''); const [detailTitle, setDetailTitle] = useState('');
const [detailForm, setDetailForm] = useState(() => createEmptyLedgerForm(activeKey)); const [detailForm, setDetailForm] = useState(() => createEmptyLedgerForm(activeKey));
const [detailValidationErrors, setDetailValidationErrors] = useState({});
const [dialogErrors, setDialogErrors] = useState({});
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [importOpen, setImportOpen] = useState(false); const [importOpen, setImportOpen] = useState(false);
const [importFile, setImportFile] = useState(null); const [importFile, setImportFile] = useState(null);
...@@ -394,6 +469,10 @@ export default function IntegrityPartyPage() { ...@@ -394,6 +469,10 @@ export default function IntegrityPartyPage() {
const pendingLocalMaterialsRef = useRef({}); const pendingLocalMaterialsRef = useRef({});
const [detailSessionId, setDetailSessionId] = useState(0); const [detailSessionId, setDetailSessionId] = useState(0);
function setDialogError(dialog, message = '') {
setDialogErrors((current) => ({ ...current, [dialog]: message }));
}
useEffect(() => { useEffect(() => {
if (!notice) { if (!notice) {
return undefined; return undefined;
...@@ -513,6 +592,8 @@ export default function IntegrityPartyPage() { ...@@ -513,6 +592,8 @@ export default function IntegrityPartyPage() {
setActiveRecord(null); setActiveRecord(null);
setDetailTitle(''); setDetailTitle('');
setDetailForm(createEmptyLedgerForm(activeKey)); setDetailForm(createEmptyLedgerForm(activeKey));
setDetailValidationErrors({});
setDialogError('detail');
setDetailOpen(true); setDetailOpen(true);
} }
...@@ -526,6 +607,8 @@ export default function IntegrityPartyPage() { ...@@ -526,6 +607,8 @@ export default function IntegrityPartyPage() {
...createEmptyLedgerForm(activeKey), ...createEmptyLedgerForm(activeKey),
...record.metadata, ...record.metadata,
}); });
setDetailValidationErrors({});
setDialogError('detail');
setDetailOpen(true); setDetailOpen(true);
} }
...@@ -539,17 +622,36 @@ export default function IntegrityPartyPage() { ...@@ -539,17 +622,36 @@ export default function IntegrityPartyPage() {
...createEmptyLedgerForm(activeKey), ...createEmptyLedgerForm(activeKey),
...record.metadata, ...record.metadata,
}); });
setDetailValidationErrors({});
setDialogError('detail');
setDetailOpen(true); setDetailOpen(true);
} }
function openDeleteDialog(record) { function openDeleteDialog(record) {
setActiveRecord(record); setActiveRecord(record);
setDialogError('delete');
setDeleteConfirm({ open: true, mode: 'single' }); setDeleteConfirm({ open: true, mode: 'single' });
} }
async function saveDetail() { 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); setSaving(true);
setError(''); setDetailValidationErrors({});
setDialogError('detail');
try { try {
for (const materials of Object.values(pendingLocalMaterialsRef.current)) { for (const materials of Object.values(pendingLocalMaterialsRef.current)) {
for (const material of materials) { for (const material of materials) {
...@@ -569,7 +671,7 @@ export default function IntegrityPartyPage() { ...@@ -569,7 +671,7 @@ export default function IntegrityPartyPage() {
pendingLocalMaterialsRef.current = {}; pendingLocalMaterialsRef.current = {};
await reload(page); await reload(page);
} catch (saveError) { } catch (saveError) {
setError(saveError?.message || '保存失败,请稍后重试。'); setDialogError('detail', saveError?.message || '保存失败,请稍后重试。');
} finally { } finally {
setSaving(false); setSaving(false);
} }
...@@ -590,14 +692,30 @@ export default function IntegrityPartyPage() { ...@@ -590,14 +692,30 @@ export default function IntegrityPartyPage() {
function handleDetailOpenChange(nextOpen) { function handleDetailOpenChange(nextOpen) {
setDetailOpen(nextOpen); setDetailOpen(nextOpen);
if (!nextOpen) { if (!nextOpen) {
setDetailValidationErrors({});
setDialogError('detail');
pendingLocalMaterialsRef.current = {}; pendingLocalMaterialsRef.current = {};
setDetailSessionId((value) => value + 1); 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() { async function confirmDelete() {
setSaving(true); setSaving(true);
setError(''); setDialogError('delete');
try { try {
if (deleteConfirm.mode === 'batch') { if (deleteConfirm.mode === 'batch') {
const result = await deleteLedgerRecords(selectedRecords); const result = await deleteLedgerRecords(selectedRecords);
...@@ -614,18 +732,20 @@ export default function IntegrityPartyPage() { ...@@ -614,18 +732,20 @@ export default function IntegrityPartyPage() {
setDeleteConfirm({ open: false, mode: 'single' }); setDeleteConfirm({ open: false, mode: 'single' });
await reload(0); await reload(0);
} catch (deleteError) { } catch (deleteError) {
setError(deleteError?.message || '删除失败,请稍后重试。'); setDialogError('delete', deleteError?.message || '删除失败,请稍后重试。');
} finally { } finally {
setSaving(false); setSaving(false);
} }
} }
async function handleTemplateExport() { async function handleTemplateExport(errorTarget = 'page') {
try { try {
const result = await exportLedgerTemplateWorkbook(activeKey); const result = await exportLedgerTemplateWorkbook(activeKey);
setNotice(`导入模板已导出:${result.fileName}`); setNotice(`导入模板已导出:${result.fileName}`);
} catch (exportError) { } 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() { ...@@ -654,6 +774,7 @@ export default function IntegrityPartyPage() {
async function handlePreviewImport(file) { async function handlePreviewImport(file) {
setImportFile(file); setImportFile(file);
setImportPreview(null); setImportPreview(null);
setDialogError('import');
if (!file) { if (!file) {
return; return;
} }
...@@ -661,17 +782,18 @@ export default function IntegrityPartyPage() { ...@@ -661,17 +782,18 @@ export default function IntegrityPartyPage() {
const preview = await previewLedgerWorkbook(activeKey, file); const preview = await previewLedgerWorkbook(activeKey, file);
setImportPreview(preview); setImportPreview(preview);
} catch (previewError) { } catch (previewError) {
setError(previewError?.message || 'Excel 预览失败,请检查文件。'); setDialogError('import', previewError?.message || 'Excel 预览失败,请检查文件。');
} }
} }
async function handleImportWorkbook() { async function handleImportWorkbook() {
if (!importFile) { if (!importFile) {
setError('请先选择 Excel 文件。'); setDialogError('import', '请先选择 Excel 文件。');
return; return;
} }
setImporting(true); setImporting(true);
setDialogError('import');
try { try {
const result = await importLedgerWorkbook(activeKey, importFile); const result = await importLedgerWorkbook(activeKey, importFile);
setNotice( setNotice(
...@@ -684,7 +806,7 @@ export default function IntegrityPartyPage() { ...@@ -684,7 +806,7 @@ export default function IntegrityPartyPage() {
setImportPreview(null); setImportPreview(null);
await reload(0); await reload(0);
} catch (importError) { } catch (importError) {
setError(importError?.message || '导入失败,请稍后重试。'); setDialogError('import', importError?.message || '导入失败,请稍后重试。');
} finally { } finally {
setImporting(false); setImporting(false);
} }
...@@ -696,11 +818,13 @@ export default function IntegrityPartyPage() { ...@@ -696,11 +818,13 @@ export default function IntegrityPartyPage() {
return; return;
} }
setBatchForm(createEmptyLedgerForm(activeKey)); setBatchForm(createEmptyLedgerForm(activeKey));
setDialogError('batch');
setBatchOpen(true); setBatchOpen(true);
} }
async function handleBatchSave() { async function handleBatchSave() {
setBatchSaving(true); setBatchSaving(true);
setDialogError('batch');
try { try {
const result = await updateLedgerRecords(activeKey, selectedRecords, batchForm); const result = await updateLedgerRecords(activeKey, selectedRecords, batchForm);
setNotice( setNotice(
...@@ -711,7 +835,7 @@ export default function IntegrityPartyPage() { ...@@ -711,7 +835,7 @@ export default function IntegrityPartyPage() {
setBatchOpen(false); setBatchOpen(false);
await reload(page); await reload(page);
} catch (batchError) { } catch (batchError) {
setError(batchError?.message || '批量修改失败,请稍后重试。'); setDialogError('batch', batchError?.message || '批量修改失败,请稍后重试。');
} finally { } finally {
setBatchSaving(false); setBatchSaving(false);
} }
...@@ -995,6 +1119,7 @@ export default function IntegrityPartyPage() { ...@@ -995,6 +1119,7 @@ export default function IntegrityPartyPage() {
size="sm" size="sm"
variant="secondary" variant="secondary"
onPress={() => { onPress={() => {
setDialogError('import');
setImportOpen(true); setImportOpen(true);
setImportFile(null); setImportFile(null);
setImportPreview(null); setImportPreview(null);
...@@ -1035,7 +1160,10 @@ export default function IntegrityPartyPage() { ...@@ -1035,7 +1160,10 @@ export default function IntegrityPartyPage() {
type="button" type="button"
size="sm" size="sm"
variant="danger-soft" variant="danger-soft"
onPress={() => setDeleteConfirm({ open: true, mode: 'batch' })} onPress={() => {
setDialogError('delete');
setDeleteConfirm({ open: true, mode: 'batch' });
}}
> >
批量删除 批量删除
</Button> </Button>
...@@ -1060,6 +1188,7 @@ export default function IntegrityPartyPage() { ...@@ -1060,6 +1188,7 @@ export default function IntegrityPartyPage() {
</>} </>}
<XinyuanModal <XinyuanModal
error={dialogErrors.detail}
isOpen={detailOpen} isOpen={detailOpen}
onOpenChange={handleDetailOpenChange} onOpenChange={handleDetailOpenChange}
size="4xl" size="4xl"
...@@ -1098,21 +1227,35 @@ export default function IntegrityPartyPage() { ...@@ -1098,21 +1227,35 @@ export default function IntegrityPartyPage() {
onRecordTitleChange={setDetailTitle} onRecordTitleChange={setDetailTitle}
editSessionId={detailSessionId} editSessionId={detailSessionId}
onPendingMaterialChange={handlePendingMaterialChange} onPendingMaterialChange={handlePendingMaterialChange}
validationErrors={detailValidationErrors}
onFieldChange={handleDetailFieldChange}
isFieldDisabled={(field) => detailMode === 'view' || isFieldDisabledByView(`metadata.${field.name}`, detailAuthorizationView, userInfo)} isFieldDisabled={(field) => detailMode === 'view' || isFieldDisabledByView(`metadata.${field.name}`, detailAuthorizationView, userInfo)}
/> />
</XinyuanModal> </XinyuanModal>
<XinyuanModal <XinyuanModal
error={dialogErrors.import}
isOpen={importOpen} isOpen={importOpen}
onOpenChange={setImportOpen} onOpenChange={(nextOpen) => {
setImportOpen(nextOpen);
if (!nextOpen) setDialogError('import');
}}
size="4xl" size="4xl"
title={`Excel 导入${activeConfig.tabLabel}`} title={`Excel 导入${activeConfig.tabLabel}`}
footer={ footer={
<> <>
<Button type="button" size="sm" variant="secondary" onPress={handleTemplateExport}> <Button type="button" size="sm" variant="secondary" onPress={() => handleTemplateExport('import')}>
下载模板 下载模板
</Button> </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>
<Button <Button
...@@ -1163,13 +1306,25 @@ export default function IntegrityPartyPage() { ...@@ -1163,13 +1306,25 @@ export default function IntegrityPartyPage() {
</XinyuanModal> </XinyuanModal>
<XinyuanModal <XinyuanModal
error={dialogErrors.batch}
isOpen={batchOpen} isOpen={batchOpen}
onOpenChange={setBatchOpen} onOpenChange={(nextOpen) => {
setBatchOpen(nextOpen);
if (!nextOpen) setDialogError('batch');
}}
size="3xl" size="3xl"
title={`批量修改${activeConfig.tabLabel}`} title={`批量修改${activeConfig.tabLabel}`}
footer={ 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>
<Button type="button" isPending={batchSaving} size="sm" variant="primary" onPress={handleBatchSave}> <Button type="button" isPending={batchSaving} size="sm" variant="primary" onPress={handleBatchSave}>
...@@ -1196,8 +1351,12 @@ export default function IntegrityPartyPage() { ...@@ -1196,8 +1351,12 @@ export default function IntegrityPartyPage() {
</XinyuanModal> </XinyuanModal>
<XinyuanModal <XinyuanModal
error={dialogErrors.delete}
isOpen={deleteConfirm.open} isOpen={deleteConfirm.open}
onOpenChange={(open) => setDeleteConfirm((current) => ({ ...current, open }))} onOpenChange={(open) => {
setDeleteConfirm((current) => ({ ...current, open }));
if (!open) setDialogError('delete');
}}
size="md" size="md"
title={deleteConfirm.mode === 'batch' ? '确认批量删除' : '确认删除记录'} title={deleteConfirm.mode === 'batch' ? '确认批量删除' : '确认删除记录'}
footer={ footer={
...@@ -1206,7 +1365,10 @@ export default function IntegrityPartyPage() { ...@@ -1206,7 +1365,10 @@ export default function IntegrityPartyPage() {
type="button" type="button"
size="sm" size="sm"
variant="secondary" variant="secondary"
onPress={() => setDeleteConfirm({ open: false, mode: 'single' })} onPress={() => {
setDialogError('delete');
setDeleteConfirm({ open: false, mode: 'single' });
}}
> >
取消 取消
</Button> </Button>
......
...@@ -828,18 +828,34 @@ function buildYearFilter(config, year = '') { ...@@ -828,18 +828,34 @@ function buildYearFilter(config, year = '') {
return null; return null;
} }
const numericYear = Number(normalizedYear);
if (!Number.isInteger(numericYear)) {
return null;
}
if (yearField.type === FIELD_TYPES.NUMBER) { if (yearField.type === FIELD_TYPES.NUMBER) {
const numericYear = Number(normalizedYear); return { [`metadata.${config.yearField}`]: numericYear };
return Number.isFinite(numericYear)
? { [`metadata.${config.yearField}`]: numericYear }
: null;
} }
return { if (yearField.type === FIELD_TYPES.DATE) {
[`metadata.${config.yearField}`]: { const startYear = String(numericYear).padStart(4, '0');
$regex: `^${escapeRegExp(normalizedYear)}`, const endYear = String(numericYear + 1).padStart(4, '0');
},
}; return {
[`metadata.${config.yearField}`]: {
$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,10 +97,12 @@ function LedgerDistributionChart({ groups }) { ...@@ -81,10 +97,12 @@ function LedgerDistributionChart({ groups }) {
} }
function YearDistributionChart({ groups }) { function YearDistributionChart({ groups }) {
const data = groups.map((item) => ({ const data = [...groups]
name: item.label, .sort(compareYearGroups)
count: Number(item.count || 0), .map((item) => ({
})); name: item.label,
count: Number(item.count || 0),
}));
if (data.length === 0) return <EmptyBlock title="暂无年度分布数据" />; if (data.length === 0) return <EmptyBlock title="暂无年度分布数据" />;
......
# 驾驶舱流程文档
## 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