bug修改

dev
huwei 2026-08-05 14:10:10 +08:00
parent 5c3c98f4fd
commit 9f4b78a5ad
21 changed files with 1358 additions and 605 deletions

View File

@ -0,0 +1,44 @@
import { apiGet } from "../../utils/enterpriseInfo/http";
/**
* 机构端首页驾驶舱数据接口
*
* 统一走项目 http @cqsjjb/jjb-common-lib自动附带 API_HOSTtoken 与机构上下文请求头
* 页面以 `await fetchXxx()` 直调并取业务 data故此处统一解包
* 失败时抛错由页面 catch 兜底展示空态
*/
function unwrap(res) {
if (!res) {
throw new Error("请求失败");
}
if (res.success === false) {
throw new Error(res.message || "请求失败");
}
// 兼容 { code, data } 与已被 http 层解包的裸数据两种形态
return Object.prototype.hasOwnProperty.call(res, "data") ? res.data : res;
}
// 当前项目节点统计
export async function fetchProjectNodeStats() {
return unwrap(await apiGet("/safetyEval/institution/dashboard/project-node-stats"));
}
// 通知提醒
export async function fetchNotices() {
return unwrap(await apiGet("/safetyEval/institution/dashboard/notices"));
}
// 服务行业项目统计
export async function fetchIndustryStat() {
return unwrap(await apiGet("/safetyEval/institution/dashboard/industry-stat"));
}
// 评价类别占比
export async function fetchEvalTypeRatio() {
return unwrap(await apiGet("/safetyEval/institution/dashboard/eval-type-ratio"));
}
// 项目执行情况limit 默认 10
export async function fetchProjectExecution(limit = 10) {
return unwrap(await apiGet("/safetyEval/institution/dashboard/project-execution", { limit }));
}

View File

@ -0,0 +1,54 @@
import { apiGet } from "../../utils/enterpriseInfo/http";
/**
* 监管端驾驶舱数据接口
*
* 统一走项目 http @cqsjjb/jjb-common-lib自动附带 API_HOSTtoken 与机构上下文请求头
* 页面以 `await fetchXxx()` 直调并取业务 data故此处统一解包
* 失败时抛错由页面 catch 兜底展示空态
*/
function unwrap(res) {
if (!res) {
throw new Error("请求失败");
}
if (res.success === false) {
throw new Error(res.message || "请求失败");
}
// 兼容 { code, data } 与已被 http 层解包的裸数据两种形态
return Object.prototype.hasOwnProperty.call(res, "data") ? res.data : res;
}
// 资质全生命周期管理
export async function fetchQualificationOverview(year) {
return unwrap(await apiGet("/safetyEval/regulator/cockpit/qualification-overview", { year }));
}
// 核心KPI指标
export async function fetchKpi(cycle, year) {
return unwrap(await apiGet("/safetyEval/regulator/cockpit/kpi", { cycle, year }));
}
// 区域地图分布
export async function fetchRegionDistribution() {
return unwrap(await apiGet("/safetyEval/regulator/cockpit/region-distribution"));
}
// 执业全过程管控 + 项目流程
export async function fetchProcessOverview() {
return unwrap(await apiGet("/safetyEval/regulator/cockpit/process-overview"));
}
// 评价类型趋势
export async function fetchEvalTypeTrend(period, year) {
return unwrap(await apiGet("/safetyEval/regulator/cockpit/eval-type-trend", { period, year }));
}
// 复盘评估改进提效
export async function fetchReviewSummary(year) {
return unwrap(await apiGet("/safetyEval/regulator/cockpit/review-summary", { year }));
}
// 项目实时监控
export async function fetchProjectMonitor(month) {
return unwrap(await apiGet("/safetyEval/regulator/cockpit/project-monitor", { month }));
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,15 @@
import backImg from "~/assets/images/map_bi/back2.png";
import { backToBase } from "~/utils/backToBase";
import "./index.less";
export default function DriverBack({ theme = "light", className = "" }) {
return (
<div
className={`driver-back driver-back--${theme} ${className}`.trim()}
onClick={backToBase}
>
<img src={backImg} alt="" />
<span>返回</span>
</div>
);
}

View File

@ -0,0 +1,23 @@
.driver-back {
display: inline-flex;
align-items: center;
gap: 5px;
cursor: pointer;
user-select: none;
font-size: 14px;
line-height: 1;
white-space: nowrap;
img {
width: 20px;
height: 20px;
}
&--light {
color: #1677ff;
}
&--dark {
color: #7ec8ff;
}
}

View File

@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react";
import { Connect } from "@cqsjjb/jjb-dva-runtime";
import { Button, Skeleton, Tabs } from "antd";
import { NS_DRIVER } from "~/enumerate/namespace";
import Institution from "~/pages/Container/Institution/Dashboard";
import Institution from "~/pages/Container/Institution";
import Supervision from "~/pages/Container/Supervision/Dashboard";
import Cockpit from "~/pages/Container/Supervision/Cockpit";

View File

@ -1,3 +0,0 @@
import InstitutionDashboard from '../Index';
export default InstitutionDashboard;

View File

@ -1,11 +1,10 @@
import React, { useEffect, useMemo, useRef } from 'react';
import * as echarts from 'echarts';
import { Card } from 'antd';
const BLUE = '#5b8ff9';
const ORANGE = '#ffb33e';
const BLUE = '#4285f4';
const ORANGE = '#ffab31';
function EChart({ option, className }) {
export default function EnterpriseTypeChart({ data }) {
const chartRef = useRef(null);
const instanceRef = useRef(null);
@ -16,7 +15,6 @@ function EChart({ option, className }) {
const observer = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(resize) : null;
if (observer) observer.observe(chartRef.current);
window.addEventListener('resize', resize);
return () => {
window.removeEventListener('resize', resize);
if (observer) observer.disconnect();
@ -25,67 +23,47 @@ function EChart({ option, className }) {
};
}, []);
useEffect(() => {
if (instanceRef.current) instanceRef.current.setOption(option, true);
}, [option]);
const list = data && data.list ? data.list : [];
const categories = list.map(item => item.industryName);
const projectData = list.map(item => Number(item.projectCount) || 0);
const statutoryData = list.map(item => Number(item.statutoryProjectCount) || 0);
const maxVal = Math.max(...projectData, ...statutoryData, 1);
const yMax = Math.ceil(maxVal / 10) * 10 || 10;
return <div className={className} ref={chartRef} />;
}
export default function EnterpriseTypeChart({ data }) {
const option = useMemo(() => ({
color: [BLUE, ORANGE],
grid: { left: 38, right: 10, top: 26, bottom: 46 },
grid: { left: 36, right: 10, top: 12, bottom: 28 },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow', shadowStyle: { color: 'rgba(91, 143, 249, .08)' } },
backgroundColor: '#fff',
borderColor: '#e9edf4',
borderColor: '#e2e8f0',
borderWidth: 1,
textStyle: { color: '#333', fontSize: 12 },
textStyle: { color: '#1e293b', fontSize: 12 },
},
xAxis: {
type: 'category',
data: data.categories,
data: categories,
axisTick: { show: false },
axisLine: { lineStyle: { color: '#edf0f5' } },
axisLabel: {
color: '#666',
fontSize: 12,
interval: 0,
width: 58,
overflow: 'break',
lineHeight: 14,
},
axisLine: { lineStyle: { color: '#e2e8f0' } },
axisLabel: { color: '#64748b', fontSize: 11, interval: 0 },
},
yAxis: {
type: 'value',
min: 0,
max: 60,
splitNumber: 6,
axisLabel: { color: '#8792a2', fontSize: 12 },
splitLine: { lineStyle: { color: '#e9edf4', type: 'dashed' } },
max: yMax,
interval: yMax > 10 ? yMax / 3 : 5,
axisLabel: { color: '#8492a6', fontSize: 10, margin: 4 },
splitLine: { lineStyle: { color: '#dce5ef', type: 'dashed' } },
},
series: [
{ name: '项目数', type: 'bar', barWidth: 12, data: data.projectCount, itemStyle: { borderRadius: [2, 2, 0, 0] } },
{ name: '金额', type: 'bar', barWidth: 12, data: data.amount, itemStyle: { borderRadius: [2, 2, 0, 0] } },
{ name: '项目数', type: 'bar', barWidth: 14, barGap: '30%', data: projectData, itemStyle: { borderRadius: [2, 2, 0, 0] } },
{ name: '法定项目', type: 'bar', barWidth: 14, data: statutoryData, itemStyle: { borderRadius: [2, 2, 0, 0] } },
],
}), [data]);
}), [categories, projectData, statutoryData, yMax]);
return (
<Card
title={<span className="institution-card-title">服务企业类型统计</span>}
size="small"
className="institution-panel-card institution-enterprise-card"
styles={{ body: { padding: '20px 26px 15px' } }}
extra={
<div className="institution-chart-legend institution-chart-legend--top">
<span><i style={{ backgroundColor: BLUE }} />项目数</span>
<span><i style={{ backgroundColor: ORANGE }} />金额</span>
</div>
}
>
<EChart className="institution-bar-chart" option={option} />
</Card>
);
useEffect(() => {
if (instanceRef.current) instanceRef.current.setOption(option, true);
}, [option]);
return <div className="institution-bar-chart" ref={chartRef} />;
}

