Commit dcdc9689 authored by David Yang's avatar David Yang

feat: 动态详情页

parent b52ff11f
import { Button } from '@/catalyst-ui-kit/javascript/button';
import { Checkbox } from '@/catalyst-ui-kit/javascript/checkbox';
import { Input } from '@/catalyst-ui-kit/javascript/input';
import { Select } from '@/catalyst-ui-kit/javascript/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/catalyst-ui-kit/javascript/table';
import { formatFieldValue, getFieldValue } from '@/utils/subjectView';
import clsx from 'clsx';
import { useMemo, useState } from 'react';
const SELECTION_COLUMN_WIDTH = 56;
const DEFAULT_ACTION_COLUMN_WIDTH = 132;
function getPinnedSide(column) {
const pinnedValue = column?.lockPosition || column?.pinned;
if (pinnedValue === 'left' || pinnedValue === 'right') {
return pinnedValue;
}
return null;
}
function buildStickyColumnMeta(columns = [], selectable, hasRowActions, actionColumnWidth) {
const leftColumns = columns.filter((column) => getPinnedSide(column) === 'left');
const centerColumns = columns.filter((column) => !getPinnedSide(column));
const rightColumns = columns.filter((column) => getPinnedSide(column) === 'right');
const orderedColumns = [...leftColumns, ...centerColumns, ...rightColumns];
let leftOffset = selectable ? SELECTION_COLUMN_WIDTH : 0;
let rightOffset = hasRowActions ? actionColumnWidth : 0;
const stickyMeta = {};
leftColumns.forEach((column) => {
stickyMeta[column.field] = {
side: 'left',
offset: leftOffset,
isEdge: column.field === leftColumns[leftColumns.length - 1]?.field,
};
leftOffset += column.width || 180;
});
[...rightColumns].reverse().forEach((column) => {
stickyMeta[column.field] = {
side: 'right',
offset: rightOffset,
isEdge: column.field === rightColumns[0]?.field,
};
rightOffset += column.width || 180;
});
return {
orderedColumns,
stickyMeta,
};
}
function getStickyCellClass(meta, kind = 'body') {
if (!meta) {
return '';
}
const baseClass =
kind === 'header'
? 'sticky z-30 bg-zinc-50/95 backdrop-blur'
: 'sticky z-20 bg-white';
const sideClass = meta.side === 'left' ? 'left-0' : 'right-0';
const edgeShadow =
meta.side === 'left' && meta.isEdge
? 'shadow-[10px_0_20px_rgba(15,23,42,0.06)]'
: meta.side === 'right' && meta.isEdge
? 'shadow-[-10px_0_20px_rgba(15,23,42,0.06)]'
: '';
return clsx(baseClass, sideClass, edgeShadow);
}
function EmptyState({ title, description }) {
return (
<div className="rounded-[28px] border border-dashed border-zinc-300 bg-white/80 px-6 py-14 text-center">
<div className="text-xl font-semibold text-slate-900">{title}</div>
<div className="mt-3 text-sm leading-6 text-slate-500">{description}</div>
</div>
);
}
function SummaryCard({ label, value, hint, tone = 'default' }) {
const toneClass =
tone === 'rose'
? 'border-rose-200/80 bg-rose-50/85'
: tone === 'amber'
? 'border-amber-200/80 bg-amber-50/85'
: tone === 'emerald'
? 'border-emerald-200/80 bg-emerald-50/85'
: 'border-zinc-200/80 bg-white/88';
return (
<div className={clsx('rounded-[26px] border p-5 shadow-sm ring-1 ring-zinc-950/5', toneClass)}>
<div className="text-xs font-semibold uppercase tracking-[0.22em] text-slate-400">
{label}
</div>
<div className="mt-3 text-3xl font-semibold tracking-tight text-slate-950">
{value}
</div>
<div className="mt-2 text-sm text-slate-500">{hint}</div>
</div>
);
}
function isActionDisabled(action, context) {
if (typeof action?.disabled === 'function') {
return Boolean(action.disabled(context));
}
return Boolean(action?.disabled);
}
function isActionHidden(action, context) {
if (typeof action?.hidden === 'function') {
return Boolean(action.hidden(context));
}
return Boolean(action?.hidden);
}
function renderButtonProps(action) {
const props = {};
if (action?.variant === 'plain') {
props.plain = true;
} else if (action?.variant === 'solid') {
props.color =
action?.color || (action?.tone === 'danger' ? 'red' : action?.tone === 'primary' ? 'rose' : undefined);
} else {
props.outline = true;
if (action?.tone === 'danger') {
props.color = action?.color || 'red';
} else if (action?.tone === 'primary') {
props.color = action?.color || 'rose';
}
}
return props;
}
function defaultGetRecordId(record) {
return record?.id || record?._id || record?.name || '';
}
function defaultGetRecordName(record) {
return record?.name || record?.id || record?._id || '';
}
function defaultGetRecordLabel(record) {
return record?.title || record?.name || record?.id || '当前记录';
}
function defaultRenderCell(record, column) {
const sourceRecord = record?.raw || record;
const value = getFieldValue(sourceRecord, column.field);
return (
<div className="whitespace-normal break-words text-sm text-slate-700">
{formatFieldValue(value, column.fieldDef) || '-'}
</div>
);
}
export default function RecordWorkbench({
summaryItems = [],
views = [],
activeViewName = '',
onViewChange,
filters = {},
filterSchema = [],
onFiltersChange,
onClearFilters,
title,
description,
columns = [],
records = [],
selectedIds = [],
onSelectionChange,
toolbarActions = [],
bulkActions = [],
rowActions = [],
detailPanel = null,
states = {},
renderCell,
getRecordId = defaultGetRecordId,
getRecordLabel = defaultGetRecordLabel,
selectable = true,
actionColumnWidth = DEFAULT_ACTION_COLUMN_WIDTH,
}) {
const [drawerRecordName, setDrawerRecordName] = useState('');
const [drawerDirty, setDrawerDirty] = useState(false);
const loading = Boolean(states?.loading);
const error = states?.error || '';
const totalRecords = records.length;
const selectedRecords = useMemo(
() => records.filter((record) => selectedIds.includes(getRecordId(record))),
[records, selectedIds, getRecordId],
);
const stickyColumns = useMemo(
() => buildStickyColumnMeta(columns, selectable, rowActions.length > 0, actionColumnWidth),
[columns, selectable, rowActions.length, actionColumnWidth],
);
const openDetail = (recordOrName) => {
const recordName =
typeof recordOrName === 'string'
? recordOrName
: detailPanel?.getRecordName?.(recordOrName) || defaultGetRecordName(recordOrName);
if (!recordName) {
return;
}
setDrawerDirty(false);
setDrawerRecordName(recordName);
};
const closeDetail = async () => {
setDrawerRecordName('');
detailPanel?.onClose?.();
if (drawerDirty) {
setDrawerDirty(false);
await detailPanel?.onDirtyClose?.();
}
};
const toolbarContext = {
records,
selectedIds,
selectedRecords,
openDetail,
};
const visibleToolbarActions = [...toolbarActions, ...bulkActions].filter(
(action) => !isActionHidden(action, toolbarContext),
);
const summaryGridClass =
summaryItems.length >= 6
? 'md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-8'
: summaryItems.length >= 4
? 'md:grid-cols-2 xl:grid-cols-4'
: 'md:grid-cols-3';
const filterGridClass =
filterSchema.length >= 7
? 'xl:grid-cols-3 2xl:grid-cols-5'
: filterSchema.length >= 4
? 'xl:grid-cols-2 2xl:grid-cols-4'
: filterSchema.length > 1
? 'xl:grid-cols-4'
: 'xl:grid-cols-[1fr_auto]';
return (
<div className="space-y-6 pb-4">
{summaryItems.length > 0 ? (
<section className={clsx('grid gap-4', summaryGridClass)}>
{summaryItems.map((item) => (
<SummaryCard
key={item.label}
label={item.label}
value={item.value}
hint={item.hint}
tone={item.tone}
/>
))}
</section>
) : null}
{views.length > 0 ? (
<section className="rounded-[30px] border border-white/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.96),rgba(250,250,250,0.92))] p-3 shadow-sm ring-1 ring-zinc-950/5">
<div className="flex flex-wrap gap-2">
{views.map((view) => {
const active = view.name === activeViewName;
return (
<button
key={view.name}
type="button"
onClick={() => onViewChange?.(view.name)}
className={clsx(
'inline-flex min-h-11 items-center gap-2 rounded-full px-4 py-2.5 text-sm font-medium transition',
active
? 'bg-[linear-gradient(135deg,#7f1d1d,#be123c)] text-white shadow-lg shadow-rose-200/60'
: 'bg-white text-slate-600 ring-1 ring-zinc-200 hover:bg-rose-50 hover:text-rose-700',
)}
>
<span>{view.title || view.name}</span>
{view.count !== undefined ? (
<span
className={clsx(
'rounded-full px-2 py-0.5 text-xs',
active ? 'bg-white/20 text-white' : 'bg-zinc-100 text-slate-500',
)}
>
{view.count}
</span>
) : null}
</button>
);
})}
</div>
</section>
) : null}
{filterSchema.length > 0 ? (
<section className="rounded-[30px] border border-zinc-200 bg-white/95 p-5 shadow-sm ring-1 ring-zinc-950/5">
<div className={clsx('grid gap-4', filterGridClass)}>
{filterSchema.map((field) => (
<div
key={field.key}
className={clsx(
field.type === 'text' && filterSchema.length > 1 && 'xl:col-span-2',
filterSchema.length >= 7 && field.type === 'text' && '2xl:col-span-2',
)}
>
<label className="mb-2 block text-sm font-medium text-slate-700">{field.label}</label>
{field.type === 'select' ? (
<Select
value={filters[field.key] || ''}
onChange={(event) => onFiltersChange?.(field.key, event.target.value)}
>
<option value="">{field.placeholder || `全部${field.label}`}</option>
{(field.options || []).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
) : (
<Input
value={filters[field.key] || ''}
onChange={(event) => onFiltersChange?.(field.key, event.target.value)}
placeholder={field.placeholder}
/>
)}
</div>
))}
{onClearFilters ? (
<div className="flex items-end">
<Button outline className="w-full justify-center" onClick={onClearFilters}>
清空筛选
</Button>
</div>
) : null}
</div>
</section>
) : null}
<section className="overflow-hidden rounded-[32px] border border-white/80 bg-white/96 shadow-[0_18px_48px_rgba(15,23,42,0.08)] ring-1 ring-zinc-950/5">
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-zinc-100 px-6 py-5">
<div>
<div className="text-lg font-semibold text-slate-900">{title}</div>
<div className="mt-1 text-sm text-slate-500">
{description || (selectable ? `当前视图显示 ${totalRecords} 条记录,已选中 ${selectedIds.length} 条。` : `当前视图显示 ${totalRecords} 条记录。`)}
</div>
</div>
{visibleToolbarActions.length > 0 ? (
<div className="flex flex-wrap gap-2">
{visibleToolbarActions.map((action) => {
const disabled = isActionDisabled(action, toolbarContext);
const buttonProps = renderButtonProps(action);
return (
<Button
key={action.key}
{...buttonProps}
disabled={disabled}
onClick={() => action.onClick?.(toolbarContext)}
>
{action.label}
</Button>
);
})}
</div>
) : null}
</div>
{error ? (
<div className="border-b border-zinc-100 bg-rose-50/70 px-6 py-4 text-sm text-rose-700">
{error}
</div>
) : null}
{loading ? (
<div className="px-6 py-6">
<EmptyState
title={states?.loadingTitle || '正在加载列表'}
description={states?.loadingDescription || '正在读取对象结构、视图配置和记录数据,请稍候。'}
/>
</div>
) : totalRecords === 0 ? (
<div className="px-6 py-6">
<EmptyState
title={states?.emptyTitle || '当前暂无数据'}
description={states?.emptyDescription || '可以切换视图或调整筛选条件后重试。'}
/>
</div>
) : (
<div className="px-3 pb-3 pt-2">
<Table bleed dense className="[--gutter:--spacing(3)]">
<TableHead>
<TableRow className="bg-zinc-50/80">
{selectable ? (
<TableHeader
className="w-14 sticky left-0 z-40 bg-zinc-50/95 backdrop-blur shadow-[10px_0_20px_rgba(15,23,42,0.06)]"
style={{ width: SELECTION_COLUMN_WIDTH, minWidth: SELECTION_COLUMN_WIDTH }}
>
<div className="flex justify-center">
<Checkbox
checked={totalRecords > 0 && selectedIds.length === totalRecords}
onChange={(checked) => {
if (!checked) {
onSelectionChange?.([]);
return;
}
onSelectionChange?.(records.map((record) => getRecordId(record)));
}}
color="rose"
aria-label="全选当前视图记录"
/>
</div>
</TableHeader>
) : null}
{stickyColumns.orderedColumns.map((column) => {
const stickyMeta = stickyColumns.stickyMeta[column.field];
return (
<TableHeader
key={column.field}
style={{
width: column.width || 180,
minWidth: Math.min(Math.max(column.width || 180, 150), 360),
left: stickyMeta?.side === 'left' ? `${stickyMeta.offset}px` : undefined,
right: stickyMeta?.side === 'right' ? `${stickyMeta.offset}px` : undefined,
}}
className={clsx(
'text-xs font-semibold uppercase tracking-[0.18em] text-slate-400',
getStickyCellClass(stickyMeta, 'header'),
)}
>
{column.label}
</TableHeader>
);
})}
{rowActions.length > 0 ? (
<TableHeader
className="sticky right-0 z-40 bg-zinc-50/95 text-xs font-semibold uppercase tracking-[0.18em] text-slate-400 shadow-[-10px_0_20px_rgba(15,23,42,0.06)] backdrop-blur"
style={{ width: actionColumnWidth, minWidth: actionColumnWidth }}
>
操作
</TableHeader>
) : null}
</TableRow>
</TableHead>
<TableBody>
{records.map((record) => {
const recordId = getRecordId(record);
const rowContext = {
record,
records,
selectedIds,
selectedRecords,
openDetail,
};
const visibleRowActions = rowActions.filter((action) => !isActionHidden(action, rowContext));
return (
<TableRow key={recordId} className="align-top">
{selectable ? (
<TableCell
className="align-top sticky left-0 z-30 bg-white shadow-[10px_0_20px_rgba(15,23,42,0.05)]"
style={{ width: SELECTION_COLUMN_WIDTH, minWidth: SELECTION_COLUMN_WIDTH }}
>
<div className="flex justify-center pt-1">
<Checkbox
checked={selectedIds.includes(recordId)}
onChange={(checked) => {
if (checked) {
onSelectionChange?.(Array.from(new Set([...selectedIds, recordId])));
return;
}
onSelectionChange?.(selectedIds.filter((id) => id !== recordId));
}}
color="rose"
aria-label={`选择 ${getRecordLabel(record)}`}
/>
</div>
</TableCell>
) : null}
{stickyColumns.orderedColumns.map((column) => {
const stickyMeta = stickyColumns.stickyMeta[column.field];
return (
<TableCell
key={column.field}
style={{
width: column.width || 180,
minWidth: Math.min(Math.max(column.width || 180, 150), 360),
left: stickyMeta?.side === 'left' ? `${stickyMeta.offset}px` : undefined,
right: stickyMeta?.side === 'right' ? `${stickyMeta.offset}px` : undefined,
}}
className={clsx('align-top', getStickyCellClass(stickyMeta))}
>
{(() => {
if (column.render) {
return column.render(record, column);
}
if (renderCell) {
const customNode = renderCell({ record, column });
if (customNode !== undefined) {
return customNode;
}
}
return defaultRenderCell(record, column);
})()}
</TableCell>
);
})}
{visibleRowActions.length > 0 ? (
<TableCell
className="align-top sticky right-0 z-30 bg-white shadow-[-10px_0_20px_rgba(15,23,42,0.05)]"
style={{ width: actionColumnWidth, minWidth: actionColumnWidth }}
>
<div className="flex min-w-28 flex-col gap-2">
{visibleRowActions.map((action) => {
const disabled = isActionDisabled(action, rowContext);
const buttonProps = renderButtonProps(action);
return (
<Button
key={action.key}
{...buttonProps}
disabled={disabled}
onClick={() => action.onClick?.(rowContext)}
>
{action.label}
</Button>
);
})}
</div>
</TableCell>
) : null}
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</section>
{drawerRecordName && detailPanel?.render
? detailPanel.render({
open: Boolean(drawerRecordName),
recordName: drawerRecordName,
onClose: closeDetail,
onDataChange: () => setDrawerDirty(true),
})
: null}
</div>
);
}
......@@ -39,7 +39,7 @@ function transformSaveValue(newValue, fieldDef) {
return newValue;
}
export function useSubjectRecord(recordName, refreshKey, readonly) {
export function useSubjectRecord(recordName, refreshKey, readonly, subjectName) {
const [remoteRecord, setRemoteRecord] = useState(null);
const [editableRecord, setEditableRecord] = useState(null);
const [subject, setSubject] = useState(null);
......@@ -67,9 +67,9 @@ export function useSubjectRecord(recordName, refreshKey, readonly) {
try {
const record = await loadRecord(recordName);
const subjectName = record?.__t || record?.subject?.name || record?.subject;
const [subjectData, viewsData] = subjectName
? await Promise.all([loadSubject(subjectName), getSubjectView(subjectName)])
const resolvedSubjectName = subjectName || record?.__t || record?.subject?.name || record?.subject;
const [subjectData, viewsData] = resolvedSubjectName
? await Promise.all([loadSubject(resolvedSubjectName), getSubjectView(resolvedSubjectName)])
: [null, []];
if (!cancelled && requestIdRef.current === requestId) {
......@@ -98,7 +98,7 @@ export function useSubjectRecord(recordName, refreshKey, readonly) {
return () => {
cancelled = true;
};
}, [recordName, refreshKey]);
}, [recordName, refreshKey, subjectName]);
const handleFieldUpdate = async (fieldPath, newValue, fieldDef) => {
if (readonly || !editableRecord) {
......
......@@ -59,7 +59,8 @@ const RecordDetailDrawer = (props, ref) => {
width = 860,
open,
recordName,
setCustomComponentClose,
subjectName,
onClose,
onDataChange,
readonly = false,
filterView = [],
......@@ -79,7 +80,7 @@ const RecordDetailDrawer = (props, ref) => {
handleFieldUpdate,
isFieldDisabled,
loading,
} = useSubjectRecord(recordName, refreshKey, readonly);
} = useSubjectRecord(recordName, refreshKey, readonly, subjectName);
useEffect(() => {
if (open) {
......@@ -156,7 +157,7 @@ const RecordDetailDrawer = (props, ref) => {
];
const closeDrawer = () => {
setCustomComponentClose?.();
onClose?.();
};
return (
......
import { countRecords, getSubjectView, loadRecord, loadSubject, queryRecords } from '@/services/DataService';
import {
buildViewColumns,
checkViewCondition,
formatFieldValue,
getFieldValue,
isViewAccessible,
normalizeText,
recordMatchesRestrictions,
sortRecordsByView,
} from '@/utils/subjectView';
export const ISSUE_RECTIFICATION_ARCHIVE_SUBJECT = 'issue_rectification_archive';
export const PROBLEM_RECTIFICATION_ARCHIVE_RECORDS_SUBJECT = 'problem_rectification_archive_records';
export function normalizeArchiveBatchRecord(record) {
const referenceRecords = Array.isArray(record?.metadata?.reference_records)
? record.metadata.reference_records
: [];
return {
id: record?._id || record?.name || '',
name: record?.name || '',
title: record?.title || record?.name || '归档批次',
archiveCount: referenceRecords.length,
referenceRecords,
raw: record,
};
}
export async function loadArchiveWorkbenchResources() {
const [archiveRecordsResult, archiveSubject, archiveViews] = await Promise.all([
queryRecords({
subject: ISSUE_RECTIFICATION_ARCHIVE_SUBJECT,
page: 0,
page_size: 1000,
sort: '-updated_at -created_at',
mode: 'full',
}),
loadSubject(ISSUE_RECTIFICATION_ARCHIVE_SUBJECT),
getSubjectView(ISSUE_RECTIFICATION_ARCHIVE_SUBJECT),
]);
const records = (archiveRecordsResult?.records || []).map(normalizeArchiveBatchRecord);
return {
records,
subject: archiveSubject,
views: archiveViews || [],
};
}
export function buildArchiveGridViews(views = []) {
const gridViews = views
.filter((view) => view.type === 'grid')
.filter((view) => isViewAccessible(view));
if (gridViews.length > 0) {
return gridViews;
}
return [
{
title: '全部批次',
type: 'grid',
name: 'all',
settings: {},
},
];
}
export function buildArchiveColumns(view, subject) {
return buildViewColumns(view, subject).filter((column) => column.field !== 'title' || !column.hide);
}
export function buildArchiveConfiguredView(records, filters, gridView, subject) {
const keyword = normalizeText(filters?.keyword).toLowerCase();
const columns = buildArchiveColumns(gridView, subject);
const filteredRecords = records.filter((record) => {
const rawRecord = record.raw || record;
if (!recordMatchesRestrictions(rawRecord, gridView?.settings?.restrictions)) {
return false;
}
if (
!checkViewCondition(
{
relationship: gridView?.settings?.filters?.relationship || 'all',
rules: gridView?.settings?.filters?.conditions || [],
},
rawRecord,
)
) {
return false;
}
if (!keyword) {
return true;
}
const searchText = columns
.map((column) => formatFieldValue(getFieldValue(rawRecord, column.field), column.fieldDef))
.join(' ')
.toLowerCase();
return searchText.includes(keyword);
});
return {
filteredRecords: sortRecordsByView(filteredRecords, gridView),
columns,
};
}
export function buildArchiveViewCountMap(records, filters, views, subject) {
return views.reduce((counts, view) => {
counts[view.name] = buildArchiveConfiguredView(records, filters, view, subject).filteredRecords.length;
return counts;
}, {});
}
export async function loadArchiveDetailMetrics(recordName) {
const batchRecord = await loadRecord(recordName);
const referenceRecordIds = Array.isArray(batchRecord?.metadata?.reference_records)
? batchRecord.metadata.reference_records
.map((item) => (typeof item === 'object' ? item._id || item.name : item))
.filter(Boolean)
: [];
const detailCount = referenceRecordIds.length > 0
? await countRecords({
subject: PROBLEM_RECTIFICATION_ARCHIVE_RECORDS_SUBJECT,
filter: {
_id: { $in: referenceRecordIds },
},
})
: { count: 0 };
return {
batchRecord,
detailCount: detailCount?.count || 0,
referenceRecordIds,
};
}
import ModuleEmptyState from '../ModuleEmptyState';
import RecordWorkbench from '@/components/record-workbench';
import RecordDetailDrawer from '@/pages/RecordDetailDrawer';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
buildArchiveConfiguredView,
buildArchiveGridViews,
buildArchiveViewCountMap,
ISSUE_RECTIFICATION_ARCHIVE_SUBJECT,
loadArchiveWorkbenchResources,
} from './archiveWorkbenchService';
export default function ProblemRectificationArchivePage() {
const [records, setRecords] = useState([]);
const [subject, setSubject] = useState(null);
const [views, setViews] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [filters, setFilters] = useState({
keyword: '',
});
const [activeViewName, setActiveViewName] = useState('');
const [selectedIds, setSelectedIds] = useState([]);
const reloadList = useCallback(async () => {
setLoading(true);
setError('');
try {
const resources = await loadArchiveWorkbenchResources();
setRecords(resources.records || []);
setSubject(resources.subject || null);
setViews(resources.views || []);
} catch (requestError) {
setRecords([]);
setSubject(null);
setViews([]);
setError(requestError?.message || '归档批次列表加载失败,请稍后重试。');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
reloadList();
}, [reloadList]);
const gridViews = useMemo(() => buildArchiveGridViews(views), [views]);
useEffect(() => {
if (gridViews.length === 0) {
setActiveViewName('');
return;
}
if (!gridViews.some((view) => view.name === activeViewName)) {
setActiveViewName(gridViews[0].name);
}
}, [gridViews, activeViewName]);
const activeGridView = useMemo(
() => gridViews.find((view) => view.name === activeViewName) || gridViews[0] || null,
[gridViews, activeViewName],
);
const configuredView = useMemo(
() => buildArchiveConfiguredView(records, filters, activeGridView, subject),
[records, filters, activeGridView, subject],
);
const filteredRecords = configuredView.filteredRecords || [];
useEffect(() => {
const visibleIds = new Set(filteredRecords.map((item) => item.id));
setSelectedIds((current) => current.filter((id) => visibleIds.has(id)));
}, [filteredRecords]);
const gridViewCounts = useMemo(
() => buildArchiveViewCountMap(records, filters, gridViews, subject),
[records, filters, gridViews, subject],
);
const summaryItems = useMemo(
() => [
{
label: '归档批次数',
value: filteredRecords.length,
hint: activeGridView?.title ? `${activeGridView.title} 视图` : '当前筛选结果',
tone: 'amber',
},
{
label: '已归档明细数',
value: filteredRecords.reduce((total, item) => total + (item.archiveCount || 0), 0),
hint: '按当前视图统计关联快照数量',
tone: 'emerald',
},
{
label: '当前已选批次',
value: selectedIds.length,
hint: '支持后续批量操作扩展',
tone: 'rose',
},
],
[filteredRecords, selectedIds.length, activeGridView],
);
return (
<ModuleEmptyState
title="往期材料归档"
description="查看归档批次和归档明细。"
<RecordWorkbench
summaryItems={summaryItems}
views={gridViews.map((view) => ({
...view,
count: gridViewCounts[view.name] || 0,
}))}
activeViewName={activeGridView?.name || ''}
onViewChange={(viewName) => {
setActiveViewName(viewName);
setSelectedIds([]);
}}
filters={filters}
filterSchema={[
{
key: 'keyword',
label: '关键词搜索',
type: 'text',
placeholder: '搜索归档批次标题、编码或关联字段...',
},
]}
onFiltersChange={(key, value) => setFilters((current) => ({ ...current, [key]: value }))}
onClearFilters={() => {
setFilters({ keyword: '' });
setSelectedIds([]);
}}
title="归档批次"
description={`当前视图显示 ${filteredRecords.length} 条批次记录,已选中 ${selectedIds.length} 条。`}
columns={configuredView.columns || []}
records={filteredRecords}
selectedIds={selectedIds}
onSelectionChange={setSelectedIds}
toolbarActions={[
{
key: 'archive-export',
label: '导出批次',
variant: 'outline',
disabled: true,
onClick: () => {},
},
{
key: 'archive-compare',
label: '批次对比',
variant: 'outline',
disabled: true,
onClick: () => {},
},
]}
rowActions={[
{
key: 'view-detail',
label: '查看详情',
variant: 'plain',
onClick: ({ openDetail, record }) => openDetail(record),
},
]}
detailPanel={{
getRecordName: (record) => record.name || record.id,
onDirtyClose: reloadList,
render: ({ open, recordName, onClose, onDataChange }) => (
<RecordDetailDrawer
open={open}
recordName={recordName}
subjectName={ISSUE_RECTIFICATION_ARCHIVE_SUBJECT}
onClose={onClose}
onDataChange={onDataChange}
preferredViewName={activeGridView?.title || activeGridView?.name}
/>
),
}}
states={{
loading,
error,
loadingTitle: '正在加载归档批次',
loadingDescription: '正在读取归档对象、视图配置和批次记录,请稍候。',
emptyTitle: '暂无归档批次',
emptyDescription: '当前还没有归档批次记录,后续完成“一键归档”后会在这里展示。',
}}
renderCell={({ record, column }) => {
if (column.field === 'metadata.reference_records') {
return (
<div className="whitespace-normal break-words text-sm text-slate-700">
{record.archiveCount || 0} 条明细
</div>
);
}
return column.render ? column.render(record, column) : null;
}}
/>
);
}
......@@ -4,6 +4,8 @@ import dayjs from 'dayjs';
import { useEffect, useMemo, useState } from 'react';
import { loadProblemRectificationDashboardData } from '../problemRectificationDashboardService';
const CHART_COLOR_SET = ['#e11d48', '#fb7185', '#f97316', '#f59e0b', '#22c55e', '#0ea5e9', '#8b5cf6'];
function formatPercent(value) {
const numericValue = Number(value || 0);
if (!Number.isFinite(numericValue)) {
......@@ -20,6 +22,14 @@ function formatCount(value) {
return `${numericValue}`;
}
function formatScore(value) {
const numericValue = Number(value || 0);
if (!Number.isFinite(numericValue)) {
return '0.00';
}
return numericValue.toFixed(2);
}
function toneCardClass(tone) {
if (tone === 'rose') {
return 'border-rose-200 bg-[linear-gradient(135deg,rgba(255,241,242,0.95),rgba(255,255,255,0.9))]';
......@@ -36,6 +46,20 @@ function toneCardClass(tone) {
return 'border-zinc-200 bg-[linear-gradient(135deg,rgba(250,250,250,0.95),rgba(255,255,255,0.92))]';
}
function SectionHeading({ eyebrow, title, description }) {
return (
<div className="space-y-2">
<div className="text-xs font-semibold uppercase tracking-[0.24em] text-rose-500">
{eyebrow}
</div>
<div className="text-2xl font-semibold tracking-tight text-slate-950">{title}</div>
{description ? (
<div className="max-w-4xl text-sm leading-6 text-slate-500">{description}</div>
) : null}
</div>
);
}
function StatCard({ label, value, hint, tone = 'default' }) {
return (
<div
......@@ -45,27 +69,28 @@ function StatCard({ label, value, hint, tone = 'default' }) {
)}
>
<div className="text-sm font-medium text-slate-500">{label}</div>
<div className="mt-4 text-4xl font-semibold tracking-tight text-slate-900">
{value}
</div>
<div className="mt-4 text-4xl font-semibold tracking-tight text-slate-900">{value}</div>
<div className="mt-2 text-sm leading-6 text-slate-500">{hint}</div>
</div>
);
}
function Panel({ title, description, children, aside }) {
function NumberCard({ label, value, hint }) {
return (
<div className="rounded-[24px] border border-zinc-200 bg-zinc-50/70 p-5">
<div className="text-sm font-medium text-slate-500">{label}</div>
<div className="mt-3 text-3xl font-semibold tracking-tight text-slate-950">{value}</div>
{hint ? <div className="mt-2 text-sm text-slate-500">{hint}</div> : null}
</div>
);
}
function Panel({ title, description, children }) {
return (
<section className="rounded-[30px] border border-zinc-200 bg-white/95 shadow-[0_20px_50px_rgba(15,23,42,0.06)]">
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-zinc-100 px-6 py-5">
<div>
<div className="text-lg font-semibold text-slate-900">{title}</div>
{description ? (
<div className="mt-1 text-sm leading-6 text-slate-500">
{description}
</div>
) : null}
</div>
{aside ? <div>{aside}</div> : null}
<div className="border-b border-zinc-100 px-6 py-5">
<div className="text-lg font-semibold text-slate-900">{title}</div>
{description ? <div className="mt-1 text-sm leading-6 text-slate-500">{description}</div> : null}
</div>
<div className="px-6 py-6">{children}</div>
</section>
......@@ -80,69 +105,189 @@ function EmptyBlock({ title }) {
);
}
function ProblemListCard({ title, description, items = [], emptyText }) {
function FocusIssueList({ title, description, items = [], tone }) {
const toneClass =
tone === 'rose'
? 'bg-rose-50 text-rose-700'
: tone === 'amber'
? 'bg-amber-50 text-amber-700'
: 'bg-sky-50 text-sky-700';
return (
<Panel title={title} description={description}>
{items.length > 0 ? (
<div className="grid gap-4 xl:grid-cols-2">
<div className="space-y-3">
{items.map((item) => (
<article
key={item.id}
className="rounded-[24px] border border-zinc-200 bg-[linear-gradient(180deg,#ffffff,rgba(248,250,252,0.92))] p-5 shadow-sm"
className="rounded-[22px] border border-zinc-200 bg-[linear-gradient(180deg,#ffffff,rgba(248,250,252,0.92))] p-4 shadow-sm"
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-lg font-semibold text-slate-900">
{item.title}
</div>
<div className="mt-1 text-sm text-slate-500">
{item.feedbackUnit || '未归类单位'}
</div>
<div className="truncate text-base font-semibold text-slate-900">{item.title}</div>
<div className="mt-1 text-sm text-slate-500">{item.feedbackUnit}</div>
</div>
<div className="rounded-full border border-rose-100 bg-rose-50 px-3 py-1 text-xs font-medium text-rose-700">
{item.dueDateLabel}
<div className={clsx('rounded-full px-3 py-1 text-xs font-medium', toneClass)}>
{item.extraLabel}
</div>
</div>
<div className="mt-4 line-clamp-2 text-sm leading-6 text-slate-600">
{item.brief}
</div>
<div className="mt-5 flex flex-wrap gap-2">
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-600">
{item.progress || '未填写进度'}
<div className="mt-3 line-clamp-2 text-sm leading-6 text-slate-600">{item.brief}</div>
<div className="mt-4 flex flex-wrap gap-2">
<span className="rounded-full bg-zinc-100 px-3 py-1 text-xs font-medium text-zinc-700">
严重程度 {item.severityLevel}
</span>
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-700">
截止 {item.dueDateLabel}
</span>
{item.problemType ? (
<span className="rounded-full bg-amber-50 px-3 py-1 text-xs font-medium text-amber-700">
{item.problemType}
</span>
) : null}
{item.relatedIssueDomain ? (
<span className="rounded-full bg-sky-50 px-3 py-1 text-xs font-medium text-sky-700">
{item.relatedIssueDomain}
</span>
) : null}
</div>
</article>
))}
</div>
) : (
<EmptyBlock title={emptyText} />
<EmptyBlock title="当前没有相关重点问题" />
)}
</Panel>
);
}
function buildHorizontalBarOption(data, colors) {
function buildDonutOption(data, colors) {
return {
color: colors,
tooltip: {
trigger: 'item',
backgroundColor: 'rgba(15,23,42,0.9)',
borderWidth: 0,
textStyle: { color: '#fff' },
},
legend: {
bottom: 0,
icon: 'circle',
textStyle: { color: '#475569' },
},
series: [
{
type: 'pie',
radius: ['52%', '74%'],
center: ['50%', '42%'],
itemStyle: {
borderRadius: 12,
borderColor: '#fff',
borderWidth: 4,
},
label: {
color: '#334155',
formatter: '{b|{b}}\n{c|{c} 条}',
rich: {
b: { fontSize: 12, fontWeight: 600, color: '#334155' },
c: { fontSize: 11, color: '#64748b' },
},
},
labelLine: {
lineStyle: { color: '#cbd5e1' },
},
data,
},
],
};
}
function buildTreemapOption(data = []) {
return {
tooltip: {
formatter: (params) => `${params.name}: ${params.value || 0} 条`,
backgroundColor: 'rgba(15,23,42,0.9)',
borderWidth: 0,
textStyle: { color: '#fff' },
},
series: [
{
type: 'treemap',
roam: false,
breadcrumb: { show: false },
nodeClick: false,
upperLabel: {
show: true,
height: 26,
color: '#0f172a',
},
label: {
show: true,
color: '#fff',
fontSize: 12,
formatter: '{b}',
},
itemStyle: {
borderColor: '#fff',
borderWidth: 3,
gapWidth: 3,
},
levels: [
{
color: ['#e11d48', '#f97316', '#0ea5e9', '#22c55e', '#8b5cf6', '#f59e0b'],
colorMappingBy: 'index',
},
{
colorSaturation: [0.4, 0.75],
itemStyle: {
borderColorSaturation: 0.7,
gapWidth: 2,
borderWidth: 2,
},
},
],
data,
},
],
};
}
function buildStackedSourceOption(sourceComparison) {
return {
color: CHART_COLOR_SET,
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
backgroundColor: 'rgba(15,23,42,0.9)',
borderWidth: 0,
textStyle: { color: '#fff' },
},
legend: {
top: 0,
textStyle: { color: '#475569' },
},
grid: {
left: 8,
right: 20,
top: 12,
right: 16,
top: 48,
bottom: 8,
containLabel: true,
},
xAxis: {
type: 'category',
data: sourceComparison.categories,
axisTick: { show: false },
axisLine: { lineStyle: { color: '#e2e8f0' } },
axisLabel: { color: '#64748b' },
},
yAxis: {
type: 'value',
splitLine: { lineStyle: { color: 'rgba(148,163,184,0.18)' } },
axisLabel: { color: '#64748b' },
},
series: sourceComparison.series.map((series) => ({
name: series.name,
type: 'bar',
stack: 'total',
barMaxWidth: 34,
emphasis: { focus: 'series' },
data: series.data,
})),
};
}
function buildHorizontalBarOption(data, color) {
return {
color: [color],
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
......@@ -150,33 +295,30 @@ function buildHorizontalBarOption(data, colors) {
borderWidth: 0,
textStyle: { color: '#fff' },
},
grid: {
left: 8,
right: 20,
top: 12,
bottom: 8,
containLabel: true,
},
xAxis: {
type: 'value',
splitLine: {
lineStyle: {
color: 'rgba(148,163,184,0.18)',
},
},
axisLabel: {
color: '#64748b',
},
splitLine: { lineStyle: { color: 'rgba(148,163,184,0.18)' } },
axisLabel: { color: '#64748b' },
},
yAxis: {
type: 'category',
data: data.map((item) => item.name),
axisTick: { show: false },
axisLine: { show: false },
axisLabel: {
color: '#334155',
fontSize: 12,
},
axisLabel: { color: '#334155', fontSize: 12 },
},
series: [
{
type: 'bar',
data: data.map((item) => item.value),
barWidth: 16,
roundCap: true,
barWidth: 18,
showBackground: true,
backgroundStyle: {
color: 'rgba(148,163,184,0.08)',
......@@ -196,110 +338,275 @@ function buildHorizontalBarOption(data, colors) {
};
}
function buildDonutOption(data, colors) {
function buildBubbleMatrixOption(data = []) {
const maxTotal = Math.max(...data.map((item) => item.totalCount), 1);
const maxOverdue = Math.max(...data.map((item) => item.overdueCount), 1);
return {
color: colors,
tooltip: {
trigger: 'item',
formatter: (params) => {
const item = params.data;
return [
`<strong>${item.name}</strong>`,
`问题总数:${item.totalCount}`,
`整改完成率:${formatPercent(item.completionRate)}`,
`逾期未完成:${item.overdueCount}`,
].join('<br/>');
},
backgroundColor: 'rgba(15,23,42,0.9)',
borderWidth: 0,
textStyle: { color: '#fff' },
},
legend: {
bottom: 0,
icon: 'circle',
textStyle: {
color: '#475569',
},
grid: {
left: 8,
right: 16,
top: 12,
bottom: 10,
containLabel: true,
},
xAxis: {
type: 'value',
name: '问题总数',
nameTextStyle: { color: '#64748b' },
splitLine: { lineStyle: { color: 'rgba(148,163,184,0.18)' } },
axisLabel: { color: '#64748b' },
},
yAxis: {
type: 'value',
name: '整改完成率(%)',
nameTextStyle: { color: '#64748b' },
min: 0,
max: 100,
splitLine: { lineStyle: { color: 'rgba(148,163,184,0.18)' } },
axisLabel: { color: '#64748b' },
},
series: [
{
type: 'pie',
radius: ['54%', '74%'],
center: ['50%', '44%'],
itemStyle: {
borderRadius: 12,
borderColor: '#fff',
borderWidth: 4,
},
type: 'scatter',
data: data.map((item) => ({
...item,
value: [item.totalCount, item.completionRate],
symbolSize: 16 + (item.overdueCount / maxOverdue) * 34,
itemStyle: {
color:
item.completionRate >= 75
? '#22c55e'
: item.completionRate >= 45
? '#f59e0b'
: '#e11d48',
opacity: 0.85,
},
})),
label: {
show: true,
formatter: (params) => params.data.name,
position: 'top',
color: '#334155',
formatter: '{b|{b}}\n{c|{c} 条}',
rich: {
b: {
fontSize: 12,
fontWeight: 600,
color: '#334155',
},
c: {
fontSize: 11,
color: '#64748b',
},
},
fontSize: 12,
},
labelLine: {
lineStyle: {
color: '#cbd5e1',
},
},
],
graphic: [
{
type: 'text',
right: 14,
top: 8,
style: {
text: `气泡大小 = 逾期数,最大问题数 ${maxTotal}`,
fill: '#94a3b8',
fontSize: 12,
},
data,
},
],
};
}
function buildTrendAreaOption(progressItems = []) {
function buildTimelinessScatterOption(data = []) {
const maxValue = Math.max(
...data.flatMap((item) => [item.value?.[0] || 0, item.value?.[1] || 0]),
1,
);
return {
color: ['#e11d48'],
tooltip: {
trigger: 'axis',
formatter: (params) => {
const item = params.data;
return [
`<strong>${item.name}</strong>`,
`预计整改时长:${item.value[0]} 天`,
`实际整改时长:${item.value[1]} 天`,
`严重程度:${item.value[2]}`,
item.isDelayed ? '判定:已逾期' : '判定:按期或提前完成',
].join('<br/>');
},
backgroundColor: 'rgba(15,23,42,0.9)',
borderWidth: 0,
textStyle: { color: '#fff' },
},
grid: {
left: 8,
right: 18,
right: 16,
top: 16,
bottom: 10,
containLabel: true,
},
xAxis: {
type: 'category',
data: progressItems.map((item) => item.name),
boundaryGap: false,
axisLine: { lineStyle: { color: '#e2e8f0' } },
axisTick: { show: false },
type: 'value',
name: '预计整改时长(天)',
nameTextStyle: { color: '#64748b' },
splitLine: { lineStyle: { color: 'rgba(148,163,184,0.18)' } },
axisLabel: { color: '#64748b' },
},
yAxis: {
type: 'value',
splitLine: {
lineStyle: { color: 'rgba(148,163,184,0.18)' },
},
name: '实际整改时长(天)',
nameTextStyle: { color: '#64748b' },
splitLine: { lineStyle: { color: 'rgba(148,163,184,0.18)' } },
axisLabel: { color: '#64748b' },
},
series: [
{
type: 'scatter',
data: data.map((item) => ({
...item,
symbolSize: item.value[2] === '高' ? 18 : item.value[2] === '中' ? 14 : 10,
itemStyle: {
color: item.isDelayed ? '#e11d48' : '#0ea5e9',
opacity: 0.82,
},
})),
},
{
type: 'line',
data: progressItems.map((item) => item.value),
smooth: true,
symbol: 'circle',
symbolSize: 10,
data: [
[0, 0],
[maxValue, maxValue],
],
silent: true,
symbol: 'none',
lineStyle: {
width: 4,
color: '#94a3b8',
type: 'dashed',
width: 1.5,
},
},
],
};
}
function buildScoreBarOption(data, color) {
return {
color: [color],
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
backgroundColor: 'rgba(15,23,42,0.9)',
borderWidth: 0,
textStyle: { color: '#fff' },
},
grid: {
left: 8,
right: 20,
top: 12,
bottom: 8,
containLabel: true,
},
xAxis: {
type: 'value',
min: 0,
max: 3,
splitLine: { lineStyle: { color: 'rgba(148,163,184,0.18)' } },
axisLabel: { color: '#64748b' },
},
yAxis: {
type: 'category',
data: data.map((item) => item.name),
axisTick: { show: false },
axisLine: { show: false },
axisLabel: { color: '#334155', fontSize: 12 },
},
series: [
{
type: 'bar',
data: data.map((item) => item.value),
barWidth: 18,
showBackground: true,
backgroundStyle: {
color: 'rgba(148,163,184,0.08)',
borderRadius: 99,
},
itemStyle: {
borderRadius: [99, 99, 99, 99],
},
label: {
show: true,
position: 'right',
formatter: (params) => formatScore(params.value),
color: '#0f172a',
fontWeight: 600,
},
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(225,29,72,0.32)' },
{ offset: 1, color: 'rgba(225,29,72,0.04)' },
],
},
],
};
}
function buildHeatmapOption(heatmap) {
return {
tooltip: {
formatter: (params) =>
`严重程度 ${heatmap.xLabels[params.value[0]]}<br/>整改评级 ${heatmap.yLabels[params.value[1]]}<br/>问题数 ${params.value[2]}`,
backgroundColor: 'rgba(15,23,42,0.9)',
borderWidth: 0,
textStyle: { color: '#fff' },
},
grid: {
left: 8,
right: 16,
top: 12,
bottom: 10,
containLabel: true,
},
xAxis: {
type: 'category',
data: heatmap.xLabels,
splitArea: { show: true },
axisLabel: { color: '#334155' },
axisTick: { show: false },
axisLine: { show: false },
},
yAxis: {
type: 'category',
data: heatmap.yLabels,
splitArea: { show: true },
axisLabel: { color: '#334155' },
axisTick: { show: false },
axisLine: { show: false },
},
visualMap: {
min: 0,
max: Math.max(...heatmap.data.map((item) => item[2]), 1),
calculable: false,
orient: 'horizontal',
left: 'center',
bottom: 0,
textStyle: { color: '#64748b' },
inRange: {
color: ['#fef3c7', '#fb7185', '#be123c'],
},
},
series: [
{
type: 'heatmap',
data: heatmap.data,
label: {
show: true,
color: '#0f172a',
fontWeight: 600,
},
emphasis: {
itemStyle: {
shadowBlur: 12,
shadowColor: 'rgba(15,23,42,0.18)',
},
},
},
......@@ -307,6 +614,83 @@ function buildTrendAreaOption(progressItems = []) {
};
}
function buildRatingStackOption(data) {
return {
color: ['#fda4af', '#fbbf24', '#4ade80'],
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
backgroundColor: 'rgba(15,23,42,0.9)',
borderWidth: 0,
textStyle: { color: '#fff' },
formatter: (params) => {
const title = params[0]?.axisValueLabel || '';
const lines = params.map(
(item) => `${item.seriesName}: ${Number(item.value).toFixed(2)}%`,
);
return [title, ...lines].join('<br/>');
},
},
legend: {
top: 0,
textStyle: { color: '#475569' },
},
grid: {
left: 8,
right: 16,
top: 44,
bottom: 8,
containLabel: true,
},
xAxis: {
type: 'value',
max: 100,
axisLabel: {
color: '#64748b',
formatter: '{value}%',
},
splitLine: { lineStyle: { color: 'rgba(148,163,184,0.18)' } },
},
yAxis: {
type: 'category',
data: data.map((item) => item.name),
axisTick: { show: false },
axisLine: { show: false },
axisLabel: { color: '#334155', fontSize: 12 },
},
series: [
{
name: '低评级',
type: 'bar',
stack: 'rating',
barMaxWidth: 24,
data: data.map((item) => Number(item.lowPercent.toFixed(2))),
},
{
name: '中评级',
type: 'bar',
stack: 'rating',
barMaxWidth: 24,
data: data.map((item) => Number(item.mediumPercent.toFixed(2))),
},
{
name: '高评级',
type: 'bar',
stack: 'rating',
barMaxWidth: 24,
label: {
show: true,
position: 'right',
formatter: (params) => `${params.value}%`,
color: '#0f172a',
fontWeight: 600,
},
data: data.map((item) => Number(item.highPercent.toFixed(2))),
},
],
};
}
export default function ProblemRectificationDashboardPage() {
const [activeGroupKey, setActiveGroupKey] = useState('all');
const [dashboardData, setDashboardData] = useState({
......@@ -339,83 +723,101 @@ export default function ProblemRectificationDashboardPage() {
const summary = dashboardData.summaries?.[activeGroupKey] || {
totalCount: 0,
fixedCount: 0,
historyRate: 0,
currentYearRate: 0,
currentYearTotal: 0,
upcoming: [],
overdue: [],
feedbackUnitDistribution: [],
problemTypeDistribution: [],
completionRate: 0,
overdueCount: 0,
overdueRate: 0,
progressDistribution: [],
domainDistribution: [],
categoryTreemap: [],
rootCauseDistribution: [],
sourceComparison: { categories: [], series: [] },
focusIssueGroups: {
economicLoss: [],
externalImpact: [],
accountability: [],
},
overdueRanking: [],
departmentMatrix: [],
timelinessScatter: [],
timelinessScoreRanking: [],
qualityScoreRanking: [],
policyRevisionCount: 0,
systemConstructionCount: 0,
severityRatingHeatmap: {
xLabels: [],
yLabels: [],
data: [],
},
departmentRatingDistribution: [],
};
const feedbackChartOption = useMemo(
() =>
buildHorizontalBarOption(summary.feedbackUnitDistribution.slice(0, 6), [
'#ef4444',
'#f97316',
'#fb7185',
'#f59e0b',
]),
[summary.feedbackUnitDistribution],
const progressOption = useMemo(
() => buildDonutOption(summary.progressDistribution, CHART_COLOR_SET),
[summary.progressDistribution],
);
const problemTypeOption = useMemo(
() =>
buildHorizontalBarOption(summary.problemTypeDistribution.slice(0, 6), [
'#0ea5e9',
'#14b8a6',
'#38bdf8',
'#8b5cf6',
]),
[summary.problemTypeDistribution],
const categoryOption = useMemo(
() => buildTreemapOption(summary.categoryTreemap),
[summary.categoryTreemap],
);
const domainOption = useMemo(
() =>
buildDonutOption(summary.domainDistribution.slice(0, 6), [
'#f43f5e',
'#fb7185',
'#f97316',
'#f59e0b',
'#38bdf8',
'#22c55e',
]),
[summary.domainDistribution],
const rootCauseOption = useMemo(
() => buildDonutOption(summary.rootCauseDistribution, CHART_COLOR_SET),
[summary.rootCauseDistribution],
);
const progressTrendOption = useMemo(
() => buildTrendAreaOption(summary.progressDistribution),
[summary.progressDistribution],
const sourceComparisonOption = useMemo(
() => buildStackedSourceOption(summary.sourceComparison),
[summary.sourceComparison],
);
const overdueRankingOption = useMemo(
() => buildHorizontalBarOption(summary.overdueRanking, '#e11d48'),
[summary.overdueRanking],
);
const departmentMatrixOption = useMemo(
() => buildBubbleMatrixOption(summary.departmentMatrix),
[summary.departmentMatrix],
);
const timelinessScatterOption = useMemo(
() => buildTimelinessScatterOption(summary.timelinessScatter),
[summary.timelinessScatter],
);
const timelinessScoreOption = useMemo(
() => buildScoreBarOption(summary.timelinessScoreRanking, '#f59e0b'),
[summary.timelinessScoreRanking],
);
const qualityScoreOption = useMemo(
() => buildScoreBarOption(summary.qualityScoreRanking, '#22c55e'),
[summary.qualityScoreRanking],
);
const heatmapOption = useMemo(
() => buildHeatmapOption(summary.severityRatingHeatmap),
[summary.severityRatingHeatmap],
);
const ratingStackOption = useMemo(
() => buildRatingStackOption(summary.departmentRatingDistribution),
[summary.departmentRatingDistribution],
);
const activeGroup = groups.find((item) => item.key === activeGroupKey);
return (
<div className="space-y-6">
<div className="space-y-8">
<section className="relative overflow-hidden rounded-[34px] border border-white/80 bg-[linear-gradient(135deg,rgba(124,24,24,0.96),rgba(190,24,93,0.92)_54%,rgba(244,63,94,0.9))] px-6 py-7 text-white shadow-[0_28px_80px_rgba(159,18,57,0.24)]">
<div className="absolute inset-y-0 right-0 w-[36%] bg-[radial-gradient(circle_at_top_right,rgba(255,255,255,0.28),transparent_45%)]" />
<div className="absolute -bottom-16 left-1/3 h-40 w-40 rounded-full bg-white/10 blur-3xl" />
<div className="relative flex flex-wrap items-end justify-between gap-6">
<div className="max-w-3xl">
<div className="max-w-4xl">
<div className="text-xs font-semibold uppercase tracking-[0.28em] text-white/75">
Integrity Risk Dashboard
</div>
<h1 className="mt-3 text-3xl font-semibold tracking-tight md:text-[2.55rem]">
问题整改驾驶舱
</h1>
<div className="mt-4 max-w-3xl text-sm leading-6 text-white/78">
围绕整改完成进度、问题风险定位、部门责任压实和整改质效评估,统一展示今年问题整改全景。
</div>
</div>
<div className="rounded-[28px] border border-white/15 bg-white/10 px-5 py-4 backdrop-blur">
<div className="text-xs uppercase tracking-[0.22em] text-white/65">
当前分组
</div>
<div className="mt-2 text-2xl font-semibold">
{activeGroup?.label || '全部'}
</div>
<div className="mt-1 text-sm text-white/72">
{dayjs().format('YYYY 年 MM 月 DD 日')}
</div>
<div className="text-xs uppercase tracking-[0.22em] text-white/65">当前分组</div>
<div className="mt-2 text-2xl font-semibold">{activeGroup?.label || '全部'}</div>
<div className="mt-1 text-sm text-white/72">{dayjs().format('YYYY 年 MM 月 DD 日')}</div>
</div>
</div>
</section>
......@@ -454,101 +856,255 @@ export default function ProblemRectificationDashboardPage() {
{!loading && dashboardData.isEmpty ? (
<div className="rounded-[30px] border border-dashed border-zinc-300 bg-white/80 px-6 py-12 text-center">
<div className="text-xl font-semibold text-slate-900">
暂无问题整改数据
</div>
<div className="mt-3 text-sm leading-6 text-slate-500">
暂无记录。
</div>
<div className="text-xl font-semibold text-slate-900">暂无问题整改数据</div>
<div className="mt-3 text-sm leading-6 text-slate-500">当前没有可用于驾驶舱展示的整改记录。</div>
</div>
) : null}
{!loading && !dashboardData.isEmpty ? (
<>
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<StatCard
label="已整改问题"
value={formatCount(summary.fixedCount)}
hint="状态为已整改或已优化的问题数量"
tone="emerald"
/>
<StatCard
label="问题总数"
value={formatCount(summary.totalCount)}
hint="当前分组内全部问题记录"
tone="rose"
/>
<StatCard
label="历史累计整改率"
value={formatPercent(summary.historyRate)}
hint="已整改或已优化 / 问题总数"
tone="amber"
/>
<StatCard
label="本年度整改率"
value={formatPercent(summary.currentYearRate)}
hint={`按检查实施时间归属本年度,共 ${formatCount(summary.currentYearTotal)} 条`}
tone="sky"
<section className="space-y-5">
<SectionHeading
eyebrow="Overview"
title="宏观总览"
description="先看整体整改健康度,快速判断问题总量、闭环情况和逾期风险。"
/>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
<StatCard
label="问题总数"
value={formatCount(summary.totalCount)}
hint="当前分组内全部问题记录"
tone="rose"
/>
<StatCard
label="已完成整改数"
value={formatCount(summary.fixedCount)}
hint="整改进度为已整改或已优化"
tone="emerald"
/>
<StatCard
label="整改完成率"
value={formatPercent(summary.completionRate)}
hint="已完成整改数 / 总问题数"
tone="sky"
/>
<StatCard
label="逾期未完成数"
value={formatCount(summary.overdueCount)}
hint="截止今天已超期且未闭环"
tone="amber"
/>
<StatCard
label="逾期率"
value={formatPercent(summary.overdueRate)}
hint="逾期未完成数 / 总问题数"
tone="amber"
/>
</div>
<div className="grid gap-6 xl:grid-cols-[0.9fr_1.1fr]">
<Panel
title="整改进度分布"
description="按整改进度统计数量与占比,直观看当前积压主要落在哪个状态。"
>
{summary.progressDistribution.length > 0 ? (
<ReactECharts option={progressOption} style={{ height: 340 }} />
) : (
<EmptyBlock title="暂无整改进度分布数据" />
)}
</Panel>
<Panel
title="进度口径说明"
description="当前逾期判定规则采用“预计整改完成时间早于今天且尚未闭环”。如果后续上线显式“是否延期”字段,可直接切换为配置化口径。"
>
<div className="grid gap-4 md:grid-cols-2">
<NumberCard
label="已闭环问题"
value={formatCount(summary.fixedCount)}
hint="适合用于经营层快速判断整体闭环能力"
/>
<NumberCard
label="待重点督办"
value={formatCount(summary.overdueCount)}
hint="适合作为督办清单和预警入口"
/>
</div>
</Panel>
</div>
</section>
<section className="grid gap-6 xl:grid-cols-[1.15fr_0.85fr]">
<Panel
title="整改进度分布"
>
{summary.progressDistribution.length > 0 ? (
<ReactECharts option={progressTrendOption} style={{ height: 320 }} />
) : (
<EmptyBlock title="暂无整改进度分布数据" />
)}
</Panel>
<section className="space-y-5">
<SectionHeading
eyebrow="Risk"
title="问题分析与风险定位"
description="把问题集中区域、根因和来源差异拉出来,支撑制度修订和内审效能判断。"
/>
<div className="grid gap-6 xl:grid-cols-[1.05fr_0.95fr]">
<Panel
title="问题类型 / 业务领域分布"
description="按问题一级至四级分类构造矩形树图,帮助定位高发领域。"
>
{summary.categoryTreemap.length > 0 ? (
<ReactECharts option={categoryOption} style={{ height: 360 }} />
) : (
<EmptyBlock title="暂无问题领域统计数据" />
)}
</Panel>
<Panel
title="问题根本原因分析"
description="按问题原因分类统计,便于识别制度、流程或执行层面的共性成因。"
>
{summary.rootCauseDistribution.length > 0 ? (
<ReactECharts option={rootCauseOption} style={{ height: 360 }} />
) : (
<EmptyBlock title="暂无问题原因统计数据" />
)}
</Panel>
</div>
<Panel
title="涉及问题领域"
title="问题来源对比"
description="横轴为问题来源,堆叠展示检查实施单位,观察内外部检查发现问题的结构差异。"
>
{summary.domainDistribution.length > 0 ? (
<ReactECharts option={domainOption} style={{ height: 320 }} />
{summary.sourceComparison.categories.length > 0 ? (
<ReactECharts option={sourceComparisonOption} style={{ height: 360 }} />
) : (
<EmptyBlock title="暂无问题领域统计数据" />
<EmptyBlock title="暂无问题来源对比数据" />
)}
</Panel>
<div className="grid gap-6 xl:grid-cols-3">
<FocusIssueList
title="经济损失问题"
description="优先展示造成经济损失且严重程度更高的问题。"
items={summary.focusIssueGroups.economicLoss}
tone="rose"
/>
<FocusIssueList
title="外部影响问题"
description="直观看到涉及处罚机构或外部影响的问题。"
items={summary.focusIssueGroups.externalImpact}
tone="amber"
/>
<FocusIssueList
title="员工问责问题"
description="展示已触发员工问责后果的问题。"
items={summary.focusIssueGroups.accountability}
tone="sky"
/>
</div>
</section>
<section className="grid gap-6 xl:grid-cols-2">
<section className="space-y-5">
<SectionHeading
eyebrow="Execution"
title="问题责任追踪与执行"
description="围绕责任部门看执行难点,快速识别逾期堆积、完成率偏低和预估时长失真的对象。"
/>
<div className="grid gap-6 xl:grid-cols-[0.9fr_1.1fr]">
<Panel
title="逾期问题排行"
description="按责任部门统计逾期未完成问题数,用于识别长期挂账和重点督办对象。"
>
{summary.overdueRanking.length > 0 ? (
<ReactECharts option={overdueRankingOption} style={{ height: 340 }} />
) : (
<EmptyBlock title="当前没有逾期问题排行数据" />
)}
</Panel>
<Panel
title="部门整改责任矩阵"
description="X 轴为问题总数,Y 轴为整改完成率,气泡大小表示逾期问题数。"
>
{summary.departmentMatrix.length > 0 ? (
<ReactECharts option={departmentMatrixOption} style={{ height: 340 }} />
) : (
<EmptyBlock title="暂无部门责任矩阵数据" />
)}
</Panel>
</div>
<Panel
title="反馈单位分布"
title="整改时效分析"
description="X 轴为预计整改时长,Y 轴为实际整改时长。虚线右上方的点代表实际时长高于预估。"
>
{summary.feedbackUnitDistribution.length > 0 ? (
<ReactECharts option={feedbackChartOption} style={{ height: 340 }} />
{summary.timelinessScatter.length > 0 ? (
<ReactECharts option={timelinessScatterOption} style={{ height: 380 }} />
) : (
<EmptyBlock title="暂无反馈单位分布数据" />
<EmptyBlock title="缺少预计整改时间、实际整改完成时间或检查完成日期,暂无法形成时效散点图" />
)}
</Panel>
</section>
<section className="space-y-5">
<SectionHeading
eyebrow="Quality"
title="审计整改质效"
description="从整改时间评估、材料质量、制度与系统助力,以及风险和整改评级匹配度四个角度审视整改成效。"
/>
<div className="grid gap-6 xl:grid-cols-2">
<Panel
title="问题整改时效"
description="按责任部门计算整改时间评估平均分,低=1,中=2,高=3。"
>
{summary.timelinessScoreRanking.length > 0 ? (
<ReactECharts option={timelinessScoreOption} style={{ height: 360 }} />
) : (
<EmptyBlock title="暂无整改时间评估数据" />
)}
</Panel>
<Panel
title="问题整改质量"
description="按责任部门计算整改材料质量平均分,便于识别形式整改和敷衍整改。"
>
{summary.qualityScoreRanking.length > 0 ? (
<ReactECharts option={qualityScoreOption} style={{ height: 360 }} />
) : (
<EmptyBlock title="暂无整改质量评估数据" />
)}
</Panel>
</div>
<div className="grid gap-6 xl:grid-cols-[0.8fr_1.2fr]">
<Panel
title="问题整改助力总结"
description="统计本年问题整改直接助力制度修订和系统建设的记录数量。"
>
<div className="grid gap-4 md:grid-cols-2">
<NumberCard
label="助力制度修订"
value={formatCount(summary.policyRevisionCount)}
hint="标记为涉及制度修订的问题数"
/>
<NumberCard
label="助力系统建设"
value={formatCount(summary.systemConstructionCount)}
hint="标记为涉及系统建设的问题数"
/>
</div>
</Panel>
<Panel
title="综合整改评定等级 vs 问题综合严重程度"
description="观察重大问题是否得到了相匹配的整改投入和结果。"
>
{summary.severityRatingHeatmap.data.length > 0 ? (
<ReactECharts option={heatmapOption} style={{ height: 360 }} />
) : (
<EmptyBlock title="暂无严重程度与整改评级对照数据" />
)}
</Panel>
</div>
<Panel
title="问题类型排行"
title="部门综合整改评定等级排行"
description="按高评级占比降序展示各责任部门低、中、高评级结构,便于横向比较整改质量。"
>
{summary.problemTypeDistribution.length > 0 ? (
<ReactECharts option={problemTypeOption} style={{ height: 340 }} />
{summary.departmentRatingDistribution.length > 0 ? (
<ReactECharts option={ratingStackOption} style={{ height: 420 }} />
) : (
<EmptyBlock title="暂无问题类型分布数据" />
<EmptyBlock title="暂无部门整改评级分布数据" />
)}
</Panel>
</section>
<ProblemListCard
title="临近整改问题"
description="预计整改完成时间在未来 1 个月内。"
items={summary.upcoming.slice(0, 6)}
emptyText="当前没有临近整改问题"
/>
<ProblemListCard
title="整改逾期问题"
description="预计整改完成时间早于今天。"
items={summary.overdue.slice(0, 6)}
emptyText="当前没有整改逾期问题"
/>
</>
) : null}
</div>
......
import { Button } from '@/catalyst-ui-kit/javascript/button';
import { Checkbox } from '@/catalyst-ui-kit/javascript/checkbox';
import {
Dialog,
DialogActions,
......@@ -9,15 +8,8 @@ import {
} from '@/catalyst-ui-kit/javascript/dialog';
import { Input } from '@/catalyst-ui-kit/javascript/input';
import { Select } from '@/catalyst-ui-kit/javascript/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/catalyst-ui-kit/javascript/table';
import { Textarea } from '@/catalyst-ui-kit/javascript/textarea';
import RecordWorkbench from '@/components/record-workbench';
import RecordDetailDrawer from '@/pages/RecordDetailDrawer';
import {
batchRemoveRecords,
......@@ -53,9 +45,6 @@ import {
loadProblemRectificationListResources,
} from '../problemRectificationListService';
const SELECTION_COLUMN_WIDTH = 56;
const ACTION_COLUMN_WIDTH = 132;
const FIELD_HINTS = {
inspect_the_implementing_unit: '按对象配置的实施单位选项选择',
rectification_progress: '状态选项来自对象配置',
......@@ -64,103 +53,44 @@ const FIELD_HINTS = {
feedback_unit: '反馈单位选项来自对象配置',
responsible_department: '可多选责任部门',
the_time_of_rectification: '提交时会自动转成 ISO 8601 时间',
has_economic_loss: '建议统一填写“是”或“否”',
economic_loss_amount: '建议填写数字金额,单位可在备注中说明',
has_external_impact: '建议统一填写“是”或“否”',
has_employee_accountability: '建议统一填写“是”或“否”',
accountability_person_count: '建议填写数字人数',
problem_severity_level: '建议统一填写“高 / 中 / 低”',
rectification_timeliness_level: '建议统一填写“高 / 中 / 低”',
rectification_material_quality_level: '建议统一填写“高 / 中 / 低”',
rectification_overall_rating: '建议统一填写“高 / 中 / 低”',
involves_policy_revision: '建议统一填写“是”或“否”',
involves_system_construction: '建议统一填写“是”或“否”',
is_historical_problem: '建议统一填写“是”或“否”',
is_repeated_problem: '建议统一填写“是”或“否”',
actual_rectification_completed_at: '建议使用 YYYY-MM-DD',
inspection_completed_at: '建议使用 YYYY-MM-DD',
};
const CREATE_FIELD_TYPE_OVERRIDES = {
economic_loss_amount: 'number',
accountability_person_count: 'number',
actual_rectification_completed_at: 'date',
inspection_completed_at: 'date',
};
const CREATE_FIELD_OPTION_OVERRIDES = {
has_economic_loss: ['是', '否'],
has_external_impact: ['是', '否'],
has_employee_accountability: ['是', '否'],
problem_severity_level: ['高', '中', '低'],
problem_category_level_1: ['内控建设', '内控运行', '内控建设及运行', '其他'],
problem_category_level_2: ['公司治理', '投资管理', '销售管理', '员工行为管理', '基金运营', '财务管理', '信息技术'],
problem_root_cause: ['内外规执行不到位', '系统化程度低', '规矩意识淡薄导致操作风险', '现有流程设计缺陷', '制度建设有待完善'],
rectification_timeliness_level: ['高', '中', '低'],
rectification_material_quality_level: ['高', '中', '低'],
involves_policy_revision: ['是', '否'],
involves_system_construction: ['是', '否'],
is_historical_problem: ['是', '否'],
is_repeated_problem: ['是', '否'],
rectification_overall_rating: ['高', '中', '低'],
};
function getPinnedSide(column) {
const pinnedValue = column?.lockPosition || column?.pinned;
if (pinnedValue === 'left' || pinnedValue === 'right') {
return pinnedValue;
}
return null;
}
function buildStickyColumnMeta(columns = []) {
const leftColumns = columns.filter((column) => getPinnedSide(column) === 'left');
const centerColumns = columns.filter((column) => !getPinnedSide(column));
const rightColumns = columns.filter((column) => getPinnedSide(column) === 'right');
const orderedColumns = [...leftColumns, ...centerColumns, ...rightColumns];
let leftOffset = SELECTION_COLUMN_WIDTH;
let rightOffset = ACTION_COLUMN_WIDTH;
const stickyMeta = {};
leftColumns.forEach((column) => {
stickyMeta[column.field] = {
side: 'left',
offset: leftOffset,
isEdge: column.field === leftColumns[leftColumns.length - 1]?.field,
};
leftOffset += column.width || 180;
});
[...rightColumns].reverse().forEach((column) => {
stickyMeta[column.field] = {
side: 'right',
offset: rightOffset,
isEdge: column.field === rightColumns[0]?.field,
};
rightOffset += column.width || 180;
});
return {
orderedColumns,
stickyMeta,
};
}
function getStickyCellClass(meta, kind = 'body') {
if (!meta) {
return '';
}
const baseClass =
kind === 'header'
? 'sticky z-30 bg-zinc-50/95 backdrop-blur'
: 'sticky z-20 bg-white';
const sideClass = meta.side === 'left' ? 'left-0' : 'right-0';
const edgeShadow =
meta.side === 'left' && meta.isEdge
? 'shadow-[10px_0_20px_rgba(15,23,42,0.06)]'
: meta.side === 'right' && meta.isEdge
? 'shadow-[-10px_0_20px_rgba(15,23,42,0.06)]'
: '';
return clsx(baseClass, sideClass, edgeShadow);
}
function SummaryCard({ label, value, hint, tone = 'default' }) {
const toneClass =
tone === 'rose'
? 'border-rose-200/80 bg-rose-50/85'
: tone === 'amber'
? 'border-amber-200/80 bg-amber-50/85'
: tone === 'emerald'
? 'border-emerald-200/80 bg-emerald-50/85'
: 'border-zinc-200/80 bg-white/88';
return (
<div className={clsx('rounded-[26px] border p-5 shadow-sm ring-1 ring-zinc-950/5', toneClass)}>
<div className="text-xs font-semibold uppercase tracking-[0.22em] text-slate-400">
{label}
</div>
<div className="mt-3 text-3xl font-semibold tracking-tight text-slate-950">
{value}
</div>
<div className="mt-2 text-sm text-slate-500">{hint}</div>
</div>
);
}
function EmptyState({ title, description }) {
return (
<div className="rounded-[28px] border border-dashed border-zinc-300 bg-white/80 px-6 py-14 text-center">
<div className="text-xl font-semibold text-slate-900">{title}</div>
<div className="mt-3 text-sm leading-6 text-slate-500">{description}</div>
</div>
);
}
function StatusPill({ text, tone = 'default' }) {
const toneClass =
......@@ -202,6 +132,26 @@ function resolveAuditTone(status = '') {
return 'default';
}
function resolveLevelTone(value = '', invert = false) {
if (!value) {
return 'default';
}
if (value === '高') {
return invert ? 'emerald' : 'rose';
}
if (value === '中') {
return 'amber';
}
if (value === '低') {
return invert ? 'rose' : 'emerald';
}
return 'default';
}
function SelectedUsers({ users, onRemove }) {
if (users.length === 0) {
return (
......@@ -336,8 +286,9 @@ function RemoteUserSelect({ label, value, onChange, placeholder, onSearch }) {
function CreateField({ fieldDef, value, onChange }) {
const options = getSelectOptions(fieldDef);
const isMultiple = Boolean(fieldDef?.multiple);
const fieldType = fieldDef?.type || 'text';
const fieldType = CREATE_FIELD_TYPE_OVERRIDES[fieldDef?.name] || fieldDef?.type || 'text';
const placeholder = FIELD_HINTS[fieldDef?.name] || `请输入${fieldDef?.label || fieldDef?.name}`;
const overrideOptions = CREATE_FIELD_OPTION_OVERRIDES[fieldDef?.name] || [];
if (fieldType === 'long_text') {
return (
......@@ -377,6 +328,19 @@ function CreateField({ fieldDef, value, onChange }) {
);
}
if (overrideOptions.length > 0) {
return (
<Select value={value || ''} onChange={(event) => onChange(event.target.value)}>
<option value="">请选择</option>
{overrideOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</Select>
);
}
return (
<Input
type={fieldType === 'number' ? 'number' : 'text'}
......@@ -392,12 +356,68 @@ function FieldValuePreview({ column, record }) {
const rawRecord = record.raw || record;
const value = getFieldValue(rawRecord, column.field);
if (fieldName === 'title') {
const tags = [
record.severityLevel ? { label: `严重程度 ${record.severityLevel}`, tone: resolveLevelTone(record.severityLevel) } : null,
record.overallRating ? { label: `整改评级 ${record.overallRating}`, tone: resolveLevelTone(record.overallRating, true) } : null,
record.primaryResponsibleDepartment
? { label: `主责 ${record.primaryResponsibleDepartment}`, tone: 'default' }
: null,
record.isOverdue ? { label: '已逾期', tone: 'rose' } : null,
record.hasEconomicLoss || record.hasExternalImpact || record.hasEmployeeAccountability
? { label: '重点后果问题', tone: 'amber' }
: null,
].filter(Boolean);
return (
<div className="space-y-2">
<div className="font-medium text-slate-900">{record.title || '(无标题)'}</div>
{tags.length > 0 ? (
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<StatusPill key={tag.label} text={tag.label} tone={tag.tone} />
))}
</div>
) : null}
</div>
);
}
if (fieldName === 'rectification_progress') {
return <StatusPill text={formatFieldValue(value, column.fieldDef) || '未填写'} tone={resolveProgressTone(formatFieldValue(value, column.fieldDef) || '')} />;
const text = formatFieldValue(value, column.fieldDef) || '未填写';
return <StatusPill text={text} tone={resolveProgressTone(text)} />;
}
if (fieldName === 'audit_status' || fieldName === 'final_audit_status') {
return <StatusPill text={formatFieldValue(value, column.fieldDef) || '未填写'} tone={resolveAuditTone(formatFieldValue(value, column.fieldDef) || '')} />;
const text = formatFieldValue(value, column.fieldDef) || '未填写';
return <StatusPill text={text} tone={resolveAuditTone(text)} />;
}
if (
fieldName === 'problem_severity_level' ||
fieldName === 'rectification_timeliness_level' ||
fieldName === 'rectification_material_quality_level'
) {
const text = formatFieldValue(value, column.fieldDef) || '未填写';
return <StatusPill text={text} tone={resolveLevelTone(text)} />;
}
if (fieldName === 'rectification_overall_rating') {
const text = formatFieldValue(value, column.fieldDef) || '未填写';
return <StatusPill text={text} tone={resolveLevelTone(text, true)} />;
}
if (
fieldName === 'has_economic_loss' ||
fieldName === 'has_external_impact' ||
fieldName === 'has_employee_accountability' ||
fieldName === 'involves_policy_revision' ||
fieldName === 'involves_system_construction' ||
fieldName === 'is_historical_problem' ||
fieldName === 'is_repeated_problem'
) {
const text = formatFieldValue(value, column.fieldDef) || '否';
return <StatusPill text={text} tone={text === '是' ? 'amber' : 'default'} />;
}
if (fieldName === 'fill_in_the_people' || fieldName === 'review_people') {
......@@ -460,6 +480,12 @@ export default function ProblemRectificationListPage() {
keyword: '',
progress: '',
auditStatus: '',
severityLevel: '',
overallRating: '',
primaryResponsibleDepartment: '',
problemFrom: '',
rootCause: '',
focusFlag: '',
});
const [activeViewName, setActiveViewName] = useState('');
const [selectedIds, setSelectedIds] = useState([]);
......@@ -468,8 +494,6 @@ export default function ProblemRectificationListPage() {
const [formValues, setFormValues] = useState({});
const [fillerUsers, setFillerUsers] = useState([]);
const [auditorUsers, setAuditorUsers] = useState([]);
const [drawerRecordName, setDrawerRecordName] = useState('');
const [drawerDirty, setDrawerDirty] = useState(false);
const reloadList = useCallback(async () => {
setLoading(true);
......@@ -518,11 +542,6 @@ export default function ProblemRectificationListPage() {
[records, filters, activeGridView, subject],
);
const stickyColumns = useMemo(
() => buildStickyColumnMeta(configuredView.columns || []),
[configuredView.columns],
);
const gridViewCounts = useMemo(
() => buildGridViewCountMap(records, filters, gridViews, subject),
[records, filters, gridViews, subject],
......@@ -558,41 +577,20 @@ export default function ProblemRectificationListPage() {
const fixedCount = filteredRecords.filter(
(item) => item.progress === '已整改' || item.progress === '已优化',
).length;
const highSeverityCount = filteredRecords.filter((item) => item.severityLevel === '高').length;
const consequenceCount = filteredRecords.filter(
(item) => item.hasEconomicLoss || item.hasExternalImpact || item.hasEmployeeAccountability,
).length;
const repeatedOrHistoricalCount = filteredRecords.filter(
(item) => item.isHistoricalProblem || item.isRepeatedProblem,
).length;
const overdueCount = filteredRecords.filter((item) => item.isOverdue).length;
const lowRatingCount = filteredRecords.filter((item) => item.overallRating === '低').length;
const pendingAuditCount = filteredRecords.filter(
(item) => item.auditStatus && item.auditStatus !== '已审批',
).length;
const toggleAll = (checked) => {
if (checked) {
setSelectedIds(filteredRecords.map((item) => item.id));
return;
}
setSelectedIds([]);
};
const toggleRow = (recordId, checked) => {
if (checked) {
setSelectedIds((current) => Array.from(new Set([...current, recordId])));
return;
}
setSelectedIds((current) => current.filter((item) => item !== recordId));
};
const handleOpenDrawer = (record) => {
setDrawerDirty(false);
setDrawerRecordName(record.name || record.id);
};
const handleCloseDrawer = async () => {
setDrawerRecordName('');
if (drawerDirty) {
setDrawerDirty(false);
await reloadList();
}
};
const handleCreate = async () => {
if (
!normalizeText(formValues.project_name) &&
......@@ -768,270 +766,235 @@ export default function ProblemRectificationListPage() {
}
};
return (
<div className="space-y-6 pb-4">
<section className="grid gap-4 md:grid-cols-3">
<SummaryCard
label="当前视图记录数"
value={filteredRecords.length}
hint={activeGridView?.title ? `${activeGridView.title} 视图` : '筛选结果'}
tone="rose"
/>
<SummaryCard
label="已整改 / 已优化"
value={fixedCount}
hint="按当前视图与筛选统计"
tone="emerald"
/>
<SummaryCard
label="待复核项"
value={pendingAuditCount}
hint="审批状态未通过的记录"
tone="amber"
/>
</section>
<section className="rounded-[30px] border border-white/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.96),rgba(250,250,250,0.92))] p-3 shadow-sm ring-1 ring-zinc-950/5">
<div className="flex flex-wrap gap-2">
{gridViews.map((view) => {
const active = view.name === activeGridView?.name;
return (
<button
key={view.name}
type="button"
onClick={() => {
setActiveViewName(view.name);
setSelectedIds([]);
}}
className={clsx(
'inline-flex min-h-11 items-center gap-2 rounded-full px-4 py-2.5 text-sm font-medium transition',
active
? 'bg-[linear-gradient(135deg,#7f1d1d,#be123c)] text-white shadow-lg shadow-rose-200/60'
: 'bg-white text-slate-600 ring-1 ring-zinc-200 hover:bg-rose-50 hover:text-rose-700',
)}
>
<span>{view.title || view.name}</span>
<span
className={clsx(
'rounded-full px-2 py-0.5 text-xs',
active ? 'bg-white/20 text-white' : 'bg-zinc-100 text-slate-500',
)}
>
{gridViewCounts[view.name] || 0}
</span>
</button>
);
})}
</div>
</section>
<section className="rounded-[30px] border border-zinc-200 bg-white/95 p-5 shadow-sm ring-1 ring-zinc-950/5">
<div className="grid gap-4 xl:grid-cols-[1.4fr_0.8fr_0.8fr_auto]">
<div>
<label className="mb-2 block text-sm font-medium text-slate-700">关键词搜索</label>
<Input
value={filters.keyword}
onChange={(event) => setFilters((current) => ({ ...current, keyword: event.target.value }))}
placeholder="搜索当前视图中的项目名称、状态、问题简述、责任人..."
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-slate-700">整改进度</label>
<Select
value={filters.progress}
onChange={(event) => setFilters((current) => ({ ...current, progress: event.target.value }))}
>
<option value="">全部进度</option>
{configuredView.progressOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</Select>
</div>
const summaryItems = [
{
label: '当前视图记录数',
value: filteredRecords.length,
hint: activeGridView?.title ? `${activeGridView.title} 视图` : '筛选结果',
tone: 'rose',
},
{
label: '高严重程度',
value: highSeverityCount,
hint: '问题综合严重程度 = 高',
tone: 'rose',
},
{
label: '重点后果问题',
value: consequenceCount,
hint: '含经济损失 / 外部影响 / 员工问责',
tone: 'amber',
},
{
label: '往期或重复问题',
value: repeatedOrHistoricalCount,
hint: '往期发现或再次出现的问题',
tone: 'amber',
},
{
label: '已逾期未完成',
value: overdueCount,
hint: '预计整改完成时间已到且未闭环',
tone: 'rose',
},
{
label: '整改评级偏低',
value: lowRatingCount,
hint: '综合整改评定等级 = 低',
tone: 'rose',
},
{
label: '已整改 / 已优化',
value: fixedCount,
hint: '按当前视图与筛选统计',
tone: 'emerald',
},
{
label: '待复核项',
value: pendingAuditCount,
hint: '审批状态未通过的记录',
tone: 'amber',
},
];
<div>
<label className="mb-2 block text-sm font-medium text-slate-700">审批状态</label>
<Select
value={filters.auditStatus}
onChange={(event) => setFilters((current) => ({ ...current, auditStatus: event.target.value }))}
>
<option value="">全部状态</option>
{configuredView.auditStatusOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</Select>
</div>
<div className="flex items-end">
<Button
outline
className="w-full justify-center"
onClick={() => {
setFilters({
keyword: '',
progress: '',
auditStatus: '',
});
setSelectedIds([]);
}}
>
清空筛选
</Button>
</div>
</div>
</section>
<section className="overflow-hidden rounded-[32px] border border-white/80 bg-white/96 shadow-[0_18px_48px_rgba(15,23,42,0.08)] ring-1 ring-zinc-950/5">
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-zinc-100 px-6 py-5">
<div>
<div className="text-lg font-semibold text-slate-900">问题清单</div>
<div className="mt-1 text-sm text-slate-500">
当前视图显示 {filteredRecords.length} 条记录,已选中 {selectedIds.length} 条。
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button color="rose" onClick={() => setShowCreateModal(true)}>
新增记录
</Button>
<Button outline disabled={selectedIds.length === 0 || actionLoading} onClick={() => setShowPushModal(true)}>
推送整改表
</Button>
<Button outline disabled>
导出
</Button>
<Button outline disabled>
一键归档
</Button>
<Button color="red" disabled={selectedIds.length === 0 || actionLoading} onClick={() => handleDelete(selectedRecords)}>
删除选中
</Button>
</div>
</div>
{error ? (
<div className="border-b border-zinc-100 bg-rose-50/70 px-6 py-4 text-sm text-rose-700">
{error}
</div>
) : null}
{loading ? (
<div className="px-6 py-6">
<EmptyState title="正在加载问题整改列表" description="正在读取对象结构、视图配置和记录数据,请稍候。" />
</div>
) : filteredRecords.length === 0 ? (
<div className="px-6 py-6">
<EmptyState
title="当前视图暂无数据"
description="可以切换其他视图,或调整关键词、审批状态和整改进度筛选。"
return (
<>
<RecordWorkbench
summaryItems={summaryItems}
views={gridViews.map((view) => ({
...view,
count: gridViewCounts[view.name] || 0,
}))}
activeViewName={activeGridView?.name || ''}
onViewChange={(viewName) => {
setActiveViewName(viewName);
setSelectedIds([]);
}}
filters={filters}
filterSchema={[
{
key: 'keyword',
label: '关键词搜索',
type: 'text',
placeholder: '搜索项目名称、严重程度、分类、责任部门、问题原因...',
},
{
key: 'progress',
label: '整改进度',
type: 'select',
placeholder: '全部进度',
options: configuredView.progressOptions.map((option) => ({ label: option, value: option })),
},
{
key: 'auditStatus',
label: '审批状态',
type: 'select',
placeholder: '全部状态',
options: configuredView.auditStatusOptions.map((option) => ({ label: option, value: option })),
},
{
key: 'severityLevel',
label: '严重程度',
type: 'select',
placeholder: '全部严重程度',
options: configuredView.severityOptions.map((option) => ({ label: option, value: option })),
},
{
key: 'overallRating',
label: '整改评级',
type: 'select',
placeholder: '全部评级',
options: configuredView.overallRatingOptions.map((option) => ({ label: option, value: option })),
},
{
key: 'primaryResponsibleDepartment',
label: '主责部门',
type: 'select',
placeholder: '全部主责部门',
options: configuredView.primaryResponsibleDepartmentOptions.map((option) => ({
label: option,
value: option,
})),
},
{
key: 'problemFrom',
label: '问题来源',
type: 'select',
placeholder: '全部来源',
options: configuredView.problemFromOptions.map((option) => ({ label: option, value: option })),
},
{
key: 'rootCause',
label: '问题原因',
type: 'select',
placeholder: '全部原因',
options: configuredView.rootCauseOptions.map((option) => ({ label: option, value: option })),
},
{
key: 'focusFlag',
label: '重点关注',
type: 'select',
placeholder: '全部类型',
options: configuredView.focusOptions,
},
]}
onFiltersChange={(key, value) => setFilters((current) => ({ ...current, [key]: value }))}
onClearFilters={() => {
setFilters({
keyword: '',
progress: '',
auditStatus: '',
severityLevel: '',
overallRating: '',
primaryResponsibleDepartment: '',
problemFrom: '',
rootCause: '',
focusFlag: '',
});
setSelectedIds([]);
}}
title="问题清单"
description={`当前视图显示 ${filteredRecords.length} 条记录,已选中 ${selectedIds.length} 条,其中重点后果问题 ${consequenceCount} 条、逾期未完成 ${overdueCount} 条。`}
columns={configuredView.columns || []}
records={filteredRecords}
selectedIds={selectedIds}
onSelectionChange={setSelectedIds}
toolbarActions={[
{
key: 'create',
label: '新增记录',
variant: 'solid',
tone: 'primary',
onClick: () => setShowCreateModal(true),
},
{
key: 'push',
label: '推送整改表',
variant: 'outline',
disabled: () => selectedIds.length === 0 || actionLoading,
onClick: () => setShowPushModal(true),
},
{
key: 'export',
label: '导出',
variant: 'outline',
disabled: true,
onClick: () => {},
},
{
key: 'archive',
label: '一键归档',
variant: 'outline',
disabled: true,
onClick: () => {},
},
{
key: 'delete-selected',
label: '删除选中',
variant: 'solid',
tone: 'danger',
disabled: () => selectedIds.length === 0 || actionLoading,
onClick: () => handleDelete(selectedRecords),
},
]}
rowActions={[
{
key: 'view-detail',
label: '查看详情',
variant: 'plain',
onClick: ({ openDetail, record }) => openDetail(record),
},
{
key: 'delete',
label: '删除',
variant: 'outline',
tone: 'danger',
onClick: ({ record }) => handleDelete([record]),
},
]}
detailPanel={{
getRecordName: (record) => record.name || record.id,
onDirtyClose: reloadList,
render: ({ open, recordName, onClose, onDataChange }) => (
<RecordDetailDrawer
open={open}
recordName={recordName}
onClose={onClose}
onDataChange={onDataChange}
preferredViewName={activeGridView?.title || activeGridView?.name}
/>
</div>
) : (
<div className="px-3 pb-3 pt-2">
<Table bleed dense className="[--gutter:--spacing(3)]">
<TableHead>
<TableRow className="bg-zinc-50/80">
<TableHeader
className="w-14 sticky left-0 z-40 bg-zinc-50/95 backdrop-blur shadow-[10px_0_20px_rgba(15,23,42,0.06)]"
style={{ width: SELECTION_COLUMN_WIDTH, minWidth: SELECTION_COLUMN_WIDTH }}
>
<div className="flex justify-center">
<Checkbox
checked={filteredRecords.length > 0 && selectedIds.length === filteredRecords.length}
onChange={toggleAll}
color="rose"
aria-label="全选当前视图记录"
/>
</div>
</TableHeader>
{stickyColumns.orderedColumns.map((column) => {
const stickyMeta = stickyColumns.stickyMeta[column.field];
return (
<TableHeader
key={column.field}
style={{
width: column.width || 180,
minWidth: Math.min(Math.max(column.width || 180, 150), 360),
left: stickyMeta?.side === 'left' ? `${stickyMeta.offset}px` : undefined,
right: stickyMeta?.side === 'right' ? `${stickyMeta.offset}px` : undefined,
}}
className={clsx(
'text-xs font-semibold uppercase tracking-[0.18em] text-slate-400',
getStickyCellClass(stickyMeta, 'header'),
)}
>
{column.label}
</TableHeader>
);
})}
<TableHeader
className="sticky right-0 z-40 bg-zinc-50/95 text-xs font-semibold uppercase tracking-[0.18em] text-slate-400 shadow-[-10px_0_20px_rgba(15,23,42,0.06)] backdrop-blur"
style={{ width: ACTION_COLUMN_WIDTH, minWidth: ACTION_COLUMN_WIDTH }}
>
操作
</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{filteredRecords.map((record) => (
<TableRow key={record.id} className="align-top">
<TableCell
className="align-top sticky left-0 z-30 bg-white shadow-[10px_0_20px_rgba(15,23,42,0.05)]"
style={{ width: SELECTION_COLUMN_WIDTH, minWidth: SELECTION_COLUMN_WIDTH }}
>
<div className="flex justify-center pt-1">
<Checkbox
checked={selectedIds.includes(record.id)}
onChange={(checked) => toggleRow(record.id, checked)}
color="rose"
aria-label={`选择 ${record.title || record.projectName || '当前记录'}`}
/>
</div>
</TableCell>
{stickyColumns.orderedColumns.map((column) => {
const stickyMeta = stickyColumns.stickyMeta[column.field];
return (
<TableCell
key={column.field}
style={{
width: column.width || 180,
minWidth: Math.min(Math.max(column.width || 180, 150), 360),
left: stickyMeta?.side === 'left' ? `${stickyMeta.offset}px` : undefined,
right: stickyMeta?.side === 'right' ? `${stickyMeta.offset}px` : undefined,
}}
className={clsx('align-top', getStickyCellClass(stickyMeta))}
>
<FieldValuePreview column={column} record={record} />
</TableCell>
);
})}
<TableCell
className="align-top sticky right-0 z-30 bg-white shadow-[-10px_0_20px_rgba(15,23,42,0.05)]"
style={{ width: ACTION_COLUMN_WIDTH, minWidth: ACTION_COLUMN_WIDTH }}
>
<div className="flex min-w-28 flex-col gap-2">
<Button plain onClick={() => handleOpenDrawer(record)}>
查看详情
</Button>
<Button outline color="red" onClick={() => handleDelete([record])}>
删除
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</section>
),
}}
states={{
loading,
error,
loadingTitle: '正在加载问题整改列表',
loadingDescription: '正在读取对象结构、视图配置和记录数据,请稍候。',
emptyTitle: '当前视图暂无数据',
emptyDescription: '可以切换其他视图,或调整严重程度、整改评级、主责部门和重点关注筛选。',
}}
renderCell={({ record, column }) => <FieldValuePreview record={record} column={column} />}
/>
<Dialog open={showCreateModal} onClose={handleCloseCreateModal} size="5xl">
<DialogTitle>新增问题整改记录</DialogTitle>
......@@ -1041,7 +1004,7 @@ export default function ProblemRectificationListPage() {
</DialogDescription>
<DialogBody>
<div className="rounded-[24px] border border-zinc-100 bg-zinc-50/60 px-4 py-3 text-sm text-slate-500">
至少填写“检查项目名称”或“问题简述”之一。当前只展示本视图所关心的字段,减少一次性录入负担。
至少填写“检查项目名称”或“问题简述”之一。基础字段沿视图顺序渲染,新增的严重程度、分类、原因和整改评估字段会自动补充到表单末尾,便于后续驾驶舱统计。
</div>
<div className="mt-6 grid gap-5 md:grid-cols-2">
......@@ -1153,16 +1116,6 @@ export default function ProblemRectificationListPage() {
</DialogActions>
</Dialog>
) : null}
{drawerRecordName ? (
<RecordDetailDrawer
open={Boolean(drawerRecordName)}
recordName={drawerRecordName}
setCustomComponentClose={handleCloseDrawer}
onDataChange={() => setDrawerDirty(true)}
preferredViewName={activeGridView?.title || activeGridView?.name}
/>
) : null}
</div>
</>
);
}
......@@ -64,6 +64,13 @@
因此前端新增字段展示、部门视图调整、列顺序变化时,应优先改 Subject View 配置,而不是直接修改页面常量。
当前项目实现约定补充:
- `problem_rectification` 与 `issue_rectification_archive` 列表页统一复用 `RecordWorkbench` 作为“结构化表格 + 工具栏 + 详情抽屉入口”的工作台容器
- 页面本身只负责提供 subject 对应的数据加载、统计口径、筛选 schema、行操作与批量操作
- 详情侧栏统一复用 `RecordDetailDrawer`,通过 `recordName`、`subjectName`、`preferredViewName` 驱动,不再在各页面重复实现抽屉骨架
- 新增同类列表页时,优先复用工作台与详情抽屉组合,避免再次复制问题整改页 JSX
### 3.1 目标页面拆分
为便于当前项目直接重构,建议把模块拆成以下 4 个页面入口:
......@@ -229,8 +236,20 @@
- 问题类型
- 反馈单位
- 问题涉及业务领域
3. 可预先填写预计整改完成时间
4. 初始时可不指定填报人、复核人
3. 根据 2026 年整改驾驶舱要求,补录扩展治理字段:
- 问题后果:是否造成经济损失、对应经济损失、是否造成外部影响、处罚机构、是否涉及员工问责、问责人数
- 问题画像:问题综合严重程度、问题一级至四级分类、问题原因
- 整改质效:整改时间评估、整改证明材料质量、是否涉及制度修订、制度名称、是否涉及系统建设、系统名称、是否为往期发现问题、问题是否重复出现、综合整改评定等级
- 责任识别:主责部门、实际整改完成时间、实际检查完成日期
4. 可预先填写预计整改完成时间
5. 初始时可不指定填报人、复核人
推荐新交互:
- 工作台筛选区增加严重程度、整改评级、主责部门、问题来源、问题原因、重点关注等维度
- 列表标题列增加风险标签,优先暴露“高严重程度”“重点后果问题”“已逾期”等重点状态
- 新增弹窗除基础字段外,还应补充严重程度、分类、原因和整改质效字段,避免驾驶舱后续无数据可用
- 摘要卡优先展示高严重程度、重点后果问题、往期/重复问题、逾期未完成、整改评级偏低等指标
### 7.2 发起整改填报
......@@ -369,12 +388,12 @@
## 9. 驾驶舱展示规则
旧驾驶舱页面以 `problem_rectification` 为唯一数据源,主要分为三部分:
当前驾驶舱页面以 `problem_rectification` 为唯一数据源,围绕 2026 年整改驾驶舱需求分为四层:
1. 按反馈单位分组的统计看板
2. 临近整改问题列表
3. 整改逾期问题列表
4. 问题涉及业务领域词云
1. 宏观总览层
2. 问题分析与风险定位层
3. 问题责任追踪与执行层
4. 审计整改质效层
### 9.1 顶部标签分组
......@@ -399,47 +418,78 @@
- 页面文案可以更贴近业务,例如“资产管理部”与“鑫沅资产”统一命名
- 分组不必写死在组件内部,建议抽为配置映射
推荐新实现:
当前实现约定:
- 顶部继续保留按 `feedback_unit` 分组的轻量 tabs
- tabs 下方分为 KPI 卡片、ECharts 图表区和重点问题清单区
- 驾驶舱仅负责统计展示,不承接编辑动作
- 如果线上 Subject 后续把新增字段从 `text` 升级为 `select` / `date` / `boolean`,驾驶舱统计口径无需调整,只需复用标准字段值
### 9.2 宏观总览层
当前 KPI 统计口径:
- 问题总数:当前分组内问题总数
- 已完成整改数:`rectification_progress` 为 `已整改` 或 `已优化`
- 整改完成率:`已完成整改数 / 问题总数`
- 逾期未完成数:`the_time_of_rectification` 早于当前日期且未闭环的问题数
- 逾期率:`逾期未完成数 / 问题总数`
- 顶部使用轻量 tabs
- tabs 下方展示 KPI 卡片
- 页面中下部拆成“临近整改”“整改逾期”“问题领域分布”三个区块
- 若词云阅读性差,可直接换成条形排名,不影响业务含义
整改进度分布:
### 9.2 统计指标口径
- 以 `rectification_progress` 为维度统计数量和占比
- 当前页面使用环形图表达,不再沿用旧版折线趋势表达
旧 `MainPanel` 已确认的统计口径:
### 9.3 问题分析与风险定位层
- 已整改问题:`rectification_progress` 为 `已整改` 或 `已优化`
- 问题数量:当前分组内问题总数
- 历史问题累计整改率:
`(已整改或已优化数量 / 问题总数) * 100`
- 本年度问题整改率:
当年 `check_the_implementation_time` 所属问题中,已整改或已优化的占比
当前图表与字段映射:
其中“当年”是通过 `check_the_implementation_time` 中提取四位年份数字判断,不是通过日期字段直接计算。
- 问题类型 / 业务领域分布:优先使用 `problem_category_level_1` 至 `problem_category_level_4` 生成矩形树图;若分类未填写,则回退到 `related_issue_domain`
- 问题根本原因分析:使用 `problem_root_cause` 聚合
- 问题来源对比:以 `problem_from` 为横轴,并按 `inspect_the_implementing_unit` 做堆叠比较
- 重点问题集中展示:
- 经济损失问题:`has_economic_loss` + `economic_loss_amount`
- 外部影响问题:`has_external_impact` + `penalty_organization`
- 员工问责问题:`has_employee_accountability` + `accountability_person_count`
- 重点问题清单中同时展示 `problem_severity_level`
### 9.3 临近整改问题
### 9.4 问题责任追踪与执行层
筛选逻辑:
当前图表与字段映射:
- `the_time_of_rectification` 在当前时间之后
- `the_time_of_rectification` 在未来 1 个月内
- `rectification_progress !== "已整改"`
- 逾期问题排行:按 `responsible_department` 聚合逾期未完成问题数
- 部门整改责任矩阵:
- X 轴:责任部门问题总数
- Y 轴:整改完成率
- 气泡大小:逾期问题数
- 整改时效分析散点图:
- X 轴:`the_time_of_rectification - inspection_completed_at`
- Y 轴:`actual_rectification_completed_at - inspection_completed_at`
- 仅统计同时填写了预计整改时间、实际整改完成时间、实际检查完成日期且天数非负的问题
### 9.4 整改逾期问题
当前逾期 / 延期判定说明:
筛选逻辑:
- 当前项目线上尚无稳定使用的显式“是否延期”字段,因此页面暂以三类规则综合判断:
- 整改进度文案包含“延期”
- 实际整改完成时间晚于预计整改完成时间
- 截止当前日期预计整改完成时间已到且问题尚未闭环
- 其中 KPI 和逾期排行的“逾期未完成”使用第三条规则
- `the_time_of_rectification` 早于当前时间
- `rectification_progress !== "已整改"`
### 9.5 审计整改质效层
### 9.5 问题涉及业务领域统计
当前图表与字段映射:
- 使用 `related_issue_domain` 做聚合统计
- 旧应用通过 `subjectSearch('problem_rectification', ...)` 获取 bucket
- 新项目可以保留词云,也可以替换成更轻量的条形列表或标签热度图
- 如果追求性能和可读性,优先推荐横向条形图或排名卡片,而不是强依赖词云
- 问题整改时效:按责任部门计算 `rectification_timeliness_level` 平均分,低=1、中=2、高=3
- 问题整改质量:按责任部门计算 `rectification_material_quality_level` 平均分,低=1、中=2、高=3
- 问题整改助力总结:
- 制度修订数量:`involves_policy_revision = 是`
- 系统建设数量:`involves_system_construction = 是`
- 风险-质量热力图:
- X 轴:`problem_severity_level`
- Y 轴:`rectification_overall_rating`
- 部门综合整改评定等级排行:
- 以责任部门为维度统计 `rectification_overall_rating` 的低/中/高占比
- 按高评级占比降序展示
---
......
import dayjs from 'dayjs';
import { queryRecords, subjectSearch } from '@/services/DataService';
import { queryRecords } from '@/services/DataService';
export const PROBLEM_RECTIFICATION_SUBJECT = 'problem_rectification';
......@@ -32,6 +32,14 @@ export const DEPARTMENT_GROUPS = [
];
const FIXED_PROGRESS_VALUES = new Set(['已整改', '已优化']);
const POSITIVE_FLAGS = new Set(['是', '有', '涉及', 'TRUE', '1', 'Y', 'YES']);
const SEVERITY_LEVELS = ['低', '中', '高'];
const RATING_LEVELS = ['低', '中', '高'];
const SCORE_LEVEL_MAP = {
低: 1,
中: 2,
高: 3,
};
function normalizeText(value) {
return String(value ?? '').trim();
......@@ -49,43 +57,37 @@ function toArray(value) {
return [value];
}
function formatDateText(value) {
function parseDate(value) {
if (!value) {
return '';
return null;
}
const parsed = dayjs(value);
if (parsed.isValid()) {
return parsed.format('YYYY-MM-DD');
return parsed;
}
const text = normalizeText(value);
if (/^\d{4}-\d{2}-\d{2}/.test(text)) {
return text.slice(0, 10);
}
return text;
}
function extractYear(value) {
const text = normalizeText(value);
if (!text) {
return null;
}
const directDate = dayjs(text);
if (directDate.isValid()) {
return directDate.year();
const fallbackParsed = dayjs(text);
return fallbackParsed.isValid() ? fallbackParsed : null;
}
function formatDateText(value) {
const parsed = parseDate(value);
if (parsed) {
return parsed.format('YYYY-MM-DD');
}
const match = text.match(/\d{4}/);
return match ? Number(match[0]) : null;
const text = normalizeText(value);
return /^\d{4}-\d{2}-\d{2}/.test(text) ? text.slice(0, 10) : text;
}
function isFixedProblem(record) {
return FIXED_PROGRESS_VALUES.has(
normalizeText(record?.metadata?.rectification_progress),
);
function isAffirmative(value) {
return POSITIVE_FLAGS.has(normalizeText(value).toUpperCase());
}
function normalizeUserLabel(value) {
......@@ -107,36 +109,458 @@ function normalizeUserLabel(value) {
);
}
function isFixedProblem(record) {
return FIXED_PROGRESS_VALUES.has(normalizeText(record?.metadata?.rectification_progress));
}
function extractPrimaryDepartment(metadata) {
const primary = normalizeText(metadata.primary_responsible_department);
if (primary) {
return primary;
}
const departments = toArray(metadata.responsible_department)
.map((item) => normalizeText(item))
.filter(Boolean);
return departments[0] || '未指定';
}
function sortByCountDesc(items = []) {
return [...items].sort((left, right) => right.value - left.value);
}
function diffDays(later, earlier) {
if (!later || !earlier) {
return null;
}
return later.startOf('day').diff(earlier.startOf('day'), 'day');
}
function buildDistributionMap(records, getKey) {
const map = new Map();
records.forEach((record) => {
const key = normalizeText(getKey(record)) || '未分类';
map.set(key, (map.get(key) || 0) + 1);
});
return sortByCountDesc(
Array.from(map.entries()).map(([name, value]) => ({ name, value })),
);
}
function upsertTreeNode(nodes, name) {
let existing = nodes.find((node) => node.name === name);
if (!existing) {
existing = { name, value: 0, children: [] };
nodes.push(existing);
}
existing.value += 1;
return existing;
}
function buildCategoryTreemap(records) {
const root = [];
records.forEach((record) => {
const path = [
record.categoryLevel1,
record.categoryLevel2,
record.categoryLevel3,
record.categoryLevel4,
].filter(Boolean);
if (path.length === 0) {
path.push(record.relatedIssueDomain || '未分类');
}
let currentNodes = root;
path.forEach((name) => {
const node = upsertTreeNode(currentNodes, name);
currentNodes = node.children;
});
});
return root;
}
function buildSourceComparison(records) {
const sourceMap = new Map();
const unitSet = new Set();
records.forEach((record) => {
const source = record.problemFrom || '未分类来源';
const inspectUnit = record.inspectUnit || '未分类单位';
if (!sourceMap.has(source)) {
sourceMap.set(source, new Map());
}
const unitMap = sourceMap.get(source);
unitMap.set(inspectUnit, (unitMap.get(inspectUnit) || 0) + 1);
unitSet.add(inspectUnit);
});
const categories = Array.from(sourceMap.keys());
const unitTotals = Array.from(unitSet).map((unit) => ({
unit,
total: categories.reduce(
(sum, source) => sum + (sourceMap.get(source)?.get(unit) || 0),
0,
),
}));
const topUnits = unitTotals
.sort((left, right) => right.total - left.total)
.slice(0, 6)
.map((item) => item.unit);
const series = topUnits.map((unit) => ({
name: unit,
data: categories.map((source) => sourceMap.get(source)?.get(unit) || 0),
}));
return {
categories,
series,
};
}
function buildFocusIssueGroups(records) {
const sortBySeverityAndDueDate = (left, right) => {
const leftSeverity = SEVERITY_LEVELS.indexOf(left.severityLevel);
const rightSeverity = SEVERITY_LEVELS.indexOf(right.severityLevel);
if (leftSeverity !== rightSeverity) {
return rightSeverity - leftSeverity;
}
if (left.dueDate && right.dueDate) {
return left.dueDate.valueOf() - right.dueDate.valueOf();
}
if (left.dueDate) {
return -1;
}
if (right.dueDate) {
return 1;
}
return left.title.localeCompare(right.title, 'zh-CN');
};
const formatItem = (record, extraLabel) => ({
id: record.id,
title: record.title,
severityLevel: record.severityLevel || '未评定',
feedbackUnit: record.feedbackUnit || '未归类单位',
extraLabel,
dueDateLabel: record.dueDateLabel,
brief: record.brief,
});
return {
economicLoss: records
.filter((record) => record.hasEconomicLoss)
.sort(sortBySeverityAndDueDate)
.slice(0, 6)
.map((record) =>
formatItem(record, record.economicLossAmount ? `损失 ${record.economicLossAmount}` : '已标记经济损失'),
),
externalImpact: records
.filter((record) => record.hasExternalImpact)
.sort(sortBySeverityAndDueDate)
.slice(0, 6)
.map((record) => formatItem(record, record.penaltyOrganization || '已标记外部影响')),
accountability: records
.filter((record) => record.hasEmployeeAccountability)
.sort(sortBySeverityAndDueDate)
.slice(0, 6)
.map((record) =>
formatItem(
record,
record.accountabilityPersonCount
? `问责 ${record.accountabilityPersonCount} 人`
: '已标记员工问责',
),
),
};
}
function collectDepartmentStats(records) {
const departmentMap = new Map();
records.forEach((record) => {
const departments = record.responsibleDepartments.length > 0
? record.responsibleDepartments
: [record.primaryResponsibleDepartment || '未指定'];
departments.forEach((department) => {
const key = department || '未指定';
if (!departmentMap.has(key)) {
departmentMap.set(key, {
name: key,
totalCount: 0,
fixedCount: 0,
overdueCount: 0,
timelinessScoreTotal: 0,
timelinessScoreCount: 0,
qualityScoreTotal: 0,
qualityScoreCount: 0,
ratingCounts: {
低: 0,
中: 0,
高: 0,
},
});
}
const stats = departmentMap.get(key);
stats.totalCount += 1;
if (record.isFixed) {
stats.fixedCount += 1;
}
if (record.isOverdue) {
stats.overdueCount += 1;
}
const timelinessScore = SCORE_LEVEL_MAP[record.timelinessLevel];
if (timelinessScore) {
stats.timelinessScoreTotal += timelinessScore;
stats.timelinessScoreCount += 1;
}
const qualityScore = SCORE_LEVEL_MAP[record.materialQualityLevel];
if (qualityScore) {
stats.qualityScoreTotal += qualityScore;
stats.qualityScoreCount += 1;
}
if (record.overallRating && stats.ratingCounts[record.overallRating] !== undefined) {
stats.ratingCounts[record.overallRating] += 1;
}
});
});
return Array.from(departmentMap.values()).map((item) => ({
...item,
completionRate: item.totalCount > 0 ? (item.fixedCount / item.totalCount) * 100 : 0,
timelinessAverage:
item.timelinessScoreCount > 0
? item.timelinessScoreTotal / item.timelinessScoreCount
: 0,
qualityAverage:
item.qualityScoreCount > 0
? item.qualityScoreTotal / item.qualityScoreCount
: 0,
}));
}
function buildDepartmentMatrix(departmentStats) {
return departmentStats
.filter((item) => item.totalCount > 0)
.sort((left, right) => right.totalCount - left.totalCount)
.map((item) => ({
name: item.name,
totalCount: item.totalCount,
completionRate: Number(item.completionRate.toFixed(2)),
overdueCount: item.overdueCount,
}));
}
function buildOverdueRanking(departmentStats) {
return sortByCountDesc(
departmentStats
.filter((item) => item.overdueCount > 0)
.map((item) => ({
name: item.name,
value: item.overdueCount,
})),
).slice(0, 10);
}
function buildTimelinessScatter(records) {
return records
.map((record) => {
const plannedDays = diffDays(record.dueDate, record.inspectionCompletedAt);
const actualDays = diffDays(
record.actualRectificationCompletedAt,
record.inspectionCompletedAt,
);
if (
plannedDays === null ||
actualDays === null ||
plannedDays < 0 ||
actualDays < 0
) {
return null;
}
return {
name: record.title,
value: [plannedDays, actualDays, record.severityLevel || '未评定'],
isDelayed: actualDays > plannedDays,
};
})
.filter(Boolean)
.slice(0, 120);
}
function buildScoreRanking(departmentStats, key) {
return [...departmentStats]
.filter((item) => item[key] > 0)
.sort((left, right) => right[key] - left[key])
.map((item) => ({
name: item.name,
value: Number(item[key].toFixed(2)),
}));
}
function buildSeverityRatingHeatmap(records) {
const matrix = [];
RATING_LEVELS.forEach((rating, yIndex) => {
SEVERITY_LEVELS.forEach((severity, xIndex) => {
const count = records.filter(
(record) =>
record.severityLevel === severity && record.overallRating === rating,
).length;
matrix.push([xIndex, yIndex, count]);
});
});
return {
xLabels: SEVERITY_LEVELS,
yLabels: RATING_LEVELS,
data: matrix,
};
}
function buildDepartmentRatingDistribution(departmentStats) {
return [...departmentStats]
.filter((item) => item.totalCount > 0)
.map((item) => {
const high = item.ratingCounts.高 || 0;
const medium = item.ratingCounts.中 || 0;
const low = item.ratingCounts.低 || 0;
const ratedTotal = high + medium + low;
return {
name: item.name,
low,
medium,
high,
lowPercent: ratedTotal > 0 ? (low / ratedTotal) * 100 : 0,
mediumPercent: ratedTotal > 0 ? (medium / ratedTotal) * 100 : 0,
highPercent: ratedTotal > 0 ? (high / ratedTotal) * 100 : 0,
highShare: ratedTotal > 0 ? high / ratedTotal : 0,
};
})
.sort((left, right) => right.highShare - left.highShare);
}
function summarizeRecords(records) {
const totalCount = records.length;
const fixedCount = records.filter((item) => item.isFixed).length;
const overdueCount = records.filter((item) => item.isOverdue).length;
const completionRate = totalCount > 0 ? (fixedCount / totalCount) * 100 : 0;
const overdueRate = totalCount > 0 ? (overdueCount / totalCount) * 100 : 0;
const departmentStats = collectDepartmentStats(records);
return {
totalCount,
fixedCount,
completionRate,
overdueCount,
overdueRate,
progressDistribution: buildDistributionMap(records, (item) => item.progress || '未填写'),
categoryTreemap: buildCategoryTreemap(records),
rootCauseDistribution: buildDistributionMap(records, (item) => item.rootCause || '未分类原因'),
sourceComparison: buildSourceComparison(records),
focusIssueGroups: buildFocusIssueGroups(records),
overdueRanking: buildOverdueRanking(departmentStats),
departmentMatrix: buildDepartmentMatrix(departmentStats),
timelinessScatter: buildTimelinessScatter(records),
timelinessScoreRanking: buildScoreRanking(departmentStats, 'timelinessAverage'),
qualityScoreRanking: buildScoreRanking(departmentStats, 'qualityAverage'),
policyRevisionCount: records.filter((item) => item.involvesPolicyRevision).length,
systemConstructionCount: records.filter((item) => item.involvesSystemConstruction).length,
severityRatingHeatmap: buildSeverityRatingHeatmap(records),
departmentRatingDistribution: buildDepartmentRatingDistribution(departmentStats),
};
}
function normalizeProblemRecord(record) {
const metadata = record?.metadata || {};
const dueDate = metadata.the_time_of_rectification
? dayjs(metadata.the_time_of_rectification)
: null;
const dueDate = parseDate(metadata.the_time_of_rectification);
const actualRectificationCompletedAt = parseDate(metadata.actual_rectification_completed_at);
const inspectionCompletedAt = parseDate(metadata.inspection_completed_at);
const progress = normalizeText(metadata.rectification_progress);
const isFixed = isFixedProblem(record);
const now = dayjs();
const delayedByProgress = progress.includes('延期');
const delayedByActual =
dueDate &&
actualRectificationCompletedAt &&
actualRectificationCompletedAt.isAfter(dueDate, 'day');
const delayedByPending = dueDate && dueDate.isBefore(now, 'day') && !isFixed;
const isDelayed = Boolean(delayedByProgress || delayedByActual || delayedByPending);
return {
id: record?._id || record?.name || '',
title: normalizeText(metadata.project_name) || normalizeText(record?.title) || '-',
title: normalizeText(record?.title) || normalizeText(metadata.project_name) || '-',
projectName: normalizeText(metadata.project_name),
inspectUnit: normalizeText(metadata.inspect_the_implementing_unit),
feedbackUnit: normalizeText(metadata.feedback_unit),
implementationYear: extractYear(metadata.check_the_implementation_time),
implementationTime: normalizeText(metadata.check_the_implementation_time),
progress: normalizeText(metadata.rectification_progress),
auditStatus: normalizeText(metadata.audit_status),
finalAuditStatus: normalizeText(metadata.final_audit_status),
problemType: normalizeText(metadata.problem_type),
progress,
problemFrom: normalizeText(metadata.problem_from),
problemType: normalizeText(metadata.problem_type),
relatedIssueDomain: normalizeText(metadata.related_issue_domain) || '未分类',
brief: normalizeText(metadata.problem_brief) || '暂无问题简述',
severityLevel: normalizeText(metadata.problem_severity_level),
categoryLevel1: normalizeText(metadata.problem_category_level_1),
categoryLevel2: normalizeText(metadata.problem_category_level_2),
categoryLevel3: normalizeText(metadata.problem_category_level_3),
categoryLevel4: normalizeText(metadata.problem_category_level_4),
rootCause: normalizeText(metadata.problem_root_cause),
timelinessLevel: normalizeText(metadata.rectification_timeliness_level),
materialQualityLevel: normalizeText(metadata.rectification_material_quality_level),
overallRating: normalizeText(metadata.rectification_overall_rating),
primaryResponsibleDepartment: extractPrimaryDepartment(metadata),
responsibleDepartments: toArray(metadata.responsible_department)
.map((item) => normalizeText(item))
.filter(Boolean),
brief: normalizeText(metadata.problem_brief) || '暂无问题简述',
detail: normalizeText(metadata.problem_discovery_situation),
rectificationSituation: normalizeText(metadata.rectification_situation),
dueDate: dueDate && dueDate.isValid() ? dueDate : null,
hasEconomicLoss: isAffirmative(metadata.has_economic_loss),
economicLossAmount: normalizeText(metadata.economic_loss_amount),
hasExternalImpact: isAffirmative(metadata.has_external_impact),
penaltyOrganization: normalizeText(metadata.penalty_organization),
hasEmployeeAccountability: isAffirmative(metadata.has_employee_accountability),
accountabilityPersonCount: normalizeText(metadata.accountability_person_count),
involvesPolicyRevision: isAffirmative(metadata.involves_policy_revision),
involvesSystemConstruction: isAffirmative(metadata.involves_system_construction),
isHistoricalProblem: isAffirmative(metadata.is_historical_problem),
isRepeatedProblem: isAffirmative(metadata.is_repeated_problem),
dueDate,
dueDateLabel: formatDateText(metadata.the_time_of_rectification) || '未填写',
actualRectificationCompletedAt,
actualRectificationCompletedLabel:
formatDateText(metadata.actual_rectification_completed_at) || '未填写',
inspectionCompletedAt,
inspectionCompletedLabel:
formatDateText(metadata.inspection_completed_at) || '未填写',
fillUsers: toArray(metadata.fill_in_the_people).map(normalizeUserLabel),
reviewUsers: toArray(metadata.review_people).map(normalizeUserLabel),
isFixed: isFixedProblem(record),
isFixed,
isDelayed,
isOverdue: delayedByPending,
};
}
......@@ -161,114 +585,12 @@ function buildDepartmentBuckets(records) {
return buckets;
}
function summarizeRecords(records, domainBuckets = []) {
const totalCount = records.length;
const fixedCount = records.filter((item) => item.isFixed).length;
const now = dayjs();
const nextMonth = dayjs().add(1, 'month');
const currentYear = now.year();
const currentYearRecords = records.filter(
(item) => item.implementationYear === currentYear,
);
const currentYearFixedCount = currentYearRecords.filter(
(item) => item.isFixed,
).length;
const upcoming = records
.filter((item) => {
if (!item.dueDate || item.isFixed) {
return false;
}
return item.dueDate.isAfter(now) && item.dueDate.isBefore(nextMonth);
})
.sort((left, right) => left.dueDate.valueOf() - right.dueDate.valueOf());
const overdue = records
.filter((item) => {
if (!item.dueDate || item.isFixed) {
return false;
}
return item.dueDate.isBefore(now);
})
.sort((left, right) => left.dueDate.valueOf() - right.dueDate.valueOf());
const feedbackUnitDistribution = Object.values(
records.reduce((accumulator, item) => {
const key = item.feedbackUnit || '未归类';
if (!accumulator[key]) {
accumulator[key] = { name: key, value: 0 };
}
accumulator[key].value += 1;
return accumulator;
}, {}),
).sort((left, right) => right.value - left.value);
const problemTypeDistribution = Object.values(
records.reduce((accumulator, item) => {
const key = item.problemType || '未分类';
if (!accumulator[key]) {
accumulator[key] = { name: key, value: 0 };
}
accumulator[key].value += 1;
return accumulator;
}, {}),
).sort((left, right) => right.value - left.value);
const progressDistribution = Object.values(
records.reduce((accumulator, item) => {
const key = item.progress || '未填写';
if (!accumulator[key]) {
accumulator[key] = { name: key, value: 0 };
}
accumulator[key].value += 1;
return accumulator;
}, {}),
).sort((left, right) => right.value - left.value);
const domainDistribution =
domainBuckets && domainBuckets.length > 0
? domainBuckets
.filter((item) => normalizeText(item?.key))
.map((item) => ({
name: item.key,
value: Number(item.doc_count || 0),
}))
.sort((left, right) => right.value - left.value)
: Object.values(
records.reduce((accumulator, item) => {
const key = item.relatedIssueDomain || '未分类';
if (!accumulator[key]) {
accumulator[key] = { name: key, value: 0 };
}
accumulator[key].value += 1;
return accumulator;
}, {}),
).sort((left, right) => right.value - left.value);
return {
totalCount,
fixedCount,
historyRate: totalCount > 0 ? (fixedCount / totalCount) * 100 : 0,
currentYearRate:
currentYearRecords.length > 0
? (currentYearFixedCount / currentYearRecords.length) * 100
: 0,
currentYearTotal: currentYearRecords.length,
upcoming,
overdue,
feedbackUnitDistribution,
problemTypeDistribution,
progressDistribution,
domainDistribution,
};
}
async function fetchAllProblemRectificationRecords() {
const firstPage = await queryRecords({
subject: PROBLEM_RECTIFICATION_SUBJECT,
page: 0,
page_size: 1000,
mode: 'full',
});
const totalRecords = Number(firstPage?.total_records || 0);
......@@ -281,6 +603,7 @@ async function fetchAllProblemRectificationRecords() {
subject: PROBLEM_RECTIFICATION_SUBJECT,
page,
page_size: 1000,
mode: 'full',
});
records.push(...(pageResult?.records || []));
}
......@@ -289,44 +612,17 @@ async function fetchAllProblemRectificationRecords() {
return records;
}
async function fetchDomainBuckets() {
try {
const result = await subjectSearch(PROBLEM_RECTIFICATION_SUBJECT, {
page: 0,
page_size: 1000,
content: false,
exact: true,
source: true,
});
return result?.aggregations?.related_issue_domain?.buckets || [];
} catch (error) {
return [];
}
}
export async function loadProblemRectificationDashboardData() {
try {
const [rawRecords, domainBuckets] = await Promise.all([
fetchAllProblemRectificationRecords(),
fetchDomainBuckets(),
]);
const rawRecords = await fetchAllProblemRectificationRecords();
const records = rawRecords.map(normalizeProblemRecord);
const departmentBuckets = buildDepartmentBuckets(records);
const summaries = Object.fromEntries(
DEPARTMENT_GROUPS.map((group) => {
const scopedRecords = departmentBuckets[group.key] || [];
const scopedDomains =
group.key === 'all'
? domainBuckets
: summarizeRecords(scopedRecords).domainDistribution;
return [
group.key,
summarizeRecords(scopedRecords, scopedDomains),
];
}),
DEPARTMENT_GROUPS.map((group) => [
group.key,
summarizeRecords(departmentBuckets[group.key] || []),
]),
);
return {
......
......@@ -20,6 +20,49 @@ import {
} from '@/utils/subjectView';
export const PROBLEM_RECTIFICATION_SUBJECT = 'problem_rectification';
const FIXED_PROGRESS_VALUES = new Set(['已整改', '已优化']);
const LEVEL_ORDER = ['高', '中', '低'];
const WORKBENCH_ENHANCED_COLUMN_FIELDS = [
'problem_severity_level',
'rectification_overall_rating',
'primary_responsible_department',
'problem_root_cause',
'actual_rectification_completed_at',
];
const WORKBENCH_PRIORITY_CREATE_FIELD_NAMES = [
'problem_severity_level',
'problem_category_level_1',
'problem_category_level_2',
'problem_category_level_3',
'problem_category_level_4',
'problem_root_cause',
'rectification_timeliness_level',
'rectification_material_quality_level',
'has_economic_loss',
'economic_loss_amount',
'has_external_impact',
'penalty_organization',
'has_employee_accountability',
'accountability_person_count',
'involves_policy_revision',
'policy_revision_name',
'involves_system_construction',
'system_construction_name',
'is_historical_problem',
'is_repeated_problem',
'rectification_overall_rating',
'actual_rectification_completed_at',
'inspection_completed_at',
'primary_responsible_department',
];
const FOCUS_FILTER_OPTIONS = [
{ label: '重点后果问题', value: 'consequence' },
{ label: '高严重程度', value: 'high_severity' },
{ label: '综合评级较低', value: 'low_rating' },
{ label: '往期或重复问题', value: 'repeated_or_historical' },
{ label: '已逾期未完成', value: 'overdue' },
{ label: '制度/系统整改类', value: 'policy_or_system' },
];
function toArray(value) {
if (!value) {
......@@ -48,6 +91,17 @@ function normalizeUserLabel(value) {
);
}
function normalizeFlagText(value) {
const text = normalizeText(value);
return text || '否';
}
function isAffirmative(value) {
return ['是', '有', '涉及', 'true', '1', 'Y', 'YES'].includes(
normalizeText(value).toUpperCase(),
);
}
function formatDateText(value) {
if (!value) {
return '';
......@@ -60,11 +114,48 @@ function formatDateText(value) {
return /^\d{4}-\d{2}-\d{2}/.test(text) ? text.slice(0, 10) : text;
}
function sortLevelOptions(values = []) {
return [...values].sort((left, right) => {
const leftIndex = LEVEL_ORDER.indexOf(left);
const rightIndex = LEVEL_ORDER.indexOf(right);
if (leftIndex === -1 && rightIndex === -1) {
return left.localeCompare(right, 'zh-CN');
}
if (leftIndex === -1) {
return 1;
}
if (rightIndex === -1) {
return -1;
}
return leftIndex - rightIndex;
});
}
function findSubjectField(subject, fieldName) {
return (subject?.fields || []).find((field) => field?.name === fieldName) || null;
}
export function normalizeProblemRectificationRecord(record) {
const metadata = record?.metadata || {};
const dueDate = metadata.the_time_of_rectification
? dayjs(metadata.the_time_of_rectification)
: null;
const normalizedProgress = normalizeText(metadata.rectification_progress);
const isFixed = FIXED_PROGRESS_VALUES.has(normalizedProgress);
const hasEconomicLoss = isAffirmative(metadata.has_economic_loss);
const hasExternalImpact = isAffirmative(metadata.has_external_impact);
const hasEmployeeAccountability = isAffirmative(metadata.has_employee_accountability);
const involvesPolicyRevision = isAffirmative(metadata.involves_policy_revision);
const involvesSystemConstruction = isAffirmative(metadata.involves_system_construction);
const isHistoricalProblem = isAffirmative(metadata.is_historical_problem);
const isRepeatedProblem = isAffirmative(metadata.is_repeated_problem);
const now = dayjs();
const isOverdue =
dueDate && dueDate.isValid() ? dueDate.isBefore(now, 'day') && !isFixed : false;
return {
id: record?._id || record?.name || '',
......@@ -72,7 +163,7 @@ export function normalizeProblemRectificationRecord(record) {
title: normalizeText(record?.title) || normalizeText(metadata.project_name),
projectName: normalizeText(metadata.project_name) || '-',
inspectUnit: normalizeText(metadata.inspect_the_implementing_unit),
progress: normalizeText(metadata.rectification_progress),
progress: normalizedProgress,
feedbackUnit: normalizeText(metadata.feedback_unit),
auditStatus: normalizeText(metadata.audit_status),
finalAuditStatus: normalizeText(metadata.final_audit_status),
......@@ -80,6 +171,37 @@ export function normalizeProblemRectificationRecord(record) {
problemFrom: normalizeText(metadata.problem_from),
relatedIssueDomain: normalizeText(metadata.related_issue_domain),
brief: normalizeText(metadata.problem_brief),
severityLevel: normalizeText(metadata.problem_severity_level),
categoryLevel1: normalizeText(metadata.problem_category_level_1),
categoryLevel2: normalizeText(metadata.problem_category_level_2),
categoryLevel3: normalizeText(metadata.problem_category_level_3),
categoryLevel4: normalizeText(metadata.problem_category_level_4),
rootCause: normalizeText(metadata.problem_root_cause),
timelinessLevel: normalizeText(metadata.rectification_timeliness_level),
materialQualityLevel: normalizeText(metadata.rectification_material_quality_level),
overallRating: normalizeText(metadata.rectification_overall_rating),
primaryResponsibleDepartment: normalizeText(metadata.primary_responsible_department),
hasEconomicLoss,
hasEconomicLossLabel: normalizeFlagText(metadata.has_economic_loss),
economicLossAmount: normalizeText(metadata.economic_loss_amount),
hasExternalImpact,
hasExternalImpactLabel: normalizeFlagText(metadata.has_external_impact),
penaltyOrganization: normalizeText(metadata.penalty_organization),
hasEmployeeAccountability,
hasEmployeeAccountabilityLabel: normalizeFlagText(metadata.has_employee_accountability),
accountabilityPersonCount: normalizeText(metadata.accountability_person_count),
involvesPolicyRevision,
involvesPolicyRevisionLabel: normalizeFlagText(metadata.involves_policy_revision),
policyRevisionName: normalizeText(metadata.policy_revision_name),
involvesSystemConstruction,
involvesSystemConstructionLabel: normalizeFlagText(metadata.involves_system_construction),
systemConstructionName: normalizeText(metadata.system_construction_name),
isHistoricalProblem,
isHistoricalProblemLabel: normalizeFlagText(metadata.is_historical_problem),
isRepeatedProblem,
isRepeatedProblemLabel: normalizeFlagText(metadata.is_repeated_problem),
actualRectificationCompletedAt: formatDateText(metadata.actual_rectification_completed_at),
inspectionCompletedAt: formatDateText(metadata.inspection_completed_at),
dueDate: dueDate && dueDate.isValid() ? dueDate : null,
fillUsers: toArray(metadata.fill_in_the_people)
.map(normalizeUserLabel)
......@@ -88,6 +210,8 @@ export function normalizeProblemRectificationRecord(record) {
.map(normalizeUserLabel)
.filter(Boolean),
dueDateLabel: formatDateText(metadata.the_time_of_rectification) || '-',
isFixed,
isOverdue,
raw: record,
};
}
......@@ -139,7 +263,28 @@ export function buildGridViews(views = []) {
}
export function buildViewColumnsForList(view, subject) {
return buildViewColumns(view, subject).filter((column) => column.field !== 'title' || !column.hide);
const baseColumns = buildViewColumns(view, subject).filter(
(column) => column.field !== 'title' || !column.hide,
);
const existingFields = new Set(baseColumns.map((column) => getFieldNameFromPath(column.field)));
const extraColumns = WORKBENCH_ENHANCED_COLUMN_FIELDS
.filter((fieldName) => !existingFields.has(fieldName))
.map((fieldName) => {
const fieldDef = findSubjectField(subject, fieldName);
if (!fieldDef) {
return null;
}
return {
field: `metadata.${fieldDef.name}`,
label: fieldDef.label || fieldDef.name,
width: 168,
fieldDef,
};
})
.filter(Boolean);
return [...baseColumns, ...extraColumns];
}
export function getRecordCellText(record, column) {
......@@ -152,6 +297,12 @@ export function buildProblemRectificationConfiguredListView(records, filters, gr
const nextKeyword = filters?.keyword || '';
const nextProgress = filters?.progress || '';
const nextAuditStatus = filters?.auditStatus || '';
const nextSeverityLevel = filters?.severityLevel || '';
const nextOverallRating = filters?.overallRating || '';
const nextPrimaryResponsibleDepartment = filters?.primaryResponsibleDepartment || '';
const nextProblemFrom = filters?.problemFrom || '';
const nextRootCause = filters?.rootCause || '';
const nextFocusFlag = filters?.focusFlag || '';
const columns = buildViewColumnsForList(gridView, subject);
const searchableFields = columns.map((column) => column.field);
......@@ -183,6 +334,67 @@ export function buildProblemRectificationConfiguredListView(records, filters, gr
return false;
}
if (nextSeverityLevel && record.severityLevel !== nextSeverityLevel) {
return false;
}
if (nextOverallRating && record.overallRating !== nextOverallRating) {
return false;
}
if (
nextPrimaryResponsibleDepartment &&
record.primaryResponsibleDepartment !== nextPrimaryResponsibleDepartment
) {
return false;
}
if (nextProblemFrom && record.problemFrom !== nextProblemFrom) {
return false;
}
if (nextRootCause && record.rootCause !== nextRootCause) {
return false;
}
if (nextFocusFlag === 'consequence') {
if (
!record.hasEconomicLoss &&
!record.hasExternalImpact &&
!record.hasEmployeeAccountability
) {
return false;
}
}
if (nextFocusFlag === 'high_severity' && record.severityLevel !== '高') {
return false;
}
if (nextFocusFlag === 'low_rating' && record.overallRating !== '低') {
return false;
}
if (
nextFocusFlag === 'repeated_or_historical' &&
!record.isHistoricalProblem &&
!record.isRepeatedProblem
) {
return false;
}
if (nextFocusFlag === 'overdue' && !record.isOverdue) {
return false;
}
if (
nextFocusFlag === 'policy_or_system' &&
!record.involvesPolicyRevision &&
!record.involvesSystemConstruction
) {
return false;
}
if (!nextKeyword) {
return true;
}
......@@ -205,11 +417,37 @@ export function buildProblemRectificationConfiguredListView(records, filters, gr
new Set(records.map((item) => item.auditStatus).filter(Boolean)),
);
const severityOptions = sortLevelOptions(
Array.from(new Set(records.map((item) => item.severityLevel).filter(Boolean))),
);
const overallRatingOptions = sortLevelOptions(
Array.from(new Set(records.map((item) => item.overallRating).filter(Boolean))),
);
const primaryResponsibleDepartmentOptions = Array.from(
new Set(records.map((item) => item.primaryResponsibleDepartment).filter(Boolean)),
).sort((left, right) => left.localeCompare(right, 'zh-CN'));
const problemFromOptions = Array.from(
new Set(records.map((item) => item.problemFrom).filter(Boolean)),
);
const rootCauseOptions = Array.from(
new Set(records.map((item) => item.rootCause).filter(Boolean)),
).sort((left, right) => left.localeCompare(right, 'zh-CN'));
return {
filteredRecords: sortedRecords,
columns,
progressOptions,
auditStatusOptions,
severityOptions,
overallRatingOptions,
primaryResponsibleDepartmentOptions,
problemFromOptions,
rootCauseOptions,
focusOptions: FOCUS_FILTER_OPTIONS,
};
}
......@@ -326,7 +564,27 @@ export function buildProblemRectificationCreateFormFields(gridView, subject) {
});
if (fields.length > 0) {
return fields;
const nextFields = [...fields];
WORKBENCH_PRIORITY_CREATE_FIELD_NAMES.forEach((fieldName) => {
if (seen.has(fieldName)) {
return;
}
const fieldDef = findSubjectField(subject, fieldName);
if (!fieldDef) {
return;
}
seen.add(fieldName);
nextFields.push({
field: `metadata.${fieldDef.name}`,
label: fieldDef.label || fieldDef.name,
fieldDef,
});
});
return nextFields;
}
return (subject?.fields || []).map((field) => ({
......
......@@ -125,6 +125,30 @@ type Record = {
| 最终审批状态 | `final_audit_status` | select | 否 | 否 | 否 |
| 备注 | `remark` | long_text | 否 | 否 | 否 |
| 整改证明材料 | `rectification_proof_materials` | file | 否 | 否 | 否 |
| 是否造成经济损失 | `has_economic_loss` | text | 否 | 否 | 否 |
| 对应经济损失 | `economic_loss_amount` | text | 否 | 否 | 否 |
| 是否造成外部影响 | `has_external_impact` | text | 否 | 否 | 否 |
| 处罚机构 | `penalty_organization` | text | 否 | 否 | 否 |
| 是否涉及员工问责 | `has_employee_accountability` | text | 否 | 否 | 否 |
| 问责人数 | `accountability_person_count` | text | 否 | 否 | 否 |
| 问题综合严重程度 | `problem_severity_level` | text | 否 | 否 | 否 |
| 问题一级分类 | `problem_category_level_1` | text | 否 | 否 | 否 |
| 问题二级分类 | `problem_category_level_2` | text | 否 | 否 | 否 |
| 问题三级分类 | `problem_category_level_3` | text | 否 | 否 | 否 |
| 问题四级分类 | `problem_category_level_4` | text | 否 | 否 | 否 |
| 问题原因 | `problem_root_cause` | text | 否 | 否 | 否 |
| 整改时间评估 | `rectification_timeliness_level` | text | 否 | 否 | 否 |
| 整改证明材料质量 | `rectification_material_quality_level` | text | 否 | 否 | 否 |
| 是否涉及制度修订 | `involves_policy_revision` | text | 否 | 否 | 否 |
| 制度名称 | `policy_revision_name` | text | 否 | 否 | 否 |
| 是否涉及系统建设 | `involves_system_construction` | text | 否 | 否 | 否 |
| 系统名称 | `system_construction_name` | text | 否 | 否 | 否 |
| 是否为往期发现问题 | `is_historical_problem` | text | 否 | 否 | 否 |
| 问题是否重复出现 | `is_repeated_problem` | text | 否 | 否 | 否 |
| 综合整改评定等级 | `rectification_overall_rating` | text | 否 | 否 | 否 |
| 实际整改完成时间 | `actual_rectification_completed_at` | text | 否 | 否 | 否 |
| 实际检查完成日期 | `inspection_completed_at` | text | 否 | 否 | 否 |
| 主责部门 | `primary_responsible_department` | text | 否 | 否 | 否 |
### 3.1.1 字段说明
......@@ -133,6 +157,7 @@ type Record = {
- `audit_status` 用于复核阶段结果,旧应用已使用 `已审批`、`已驳回`
- `final_audit_status` 用于最终审批阶段结果,旧应用已使用 `已审批`、`已驳回`
- `rectification_progress` 在旧驾驶舱统计中已使用 `已整改`、`已优化` 两个值判断“已整改问题”
- 新增驾驶舱扩展字段当前在线上 Subject 中大多仍为 `text` 类型,前端工作台按标准值引导录入,用于支持后续严重程度、问题分类、问题原因和整改质效分析
- 其余 `select` 类型的可选值必须以线上 Subject 配置中的 `settings.options` 为准,禁止在页面内硬编码
### 3.1.2 metadata 示例
......
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