机构端、监管端首页修改

dev
huwei 2026-08-05 14:09:04 +08:00
parent de49125981
commit 553a2a75a7
6 changed files with 346 additions and 350 deletions

View File

@ -1,8 +1,11 @@
import React from 'react';
export default function ProjectCompletionTable({ data, onView }) {
export default function ProjectCompletionTable({ data, loading, onView }) {
const list = data && data.list ? data.list : [];
//
const skeletonRows = Array.from({ length: 5 });
return (
<section className="dashboard-block dashboard-project-block">
<div className="dashboard-block-head">
@ -26,26 +29,39 @@ export default function ProjectCompletionTable({ data, onView }) {
</tr>
</thead>
<tbody>
{list.map((record, index) => (
<tr key={record.projectId || index}>
<td>{index + 1}</td>
<td>{record.projectNo}</td>
<td>{record.projectName}</td>
<td>{record.customerName}</td>
<td>{record.evalTypeName}</td>
<td>{record.projectLeaderName}</td>
<td>{record.planEndDate}</td>
<td>
<button
className="btn btn-primary btn-sm"
type="button"
onClick={() => onView && onView(record)}
>
查看
</button>
</td>
</tr>
))}
{loading
? skeletonRows.map((_, index) => (
<tr key={`sk-${index}`}>
<td><span className="sk-block sk-cell" /></td>
<td><span className="sk-block sk-cell" /></td>
<td><span className="sk-block sk-cell sk-cell--lg" /></td>
<td><span className="sk-block sk-cell" /></td>
<td><span className="sk-block sk-cell" /></td>
<td><span className="sk-block sk-cell" /></td>
<td><span className="sk-block sk-cell" /></td>
<td><span className="sk-block sk-cell sk-cell--sm" /></td>
</tr>
))
: list.map((record, index) => (
<tr key={record.projectId || index}>
<td>{index + 1}</td>
<td>{record.projectNo}</td>
<td>{record.projectName}</td>
<td>{record.customerName}</td>
<td>{record.evalTypeName}</td>
<td>{record.projectLeaderName}</td>
<td>{record.planEndDate}</td>
<td>
<button
className="btn btn-primary btn-sm"
type="button"
onClick={() => onView && onView(record)}
>
查看
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>

View File

@ -357,6 +357,40 @@
color: #fff;
}
/* ============ 骨架占位(加载态) ============ */
.sk-block {
display: inline-block;
background: linear-gradient(90deg, #eef2f7 25%, #e2e8f0 37%, #eef2f7 63%);
background-size: 400% 100%;
animation: sk-shimmer 1.3s ease-in-out infinite;
vertical-align: middle;
}
@keyframes sk-shimmer {
0% { background-position: 100% 0; }
100% { background-position: 0 0; }
}
/* 图表占位块 */
.sk-chart {
width: 100%;
height: 225px;
border-radius: 8px;
background: linear-gradient(90deg, #eef2f7 25%, #e2e8f0 37%, #eef2f7 63%);
background-size: 400% 100%;
animation: sk-shimmer 1.3s ease-in-out infinite;
}
.sk-chart--donut { height: 185px; }
/* 表格单元格骨架 */
.sk-cell {
width: 70%;
height: 12px;
border-radius: 4px;
}
.sk-cell--lg { width: 85%; }
.sk-cell--sm { width: 36px; }
/* ============ 响应式 ============ */
@media (max-width: 1280px) {
.dashboard-classic-top,

View File

@ -7,12 +7,6 @@ import './index.css';
import {
STATISTIC_CARDS,
INFO_ALERTS,
PROJECT_SUMMARY,
ENTERPRISE_TYPE_STATS,
PROJECT_TYPE_DISTRIBUTION,
PROJECT_TOTAL,
PROJECT_COMPLETION_LIST,
} from './mockData';
import {
@ -23,65 +17,37 @@ import {
fetchProjectExecution,
} from '~/api/institution';
// ==================== UI 展示常量(与原型保持一致,非数据映射) ====================
function mockIndustryToApiFormat() {
return {
list: ENTERPRISE_TYPE_STATS.categories.map((name, i) => ({
industryCode: '',
industryName: name,
projectCount: ENTERPRISE_TYPE_STATS.projectCount[i],
statutoryProjectCount: ENTERPRISE_TYPE_STATS.statutoryCount[i],
})),
};
}
function mockEvalTypeToApiFormat() {
return {
totalProjectCount: PROJECT_TOTAL,
items: PROJECT_TYPE_DISTRIBUTION.map(item => ({
evalTypeCode: '',
evalTypeName: item.name,
count: item.value,
})),
};
}
function mockExecutionToApiFormat() {
return {
list: PROJECT_COMPLETION_LIST.map(item => ({
projectId: item.id,
projectNo: item.projectCode,
projectName: item.projectName,
customerName: item.evalCompany,
evalTypeCode: '',
evalTypeName: item.evalType,
projectLeaderName: item.projectLeader,
planEndDate: item.endDate,
})),
};
// ==================== 骨架占位 ====================
function Skeleton({ width = '100%', height = 14, radius = 4, className = '' }) {
return (
<span
className={`sk-block ${className}`}
style={{ width, height, borderRadius: radius }}
aria-hidden="true"
/>
);
}
// ==================== 子组件 ====================
function StatusCard({ item }) {
function StatusCard({ item, loading }) {
return (
<article>
<i className={item.colorClass}>{item.char}</i>
<div>
<span>{item.title}</span>
<strong>{item.value}</strong>
{loading ? <Skeleton width={42} height={16} /> : <strong>{item.value}</strong>}
</div>
</article>
);
}
function ReminderCard({ item }) {
function ReminderCard({ item, loading }) {
return (
<article className={item.colorClass}>
<i>{item.char}</i>
<div>
<span>{item.title}</span>
<strong>{item.value}</strong>
{loading ? <Skeleton width={42} height={16} /> : <strong>{item.value}</strong>}
</div>
</article>
);
@ -92,6 +58,7 @@ export default function InstitutionDashboard() {
const handleReminderClick = useCallback(() => {}, []);
// 各 API 的原始返回数据null 表示接口未成功,走 mock
const [loading, setLoading] = useState(true);
const [nodeStatsApi, setNodeStatsApi] = useState(null);
const [noticesApi, setNoticesApi] = useState(null);
const [industryStatApi, setIndustryStatApi] = useState(null);
@ -99,25 +66,14 @@ export default function InstitutionDashboard() {
const [projectExecutionApi, setProjectExecutionApi] = useState(null);
useEffect(() => {
fetchProjectNodeStats()
.then(setNodeStatsApi)
.catch(() => {});
fetchNotices()
.then(setNoticesApi)
.catch(() => {});
fetchIndustryStat()
.then(setIndustryStatApi)
.catch(() => {});
fetchEvalTypeRatio()
.then(setEvalTypeRatioApi)
.catch(() => {});
fetchProjectExecution()
.then(setProjectExecutionApi)
.catch(() => {});
const tasks = [
fetchProjectNodeStats().then(setNodeStatsApi).catch(() => {}),
fetchNotices().then(setNoticesApi).catch(() => {}),
fetchIndustryStat().then(setIndustryStatApi).catch(() => {}),
fetchEvalTypeRatio().then(setEvalTypeRatioApi).catch(() => {}),
fetchProjectExecution().then(setProjectExecutionApi).catch(() => {}),
];
Promise.all(tasks).finally(() => setLoading(false));
}, []);
// 衍生展示数据始终渲染固定卡片集合8 个节点 + 已归档 + 项目延期),
@ -144,7 +100,7 @@ export default function InstitutionDashboard() {
}, [nodeStatsApi]);
const projectSummary = useMemo(() => {
if (!nodeStatsApi) return PROJECT_SUMMARY;
if (!nodeStatsApi) return { total: 0, statutory: 0 };
return {
total: Number(nodeStatsApi.totalProjects) || 0,
statutory: Number(nodeStatsApi.statutoryProjects) || 0,
@ -152,7 +108,13 @@ export default function InstitutionDashboard() {
}, [nodeStatsApi]);
const alertItems = useMemo(() => {
if (!noticesApi) return INFO_ALERTS;
if (!noticesApi) {
return [
{ key: 'qualCheck', title: '资质现场审查通知', value: 0, char: '审', colorClass: 'yellow' },
{ key: 'supervisionCheck', title: '监督检查通知', value: 0, char: '检', colorClass: 'red' },
{ key: 'unreadNotice', title: '未读监管通知', value: 0, char: '未', colorClass: 'blue' },
];
}
return [
{ key: 'qualCheck', title: '资质现场审查通知', value: Number(noticesApi.qualOnSiteReviewCount) || 0, char: '审', colorClass: 'yellow' },
{ key: 'supervisionCheck', title: '监督检查通知', value: Number(noticesApi.inspectionCount) || 0, char: '检', colorClass: 'red' },
@ -160,23 +122,12 @@ export default function InstitutionDashboard() {
];
}, [noticesApi]);
const industryStatData = useMemo(() => {
return (industryStatApi && industryStatApi.list && industryStatApi.list.length > 0)
? industryStatApi
: mockIndustryToApiFormat();
}, [industryStatApi]);
// 接口无数据时使用空结构,不再用 mock 伪造数据
const industryStatData = useMemo(() => industryStatApi || { list: [] }, [industryStatApi]);
const evalTypeRatioData = useMemo(() => {
return (evalTypeRatioApi && evalTypeRatioApi.items && evalTypeRatioApi.items.length > 0)
? evalTypeRatioApi
: mockEvalTypeToApiFormat();
}, [evalTypeRatioApi]);
const evalTypeRatioData = useMemo(() => evalTypeRatioApi || { totalProjectCount: 0, items: [] }, [evalTypeRatioApi]);
const projectExecutionData = useMemo(() => {
return (projectExecutionApi && projectExecutionApi.list && projectExecutionApi.list.length > 0)
? projectExecutionApi
: mockExecutionToApiFormat();
}, [projectExecutionApi]);
const projectExecutionData = useMemo(() => projectExecutionApi || { list: [] }, [projectExecutionApi]);
const legendColors = useMemo(() => ({
project: '#4285f4',
@ -189,20 +140,20 @@ export default function InstitutionDashboard() {
<DriverBack />
</div>
<div className="dashboard-classic">
<div className="dashboard-classic">
{/* ===== 第一行:项目节点统计 + 通知提醒 ===== */}
<div className="dashboard-classic-top">
<section className="dashboard-block dashboard-status-block">
<div className="dashboard-block-head">
<h3>当前项目节点统计</h3>
<div>
<span>项目总数<b>{projectSummary.total}</b></span>
<span>法定项目<b>{projectSummary.statutory}</b></span>
<span>项目总数{loading ? <Skeleton width={28} height={14} /> : <b>{projectSummary.total}</b>}</span>
<span>法定项目{loading ? <Skeleton width={28} height={14} /> : <b>{projectSummary.statutory}</b>}</span>
</div>
</div>
<div className="dashboard-status-grid">
{statusCards.map(item => (
<StatusCard key={item.key} item={item} />
<StatusCard key={item.key} item={item} loading={loading} />
))}
</div>
</section>
@ -213,7 +164,7 @@ export default function InstitutionDashboard() {
</div>
<div className="dashboard-reminder-grid">
{alertItems.map(item => (
<ReminderCard key={item.key} item={item} onClick={handleReminderClick} />
<ReminderCard key={item.key} item={item} loading={loading} onClick={handleReminderClick} />
))}
</div>
</section>
@ -229,20 +180,25 @@ export default function InstitutionDashboard() {
<span><i style={{ backgroundColor: legendColors.statutory }} />法定项目</span>
</div>
</div>
<EnterpriseTypeChart data={industryStatData} />
{loading
? <div className="sk-chart" />
: <EnterpriseTypeChart data={industryStatData} />}
</section>
<section className="dashboard-block dashboard-type-block">
<div className="dashboard-block-head">
<h3>评价类别占比</h3>
</div>
<ProjectTypeChart data={evalTypeRatioData} />
{loading
? <div className="sk-chart sk-chart--donut" />
: <ProjectTypeChart data={evalTypeRatioData} />}
</section>
</div>
{/* ===== 第三行:项目执行情况 ===== */}
<ProjectCompletionTable
data={projectExecutionData}
loading={loading}
onView={() => {}}
/>
</div>

View File

@ -1,79 +1,17 @@
/**
* 机构端首页 - 模拟数据1:1 还原原型 V1.4
* 机构端首页 - 卡片模板仅标题/图标/样式value 统一为 0由接口数据覆盖
* 不再保留任何 mock 假数据接口无数据时展示 0 / 空值
*/
// 仅作为卡片模板(标题/图标/样式value 统一默认 0由接口数据覆盖。
export const STATISTIC_CARDS = [
{ key: 'riskAnalysis', title: '待风险分析', value: 3, char: '险', colorClass: 'blue' },
{ key: 'contractInput', title: '待合同录入', value: 2, char: '合', colorClass: 'violet' },
{ key: 'projectTeam', title: '待项目组成立', value: 2, char: '组', colorClass: 'green' },
{ key: 'siteSurvey', title: '待现场踏勘', value: 4, char: '场', colorClass: 'orange' },
{ key: 'reportDraft', title: '待报告编制', value: 5, char: '报', colorClass: 'blue' },
{ key: 'internalReview', title: '待内部审核', value: 3, char: '内', colorClass: 'violet' },
{ key: 'techReview', title: '待技术审核', value: 2, char: '技', colorClass: 'orange' },
{ key: 'processReview', title: '待过程控制审核', value: 1, char: '控', colorClass: 'green' },
{ key: 'archived', title: '已归档项目', value: 9, char: '档', colorClass: 'cyan' },
{ key: 'delayed', title: '项目延期', value: 2, char: '延', colorClass: 'red' },
];
export const INFO_ALERTS = [
{ key: 'qualCheck', title: '资质现场审查通知', value: 1, char: '审', colorClass: 'yellow' },
{ key: 'supervisionCheck', title: '监督检查通知', value: 3, char: '检', colorClass: 'red' },
{ key: 'unreadNotice', title: '未读监管通知', value: 2, char: '未', colorClass: 'blue' },
];
export const PROJECT_SUMMARY = {
total: 31,
statutory: 24,
};
export const ENTERPRISE_TYPE_STATS = {
categories: ['危险化学品', '非煤矿山', '金属冶炼', '工贸', '烟花爆竹', '石油天然气', '其他行业'],
projectCount: [24, 20, 15, 18, 12, 14, 10],
statutoryCount: [19, 16, 12, 14, 9, 11, 7],
};
export const PROJECT_TYPE_DISTRIBUTION = [
{ name: '安全预评价', value: 12, color: '#4285f4' },
{ name: '安全验收评价', value: 8, color: '#58b874' },
{ name: '安全现状评价', value: 11, color: '#ff9f2f' },
];
export const PROJECT_TOTAL = 31;
export const PROJECT_COMPLETION_LIST = [
{
id: 1,
projectCode: 'XP-2026-001',
projectName: '华安化工园区安全预评价',
evalCompany: '重庆华安安全科技有限公司',
evalType: '安全预评价',
projectLeader: '张建国',
endDate: '2026-09-30',
},
{
id: 2,
projectCode: 'XP-2026-002',
projectName: '鼎信建设安全验收评价',
evalCompany: '重庆鼎信安全咨询有限公司',
evalType: '安全验收评价',
projectLeader: '李明华',
endDate: '2026-08-15',
},
{
id: 3,
projectCode: 'XP-2026-003',
projectName: '西南矿业安全现状评价',
evalCompany: '重庆西南矿业有限公司',
evalType: '安全现状评价',
projectLeader: '陈志强',
endDate: '2026-10-20',
},
{
id: 4,
projectCode: 'XP-2026-004',
projectName: '两江装备制造安全现状评价',
evalCompany: '重庆两江装备制造有限公司',
evalType: '安全现状评价',
projectLeader: '王海峰',
endDate: '2026-11-08',
},
{ key: 'riskAnalysis', title: '待风险分析', value: 0, char: '险', colorClass: 'blue' },
{ key: 'contractInput', title: '待合同录入', value: 0, char: '合', colorClass: 'violet' },
{ key: 'projectTeam', title: '待项目组成立', value: 0, char: '组', colorClass: 'green' },
{ key: 'siteSurvey', title: '待现场踏勘', value: 0, char: '场', colorClass: 'orange' },
{ key: 'reportDraft', title: '待报告编制', value: 0, char: '报', colorClass: 'blue' },
{ key: 'internalReview', title: '待内部审核', value: 0, char: '内', colorClass: 'violet' },
{ key: 'techReview', title: '待技术审核', value: 0, char: '技', colorClass: 'orange' },
{ key: 'processReview', title: '待过程控制审核', value: 0, char: '控', colorClass: 'green' },
{ key: 'archived', title: '已归档项目', value: 0, char: '档', colorClass: 'cyan' },
{ key: 'delayed', title: '项目延期', value: 0, char: '延', colorClass: 'red' },
];

View File

@ -313,3 +313,37 @@
font-size: 12px;
color: #d9f2ff;
}
/* ============ 骨架占位(深色大屏) ============ */
.sk-block--dark {
background: linear-gradient(90deg, rgba(40, 92, 160, .35) 25%, rgba(70, 130, 210, .5) 37%, rgba(40, 92, 160, .35) 63%);
background-size: 400% 100%;
animation: sk-shimmer-dark 1.3s ease-in-out infinite;
}
@keyframes sk-shimmer-dark {
0% { background-position: 100% 0; }
100% { background-position: 0 0; }
}
/* 图表占位块 */
.sk-chart--dark {
width: 100%;
height: 150px;
border-radius: 8px;
background: linear-gradient(90deg, rgba(40, 92, 160, .3) 25%, rgba(70, 130, 210, .45) 37%, rgba(40, 92, 160, .3) 63%);
background-size: 400% 100%;
animation: sk-shimmer-dark 1.3s ease-in-out infinite;
}
.sk-chart--donut { height: 144px; }
.sk-chart--dark.sk-chart--donut { height: 144px; }
/* 风险/预警小骨架块微调 */
.risk-item .sk-block--dark,
.review-stat .sk-block--dark,
.life-stat .sk-block--dark,
.progress-row .sk-block--dark,
.map-summary .sk-block--dark {
display: inline-block;
vertical-align: middle;
}

View File

@ -14,7 +14,6 @@ import {
} from '@ant-design/icons';
import DriverBack from '~/pages/Container/Driver/components/DriverBack';
import './index.css';
import { cockpitMockData } from './mockData';
import chongqingGeoJson from './data/chongqingGeoJson.json';
import {
fetchQualificationOverview,
@ -46,85 +45,60 @@ function formatNow(date = new Date()) {
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
// 将 mock 数据转为 API 结构,作为初始值
function buildInitialFromMock(m) {
// 所有数据的真实空值初始结构(无 mock接口无数据时展示 0 / 空)
function buildInitialState() {
return {
title: m.title,
title: '重庆市安全评价在线监管一件事',
currentTime: formatNow(),
lifecycleStats: {
newFilingOrgCount: m.lifecycleStats[0]?.value || 0,
currentFilingOrgCount: m.lifecycleStats[1]?.value || 0,
logoutOrgCount: m.lifecycleStats[2]?.value || 0,
totalFilingOrgCount: m.lifecycleStats[3]?.value || 0,
exitedEvaluatorCount: m.lifecycleStats[4]?.value || 0,
currentEvaluatorCount: m.lifecycleStats[5]?.value || 0,
newFilingOrgCount: 0,
currentFilingOrgCount: 0,
logoutOrgCount: 0,
totalFilingOrgCount: 0,
exitedEvaluatorCount: 0,
currentEvaluatorCount: 0,
},
progress: {
filingCompleteRate: m.progress[0]?.value || 0,
bizActiveRate: m.progress[1]?.value || 0,
newGrowthRate: m.progress[2]?.value || 0,
filingCompleteRate: 0,
bizActiveRate: 0,
newGrowthRate: 0,
},
industryList: m.industry.map((item, i) => ({
industryCode: String(i + 1),
industryName: item.name,
orgCount: item.institution,
typeCount: item.rectification,
})),
riskItems: m.riskItems,
riskPie: m.riskPie,
kpiItems: m.kpis.map(k => ({
code: '',
name: k.title,
value: parseFloat(k.value),
yoy: parseFloat(k.change),
})),
regionList: m.mapAreaStats
.filter(item => item.name !== '重庆市')
.map(item => ({
districtCode: '',
districtName: item.name,
evalProjectCount: item.projectCount,
filingOrgCount: item.institutionCount,
})),
industryList: [],
riskItems: [],
riskPie: [],
kpiItems: [],
regionList: [],
regionSummary: {
totalEvalProject: m.mapAreaStats.find(s => s.name === '重庆市')?.projectCount || 0,
totalFilingOrg: m.mapAreaStats.find(s => s.name === '重庆市')?.institutionCount || 0,
totalEvalProject: 0,
totalFilingOrg: 0,
},
mapScatterPoints: m.mapScatterPoints,
processNodes: m.todo.map((item, i) => ({
nodeCode: `NODE_${i}`,
nodeName: item.label,
totalCount: 0,
pendingCount: item.value,
})),
trendBuckets: m.reviewLine.xAxis,
trendSeries: [
{ evalTypeCode: '', evalTypeName: '项目数', projectCounts: m.reviewLine.project, inspCounts: [] },
{ evalTypeCode: '', evalTypeName: '监督检查数', projectCounts: m.reviewLine.supervision, inspCounts: [] },
],
mapScatterPoints: [],
processNodes: [],
trendBuckets: [],
trendSeries: [],
reviewStats: {
inspCount: m.reviewProgress[0]?.value || 0,
onsiteCheckCount: m.reviewProgress[0]?.value || 0,
inspCount: 0,
onsiteCheckCount: 0,
qualKeepCount: 0,
specialCount: 0,
checkedOrgCount: m.reviewProgress[4]?.value || 0,
foundProblemCount: m.reviewProgress[5]?.value || 0,
checkedOrgCount: 0,
foundProblemCount: 0,
},
reviewPieItems: m.reviewPie.map(item => ({
code: '',
name: item.name,
value: item.value,
})),
// 项目流程 mock 所用(基于 processOverview API nodes 结构)
processMockNodes: m.process.map((item, i) => ({
nodeCode: `NODE_${i}`,
nodeName: item.label,
totalCount: item.value,
pendingCount: 0,
})),
reviewPieItems: [],
};
}
// ==================== 骨架占位(深色大屏) ====================
function Skeleton({ width = '100%', height = 14, radius = 4, className = '' }) {
return (
<span
className={`sk-block sk-block--dark ${className}`}
style={{ width, height, borderRadius: radius }}
aria-hidden="true"
/>
);
}
// ==================== 子组件 ====================
function EChart({ option, className, events, onInstance }) {
@ -192,7 +166,7 @@ function Panel({ title, children, className = '' }) {
}
// ==================== 资质全生命周期管理 ====================
function LifecyclePanel({ data }) {
function LifecyclePanel({ data, loading }) {
const { lifecycleStats, progress, industryList } = data;
const statItems = useMemo(() => [
@ -239,7 +213,10 @@ function LifecyclePanel({ data }) {
{statItems.map((item) => (
<div className="life-stat" key={item.label}>
<span className="life-stat__icon"><FileProtectOutlined /></span>
<div><p>{item.label}</p><strong>{item.value}</strong></div>
<div>
<p>{item.label}</p>
{loading ? <Skeleton width={48} height={18} /> : <strong>{item.value}</strong>}
</div>
</div>
))}
</div>
@ -248,17 +225,17 @@ function LifecyclePanel({ data }) {
<div className="progress-row" key={item.label}>
<span>{item.label}</span>
<div className="progress-track"><i style={{ width: `${item.value}%`, background: item.color }} /></div>
<b>{item.value}%</b>
{loading ? <Skeleton width={40} height={14} /> : <b>{item.value}%</b>}
</div>
))}
</div>
<EChart className="industry-chart" option={barOption} />
{loading ? <div className="sk-chart sk-chart--dark" /> : <EChart className="industry-chart" option={barOption} />}
</Panel>
);
}
// ==================== 风险预警(仍用 mock无 API ====================
function RiskPanel({ data }) {
// ==================== 风险预警(暂无对应 API接口未接入时展示空值 ====================
function RiskPanel({ data, loading }) {
const pieOption = useMemo(() => ({
color: ['#5f8cff', '#e96fff', '#5fd8ae', '#b6a0ff'],
tooltip: { trigger: 'item', backgroundColor: 'rgba(3, 25, 66, .92)', borderColor: '#1aa7ff', textStyle: { color: '#dff7ff' } },
@ -266,28 +243,35 @@ function RiskPanel({ data }) {
series: [{ type: 'pie', radius: ['46%', '70%'], center: ['30%', '55%'], avoidLabelOverlap: true, label: { show: false }, data: data.riskPie }],
}), [data.riskPie]);
const riskSkeleton = Array.from({ length: 4 });
return (
<Panel title="风险预警智能研判" className="risk-panel">
<h3>资质备案类失信统计</h3>
<div className="risk-grid">
{data.riskItems.map(item => <div className="risk-item" key={item.label}><span>{item.label}</span><b>{item.value}</b></div>)}
{loading
? riskSkeleton.map((_, i) => (
<div className="risk-item" key={`sk-${i}`}><span><Skeleton width={64} height={12} /></span><Skeleton width={28} height={14} /></div>
))
: data.riskItems.map(item => <div className="risk-item" key={item.label}><span>{item.label}</span><b>{item.value}</b></div>)}
</div>
<h3>备案机构违法触发统计</h3>
<EChart className="risk-pie" option={pieOption} />
{loading ? <div className="sk-chart sk-chart--dark sk-chart--donut" /> : <EChart className="risk-pie" option={pieOption} />}
</Panel>
);
}
// ==================== KPI 指标卡片 ====================
function KpiCards({ data }) {
function KpiCards({ data, loading }) {
const { kpiItems } = data;
const kpiSkeleton = Array.from({ length: 5 });
return (
<div className="kpi-row">
{kpiItems.map(item => (
<div className="kpi-card" key={item.name}>
<p>{item.name}</p>
<strong>{item.value}%</strong>
<span>同比 <b> {item.yoy}%</b></span>
{(loading ? kpiSkeleton : kpiItems).map((item, index) => (
<div className="kpi-card" key={loading ? `sk-${index}` : item.name}>
<p>{loading ? <Skeleton width={56} height={12} /> : item.name}</p>
<strong>{loading ? <Skeleton width={44} height={20} /> : `${item.value}%`}</strong>
<span>{loading ? <Skeleton width={72} height={12} /> : `同比 ↗ ${item.yoy}%`}</span>
</div>
))}
</div>
@ -295,7 +279,7 @@ function KpiCards({ data }) {
}
// ==================== 地图区域分布 ====================
function MapSection({ data }) {
function MapSection({ data, loading }) {
const [selectedAreaName, setSelectedAreaName] = useState('重庆市');
const zoomRef = useRef(1.1);
const chartInstanceRef = useRef(null);
@ -443,11 +427,15 @@ function MapSection({ data }) {
return (
<div className="map-shell">
<EChart className="map-chart" option={mapOption} events={mapEvents} onInstance={handleMapInstance} />
{mapScatterPoints.map((item, index) => <div className={`map-card map-card--${index + 1}`} key={item.name}><strong>{item.name}</strong><span>{item.value}</span></div>)}
{!loading && mapScatterPoints.map((item, index) => <div className={`map-card map-card--${index + 1}`} key={item.name}><strong>{item.name}</strong><span>{item.value}</span></div>)}
<div className="map-summary">
<strong>{selectedArea.name}</strong>
<span>项目数{selectedArea.evalProjectCount}</span>
<span>备案机构数{selectedArea.filingOrgCount}</span>
{loading
? <><Skeleton width={70} height={14} /><Skeleton width={90} height={14} /></>
: <>
<span>项目数{selectedArea.evalProjectCount}</span>
<span>备案机构数{selectedArea.filingOrgCount}</span>
</>}
</div>
<ul className="map-legend"><li>0-10</li><li>11-30</li><li>31-50</li><li>51-100</li></ul>
</div>
@ -455,15 +443,21 @@ function MapSection({ data }) {
}
// ==================== 执业全过程管控 ====================
function TodoPanel({ data }) {
function TodoPanel({ data, loading }) {
const { processNodes } = data;
const skeletonNodes = Array.from({ length: 9 });
return (
<Panel title="执业全过程管控" className="todo-panel">
<div className="todo-grid">
{processNodes.map((item, index) => {
const Icon = iconMap[index] || FileDoneOutlined;
return <div className="todo-item" key={item.nodeName}><span><Icon /></span><p>{item.nodeName}</p><b>{item.pendingCount}</b></div>;
})}
{loading
? skeletonNodes.map((_, index) => {
const Icon = iconMap[index] || FileDoneOutlined;
return <div className="todo-item" key={`sk-${index}`}><span><Icon /></span><p><Skeleton width={56} height={12} /></p><Skeleton width={24} height={16} /></div>;
})
: processNodes.map((item, index) => {
const Icon = iconMap[index] || FileDoneOutlined;
return <div className="todo-item" key={item.nodeName}><span><Icon /></span><p>{item.nodeName}</p><b>{item.pendingCount}</b></div>;
})}
</div>
</Panel>
);
@ -476,7 +470,7 @@ const PERIOD_TABS = [
{ key: 'month', label: '月' },
];
function ReviewLinePanel({ data, period, onPeriodChange }) {
function ReviewLinePanel({ data, period, onPeriodChange, loading }) {
const { trendBuckets, trendSeries } = data;
const lineOption = useMemo(() => {
@ -510,13 +504,13 @@ function ReviewLinePanel({ data, period, onPeriodChange }) {
>{tab.label}</span>
))}
</div>
<EChart className="line-chart" option={lineOption} />
{loading ? <div className="sk-chart sk-chart--dark" /> : <EChart className="line-chart" option={lineOption} />}
</Panel>
);
}
// ==================== 复盘评估改进提效 ====================
function ReviewProgressPanel({ data }) {
function ReviewProgressPanel({ data, loading }) {
const { reviewStats, reviewPieItems } = data;
const statItems = useMemo(() => [
@ -538,25 +532,39 @@ function ReviewProgressPanel({ data }) {
return (
<Panel title="复盘评估改进提效" className="review-panel">
<div className="review-stats">
{statItems.map(item => <div className="review-stat" key={item.label}><span><FundProjectionScreenOutlined /></span><p>{item.label}<b>{item.value}</b></p></div>)}
{statItems.map(item => (
<div className="review-stat" key={item.label}>
<span><FundProjectionScreenOutlined /></span>
<p>{item.label}{loading ? <Skeleton width={28} height={13} /> : <b>{item.value}</b>}</p>
</div>
))}
</div>
<EChart className="review-pie" option={pieOption} />
{loading ? <div className="sk-chart sk-chart--dark sk-chart--donut" /> : <EChart className="review-pie" option={pieOption} />}
</Panel>
);
}
// ==================== 项目流程 ====================
function ProcessPanel({ data }) {
function ProcessPanel({ data, loading }) {
const { processNodes } = data;
const skeletonNodes = Array.from({ length: 5 });
return (
<Panel title="项目流程" className="process-panel">
<div className="process-grid">
{processNodes.map((item, index) => (
<div className="process-node" key={item.nodeName}>
<strong>{item.totalCount}</strong>
<span>{item.nodeName}</span>
{index < processNodes.length - 1 && <i />}
</div>
{loading
? skeletonNodes.map((_, index) => (
<div className="process-node" key={`sk-${index}`}>
<Skeleton width={44} height={18} />
<span><Skeleton width={60} height={12} /></span>
{index < skeletonNodes.length - 1 && <i />}
</div>
))
: processNodes.map((item, index) => (
<div className="process-node" key={item.nodeName}>
<strong>{item.totalCount}</strong>
<span>{item.nodeName}</span>
{index < processNodes.length - 1 && <i />}
</div>
))}
</div>
</Panel>
@ -565,7 +573,7 @@ function ProcessPanel({ data }) {
// ==================== 主组件 ====================
export default function SupervisionCockpit() {
const [data, setData] = useState(() => buildInitialFromMock(cockpitMockData));
const [data, setData] = useState(() => buildInitialState());
// 取当前年份/月份
const currentYear = useMemo(() => new Date().getFullYear(), []);
@ -591,68 +599,76 @@ export default function SupervisionCockpit() {
process: false,
});
// 首屏加载态(请求期间展示骨架占位,避免空白)
const [loading, setLoading] = useState(true);
// 趋势图切换加载态
const [trendLoading, setTrendLoading] = useState(true);
useEffect(() => {
// 1. 资质全生命周期管理
fetchQualificationOverview(currentYear)
.then((apiData) => {
setData(prev => ({
...prev,
lifecycleStats: apiData.lifecycleStats || prev.lifecycleStats,
progress: apiData.progress || prev.progress,
industryList: apiData.industryList || prev.industryList,
}));
setApiFlags(prev => ({ ...prev, qualification: true }));
})
.catch(() => { /* 接口异常,沿用 mock 数据 */ });
const tasks = [
// 1. 资质全生命周期管理
fetchQualificationOverview(currentYear)
.then((apiData) => {
setData(prev => ({
...prev,
lifecycleStats: apiData.lifecycleStats || prev.lifecycleStats,
progress: apiData.progress || prev.progress,
industryList: apiData.industryList || prev.industryList,
}));
setApiFlags(prev => ({ ...prev, qualification: true }));
})
.catch(() => {}),
// 2. 核心 KPI
fetchKpi('year', currentYear)
.then((apiData) => {
if (apiData.items && apiData.items.length > 0) {
setData(prev => ({ ...prev, kpiItems: apiData.items }));
}
})
.catch(() => {});
// 2. 核心 KPI
fetchKpi('year', currentYear)
.then((apiData) => {
if (apiData.items && apiData.items.length > 0) {
setData(prev => ({ ...prev, kpiItems: apiData.items }));
}
})
.catch(() => {}),
// 3. 区域地图分布
fetchRegionDistribution()
.then((apiData) => {
setData(prev => ({
...prev,
regionList: apiData.list || prev.regionList,
regionSummary: apiData.summary || prev.regionSummary,
}));
})
.catch(() => {});
// 3. 区域地图分布
fetchRegionDistribution()
.then((apiData) => {
setData(prev => ({
...prev,
regionList: apiData.list || prev.regionList,
regionSummary: apiData.summary || prev.regionSummary,
}));
})
.catch(() => {}),
// 4. 执业全过程管控 + 项目流程
fetchProcessOverview()
.then((apiData) => {
if (apiData.nodes && apiData.nodes.length > 0) {
setData(prev => ({ ...prev, processNodes: apiData.nodes }));
setApiFlags(prev => ({ ...prev, process: true }));
}
})
.catch(() => {});
// 4. 执业全过程管控 + 项目流程
fetchProcessOverview()
.then((apiData) => {
if (apiData.nodes && apiData.nodes.length > 0) {
setData(prev => ({ ...prev, processNodes: apiData.nodes }));
setApiFlags(prev => ({ ...prev, process: true }));
}
})
.catch(() => {}),
// 6. 复盘评估改进提效
fetchReviewSummary(currentYear)
.then((apiData) => {
setData(prev => ({
...prev,
reviewStats: apiData.stats || prev.reviewStats,
reviewPieItems: apiData.pie || prev.reviewPieItems,
}));
})
.catch(() => {});
// 6. 复盘评估改进提效
fetchReviewSummary(currentYear)
.then((apiData) => {
setData(prev => ({
...prev,
reviewStats: apiData.stats || prev.reviewStats,
reviewPieItems: apiData.pie || prev.reviewPieItems,
}));
})
.catch(() => {}),
// 7. 项目实时监控(当前暂无对应面板,仅预取)
fetchProjectMonitor(currentMonth)
.catch(() => {});
// 7. 项目实时监控(当前暂无对应面板,仅预取)
fetchProjectMonitor(currentMonth).catch(() => {}),
];
Promise.all(tasks).finally(() => setLoading(false));
}, [currentYear, currentMonth]);
// 5. 评价类型趋势(随 年/季/月 tab 切换重新查询)
useEffect(() => {
setTrendLoading(true);
fetchEvalTypeTrend(trendPeriod, currentYear)
.then((apiData) => {
setData(prev => ({
@ -661,16 +677,17 @@ export default function SupervisionCockpit() {
trendSeries: apiData.series || prev.trendSeries,
}));
})
.catch(() => {});
.catch(() => {})
.finally(() => setTrendLoading(false));
}, [trendPeriod, currentYear]);
// 项目流程面板数据:API 有值时用 processNodes否则用 mock processMockNodes
// 项目流程面板数据:无接口数据时展示空
const processPanelNodes = useMemo(() => {
if (apiFlags.process && data.processNodes && data.processNodes.length > 0) {
return data.processNodes;
}
return data.processMockNodes || data.processNodes || [];
}, [apiFlags.process, data.processNodes, data.processMockNodes]);
return data.processNodes || [];
}, [apiFlags.process, data.processNodes]);
return (
<div className="cockpit-page">
@ -678,22 +695,23 @@ export default function SupervisionCockpit() {
<ScreenHeader data={data} />
<main className="cockpit-layout">
<aside className="cockpit-left">
<LifecyclePanel data={data} />
<RiskPanel data={data} />
<LifecyclePanel data={data} loading={loading} />
<RiskPanel data={data} loading={loading} />
</aside>
<section className="cockpit-center">
<KpiCards data={data} />
<MapSection data={data} />
<ProcessPanel data={{ processNodes: processPanelNodes }} />
<KpiCards data={data} loading={loading} />
<MapSection data={data} loading={loading} />
<ProcessPanel data={{ processNodes: processPanelNodes }} loading={loading} />
</section>
<aside className="cockpit-right">
<TodoPanel data={data} />
<TodoPanel data={data} loading={loading} />
<ReviewLinePanel
data={data}
period={trendPeriod}
onPeriodChange={setTrendPeriod}
loading={trendLoading}
/>
<ReviewProgressPanel data={data} />
<ReviewProgressPanel data={data} loading={loading} />
</aside>
</main>
</div>