Commit e2c3190a authored by drigle's avatar drigle

feat: improve archive details and cache rectification counts

parent 9bf42156
......@@ -121,7 +121,7 @@ function ReferenceRecordsFrame({ action, count, children }) {
);
}
export default function ReferenceRecordsTable({ fieldDef, value, pageSize = 1000, action = null }) {
export default function ReferenceRecordsTable({ fieldDef, value, pageSize = 1000, action = null, renderCell }) {
const subjectName = getReferenceSubjectName(fieldDef);
const referenceIds = useMemo(() => getReferenceIds(value), [value]);
const populatedRecords = useMemo(
......@@ -249,12 +249,11 @@ export default function ReferenceRecordsTable({ fieldDef, value, pageSize = 1000
title: column.label,
minWidth: index === 0 ? 240 : 180,
isPrimary: index === 0,
render: (record) => (
<CellValue
value={getFieldValue(record, column.field)}
field={column}
/>
),
render: (record) => {
const value = getFieldValue(record, column.field);
const customCell = renderCell?.({ column, record, value });
return customCell !== undefined ? customCell : <CellValue value={value} field={column} />;
},
}))}
rows={records}
getRowKey={(record, rowIndex) =>
......
......@@ -17,6 +17,7 @@ import ReferenceSearchSelect from '@/components/ReferenceSearchSelect';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import ProblemRectificationMaterialField from '@/pages/problem_rectification/components/ProblemRectificationMaterialField';
import ReferenceRecordsTable from './ReferenceRecordsTable';
import ProblemRectificationArchiveRecordsTable from '@/pages/problem_rectification/archive/ProblemRectificationArchiveRecordsTable';
import {
dateInputValue,
formatFieldValue,
......@@ -292,9 +293,16 @@ function FieldOptionSelect({ multiple, onChange, options, value, variant }) {
);
}
function ReadonlyReferenceRecordsTable(props) {
const Table = getReferenceSubjectName(props.fieldDef) === 'problem_rectification_archive_records'
? ProblemRectificationArchiveRecordsTable
: ReferenceRecordsTable;
return <Table {...props} />;
}
function ReadonlyFieldValue({ value, fieldDef }) {
if (isReferenceFieldType(fieldDef?.type)) {
return <ReferenceRecordsTable value={value} fieldDef={fieldDef} />;
return <ReadonlyReferenceRecordsTable value={value} fieldDef={fieldDef} />;
}
return <FieldValue value={value} fieldDef={fieldDef} />;
......@@ -670,7 +678,7 @@ export default function TailwindEditableField({
if (isReferenceFieldType(fieldType)) {
return (
<div>
<ReferenceRecordsTable
<ReadonlyReferenceRecordsTable
value={value}
fieldDef={fieldDef}
action={
......
import FullscreenExitOutlined from '@ant-design/icons/FullscreenExitOutlined';
import FullscreenOutlined from '@ant-design/icons/FullscreenOutlined';
import { Close } from '@/components/AppIcons';
import { Close, Fullscreen, FullscreenExit } from '@/components/AppIcons';
import { Alert, Button, Modal, Spinner, Tabs, Tooltip } from '@heroui/react';
import React, {
useCallback,
......@@ -372,7 +370,6 @@ const RecordDetailDrawer = (props, ref) => {
<Modal.Container placement="center" scroll="inside" size={isFullscreen ? 'full' : 'lg'}>
<Modal.Dialog className={isFullscreen ? undefined : 'p-7 sm:max-w-[68rem]'}>
<div className="absolute right-7 top-7 z-10 flex items-center gap-2">
{!minimalContent ? (
<Tooltip>
<Button
aria-label={isFullscreen ? '退出全屏' : '全屏查看'}
......@@ -383,14 +380,13 @@ const RecordDetailDrawer = (props, ref) => {
onPress={() => setIsFullscreen((current) => !current)}
>
{isFullscreen ? (
<FullscreenExitOutlined className="block" aria-hidden="true" />
<FullscreenExit size={16} />
) : (
<FullscreenOutlined className="block" aria-hidden="true" />
<Fullscreen size={16} />
)}
</Button>
<Tooltip.Content>{isFullscreen ? '退出全屏' : '全屏查看'}</Tooltip.Content>
</Tooltip>
) : null}
{!isEditing ? (
<Modal.CloseTrigger className="static" aria-label="关闭">
<Close size={16} />
......
import { useEffect, useMemo, useState } from 'react';
import { Alert, Button, Link, Modal, Spinner, Surface, Tooltip } from '@heroui/react';
import { Close, Eye, Fullscreen, FullscreenExit } from '@/components/AppIcons';
import { formatFieldValue, toArray } from '@/utils/subjectView';
import { loadArchiveSnapshotItemSubject } from './archiveWorkbenchService';
import { ARCHIVE_SNAPSHOT_ITEM_FIELDS, normalizeArchiveSnapshotItems } from './archiveSnapshotItems.mjs';
const FULL_WIDTH_FIELD_TYPES = new Set(['long_text', 'rich_text', 'file', 'object', 'subject']);
function SnapshotFieldValue({ field, value }) {
if (field.type === 'file' && toArray(value).length > 0) {
return (
<div className="flex flex-wrap gap-2">
{toArray(value).map((file, index) => {
const label = file?.name || file?.title || `附件 ${index + 1}`;
return file?.url ? (
<Link key={`${file.url}-${index}`} href={file.url} target="_blank" rel="noreferrer">
{label}
</Link>
) : <span key={index}>{label}</span>;
})}
</div>
);
}
const text = formatFieldValue(value, field);
return (
<span className={`whitespace-pre-wrap break-words text-sm ${text ? 'text-foreground' : 'text-muted'}`}>
{text || '暂无内容'}
</span>
);
}
export default function ArchiveSnapshotItems({ fieldName, value, recordTitle }) {
const configuration = ARCHIVE_SNAPSHOT_ITEM_FIELDS[fieldName];
const [isOpen, setIsOpen] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [subject, setSubject] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [retryKey, setRetryKey] = useState(0);
const snapshot = useMemo(() => {
try {
return { items: normalizeArchiveSnapshotItems(value), error: '' };
} catch (parseError) {
return { items: [], error: parseError.message };
}
}, [value]);
useEffect(() => {
if (!isOpen || snapshot.error || snapshot.items.length === 0) return;
let cancelled = false;
setLoading(true);
setError('');
setSubject(null);
loadArchiveSnapshotItemSubject(fieldName)
.then((definition) => {
if (!cancelled) setSubject(definition);
})
.catch((requestError) => {
if (!cancelled) setError(requestError?.message || '归档明细字段加载失败,请稍后重试。');
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [fieldName, isOpen, retryKey, snapshot]);
if (!snapshot.error && snapshot.items.length === 0) {
return <span className="text-sm text-muted">暂无{configuration.label}</span>;
}
return (
<Modal.Root
isOpen={isOpen}
onOpenChange={(nextOpen) => {
setIsOpen(nextOpen);
if (nextOpen) setIsFullscreen(false);
}}
>
<Modal.Trigger
render={(triggerProps) => (
<Button
{...triggerProps}
size="sm"
variant="secondary"
aria-label={`查看${recordTitle || '归档问题'}的${configuration.label}`}
/>
)}
>
<Eye size={16} />
{snapshot.error ? '查看异常数据' : `${snapshot.items.length} 条明细`}
</Modal.Trigger>
<Modal.Backdrop variant="blur">
<Modal.Container placement="center" scroll="inside" size={isFullscreen ? 'full' : 'lg'}>
<Modal.Dialog className={isFullscreen ? undefined : 'p-7 sm:max-w-[68rem]'}>
<Modal.Header className="flex-row items-start justify-between gap-4">
<div className="min-w-0 space-y-1">
<Modal.Heading>{configuration.label}</Modal.Heading>
<p className="break-words text-sm text-muted">{recordTitle || '归档问题'} · 历史快照</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Tooltip>
<Button
aria-label={isFullscreen ? '退出全屏' : '全屏查看'}
className="size-6 min-w-6 p-0"
isIconOnly
size="sm"
variant="ghost"
onPress={() => setIsFullscreen((current) => !current)}
>
{isFullscreen ? <FullscreenExit size={16} /> : <Fullscreen size={16} />}
</Button>
<Tooltip.Content>{isFullscreen ? '退出全屏' : '全屏查看'}</Tooltip.Content>
</Tooltip>
<Modal.CloseTrigger className="static" aria-label="关闭明细">
<Close size={16} />
</Modal.CloseTrigger>
</div>
</Modal.Header>
<Modal.Body>
{snapshot.error || error ? (
<div className="space-y-3">
<Alert status="danger">
<Alert.Content><Alert.Description>{snapshot.error || error}</Alert.Description></Alert.Content>
</Alert>
{!snapshot.error ? (
<Button size="sm" variant="secondary" onPress={() => setRetryKey((current) => current + 1)}>
重试
</Button>
) : null}
</div>
) : loading || !subject ? (
<div className="flex min-h-32 items-center justify-center" role="status" aria-label="正在加载归档明细字段">
<Spinner size="sm" />
</div>
) : (
<div className="space-y-6">
{snapshot.items.map((item, index) => (
<section key={`${item.item_id || index}-${index}`} className="min-w-0 space-y-3">
<h3 className="text-sm font-medium">
{item.responsible_department || `第 ${index + 1} 条明细`}
</h3>
<Surface variant="secondary" className="rounded-2xl px-5 py-2 sm:px-6 sm:py-3">
<dl className="grid min-w-0 grid-cols-1 gap-x-8 gap-y-2 md:grid-cols-2">
{subject.fields.map((field) => (
<div
key={field.name}
className={`min-w-0 py-3 ${FULL_WIDTH_FIELD_TYPES.has(field.type) ? 'md:col-span-2' : ''}`}
>
<dt className="text-sm font-medium text-muted">{field.label || field.name}</dt>
<dd className="mt-1.5 min-w-0"><SnapshotFieldValue field={field} value={item[field.name]} /></dd>
</div>
))}
</dl>
</Surface>
</section>
))}
</div>
)}
</Modal.Body>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal.Root>
);
}
import ReferenceRecordsTable from '@/pages/RecordDetailDrawer/components/ReferenceRecordsTable';
import ArchiveSnapshotItems from './ArchiveSnapshotItems';
import { ARCHIVE_SNAPSHOT_ITEM_FIELDS } from './archiveSnapshotItems.mjs';
export default function ProblemRectificationArchiveRecordsTable(props) {
return (
<ReferenceRecordsTable
{...props}
renderCell={({ column, record, value }) => {
const fieldName = column.field.replace(/^metadata\./, '');
if (!ARCHIVE_SNAPSHOT_ITEM_FIELDS[fieldName]) return undefined;
return (
<ArchiveSnapshotItems
fieldName={fieldName}
value={value}
recordTitle={record.title || record.metadata?.problem_brief || record.name}
/>
);
}}
/>
);
}
export const ARCHIVE_SNAPSHOT_ITEM_FIELDS = Object.freeze({
department_rectification_items: {
subjectName: 'department_rectification_items',
label: '部门整改明细',
},
problem_rectification_delay_request_items: {
subjectName: 'problem_rectification_delay_request',
label: '问题整改延期',
},
});
function parseSnapshotValue(value) {
if (typeof value !== 'string') return value;
if (!value.trim()) return null;
try {
return JSON.parse(value);
} catch {
throw new Error('归档明细的 JSON 格式不正确,无法展示,请联系管理员检查历史快照。');
}
}
export function normalizeArchiveSnapshotItems(value) {
const parsedValue = parseSnapshotValue(value);
if (parsedValue === null || parsedValue === undefined) return [];
return (Array.isArray(parsedValue) ? parsedValue : [parsedValue]).map((value) => {
const item = parseSnapshotValue(value);
if (!item || typeof item !== 'object' || Array.isArray(item)) {
throw new Error('归档明细包含无法识别的数据,无法展示,请联系管理员检查历史快照。');
}
const { metadata, ...recordFields } = item;
return {
...recordFields,
...(metadata && typeof metadata === 'object' && !Array.isArray(metadata) ? metadata : {}),
};
});
}
......@@ -10,10 +10,22 @@ import {
recordMatchesRestrictions,
sortRecordsByView,
} from '@/utils/subjectView';
import { ARCHIVE_SNAPSHOT_ITEM_FIELDS } from './archiveSnapshotItems.mjs';
export const ISSUE_RECTIFICATION_ARCHIVE_SUBJECT = 'issue_rectification_archive';
export const PROBLEM_RECTIFICATION_ARCHIVE_RECORDS_SUBJECT = 'problem_rectification_archive_records';
export async function loadArchiveSnapshotItemSubject(fieldName) {
const configuration = ARCHIVE_SNAPSHOT_ITEM_FIELDS[fieldName];
if (!configuration) throw new Error('归档明细缺少对象配置。');
const subject = await loadSubject(configuration.subjectName);
if (!Array.isArray(subject?.fields) || subject.fields.length === 0) {
throw new Error('归档明细字段定义为空,请联系管理员检查对象配置。');
}
return subject;
}
export function normalizeArchiveBatchRecord(record) {
const referenceRecords = Array.isArray(record?.metadata?.reference_records)
? record.metadata.reference_records
......
......@@ -46,7 +46,7 @@ import {
} from '@heroui/react';
import clsx from 'clsx';
import dayjs from 'dayjs';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { toast } from '@heroui/react';
import {
formatFieldValue,
......@@ -820,6 +820,8 @@ export default function ProblemRectificationListPage() {
totalPages: 1,
});
const [gridViewCounts, setGridViewCounts] = useState({});
const viewCountCacheRef = useRef(new Map());
const listRequestIdRef = useRef(0);
const [workflowUserLabels, setWorkflowUserLabels] = useState({});
const [maintenanceFlowByRecordId, setMaintenanceFlowByRecordId] = useState({});
const createFieldVariant = 'secondary';
......@@ -984,7 +986,9 @@ export default function ProblemRectificationListPage() {
const reloadList = useCallback(
async (overrides = {}) => {
const requestId = ++listRequestIdRef.current;
if (!subject || !activeGridView) {
viewCountCacheRef.current = new Map();
setRecords([]);
setGridViewCounts({});
setPagination({
......@@ -999,6 +1003,11 @@ export default function ProblemRectificationListPage() {
const nextPage = overrides.page ?? page;
const nextPageSize = overrides.pageSize ?? pageSize;
const nextFilters = overrides.filters ?? filters;
// 数据操作和手动刷新重新统计;Tab、分页及相同筛选复用查询条件对应的数量。
if (overrides.refreshCounts !== false) {
viewCountCacheRef.current = new Map();
}
const countCache = viewCountCacheRef.current;
setRecordsLoading(true);
setError('');
......@@ -1014,13 +1023,18 @@ export default function ProblemRectificationListPage() {
filters: nextFilters,
gridView: activeGridView,
subject,
countCache,
}),
loadProblemRectificationViewCountMap({
filters: nextFilters,
views: gridViews,
views: gridViews.filter((view) => view.name !== activeGridView.name),
subject,
countCache,
}),
]);
if (requestId !== listRequestIdRef.current) {
return;
}
const nextPagination = pageResult.pagination || {
page: nextPage,
......@@ -1031,7 +1045,10 @@ export default function ProblemRectificationListPage() {
setRecords(pageResult.records || []);
setPagination(nextPagination);
setGridViewCounts(countMap || {});
setGridViewCounts({
...countMap,
[activeGridView.name]: nextPagination.totalRecords,
});
if (
nextPagination.totalRecords > 0 &&
......@@ -1041,6 +1058,9 @@ export default function ProblemRectificationListPage() {
setPage(nextPagination.totalPages - 1);
}
} catch (requestError) {
if (requestId !== listRequestIdRef.current) {
return;
}
setRecords([]);
setGridViewCounts({});
setPagination({
......@@ -1051,14 +1071,19 @@ export default function ProblemRectificationListPage() {
});
setError(requestError?.message || '问题整改列表加载失败,请稍后重试。');
} finally {
if (requestId === listRequestIdRef.current) {
setRecordsLoading(false);
}
}
},
[activeGridView, filters, gridViews, page, pageSize, subject],
);
useEffect(() => {
reloadList();
reloadList({ refreshCounts: false });
return () => {
listRequestIdRef.current += 1;
};
}, [reloadList]);
const configuredView = useMemo(
......
......@@ -88,7 +88,8 @@
- `problem_rectification` 主列表采用传统分页表格,不一次性加载全部数据;每次按当前视图、视图字段筛选条件和页码调用 `/record/query`,分页参数使用 `page` 与 `page_size`
- 主列表提供“检查报告出具月份”筛选,用户选择 `YYYY-MM` 后,仅查询 `metadata.inspection_report_date` 位于该月份的记录;该筛选不使用其它检查或整改日期兜底
- 主列表固定每页 10 条,不显示页容量选择器;切换视图、修改筛选或清空筛选时回到第 1 页。表头勾选只选择当前页记录,跨页批量处理需用户逐页确认选择范围。
- 主列表视图页签数量通过 `/record/count` 按各视图限制和当前筛选条件计算,表格标题区展示服务端筛选后的总数与当前页记录数
- 主列表当前视图的页签数量、表格总数和分页复用 `/record/query` 返回的 `total_records`(兼容 `total_count` / `total`),不再额外调用 `/record/count`。其他有权限的视图按视图限制和当前筛选条件统计,并在页面会话内按实际查询条件缓存;相同条件的并发统计共用一个请求。切换 Tab、翻页或切换每页条数时复用缓存,新的筛选条件只统计尚未缓存的查询。切换 Tab 仍清空筛选并回到第 1 页。
- 首次加载、新筛选条件、视图配置变化时补齐其他视图的数量;手动刷新或新增、复制、删除、详情保存、锁定、推送等数据操作后清空数量缓存,再读取当前视图列表并统计其他视图,避免数量过期。列表加载期间保留 DataGrid loading 状态,失败时显示现有错误提示;失败的统计请求不保留缓存,允许重试。快速切换时只应用最后一次列表请求的结果,旧响应不得覆盖当前列表和页签数量。
- 主列表显示标题列时,标题单元格直接展示可点击的延期申请状态:存在历史问题级延期或部门级延期明细时,按当前阶段显示“延期已发起 / 延期待审批 / 延期待终审 / 延期处理中 / 延期已生效 / 延期已退回”日历徽标,不能等用户再次点击推送后才提示已发起;状态同时使用图标和文字,不只依赖颜色。视图启用 `shortTitle` 时,“问题动态”列按相同规则直接展示延期状态和问题明细数量;没有明细或延期时明确显示“暂无问题明细”“未发起延期”,操作列仍保留“查看延期申请”入口
- 点击延期状态后打开“延期申请详情”弹窗,按责任部门展示原预计完成时间、申请完成时间、延期事由、填写人、一级审批人、最终审批人以及三级流程状态;申请完成时间或延期事由尚未提交时明确显示“待填写”,历史问题级延期按“整条问题”展示;行操作区同时提供“查看延期申请”日历按钮,保证窄屏下标题列被固定列压缩时仍可进入详情
- 用户单选已有延期申请的记录时,顶部“推送延期申请”按钮改为显示当前延期状态并禁用,避免形成看似可以重复推送的入口
......@@ -515,7 +516,7 @@
- 明细转换:按 `problem_rectification_archive_records` 的 51 个字段从主记录 `metadata` 生成独立快照;`responsible_department` 转为逗号拼接文本;`fill_in_the_people`、`review_people` 保持 `user` 类型并只保存用户 `_id[]`;`rectification_proof_materials` 作为文件对象保留;日期字段继续保存 ISO 8601 字符串
- 嵌套快照:`department_rectification_items` 与 `problem_rectification_delay_request_items` 在归档明细中均使用多值 `object`,归档时深拷贝整组对象,不使用 `subject` 或 `reference` 关联原问题中的嵌套数据
- 批次写入:归档明细导入成功后创建 1 条 `issue_rectification_archive` 批次记录,标题格式为“问题整改归档 YYYY-MM-DD”,并在 `metadata.reference_records` 中保存本批次所有归档明细 `_id`
- 归档查看:`/issue_rectification_archive` 展示归档批次列表;批次详情通过 `reference_records` 追溯归档明细数量和具体快照内容
- 归档查看:`/issue_rectification_archive` 展示归档批次列表;批次详情通过 `reference_records` 追溯归档明细数量和具体快照内容。批次详情支持全屏切换,两组嵌套明细以条数入口打开只读结构化快照,具体交互与异常处理见 10.3
- 异常限制:归档是一次性历史快照,原问题记录后续编辑不会反向更新归档明细;若归档明细导入失败,不创建批次;若批次创建失败,需要提示用户本次归档未完整闭环,避免出现孤立快照无人可查
---
......@@ -741,6 +742,15 @@
- 归档明细导入失败时不创建归档批次;归档批次创建失败时应提示用户归档未完整完成,避免出现无法从批次入口追溯的孤立明细
- 归档期间按钮进入加载状态并禁止重复提交;读取原记录条数、导入返回的归档明细 ID 数量与用户选择数必须一致,否则中止创建批次并提示刷新或联系管理员处理
### 10.3 归档详情查看
- 功能入口:`/issue_rectification_archive` 批次列表的“查看详情”打开“归档批次详情”,通过批次 `metadata.reference_records` 展示归档问题列表。
- 批次详情右上角提供“全屏查看 / 退出全屏”和关闭按钮;全屏沿用 HeroUI Modal `full`,恢复时回到 1088px 基准弹窗,正文内部滚动,宽表仅在表格内部横向滚动。
- 归档问题的 `department_rectification_items`、`problem_rectification_delay_request_items` 列显示明细条数入口,不直接输出 JSON;无数据时分别显示“暂无部门整改明细”“暂无问题整改延期”。点击入口打开只读明细弹窗,支持独立全屏和关闭,关闭后返回原批次详情。
- 明细弹窗按条目/责任部门分组展示标签和值。打开时经 service 层调用 `loadSubject` 读取 `department_rectification_items` 或 `problem_rectification_delay_request` 的字段定义,字段名称、类型、顺序与标签以实时定义为准;字段值始终取自归档快照。部门明细包含整改进度、整改情况、预计完成时间、填写/审批状态、人员和证明材料;延期明细包含原预计完成时间、申请延期至、延期事由、审批人员/状态和审批时间。日期按日期展示,人员对象显示姓名,附件通过归档文件地址查看。
- 查看兼容对象数组、单对象、历史 JSON 字符串、数组中的 JSON 字符串及 `metadata` 包裹的嵌套对象,仅在内存中解析展示,不回写归档或原问题。打开、全屏切换、关闭均为本地查看状态,不改变整改或延期审批状态,也不读取原问题的当前值覆盖历史快照。
- 读取字段期间展示加载状态;字段请求失败或定义为空时保留弹窗并显示错误及“重试”。历史 JSON 损坏或条目类型异常时显示“查看异常数据”入口及明确错误,不静默丢弃数据,不显示误导性的零条明细。空字段显示“暂无内容”。
---
## 11. 页面与交互重构建议
......@@ -827,7 +837,7 @@
### 11.5 归档页
- 首页展示归档批次列表
- 点击批次进入右侧详情抽屉或二级详情页
- 点击批次打开支持全屏查看的详情弹窗,交互以 10.3 为准
- 批次详情内展示:
- 归档标题
- 归档时间
......
......@@ -1307,30 +1307,37 @@ export async function loadProblemRectificationListData({
filters = {},
gridView = null,
subject = null,
countCache = null,
} = {}) {
const requestPage = Math.max(0, Number(page) || 0);
const requestPageSize = PROBLEM_RECTIFICATION_PAGE_SIZE_OPTIONS.includes(Number(pageSize))
? Number(pageSize)
: DEFAULT_PROBLEM_RECTIFICATION_PAGE_SIZE;
const result = await queryRecords({
const countQuery = {
subject: PROBLEM_RECTIFICATION_SUBJECT,
filter: buildProblemRectificationQueryFilter(filters, gridView, subject),
};
const result = await queryRecords({
...countQuery,
page: requestPage,
page_size: requestPageSize,
filter: buildProblemRectificationQueryFilter(filters, gridView, subject),
sort: buildProblemRectificationSort(gridView),
mode: 'full',
});
const records = (result?.records || []).map(normalizeProblemRectificationRecord);
const totalRecords = Number(result?.total_records ?? result?.total ?? records.length);
const totalRecords = Number(
result?.total_records ?? result?.total_count ?? result?.total ?? records.length,
);
const totalPages = Number(
result?.total_pages ?? Math.max(1, Math.ceil(totalRecords / requestPageSize)),
);
countCache?.set(JSON.stringify(countQuery), Promise.resolve(totalRecords));
return {
records,
pagination: {
page: Number(result?.page ?? requestPage),
page: Number(result?.current_page ?? result?.page ?? requestPage),
pageSize: Number(result?.page_size ?? requestPageSize),
totalRecords,
totalPages: Math.max(1, totalPages),
......@@ -1371,15 +1378,34 @@ export async function loadProblemRectificationListResources() {
};
}
export async function loadProblemRectificationViewCountMap({ filters = {}, views = [], subject = null } = {}) {
export async function loadProblemRectificationViewCountMap({
filters = {},
views = [],
subject = null,
countCache = new Map(),
} = {}) {
const entries = await Promise.all(
views.map(async (view) => {
const result = await countRecords({
const countQuery = {
subject: PROBLEM_RECTIFICATION_SUBJECT,
filter: buildProblemRectificationQueryFilter(filters, view, subject),
};
const countKey = JSON.stringify(countQuery);
if (!countCache.has(countKey)) {
const countRequest = countRecords(countQuery)
.then((result) => Number(result?.count ?? result?.total ?? 0))
.catch((error) => {
if (countCache.get(countKey) === countRequest) {
countCache.delete(countKey);
}
throw error;
});
countCache.set(countKey, countRequest);
}
return [view.name, Number(result?.count ?? result?.total ?? 0)];
await countCache.get(countKey);
// 等待统计期间,同条件的列表响应可能已用更新的总数替换缓存。
return [view.name, await countCache.get(countKey)];
}),
);
......
......@@ -197,6 +197,20 @@ const response = await queryRecords({
});
```
**分页响应结构**(记录内容省略):
```json
{
"total_records": 19,
"records": [],
"current_page": 0,
"page_size": 100,
"total_pages": 1
}
```
`total_records` 是当前查询条件下的记录总数,与当前页 `records.length` 不同;列表分页和当前视图数量可直接复用该值,无需再对相同条件调用 `/record/count`。
#### 3.1.2 POST `/record/query/removed`
权限:`api.record.removed.query` + 管理员
......
import assert from 'node:assert/strict';
import test from 'node:test';
import { normalizeArchiveSnapshotItems } from '../src/pages/problem_rectification/archive/archiveSnapshotItems.mjs';
test('reads structured snapshots and legacy JSON without changing archived values', () => {
const items = [{ item_id: 'dept-1', responsible_department: '审计部', rectification_progress: '已完成' }];
assert.deepEqual(normalizeArchiveSnapshotItems(items), items);
assert.deepEqual(normalizeArchiveSnapshotItems(JSON.stringify(items)), items);
assert.deepEqual(normalizeArchiveSnapshotItems(items.map(JSON.stringify)), items);
assert.deepEqual(normalizeArchiveSnapshotItems(items[0]), items);
assert.deepEqual(normalizeArchiveSnapshotItems(JSON.stringify(items[0])), items);
});
test('reads metadata-wrapped embedded snapshots while preserving false and ISO dates', () => {
const metadata = {
original_due_date: '2026-10-07T00:00:00.000Z',
requested_due_date: '2026-11-07T00:00:00.000Z',
is_locked: false,
delay_reason: '待完成系统建设',
};
const input = { item_id: 'delay-1', metadata };
assert.deepEqual(normalizeArchiveSnapshotItems([input]), [{ item_id: 'delay-1', ...metadata }]);
assert.deepEqual(input, { item_id: 'delay-1', metadata });
});
test('treats empty snapshots as empty lists', () => {
for (const value of [undefined, null, '', ' ', 'null', '[]', []]) {
assert.deepEqual(normalizeArchiveSnapshotItems(value), []);
}
});
test('reports damaged snapshots instead of silently discarding historical rows', () => {
assert.throws(() => normalizeArchiveSnapshotItems('[{'), /JSON 格式/);
for (const value of [false, 12, [null], [[], {}], ['broken'], '[{},42]']) {
assert.throws(() => normalizeArchiveSnapshotItems(value), /无法|JSON/);
}
});
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { dirname, resolve } from 'node:path';
import test from 'node:test';
import ts from 'typescript';
const require = createRequire(import.meta.url);
const sourceRoot = new URL('../src/', import.meta.url).pathname;
const servicePath = resolve(sourceRoot, 'pages/problem_rectification/problemRectificationListService.js');
// 使用真实的筛选、记录归一化和分页逻辑,只替换 HTTP service 边界。
function loadListService(dataService) {
const modules = new Map();
const loadModule = (path) => {
if (modules.has(path)) return modules.get(path);
const exports = {};
modules.set(path, exports);
const { outputText } = ts.transpileModule(readFileSync(path, 'utf8'), {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
esModuleInterop: true,
},
});
const localRequire = (specifier) => {
if (specifier === '@/services/DataService') return dataService;
if (!specifier.startsWith('.') && !specifier.startsWith('@/')) return require(specifier);
const dependencyPath = specifier.startsWith('@/')
? resolve(sourceRoot, specifier.slice(2))
: resolve(dirname(path), specifier);
return dependencyPath.endsWith('.mjs')
? require(dependencyPath)
: loadModule(`${dependencyPath}.js`);
};
new Function('require', 'exports', outputText)(localRequire, exports);
return exports;
};
return loadModule(servicePath);
}
const VIEWS = [
{ name: 'all', settings: {} },
...['内部检查', '监管检查'].map((value, index) => ({
name: `source-${index}`,
settings: {
restrictions: [{ type: 'field', field: 'metadata.problem_from', comparator: '$eq', value }],
},
})),
];
test('query totals drive pagination and counts without counting the current view again', async () => {
const service = loadListService({
queryRecords: async () => ({
records: [{ _id: 'record-1', metadata: {} }],
total_records: 41,
current_page: 1,
page_size: 20,
total_pages: 3,
}),
countRecords: async () => { throw new Error('不应调用 count'); },
});
const countCache = new Map();
const result = await service.loadProblemRectificationListData({ gridView: VIEWS[0], countCache });
assert.equal(result.records.length, 1);
assert.deepEqual(result.pagination, { page: 1, pageSize: 20, totalRecords: 41, totalPages: 3 });
assert.deepEqual(
await service.loadProblemRectificationViewCountMap({ views: [VIEWS[0]], countCache }),
{ all: 41 },
);
});
test('tab switches and pagination reuse counts; new filters and invalidation refresh them', async () => {
const queries = [];
const counts = [];
const service = loadListService({
queryRecords: async (query) => {
queries.push(query);
return { records: [], total_records: 41 };
},
countRecords: async (query) => {
counts.push(query);
return { count: 12 };
},
});
let countCache = new Map();
const loadPage = async (gridView, page = 0, filters = {}) => Promise.all([
service.loadProblemRectificationListData({ gridView, page, filters, countCache }),
service.loadProblemRectificationViewCountMap({
views: VIEWS.filter((view) => view.name !== gridView.name),
filters,
countCache,
}),
]);
await loadPage(VIEWS[0]);
assert.equal(counts.length, 2);
await loadPage(VIEWS[1]);
await loadPage(VIEWS[2], 1);
assert.equal(counts.length, 2);
assert.equal(queries.length, 3);
assert.equal(queries[2].page, 1);
await loadPage(VIEWS[2], 0, { progress: '整改中' });
assert.equal(counts.length, 4);
assert.ok(counts.slice(2).every((query) => JSON.stringify(query.filter).includes('整改中')));
countCache = new Map();
await loadPage(VIEWS[2]);
assert.equal(counts.length, 6);
});
test('total_count is supported and a zero total never falls back to another field', async () => {
for (const [response, expected] of [
[{ total_count: 51 }, 51],
[{ total_records: 0, total_count: 51 }, 0],
]) {
const service = loadListService({ queryRecords: async () => ({ records: [], ...response }) });
const result = await service.loadProblemRectificationListData();
assert.equal(result.pagination.totalRecords, expected);
assert.equal(result.pagination.totalPages, Math.max(1, Math.ceil(expected / 20)));
}
});
test('concurrent requests and equivalent view filters share a single count request', async () => {
let callCount = 0;
let finishCount;
const service = loadListService({
countRecords: () => {
callCount += 1;
return new Promise((resolveCount) => { finishCount = resolveCount; });
},
});
const countCache = new Map();
const views = [VIEWS[0], { ...VIEWS[0], name: 'same-filter' }];
const first = service.loadProblemRectificationViewCountMap({ views, countCache });
const second = service.loadProblemRectificationViewCountMap({ views, countCache });
assert.equal(callCount, 1);
finishCount({ count: 19 });
for (const result of await Promise.all([first, second])) {
assert.deepEqual(result, { all: 19, 'same-filter': 19 });
}
});
test('failed counts are retried instead of cached permanently', async () => {
let callCount = 0;
const service = loadListService({
countRecords: async () => {
callCount += 1;
if (callCount === 1) throw new Error('统计失败');
return { count: 19 };
},
});
const options = { views: [VIEWS[0]], countCache: new Map() };
await assert.rejects(service.loadProblemRectificationViewCountMap(options), /统计失败/);
assert.deepEqual(await service.loadProblemRectificationViewCountMap(options), { all: 19 });
assert.equal(callCount, 2);
});
test('a late count response cannot replace the newer query total', async () => {
let finishCount;
const service = loadListService({
countRecords: () => new Promise((resolveCount) => { finishCount = resolveCount; }),
queryRecords: async () => ({ records: [], total_records: 22 }),
});
const countCache = new Map();
const pendingCounts = service.loadProblemRectificationViewCountMap({ views: [VIEWS[0]], countCache });
await service.loadProblemRectificationListData({ gridView: VIEWS[0], countCache });
finishCount({ count: 19 });
assert.deepEqual(await pendingCounts, { all: 22 });
});
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