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 = {
export default defineConfig({
layout: false,
fastRefresh: true,
base: '/problem_rectification_new/',
publicPath: '/problem_rectification_new/',
alias: {
react: resolvePackageRoot('react'),
'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 {
TableRow,
} from '@/catalyst-ui-kit/javascript/table';
import { formatFieldValue, getFieldValue } from '@/utils/subjectView';
import {
DocumentDuplicateIcon,
EyeIcon,
TrashIcon,
} from '@heroicons/react/24/outline';
import clsx from 'clsx';
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
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) {
const pinnedValue = column?.lockPosition || column?.pinned;
......@@ -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') {
if (!meta) {
return '';
......@@ -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({
summaryItems = [],
views = [],
......@@ -178,9 +338,10 @@ export default function RecordWorkbench({
onClearFilters,
title,
description,
columns = [],
records = [],
selectedIds = [],
columns = EMPTY_ARRAY,
columnGroups = EMPTY_ARRAY,
records = EMPTY_ARRAY,
selectedIds = EMPTY_ARRAY,
onSelectionChange,
toolbarActions = [],
bulkActions = [],
......@@ -195,19 +356,74 @@ export default function RecordWorkbench({
}) {
const [drawerRecordName, setDrawerRecordName] = useState('');
const [drawerDirty, setDrawerDirty] = useState(false);
const [collapsedColumnGroups, setCollapsedColumnGroups] = useState({});
const loading = Boolean(states?.loading);
const error = states?.error || '';
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(
() => 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],
() => buildStickyColumnMeta(orderedVisibleColumns, selectable, rowActions.length > 0, 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 recordName =
typeof recordOrName === 'string'
......@@ -383,12 +599,32 @@ export default function RecordWorkbench({
) : null}
</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 ? (
<div className="border-b border-zinc-100 bg-rose-50/70 px-6 py-4 text-sm text-rose-700">
{error}
</div>
) : 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 ? (
<div className="px-6 py-6">
<EmptyState
......@@ -407,8 +643,81 @@ export default function RecordWorkbench({
<div className="px-3 pb-3 pt-2">
<Table bleed dense className="[--gutter:--spacing(3)]">
<TableHead>
<TableRow className="bg-zinc-50/80">
{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">
{selectable && !showColumnGroupHeader ? (
<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 }}
......@@ -452,7 +761,7 @@ export default function RecordWorkbench({
);
})}
{rowActions.length > 0 ? (
{rowActions.length > 0 && !showColumnGroupHeader ? (
<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 }}
......@@ -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)]"
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) => {
const disabled = isActionDisabled(action, rowContext);
const buttonProps = renderButtonProps(action);
return (
<Button
<RowActionButton
key={action.key}
{...buttonProps}
action={action}
disabled={disabled}
onClick={() => action.onClick?.(rowContext)}
>
{action.label}
</Button>
/>
);
})}
</div>
......
......@@ -15,25 +15,6 @@ import {
isViewAccessible,
} 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() {
return (
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className="size-5">
......@@ -141,20 +122,6 @@ const RecordDetailDrawer = (props, ref) => {
const activeView = detailViews.find((view) => view.name === activeTabKey) || detailViews[0];
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 = () => {
onClose?.();
......@@ -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"
>
<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">
<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="min-w-0 flex-1">
<div className="text-[11px] font-semibold uppercase tracking-[0.32em] text-rose-500">
Record Detail
</div>
<Headless.DialogTitle className="mt-2 truncate text-[28px] font-semibold tracking-tight text-zinc-950">
<Headless.DialogTitle className="sr-only">
{editableRecord?.title || editableRecord?.name || '记录详情'}
</Headless.DialogTitle>
<div className="mt-2 flex flex-wrap items-center gap-2 text-sm text-zinc-500">
<span className="inline-flex rounded-full bg-white/90 px-3 py-1 font-medium text-zinc-700 ring-1 ring-white shadow-sm">
{readonly ? '只读模式' : '可编辑'}
</span>
{editableRecord?.status ? (
<span className="inline-flex rounded-full bg-rose-50 px-3 py-1 font-medium text-rose-700 ring-1 ring-rose-100">
{editableRecord.status}
</span>
) : null}
</div>
<div className="mt-4 grid gap-3 md:grid-cols-3">
{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"
<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="flex items-center justify-between gap-4">
<div className="min-w-0 flex-1">
<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',
)}
>
<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>
))}
{view.title || view.name}
</button>
);
})}
</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">
......@@ -231,29 +190,6 @@ const RecordDetailDrawer = (props, ref) => {
</button>
</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>
<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';
import { loadProblemRectificationDashboardData } from '../problemRectificationDashboardService';
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) {
const numericValue = Number(value || 0);
......@@ -22,14 +28,6 @@ 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))]';
......@@ -191,51 +189,69 @@ function buildDonutOption(data, colors) {
};
}
function buildTreemapOption(data = []) {
function buildCategoryBarOption(data = []) {
return {
color: ['#e11d48'],
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)',
borderWidth: 0,
textStyle: { color: '#fff' },
},
series: [
{
type: 'treemap',
roam: false,
breadcrumb: { show: false },
nodeClick: false,
upperLabel: {
show: true,
height: 26,
color: '#0f172a',
grid: {
left: 18,
right: 56,
top: 20,
bottom: 18,
containLabel: true,
},
label: {
show: true,
color: '#fff',
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,
formatter: '{b}',
margin: 12,
width: 116,
overflow: 'truncate',
},
itemStyle: {
borderColor: '#fff',
borderWidth: 3,
gapWidth: 3,
},
levels: [
series: [
{
color: ['#e11d48', '#f97316', '#0ea5e9', '#22c55e', '#8b5cf6', '#f59e0b'],
colorMappingBy: 'index',
type: 'bar',
data: data.map((item) => item.value),
barMaxWidth: 22,
showBackground: true,
backgroundStyle: {
color: 'rgba(148,163,184,0.08)',
borderRadius: 99,
},
{
colorSaturation: [0.4, 0.75],
itemStyle: {
borderColorSaturation: 0.7,
gapWidth: 2,
borderWidth: 2,
borderRadius: [0, 99, 99, 0],
},
label: {
show: true,
position: 'right',
distance: 8,
formatter: '{c}',
color: '#0f172a',
fontWeight: 600,
},
],
data,
},
],
};
......@@ -494,198 +510,43 @@ function buildTimelinessScatterOption(data = []) {
};
}
function buildScoreBarOption(data, color) {
function buildQualityRatingOption(data) {
return {
color: [color],
color: ['#22c55e', '#f59e0b', '#e11d48'],
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
trigger: 'item',
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 },
legend: {
bottom: 0,
icon: 'circle',
textStyle: { color: '#475569' },
},
series: [
{
type: 'bar',
data: data.map((item) => item.value),
barWidth: 18,
showBackground: true,
backgroundStyle: {
color: 'rgba(148,163,184,0.08)',
borderRadius: 99,
},
type: 'pie',
radius: ['54%', '76%'],
center: ['50%', '42%'],
avoidLabelOverlap: true,
itemStyle: {
borderRadius: [99, 99, 99, 99],
},
label: {
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'],
},
borderRadius: 12,
borderColor: '#fff',
borderWidth: 4,
},
series: [
{
type: 'heatmap',
data: heatmap.data,
label: {
show: true,
color: '#0f172a',
fontWeight: 600,
},
emphasis: {
itemStyle: {
shadowBlur: 12,
shadowColor: 'rgba(15,23,42,0.18)',
},
},
},
],
};
}
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))),
formatter: '{b|{b}}\n{c|{c} 条}',
rich: {
b: { fontSize: 13, fontWeight: 700, color: '#0f172a' },
c: { fontSize: 12, color: '#64748b' },
},
{
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,
labelLine: {
lineStyle: { color: '#cbd5e1' },
},
data: data.map((item) => Number(item.highPercent.toFixed(2))),
data,
},
],
};
......@@ -693,6 +554,7 @@ function buildRatingStackOption(data) {
export default function ProblemRectificationDashboardPage() {
const [activeGroupKey, setActiveGroupKey] = useState('all');
const [activeCategoryLevel, setActiveCategoryLevel] = useState('level1');
const [dashboardData, setDashboardData] = useState({
groups: [],
summaries: {},
......@@ -727,7 +589,12 @@ export default function ProblemRectificationDashboardPage() {
overdueCount: 0,
overdueRate: 0,
progressDistribution: [],
categoryTreemap: [],
categoryDistributions: {
level1: [],
level2: [],
level3: [],
level4: [],
},
rootCauseDistribution: [],
sourceComparison: { categories: [], series: [] },
focusIssueGroups: {
......@@ -738,25 +605,17 @@ export default function ProblemRectificationDashboardPage() {
overdueRanking: [],
departmentMatrix: [],
timelinessScatter: [],
timelinessScoreRanking: [],
qualityScoreRanking: [],
policyRevisionCount: 0,
systemConstructionCount: 0,
severityRatingHeatmap: {
xLabels: [],
yLabels: [],
data: [],
},
departmentRatingDistribution: [],
qualityRatingDistribution: [],
};
const progressOption = useMemo(
() => buildDonutOption(summary.progressDistribution, CHART_COLOR_SET),
[summary.progressDistribution],
);
const activeCategoryData = summary.categoryDistributions?.[activeCategoryLevel] || [];
const categoryOption = useMemo(
() => buildTreemapOption(summary.categoryTreemap),
[summary.categoryTreemap],
() => buildCategoryBarOption(activeCategoryData),
[activeCategoryData],
);
const rootCauseOption = useMemo(
() => buildDonutOption(summary.rootCauseDistribution, CHART_COLOR_SET),
......@@ -778,21 +637,9 @@ export default function ProblemRectificationDashboardPage() {
() => 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 qualityRatingOption = useMemo(
() => buildQualityRatingOption(summary.qualityRatingDistribution),
[summary.qualityRatingDistribution],
);
const activeGroup = groups.find((item) => item.key === activeGroupKey);
......@@ -941,9 +788,31 @@ export default function ProblemRectificationDashboardPage() {
<div className="grid gap-6 xl:grid-cols-[1.05fr_0.95fr]">
<Panel
title="问题类型 / 业务领域分布"
description="按问题一级至四级分类构造矩形树图,帮助定位高发领域。"
description="切换查看问题一级至四级分类数量分布,帮助定位高发领域。"
>
<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)}
>
{summary.categoryTreemap.length > 0 ? (
{tab.label}
</button>
);
})}
</div>
{activeCategoryData.length > 0 ? (
<ReactECharts option={categoryOption} style={{ height: 360 }} />
) : (
<EmptyBlock title="暂无问题领域统计数据" />
......@@ -1039,69 +908,16 @@ export default function ProblemRectificationDashboardPage() {
<SectionHeading
eyebrow="Quality"
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
title="部门综合整改评定等级排行"
description="按高评级占比降序展示各责任部门低、中、高评级结构,便于横向比较整改质量。"
title="综合整改质效结果"
description="按高、中、低展示最终整改质效等级;暂按“两个及以上高为高、出现低则低、一个高则中”的初步规则计算,具体维度和规则后续确认。"
>
{summary.departmentRatingDistribution.length > 0 ? (
<ReactECharts option={ratingStackOption} style={{ height: 420 }} />
{summary.qualityRatingDistribution.length > 0 ? (
<ReactECharts option={qualityRatingOption} style={{ height: 380 }} />
) : (
<EmptyBlock title="暂无部门整改评级分布数据" />
<EmptyBlock title="暂无综合整改质效结果数据" />
)}
</Panel>
</section>
......
......@@ -14,6 +14,7 @@ import RecordDetailDrawer from '@/pages/RecordDetailDrawer';
import {
batchRemoveRecords,
batchUpdateRecords,
cloneRecord,
createNotice,
createRecord,
executeAction,
......@@ -491,13 +492,16 @@ export default function ProblemRectificationListPage() {
const [selectedIds, setSelectedIds] = useState([]);
const [showCreateModal, setShowCreateModal] = useState(false);
const [showPushModal, setShowPushModal] = useState(false);
const [cloneTargetRecord, setCloneTargetRecord] = useState(null);
const [formValues, setFormValues] = useState({});
const [fillerUsers, setFillerUsers] = useState([]);
const [auditorUsers, setAuditorUsers] = useState([]);
const [successMessage, setSuccessMessage] = useState('');
const reloadList = useCallback(async () => {
setLoading(true);
setError('');
setSuccessMessage('');
try {
const resources = await loadProblemRectificationListResources();
......@@ -590,6 +594,7 @@ export default function ProblemRectificationListPage() {
const pendingAuditCount = filteredRecords.filter(
(item) => item.auditStatus && item.auditStatus !== '已审批',
).length;
const quickSelectEnabled = activeGridView?.settings?.quickSelect !== false;
const handleCreate = async () => {
if (
......@@ -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 filter = keyword.trim()
? {
......@@ -916,9 +949,11 @@ export default function ProblemRectificationListPage() {
title="问题清单"
description={`当前视图显示 ${filteredRecords.length} 条记录,已选中 ${selectedIds.length} 条,其中重点后果问题 ${consequenceCount} 条、逾期未完成 ${overdueCount} 条。`}
columns={configuredView.columns || []}
columnGroups={activeGridView?.settings?.groups || []}
records={filteredRecords}
selectedIds={selectedIds}
onSelectionChange={setSelectedIds}
selectable={quickSelectEnabled}
toolbarActions={[
{
key: 'create',
......@@ -964,6 +999,13 @@ export default function ProblemRectificationListPage() {
variant: 'plain',
onClick: ({ openDetail, record }) => openDetail(record),
},
{
key: 'clone',
label: '复制',
variant: 'outline',
disabled: () => actionLoading,
onClick: ({ record }) => setCloneTargetRecord(record),
},
{
key: 'delete',
label: '删除',
......@@ -988,6 +1030,7 @@ export default function ProblemRectificationListPage() {
states={{
loading,
error,
success: successMessage,
loadingTitle: '正在加载问题整改列表',
loadingDescription: '正在读取对象结构、视图配置和记录数据,请稍候。',
emptyTitle: '当前视图暂无数据',
......@@ -1116,6 +1159,31 @@ export default function ProblemRectificationListPage() {
</DialogActions>
</Dialog>
) : 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 @@
问题整改主列表与详情抽屉统一以 `getSubjectView(subjectName)` 返回的视图配置驱动,不再在页面内写死部门标签和字段顺序:
- `type = "grid"` 的视图用于主列表顶部视图切换,替代原先写死的 tab
- `settings.columns` 决定列表列顺序、宽度和排序优先级
- `settings.columns` 决定列表列顺序、宽度、固定列位置和排序优先级,列表页不得在视图列后追加自定义业务字段列
- `settings.groups` 决定列表列分组,例如把多个问题分类字段归入同一分组;前端需展示分组表头,同一分组下的列即使在 `settings.columns` 中不连续,也应在表格内聚拢展示,并支持每个分组独立折叠 / 展开,折叠后隐藏该分组子列
- `settings.quickSelect` 决定列表是否展示勾选列,用于批量推送、删除等操作
- `settings.restrictions` 与 `settings.filters.conditions` 共同决定该视图下可见的数据范围
- `settings.viewAuth` 决定当前用户是否能看到该视图
- `type = "detail"` 的视图用于详情抽屉字段编排,按 `columns` 顺序渲染字段
......@@ -69,6 +71,8 @@
- `problem_rectification` 与 `issue_rectification_archive` 列表页统一复用 `RecordWorkbench` 作为“结构化表格 + 工具栏 + 详情抽屉入口”的工作台容器
- 页面本身只负责提供 subject 对应的数据加载、统计口径、筛选 schema、行操作与批量操作
- 详情侧栏统一复用 `RecordDetailDrawer`,通过 `recordName`、`subjectName`、`preferredViewName` 驱动,不再在各页面重复实现抽屉骨架
- 详情侧栏顶部不展示记录标题、状态和摘要卡片,只保留详情视图切换与全屏 / 关闭操作,字段主体作为主要阅读区域
- 列表行操作列使用图标按钮横向排列,查看、复制、删除等常用操作通过 tooltip / aria-label 标明含义,避免文字按钮纵向堆叠
- 新增同类列表页时,优先复用工作台与详情抽屉组合,避免再次复制问题整改页 JSX
### 3.1 目标页面拆分
......@@ -134,10 +138,11 @@
旧应用 `problem_rectification` 列表配置中实际暴露了 5 个操作:
1. 查看详情
2. 删除数据
3. 推送问题整改表
4. 导出问题整改表
5. 一键归档
2. 复制数据
3. 删除数据
4. 推送问题整改表
5. 导出问题整改表
6. 一键归档
这些操作分别对应如下业务含义:
......@@ -146,12 +151,19 @@
- 查看问题记录完整字段
- 支持后续在详情中编辑整改情况、证明材料、审批状态
### 5.2 删除数据
### 5.2 复制数据
- 在问题整改列表的单行操作中触发
- 弹窗确认后调用 `cloneRecord(record.name)`,由 `/record/clone` 基于当前记录生成新记录
- 复制完成后刷新当前列表,并展示成功提示
- 复制操作不改变原记录的整改、复核或归档状态
### 5.3 删除数据
- 支持删除单条或批量删除选中项
- 本质调用 `/record/batch/remove`
### 5.3 推送问题整改表
### 5.4 推送问题整改表
- 从已选问题记录中选择填报人和审核人
- 创建一条 `message_reference_id` 流程主单
......@@ -160,19 +172,19 @@
- 给填报人发送内部通知
- 创建 OA 待办,链接指向 `/problem_rectification_form?id=<message_record_name>`
### 5.4 导出问题整改表
### 5.5 导出问题整改表
- 按选中问题导出 Excel
- 导出字段来自 `problem_rectification.metadata`
- 本质是把主表字段映射成报表列,不参与状态流转
### 5.5 一键归档
### 5.6 一键归档
- 将选中问题记录导入 `problem_rectification_archive_records`
- 再创建一条 `issue_rectification_archive` 记录,保存归档明细引用列表
- 归档的本质是生成快照,不应直接依赖原始问题记录后续继续存在
### 5.6 旧多维表格能力盘点
### 5.7 旧多维表格能力盘点
实际页面检查后,旧系统“问题整改”和“往期材料归档”页都运行在同一套多维表格容器上,页面共有能力包括:
......@@ -187,6 +199,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_from` 为横轴,并按 `inspect_the_implementing_unit` 做堆叠比较
- 重点问题集中展示:
......@@ -479,17 +492,16 @@
当前图表与字段映射:
- 问题整改时效:按责任部门计算 `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` 的低/中/高占比
- 按高评级占比降序展示
- 驾驶舱只展示最终“综合整改质效结果”的高 / 中 / 低分布,不再展示整改时间评估、整改证明材料质量、制度修订、系统建设、往期发现、重复出现等明细维度
- 明细维度仍在维护页录入和校验,包括:
- `rectification_timeliness_level`:整改时间评估,低 / 中 / 高
- `rectification_material_quality_level`:整改证明材料质量,低 / 中 / 高
- `involves_policy_revision`、`policy_revision_name`:是否涉及制度修订及制度名称
- `involves_system_construction`、`system_construction_name`:是否涉及系统建设及系统名称
- `is_historical_problem`:是否为往期发现问题
- `is_repeated_problem`:问题是否重复出现
- `rectification_overall_rating`:综合整改评定等级,低 / 中 / 高
- 当前驾驶舱优先使用已维护的 `rectification_overall_rating`;若该字段为空,则按初步规则从明细维度推导结果:两个及以上高为高;出现低则低;一个高则中。具体参与维度和最终规则后续需业务确认
---
......
......@@ -35,11 +35,6 @@ 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();
......@@ -113,6 +108,42 @@ function isFixedProblem(record) {
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) {
const primary = normalizeText(metadata.primary_responsible_department);
if (primary) {
......@@ -151,39 +182,16 @@ function buildDistributionMap(records, getKey) {
);
}
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 buildCategoryDistributions(records) {
return {
level1: buildDistributionMap(
records,
(item) => item.categoryLevel1 || item.relatedIssueDomain || '未分类',
),
level2: buildDistributionMap(records, (item) => item.categoryLevel2 || '未分类'),
level3: buildDistributionMap(records, (item) => item.categoryLevel3 || '未分类'),
level4: buildDistributionMap(records, (item) => item.categoryLevel4 || '未分类'),
};
}
function buildSourceComparison(records) {
......@@ -306,15 +314,6 @@ function collectDepartmentStats(records) {
totalCount: 0,
fixedCount: 0,
overdueCount: 0,
timelinessScoreTotal: 0,
timelinessScoreCount: 0,
qualityScoreTotal: 0,
qualityScoreCount: 0,
ratingCounts: {
低: 0,
中: 0,
高: 0,
},
});
}
......@@ -328,36 +327,12 @@ function collectDepartmentStats(records) {
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,
}));
}
......@@ -412,58 +387,25 @@ function buildTimelinessScatter(records) {
.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 buildQualityRatingDistribution(records) {
const counts = {
高: 0,
中: 0,
低: 0,
};
}
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;
records.forEach((record) => {
if (record.qualityRating && counts[record.qualityRating] !== undefined) {
counts[record.qualityRating] += 1;
}
});
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);
return ['高', '中', '低']
.map((level) => ({
name: level,
value: counts[level],
}))
.filter((item) => item.value > 0);
}
function summarizeRecords(records) {
......@@ -481,19 +423,14 @@ function summarizeRecords(records) {
overdueCount,
overdueRate,
progressDistribution: buildDistributionMap(records, (item) => item.progress || '未填写'),
categoryTreemap: buildCategoryTreemap(records),
categoryDistributions: buildCategoryDistributions(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),
qualityRatingDistribution: buildQualityRatingDistribution(records),
};
}
......@@ -514,7 +451,7 @@ function normalizeProblemRecord(record) {
const delayedByPending = dueDate && dueDate.isBefore(now, 'day') && !isFixed;
const isDelayed = Boolean(delayedByProgress || delayedByActual || delayedByPending);
return {
const normalizedRecord = {
id: record?._id || record?.name || '',
title: normalizeText(record?.title) || normalizeText(metadata.project_name) || '-',
projectName: normalizeText(metadata.project_name),
......@@ -562,6 +499,11 @@ function normalizeProblemRecord(record) {
isDelayed,
isOverdue: delayedByPending,
};
return {
...normalizedRecord,
qualityRating: calculateQualityRating(normalizedRecord),
};
}
function buildDepartmentBuckets(records) {
......
......@@ -22,13 +22,6 @@ import {
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',
......@@ -263,28 +256,9 @@ export function buildGridViews(views = []) {
}
export function buildViewColumnsForList(view, subject) {
const baseColumns = buildViewColumns(view, subject).filter(
return 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) {
......
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