View File

@ -1,37 +1,16 @@
import React from 'react';
import {
AuditOutlined,
StarFilled,
UserDeleteOutlined
} from '@ant-design/icons';
const iconMap = {
Star: StarFilled,
Audit: AuditOutlined,
UserDelete: UserDeleteOutlined
};
function AlertItem({ item }) {
const Icon = iconMap[item.icon] || AuditOutlined;
return (
<div className="institution-alert-item" style={{ backgroundColor: item.bgColor }}>
<div className="institution-alert-item__icon" style={{ backgroundColor: item.color }}>
<Icon />
</div>
<div className="institution-alert-item__content">
<div className="institution-alert-item__title">{item.title}</div>
<div className="institution-alert-item__value">{item.value}</div>
</div>
</div>
);
}
export default function InfoAlerts({ data }) {
return (
<div className="institution-alert-grid">
{data.map((item) => (
<AlertItem key={item.key} item={item} />
<div className="dashboard-reminder-grid">
{data.map(item => (
<article key={item.key} className={item.colorClass}>
<i>{item.char}</i>
<div>
<span>{item.title}</span>
<strong>{item.value}</strong>
</div>
</article>
))}
</div>
);

View File

@ -1,35 +1,70 @@
import React from 'react';
import { Card, Table } from 'antd';
const columns = [
{ title: '序号', dataIndex: 'id', key: 'id', width: 70, align: 'center' },
{ title: '项目名称', dataIndex: 'projectName', key: 'projectName', ellipsis: true },
{ title: '项目状态', dataIndex: 'status', key: 'status', width: 120, align: 'center' },
{ title: '项目负责人', dataIndex: 'projectLeader', key: 'projectLeader', width: 120, align: 'center' },
{ title: '客户负责人', dataIndex: 'clientLeader', key: 'clientLeader', width: 120, align: 'center' },
{ title: '项目开始时间', dataIndex: 'startDate', key: 'startDate', width: 180, align: 'center' },
{ title: '项目结束时间', dataIndex: 'endDate', key: 'endDate', width: 180, align: 'center' },
{ title: '项目阶段', dataIndex: 'acceptanceDate', key: 'acceptanceDate', width: 180, align: 'center' },
{ title: '操作', key: 'action', width: 110, align: 'center', render: () => <a className="institution-table-link">查看详情</a> }
];
export default function ProjectCompletionTable({ data, loading, onView }) {
const list = data && data.list ? data.list : [];
//
const skeletonRows = Array.from({ length: 5 });
export default function ProjectCompletionTable({ data }) {
return (
<Card
title={<span className="institution-card-title">项目完成情况统计</span>}
size="small"
className="institution-panel-card institution-table-card"
styles={{ body: { padding: '12px 14px 14px' } }}
>
<Table
className="institution-completion-table"
columns={columns}
dataSource={data}
rowKey="id"
pagination={false}
size="small"
scroll={{ x: 1180 }}
/>
</Card>
<section className="dashboard-block dashboard-project-block">
<div className="dashboard-block-head">
<h3>项目执行情况</h3>
<button className="dashboard-btn-ghost" type="button">
查看全部
</button>
</div>
<div className="data-table dashboard-project-table">
<table>
<thead>
<tr>
<th>序号</th>
<th>项目编号</th>
<th>项目名称</th>
<th>被评价企业</th>
<th>评价类别</th>
<th>项目负责人</th>
<th>项目结束日期</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{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>
</section>
);
}

View File

@ -1,6 +1,7 @@
import React, { useEffect, useMemo, useRef } from 'react';
import * as echarts from 'echarts';
import { Card } from 'antd';
const COLORS = ['#4285f4', '#58b874', '#ff9f2f'];
function EChart({ option, className }) {
const chartRef = useRef(null);
@ -13,7 +14,6 @@ function EChart({ option, className }) {
const observer = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(resize) : null;
if (observer) observer.observe(chartRef.current);
window.addEventListener('resize', resize);
return () => {
window.removeEventListener('resize', resize);
if (observer) observer.disconnect();
@ -29,89 +29,80 @@ function EChart({ option, className }) {
return <div className={className} ref={chartRef} />;
}
function Legend({ data }) {
return (
<div className="institution-project-legend">
{data.map((item) => (
<span key={item.name}>
<i style={{ backgroundColor: item.color }} />
{item.name}
</span>
))}
</div>
);
}
export default function ProjectTypeChart({ data }) {
const items = data && data.items ? data.items : [];
const totalProjectCount = data ? Number(data.totalProjectCount) || 0 : 0;
const computedTotal = totalProjectCount || items.reduce((sum, item) => sum + (Number(item.count) || 0), 0);
export default function ProjectTypeChart({ data, total }) {
const totalValue = total || data.reduce((sum, item) => sum + item.value, 0);
const option = useMemo(() => ({
color: data.map(item => item.color),
color: COLORS.slice(0, items.length),
tooltip: {
trigger: 'item',
backgroundColor: '#fff',
borderColor: '#e9edf4',
borderColor: '#e2e8f0',
borderWidth: 1,
textStyle: { color: '#333', fontSize: 12 },
textStyle: { color: '#1e293b', fontSize: 12 },
formatter: '{b}<br/>项目数:{c}<br/>占比:{d}%',
},
series: [
{
name: '项目类型占比',
name: '评价类别占比',
type: 'pie',
radius: ['46%', '74%'],
center: ['50%', '50%'],
radius: ['48%', '72%'],
center: ['50%', '48%'],
avoidLabelOverlap: true,
minAngle: 5,
label: { show: false },
labelLine: { show: false },
itemStyle: {
borderColor: '#fff',
borderWidth: 2,
borderRadius: 4,
},
emphasis: {
scale: true,
scaleSize: 4,
},
data: data.map(item => ({ name: item.name, value: item.value })),
data: items.map((item) => ({
name: item.evalTypeName,
value: Number(item.count) || 0,
})),
},
],
graphic: [
{
type: 'text',
left: 'center',
top: '42%',
top: '38%',
style: {
text: '项目总数',
textAlign: 'center',
fill: '#a0a7b2',
fontSize: 15,
fill: '#94a3b8',
fontSize: 13,
fontWeight: 600,
},
},
{
type: 'text',
left: 'center',
top: '53%',
top: '50%',
style: {
text: String(totalValue),
text: String(computedTotal),
textAlign: 'center',
fill: '#111827',
fontSize: 24,
fill: '#1e293b',
fontSize: 22,
fontWeight: 700,
},
},
],
}), [data, totalValue]);
}), [items, computedTotal]);
return (
<Card
title={<span className="institution-card-title">项目类型占比</span>}
size="small"
className="institution-panel-card institution-project-card"
styles={{ body: { padding: '24px 22px 18px' } }}
>
<>
<EChart className="institution-donut-chart" option={option} />
<Legend data={data} />
</Card>
<div className="dashboard-type-legend">
{items.map((item, i) => (
<span key={item.evalTypeCode || i}>
<i style={{ backgroundColor: COLORS[i % COLORS.length] }} />
{item.evalTypeName} {Number(item.count) || 0}
</span>
))}
</div>
</>
);
}

View File

@ -1,39 +1,13 @@
import React from 'react';
import {
AppstoreOutlined,
AuditOutlined,
CompassOutlined,
DatabaseOutlined,
FileDoneOutlined,
FileProtectOutlined,
LineChartOutlined,
NotificationOutlined,
ProfileOutlined,
ReadOutlined,
SafetyCertificateOutlined
} from '@ant-design/icons';
const iconMap = {
FileDone: FileDoneOutlined,
LineChart: LineChartOutlined,
Appstore: AppstoreOutlined,
FileProtect: FileProtectOutlined,
Safety: SafetyCertificateOutlined,
Profile: ProfileOutlined,
Notification: NotificationOutlined,
Read: ReadOutlined,
Compass: CompassOutlined,
Database: DatabaseOutlined,
Audit: AuditOutlined
};
function StatisticCard({ item }) {
const Icon = iconMap[item.icon] || FileDoneOutlined;
return (
<div className="institution-stat-card">
<div className="institution-stat-card__icon" style={{ backgroundColor: item.bgColor }}>
<Icon />
<div
className="institution-stat-card__icon"
style={{ backgroundColor: item.color, color: item.charColor }}
>
{item.char}
</div>
<div className="institution-stat-card__content">
<div className="institution-stat-card__title">{item.title}</div>

View File

@ -1,256 +1,408 @@
.institution-dashboard {
/* ============ 机构端首页 — 1:1 还原原型 dashboard-classic ============ */
.institution-dashboard {
min-height: 100%;
padding: 0 2px 0;
background: #f3f3f3;
color: #333;
background: #f8fafc;
color: #1e293b;
line-height: 1.6;
}
.institution-dashboard__top,
.institution-dashboard__charts {
/* ============ dashboard-classic 布局 ============ */
.dashboard-classic {
display: grid;
grid-template-columns: minmax(0, 2.12fr) 397px;
gap: 20px;
margin-bottom: 15px;
gap: 0.85rem;
}
.institution-panel-card {
border: 0 !important;
border-radius: 2px !important;
box-shadow: none !important;
}
.institution-panel-card > :is(.ant-card-head, .micro-temp-card-head) {
min-height: 45px;
padding: 0 16px;
border-bottom: 0;
}
.institution-panel-card > :is(.ant-card-head, .micro-temp-card-head) :is(.ant-card-head-title, .micro-temp-card-head-title) {
padding: 14px 0 8px;
}
.institution-card-title {
font-size: 14px;
font-weight: 700;
color: #303133;
}
.institution-dashboard__summary {
font-size: 13px;
color: #3d3d3d;
}
.institution-dashboard__summary strong {
margin-left: 8px;
font-size: 13px;
font-weight: 500;
}
.institution-dashboard__summary .is-blue {
color: #1684ff;
}
.institution-stat-grid {
.dashboard-classic-top,
.dashboard-classic-middle {
display: grid;
grid-template-columns: repeat(5, minmax(120px, 1fr));
gap: 26px 28px;
grid-template-columns: minmax(0, 3.2fr) minmax(300px, 1fr);
gap: 0.85rem;
}
.institution-stat-card {
/* ============ dashboard-block 卡片 ============ */
.dashboard-block {
background: #fff;
border: 1px solid #e2e8f0;
padding: 0.95rem;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04);
}
.dashboard-block-head {
min-height: 28px;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.8rem;
margin-bottom: 0.75rem;
}
.dashboard-block-head h3 {
font-size: 0.88rem;
margin: 0;
font-weight: 600;
color: #1e293b;
}
.dashboard-block-head > div {
display: flex;
gap: 1.4rem;
font-size: 0.7rem;
color: #64748b;
}
.dashboard-block-head b {
color: #2563eb;
}
/* ============ 当前项目节点统计 ============ */
.dashboard-status-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 0.75rem;
}
.dashboard-status-grid article {
display: flex;
align-items: center;
min-height: 56px;
padding: 10px 13px;
gap: 0.65rem;
min-height: 62px;
padding: 0.65rem;
background: #fff;
border-radius: 4px;
box-shadow: 0 6px 17px rgba(33, 57, 98, 0.09);
box-shadow: 0 4px 16px rgba(30, 64, 100, 0.07);
}
.institution-stat-card__icon,
.institution-alert-item__icon {
.dashboard-status-grid article > i {
width: 34px;
height: 34px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
color: #fff;
font-style: normal;
font-weight: 700;
font-size: 13px;
flex: 0 0 34px;
}
.dashboard-status-grid .blue { background: #4098f7; }
.dashboard-status-grid .violet { background: #8268ef; }
.dashboard-status-grid .green { background: #23bfa5; }
.dashboard-status-grid .orange { background: #ff9f2f; }
.dashboard-status-grid .cyan { background: #0eafbd; }
.dashboard-status-grid .red { background: #f0645b; }
.dashboard-status-grid span,
.dashboard-status-grid strong {
display: block;
}
.dashboard-status-grid span {
font-size: 0.7rem;
color: #64748b;
}
.dashboard-status-grid strong {
font-size: 1rem;
margin-top: 0.15rem;
color: #1e293b;
}
/* ============ 通知提醒 ============ */
.dashboard-reminder-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
.dashboard-reminder-grid article {
display: flex;
align-items: center;
gap: 0.65rem;
min-height: 64px;
padding: 0.65rem;
cursor: pointer;
}
.dashboard-reminder-grid article:last-child {
grid-column: 1 / 2;
}
.dashboard-reminder-grid article.yellow { background: #fff9e8; }
.dashboard-reminder-grid article.red { background: #fff2ef; }
.dashboard-reminder-grid article.blue { background: #edf4ff; }
.dashboard-reminder-grid i {
width: 34px;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 5px;
color: #fff;
font-size: 19px;
}
.institution-stat-card__content {
min-width: 0;
margin-left: 12px;
}
.institution-stat-card__title,
.institution-alert-item__title {
overflow: hidden;
color: #333;
font-size: 12px;
font-style: normal;
font-weight: 700;
line-height: 18px;
text-overflow: ellipsis;
font-size: 13px;
flex: 0 0 34px;
}
.dashboard-reminder-grid .yellow i { background: #f7bd08; }
.dashboard-reminder-grid .red i { background: #ff725c; }
.dashboard-reminder-grid .blue i { background: #5b8def; }
.dashboard-reminder-grid span,
.dashboard-reminder-grid strong {
display: block;
}
.dashboard-reminder-grid span {
font-size: 0.68rem;
color: #64748b;
}
.dashboard-reminder-grid strong {
font-size: 1rem;
margin-top: 0.12rem;
color: #1e293b;
}
/* ============ 服务行业项目统计 / 评价类别占比 ============ */
.dashboard-industry-block,
.dashboard-type-block {
min-height: 285px;
}
/* 图例 */
.chart-legend span {
display: flex;
align-items: center;
gap: 0.25rem;
}
.chart-legend i {
width: 14px;
height: 6px;
border-radius: 5px;
background: #4285f4;
}
.chart-legend span:last-child i {
background: #ffab31;
}
/* 柱状图容器 */
.dashboard-bar-chart {
height: 225px;
}
/* ECharts 柱状图 */
.institution-bar-chart {
width: 100%;
height: 225px;
}
/* ============ 评价类别占比 ============ */
.institution-donut-chart {
width: 100%;
height: 185px;
display: flex;
justify-content: center;
align-items: center;
}
.dashboard-type-legend {
display: flex;
justify-content: center;
gap: 0.8rem;
flex-wrap: wrap;
font-size: 0.62rem;
color: #64748b;
margin-top: 8px;
}
.dashboard-type-legend span {
display: flex;
align-items: center;
}
.dashboard-type-legend i {
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
margin-right: 0.25rem;
}
/* ============ 项目执行情况表格 ============ */
.dashboard-project-block .data-table {
background: #fff;
border: 0;
border-radius: 0;
box-shadow: none;
overflow: visible;
}
.dashboard-project-table table {
width: 100%;
border-collapse: collapse;
}
.dashboard-project-table th,
.dashboard-project-table td {
padding: 0.65rem 0.55rem;
font-size: 0.7rem;
}
.dashboard-project-table th {
text-align: left;
font-weight: 600;
color: #64748b;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
white-space: nowrap;
}
.institution-stat-card__value {
color: #333;
font-size: 16px;
font-weight: 500;
line-height: 21px;
.dashboard-project-table td {
border-bottom: 1px solid #e2e8f0;
color: #1e293b;
}
.institution-alert-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 18px;
.dashboard-project-table tr:last-child td {
border-bottom: none;
}
.institution-alert-item {
display: flex;
align-items: center;
min-height: 66px;
padding: 12px 16px;
border-radius: 2px;
.dashboard-project-table tr:hover td {
background: #f8fafc;
}
.institution-alert-item__icon {
width: 34px;
height: 34px;
box-shadow: 0 5px 12px rgba(75, 91, 131, 0.2);
}
.institution-alert-item__content {
min-width: 0;
margin-left: 13px;
}
.institution-alert-item__value {
color: #222;
font-size: 17px;
font-weight: 700;
line-height: 22px;
}
.institution-enterprise-card,
.institution-project-card {
height: 300px;
}
.institution-chart-legend,
.institution-chart-legend span {
display: flex;
align-items: center;
}
.institution-chart-legend {
gap: 10px;
color: #6f7785;
font-size: 12px;
}
.institution-chart-legend i,
.institution-project-legend i {
display: inline-block;
flex: 0 0 auto;
width: 20px;
height: 8px;
margin-right: 5px;
border-radius: 4px;
}
.institution-bar-chart {
width: 100%;
height: 232px;
}
.institution-project-card :is(.ant-card-body, .micro-temp-card-body) {
text-align: center;
}
.institution-donut-chart {
width: 218px;
height: 218px;
margin: -1px auto 4px;
}
.institution-project-legend {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px 16px;
color: #697386;
font-size: 12px;
line-height: 18px;
}
.institution-project-legend span {
/* ============ 原生按钮(匹配原型) ============ */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 0.42rem 0.85rem;
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 8px;
font-size: 0.78rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
background: #fff;
color: #475569;
text-decoration: none;
}
.institution-project-legend i {
width: 7px;
height: 7px;
margin-right: 6px;
border-radius: 50%;
.btn-primary {
background: #2563eb;
color: #fff;
border-color: #2563eb;
}
.btn-primary:hover { background: #1d4ed8; border-color: #1d4ed8; }
.btn-ghost {
background: transparent;
color: #64748b;
border: none;
}
.btn-ghost:hover { background: #f1f5f9; color: #1e293b; }
.btn-sm { padding: 0.3rem 0.75rem; font-size: 0.7rem; }
.dashboard-btn-ghost {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.3rem 0.75rem;
border: none;
border-radius: 8px;
font-size: 0.7rem;
font-weight: 500;
cursor: pointer;
background: transparent;
color: #64748b;
transition: all 0.15s ease;
}
.dashboard-btn-ghost:hover {
background: #f1f5f9;
color: #1e293b;
}
.institution-table-card {
min-height: 202px;
/* 表格操作按钮 */
.dashboard-table-link {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.3rem 0.75rem;
border: none;
border-radius: 8px;
font-size: 0.7rem;
font-weight: 500;
cursor: pointer;
text-decoration: none;
background: #2563eb;
color: #fff;
transition: all 0.15s ease;
}
.institution-completion-table :is(.ant-table, .micro-temp-table) {
color: #222;
font-size: 12px;
.dashboard-table-link:hover {
background: #1d4ed8;
text-decoration: none;
color: #fff;
}
.institution-completion-table :is(.ant-table-thead, .micro-temp-table-thead) > tr > th {
height: 27px;
padding: 6px 10px;
background: #eef2f8 !important;
border-bottom: 0;
color: #333;
font-size: 12px;
font-weight: 700;
/* ============ 骨架占位(加载态) ============ */
.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;
}
.institution-completion-table :is(.ant-table-tbody, .micro-temp-table-tbody) > tr > td {
height: 29px;
padding: 6px 10px;
border-bottom: 0;
@keyframes sk-shimmer {
0% { background-position: 100% 0; }
100% { background-position: 0 0; }
}
.institution-completion-table :is(.ant-table-tbody, .micro-temp-table-tbody) > tr:nth-child(even) > td {
background: #eaf2ff;
/* 图表占位块 */
.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; }
.institution-completion-table :is(.ant-table-tbody, .micro-temp-table-tbody) > tr:hover > td {
background: #dfeeff !important;
/* 表格单元格骨架 */
.sk-cell {
width: 70%;
height: 12px;
border-radius: 4px;
}
.sk-cell--lg { width: 85%; }
.sk-cell--sm { width: 36px; }
.institution-table-link {
color: #1684ff;
font-size: 12px;
}
@media (max-width: 1180px) {
.institution-dashboard__top,
.institution-dashboard__charts {
/* ============ 响应式 ============ */
@media (max-width: 1280px) {
.dashboard-classic-top,
.dashboard-classic-middle {
grid-template-columns: 1fr;
}
.institution-stat-grid {
grid-template-columns: repeat(3, minmax(120px, 1fr));
.dashboard-status-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 760px) {
.institution-stat-grid,
.institution-alert-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
.dashboard-status-grid {
grid-template-columns: repeat(2, 1fr);
}
.dashboard-reminder-grid {
grid-template-columns: 1fr;
}
}

View File

@ -1,8 +1,4 @@
import React from 'react';
import { Space, Card } from 'antd';
import StatisticCards from './components/StatisticCards';
import InfoAlerts from './components/InfoAlerts';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import EnterpriseTypeChart from './components/EnterpriseTypeChart';
import ProjectTypeChart from './components/ProjectTypeChart';
import ProjectCompletionTable from './components/ProjectCompletionTable';
@ -10,53 +6,197 @@ import './index.css';
import {
STATISTIC_CARDS,
INFO_ALERTS,
PROJECT_SUMMARY,
ENTERPRISE_TYPE_STATS,
PROJECT_TYPE_DISTRIBUTION,
PROJECT_TOTAL,
PROJECT_COMPLETION_LIST
} from './mockData';
import {
fetchProjectNodeStats,
fetchNotices,
fetchIndustryStat,
fetchEvalTypeRatio,
fetchProjectExecution,
} from '~/api/institution';
// ==================== 骨架占位 ====================
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, loading }) {
return (
<article>
<i className={item.colorClass}>{item.char}</i>
<div>
<span>{item.title}</span>
{loading ? <Skeleton width={42} height={16} /> : <strong>{item.value}</strong>}
</div>
</article>
);
}
function ReminderCard({ item, loading }) {
return (
<article className={item.colorClass}>
<i>{item.char}</i>
<div>
<span>{item.title}</span>
{loading ? <Skeleton width={42} height={16} /> : <strong>{item.value}</strong>}
</div>
</article>
);
}
// ==================== 主组件 ====================
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);
const [evalTypeRatioApi, setEvalTypeRatioApi] = useState(null);
const [projectExecutionApi, setProjectExecutionApi] = useState(null);
useEffect(() => {
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 个节点 + 已归档 + 项目延期),
// 仅用 API 数据覆盖对应数值,保证无论接口返回多少节点,卡片项数都不减少。
const statusCards = useMemo(() => {
if (!nodeStatsApi) return STATISTIC_CARDS;
const { nodeStats = [], archivedProjectCount = 0, delayedProjectCount = 0 } = nodeStatsApi || {};
const nodeByName = new Map();
(nodeStats || []).forEach((n) => {
if (n && n.nodeName) nodeByName.set(n.nodeName, n);
});
return STATISTIC_CARDS.map((card) => {
let value = card.value;
if (card.key === 'archived') {
value = Number(archivedProjectCount) || 0;
} else if (card.key === 'delayed') {
value = Number(delayedProjectCount) || 0;
} else {
const node = nodeByName.get(card.title);
if (node) value = Number(node.pendingCount) || 0;
}
return { ...card, value };
});
}, [nodeStatsApi]);
const projectSummary = useMemo(() => {
if (!nodeStatsApi) return { total: 0, statutory: 0 };
return {
total: Number(nodeStatsApi.totalProjects) || 0,
statutory: Number(nodeStatsApi.statutoryProjects) || 0,
};
}, [nodeStatsApi]);
const alertItems = useMemo(() => {
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' },
{ key: 'unreadNotice', title: '未读监管通知', value: Number(noticesApi.unreadRegulatoryCount) || 0, char: '未', colorClass: 'blue' },
];
}, [noticesApi]);
// 接口无数据时使用空结构,不再用 mock 伪造数据
const industryStatData = useMemo(() => industryStatApi || { list: [] }, [industryStatApi]);
const evalTypeRatioData = useMemo(() => evalTypeRatioApi || { totalProjectCount: 0, items: [] }, [evalTypeRatioApi]);
const projectExecutionData = useMemo(() => projectExecutionApi || { list: [] }, [projectExecutionApi]);
const legendColors = useMemo(() => ({
project: '#4285f4',
statutory: '#ffab31',
}), []);
return (
<div className="institution-dashboard">
<div className="institution-dashboard__top">
<Card
title={<span className="institution-card-title">当前评价状态状态统计</span>}
size="small"
className="institution-dashboard__status-card institution-panel-card"
extra={
<Space size={30} className="institution-dashboard__summary">
<span>
项目总数: <strong className="is-blue">{PROJECT_SUMMARY.total}</strong>
</span>
<span>
超期数: <strong className="is-blue">{PROJECT_SUMMARY.overdue}</strong>
</span>
</Space>
}
styles={{ body: { padding: '16px 34px 17px' } }}
>
<StatisticCards data={STATISTIC_CARDS} />
</Card>
<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>项目总数{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} loading={loading} />
))}
</div>
</section>
<Card
title={<span className="institution-card-title">信息提醒</span>}
size="small"
className="institution-dashboard__alerts-card institution-panel-card"
styles={{ body: { padding: '20px 24px' } }}
>
<InfoAlerts data={INFO_ALERTS} />
</Card>
<section className="dashboard-block dashboard-reminder-block">
<div className="dashboard-block-head">
<h3>通知提醒</h3>
</div>
<div className="dashboard-reminder-grid">
{alertItems.map(item => (
<ReminderCard key={item.key} item={item} loading={loading} onClick={handleReminderClick} />
))}
</div>
</section>
</div>
{/* ===== 第二行:服务行业项目统计 + 评价类别占比 ===== */}
<div className="dashboard-classic-middle">
<section className="dashboard-block dashboard-industry-block">
<div className="dashboard-block-head">
<h3>服务行业项目统计</h3>
<div className="chart-legend">
<span><i style={{ backgroundColor: legendColors.project }} />项目数</span>
<span><i style={{ backgroundColor: legendColors.statutory }} />法定项目</span>
</div>
</div>
{loading
? <div className="sk-chart" />
: <EnterpriseTypeChart data={industryStatData} />}
</section>
<section className="dashboard-block dashboard-type-block">
<div className="dashboard-block-head">
<h3>评价类别占比</h3>
</div>
{loading
? <div className="sk-chart sk-chart--donut" />
: <ProjectTypeChart data={evalTypeRatioData} />}
</section>
</div>
{/* ===== 第三行:项目执行情况 ===== */}
<ProjectCompletionTable
data={projectExecutionData}
loading={loading}
onView={() => {}}
/>
</div>
<div className="institution-dashboard__charts">
<EnterpriseTypeChart data={ENTERPRISE_TYPE_STATS} />
<ProjectTypeChart data={PROJECT_TYPE_DISTRIBUTION} total={PROJECT_TOTAL} />
</div>
<ProjectCompletionTable data={PROJECT_COMPLETION_LIST} />
</div>
);
}

View File

@ -1,86 +1,17 @@
/**
* 机构端首页 - 模拟数据
* 机构端首页 - 卡片模板仅标题/图标/样式value 统一为 0由接口数据覆盖
* 不再保留任何 mock 假数据接口无数据时展示 0 / 空值
*/
// 仅作为卡片模板(标题/图标/样式value 统一默认 0由接口数据覆盖。
export const STATISTIC_CARDS = [
{ key: 'contract', title: '项目合同签订', value: 5632, icon: 'FileDone', color: '#409eff', bgColor: '#409eff' },
{ key: 'riskAnalysis', title: '待风险分析', value: 951, icon: 'LineChart', color: '#8b7cf6', bgColor: '#8b7cf6' },
{ key: 'projectTeam', title: '待成立项目组', value: 5632, icon: 'Appstore', color: '#48d1bd', bgColor: '#48d1bd' },
{ key: 'workPlan', title: '待制定工作计划', value: 5632, icon: 'FileProtect', color: '#ff9f43', bgColor: '#ff9f43' },
{ key: 'initialEval', title: '待初提', value: 5632, icon: 'Safety', color: '#8b7cf6', bgColor: '#8b7cf6' },
{ key: 'checklist', title: '待编制检查表', value: 456, icon: 'Profile', color: '#8b7cf6', bgColor: '#8b7cf6' },
{ key: 'industryNotice', title: '待从业告知', value: 5632, icon: 'Notification', color: '#ffb12a', bgColor: '#ffb12a' },
{ key: 'siteSurvey', title: '待现场勘查', value: 5632, icon: 'Read', color: '#8b7cf6', bgColor: '#8b7cf6' },
{ key: 'processControl', title: '过程管控', value: 951, icon: 'Compass', color: '#409eff', bgColor: '#409eff' },
{ key: 'archive', title: '归档', value: 651, icon: 'Database', color: '#08bea7', bgColor: '#08bea7' }
];
export const INFO_ALERTS = [
{ key: 'orgQualification', title: '机构资质到期', value: 5, icon: 'Star', color: '#ffca0a', bgColor: '#fff9e7' },
{ key: 'personnelQualification', title: '人员资质到期', value: 35, icon: 'Audit', color: '#ff7a59', bgColor: '#fff3ef' },
{ key: 'personnelResignation', title: '人员离岗信息', value: 56, icon: 'UserDelete', color: '#6395f9', bgColor: '#f1f6ff' }
];
export const PROJECT_SUMMARY = {
total: 5632,
overdue: 56
};
export const ENTERPRISE_TYPE_STATS = {
categories: ['矿山开采业', '危险化学品行业', '烟花爆竹行业', '金属冶炼与加工', '建筑施工', '交通运输业', '消防重点单位', '特种设备相关企业', '涉爆粉尘企业'],
projectCount: [42, 35, 33, 35, 24, 42, 36, 42, 32],
amount: [52, 54, 29, 48, 39, 36, 56, 35, 45]
};
export const PROJECT_TYPE_DISTRIBUTION = [
{ name: '定期检测', value: 5054, color: '#3f7df4' },
{ name: '现状评价', value: 1540, color: '#63c174' },
{ name: '控制效果评价', value: 1600, color: '#ffa533' },
{ name: '预评价', value: 900, color: '#ff694f' },
{ name: '设计专篇', value: 430, color: '#23c6c8' },
{ name: '委托检测', value: 330, color: '#55c653' }
];
export const PROJECT_TOTAL = 9854;
export const PROJECT_COMPLETION_LIST = [
{
id: 1,
projectName: '玉田县志达贸易有限公司蓝兴加油站、LNG加气设施合并项目',
status: '检测完成',
projectLeader: '梁永利',
clientLeader: '赵天祥',
startDate: '2025-5-4 16:12:23',
endDate: '2025-5-4 16:12:23',
acceptanceDate: '2025-5-4 16:12:23'
},
{
id: 2,
projectName: '北京首钢铁合金有限公司迁安分公司包芯线生产线扩建项目',
status: '检测完成',
projectLeader: '梁永利',
clientLeader: '赵天祥',
startDate: '2025-5-4 16:12:23',
endDate: '2025-5-4 16:12:23',
acceptanceDate: '2025-5-4 16:12:23'
},
{
id: 3,
projectName: '中特伟业科技有限公司沧州金固废回收利用项目',
status: '检测完成',
projectLeader: '梁永利',
clientLeader: '赵天祥',
startDate: '2025-5-4 16:12:23',
endDate: '2025-5-4 16:12:23',
acceptanceDate: '2025-5-4 16:12:23'
},
{
id: 4,
projectName: '荣信钢铁有限公司整合重组装备更新一期工程项目安全预评价',
status: '检测完成',
projectLeader: '梁永利',
clientLeader: '赵天祥',
startDate: '2025-5-4 16:12:23',
endDate: '2025-5-4 16:12:23',
acceptanceDate: '2025-5-4 16:12:23'
}
{ 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

@ -432,8 +432,7 @@ export default function SimulatedLayout({ children, history }) {
<Content
style={{
margin: 0,
minHeight: 280,
position: "relative",
flex: 1,
}}
>
{children}

View File

@ -1,16 +1,13 @@
.cockpit-page {
min-height: 100vh;
min-height: 100%;
background: #010817;
color: #d8eeff;
overflow: auto;
font-family: "Microsoft YaHei", "PingFang SC", Arial, sans-serif;
}
.cockpit-stage {
width: 100%;
min-width: 1000px;
min-height: calc(100vh - 100px);
aspect-ratio: 1400 / 786;
margin: 0 auto;
position: relative;
overflow: hidden;
@ -81,8 +78,7 @@
.cockpit-layout {
position: relative;
z-index: 1;
height: calc(100% - 45px);
min-height: 680px;
min-height: calc(100vh - 100px);
padding: 16px 16px 14px;
display: grid;
grid-template-columns: minmax(280px, 28.5%) minmax(420px, 1fr) minmax(280px, 28.5%);
@ -92,7 +88,6 @@
.cockpit-left,
.cockpit-right,
.cockpit-center {
min-height: 0;
display: flex;
flex-direction: column;
gap: 14px;
@ -154,6 +149,7 @@
.line-panel { height: 222px; }
.review-panel { flex: 1; min-height: 250px; }
.process-panel { height: 176px; }
.map-shell { flex: 1; min-height: 456px; position: relative; }
.life-grid {
display: grid;
@ -217,7 +213,6 @@
.kpi-card span { float: right; margin-top: 7px; color: #d9ebff; font-size: 11px; }
.kpi-card b { color: #fff; }
.map-shell { flex: 1; min-height: 456px; position: relative; }
.map-chart { position: absolute; inset: 0 0 0; }
.map-card {
position: absolute;
@ -248,8 +243,9 @@
.todo-item p { margin: 0; color: #d8edff; font-size: 11px; white-space: normal; line-height: 1.1; overflow-wrap: anywhere; }
.todo-item b { color: #13c8ff; font-size: 15px; }
.period-tabs { position: absolute; left: 14px; top: 42px; display: flex; z-index: 2; }
.period-tabs span { width: 34px; height: 19px; display: grid; place-items: center; color: #d8f2ff; border: 1px solid rgba(46, 159, 232, .48); background: rgba(7, 37, 88, .6); font-size: 11px; }
.period-tabs span:first-child { background: rgba(5, 126, 176, .5); }
.period-tabs span { width: 34px; height: 19px; display: grid; place-items: center; color: #d8f2ff; border: 1px solid rgba(46, 159, 232, .48); background: rgba(7, 37, 88, .6); font-size: 11px; cursor: pointer; }
.period-tabs span + span { border-left: none; }
.period-tabs span.active { background: rgba(5, 126, 176, .5); color: #fff; }
.line-chart { height: 176px; }
.review-stats { display: grid; grid-template-columns: 1fr 1fr; gap: 11px 14px; }
@ -304,3 +300,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

@ -3,21 +3,26 @@ import * as echarts from 'echarts';
import {
AuditOutlined,
BellOutlined,
CheckCircleOutlined,
ClockCircleOutlined,
ContainerOutlined,
FileDoneOutlined,
FileProtectOutlined,
FundProjectionScreenOutlined,
HourglassOutlined,
SafetyCertificateOutlined,
ScheduleOutlined,
SnippetsOutlined,
TeamOutlined,
} from '@ant-design/icons';
import './index.css';
import { cockpitMockData } from './mockData';
import chongqingGeoJson from './data/chongqingGeoJson.json';
import {
fetchQualificationOverview,
fetchKpi,
fetchRegionDistribution,
fetchProcessOverview,
fetchEvalTypeTrend,
fetchReviewSummary,
fetchProjectMonitor,
} from '~/api/supervision';
echarts.registerMap('chongqing', chongqingGeoJson);
@ -33,13 +38,76 @@ const iconMap = [
ContainerOutlined,
];
function EChart({ option, className, events }) {
// 格式化当前时间为 yyyy年MM月dd日 HH:mm:ss
function formatNow(date = new Date()) {
const pad = n => String(n).padStart(2, '0');
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
// 所有数据的真实空值初始结构(无 mock接口无数据时展示 0 / 空)
function buildInitialState() {
return {
title: '重庆市安全评价在线监管一件事',
currentTime: formatNow(),
lifecycleStats: {
newFilingOrgCount: 0,
currentFilingOrgCount: 0,
logoutOrgCount: 0,
totalFilingOrgCount: 0,
exitedEvaluatorCount: 0,
currentEvaluatorCount: 0,
},
progress: {
filingCompleteRate: 0,
bizActiveRate: 0,
newGrowthRate: 0,
},
industryList: [],
riskItems: [],
riskPie: [],
kpiItems: [],
regionList: [],
regionSummary: {
totalEvalProject: 0,
totalFilingOrg: 0,
},
mapScatterPoints: [],
processNodes: [],
trendBuckets: [],
trendSeries: [],
reviewStats: {
inspCount: 0,
onsiteCheckCount: 0,
qualKeepCount: 0,
specialCount: 0,
checkedOrgCount: 0,
foundProblemCount: 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 }) {
const chartRef = useRef(null);
const instanceRef = useRef(null);
useEffect(() => {
if (!chartRef.current) return undefined;
instanceRef.current = echarts.init(chartRef.current, null, { renderer: 'svg' });
if (onInstance) onInstance(instanceRef.current);
const resize = () => instanceRef.current && instanceRef.current.resize();
const observer = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(resize) : null;
@ -51,6 +119,7 @@ function EChart({ option, className, events }) {
}
return () => {
if (onInstance) onInstance(null);
if (events && instanceRef.current) {
Object.entries(events).forEach(([eventName, handler]) => instanceRef.current.off(eventName, handler));
}
@ -94,7 +163,25 @@ function Panel({ title, children, className = '' }) {
);
}
function LifecyclePanel({ data }) {
// ==================== 资质全生命周期管理 ====================
function LifecyclePanel({ data, loading }) {
const { lifecycleStats, progress, industryList } = data;
const statItems = useMemo(() => [
{ label: '本年新增备案机构数', value: lifecycleStats.newFilingOrgCount },
{ label: '当前备案机构数', value: lifecycleStats.currentFilingOrgCount },
{ label: '本年注销机构数', value: lifecycleStats.logoutOrgCount },
{ label: '全部备案机构数', value: lifecycleStats.totalFilingOrgCount },
{ label: '退出备案评价师数', value: lifecycleStats.exitedEvaluatorCount },
{ label: '当前备案评价师数', value: lifecycleStats.currentEvaluatorCount },
], [lifecycleStats]);
const progressItems = useMemo(() => [
{ label: '评价机构备案完成率', value: progress.filingCompleteRate, color: '#21d8ff' },
{ label: '备案机构开展业务率', value: progress.bizActiveRate, color: '#18f7d2' },
{ label: '备案机构新增率', value: progress.newGrowthRate, color: '#ffd11a' },
], [progress]);
const barOption = useMemo(() => ({
color: ['#1aa7ff', '#d7a92c'],
grid: { left: 28, right: 10, top: 20, bottom: 42 },
@ -102,7 +189,7 @@ function LifecyclePanel({ data }) {
tooltip: { trigger: 'axis', backgroundColor: 'rgba(3, 25, 66, .92)', borderColor: '#1aa7ff', textStyle: { color: '#dff7ff' } },
xAxis: {
type: 'category',
data: data.industry.map(item => item.name),
data: industryList.map(item => item.industryName),
axisLabel: { color: '#b9cfff', fontSize: 10, interval: 0, rotate: 0, width: 38, overflow: 'break' },
axisLine: { lineStyle: { color: '#17416e' } },
axisTick: { show: false },
@ -113,36 +200,40 @@ function LifecyclePanel({ data }) {
splitLine: { lineStyle: { color: 'rgba(78, 143, 219, .2)', type: 'dashed' } },
},
series: [
{ name: '类型数', type: 'bar', stack: 'total', barWidth: 14, data: data.industry.map(item => item.rectification) },
{ name: '机构数', type: 'bar', stack: 'total', barWidth: 14, data: data.industry.map(item => item.institution) },
{ name: '类型数', type: 'bar', stack: 'total', barWidth: 14, data: industryList.map(item => item.typeCount) },
{ name: '机构数', type: 'bar', stack: 'total', barWidth: 14, data: industryList.map(item => item.orgCount) },
],
}), [data.industry]);
}), [industryList]);
return (
<Panel title="资质全生命周期管理" className="lifecycle-panel">
<div className="life-grid">
{data.lifecycleStats.map((item, index) => (
{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>
<div className="progress-list">
{data.progress.map(item => (
{progressItems.map(item => (
<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>
);
}
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' } },
@ -150,46 +241,88 @@ 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>
);
}
function KpiCards({ data }) {
return <div className="kpi-row">{data.kpis.map(item => <div className="kpi-card" key={item.title}><p>{item.title}</p><strong>{item.value}</strong><span> <b> {item.change}</b></span></div>)}</div>;
// ==================== KPI 指标卡片 ====================
function KpiCards({ data, loading }) {
const { kpiItems } = data;
const kpiSkeleton = Array.from({ length: 5 });
return (
<div className="kpi-row">
{(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>
);
}
function MapSection({ data }) {
// ==================== 地图区域分布 ====================
function MapSection({ data, loading }) {
const [selectedAreaName, setSelectedAreaName] = useState('重庆市');
const statMap = useMemo(() => new Map(data.mapAreaStats.map(item => [item.name, item])), [data.mapAreaStats]);
const zoomRef = useRef(1.1);
const chartInstanceRef = useRef(null);
const { regionList, regionSummary, mapScatterPoints } = data;
const statMap = useMemo(() => {
const map = new Map();
regionList.forEach(item => {
map.set(item.districtName, {
name: item.districtName,
districtCode: item.districtCode,
evalProjectCount: item.evalProjectCount,
filingOrgCount: item.filingOrgCount,
});
});
return map;
}, [regionList]);
// 使用后端真实数据;后端未返回的区县补 0不填充伪造数字
const mapData = useMemo(() => {
return (chongqingGeoJson.features || []).map((feature, index) => {
return (chongqingGeoJson.features || []).map((feature) => {
const name = feature.properties && feature.properties.name;
const stat = statMap.get(name) || {
const stat = statMap.get(name);
return {
name,
value: 18 + index * 7,
projectCount: 18 + index * 7,
hiddenDanger: 0,
institutionCount: 0,
rectificationRate: '100%',
districtCode: (stat && stat.districtCode) || '',
evalProjectCount: (stat && stat.evalProjectCount) || 0,
filingOrgCount: (stat && stat.filingOrgCount) || 0,
};
return { ...stat, name };
});
}, [statMap]);
const selectedArea = statMap.get(selectedAreaName) || mapData[0] || data.mapAreaStats[0];
const values = mapData.map(item => Number(item.value) || 0);
const selectedArea = useMemo(() => {
if (selectedAreaName === '重庆市') {
return { name: '重庆市', evalProjectCount: regionSummary.totalEvalProject, filingOrgCount: regionSummary.totalFilingOrg };
}
return statMap.get(selectedAreaName) || { name: selectedAreaName, evalProjectCount: 0, filingOrgCount: 0 };
}, [selectedAreaName, statMap, regionSummary]);
const values = mapData.map(item => Number(item.evalProjectCount) || 0);
const maxValue = Math.max(...values, 100);
const mapOption = useMemo(() => ({
tooltip: {
trigger: 'item',
confine: false,
backgroundColor: 'rgba(2, 31, 82, .96)',
borderColor: '#1aa7ff',
borderWidth: 1,
@ -198,13 +331,15 @@ function MapSection({ data }) {
formatter: params => {
const item = params.data || statMap.get(params.name);
if (!item) return params.name;
return [
`<strong>${params.name}</strong>`,
`评价项目数:${item.projectCount || item.value || 0}`,
`备案机构数:${item.institutionCount || 0}`,
`隐患数量:${item.hiddenDanger || 0}`,
`整改率:${item.rectificationRate || '-'}`,
].join('<br/>');
// 随地图缩放比例同步缩放悬浮框字体
const z = zoomRef.current || 1;
const fs = Math.max(10, Math.round(12 * z));
const title = Math.round(13 * z);
return `<div style="font-size:${fs}px;line-height:1.7">
<strong style="font-size:${title}px">${params.name}</strong><br/>
评价项目数${item.evalProjectCount || 0}<br/>
备案机构数${item.filingOrgCount || 0}
</div>`;
},
},
visualMap: {
@ -247,7 +382,11 @@ function MapSection({ data }) {
map: 'chongqing',
geoIndex: 0,
selectedMode: 'single',
data: mapData.map(item => ({ ...item, selected: item.name === selectedAreaName })),
data: mapData.map(item => ({
...item,
value: item.evalProjectCount,
selected: item.name === selectedAreaName,
})),
},
{
name: '重点乡镇',
@ -257,98 +396,323 @@ function MapSection({ data }) {
symbolSize: value => Math.max(8, Math.min(18, value[2] / 4)),
itemStyle: { color: '#12f4ff', shadowBlur: 10, shadowColor: '#12f4ff' },
label: { show: false },
data: data.mapScatterPoints.map(item => ({ name: item.name, value: [...item.coord, item.value] })),
data: mapScatterPoints.map(item => ({ name: item.name, value: [...item.coord, item.value] })),
},
],
}), [data.mapScatterPoints, mapData, maxValue, selectedAreaName, statMap]);
}), [mapData, maxValue, selectedAreaName, statMap, mapScatterPoints]);
const mapEvents = useMemo(() => ({
click: params => {
if (params && params.name) setSelectedAreaName(params.name);
},
}), []);
// 地图缩放/平移时更新缩放比例tooltip 同步跟随并按比例缩放
georoam: params => {
if (params && typeof params.zoom === 'number') {
zoomRef.current = params.zoom;
// 刷新已显示的 tooltip使其随缩放实时更新字体大小无 hover 时无副作用)
const chart = chartInstanceRef.current;
if (chart) {
chart.dispatchAction({ type: 'showTip' });
}
}
},
}), [selectedAreaName]);
const handleMapInstance = useMemo(() => (instance) => {
chartInstanceRef.current = instance;
}, []);
return (
<div className="map-shell">
<EChart className="map-chart" option={mapOption} events={mapEvents} />
{data.mapScatterPoints.map((item, index) => <div className={`map-card map-card--${index + 1}`} key={item.name}><strong>{item.name}</strong><span>{item.value}</span></div>)}
<EChart className="map-chart" option={mapOption} events={mapEvents} onInstance={handleMapInstance} />
{!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 && selectedArea.name}</strong>
<span>项目数{selectedArea && (selectedArea.projectCount || selectedArea.value)}</span>
<span>隐患{selectedArea && selectedArea.hiddenDanger}</span>
<span>整改率{selectedArea && selectedArea.rectificationRate}</span>
<strong>{selectedArea.name}</strong>
{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>
);
}
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">
{data.todo.map((item, index) => {
const Icon = iconMap[index] || FileDoneOutlined;
return <div className="todo-item" key={item.label}><span><Icon /></span><p>{item.label}</p><b>{item.value}</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>
);
}
function ReviewLinePanel({ data }) {
const lineOption = useMemo(() => ({
color: ['#0da5ff', '#29e487'],
grid: { left: 34, right: 12, top: 28, bottom: 32 },
legend: { right: 0, top: 0, itemWidth: 14, itemHeight: 4, textStyle: { color: '#c6dcff', fontSize: 10 } },
tooltip: { trigger: 'axis', backgroundColor: 'rgba(3, 25, 66, .92)', borderColor: '#1aa7ff', textStyle: { color: '#dff7ff' } },
xAxis: { type: 'category', boundaryGap: false, data: data.reviewLine.xAxis, axisLabel: { color: '#c6dcff', fontSize: 10 }, axisLine: { lineStyle: { color: '#17416e' } }, axisTick: { show: false } },
yAxis: { type: 'value', min: 0, max: 100, splitNumber: 5, axisLabel: { color: '#c6dcff', fontSize: 10 }, splitLine: { lineStyle: { color: 'rgba(78, 143, 219, .22)', type: 'dashed' } } },
series: [
{ name: '项目数', type: 'line', smooth: true, symbolSize: 5, areaStyle: { opacity: .22 }, data: data.reviewLine.project },
{ name: '监督检查数', type: 'line', smooth: true, symbolSize: 5, areaStyle: { opacity: .14 }, data: data.reviewLine.supervision },
],
}), [data.reviewLine]);
return <Panel title="评价类型趋势" className="line-panel"><div className="period-tabs"><span></span><span></span><span></span></div><EChart className="line-chart" option={lineOption} /></Panel>;
// ==================== 评价类型趋势 ====================
const PERIOD_TABS = [
{ key: 'year', label: '年' },
{ key: 'quarter', label: '季' },
{ key: 'month', label: '月' },
];
function ReviewLinePanel({ data, period, onPeriodChange, loading }) {
const { trendBuckets, trendSeries } = data;
const lineOption = useMemo(() => {
const seriesConfig = trendSeries.map(s => ({
name: s.evalTypeName,
type: 'line',
smooth: true,
symbolSize: 5,
areaStyle: { opacity: .22 },
data: s.projectCounts,
}));
return {
color: ['#0da5ff', '#29e487', '#ffb74d', '#ce93d8', '#4dd0e1', '#f48fb1'],
grid: { left: 34, right: 12, top: 28, bottom: 32 },
legend: { right: 0, top: 0, itemWidth: 14, itemHeight: 4, textStyle: { color: '#c6dcff', fontSize: 10 } },
tooltip: { trigger: 'axis', backgroundColor: 'rgba(3, 25, 66, .92)', borderColor: '#1aa7ff', textStyle: { color: '#dff7ff' } },
xAxis: { type: 'category', boundaryGap: false, data: trendBuckets, axisLabel: { color: '#c6dcff', fontSize: 10 }, axisLine: { lineStyle: { color: '#17416e' } }, axisTick: { show: false } },
yAxis: { type: 'value', min: 0, max: 100, splitNumber: 5, axisLabel: { color: '#c6dcff', fontSize: 10 }, splitLine: { lineStyle: { color: 'rgba(78, 143, 219, .22)', type: 'dashed' } } },
series: seriesConfig,
};
}, [trendBuckets, trendSeries]);
return (
<Panel title="评价类型趋势" className="line-panel">
<div className="period-tabs">
{PERIOD_TABS.map(tab => (
<span
key={tab.key}
className={period === tab.key ? 'active' : ''}
onClick={() => onPeriodChange && onPeriodChange(tab.key)}
>{tab.label}</span>
))}
</div>
{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(() => [
{ label: '监督检查总数', value: reviewStats.inspCount },
{ label: '项目过程检查数', value: reviewStats.onsiteCheckCount },
{ label: '资质保持检查数', value: reviewStats.qualKeepCount },
{ label: '专项检查数', value: reviewStats.specialCount },
{ label: '检查机构数', value: reviewStats.checkedOrgCount },
{ label: '发现问题数', value: reviewStats.foundProblemCount },
], [reviewStats]);
const pieOption = useMemo(() => ({
color: ['#1677ff', '#00c089', '#ff7a1a', '#f7cb16', '#ab5fde'],
tooltip: { trigger: 'item', backgroundColor: 'rgba(3, 25, 66, .92)', borderColor: '#1aa7ff', textStyle: { color: '#dff7ff' } },
legend: { orient: 'vertical', right: 0, top: 'center', itemWidth: 10, itemHeight: 8, textStyle: { color: '#c6dcff', fontSize: 10 } },
series: [{ type: 'pie', radius: ['32%', '68%'], center: ['28%', '56%'], label: { show: false }, data: data.reviewPie }],
}), [data.reviewPie]);
series: [{ type: 'pie', radius: ['32%', '68%'], center: ['28%', '56%'], label: { show: false }, data: reviewPieItems.map(item => ({ name: item.name, value: item.value })) }],
}), [reviewPieItems]);
return (
<Panel title="复盘评估改进提效" className="review-panel">
<div className="review-stats">{data.reviewProgress.map(item => <div className="review-stat" key={item.label}><span><FundProjectionScreenOutlined /></span><p>{item.label}<b className={item.danger ? 'danger' : ''}>{item.value}</b></p></div>)}</div>
<EChart className="review-pie" option={pieOption} />
<div className="review-stats">
{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>
{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">{data.process.map((item, index) => <div className="process-node" key={item.label}><strong>{item.value}</strong><span>{item.label}</span>{index < data.process.length - 1 && <i />}</div>)}</div>
<div className="process-grid">
{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>
);
}
// ==================== 主组件 ====================
export default function SupervisionCockpit() {
const data = cockpitMockData;
const [data, setData] = useState(() => buildInitialState());
// 取当前年份/月份
const currentYear = useMemo(() => new Date().getFullYear(), []);
// 右上角实时日期时间,每秒刷新
useEffect(() => {
const timer = setInterval(() => {
setData(prev => ({ ...prev, currentTime: formatNow() }));
}, 1000);
return () => clearInterval(timer);
}, []);
const currentMonth = useMemo(() => {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
}, []);
// 评价类型趋势的时间粒度(年/季/月)
const [trendPeriod, setTrendPeriod] = useState('year');
// 标记是否使用 API 数据(仅当对应 API 成功时为 true
const [apiFlags, setApiFlags] = useState({
qualification: false,
process: false,
});
// 首屏加载态(请求期间展示骨架占位,避免空白)
const [loading, setLoading] = useState(true);
// 趋势图切换加载态
const [trendLoading, setTrendLoading] = useState(true);
useEffect(() => {
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(() => {}),
// 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(() => {}),
// 6. 复盘评估改进提效
fetchReviewSummary(currentYear)
.then((apiData) => {
setData(prev => ({
...prev,
reviewStats: apiData.stats || prev.reviewStats,
reviewPieItems: apiData.pie || prev.reviewPieItems,
}));
})
.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 => ({
...prev,
trendBuckets: apiData.buckets || prev.trendBuckets,
trendSeries: apiData.series || prev.trendSeries,
}));
})
.catch(() => {})
.finally(() => setTrendLoading(false));
}, [trendPeriod, currentYear]);
// 项目流程面板数据:无接口数据时展示空
const processPanelNodes = useMemo(() => {
if (apiFlags.process && data.processNodes && data.processNodes.length > 0) {
return data.processNodes;
}
return data.processNodes || [];
}, [apiFlags.process, data.processNodes]);
return (
<div className="cockpit-page">
<div className="cockpit-stage">
<ScreenHeader data={data} />
<main className="cockpit-layout">
<aside className="cockpit-left"><LifecyclePanel data={data} /><RiskPanel data={data} /></aside>
<section className="cockpit-center"><KpiCards data={data} /><MapSection data={data} /><ProcessPanel data={data} /></section>
<aside className="cockpit-right"><TodoPanel data={data} /><ReviewLinePanel data={data} /><ReviewProgressPanel data={data} /></aside>
<aside className="cockpit-left">
<LifecyclePanel data={data} loading={loading} />
<RiskPanel data={data} loading={loading} />
</aside>
<section className="cockpit-center">
<KpiCards data={data} loading={loading} />
<MapSection data={data} loading={loading} />
<ProcessPanel data={{ processNodes: processPanelNodes }} loading={loading} />
</section>
<aside className="cockpit-right">
<TodoPanel data={data} loading={loading} />
<ReviewLinePanel
data={data}
period={trendPeriod}
onPeriodChange={setTrendPeriod}
loading={trendLoading}
/>
<ReviewProgressPanel data={data} loading={loading} />
</aside>
</main>
</div>
</div>
);
}

15
src/utils/backToBase.js Normal file
View File

@ -0,0 +1,15 @@
/**
* 返回 GBS 基座逻辑与 Map Header / RightUtils 保持一致
*/
export function backToBase() {
window.close();
setTimeout(() => {
if (!window.closed && !window.opener) {
const apiHost =
window.__JJB_ENVIRONMENT__?.API_HOST ||
window.process?.env?.app?.API_HOST ||
"https://gbs-gateway.qhdsafety.com/";
window.location.href = apiHost.endsWith("/") ? apiHost : `${apiHost}/`;
}
}, 500);
}

View File

@ -0,0 +1,10 @@
/**
* 统一处理接口返回剥离外层包装取业务数据
* 兼容 { data } / { result } / 直接数组或对象 等情况
*/
export function handleApiResponse(resp) {
if (resp === null || resp === undefined) return null;
if (typeof resp === "object" && "data" in resp) return resp.data;
if (typeof resp === "object" && "result" in resp) return resp.result;
return resp;
}

View File

@ -0,0 +1,22 @@
/**
* 解析驾驶舱 / 大屏鉴权参数
* - 优先从 URL query 读取基座跳转携带
* - 其次从 sessionStorage 读取已登录态
* 返回 { isInstitution, isSupervision, regulatorCode, token }
*/
export function resolveDriverAuthParams() {
const params = new URLSearchParams(window.location.search);
const get = (key, fallback) => params.get(key) || sessionStorage.getItem(key) || fallback;
const role = get("role", "");
const isInstitution = role === "institution" || !!get("enterpriseId");
const isSupervision = role === "supervision" || !!get("regulatorCode");
return {
isInstitution,
isSupervision,
regulatorCode: get("regulatorCode", ""),
enterpriseId: get("enterpriseId", ""),
token: get("token", ""),
};
}