Commit 88f229a6 authored by David Yang's avatar David Yang

fix: bugs

parent dcdc9689
No preview for this file type
...@@ -16,6 +16,8 @@ const ROUTE_PATHS = { ...@@ -16,6 +16,8 @@ const ROUTE_PATHS = {
export default defineConfig({ export default defineConfig({
layout: false, layout: false,
fastRefresh: true, fastRefresh: true,
base: '/problem_rectification_new/',
publicPath: '/problem_rectification_new/',
alias: { alias: {
react: resolvePackageRoot('react'), react: resolvePackageRoot('react'),
'react-dom': resolvePackageRoot('react-dom'), 'react-dom': resolvePackageRoot('react-dom'),
......
{
"name": "problem_rectification_new",
"version": "1.0.3",
"build": "abc",
"description": "merge master",
"date": "2025-07-14 19:27:20",
"author": "yang"
}
This source diff could not be displayed because it is too large. You can view the blob instead.
No preview for this file type
...@@ -11,11 +11,22 @@ import { ...@@ -11,11 +11,22 @@ import {
TableRow, TableRow,
} from '@/catalyst-ui-kit/javascript/table'; } from '@/catalyst-ui-kit/javascript/table';
import { formatFieldValue, getFieldValue } from '@/utils/subjectView'; import { formatFieldValue, getFieldValue } from '@/utils/subjectView';
import {
DocumentDuplicateIcon,
EyeIcon,
TrashIcon,
} from '@heroicons/react/24/outline';
import clsx from 'clsx'; import clsx from 'clsx';
import { useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
const SELECTION_COLUMN_WIDTH = 56; const SELECTION_COLUMN_WIDTH = 56;
const DEFAULT_ACTION_COLUMN_WIDTH = 132; const DEFAULT_ACTION_COLUMN_WIDTH = 168;
const EMPTY_ARRAY = [];
const DEFAULT_ROW_ACTION_ICONS = {
clone: DocumentDuplicateIcon,
delete: TrashIcon,
'view-detail': EyeIcon,
};
function getPinnedSide(column) { function getPinnedSide(column) {
const pinnedValue = column?.lockPosition || column?.pinned; const pinnedValue = column?.lockPosition || column?.pinned;
...@@ -59,6 +70,103 @@ function buildStickyColumnMeta(columns = [], selectable, hasRowActions, actionCo ...@@ -59,6 +70,103 @@ function buildStickyColumnMeta(columns = [], selectable, hasRowActions, actionCo
}; };
} }
function buildColumnGroupMeta(columnGroups = [], columns = []) {
const visibleFieldSet = new Set(columns.map((column) => column.field));
const normalizedGroups = (columnGroups || [])
.map((group, index) => {
const id = group?.id || group?.name || `group-${index}`;
const children = (group?.children || []).filter((field) => visibleFieldSet.has(field));
return {
...group,
id,
name: group?.name || id,
children,
};
})
.filter((group) => group.children.length > 0);
const fieldToGroup = normalizedGroups.reduce((mapping, group) => {
group.children.forEach((field) => {
if (!mapping[field]) {
mapping[field] = group.id;
}
});
return mapping;
}, {});
const groupById = normalizedGroups.reduce((mapping, group) => {
mapping[group.id] = group;
return mapping;
}, {});
return {
groups: normalizedGroups,
fieldToGroup,
groupById,
};
}
function buildColumnGroupSegments(columns = [], fieldToGroup = {}, groupById = {}) {
return columns.reduce((segments, column) => {
const groupId = fieldToGroup[column.field] || '';
const pinnedSide = getPinnedSide(column) || 'center';
const previous = segments[segments.length - 1];
if (previous && previous.groupId === groupId && previous.pinnedSide === pinnedSide) {
previous.columns.push(column);
return segments;
}
segments.push({
key: `${groupId || `ungrouped-${column.field}`}-${pinnedSide}-${segments.length}`,
groupId,
pinnedSide,
group: groupId ? groupById[groupId] : null,
columns: [column],
});
return segments;
}, []);
}
function orderColumnsByGroups(columns = [], fieldToGroup = {}, groupById = {}) {
const columnsByField = new Map(columns.map((column) => [column.field, column]));
const emittedFields = new Set();
const orderedColumns = [];
columns.forEach((column) => {
if (emittedFields.has(column.field)) {
return;
}
const groupId = fieldToGroup[column.field];
const group = groupId ? groupById[groupId] : null;
if (!group) {
emittedFields.add(column.field);
orderedColumns.push(column);
return;
}
const pinnedSide = getPinnedSide(column) || 'center';
group.children.forEach((field) => {
const groupedColumn = columnsByField.get(field);
if (!groupedColumn || emittedFields.has(field)) {
return;
}
if ((getPinnedSide(groupedColumn) || 'center') !== pinnedSide) {
return;
}
emittedFields.add(field);
orderedColumns.push(groupedColumn);
});
});
return orderedColumns;
}
function getStickyCellClass(meta, kind = 'body') { function getStickyCellClass(meta, kind = 'body') {
if (!meta) { if (!meta) {
return ''; return '';
...@@ -167,6 +275,58 @@ function defaultRenderCell(record, column) { ...@@ -167,6 +275,58 @@ function defaultRenderCell(record, column) {
); );
} }
function ColumnGroupButton({ group, collapsed, onToggle }) {
return (
<button
type="button"
onClick={onToggle}
className={clsx(
'inline-flex min-h-9 items-center gap-2 rounded-full px-3 py-1.5 text-xs font-medium transition',
collapsed
? 'bg-zinc-100 text-slate-500 ring-1 ring-zinc-200 hover:bg-zinc-200'
: 'bg-rose-50 text-rose-700 ring-1 ring-rose-100 hover:bg-rose-100',
)}
aria-expanded={!collapsed}
aria-label={`${collapsed ? '展开' : '折叠'}${group.name}`}
>
<span className="text-sm leading-none">{collapsed ? '+' : '-'}</span>
<span>{group.name}</span>
<span className={clsx('rounded-full px-2 py-0.5', collapsed ? 'bg-white text-slate-400' : 'bg-white/80 text-rose-500')}>
{group.children.length}
</span>
</button>
);
}
function RowActionButton({ action, disabled, onClick }) {
const Icon = action.icon || DEFAULT_ROW_ACTION_ICONS[action.key];
const isDanger = action.tone === 'danger';
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
title={action.label}
aria-label={action.label}
className={clsx(
'inline-flex size-9 shrink-0 items-center justify-center rounded-full border text-sm font-medium shadow-sm transition',
'disabled:cursor-not-allowed disabled:opacity-45',
isDanger
? 'border-rose-100 bg-rose-50 text-rose-600 hover:border-rose-200 hover:bg-rose-100'
: 'border-zinc-200 bg-white text-slate-500 hover:border-rose-100 hover:bg-rose-50 hover:text-rose-700',
)}
>
{Icon ? (
<Icon className="size-4.5" aria-hidden="true" />
) : (
<span aria-hidden="true">{action.label?.slice(0, 1) || '?'}</span>
)}
<span className="sr-only">{action.label}</span>
</button>
);
}
export default function RecordWorkbench({ export default function RecordWorkbench({
summaryItems = [], summaryItems = [],
views = [], views = [],
...@@ -178,9 +338,10 @@ export default function RecordWorkbench({ ...@@ -178,9 +338,10 @@ export default function RecordWorkbench({
onClearFilters, onClearFilters,
title, title,
description, description,
columns = [], columns = EMPTY_ARRAY,
records = [], columnGroups = EMPTY_ARRAY,
selectedIds = [], records = EMPTY_ARRAY,
selectedIds = EMPTY_ARRAY,
onSelectionChange, onSelectionChange,
toolbarActions = [], toolbarActions = [],
bulkActions = [], bulkActions = [],
...@@ -195,18 +356,73 @@ export default function RecordWorkbench({ ...@@ -195,18 +356,73 @@ export default function RecordWorkbench({
}) { }) {
const [drawerRecordName, setDrawerRecordName] = useState(''); const [drawerRecordName, setDrawerRecordName] = useState('');
const [drawerDirty, setDrawerDirty] = useState(false); const [drawerDirty, setDrawerDirty] = useState(false);
const [collapsedColumnGroups, setCollapsedColumnGroups] = useState({});
const loading = Boolean(states?.loading); const loading = Boolean(states?.loading);
const error = states?.error || ''; const error = states?.error || '';
const totalRecords = records.length; const totalRecords = records.length;
const columnGroupMeta = useMemo(
() => buildColumnGroupMeta(columnGroups, columns),
[columnGroups, columns],
);
const visibleColumns = useMemo(
() =>
columns.filter((column) => {
const groupId = columnGroupMeta.fieldToGroup[column.field];
return !groupId || !collapsedColumnGroups[groupId];
}),
[columns, columnGroupMeta.fieldToGroup, collapsedColumnGroups],
);
const orderedVisibleColumns = useMemo(
() =>
orderColumnsByGroups(
visibleColumns,
columnGroupMeta.fieldToGroup,
columnGroupMeta.groupById,
),
[visibleColumns, columnGroupMeta.fieldToGroup, columnGroupMeta.groupById],
);
const selectedRecords = useMemo( const selectedRecords = useMemo(
() => records.filter((record) => selectedIds.includes(getRecordId(record))), () => records.filter((record) => selectedIds.includes(getRecordId(record))),
[records, selectedIds, getRecordId], [records, selectedIds, getRecordId],
); );
const stickyColumns = useMemo( const stickyColumns = useMemo(
() => buildStickyColumnMeta(columns, selectable, rowActions.length > 0, actionColumnWidth), () => buildStickyColumnMeta(orderedVisibleColumns, selectable, rowActions.length > 0, actionColumnWidth),
[columns, selectable, rowActions.length, actionColumnWidth], [orderedVisibleColumns, selectable, rowActions.length, actionColumnWidth],
);
const columnGroupSegments = useMemo(
() =>
buildColumnGroupSegments(
stickyColumns.orderedColumns,
columnGroupMeta.fieldToGroup,
columnGroupMeta.groupById,
),
[stickyColumns.orderedColumns, columnGroupMeta.fieldToGroup, columnGroupMeta.groupById],
); );
const hasColumnGroups = columnGroupMeta.groups.length > 0;
const showColumnGroupHeader = columnGroupSegments.some((segment) => segment.group);
useEffect(() => {
const validGroupIds = new Set(columnGroupMeta.groups.map((group) => group.id));
setCollapsedColumnGroups((current) => {
const nextCollapsedGroups = Object.fromEntries(
Object.entries(current).filter(([groupId]) => validGroupIds.has(groupId)),
);
if (Object.keys(nextCollapsedGroups).length === Object.keys(current).length) {
return current;
}
return nextCollapsedGroups;
});
}, [columnGroupMeta.groups]);
const toggleColumnGroup = (groupId) => {
setCollapsedColumnGroups((current) => ({
...current,
[groupId]: !current[groupId],
}));
};
const openDetail = (recordOrName) => { const openDetail = (recordOrName) => {
const recordName = const recordName =
...@@ -383,12 +599,32 @@ export default function RecordWorkbench({ ...@@ -383,12 +599,32 @@ export default function RecordWorkbench({
) : null} ) : null}
</div> </div>
{hasColumnGroups ? (
<div className="flex flex-wrap items-center gap-2 border-b border-zinc-100 bg-zinc-50/50 px-6 py-3">
<span className="mr-1 text-xs font-medium text-slate-400">列分组</span>
{columnGroupMeta.groups.map((group) => (
<ColumnGroupButton
key={group.id}
group={group}
collapsed={Boolean(collapsedColumnGroups[group.id])}
onToggle={() => toggleColumnGroup(group.id)}
/>
))}
</div>
) : null}
{error ? ( {error ? (
<div className="border-b border-zinc-100 bg-rose-50/70 px-6 py-4 text-sm text-rose-700"> <div className="border-b border-zinc-100 bg-rose-50/70 px-6 py-4 text-sm text-rose-700">
{error} {error}
</div> </div>
) : null} ) : null}
{states?.success ? (
<div className="border-b border-zinc-100 bg-emerald-50/70 px-6 py-4 text-sm text-emerald-700">
{states.success}
</div>
) : null}
{loading ? ( {loading ? (
<div className="px-6 py-6"> <div className="px-6 py-6">
<EmptyState <EmptyState
...@@ -407,8 +643,81 @@ export default function RecordWorkbench({ ...@@ -407,8 +643,81 @@ export default function RecordWorkbench({
<div className="px-3 pb-3 pt-2"> <div className="px-3 pb-3 pt-2">
<Table bleed dense className="[--gutter:--spacing(3)]"> <Table bleed dense className="[--gutter:--spacing(3)]">
<TableHead> <TableHead>
{showColumnGroupHeader ? (
<TableRow className="bg-zinc-50/90">
{selectable ? (
<TableHeader
rowSpan={2}
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}
{columnGroupSegments.map((segment) => {
const firstColumn = segment.columns[0];
const stickyMeta = stickyColumns.stickyMeta[firstColumn.field];
return (
<TableHeader
key={segment.key}
colSpan={segment.columns.length}
style={{
left: stickyMeta?.side === 'left' ? `${stickyMeta.offset}px` : undefined,
right: stickyMeta?.side === 'right' ? `${stickyMeta.offset}px` : undefined,
}}
className={clsx(
'text-xs font-semibold text-slate-500',
segment.group ? 'bg-rose-50/80 text-rose-700' : 'bg-zinc-50/95 text-slate-300',
getStickyCellClass(stickyMeta, 'header'),
)}
>
{segment.group ? (
<button
type="button"
onClick={() => toggleColumnGroup(segment.group.id)}
className="inline-flex min-h-8 items-center gap-2 rounded-full px-2 py-1 transition hover:bg-white/70"
aria-label={`折叠${segment.group.name}`}
>
<span aria-hidden="true">-</span>
<span>{segment.group.name}</span>
<span className="rounded-full bg-white/80 px-2 py-0.5 text-[11px] text-rose-500">
{segment.group.children.length}
</span>
</button>
) : null}
</TableHeader>
);
})}
{rowActions.length > 0 ? (
<TableHeader
rowSpan={2}
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>
) : null}
<TableRow className="bg-zinc-50/80"> <TableRow className="bg-zinc-50/80">
{selectable ? ( {selectable && !showColumnGroupHeader ? (
<TableHeader <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)]" 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 }} style={{ width: SELECTION_COLUMN_WIDTH, minWidth: SELECTION_COLUMN_WIDTH }}
...@@ -452,7 +761,7 @@ export default function RecordWorkbench({ ...@@ -452,7 +761,7 @@ export default function RecordWorkbench({
); );
})} })}
{rowActions.length > 0 ? ( {rowActions.length > 0 && !showColumnGroupHeader ? (
<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" 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 }} style={{ width: actionColumnWidth, minWidth: actionColumnWidth }}
...@@ -535,20 +844,17 @@ export default function RecordWorkbench({ ...@@ -535,20 +844,17 @@ export default function RecordWorkbench({
className="align-top sticky right-0 z-30 bg-white shadow-[-10px_0_20px_rgba(15,23,42,0.05)]" 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 }} style={{ width: actionColumnWidth, minWidth: actionColumnWidth }}
> >
<div className="flex min-w-28 flex-col gap-2"> <div className="flex min-w-36 flex-row flex-wrap justify-end gap-2">
{visibleRowActions.map((action) => { {visibleRowActions.map((action) => {
const disabled = isActionDisabled(action, rowContext); const disabled = isActionDisabled(action, rowContext);
const buttonProps = renderButtonProps(action);
return ( return (
<Button <RowActionButton
key={action.key} key={action.key}
{...buttonProps} action={action}
disabled={disabled} disabled={disabled}
onClick={() => action.onClick?.(rowContext)} onClick={() => action.onClick?.(rowContext)}
> />
{action.label}
</Button>
); );
})} })}
</div> </div>
......
...@@ -15,25 +15,6 @@ import { ...@@ -15,25 +15,6 @@ import {
isViewAccessible, isViewAccessible,
} from '@/utils/subjectView'; } from '@/utils/subjectView';
function formatDateTime(value) {
if (!value) {
return '未记录';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '未记录';
}
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(date);
}
function CloseIcon() { function CloseIcon() {
return ( return (
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className="size-5"> <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className="size-5">
...@@ -141,20 +122,6 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -141,20 +122,6 @@ const RecordDetailDrawer = (props, ref) => {
const activeView = detailViews.find((view) => view.name === activeTabKey) || detailViews[0]; const activeView = detailViews.find((view) => view.name === activeTabKey) || detailViews[0];
const drawerWidth = isFullscreen ? '100vw' : typeof width === 'number' ? `${width}px` : width; const drawerWidth = isFullscreen ? '100vw' : typeof width === 'number' ? `${width}px` : width;
const summaryItems = [
{
label: '主题',
value: subject?.title || subject?.name || '未识别',
},
{
label: '当前视图',
value: activeView?.title || activeView?.name || '基础信息',
},
{
label: '最近更新',
value: formatDateTime(editableRecord?.updated_at),
},
];
const closeDrawer = () => { const closeDrawer = () => {
onClose?.(); onClose?.();
...@@ -176,40 +143,32 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -176,40 +143,32 @@ const RecordDetailDrawer = (props, ref) => {
className="pointer-events-auto h-full transform overflow-hidden border-l border-white/70 bg-[linear-gradient(180deg,#fffdf8_0%,#f8fafc_38%,#f4f4f5_100%)] shadow-[0_24px_80px_rgba(15,23,42,0.24)] ring-1 ring-zinc-950/5 transition duration-300 data-closed:translate-x-full data-enter:ease-out data-leave:ease-in" className="pointer-events-auto h-full transform overflow-hidden border-l border-white/70 bg-[linear-gradient(180deg,#fffdf8_0%,#f8fafc_38%,#f4f4f5_100%)] shadow-[0_24px_80px_rgba(15,23,42,0.24)] ring-1 ring-zinc-950/5 transition duration-300 data-closed:translate-x-full data-enter:ease-out data-leave:ease-in"
> >
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<header className="relative overflow-hidden border-b border-white/70 bg-[linear-gradient(135deg,rgba(255,255,255,0.95),rgba(255,247,237,0.9)_45%,rgba(244,244,245,0.95))] px-6 pb-5 pt-6 shadow-[0_8px_24px_rgba(15,23,42,0.06)] backdrop-blur"> <Headless.DialogTitle className="sr-only">
{editableRecord?.title || editableRecord?.name || '记录详情'}
</Headless.DialogTitle>
<header className="relative border-b border-white/70 bg-white/84 px-6 py-4 shadow-[0_8px_24px_rgba(15,23,42,0.05)] backdrop-blur">
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-rose-200 to-transparent" /> <div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-rose-200 to-transparent" />
<div className="flex items-start justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-[11px] font-semibold uppercase tracking-[0.32em] text-rose-500"> <div className="flex gap-2 overflow-x-auto">
Record Detail {detailViews.map((view) => {
</div> const selected = view.name === activeView?.name;
<Headless.DialogTitle className="mt-2 truncate text-[28px] font-semibold tracking-tight text-zinc-950"> return (
{editableRecord?.title || editableRecord?.name || '记录详情'} <button
</Headless.DialogTitle> key={view.name}
<div className="mt-2 flex flex-wrap items-center gap-2 text-sm text-zinc-500"> type="button"
<span className="inline-flex rounded-full bg-white/90 px-3 py-1 font-medium text-zinc-700 ring-1 ring-white shadow-sm"> onClick={() => setActiveTabKey(view.name)}
{readonly ? '只读模式' : '可编辑'} className={clsx(
</span> 'shrink-0 rounded-full px-4 py-2 text-sm font-medium transition',
{editableRecord?.status ? ( selected
<span className="inline-flex rounded-full bg-rose-50 px-3 py-1 font-medium text-rose-700 ring-1 ring-rose-100"> ? 'bg-[linear-gradient(135deg,#7f1d1d,#be123c)] text-white shadow-lg shadow-rose-200/60'
{editableRecord.status} : 'bg-white/92 text-zinc-600 ring-1 ring-zinc-200/80 hover:bg-rose-50 hover:text-rose-700',
</span> )}
) : null} >
</div> {view.title || view.name}
<div className="mt-4 grid gap-3 md:grid-cols-3"> </button>
{summaryItems.map((item) => ( );
<div })}
key={item.label}
className="rounded-2xl border border-white/80 bg-white/72 px-4 py-3 shadow-sm ring-1 ring-zinc-950/5"
>
<div className="text-xs font-medium uppercase tracking-[0.16em] text-zinc-400">
{item.label}
</div>
<div className="mt-1 truncate text-sm font-semibold text-zinc-800">
{item.value}
</div>
</div>
))}
</div> </div>
</div> </div>
<div className="flex shrink-0 gap-2 rounded-full border border-white/80 bg-white/88 p-1.5 shadow-sm ring-1 ring-zinc-950/5 backdrop-blur"> <div className="flex shrink-0 gap-2 rounded-full border border-white/80 bg-white/88 p-1.5 shadow-sm ring-1 ring-zinc-950/5 backdrop-blur">
...@@ -231,29 +190,6 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -231,29 +190,6 @@ const RecordDetailDrawer = (props, ref) => {
</button> </button>
</div> </div>
</div> </div>
<div className="mt-5">
<div className="flex gap-2 overflow-x-auto">
{detailViews.map((view) => {
const selected = view.name === activeView?.name;
return (
<button
key={view.name}
type="button"
onClick={() => setActiveTabKey(view.name)}
className={clsx(
'shrink-0 rounded-full px-4 py-2 text-sm font-medium transition',
selected
? 'bg-[linear-gradient(135deg,#7f1d1d,#be123c)] text-white shadow-lg shadow-rose-200/60'
: 'bg-white/92 text-zinc-600 ring-1 ring-zinc-200/80 hover:bg-rose-50 hover:text-rose-700',
)}
>
{view.title || view.name}
</button>
);
})}
</div>
</div>
</header> </header>
<main className="min-h-0 flex-1 overflow-y-auto bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.9),rgba(248,250,252,0.96)_32%,rgba(244,244,245,0.98)_100%)] px-6 py-5"> <main className="min-h-0 flex-1 overflow-y-auto bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.9),rgba(248,250,252,0.96)_32%,rgba(244,244,245,0.98)_100%)] px-6 py-5">
......
...@@ -5,6 +5,12 @@ import { useEffect, useMemo, useState } from 'react'; ...@@ -5,6 +5,12 @@ import { useEffect, useMemo, useState } from 'react';
import { loadProblemRectificationDashboardData } from '../problemRectificationDashboardService'; import { loadProblemRectificationDashboardData } from '../problemRectificationDashboardService';
const CHART_COLOR_SET = ['#e11d48', '#fb7185', '#f97316', '#f59e0b', '#22c55e', '#0ea5e9', '#8b5cf6']; const CHART_COLOR_SET = ['#e11d48', '#fb7185', '#f97316', '#f59e0b', '#22c55e', '#0ea5e9', '#8b5cf6'];
const CATEGORY_LEVEL_TABS = [
{ key: 'level1', label: '一级分类' },
{ key: 'level2', label: '二级分类' },
{ key: 'level3', label: '三级分类' },
{ key: 'level4', label: '四级分类' },
];
function formatPercent(value) { function formatPercent(value) {
const numericValue = Number(value || 0); const numericValue = Number(value || 0);
...@@ -22,14 +28,6 @@ function formatCount(value) { ...@@ -22,14 +28,6 @@ function formatCount(value) {
return `${numericValue}`; return `${numericValue}`;
} }
function formatScore(value) {
const numericValue = Number(value || 0);
if (!Number.isFinite(numericValue)) {
return '0.00';
}
return numericValue.toFixed(2);
}
function toneCardClass(tone) { function toneCardClass(tone) {
if (tone === 'rose') { if (tone === 'rose') {
return 'border-rose-200 bg-[linear-gradient(135deg,rgba(255,241,242,0.95),rgba(255,255,255,0.9))]'; return 'border-rose-200 bg-[linear-gradient(135deg,rgba(255,241,242,0.95),rgba(255,255,255,0.9))]';
...@@ -191,51 +189,69 @@ function buildDonutOption(data, colors) { ...@@ -191,51 +189,69 @@ function buildDonutOption(data, colors) {
}; };
} }
function buildTreemapOption(data = []) { function buildCategoryBarOption(data = []) {
return { return {
color: ['#e11d48'],
tooltip: { tooltip: {
formatter: (params) => `${params.name}: ${params.value || 0} 条`, trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: (params) => {
const item = params[0];
return `${item?.axisValueLabel || ''}: ${item?.value || 0} 条`;
},
backgroundColor: 'rgba(15,23,42,0.9)', backgroundColor: 'rgba(15,23,42,0.9)',
borderWidth: 0, borderWidth: 0,
textStyle: { color: '#fff' }, textStyle: { color: '#fff' },
}, },
grid: {
left: 18,
right: 56,
top: 20,
bottom: 18,
containLabel: true,
},
xAxis: {
type: 'value',
minInterval: 1,
splitLine: { lineStyle: { color: 'rgba(148,163,184,0.18)' } },
axisLabel: { color: '#64748b', margin: 10 },
axisTick: { show: false },
axisLine: { lineStyle: { color: '#e2e8f0' } },
},
yAxis: {
type: 'category',
data: data.map((item) => item.name),
axisTick: { show: false },
axisLine: { show: false },
axisLabel: {
color: '#334155',
fontSize: 12,
margin: 12,
width: 116,
overflow: 'truncate',
},
},
series: [ series: [
{ {
type: 'treemap', type: 'bar',
roam: false, data: data.map((item) => item.value),
breadcrumb: { show: false }, barMaxWidth: 22,
nodeClick: false, showBackground: true,
upperLabel: { backgroundStyle: {
show: true, color: 'rgba(148,163,184,0.08)',
height: 26, borderRadius: 99,
color: '#0f172a', },
itemStyle: {
borderRadius: [0, 99, 99, 0],
}, },
label: { label: {
show: true, show: true,
color: '#fff', position: 'right',
fontSize: 12, distance: 8,
formatter: '{b}', formatter: '{c}',
}, color: '#0f172a',
itemStyle: { fontWeight: 600,
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,
}, },
], ],
}; };
...@@ -494,198 +510,43 @@ function buildTimelinessScatterOption(data = []) { ...@@ -494,198 +510,43 @@ function buildTimelinessScatterOption(data = []) {
}; };
} }
function buildScoreBarOption(data, color) { function buildQualityRatingOption(data) {
return { return {
color: [color], color: ['#22c55e', '#f59e0b', '#e11d48'],
tooltip: { tooltip: {
trigger: 'axis', trigger: 'item',
axisPointer: { type: 'shadow' },
backgroundColor: 'rgba(15,23,42,0.9)', backgroundColor: 'rgba(15,23,42,0.9)',
borderWidth: 0, borderWidth: 0,
textStyle: { color: '#fff' }, textStyle: { color: '#fff' },
}, },
grid: { legend: {
left: 8, bottom: 0,
right: 20, icon: 'circle',
top: 12, textStyle: { color: '#475569' },
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: [ series: [
{ {
type: 'bar', type: 'pie',
data: data.map((item) => item.value), radius: ['54%', '76%'],
barWidth: 18, center: ['50%', '42%'],
showBackground: true, avoidLabelOverlap: true,
backgroundStyle: {
color: 'rgba(148,163,184,0.08)',
borderRadius: 99,
},
itemStyle: { itemStyle: {
borderRadius: [99, 99, 99, 99], borderRadius: 12,
}, borderColor: '#fff',
label: { borderWidth: 4,
show: true,
position: 'right',
formatter: (params) => formatScore(params.value),
color: '#0f172a',
fontWeight: 600,
}, },
},
],
};
}
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: { label: {
show: true, show: true,
color: '#0f172a', formatter: '{b|{b}}\n{c|{c} 条}',
fontWeight: 600, rich: {
}, b: { fontSize: 13, fontWeight: 700, color: '#0f172a' },
emphasis: { c: { fontSize: 12, color: '#64748b' },
itemStyle: {
shadowBlur: 12,
shadowColor: 'rgba(15,23,42,0.18)',
}, },
}, },
}, labelLine: {
], lineStyle: { color: '#cbd5e1' },
};
}
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))), data,
}, },
], ],
}; };
...@@ -693,6 +554,7 @@ function buildRatingStackOption(data) { ...@@ -693,6 +554,7 @@ function buildRatingStackOption(data) {
export default function ProblemRectificationDashboardPage() { export default function ProblemRectificationDashboardPage() {
const [activeGroupKey, setActiveGroupKey] = useState('all'); const [activeGroupKey, setActiveGroupKey] = useState('all');
const [activeCategoryLevel, setActiveCategoryLevel] = useState('level1');
const [dashboardData, setDashboardData] = useState({ const [dashboardData, setDashboardData] = useState({
groups: [], groups: [],
summaries: {}, summaries: {},
...@@ -727,7 +589,12 @@ export default function ProblemRectificationDashboardPage() { ...@@ -727,7 +589,12 @@ export default function ProblemRectificationDashboardPage() {
overdueCount: 0, overdueCount: 0,
overdueRate: 0, overdueRate: 0,
progressDistribution: [], progressDistribution: [],
categoryTreemap: [], categoryDistributions: {
level1: [],
level2: [],
level3: [],
level4: [],
},
rootCauseDistribution: [], rootCauseDistribution: [],
sourceComparison: { categories: [], series: [] }, sourceComparison: { categories: [], series: [] },
focusIssueGroups: { focusIssueGroups: {
...@@ -738,25 +605,17 @@ export default function ProblemRectificationDashboardPage() { ...@@ -738,25 +605,17 @@ export default function ProblemRectificationDashboardPage() {
overdueRanking: [], overdueRanking: [],
departmentMatrix: [], departmentMatrix: [],
timelinessScatter: [], timelinessScatter: [],
timelinessScoreRanking: [], qualityRatingDistribution: [],
qualityScoreRanking: [],
policyRevisionCount: 0,
systemConstructionCount: 0,
severityRatingHeatmap: {
xLabels: [],
yLabels: [],
data: [],
},
departmentRatingDistribution: [],
}; };
const progressOption = useMemo( const progressOption = useMemo(
() => buildDonutOption(summary.progressDistribution, CHART_COLOR_SET), () => buildDonutOption(summary.progressDistribution, CHART_COLOR_SET),
[summary.progressDistribution], [summary.progressDistribution],
); );
const activeCategoryData = summary.categoryDistributions?.[activeCategoryLevel] || [];
const categoryOption = useMemo( const categoryOption = useMemo(
() => buildTreemapOption(summary.categoryTreemap), () => buildCategoryBarOption(activeCategoryData),
[summary.categoryTreemap], [activeCategoryData],
); );
const rootCauseOption = useMemo( const rootCauseOption = useMemo(
() => buildDonutOption(summary.rootCauseDistribution, CHART_COLOR_SET), () => buildDonutOption(summary.rootCauseDistribution, CHART_COLOR_SET),
...@@ -778,21 +637,9 @@ export default function ProblemRectificationDashboardPage() { ...@@ -778,21 +637,9 @@ export default function ProblemRectificationDashboardPage() {
() => buildTimelinessScatterOption(summary.timelinessScatter), () => buildTimelinessScatterOption(summary.timelinessScatter),
[summary.timelinessScatter], [summary.timelinessScatter],
); );
const timelinessScoreOption = useMemo( const qualityRatingOption = useMemo(
() => buildScoreBarOption(summary.timelinessScoreRanking, '#f59e0b'), () => buildQualityRatingOption(summary.qualityRatingDistribution),
[summary.timelinessScoreRanking], [summary.qualityRatingDistribution],
);
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); const activeGroup = groups.find((item) => item.key === activeGroupKey);
...@@ -941,9 +788,31 @@ export default function ProblemRectificationDashboardPage() { ...@@ -941,9 +788,31 @@ export default function ProblemRectificationDashboardPage() {
<div className="grid gap-6 xl:grid-cols-[1.05fr_0.95fr]"> <div className="grid gap-6 xl:grid-cols-[1.05fr_0.95fr]">
<Panel <Panel
title="问题类型 / 业务领域分布" title="问题类型 / 业务领域分布"
description="按问题一级至四级分类构造矩形树图,帮助定位高发领域。" description="切换查看问题一级至四级分类数量分布,帮助定位高发领域。"
> >
{summary.categoryTreemap.length > 0 ? ( <div className="mb-5 flex flex-wrap gap-2">
{CATEGORY_LEVEL_TABS.map((tab) => {
const isActive = activeCategoryLevel === tab.key;
return (
<button
key={tab.key}
type="button"
aria-pressed={isActive}
className={clsx(
'min-h-11 rounded-full border px-4 text-sm font-medium transition',
isActive
? 'border-rose-500 bg-rose-500 text-white shadow-sm'
: 'border-zinc-200 bg-white text-slate-600 hover:border-rose-200 hover:bg-rose-50 hover:text-rose-700',
)}
onClick={() => setActiveCategoryLevel(tab.key)}
>
{tab.label}
</button>
);
})}
</div>
{activeCategoryData.length > 0 ? (
<ReactECharts option={categoryOption} style={{ height: 360 }} /> <ReactECharts option={categoryOption} style={{ height: 360 }} />
) : ( ) : (
<EmptyBlock title="暂无问题领域统计数据" /> <EmptyBlock title="暂无问题领域统计数据" />
...@@ -1039,69 +908,16 @@ export default function ProblemRectificationDashboardPage() { ...@@ -1039,69 +908,16 @@ export default function ProblemRectificationDashboardPage() {
<SectionHeading <SectionHeading
eyebrow="Quality" eyebrow="Quality"
title="审计整改质效" title="审计整改质效"
description="从整改时间评估、材料质量、制度与系统助力,以及风险和整改评级匹配度四个角度审视整改成效。" 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 <Panel
title="部门综合整改评定等级排行" title="综合整改质效结果"
description="按高评级占比降序展示各责任部门低、中、高评级结构,便于横向比较整改质量。" description="按高、中、低展示最终整改质效等级;暂按“两个及以上高为高、出现低则低、一个高则中”的初步规则计算,具体维度和规则后续确认。"
> >
{summary.departmentRatingDistribution.length > 0 ? ( {summary.qualityRatingDistribution.length > 0 ? (
<ReactECharts option={ratingStackOption} style={{ height: 420 }} /> <ReactECharts option={qualityRatingOption} style={{ height: 380 }} />
) : ( ) : (
<EmptyBlock title="暂无部门整改评级分布数据" /> <EmptyBlock title="暂无综合整改质效结果数据" />
)} )}
</Panel> </Panel>
</section> </section>
......
...@@ -14,6 +14,7 @@ import RecordDetailDrawer from '@/pages/RecordDetailDrawer'; ...@@ -14,6 +14,7 @@ import RecordDetailDrawer from '@/pages/RecordDetailDrawer';
import { import {
batchRemoveRecords, batchRemoveRecords,
batchUpdateRecords, batchUpdateRecords,
cloneRecord,
createNotice, createNotice,
createRecord, createRecord,
executeAction, executeAction,
...@@ -491,13 +492,16 @@ export default function ProblemRectificationListPage() { ...@@ -491,13 +492,16 @@ export default function ProblemRectificationListPage() {
const [selectedIds, setSelectedIds] = useState([]); const [selectedIds, setSelectedIds] = useState([]);
const [showCreateModal, setShowCreateModal] = useState(false); const [showCreateModal, setShowCreateModal] = useState(false);
const [showPushModal, setShowPushModal] = useState(false); const [showPushModal, setShowPushModal] = useState(false);
const [cloneTargetRecord, setCloneTargetRecord] = useState(null);
const [formValues, setFormValues] = useState({}); const [formValues, setFormValues] = useState({});
const [fillerUsers, setFillerUsers] = useState([]); const [fillerUsers, setFillerUsers] = useState([]);
const [auditorUsers, setAuditorUsers] = useState([]); const [auditorUsers, setAuditorUsers] = useState([]);
const [successMessage, setSuccessMessage] = useState('');
const reloadList = useCallback(async () => { const reloadList = useCallback(async () => {
setLoading(true); setLoading(true);
setError(''); setError('');
setSuccessMessage('');
try { try {
const resources = await loadProblemRectificationListResources(); const resources = await loadProblemRectificationListResources();
...@@ -590,6 +594,7 @@ export default function ProblemRectificationListPage() { ...@@ -590,6 +594,7 @@ export default function ProblemRectificationListPage() {
const pendingAuditCount = filteredRecords.filter( const pendingAuditCount = filteredRecords.filter(
(item) => item.auditStatus && item.auditStatus !== '已审批', (item) => item.auditStatus && item.auditStatus !== '已审批',
).length; ).length;
const quickSelectEnabled = activeGridView?.settings?.quickSelect !== false;
const handleCreate = async () => { const handleCreate = async () => {
if ( if (
...@@ -657,6 +662,34 @@ export default function ProblemRectificationListPage() { ...@@ -657,6 +662,34 @@ export default function ProblemRectificationListPage() {
} }
}; };
const handleClone = async () => {
if (!cloneTargetRecord) {
return;
}
const recordName = cloneTargetRecord.name || cloneTargetRecord.id;
if (!recordName) {
setError('当前记录缺少可复制的 name 标识。');
return;
}
setActionLoading(true);
setError('');
setSuccessMessage('');
try {
await cloneRecord(recordName);
const title = cloneTargetRecord.title || cloneTargetRecord.projectName || cloneTargetRecord.name || '当前记录';
setCloneTargetRecord(null);
await reloadList();
setSuccessMessage(`已复制“${title}”,新记录已加入当前列表。`);
} catch (requestError) {
setError(requestError?.message || '复制问题整改记录失败,请稍后重试。');
} finally {
setActionLoading(false);
}
};
const handleSearchUsers = useCallback(async (keyword) => { const handleSearchUsers = useCallback(async (keyword) => {
const filter = keyword.trim() const filter = keyword.trim()
? { ? {
...@@ -916,9 +949,11 @@ export default function ProblemRectificationListPage() { ...@@ -916,9 +949,11 @@ export default function ProblemRectificationListPage() {
title="问题清单" title="问题清单"
description={`当前视图显示 ${filteredRecords.length} 条记录,已选中 ${selectedIds.length} 条,其中重点后果问题 ${consequenceCount} 条、逾期未完成 ${overdueCount} 条。`} description={`当前视图显示 ${filteredRecords.length} 条记录,已选中 ${selectedIds.length} 条,其中重点后果问题 ${consequenceCount} 条、逾期未完成 ${overdueCount} 条。`}
columns={configuredView.columns || []} columns={configuredView.columns || []}
columnGroups={activeGridView?.settings?.groups || []}
records={filteredRecords} records={filteredRecords}
selectedIds={selectedIds} selectedIds={selectedIds}
onSelectionChange={setSelectedIds} onSelectionChange={setSelectedIds}
selectable={quickSelectEnabled}
toolbarActions={[ toolbarActions={[
{ {
key: 'create', key: 'create',
...@@ -964,6 +999,13 @@ export default function ProblemRectificationListPage() { ...@@ -964,6 +999,13 @@ export default function ProblemRectificationListPage() {
variant: 'plain', variant: 'plain',
onClick: ({ openDetail, record }) => openDetail(record), onClick: ({ openDetail, record }) => openDetail(record),
}, },
{
key: 'clone',
label: '复制',
variant: 'outline',
disabled: () => actionLoading,
onClick: ({ record }) => setCloneTargetRecord(record),
},
{ {
key: 'delete', key: 'delete',
label: '删除', label: '删除',
...@@ -988,6 +1030,7 @@ export default function ProblemRectificationListPage() { ...@@ -988,6 +1030,7 @@ export default function ProblemRectificationListPage() {
states={{ states={{
loading, loading,
error, error,
success: successMessage,
loadingTitle: '正在加载问题整改列表', loadingTitle: '正在加载问题整改列表',
loadingDescription: '正在读取对象结构、视图配置和记录数据,请稍候。', loadingDescription: '正在读取对象结构、视图配置和记录数据,请稍候。',
emptyTitle: '当前视图暂无数据', emptyTitle: '当前视图暂无数据',
...@@ -1116,6 +1159,31 @@ export default function ProblemRectificationListPage() { ...@@ -1116,6 +1159,31 @@ export default function ProblemRectificationListPage() {
</DialogActions> </DialogActions>
</Dialog> </Dialog>
) : null} ) : null}
{cloneTargetRecord ? (
<Dialog open={Boolean(cloneTargetRecord)} onClose={() => setCloneTargetRecord(null)} size="lg">
<DialogTitle>复制问题整改记录</DialogTitle>
<DialogDescription>
将基于当前记录复制一条新记录,复制完成后会刷新列表。
</DialogDescription>
<DialogBody>
<div className="rounded-[24px] border border-zinc-200 bg-zinc-50/80 p-4">
<div className="text-sm font-medium text-slate-900">待复制记录</div>
<div className="mt-2 text-sm leading-6 text-slate-600">
{cloneTargetRecord.title || cloneTargetRecord.projectName || cloneTargetRecord.name || '当前记录'}
</div>
</div>
</DialogBody>
<DialogActions>
<Button outline disabled={actionLoading} onClick={() => setCloneTargetRecord(null)}>
取消
</Button>
<Button color="blue" disabled={actionLoading} onClick={handleClone}>
{actionLoading ? '复制中...' : '确认复制'}
</Button>
</DialogActions>
</Dialog>
) : null}
</> </>
); );
} }
...@@ -56,7 +56,9 @@ ...@@ -56,7 +56,9 @@
问题整改主列表与详情抽屉统一以 `getSubjectView(subjectName)` 返回的视图配置驱动,不再在页面内写死部门标签和字段顺序: 问题整改主列表与详情抽屉统一以 `getSubjectView(subjectName)` 返回的视图配置驱动,不再在页面内写死部门标签和字段顺序:
- `type = "grid"` 的视图用于主列表顶部视图切换,替代原先写死的 tab - `type = "grid"` 的视图用于主列表顶部视图切换,替代原先写死的 tab
- `settings.columns` 决定列表列顺序、宽度和排序优先级 - `settings.columns` 决定列表列顺序、宽度、固定列位置和排序优先级,列表页不得在视图列后追加自定义业务字段列
- `settings.groups` 决定列表列分组,例如把多个问题分类字段归入同一分组;前端需展示分组表头,同一分组下的列即使在 `settings.columns` 中不连续,也应在表格内聚拢展示,并支持每个分组独立折叠 / 展开,折叠后隐藏该分组子列
- `settings.quickSelect` 决定列表是否展示勾选列,用于批量推送、删除等操作
- `settings.restrictions` 与 `settings.filters.conditions` 共同决定该视图下可见的数据范围 - `settings.restrictions` 与 `settings.filters.conditions` 共同决定该视图下可见的数据范围
- `settings.viewAuth` 决定当前用户是否能看到该视图 - `settings.viewAuth` 决定当前用户是否能看到该视图
- `type = "detail"` 的视图用于详情抽屉字段编排,按 `columns` 顺序渲染字段 - `type = "detail"` 的视图用于详情抽屉字段编排,按 `columns` 顺序渲染字段
...@@ -69,6 +71,8 @@ ...@@ -69,6 +71,8 @@
- `problem_rectification` 与 `issue_rectification_archive` 列表页统一复用 `RecordWorkbench` 作为“结构化表格 + 工具栏 + 详情抽屉入口”的工作台容器 - `problem_rectification` 与 `issue_rectification_archive` 列表页统一复用 `RecordWorkbench` 作为“结构化表格 + 工具栏 + 详情抽屉入口”的工作台容器
- 页面本身只负责提供 subject 对应的数据加载、统计口径、筛选 schema、行操作与批量操作 - 页面本身只负责提供 subject 对应的数据加载、统计口径、筛选 schema、行操作与批量操作
- 详情侧栏统一复用 `RecordDetailDrawer`,通过 `recordName`、`subjectName`、`preferredViewName` 驱动,不再在各页面重复实现抽屉骨架 - 详情侧栏统一复用 `RecordDetailDrawer`,通过 `recordName`、`subjectName`、`preferredViewName` 驱动,不再在各页面重复实现抽屉骨架
- 详情侧栏顶部不展示记录标题、状态和摘要卡片,只保留详情视图切换与全屏 / 关闭操作,字段主体作为主要阅读区域
- 列表行操作列使用图标按钮横向排列,查看、复制、删除等常用操作通过 tooltip / aria-label 标明含义,避免文字按钮纵向堆叠
- 新增同类列表页时,优先复用工作台与详情抽屉组合,避免再次复制问题整改页 JSX - 新增同类列表页时,优先复用工作台与详情抽屉组合,避免再次复制问题整改页 JSX
### 3.1 目标页面拆分 ### 3.1 目标页面拆分
...@@ -134,10 +138,11 @@ ...@@ -134,10 +138,11 @@
旧应用 `problem_rectification` 列表配置中实际暴露了 5 个操作: 旧应用 `problem_rectification` 列表配置中实际暴露了 5 个操作:
1. 查看详情 1. 查看详情
2. 删除数据 2. 复制数据
3. 推送问题整改表 3. 删除数据
4. 导出问题整改表 4. 推送问题整改表
5. 一键归档 5. 导出问题整改表
6. 一键归档
这些操作分别对应如下业务含义: 这些操作分别对应如下业务含义:
...@@ -146,12 +151,19 @@ ...@@ -146,12 +151,19 @@
- 查看问题记录完整字段 - 查看问题记录完整字段
- 支持后续在详情中编辑整改情况、证明材料、审批状态 - 支持后续在详情中编辑整改情况、证明材料、审批状态
### 5.2 删除数据 ### 5.2 复制数据
- 在问题整改列表的单行操作中触发
- 弹窗确认后调用 `cloneRecord(record.name)`,由 `/record/clone` 基于当前记录生成新记录
- 复制完成后刷新当前列表,并展示成功提示
- 复制操作不改变原记录的整改、复核或归档状态
### 5.3 删除数据
- 支持删除单条或批量删除选中项 - 支持删除单条或批量删除选中项
- 本质调用 `/record/batch/remove` - 本质调用 `/record/batch/remove`
### 5.3 推送问题整改表 ### 5.4 推送问题整改表
- 从已选问题记录中选择填报人和审核人 - 从已选问题记录中选择填报人和审核人
- 创建一条 `message_reference_id` 流程主单 - 创建一条 `message_reference_id` 流程主单
...@@ -160,19 +172,19 @@ ...@@ -160,19 +172,19 @@
- 给填报人发送内部通知 - 给填报人发送内部通知
- 创建 OA 待办,链接指向 `/problem_rectification_form?id=<message_record_name>` - 创建 OA 待办,链接指向 `/problem_rectification_form?id=<message_record_name>`
### 5.4 导出问题整改表 ### 5.5 导出问题整改表
- 按选中问题导出 Excel - 按选中问题导出 Excel
- 导出字段来自 `problem_rectification.metadata` - 导出字段来自 `problem_rectification.metadata`
- 本质是把主表字段映射成报表列,不参与状态流转 - 本质是把主表字段映射成报表列,不参与状态流转
### 5.5 一键归档 ### 5.6 一键归档
- 将选中问题记录导入 `problem_rectification_archive_records` - 将选中问题记录导入 `problem_rectification_archive_records`
- 再创建一条 `issue_rectification_archive` 记录,保存归档明细引用列表 - 再创建一条 `issue_rectification_archive` 记录,保存归档明细引用列表
- 归档的本质是生成快照,不应直接依赖原始问题记录后续继续存在 - 归档的本质是生成快照,不应直接依赖原始问题记录后续继续存在
### 5.6 旧多维表格能力盘点 ### 5.7 旧多维表格能力盘点
实际页面检查后,旧系统“问题整改”和“往期材料归档”页都运行在同一套多维表格容器上,页面共有能力包括: 实际页面检查后,旧系统“问题整改”和“往期材料归档”页都运行在同一套多维表格容器上,页面共有能力包括:
...@@ -187,6 +199,7 @@ ...@@ -187,6 +199,7 @@
- 搜索 / 筛选 - 搜索 / 筛选
- 批量勾选 - 批量勾选
- 查看详情 - 查看详情
- 复制数据
- 推送整改表 - 推送整改表
- 导出 - 导出
- 一键归档 - 一键归档
...@@ -444,7 +457,7 @@ ...@@ -444,7 +457,7 @@
当前图表与字段映射: 当前图表与字段映射:
- 问题类型 / 业务领域分布:优先使用 `problem_category_level_1` 至 `problem_category_level_4` 生成矩形树图;若分类未填写,则回退到 `related_issue_domain` - 问题类型 / 业务领域分布:使用 `problem_category_level_1` 至 `problem_category_level_4` 分别生成四个层级 tab,切换后以横向柱状图展示当前层级各分类数量;一级分类缺失时回退到 `related_issue_domain`,其余层级缺失时归入“未分类”
- 问题根本原因分析:使用 `problem_root_cause` 聚合 - 问题根本原因分析:使用 `problem_root_cause` 聚合
- 问题来源对比:以 `problem_from` 为横轴,并按 `inspect_the_implementing_unit` 做堆叠比较 - 问题来源对比:以 `problem_from` 为横轴,并按 `inspect_the_implementing_unit` 做堆叠比较
- 重点问题集中展示: - 重点问题集中展示:
...@@ -479,17 +492,16 @@ ...@@ -479,17 +492,16 @@
当前图表与字段映射: 当前图表与字段映射:
- 问题整改时效:按责任部门计算 `rectification_timeliness_level` 平均分,低=1、中=2、高=3 - 驾驶舱只展示最终“综合整改质效结果”的高 / 中 / 低分布,不再展示整改时间评估、整改证明材料质量、制度修订、系统建设、往期发现、重复出现等明细维度
- 问题整改质量:按责任部门计算 `rectification_material_quality_level` 平均分,低=1、中=2、高=3 - 明细维度仍在维护页录入和校验,包括:
- 问题整改助力总结: - `rectification_timeliness_level`:整改时间评估,低 / 中 / 高
- 制度修订数量:`involves_policy_revision = 是` - `rectification_material_quality_level`:整改证明材料质量,低 / 中 / 高
- 系统建设数量:`involves_system_construction = 是` - `involves_policy_revision`、`policy_revision_name`:是否涉及制度修订及制度名称
- 风险-质量热力图: - `involves_system_construction`、`system_construction_name`:是否涉及系统建设及系统名称
- X 轴:`problem_severity_level` - `is_historical_problem`:是否为往期发现问题
- Y 轴:`rectification_overall_rating` - `is_repeated_problem`:问题是否重复出现
- 部门综合整改评定等级排行: - `rectification_overall_rating`:综合整改评定等级,低 / 中 / 高
- 以责任部门为维度统计 `rectification_overall_rating` 的低/中/高占比 - 当前驾驶舱优先使用已维护的 `rectification_overall_rating`;若该字段为空,则按初步规则从明细维度推导结果:两个及以上高为高;出现低则低;一个高则中。具体参与维度和最终规则后续需业务确认
- 按高评级占比降序展示
--- ---
......
...@@ -35,11 +35,6 @@ const FIXED_PROGRESS_VALUES = new Set(['已整改', '已优化']); ...@@ -35,11 +35,6 @@ const FIXED_PROGRESS_VALUES = new Set(['已整改', '已优化']);
const POSITIVE_FLAGS = new Set(['是', '有', '涉及', 'TRUE', '1', 'Y', 'YES']); const POSITIVE_FLAGS = new Set(['是', '有', '涉及', 'TRUE', '1', 'Y', 'YES']);
const SEVERITY_LEVELS = ['低', '中', '高']; const SEVERITY_LEVELS = ['低', '中', '高'];
const RATING_LEVELS = ['低', '中', '高']; const RATING_LEVELS = ['低', '中', '高'];
const SCORE_LEVEL_MAP = {
低: 1,
中: 2,
高: 3,
};
function normalizeText(value) { function normalizeText(value) {
return String(value ?? '').trim(); return String(value ?? '').trim();
...@@ -113,6 +108,42 @@ function isFixedProblem(record) { ...@@ -113,6 +108,42 @@ function isFixedProblem(record) {
return FIXED_PROGRESS_VALUES.has(normalizeText(record?.metadata?.rectification_progress)); return FIXED_PROGRESS_VALUES.has(normalizeText(record?.metadata?.rectification_progress));
} }
function normalizeRatingLevel(value) {
const text = normalizeText(value);
return RATING_LEVELS.includes(text) ? text : '';
}
function calculateQualityRating(record) {
const explicitRating = normalizeRatingLevel(record.overallRating);
if (explicitRating) {
return explicitRating;
}
const dimensions = [
normalizeRatingLevel(record.timelinessLevel),
normalizeRatingLevel(record.materialQualityLevel),
record.involvesPolicyRevision ? '高' : '',
record.involvesSystemConstruction ? '高' : '',
record.isHistoricalProblem ? '低' : '',
record.isRepeatedProblem ? '低' : '',
].filter(Boolean);
if (dimensions.length === 0) {
return '';
}
if (dimensions.includes('低')) {
return '低';
}
const highCount = dimensions.filter((item) => item === '高').length;
if (highCount >= 2) {
return '高';
}
return '中';
}
function extractPrimaryDepartment(metadata) { function extractPrimaryDepartment(metadata) {
const primary = normalizeText(metadata.primary_responsible_department); const primary = normalizeText(metadata.primary_responsible_department);
if (primary) { if (primary) {
...@@ -151,39 +182,16 @@ function buildDistributionMap(records, getKey) { ...@@ -151,39 +182,16 @@ function buildDistributionMap(records, getKey) {
); );
} }
function upsertTreeNode(nodes, name) { function buildCategoryDistributions(records) {
let existing = nodes.find((node) => node.name === name); return {
if (!existing) { level1: buildDistributionMap(
existing = { name, value: 0, children: [] }; records,
nodes.push(existing); (item) => item.categoryLevel1 || item.relatedIssueDomain || '未分类',
} ),
existing.value += 1; level2: buildDistributionMap(records, (item) => item.categoryLevel2 || '未分类'),
return existing; level3: buildDistributionMap(records, (item) => item.categoryLevel3 || '未分类'),
} level4: buildDistributionMap(records, (item) => item.categoryLevel4 || '未分类'),
};
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) { function buildSourceComparison(records) {
...@@ -306,15 +314,6 @@ function collectDepartmentStats(records) { ...@@ -306,15 +314,6 @@ function collectDepartmentStats(records) {
totalCount: 0, totalCount: 0,
fixedCount: 0, fixedCount: 0,
overdueCount: 0, overdueCount: 0,
timelinessScoreTotal: 0,
timelinessScoreCount: 0,
qualityScoreTotal: 0,
qualityScoreCount: 0,
ratingCounts: {
低: 0,
中: 0,
高: 0,
},
}); });
} }
...@@ -328,36 +327,12 @@ function collectDepartmentStats(records) { ...@@ -328,36 +327,12 @@ function collectDepartmentStats(records) {
if (record.isOverdue) { if (record.isOverdue) {
stats.overdueCount += 1; 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) => ({ return Array.from(departmentMap.values()).map((item) => ({
...item, ...item,
completionRate: item.totalCount > 0 ? (item.fixedCount / item.totalCount) * 100 : 0, 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,
})); }));
} }
...@@ -412,58 +387,25 @@ function buildTimelinessScatter(records) { ...@@ -412,58 +387,25 @@ function buildTimelinessScatter(records) {
.slice(0, 120); .slice(0, 120);
} }
function buildScoreRanking(departmentStats, key) { function buildQualityRatingDistribution(records) {
return [...departmentStats] const counts = {
.filter((item) => item[key] > 0) 高: 0,
.sort((left, right) => right[key] - left[key]) 中: 0,
.map((item) => ({ 低: 0,
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) { records.forEach((record) => {
return [...departmentStats] if (record.qualityRating && counts[record.qualityRating] !== undefined) {
.filter((item) => item.totalCount > 0) counts[record.qualityRating] += 1;
.map((item) => { }
const high = item.ratingCounts.高 || 0; });
const medium = item.ratingCounts.中 || 0;
const low = item.ratingCounts.低 || 0;
const ratedTotal = high + medium + low;
return { return ['高', '中', '低']
name: item.name, .map((level) => ({
low, name: level,
medium, value: counts[level],
high, }))
lowPercent: ratedTotal > 0 ? (low / ratedTotal) * 100 : 0, .filter((item) => item.value > 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) { function summarizeRecords(records) {
...@@ -481,19 +423,14 @@ function summarizeRecords(records) { ...@@ -481,19 +423,14 @@ function summarizeRecords(records) {
overdueCount, overdueCount,
overdueRate, overdueRate,
progressDistribution: buildDistributionMap(records, (item) => item.progress || '未填写'), progressDistribution: buildDistributionMap(records, (item) => item.progress || '未填写'),
categoryTreemap: buildCategoryTreemap(records), categoryDistributions: buildCategoryDistributions(records),
rootCauseDistribution: buildDistributionMap(records, (item) => item.rootCause || '未分类原因'), rootCauseDistribution: buildDistributionMap(records, (item) => item.rootCause || '未分类原因'),
sourceComparison: buildSourceComparison(records), sourceComparison: buildSourceComparison(records),
focusIssueGroups: buildFocusIssueGroups(records), focusIssueGroups: buildFocusIssueGroups(records),
overdueRanking: buildOverdueRanking(departmentStats), overdueRanking: buildOverdueRanking(departmentStats),
departmentMatrix: buildDepartmentMatrix(departmentStats), departmentMatrix: buildDepartmentMatrix(departmentStats),
timelinessScatter: buildTimelinessScatter(records), timelinessScatter: buildTimelinessScatter(records),
timelinessScoreRanking: buildScoreRanking(departmentStats, 'timelinessAverage'), qualityRatingDistribution: buildQualityRatingDistribution(records),
qualityScoreRanking: buildScoreRanking(departmentStats, 'qualityAverage'),
policyRevisionCount: records.filter((item) => item.involvesPolicyRevision).length,
systemConstructionCount: records.filter((item) => item.involvesSystemConstruction).length,
severityRatingHeatmap: buildSeverityRatingHeatmap(records),
departmentRatingDistribution: buildDepartmentRatingDistribution(departmentStats),
}; };
} }
...@@ -514,7 +451,7 @@ function normalizeProblemRecord(record) { ...@@ -514,7 +451,7 @@ function normalizeProblemRecord(record) {
const delayedByPending = dueDate && dueDate.isBefore(now, 'day') && !isFixed; const delayedByPending = dueDate && dueDate.isBefore(now, 'day') && !isFixed;
const isDelayed = Boolean(delayedByProgress || delayedByActual || delayedByPending); const isDelayed = Boolean(delayedByProgress || delayedByActual || delayedByPending);
return { const normalizedRecord = {
id: record?._id || record?.name || '', id: record?._id || record?.name || '',
title: normalizeText(record?.title) || normalizeText(metadata.project_name) || '-', title: normalizeText(record?.title) || normalizeText(metadata.project_name) || '-',
projectName: normalizeText(metadata.project_name), projectName: normalizeText(metadata.project_name),
...@@ -562,6 +499,11 @@ function normalizeProblemRecord(record) { ...@@ -562,6 +499,11 @@ function normalizeProblemRecord(record) {
isDelayed, isDelayed,
isOverdue: delayedByPending, isOverdue: delayedByPending,
}; };
return {
...normalizedRecord,
qualityRating: calculateQualityRating(normalizedRecord),
};
} }
function buildDepartmentBuckets(records) { function buildDepartmentBuckets(records) {
......
...@@ -22,13 +22,6 @@ import { ...@@ -22,13 +22,6 @@ import {
export const PROBLEM_RECTIFICATION_SUBJECT = 'problem_rectification'; export const PROBLEM_RECTIFICATION_SUBJECT = 'problem_rectification';
const FIXED_PROGRESS_VALUES = new Set(['已整改', '已优化']); const FIXED_PROGRESS_VALUES = new Set(['已整改', '已优化']);
const LEVEL_ORDER = ['高', '中', '低']; 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 = [ const WORKBENCH_PRIORITY_CREATE_FIELD_NAMES = [
'problem_severity_level', 'problem_severity_level',
'problem_category_level_1', 'problem_category_level_1',
...@@ -263,28 +256,9 @@ export function buildGridViews(views = []) { ...@@ -263,28 +256,9 @@ export function buildGridViews(views = []) {
} }
export function buildViewColumnsForList(view, subject) { export function buildViewColumnsForList(view, subject) {
const baseColumns = buildViewColumns(view, subject).filter( return buildViewColumns(view, subject).filter(
(column) => column.field !== 'title' || !column.hide, (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) { export function getRecordCellText(record, column) {
......
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