Commit 2d3757b2 authored by David Yang's avatar David Yang

feat: 驾驶舱拆分

parent c536e180
...@@ -8,6 +8,7 @@ function resolvePackageRoot(packageName: string) { ...@@ -8,6 +8,7 @@ function resolvePackageRoot(packageName: string) {
const ROUTE_PATHS = { const ROUTE_PATHS = {
login: '/login', login: '/login',
dashboard: '/integrity_risk_table/dashboard', dashboard: '/integrity_risk_table/dashboard',
businessDashboard: '/integrity_risk_table/business_dashboard',
problemRectification: '/problem_rectification', problemRectification: '/problem_rectification',
issueRectificationArchive: '/issue_rectification_archive', issueRectificationArchive: '/issue_rectification_archive',
problemRectificationForm: '/problem_rectification_form', problemRectificationForm: '/problem_rectification_form',
...@@ -41,6 +42,10 @@ export default defineConfig({ ...@@ -41,6 +42,10 @@ export default defineConfig({
path: ROUTE_PATHS.dashboard, path: ROUTE_PATHS.dashboard,
component: './problem_rectification/dashboard/index', component: './problem_rectification/dashboard/index',
}, },
{
path: ROUTE_PATHS.businessDashboard,
component: './problem_rectification/businessDashboard/BusinessDashboard',
},
{ {
path: ROUTE_PATHS.problemRectification, path: ROUTE_PATHS.problemRectification,
component: './problem_rectification/list/index', component: './problem_rectification/list/index',
......
...@@ -5,6 +5,7 @@ import { SiteContext } from '@/wrapper/Site'; ...@@ -5,6 +5,7 @@ import { SiteContext } from '@/wrapper/Site';
import { import {
ArchiveBoxIcon, ArchiveBoxIcon,
Bars3Icon, Bars3Icon,
ChartPieIcon,
ClipboardDocumentCheckIcon, ClipboardDocumentCheckIcon,
HomeModernIcon, HomeModernIcon,
Squares2X2Icon, Squares2X2Icon,
...@@ -25,6 +26,11 @@ const menuGroups = [ ...@@ -25,6 +26,11 @@ const menuGroups = [
path: '/integrity_risk_table/dashboard', path: '/integrity_risk_table/dashboard',
icon: HomeModernIcon, icon: HomeModernIcon,
}, },
{
name: '业务驾驶舱',
path: '/integrity_risk_table/business_dashboard',
icon: ChartPieIcon,
},
{ {
name: '问题整改', name: '问题整改',
path: '/problem_rectification', path: '/problem_rectification',
...@@ -280,9 +286,6 @@ export default function ProblemRectificationLayout() { ...@@ -280,9 +286,6 @@ export default function ProblemRectificationLayout() {
<h1 className="truncate text-sm font-semibold tracking-[0.02em] text-white/95"> <h1 className="truncate text-sm font-semibold tracking-[0.02em] text-white/95">
{APP_NAME} {APP_NAME}
</h1> </h1>
<div className="mt-1 text-xs text-zinc-400">
替代旧多维表格版问题整改应用
</div>
</div> </div>
{applicationSwitcherButton} {applicationSwitcherButton}
</> </>
......
import { ProblemRectificationDashboardPage } from '../dashboard';
export default function BusinessDashboard() {
return <ProblemRectificationDashboardPage dashboardMode="business" />;
}
import { Button } from '@/catalyst-ui-kit/javascript/button';
import {
Dialog,
DialogActions,
DialogBody,
DialogDescription,
DialogTitle,
} from '@/catalyst-ui-kit/javascript/dialog';
import { Select } from '@/catalyst-ui-kit/javascript/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/catalyst-ui-kit/javascript/table';
import ReactECharts from 'echarts-for-react'; import ReactECharts from 'echarts-for-react';
import clsx from 'clsx'; import clsx from 'clsx';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
...@@ -11,6 +28,32 @@ const CATEGORY_LEVEL_TABS = [ ...@@ -11,6 +28,32 @@ const CATEGORY_LEVEL_TABS = [
{ key: 'level3', label: '三级分类' }, { key: 'level3', label: '三级分类' },
{ key: 'level4', label: '四级分类' }, { key: 'level4', label: '四级分类' },
]; ];
const FOCUS_ISSUE_TYPES = [
{
key: 'economicLoss',
label: '经济损失问题',
shortLabel: '经济损失',
description: '造成经济损失且严重程度较高的问题。',
color: '#e11d48',
badgeClass: 'bg-rose-50 text-rose-700',
},
{
key: 'externalImpact',
label: '外部影响问题',
shortLabel: '外部影响',
description: '涉及处罚机构或外部影响的问题。',
color: '#f59e0b',
badgeClass: 'bg-amber-50 text-amber-700',
},
{
key: 'accountability',
label: '员工问责问题',
shortLabel: '员工问责',
description: '已触发员工问责后果的问题。',
color: '#0ea5e9',
badgeClass: 'bg-sky-50 text-sky-700',
},
];
function formatPercent(value) { function formatPercent(value) {
const numericValue = Number(value || 0); const numericValue = Number(value || 0);
...@@ -73,16 +116,6 @@ function StatCard({ label, value, hint, tone = 'default' }) { ...@@ -73,16 +116,6 @@ function StatCard({ label, value, hint, tone = 'default' }) {
); );
} }
function NumberCard({ label, value, hint }) {
return (
<div className="rounded-[24px] border border-zinc-200 bg-zinc-50/70 p-5">
<div className="text-sm font-medium text-slate-500">{label}</div>
<div className="mt-3 text-3xl font-semibold tracking-tight text-slate-950">{value}</div>
{hint ? <div className="mt-2 text-sm text-slate-500">{hint}</div> : null}
</div>
);
}
function Panel({ title, description, children }) { function Panel({ title, description, children }) {
return ( return (
<section className="rounded-[30px] border border-zinc-200 bg-white/95 shadow-[0_20px_50px_rgba(15,23,42,0.06)]"> <section className="rounded-[30px] border border-zinc-200 bg-white/95 shadow-[0_20px_50px_rgba(15,23,42,0.06)]">
...@@ -103,48 +136,57 @@ function EmptyBlock({ title }) { ...@@ -103,48 +136,57 @@ function EmptyBlock({ title }) {
); );
} }
function FocusIssueList({ title, description, items = [], tone }) { function FocusIssueDetailDialog({ issueType, items = [], onClose }) {
const toneClass =
tone === 'rose'
? 'bg-rose-50 text-rose-700'
: tone === 'amber'
? 'bg-amber-50 text-amber-700'
: 'bg-sky-50 text-sky-700';
return ( return (
<Panel title={title} description={description}> <Dialog open={Boolean(issueType)} onClose={onClose} size="5xl">
<DialogTitle>{issueType?.label || '重点问题详情'}</DialogTitle>
<DialogDescription>
{issueType?.description || '当前重点问题详情'} 共 {items.length} 条
</DialogDescription>
<DialogBody>
{items.length > 0 ? ( {items.length > 0 ? (
<div className="space-y-3"> <Table dense striped className="[--gutter:--spacing(2)]">
<TableHead>
<TableRow>
<TableHeader>问题</TableHeader>
<TableHeader>反馈单位</TableHeader>
<TableHeader>标记</TableHeader>
<TableHeader>严重程度</TableHeader>
<TableHeader>截止日期</TableHeader>
<TableHeader>问题简述</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{items.map((item) => ( {items.map((item) => (
<article <TableRow key={item.id} className="align-top">
key={item.id} <TableCell className="min-w-56 font-medium text-slate-950">
className="rounded-[22px] border border-zinc-200 bg-[linear-gradient(180deg,#ffffff,rgba(248,250,252,0.92))] p-4 shadow-sm" {item.title || '-'}
> </TableCell>
<div className="flex items-start justify-between gap-3"> <TableCell className="min-w-36 text-slate-600">
<div className="min-w-0"> {item.feedbackUnit || '-'}
<div className="truncate text-base font-semibold text-slate-900">{item.title}</div> </TableCell>
<div className="mt-1 text-sm text-slate-500">{item.feedbackUnit}</div> <TableCell>
</div> <span className={clsx('rounded-full px-2.5 py-1 text-xs font-medium', issueType?.badgeClass)}>
<div className={clsx('rounded-full px-3 py-1 text-xs font-medium', toneClass)}> {item.extraLabel || '-'}
{item.extraLabel}
</div>
</div>
<div className="mt-3 line-clamp-2 text-sm leading-6 text-slate-600">{item.brief}</div>
<div className="mt-4 flex flex-wrap gap-2">
<span className="rounded-full bg-zinc-100 px-3 py-1 text-xs font-medium text-zinc-700">
严重程度 {item.severityLevel}
</span> </span>
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-700"> </TableCell>
截止 {item.dueDateLabel} <TableCell>{item.severityLevel || '-'}</TableCell>
</span> <TableCell>{item.dueDateLabel || '-'}</TableCell>
</div> <TableCell className="min-w-80 max-w-[32rem] whitespace-normal text-slate-600">
</article> {item.brief || '-'}
</TableCell>
</TableRow>
))} ))}
</div> </TableBody>
</Table>
) : ( ) : (
<EmptyBlock title="当前没有相关重点问题" /> <EmptyBlock title="当前没有相关重点问题" />
)} )}
</Panel> </DialogBody>
<DialogActions>
<Button outline onClick={onClose}>关闭</Button>
</DialogActions>
</Dialog>
); );
} }
...@@ -354,6 +396,71 @@ function buildHorizontalBarOption(data, color) { ...@@ -354,6 +396,71 @@ function buildHorizontalBarOption(data, color) {
}; };
} }
function buildFocusIssueBarOption(data = []) {
return {
color: data.map((item) => item.color),
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: (params) => {
const item = params[0];
return `${item?.axisValueLabel || ''}: ${item?.value || 0} 条<br/>点击查看详情列表`;
},
backgroundColor: 'rgba(15,23,42,0.9)',
borderWidth: 0,
textStyle: { color: '#fff' },
},
grid: {
left: 8,
right: 20,
top: 18,
bottom: 8,
containLabel: true,
},
xAxis: {
type: 'value',
minInterval: 1,
splitLine: { lineStyle: { color: 'rgba(148,163,184,0.18)' } },
axisLabel: { color: '#64748b' },
},
yAxis: {
type: 'category',
data: data.map((item) => item.label),
axisTick: { show: false },
axisLine: { show: false },
axisLabel: { color: '#334155', fontSize: 12 },
},
series: [
{
type: 'bar',
cursor: 'pointer',
data: data.map((item) => ({
name: item.label,
value: item.value,
issueTypeKey: item.key,
itemStyle: {
color: item.color,
borderRadius: [0, 99, 99, 0],
},
})),
barMaxWidth: 34,
showBackground: true,
backgroundStyle: {
color: 'rgba(148,163,184,0.08)',
borderRadius: 99,
},
label: {
show: true,
position: 'right',
formatter: '{c}',
color: '#0f172a',
fontWeight: 700,
},
},
],
};
}
function buildBubbleMatrixOption(data = []) { function buildBubbleMatrixOption(data = []) {
const maxTotal = Math.max(...data.map((item) => item.totalCount), 1); const maxTotal = Math.max(...data.map((item) => item.totalCount), 1);
const maxOverdue = Math.max(...data.map((item) => item.overdueCount), 1); const maxOverdue = Math.max(...data.map((item) => item.overdueCount), 1);
...@@ -437,79 +544,6 @@ function buildBubbleMatrixOption(data = []) { ...@@ -437,79 +544,6 @@ function buildBubbleMatrixOption(data = []) {
}; };
} }
function buildTimelinessScatterOption(data = []) {
const maxValue = Math.max(
...data.flatMap((item) => [item.value?.[0] || 0, item.value?.[1] || 0]),
1,
);
return {
tooltip: {
formatter: (params) => {
const item = params.data;
return [
`<strong>${item.name}</strong>`,
`预计整改时长:${item.value[0]} 天`,
`实际整改时长:${item.value[1]} 天`,
`严重程度:${item.value[2]}`,
item.isDelayed ? '判定:已逾期' : '判定:按期或提前完成',
].join('<br/>');
},
backgroundColor: 'rgba(15,23,42,0.9)',
borderWidth: 0,
textStyle: { color: '#fff' },
},
grid: {
left: 8,
right: 16,
top: 16,
bottom: 10,
containLabel: true,
},
xAxis: {
type: 'value',
name: '预计整改时长(天)',
nameTextStyle: { color: '#64748b' },
splitLine: { lineStyle: { color: 'rgba(148,163,184,0.18)' } },
axisLabel: { color: '#64748b' },
},
yAxis: {
type: 'value',
name: '实际整改时长(天)',
nameTextStyle: { color: '#64748b' },
splitLine: { lineStyle: { color: 'rgba(148,163,184,0.18)' } },
axisLabel: { color: '#64748b' },
},
series: [
{
type: 'scatter',
data: data.map((item) => ({
...item,
symbolSize: item.value[2] === '高' ? 18 : item.value[2] === '中' ? 14 : 10,
itemStyle: {
color: item.isDelayed ? '#e11d48' : '#0ea5e9',
opacity: 0.82,
},
})),
},
{
type: 'line',
data: [
[0, 0],
[maxValue, maxValue],
],
silent: true,
symbol: 'none',
lineStyle: {
color: '#94a3b8',
type: 'dashed',
width: 1.5,
},
},
],
};
}
function buildQualityRatingOption(data) { function buildQualityRatingOption(data) {
return { return {
color: ['#22c55e', '#f59e0b', '#e11d48'], color: ['#22c55e', '#f59e0b', '#e11d48'],
...@@ -552,12 +586,16 @@ function buildQualityRatingOption(data) { ...@@ -552,12 +586,16 @@ function buildQualityRatingOption(data) {
}; };
} }
export default function ProblemRectificationDashboardPage() { export function ProblemRectificationDashboardPage({ dashboardMode = 'overview' } = {}) {
const [activeGroupKey, setActiveGroupKey] = useState('all'); const [activeGroupKey, setActiveGroupKey] = useState('all');
const [activeCategoryLevel, setActiveCategoryLevel] = useState('level1'); const [activeCategoryLevel, setActiveCategoryLevel] = useState('level1');
const [activeYear, setActiveYear] = useState('all');
const [activeFocusIssueTypeKey, setActiveFocusIssueTypeKey] = useState('');
const [dashboardData, setDashboardData] = useState({ const [dashboardData, setDashboardData] = useState({
groups: [], groups: [],
summaries: {}, summaries: {},
yearOptions: [],
yearScopes: {},
error: '', error: '',
isEmpty: false, isEmpty: false,
}); });
...@@ -582,7 +620,15 @@ export default function ProblemRectificationDashboardPage() { ...@@ -582,7 +620,15 @@ export default function ProblemRectificationDashboardPage() {
}, []); }, []);
const groups = dashboardData.groups || []; const groups = dashboardData.groups || [];
const summary = dashboardData.summaries?.[activeGroupKey] || { const yearOptions = dashboardData.yearOptions || [];
const activeScope = activeYear === 'all'
? dashboardData
: dashboardData.yearScopes?.[activeYear] || {
summaries: {},
departmentBuckets: {},
isEmpty: true,
};
const summary = activeScope.summaries?.[activeGroupKey] || {
totalCount: 0, totalCount: 0,
fixedCount: 0, fixedCount: 0,
completionRate: 0, completionRate: 0,
...@@ -604,7 +650,6 @@ export default function ProblemRectificationDashboardPage() { ...@@ -604,7 +650,6 @@ export default function ProblemRectificationDashboardPage() {
}, },
overdueRanking: [], overdueRanking: [],
departmentMatrix: [], departmentMatrix: [],
timelinessScatter: [],
qualityRatingDistribution: [], qualityRatingDistribution: [],
}; };
...@@ -633,16 +678,44 @@ export default function ProblemRectificationDashboardPage() { ...@@ -633,16 +678,44 @@ export default function ProblemRectificationDashboardPage() {
() => buildBubbleMatrixOption(summary.departmentMatrix), () => buildBubbleMatrixOption(summary.departmentMatrix),
[summary.departmentMatrix], [summary.departmentMatrix],
); );
const timelinessScatterOption = useMemo(
() => buildTimelinessScatterOption(summary.timelinessScatter),
[summary.timelinessScatter],
);
const qualityRatingOption = useMemo( const qualityRatingOption = useMemo(
() => buildQualityRatingOption(summary.qualityRatingDistribution), () => buildQualityRatingOption(summary.qualityRatingDistribution),
[summary.qualityRatingDistribution], [summary.qualityRatingDistribution],
); );
const focusIssueChartData = useMemo(
() => FOCUS_ISSUE_TYPES.map((type) => ({
...type,
value: summary.focusIssueGroups?.[type.key]?.length || 0,
})),
[summary.focusIssueGroups],
);
const focusIssueOption = useMemo(
() => buildFocusIssueBarOption(focusIssueChartData),
[focusIssueChartData],
);
const focusIssueChartEvents = useMemo(
() => ({
click: (params) => {
const issueTypeKey = params?.data?.issueTypeKey;
if (issueTypeKey) {
setActiveFocusIssueTypeKey(issueTypeKey);
}
},
}),
[],
);
const activeGroup = groups.find((item) => item.key === activeGroupKey); const activeGroup = groups.find((item) => item.key === activeGroupKey);
const activeFocusIssueType = FOCUS_ISSUE_TYPES.find((type) => type.key === activeFocusIssueTypeKey) || null;
const activeFocusIssueItems = activeFocusIssueType
? summary.focusIssueGroups?.[activeFocusIssueType.key] || []
: [];
const isBusinessDashboard = dashboardMode === 'business';
const dashboardTitle = isBusinessDashboard ? '业务驾驶舱' : '问题整改驾驶舱';
const dashboardSubtitle = isBusinessDashboard
? '围绕问题风险定位、部门责任追踪和整改质效评估,沉淀业务分析与督办依据。'
: '围绕整改完成进度、问题风险定位、部门责任压实和整改质效评估,统一展示今年问题整改全景。';
const yearLabel = activeYear === 'all' ? '全部年份' : `${activeYear} 年`;
return ( return (
<div className="space-y-8"> <div className="space-y-8">
...@@ -655,22 +728,24 @@ export default function ProblemRectificationDashboardPage() { ...@@ -655,22 +728,24 @@ export default function ProblemRectificationDashboardPage() {
Integrity Risk Dashboard Integrity Risk Dashboard
</div> </div>
<h1 className="mt-3 text-3xl font-semibold tracking-tight md:text-[2.55rem]"> <h1 className="mt-3 text-3xl font-semibold tracking-tight md:text-[2.55rem]">
问题整改驾驶舱 {dashboardTitle}
</h1> </h1>
<div className="mt-4 max-w-3xl text-sm leading-6 text-white/78"> <div className="mt-4 max-w-3xl text-sm leading-6 text-white/78">
围绕整改完成进度、问题风险定位、部门责任压实和整改质效评估,统一展示今年问题整改全景。 {dashboardSubtitle}
</div> </div>
</div> </div>
<div className="rounded-[28px] border border-white/15 bg-white/10 px-5 py-4 backdrop-blur"> <div className="rounded-[28px] border border-white/15 bg-white/10 px-5 py-4 backdrop-blur">
<div className="text-xs uppercase tracking-[0.22em] text-white/65">当前分组</div> <div className="text-xs uppercase tracking-[0.22em] text-white/65">当前分组</div>
<div className="mt-2 text-2xl font-semibold">{activeGroup?.label || '全部'}</div> <div className="mt-2 text-2xl font-semibold">{activeGroup?.label || '全部'}</div>
<div className="mt-1 text-sm text-white/72">{dayjs().format('YYYY 年 MM 月 DD 日')}</div> <div className="mt-1 text-sm text-white/72">{yearLabel} · {dayjs().format('YYYY 年 MM 月 DD 日')}</div>
</div> </div>
</div> </div>
</section> </section>
<section className="overflow-x-auto"> <section className="rounded-[28px] border border-zinc-200 bg-white/90 p-2 shadow-sm">
<div className="inline-flex min-w-full gap-3 rounded-[28px] border border-zinc-200 bg-white/90 p-2 shadow-sm"> <div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
<div className="overflow-x-auto">
<div className="inline-flex min-w-full gap-3">
{groups.map((group) => ( {groups.map((group) => (
<button <button
key={group.key} key={group.key}
...@@ -687,6 +762,22 @@ export default function ProblemRectificationDashboardPage() { ...@@ -687,6 +762,22 @@ export default function ProblemRectificationDashboardPage() {
</button> </button>
))} ))}
</div> </div>
</div>
<label className="flex shrink-0 items-center gap-3 rounded-[20px] border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-slate-600">
<span className="font-medium text-slate-700">统计年份</span>
<Select
value={activeYear}
onChange={(event) => setActiveYear(event.target.value)}
>
<option value="all">全部年份</option>
{yearOptions.map((year) => (
<option key={year} value={String(year)}>
{year} 年
</option>
))}
</Select>
</label>
</div>
</section> </section>
{dashboardData.error ? ( {dashboardData.error ? (
...@@ -697,7 +788,7 @@ export default function ProblemRectificationDashboardPage() { ...@@ -697,7 +788,7 @@ export default function ProblemRectificationDashboardPage() {
{loading ? ( {loading ? (
<div className="rounded-[26px] border border-sky-200 bg-sky-50 px-5 py-4 text-sm text-sky-800"> <div className="rounded-[26px] border border-sky-200 bg-sky-50 px-5 py-4 text-sm text-sky-800">
正在加载问题整改驾驶舱... 正在加载{dashboardTitle}...
</div> </div>
) : null} ) : null}
...@@ -708,8 +799,16 @@ export default function ProblemRectificationDashboardPage() { ...@@ -708,8 +799,16 @@ export default function ProblemRectificationDashboardPage() {
</div> </div>
) : null} ) : null}
{!loading && !dashboardData.isEmpty ? ( {!loading && !dashboardData.isEmpty && activeScope.isEmpty ? (
<div className="rounded-[30px] border border-dashed border-zinc-300 bg-white/80 px-6 py-12 text-center">
<div className="text-xl font-semibold text-slate-900">{yearLabel}暂无问题整改数据</div>
<div className="mt-3 text-sm leading-6 text-slate-500">请选择其它年份或切回全部年份查看统计。</div>
</div>
) : null}
{!loading && !dashboardData.isEmpty && !activeScope.isEmpty ? (
<> <>
{!isBusinessDashboard ? (
<section className="space-y-5"> <section className="space-y-5">
<SectionHeading <SectionHeading
eyebrow="Overview" eyebrow="Overview"
...@@ -748,7 +847,18 @@ export default function ProblemRectificationDashboardPage() { ...@@ -748,7 +847,18 @@ export default function ProblemRectificationDashboardPage() {
tone="amber" tone="amber"
/> />
</div> </div>
<div className="grid gap-6 xl:grid-cols-[0.9fr_1.1fr]"> </section>
) : null}
{isBusinessDashboard ? (
<>
<section className="space-y-5">
<SectionHeading
eyebrow="Progress"
title="整改进度"
description="按整改进度统计分布与口径,辅助判断当前积压状态和督办入口。"
/>
<div className="grid gap-6">
<Panel <Panel
title="整改进度分布" title="整改进度分布"
description="按整改进度统计数量与占比,直观看当前积压主要落在哪个状态。" description="按整改进度统计数量与占比,直观看当前积压主要落在哪个状态。"
...@@ -759,23 +869,6 @@ export default function ProblemRectificationDashboardPage() { ...@@ -759,23 +869,6 @@ export default function ProblemRectificationDashboardPage() {
<EmptyBlock title="暂无整改进度分布数据" /> <EmptyBlock title="暂无整改进度分布数据" />
)} )}
</Panel> </Panel>
<Panel
title="进度口径说明"
description="当前逾期判定规则采用“预计整改完成时间早于今天且尚未闭环”。如果后续上线显式“是否延期”字段,可直接切换为配置化口径。"
>
<div className="grid gap-4 md:grid-cols-2">
<NumberCard
label="已闭环问题"
value={formatCount(summary.fixedCount)}
hint="适合用于经营层快速判断整体闭环能力"
/>
<NumberCard
label="待重点督办"
value={formatCount(summary.overdueCount)}
hint="适合作为督办清单和预警入口"
/>
</div>
</Panel>
</div> </div>
</section> </section>
...@@ -841,26 +934,33 @@ export default function ProblemRectificationDashboardPage() { ...@@ -841,26 +934,33 @@ export default function ProblemRectificationDashboardPage() {
)} )}
</Panel> </Panel>
<div className="grid gap-6 xl:grid-cols-3"> <Panel
<FocusIssueList title="重点问题分布"
title="经济损失问题" description="按经济损失、外部影响和员工问责统计重点问题数量,点击柱状图查看对应详情列表。"
description="优先展示造成经济损失且严重程度更高的问题。" >
items={summary.focusIssueGroups.economicLoss} {focusIssueChartData.some((item) => item.value > 0) ? (
tone="rose" <div className="space-y-4">
/> <ReactECharts
<FocusIssueList option={focusIssueOption}
title="外部影响问题" onEvents={focusIssueChartEvents}
description="直观看到涉及处罚机构或外部影响的问题。" style={{ height: 320 }}
items={summary.focusIssueGroups.externalImpact}
tone="amber"
/>
<FocusIssueList
title="员工问责问题"
description="展示已触发员工问责后果的问题。"
items={summary.focusIssueGroups.accountability}
tone="sky"
/> />
<div className="flex flex-wrap gap-2">
{focusIssueChartData.map((item) => (
<Button
key={item.key}
outline
onClick={() => setActiveFocusIssueTypeKey(item.key)}
>
{item.shortLabel} {formatCount(item.value)} 条
</Button>
))}
</div> </div>
</div>
) : (
<EmptyBlock title="当前没有相关重点问题" />
)}
</Panel>
</section> </section>
<section className="space-y-5"> <section className="space-y-5">
...@@ -891,17 +991,6 @@ export default function ProblemRectificationDashboardPage() { ...@@ -891,17 +991,6 @@ export default function ProblemRectificationDashboardPage() {
)} )}
</Panel> </Panel>
</div> </div>
<Panel
title="整改时效分析"
description="X 轴为预计整改时长,Y 轴为实际整改时长。虚线右上方的点代表实际时长高于预估。"
>
{summary.timelinessScatter.length > 0 ? (
<ReactECharts option={timelinessScatterOption} style={{ height: 380 }} />
) : (
<EmptyBlock title="缺少预计整改时间、实际整改完成时间或检查完成日期,暂无法形成时效散点图" />
)}
</Panel>
</section> </section>
<section className="space-y-5"> <section className="space-y-5">
...@@ -923,6 +1012,16 @@ export default function ProblemRectificationDashboardPage() { ...@@ -923,6 +1012,16 @@ export default function ProblemRectificationDashboardPage() {
</section> </section>
</> </>
) : null} ) : null}
</>
) : null}
<FocusIssueDetailDialog
issueType={activeFocusIssueType}
items={activeFocusIssueItems}
onClose={() => setActiveFocusIssueTypeKey('')}
/>
</div> </div>
); );
} }
export default ProblemRectificationDashboardPage;
...@@ -38,15 +38,17 @@ ...@@ -38,15 +38,17 @@
## 3. 功能入口 ## 3. 功能入口
建议在当前项目中保留以下三个一级入口: 建议在当前项目中保留以下四个一级入口:
- 驾驶舱:`/integrity_risk_table/dashboard` - 驾驶舱:`/integrity_risk_table/dashboard`
- 业务驾驶舱:`/integrity_risk_table/business_dashboard`
- 问题整改:`/problem_rectification` - 问题整改:`/problem_rectification`
- 往期材料归档:`/issue_rectification_archive` - 往期材料归档:`/issue_rectification_archive`
建议交互形态: 建议交互形态:
- 驾驶舱使用轻量卡片、统计区块、分组标签页和列表卡片 - 驾驶舱仅展示顶部横幅、部门分组切换与宏观总览 KPI 卡片
- 业务驾驶舱承接整改进度分布、问题风险定位、责任追踪与整改质效图表
- 问题整改主列表使用普通数据表格,不再依赖多维表格编辑器 - 问题整改主列表使用普通数据表格,不再依赖多维表格编辑器
- 新增、编辑、查看详情优先采用抽屉或弹窗,不走重型嵌套表格交互 - 新增、编辑、查看详情优先采用抽屉或弹窗,不走重型嵌套表格交互
- 批量操作放在顶部工具栏,不再依赖右键菜单作为唯一入口 - 批量操作放在顶部工具栏,不再依赖右键菜单作为唯一入口
...@@ -79,20 +81,23 @@ ...@@ -79,20 +81,23 @@
### 3.1 目标页面拆分 ### 3.1 目标页面拆分
为便于当前项目直接重构,建议把模块拆成以下 4 个页面入口: 为便于当前项目直接重构,建议把模块拆成以下 5 个页面入口:
1. `integrity_risk_table/dashboard` 1. `integrity_risk_table/dashboard`
问题整改驾驶舱 问题整改驾驶舱
2. `problem_rectification` 2. `integrity_risk_table/business_dashboard`
业务驾驶舱
3. `problem_rectification`
问题整改主列表 问题整改主列表
3. `issue_rectification_archive` 4. `issue_rectification_archive`
归档批次列表 归档批次列表
4. `problem_rectification_form` 5. `problem_rectification_form`
外链填写 / 复核 / 最终审核页 外链填写 / 复核 / 最终审核页
对应推荐页面职责: 对应推荐页面职责:
- 驾驶舱:只做统计、提醒、分组切换,不承接编辑 - 驾驶舱:展示管理层首屏关注的宏观总览,保留部门分组切换,不承接编辑
- 业务驾驶舱:展示后续业务分析图表、责任追踪、重点问题清单与质效分布,保留部门分组切换,不承接编辑
- 主列表:承接查询、筛选、批量操作、详情入口 - 主列表:承接查询、筛选、批量操作、详情入口
- 归档页:承接归档批次查看与明细追溯 - 归档页:承接归档批次查看与明细追溯
- 外链流程页:承接填写、复核、最终审核,不与主列表混杂 - 外链流程页:承接填写、复核、最终审核,不与主列表混杂
...@@ -403,7 +408,12 @@ ...@@ -403,7 +408,12 @@
## 9. 驾驶舱展示规则 ## 9. 驾驶舱展示规则
当前驾驶舱页面以 `problem_rectification` 为唯一数据源,围绕 2026 年整改驾驶舱需求分为四层: 当前驾驶舱能力以 `problem_rectification` 为唯一数据源,并拆分为两个菜单入口:
- `integrity_risk_table/dashboard`:问题整改驾驶舱,仅展示顶部横幅、部门分组切换与宏观总览 KPI
- `integrity_risk_table/business_dashboard`:业务驾驶舱,展示原宏观总览之后的业务分析内容,并继续支持部门分组切换
围绕 2026 年整改驾驶舱需求,整体统计能力分为四层:
1. 宏观总览层 1. 宏观总览层
2. 问题分析与风险定位层 2. 问题分析与风险定位层
...@@ -435,11 +445,24 @@ ...@@ -435,11 +445,24 @@
当前实现约定: 当前实现约定:
- 顶部继续保留按 `feedback_unit` 分组的轻量 tabs - 两个驾驶舱入口顶部均保留按 `feedback_unit` 分组的轻量 tabs
- tabs 下方分为 KPI 卡片、ECharts 图表区和重点问题清单区 - 两个驾驶舱入口顶部均提供“统计年份”选择器,默认“全部年份”,选择具体年份后仅统计该年份内的问题记录
- 驾驶舱仅负责统计展示,不承接编辑动作 - 问题整改驾驶舱的 tabs 下方只展示宏观总览 KPI 卡片
- 业务驾驶舱的 tabs 下方展示整改进度分布、业务风险分析、责任追踪和整改质效图表
- 两个驾驶舱均仅负责统计展示,不承接编辑动作
- 如果线上 Subject 后续把新增字段从 `text` 升级为 `select` / `date` / `boolean`,驾驶舱统计口径无需调整,只需复用标准字段值 - 如果线上 Subject 后续把新增字段从 `text` 升级为 `select` / `date` / `boolean`,驾驶舱统计口径无需调整,只需复用标准字段值
### 9.1.1 统计年份筛选
年份筛选用于支持管理层按年度查看整改概览和业务分析:
- 默认选项为“全部年份”,不做年度过滤,按当前数据全量统计
- 年份选项从当前已加载的 `problem_rectification` 记录自动提取,按年份倒序展示
- 记录年份优先从检查类日期和业务发生时间推导,依次读取:`inspection_report_date`、`check_the_implementation_time`、`inspection_completed_at`、`the_time_of_rectification`、`actual_rectification_completed_at`
- 如果上述字段都无法识别年份,则回退到记录的 `created_at` / `updated_at`
- 选择年份后,部门分组、宏观 KPI、整改进度、问题风险定位、责任追踪和整改质效统计均基于该年份内记录重新计算
- 如果某年份下没有可展示记录,页面展示空状态,并提示切换其它年份或全部年份
### 9.2 宏观总览层 ### 9.2 宏观总览层
当前 KPI 统计口径: 当前 KPI 统计口径:
...@@ -454,6 +477,7 @@ ...@@ -454,6 +477,7 @@
- 以 `rectification_progress` 为维度统计数量和占比 - 以 `rectification_progress` 为维度统计数量和占比
- 当前页面使用环形图表达,不再沿用旧版折线趋势表达 - 当前页面使用环形图表达,不再沿用旧版折线趋势表达
- 业务驾驶舱仅保留整改进度分布图,不再展示单独的“进度口径说明”卡片
### 9.3 问题分析与风险定位层 ### 9.3 问题分析与风险定位层
...@@ -466,7 +490,8 @@ ...@@ -466,7 +490,8 @@
- 经济损失问题:`has_economic_loss` + `economic_loss_amount` - 经济损失问题:`has_economic_loss` + `economic_loss_amount`
- 外部影响问题:`has_external_impact` + `penalty_organization` - 外部影响问题:`has_external_impact` + `penalty_organization`
- 员工问责问题:`has_employee_accountability` + `accountability_person_count` - 员工问责问题:`has_employee_accountability` + `accountability_person_count`
- 重点问题清单中同时展示 `problem_severity_level` - 页面以柱状图展示三类重点问题数量;点击柱状图或下方按钮后,通过弹框展示对应详情列表
- 重点问题详情列表中同时展示 `problem_severity_level`、截止日期、反馈单位、标记信息和问题简述
### 9.4 问题责任追踪与执行层 ### 9.4 问题责任追踪与执行层
...@@ -477,10 +502,7 @@ ...@@ -477,10 +502,7 @@
- X 轴:责任部门问题总数 - X 轴:责任部门问题总数
- Y 轴:整改完成率 - Y 轴:整改完成率
- 气泡大小:逾期问题数 - 气泡大小:逾期问题数
- 整改时效分析散点图: - 业务驾驶舱不再展示单独的“整改时效分析”散点图
- X 轴:`the_time_of_rectification - inspection_completed_at`
- Y 轴:`actual_rectification_completed_at - inspection_completed_at`
- 仅统计同时填写了预计整改时间、实际整改完成时间、实际检查完成日期且天数非负的问题
当前逾期 / 延期判定说明: 当前逾期 / 延期判定说明:
...@@ -701,6 +723,7 @@ const result = await queryUsers(0, 20, { ...@@ -701,6 +723,7 @@ const result = await queryUsers(0, 20, {
- `src/pages/problem_rectification/Layout.jsx` - `src/pages/problem_rectification/Layout.jsx`
- `src/pages/problem_rectification/dashboard/index.jsx` - `src/pages/problem_rectification/dashboard/index.jsx`
- `src/pages/problem_rectification/businessDashboard/BusinessDashboard.jsx`
- `src/pages/problem_rectification/list/index.jsx` - `src/pages/problem_rectification/list/index.jsx`
- `src/pages/problem_rectification/archive/index.jsx` - `src/pages/problem_rectification/archive/index.jsx`
- `src/pages/problem_rectification/form/index.jsx` - `src/pages/problem_rectification/form/index.jsx`
......
...@@ -35,6 +35,13 @@ const FIXED_PROGRESS_VALUES = new Set(['已整改', '已优化']); ...@@ -35,6 +35,13 @@ 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 YEAR_SOURCE_FIELDS = [
'inspection_report_date',
'check_the_implementation_time',
'inspection_completed_at',
'the_time_of_rectification',
'actual_rectification_completed_at',
];
function normalizeText(value) { function normalizeText(value) {
return String(value ?? '').trim(); return String(value ?? '').trim();
...@@ -81,6 +88,36 @@ function formatDateText(value) { ...@@ -81,6 +88,36 @@ function formatDateText(value) {
return /^\d{4}-\d{2}-\d{2}/.test(text) ? text.slice(0, 10) : text; return /^\d{4}-\d{2}-\d{2}/.test(text) ? text.slice(0, 10) : text;
} }
function extractYear(value) {
const text = normalizeText(value);
if (!text) {
return null;
}
const parsed = parseDate(text);
if (parsed) {
return parsed.year();
}
const yearMatch = text.match(/(?:19|20)\d{2}/);
if (!yearMatch) {
return null;
}
return Number(yearMatch[0]);
}
function extractRecordYear(record, metadata) {
for (const field of YEAR_SOURCE_FIELDS) {
const year = extractYear(metadata[field]);
if (year) {
return year;
}
}
return extractYear(record?.created_at) || extractYear(record?.updated_at);
}
function isAffirmative(value) { function isAffirmative(value) {
return POSITIVE_FLAGS.has(normalizeText(value).toUpperCase()); return POSITIVE_FLAGS.has(normalizeText(value).toUpperCase());
} }
...@@ -161,14 +198,6 @@ function sortByCountDesc(items = []) { ...@@ -161,14 +198,6 @@ function sortByCountDesc(items = []) {
return [...items].sort((left, right) => right.value - left.value); return [...items].sort((left, right) => right.value - left.value);
} }
function diffDays(later, earlier) {
if (!later || !earlier) {
return null;
}
return later.startOf('day').diff(earlier.startOf('day'), 'day');
}
function buildDistributionMap(records, getKey) { function buildDistributionMap(records, getKey) {
const map = new Map(); const map = new Map();
...@@ -274,19 +303,16 @@ function buildFocusIssueGroups(records) { ...@@ -274,19 +303,16 @@ function buildFocusIssueGroups(records) {
economicLoss: records economicLoss: records
.filter((record) => record.hasEconomicLoss) .filter((record) => record.hasEconomicLoss)
.sort(sortBySeverityAndDueDate) .sort(sortBySeverityAndDueDate)
.slice(0, 6)
.map((record) => .map((record) =>
formatItem(record, record.economicLossAmount ? `损失 ${record.economicLossAmount}` : '已标记经济损失'), formatItem(record, record.economicLossAmount ? `损失 ${record.economicLossAmount}` : '已标记经济损失'),
), ),
externalImpact: records externalImpact: records
.filter((record) => record.hasExternalImpact) .filter((record) => record.hasExternalImpact)
.sort(sortBySeverityAndDueDate) .sort(sortBySeverityAndDueDate)
.slice(0, 6)
.map((record) => formatItem(record, record.penaltyOrganization || '已标记外部影响')), .map((record) => formatItem(record, record.penaltyOrganization || '已标记外部影响')),
accountability: records accountability: records
.filter((record) => record.hasEmployeeAccountability) .filter((record) => record.hasEmployeeAccountability)
.sort(sortBySeverityAndDueDate) .sort(sortBySeverityAndDueDate)
.slice(0, 6)
.map((record) => .map((record) =>
formatItem( formatItem(
record, record,
...@@ -359,34 +385,6 @@ function buildOverdueRanking(departmentStats) { ...@@ -359,34 +385,6 @@ function buildOverdueRanking(departmentStats) {
).slice(0, 10); ).slice(0, 10);
} }
function buildTimelinessScatter(records) {
return records
.map((record) => {
const plannedDays = diffDays(record.dueDate, record.inspectionCompletedAt);
const actualDays = diffDays(
record.actualRectificationCompletedAt,
record.inspectionCompletedAt,
);
if (
plannedDays === null ||
actualDays === null ||
plannedDays < 0 ||
actualDays < 0
) {
return null;
}
return {
name: record.title,
value: [plannedDays, actualDays, record.severityLevel || '未评定'],
isDelayed: actualDays > plannedDays,
};
})
.filter(Boolean)
.slice(0, 120);
}
function buildQualityRatingDistribution(records) { function buildQualityRatingDistribution(records) {
const counts = { const counts = {
高: 0, 高: 0,
...@@ -429,7 +427,6 @@ function summarizeRecords(records) { ...@@ -429,7 +427,6 @@ function summarizeRecords(records) {
focusIssueGroups: buildFocusIssueGroups(records), focusIssueGroups: buildFocusIssueGroups(records),
overdueRanking: buildOverdueRanking(departmentStats), overdueRanking: buildOverdueRanking(departmentStats),
departmentMatrix: buildDepartmentMatrix(departmentStats), departmentMatrix: buildDepartmentMatrix(departmentStats),
timelinessScatter: buildTimelinessScatter(records),
qualityRatingDistribution: buildQualityRatingDistribution(records), qualityRatingDistribution: buildQualityRatingDistribution(records),
}; };
} }
...@@ -454,6 +451,7 @@ function normalizeProblemRecord(record) { ...@@ -454,6 +451,7 @@ function normalizeProblemRecord(record) {
const normalizedRecord = { 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) || '-',
recordYear: extractRecordYear(record, metadata),
projectName: normalizeText(metadata.project_name), projectName: normalizeText(metadata.project_name),
inspectUnit: normalizeText(metadata.inspect_the_implementing_unit), inspectUnit: normalizeText(metadata.inspect_the_implementing_unit),
feedbackUnit: normalizeText(metadata.feedback_unit), feedbackUnit: normalizeText(metadata.feedback_unit),
...@@ -527,6 +525,32 @@ function buildDepartmentBuckets(records) { ...@@ -527,6 +525,32 @@ function buildDepartmentBuckets(records) {
return buckets; return buckets;
} }
function buildDashboardScope(records) {
const departmentBuckets = buildDepartmentBuckets(records);
const summaries = Object.fromEntries(
DEPARTMENT_GROUPS.map((group) => [
group.key,
summarizeRecords(departmentBuckets[group.key] || []),
]),
);
return {
departmentBuckets,
summaries,
isEmpty: records.length === 0,
};
}
function buildYearOptions(records) {
return Array.from(
new Set(
records
.map((record) => record.recordYear)
.filter((year) => Number.isInteger(year)),
),
).sort((left, right) => right - left);
}
async function fetchAllProblemRectificationRecords() { async function fetchAllProblemRectificationRecords() {
const firstPage = await queryRecords({ const firstPage = await queryRecords({
subject: PROBLEM_RECTIFICATION_SUBJECT, subject: PROBLEM_RECTIFICATION_SUBJECT,
...@@ -558,27 +582,31 @@ export async function loadProblemRectificationDashboardData() { ...@@ -558,27 +582,31 @@ export async function loadProblemRectificationDashboardData() {
try { try {
const rawRecords = await fetchAllProblemRectificationRecords(); const rawRecords = await fetchAllProblemRectificationRecords();
const records = rawRecords.map(normalizeProblemRecord); const records = rawRecords.map(normalizeProblemRecord);
const departmentBuckets = buildDepartmentBuckets(records); const yearOptions = buildYearOptions(records);
const allScope = buildDashboardScope(records);
const summaries = Object.fromEntries( const yearScopes = Object.fromEntries(
DEPARTMENT_GROUPS.map((group) => [ yearOptions.map((year) => [
group.key, String(year),
summarizeRecords(departmentBuckets[group.key] || []), buildDashboardScope(records.filter((record) => record.recordYear === year)),
]), ]),
); );
return { return {
groups: DEPARTMENT_GROUPS, groups: DEPARTMENT_GROUPS,
records, records,
departmentBuckets, yearOptions,
summaries, yearScopes,
departmentBuckets: allScope.departmentBuckets,
summaries: allScope.summaries,
error: '', error: '',
isEmpty: records.length === 0, isEmpty: allScope.isEmpty,
}; };
} catch (error) { } catch (error) {
return { return {
groups: DEPARTMENT_GROUPS, groups: DEPARTMENT_GROUPS,
records: [], records: [],
yearOptions: [],
yearScopes: {},
departmentBuckets: {}, departmentBuckets: {},
summaries: {}, summaries: {},
error: error?.message || '驾驶舱数据加载失败,请稍后重试。', error: error?.message || '驾驶舱数据加载失败,请稍后重试。',
......
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