bug修改
parent
5c3c98f4fd
commit
9f4b78a5ad
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { apiGet } from "../../utils/enterpriseInfo/http";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 机构端首页(驾驶舱)数据接口。
|
||||||
|
*
|
||||||
|
* 统一走项目 http 层(@cqsjjb/jjb-common-lib),自动附带 API_HOST、token 与机构上下文请求头。
|
||||||
|
* 页面以 `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 }));
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { apiGet } from "../../utils/enterpriseInfo/http";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 监管端驾驶舱数据接口。
|
||||||
|
*
|
||||||
|
* 统一走项目 http 层(@cqsjjb/jjb-common-lib),自动附带 API_HOST、token 与机构上下文请求头。
|
||||||
|
* 页面以 `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 |
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react";
|
||||||
import { Connect } from "@cqsjjb/jjb-dva-runtime";
|
import { Connect } from "@cqsjjb/jjb-dva-runtime";
|
||||||
import { Button, Skeleton, Tabs } from "antd";
|
import { Button, Skeleton, Tabs } from "antd";
|
||||||
import { NS_DRIVER } from "~/enumerate/namespace";
|
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 Supervision from "~/pages/Container/Supervision/Dashboard";
|
||||||
import Cockpit from "~/pages/Container/Supervision/Cockpit";
|
import Cockpit from "~/pages/Container/Supervision/Cockpit";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
import InstitutionDashboard from '../Index';
|
|
||||||
|
|
||||||
export default InstitutionDashboard;
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
import React, { useEffect, useMemo, useRef } from 'react';
|
import React, { useEffect, useMemo, useRef } from 'react';
|
||||||
import * as echarts from 'echarts';
|
import * as echarts from 'echarts';
|
||||||
import { Card } from 'antd';
|
|
||||||
|
|
||||||
const BLUE = '#5b8ff9';
|
const BLUE = '#4285f4';
|
||||||
const ORANGE = '#ffb33e';
|
const ORANGE = '#ffab31';
|
||||||
|
|
||||||
function EChart({ option, className }) {
|
export default function EnterpriseTypeChart({ data }) {
|
||||||
const chartRef = useRef(null);
|
const chartRef = useRef(null);
|
||||||
const instanceRef = useRef(null);
|
const instanceRef = useRef(null);
|
||||||
|
|
||||||
|
|
@ -16,7 +15,6 @@ function EChart({ option, className }) {
|
||||||
const observer = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(resize) : null;
|
const observer = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(resize) : null;
|
||||||
if (observer) observer.observe(chartRef.current);
|
if (observer) observer.observe(chartRef.current);
|
||||||
window.addEventListener('resize', resize);
|
window.addEventListener('resize', resize);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('resize', resize);
|
window.removeEventListener('resize', resize);
|
||||||
if (observer) observer.disconnect();
|
if (observer) observer.disconnect();
|
||||||
|
|
@ -25,67 +23,47 @@ function EChart({ option, className }) {
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
const list = data && data.list ? data.list : [];
|
||||||
if (instanceRef.current) instanceRef.current.setOption(option, true);
|
const categories = list.map(item => item.industryName);
|
||||||
}, [option]);
|
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(() => ({
|
const option = useMemo(() => ({
|
||||||
color: [BLUE, ORANGE],
|
color: [BLUE, ORANGE],
|
||||||
grid: { left: 38, right: 10, top: 26, bottom: 46 },
|
grid: { left: 36, right: 10, top: 12, bottom: 28 },
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: 'axis',
|
trigger: 'axis',
|
||||||
axisPointer: { type: 'shadow', shadowStyle: { color: 'rgba(91, 143, 249, .08)' } },
|
|
||||||
backgroundColor: '#fff',
|
backgroundColor: '#fff',
|
||||||
borderColor: '#e9edf4',
|
borderColor: '#e2e8f0',
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
textStyle: { color: '#333', fontSize: 12 },
|
textStyle: { color: '#1e293b', fontSize: 12 },
|
||||||
},
|
},
|
||||||
xAxis: {
|
xAxis: {
|
||||||
type: 'category',
|
type: 'category',
|
||||||
data: data.categories,
|
data: categories,
|
||||||
axisTick: { show: false },
|
axisTick: { show: false },
|
||||||
axisLine: { lineStyle: { color: '#edf0f5' } },
|
axisLine: { lineStyle: { color: '#e2e8f0' } },
|
||||||
axisLabel: {
|
axisLabel: { color: '#64748b', fontSize: 11, interval: 0 },
|
||||||
color: '#666',
|
|
||||||
fontSize: 12,
|
|
||||||
interval: 0,
|
|
||||||
width: 58,
|
|
||||||
overflow: 'break',
|
|
||||||
lineHeight: 14,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
yAxis: {
|
yAxis: {
|
||||||
type: 'value',
|
type: 'value',
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 60,
|
max: yMax,
|
||||||
splitNumber: 6,
|
interval: yMax > 10 ? yMax / 3 : 5,
|
||||||
axisLabel: { color: '#8792a2', fontSize: 12 },
|
axisLabel: { color: '#8492a6', fontSize: 10, margin: 4 },
|
||||||
splitLine: { lineStyle: { color: '#e9edf4', type: 'dashed' } },
|
splitLine: { lineStyle: { color: '#dce5ef', type: 'dashed' } },
|
||||||
},
|
},
|
||||||
series: [
|
series: [
|
||||||
{ name: '项目数', type: 'bar', barWidth: 12, data: data.projectCount, 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: 12, data: data.amount, 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 (
|
useEffect(() => {
|
||||||
<Card
|
if (instanceRef.current) instanceRef.current.setOption(option, true);
|
||||||
title={<span className="institution-card-title">服务企业类型统计</span>}
|
}, [option]);
|
||||||
size="small"
|
|
||||||
className="institution-panel-card institution-enterprise-card"
|
return <div className="institution-bar-chart" ref={chartRef} />;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,16 @@
|
||||||
import React from 'react';
|
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 }) {
|
export default function InfoAlerts({ data }) {
|
||||||
return (
|
return (
|
||||||
<div className="institution-alert-grid">
|
<div className="dashboard-reminder-grid">
|
||||||
{data.map((item) => (
|
{data.map(item => (
|
||||||
<AlertItem key={item.key} item={item} />
|
<article key={item.key} className={item.colorClass}>
|
||||||
|
<i>{item.char}</i>
|
||||||
|
<div>
|
||||||
|
<span>{item.title}</span>
|
||||||
|
<strong>{item.value}</strong>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,70 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Card, Table } from 'antd';
|
|
||||||
|
|
||||||
const columns = [
|
export default function ProjectCompletionTable({ data, loading, onView }) {
|
||||||
{ title: '序号', dataIndex: 'id', key: 'id', width: 70, align: 'center' },
|
const list = data && data.list ? data.list : [];
|
||||||
{ 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' },
|
const skeletonRows = Array.from({ length: 5 });
|
||||||
{ 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 }) {
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<section className="dashboard-block dashboard-project-block">
|
||||||
title={<span className="institution-card-title">项目完成情况统计</span>}
|
<div className="dashboard-block-head">
|
||||||
size="small"
|
<h3>项目执行情况</h3>
|
||||||
className="institution-panel-card institution-table-card"
|
<button className="dashboard-btn-ghost" type="button">
|
||||||
styles={{ body: { padding: '12px 14px 14px' } }}
|
查看全部
|
||||||
|
</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)}
|
||||||
>
|
>
|
||||||
<Table
|
查看
|
||||||
className="institution-completion-table"
|
</button>
|
||||||
columns={columns}
|
</td>
|
||||||
dataSource={data}
|
</tr>
|
||||||
rowKey="id"
|
))}
|
||||||
pagination={false}
|
</tbody>
|
||||||
size="small"
|
</table>
|
||||||
scroll={{ x: 1180 }}
|
</div>
|
||||||
/>
|
</section>
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React, { useEffect, useMemo, useRef } from 'react';
|
import React, { useEffect, useMemo, useRef } from 'react';
|
||||||
import * as echarts from 'echarts';
|
import * as echarts from 'echarts';
|
||||||
import { Card } from 'antd';
|
|
||||||
|
const COLORS = ['#4285f4', '#58b874', '#ff9f2f'];
|
||||||
|
|
||||||
function EChart({ option, className }) {
|
function EChart({ option, className }) {
|
||||||
const chartRef = useRef(null);
|
const chartRef = useRef(null);
|
||||||
|
|
@ -13,7 +14,6 @@ function EChart({ option, className }) {
|
||||||
const observer = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(resize) : null;
|
const observer = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(resize) : null;
|
||||||
if (observer) observer.observe(chartRef.current);
|
if (observer) observer.observe(chartRef.current);
|
||||||
window.addEventListener('resize', resize);
|
window.addEventListener('resize', resize);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('resize', resize);
|
window.removeEventListener('resize', resize);
|
||||||
if (observer) observer.disconnect();
|
if (observer) observer.disconnect();
|
||||||
|
|
@ -29,89 +29,80 @@ function EChart({ option, className }) {
|
||||||
return <div className={className} ref={chartRef} />;
|
return <div className={className} ref={chartRef} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Legend({ data }) {
|
export default function ProjectTypeChart({ data }) {
|
||||||
return (
|
const items = data && data.items ? data.items : [];
|
||||||
<div className="institution-project-legend">
|
const totalProjectCount = data ? Number(data.totalProjectCount) || 0 : 0;
|
||||||
{data.map((item) => (
|
const computedTotal = totalProjectCount || items.reduce((sum, item) => sum + (Number(item.count) || 0), 0);
|
||||||
<span key={item.name}>
|
|
||||||
<i style={{ backgroundColor: item.color }} />
|
|
||||||
{item.name}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ProjectTypeChart({ data, total }) {
|
|
||||||
const totalValue = total || data.reduce((sum, item) => sum + item.value, 0);
|
|
||||||
const option = useMemo(() => ({
|
const option = useMemo(() => ({
|
||||||
color: data.map(item => item.color),
|
color: COLORS.slice(0, items.length),
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: 'item',
|
trigger: 'item',
|
||||||
backgroundColor: '#fff',
|
backgroundColor: '#fff',
|
||||||
borderColor: '#e9edf4',
|
borderColor: '#e2e8f0',
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
textStyle: { color: '#333', fontSize: 12 },
|
textStyle: { color: '#1e293b', fontSize: 12 },
|
||||||
formatter: '{b}<br/>项目数:{c}<br/>占比:{d}%',
|
formatter: '{b}<br/>项目数:{c}<br/>占比:{d}%',
|
||||||
},
|
},
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
name: '项目类型占比',
|
name: '评价类别占比',
|
||||||
type: 'pie',
|
type: 'pie',
|
||||||
radius: ['46%', '74%'],
|
radius: ['48%', '72%'],
|
||||||
center: ['50%', '50%'],
|
center: ['50%', '48%'],
|
||||||
avoidLabelOverlap: true,
|
avoidLabelOverlap: true,
|
||||||
minAngle: 5,
|
|
||||||
label: { show: false },
|
label: { show: false },
|
||||||
labelLine: { show: false },
|
labelLine: { show: false },
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
borderColor: '#fff',
|
borderColor: '#fff',
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
|
borderRadius: 4,
|
||||||
},
|
},
|
||||||
emphasis: {
|
data: items.map((item) => ({
|
||||||
scale: true,
|
name: item.evalTypeName,
|
||||||
scaleSize: 4,
|
value: Number(item.count) || 0,
|
||||||
},
|
})),
|
||||||
data: data.map(item => ({ name: item.name, value: item.value })),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
graphic: [
|
graphic: [
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
left: 'center',
|
left: 'center',
|
||||||
top: '42%',
|
top: '38%',
|
||||||
style: {
|
style: {
|
||||||
text: '项目总数',
|
text: '项目总数',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
fill: '#a0a7b2',
|
fill: '#94a3b8',
|
||||||
fontSize: 15,
|
fontSize: 13,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
left: 'center',
|
left: 'center',
|
||||||
top: '53%',
|
top: '50%',
|
||||||
style: {
|
style: {
|
||||||
text: String(totalValue),
|
text: String(computedTotal),
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
fill: '#111827',
|
fill: '#1e293b',
|
||||||
fontSize: 24,
|
fontSize: 22,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}), [data, totalValue]);
|
}), [items, computedTotal]);
|
||||||
|
|
||||||
return (
|
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} />
|
<EChart className="institution-donut-chart" option={option} />
|
||||||
<Legend data={data} />
|
<div className="dashboard-type-legend">
|
||||||
</Card>
|
{items.map((item, i) => (
|
||||||
|
<span key={item.evalTypeCode || i}>
|
||||||
|
<i style={{ backgroundColor: COLORS[i % COLORS.length] }} />
|
||||||
|
{item.evalTypeName} {Number(item.count) || 0}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,13 @@
|
||||||
import React from 'react';
|
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 }) {
|
function StatisticCard({ item }) {
|
||||||
const Icon = iconMap[item.icon] || FileDoneOutlined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="institution-stat-card">
|
<div className="institution-stat-card">
|
||||||
<div className="institution-stat-card__icon" style={{ backgroundColor: item.bgColor }}>
|
<div
|
||||||
<Icon />
|
className="institution-stat-card__icon"
|
||||||
|
style={{ backgroundColor: item.color, color: item.charColor }}
|
||||||
|
>
|
||||||
|
{item.char}
|
||||||
</div>
|
</div>
|
||||||
<div className="institution-stat-card__content">
|
<div className="institution-stat-card__content">
|
||||||
<div className="institution-stat-card__title">{item.title}</div>
|
<div className="institution-stat-card__title">{item.title}</div>
|
||||||
|
|
|
||||||
|
|
@ -1,256 +1,408 @@
|
||||||
.institution-dashboard {
|
/* ============ 机构端首页 — 1:1 还原原型 dashboard-classic ============ */
|
||||||
|
|
||||||
|
.institution-dashboard {
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
padding: 0 2px 0;
|
background: #f8fafc;
|
||||||
background: #f3f3f3;
|
color: #1e293b;
|
||||||
color: #333;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.institution-dashboard__top,
|
/* ============ dashboard-classic 布局 ============ */
|
||||||
.institution-dashboard__charts {
|
.dashboard-classic {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 2.12fr) 397px;
|
gap: 0.85rem;
|
||||||
gap: 20px;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.institution-panel-card {
|
.dashboard-classic-top,
|
||||||
border: 0 !important;
|
.dashboard-classic-middle {
|
||||||
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 {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(5, minmax(120px, 1fr));
|
grid-template-columns: minmax(0, 3.2fr) minmax(300px, 1fr);
|
||||||
gap: 26px 28px;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-height: 56px;
|
gap: 0.65rem;
|
||||||
padding: 10px 13px;
|
min-height: 62px;
|
||||||
|
padding: 0.65rem;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 4px;
|
box-shadow: 0 4px 16px rgba(30, 64, 100, 0.07);
|
||||||
box-shadow: 0 6px 17px rgba(33, 57, 98, 0.09);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.institution-stat-card__icon,
|
.dashboard-status-grid article > i {
|
||||||
.institution-alert-item__icon {
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 4px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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;
|
width: 34px;
|
||||||
height: 34px;
|
height: 34px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 19px;
|
font-style: normal;
|
||||||
}
|
|
||||||
|
|
||||||
.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-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 18px;
|
font-size: 13px;
|
||||||
text-overflow: ellipsis;
|
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;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.institution-stat-card__value {
|
.dashboard-project-table td {
|
||||||
color: #333;
|
border-bottom: 1px solid #e2e8f0;
|
||||||
font-size: 16px;
|
color: #1e293b;
|
||||||
font-weight: 500;
|
|
||||||
line-height: 21px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.institution-alert-grid {
|
.dashboard-project-table tr:last-child td {
|
||||||
display: grid;
|
border-bottom: none;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 12px 18px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.institution-alert-item {
|
.dashboard-project-table tr:hover td {
|
||||||
display: flex;
|
background: #f8fafc;
|
||||||
align-items: center;
|
|
||||||
min-height: 66px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.institution-alert-item__icon {
|
/* ============ 原生按钮(匹配原型) ============ */
|
||||||
width: 34px;
|
.btn {
|
||||||
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 {
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
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 {
|
.btn-primary {
|
||||||
width: 7px;
|
background: #2563eb;
|
||||||
height: 7px;
|
color: #fff;
|
||||||
margin-right: 6px;
|
border-color: #2563eb;
|
||||||
border-radius: 50%;
|
}
|
||||||
|
.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) {
|
.dashboard-table-link:hover {
|
||||||
color: #222;
|
background: #1d4ed8;
|
||||||
font-size: 12px;
|
text-decoration: none;
|
||||||
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.institution-completion-table :is(.ant-table-thead, .micro-temp-table-thead) > tr > th {
|
/* ============ 骨架占位(加载态) ============ */
|
||||||
height: 27px;
|
.sk-block {
|
||||||
padding: 6px 10px;
|
display: inline-block;
|
||||||
background: #eef2f8 !important;
|
background: linear-gradient(90deg, #eef2f7 25%, #e2e8f0 37%, #eef2f7 63%);
|
||||||
border-bottom: 0;
|
background-size: 400% 100%;
|
||||||
color: #333;
|
animation: sk-shimmer 1.3s ease-in-out infinite;
|
||||||
font-size: 12px;
|
vertical-align: middle;
|
||||||
font-weight: 700;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.institution-completion-table :is(.ant-table-tbody, .micro-temp-table-tbody) > tr > td {
|
@keyframes sk-shimmer {
|
||||||
height: 29px;
|
0% { background-position: 100% 0; }
|
||||||
padding: 6px 10px;
|
100% { background-position: 0 0; }
|
||||||
border-bottom: 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;
|
@media (max-width: 1280px) {
|
||||||
font-size: 12px;
|
.dashboard-classic-top,
|
||||||
}
|
.dashboard-classic-middle {
|
||||||
|
|
||||||
@media (max-width: 1180px) {
|
|
||||||
.institution-dashboard__top,
|
|
||||||
.institution-dashboard__charts {
|
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.institution-stat-grid {
|
.dashboard-status-grid {
|
||||||
grid-template-columns: repeat(3, minmax(120px, 1fr));
|
grid-template-columns: repeat(2, 1fr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.institution-stat-grid,
|
.dashboard-status-grid {
|
||||||
.institution-alert-grid {
|
grid-template-columns: repeat(2, 1fr);
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
}
|
||||||
|
.dashboard-reminder-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,4 @@
|
||||||
import React from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Space, Card } from 'antd';
|
|
||||||
|
|
||||||
import StatisticCards from './components/StatisticCards';
|
|
||||||
import InfoAlerts from './components/InfoAlerts';
|
|
||||||
import EnterpriseTypeChart from './components/EnterpriseTypeChart';
|
import EnterpriseTypeChart from './components/EnterpriseTypeChart';
|
||||||
import ProjectTypeChart from './components/ProjectTypeChart';
|
import ProjectTypeChart from './components/ProjectTypeChart';
|
||||||
import ProjectCompletionTable from './components/ProjectCompletionTable';
|
import ProjectCompletionTable from './components/ProjectCompletionTable';
|
||||||
|
|
@ -10,53 +6,197 @@ import './index.css';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
STATISTIC_CARDS,
|
STATISTIC_CARDS,
|
||||||
INFO_ALERTS,
|
|
||||||
PROJECT_SUMMARY,
|
|
||||||
ENTERPRISE_TYPE_STATS,
|
|
||||||
PROJECT_TYPE_DISTRIBUTION,
|
|
||||||
PROJECT_TOTAL,
|
|
||||||
PROJECT_COMPLETION_LIST
|
|
||||||
} from './mockData';
|
} 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() {
|
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 (
|
return (
|
||||||
<div className="institution-dashboard">
|
<div className="institution-dashboard">
|
||||||
<div className="institution-dashboard__top">
|
<div className="dashboard-classic">
|
||||||
<Card
|
{/* ===== 第一行:项目节点统计 + 通知提醒 ===== */}
|
||||||
title={<span className="institution-card-title">当前评价状态状态统计</span>}
|
<div className="dashboard-classic-top">
|
||||||
size="small"
|
<section className="dashboard-block dashboard-status-block">
|
||||||
className="institution-dashboard__status-card institution-panel-card"
|
<div className="dashboard-block-head">
|
||||||
extra={
|
<h3>当前项目节点统计</h3>
|
||||||
<Space size={30} className="institution-dashboard__summary">
|
<div>
|
||||||
<span>
|
<span>项目总数:{loading ? <Skeleton width={28} height={14} /> : <b>{projectSummary.total}</b>}</span>
|
||||||
项目总数: <strong className="is-blue">{PROJECT_SUMMARY.total}</strong>
|
<span>法定项目:{loading ? <Skeleton width={28} height={14} /> : <b>{projectSummary.statutory}</b>}</span>
|
||||||
</span>
|
</div>
|
||||||
<span>
|
</div>
|
||||||
超期数: <strong className="is-blue">{PROJECT_SUMMARY.overdue}</strong>
|
<div className="dashboard-status-grid">
|
||||||
</span>
|
{statusCards.map(item => (
|
||||||
</Space>
|
<StatusCard key={item.key} item={item} loading={loading} />
|
||||||
}
|
))}
|
||||||
styles={{ body: { padding: '16px 34px 17px' } }}
|
</div>
|
||||||
>
|
</section>
|
||||||
<StatisticCards data={STATISTIC_CARDS} />
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card
|
<section className="dashboard-block dashboard-reminder-block">
|
||||||
title={<span className="institution-card-title">信息提醒</span>}
|
<div className="dashboard-block-head">
|
||||||
size="small"
|
<h3>通知提醒</h3>
|
||||||
className="institution-dashboard__alerts-card institution-panel-card"
|
</div>
|
||||||
styles={{ body: { padding: '20px 24px' } }}
|
<div className="dashboard-reminder-grid">
|
||||||
>
|
{alertItems.map(item => (
|
||||||
<InfoAlerts data={INFO_ALERTS} />
|
<ReminderCard key={item.key} item={item} loading={loading} onClick={handleReminderClick} />
|
||||||
</Card>
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="institution-dashboard__charts">
|
{/* ===== 第二行:服务行业项目统计 + 评价类别占比 ===== */}
|
||||||
<EnterpriseTypeChart data={ENTERPRISE_TYPE_STATS} />
|
<div className="dashboard-classic-middle">
|
||||||
<ProjectTypeChart data={PROJECT_TYPE_DISTRIBUTION} total={PROJECT_TOTAL} />
|
<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>
|
</div>
|
||||||
|
|
||||||
<ProjectCompletionTable data={PROJECT_COMPLETION_LIST} />
|
{/* ===== 第三行:项目执行情况 ===== */}
|
||||||
|
<ProjectCompletionTable
|
||||||
|
data={projectExecutionData}
|
||||||
|
loading={loading}
|
||||||
|
onView={() => {}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,86 +1,17 @@
|
||||||
/**
|
/**
|
||||||
* 机构端首页 - 模拟数据
|
* 机构端首页 - 卡片模板(仅标题/图标/样式,value 统一为 0,由接口数据覆盖)。
|
||||||
|
* 不再保留任何 mock 假数据:接口无数据时展示 0 / 空值。
|
||||||
*/
|
*/
|
||||||
|
// 仅作为卡片模板(标题/图标/样式),value 统一默认 0,由接口数据覆盖。
|
||||||
export const STATISTIC_CARDS = [
|
export const STATISTIC_CARDS = [
|
||||||
{ key: 'contract', title: '项目合同签订', value: 5632, icon: 'FileDone', color: '#409eff', bgColor: '#409eff' },
|
{ key: 'riskAnalysis', title: '待风险分析', value: 0, char: '险', colorClass: 'blue' },
|
||||||
{ key: 'riskAnalysis', title: '待风险分析', value: 951, icon: 'LineChart', color: '#8b7cf6', bgColor: '#8b7cf6' },
|
{ key: 'contractInput', title: '待合同录入', value: 0, char: '合', colorClass: 'violet' },
|
||||||
{ key: 'projectTeam', title: '待成立项目组', value: 5632, icon: 'Appstore', color: '#48d1bd', bgColor: '#48d1bd' },
|
{ key: 'projectTeam', title: '待项目组成立', value: 0, char: '组', colorClass: 'green' },
|
||||||
{ key: 'workPlan', title: '待制定工作计划', value: 5632, icon: 'FileProtect', color: '#ff9f43', bgColor: '#ff9f43' },
|
{ key: 'siteSurvey', title: '待现场踏勘', value: 0, char: '场', colorClass: 'orange' },
|
||||||
{ key: 'initialEval', title: '待初提', value: 5632, icon: 'Safety', color: '#8b7cf6', bgColor: '#8b7cf6' },
|
{ key: 'reportDraft', title: '待报告编制', value: 0, char: '报', colorClass: 'blue' },
|
||||||
{ key: 'checklist', title: '待编制检查表', value: 456, icon: 'Profile', color: '#8b7cf6', bgColor: '#8b7cf6' },
|
{ key: 'internalReview', title: '待内部审核', value: 0, char: '内', colorClass: 'violet' },
|
||||||
{ key: 'industryNotice', title: '待从业告知', value: 5632, icon: 'Notification', color: '#ffb12a', bgColor: '#ffb12a' },
|
{ key: 'techReview', title: '待技术审核', value: 0, char: '技', colorClass: 'orange' },
|
||||||
{ key: 'siteSurvey', title: '待现场勘查', value: 5632, icon: 'Read', color: '#8b7cf6', bgColor: '#8b7cf6' },
|
{ key: 'processReview', title: '待过程控制审核', value: 0, char: '控', colorClass: 'green' },
|
||||||
{ key: 'processControl', title: '过程管控', value: 951, icon: 'Compass', color: '#409eff', bgColor: '#409eff' },
|
{ key: 'archived', title: '已归档项目', value: 0, char: '档', colorClass: 'cyan' },
|
||||||
{ key: 'archive', title: '归档', value: 651, icon: 'Database', color: '#08bea7', bgColor: '#08bea7' }
|
{ key: 'delayed', title: '项目延期', value: 0, char: '延', colorClass: 'red' },
|
||||||
];
|
|
||||||
|
|
||||||
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'
|
|
||||||
}
|
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -432,8 +432,7 @@ export default function SimulatedLayout({ children, history }) {
|
||||||
<Content
|
<Content
|
||||||
style={{
|
style={{
|
||||||
margin: 0,
|
margin: 0,
|
||||||
minHeight: 280,
|
flex: 1,
|
||||||
position: "relative",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,13 @@
|
||||||
.cockpit-page {
|
.cockpit-page {
|
||||||
min-height: 100vh;
|
min-height: 100%;
|
||||||
background: #010817;
|
background: #010817;
|
||||||
color: #d8eeff;
|
color: #d8eeff;
|
||||||
overflow: auto;
|
|
||||||
font-family: "Microsoft YaHei", "PingFang SC", Arial, sans-serif;
|
font-family: "Microsoft YaHei", "PingFang SC", Arial, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cockpit-stage {
|
.cockpit-stage {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 1000px;
|
min-width: 1000px;
|
||||||
min-height: calc(100vh - 100px);
|
|
||||||
aspect-ratio: 1400 / 786;
|
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
@ -81,8 +78,7 @@
|
||||||
.cockpit-layout {
|
.cockpit-layout {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
height: calc(100% - 45px);
|
min-height: calc(100vh - 100px);
|
||||||
min-height: 680px;
|
|
||||||
padding: 16px 16px 14px;
|
padding: 16px 16px 14px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(280px, 28.5%) minmax(420px, 1fr) minmax(280px, 28.5%);
|
grid-template-columns: minmax(280px, 28.5%) minmax(420px, 1fr) minmax(280px, 28.5%);
|
||||||
|
|
@ -92,7 +88,6 @@
|
||||||
.cockpit-left,
|
.cockpit-left,
|
||||||
.cockpit-right,
|
.cockpit-right,
|
||||||
.cockpit-center {
|
.cockpit-center {
|
||||||
min-height: 0;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
|
|
@ -154,6 +149,7 @@
|
||||||
.line-panel { height: 222px; }
|
.line-panel { height: 222px; }
|
||||||
.review-panel { flex: 1; min-height: 250px; }
|
.review-panel { flex: 1; min-height: 250px; }
|
||||||
.process-panel { height: 176px; }
|
.process-panel { height: 176px; }
|
||||||
|
.map-shell { flex: 1; min-height: 456px; position: relative; }
|
||||||
|
|
||||||
.life-grid {
|
.life-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
@ -217,7 +213,6 @@
|
||||||
.kpi-card span { float: right; margin-top: 7px; color: #d9ebff; font-size: 11px; }
|
.kpi-card span { float: right; margin-top: 7px; color: #d9ebff; font-size: 11px; }
|
||||||
.kpi-card b { color: #fff; }
|
.kpi-card b { color: #fff; }
|
||||||
|
|
||||||
.map-shell { flex: 1; min-height: 456px; position: relative; }
|
|
||||||
.map-chart { position: absolute; inset: 0 0 0; }
|
.map-chart { position: absolute; inset: 0 0 0; }
|
||||||
.map-card {
|
.map-card {
|
||||||
position: absolute;
|
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 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; }
|
.todo-item b { color: #13c8ff; font-size: 15px; }
|
||||||
.period-tabs { position: absolute; left: 14px; top: 42px; display: flex; z-index: 2; }
|
.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 { 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:first-child { background: rgba(5, 126, 176, .5); }
|
.period-tabs span + span { border-left: none; }
|
||||||
|
.period-tabs span.active { background: rgba(5, 126, 176, .5); color: #fff; }
|
||||||
.line-chart { height: 176px; }
|
.line-chart { height: 176px; }
|
||||||
|
|
||||||
.review-stats { display: grid; grid-template-columns: 1fr 1fr; gap: 11px 14px; }
|
.review-stats { display: grid; grid-template-columns: 1fr 1fr; gap: 11px 14px; }
|
||||||
|
|
@ -304,3 +300,37 @@
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #d9f2ff;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,21 +3,26 @@ import * as echarts from 'echarts';
|
||||||
import {
|
import {
|
||||||
AuditOutlined,
|
AuditOutlined,
|
||||||
BellOutlined,
|
BellOutlined,
|
||||||
CheckCircleOutlined,
|
|
||||||
ClockCircleOutlined,
|
ClockCircleOutlined,
|
||||||
ContainerOutlined,
|
ContainerOutlined,
|
||||||
FileDoneOutlined,
|
FileDoneOutlined,
|
||||||
FileProtectOutlined,
|
FileProtectOutlined,
|
||||||
FundProjectionScreenOutlined,
|
FundProjectionScreenOutlined,
|
||||||
HourglassOutlined,
|
|
||||||
SafetyCertificateOutlined,
|
SafetyCertificateOutlined,
|
||||||
ScheduleOutlined,
|
ScheduleOutlined,
|
||||||
SnippetsOutlined,
|
SnippetsOutlined,
|
||||||
TeamOutlined,
|
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
import { cockpitMockData } from './mockData';
|
|
||||||
import chongqingGeoJson from './data/chongqingGeoJson.json';
|
import chongqingGeoJson from './data/chongqingGeoJson.json';
|
||||||
|
import {
|
||||||
|
fetchQualificationOverview,
|
||||||
|
fetchKpi,
|
||||||
|
fetchRegionDistribution,
|
||||||
|
fetchProcessOverview,
|
||||||
|
fetchEvalTypeTrend,
|
||||||
|
fetchReviewSummary,
|
||||||
|
fetchProjectMonitor,
|
||||||
|
} from '~/api/supervision';
|
||||||
|
|
||||||
echarts.registerMap('chongqing', chongqingGeoJson);
|
echarts.registerMap('chongqing', chongqingGeoJson);
|
||||||
|
|
||||||
|
|
@ -33,13 +38,76 @@ const iconMap = [
|
||||||
ContainerOutlined,
|
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 chartRef = useRef(null);
|
||||||
const instanceRef = useRef(null);
|
const instanceRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!chartRef.current) return undefined;
|
if (!chartRef.current) return undefined;
|
||||||
instanceRef.current = echarts.init(chartRef.current, null, { renderer: 'svg' });
|
instanceRef.current = echarts.init(chartRef.current, null, { renderer: 'svg' });
|
||||||
|
if (onInstance) onInstance(instanceRef.current);
|
||||||
|
|
||||||
const resize = () => instanceRef.current && instanceRef.current.resize();
|
const resize = () => instanceRef.current && instanceRef.current.resize();
|
||||||
const observer = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(resize) : null;
|
const observer = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(resize) : null;
|
||||||
|
|
@ -51,6 +119,7 @@ function EChart({ option, className, events }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
if (onInstance) onInstance(null);
|
||||||
if (events && instanceRef.current) {
|
if (events && instanceRef.current) {
|
||||||
Object.entries(events).forEach(([eventName, handler]) => instanceRef.current.off(eventName, handler));
|
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(() => ({
|
const barOption = useMemo(() => ({
|
||||||
color: ['#1aa7ff', '#d7a92c'],
|
color: ['#1aa7ff', '#d7a92c'],
|
||||||
grid: { left: 28, right: 10, top: 20, bottom: 42 },
|
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' } },
|
tooltip: { trigger: 'axis', backgroundColor: 'rgba(3, 25, 66, .92)', borderColor: '#1aa7ff', textStyle: { color: '#dff7ff' } },
|
||||||
xAxis: {
|
xAxis: {
|
||||||
type: 'category',
|
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' },
|
axisLabel: { color: '#b9cfff', fontSize: 10, interval: 0, rotate: 0, width: 38, overflow: 'break' },
|
||||||
axisLine: { lineStyle: { color: '#17416e' } },
|
axisLine: { lineStyle: { color: '#17416e' } },
|
||||||
axisTick: { show: false },
|
axisTick: { show: false },
|
||||||
|
|
@ -113,36 +200,40 @@ function LifecyclePanel({ data }) {
|
||||||
splitLine: { lineStyle: { color: 'rgba(78, 143, 219, .2)', type: 'dashed' } },
|
splitLine: { lineStyle: { color: 'rgba(78, 143, 219, .2)', type: 'dashed' } },
|
||||||
},
|
},
|
||||||
series: [
|
series: [
|
||||||
{ name: '类型数', type: 'bar', stack: 'total', barWidth: 14, data: data.industry.map(item => item.rectification) },
|
{ name: '类型数', type: 'bar', stack: 'total', barWidth: 14, data: industryList.map(item => item.typeCount) },
|
||||||
{ 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.orgCount) },
|
||||||
],
|
],
|
||||||
}), [data.industry]);
|
}), [industryList]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel title="资质全生命周期管理" className="lifecycle-panel">
|
<Panel title="资质全生命周期管理" className="lifecycle-panel">
|
||||||
<div className="life-grid">
|
<div className="life-grid">
|
||||||
{data.lifecycleStats.map((item, index) => (
|
{statItems.map((item) => (
|
||||||
<div className="life-stat" key={item.label}>
|
<div className="life-stat" key={item.label}>
|
||||||
<span className="life-stat__icon"><FileProtectOutlined /></span>
|
<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>
|
</div>
|
||||||
<div className="progress-list">
|
<div className="progress-list">
|
||||||
{data.progress.map(item => (
|
{progressItems.map(item => (
|
||||||
<div className="progress-row" key={item.label}>
|
<div className="progress-row" key={item.label}>
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
<div className="progress-track"><i style={{ width: `${item.value}%`, background: item.color }} /></div>
|
<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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<EChart className="industry-chart" option={barOption} />
|
{loading ? <div className="sk-chart sk-chart--dark" /> : <EChart className="industry-chart" option={barOption} />}
|
||||||
</Panel>
|
</Panel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RiskPanel({ data }) {
|
// ==================== 风险预警(暂无对应 API,接口未接入时展示空值) ====================
|
||||||
|
function RiskPanel({ data, loading }) {
|
||||||
const pieOption = useMemo(() => ({
|
const pieOption = useMemo(() => ({
|
||||||
color: ['#5f8cff', '#e96fff', '#5fd8ae', '#b6a0ff'],
|
color: ['#5f8cff', '#e96fff', '#5fd8ae', '#b6a0ff'],
|
||||||
tooltip: { trigger: 'item', backgroundColor: 'rgba(3, 25, 66, .92)', borderColor: '#1aa7ff', textStyle: { color: '#dff7ff' } },
|
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 }],
|
series: [{ type: 'pie', radius: ['46%', '70%'], center: ['30%', '55%'], avoidLabelOverlap: true, label: { show: false }, data: data.riskPie }],
|
||||||
}), [data.riskPie]);
|
}), [data.riskPie]);
|
||||||
|
|
||||||
|
const riskSkeleton = Array.from({ length: 4 });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel title="风险预警智能研判" className="risk-panel">
|
<Panel title="风险预警智能研判" className="risk-panel">
|
||||||
<h3>资质备案类失信统计</h3>
|
<h3>资质备案类失信统计</h3>
|
||||||
<div className="risk-grid">
|
<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>
|
</div>
|
||||||
<h3>备案机构违法触发统计</h3>
|
<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>
|
</Panel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function KpiCards({ data }) {
|
// ==================== KPI 指标卡片 ====================
|
||||||
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>;
|
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 [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(() => {
|
const mapData = useMemo(() => {
|
||||||
return (chongqingGeoJson.features || []).map((feature, index) => {
|
return (chongqingGeoJson.features || []).map((feature) => {
|
||||||
const name = feature.properties && feature.properties.name;
|
const name = feature.properties && feature.properties.name;
|
||||||
const stat = statMap.get(name) || {
|
const stat = statMap.get(name);
|
||||||
|
return {
|
||||||
name,
|
name,
|
||||||
value: 18 + index * 7,
|
districtCode: (stat && stat.districtCode) || '',
|
||||||
projectCount: 18 + index * 7,
|
evalProjectCount: (stat && stat.evalProjectCount) || 0,
|
||||||
hiddenDanger: 0,
|
filingOrgCount: (stat && stat.filingOrgCount) || 0,
|
||||||
institutionCount: 0,
|
|
||||||
rectificationRate: '100%',
|
|
||||||
};
|
};
|
||||||
return { ...stat, name };
|
|
||||||
});
|
});
|
||||||
}, [statMap]);
|
}, [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 maxValue = Math.max(...values, 100);
|
||||||
|
|
||||||
const mapOption = useMemo(() => ({
|
const mapOption = useMemo(() => ({
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: 'item',
|
trigger: 'item',
|
||||||
|
confine: false,
|
||||||
backgroundColor: 'rgba(2, 31, 82, .96)',
|
backgroundColor: 'rgba(2, 31, 82, .96)',
|
||||||
borderColor: '#1aa7ff',
|
borderColor: '#1aa7ff',
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
|
|
@ -198,13 +331,15 @@ function MapSection({ data }) {
|
||||||
formatter: params => {
|
formatter: params => {
|
||||||
const item = params.data || statMap.get(params.name);
|
const item = params.data || statMap.get(params.name);
|
||||||
if (!item) return params.name;
|
if (!item) return params.name;
|
||||||
return [
|
// 随地图缩放比例同步缩放悬浮框字体
|
||||||
`<strong>${params.name}</strong>`,
|
const z = zoomRef.current || 1;
|
||||||
`评价项目数:${item.projectCount || item.value || 0}`,
|
const fs = Math.max(10, Math.round(12 * z));
|
||||||
`备案机构数:${item.institutionCount || 0}`,
|
const title = Math.round(13 * z);
|
||||||
`隐患数量:${item.hiddenDanger || 0}`,
|
return `<div style="font-size:${fs}px;line-height:1.7">
|
||||||
`整改率:${item.rectificationRate || '-'}`,
|
<strong style="font-size:${title}px">${params.name}</strong><br/>
|
||||||
].join('<br/>');
|
评价项目数:${item.evalProjectCount || 0}<br/>
|
||||||
|
备案机构数:${item.filingOrgCount || 0}
|
||||||
|
</div>`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
visualMap: {
|
visualMap: {
|
||||||
|
|
@ -247,7 +382,11 @@ function MapSection({ data }) {
|
||||||
map: 'chongqing',
|
map: 'chongqing',
|
||||||
geoIndex: 0,
|
geoIndex: 0,
|
||||||
selectedMode: 'single',
|
selectedMode: 'single',
|
||||||
data: mapData.map(item => ({ ...item, selected: item.name === selectedAreaName })),
|
data: mapData.map(item => ({
|
||||||
|
...item,
|
||||||
|
value: item.evalProjectCount,
|
||||||
|
selected: item.name === selectedAreaName,
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: '重点乡镇',
|
name: '重点乡镇',
|
||||||
|
|
@ -257,98 +396,323 @@ function MapSection({ data }) {
|
||||||
symbolSize: value => Math.max(8, Math.min(18, value[2] / 4)),
|
symbolSize: value => Math.max(8, Math.min(18, value[2] / 4)),
|
||||||
itemStyle: { color: '#12f4ff', shadowBlur: 10, shadowColor: '#12f4ff' },
|
itemStyle: { color: '#12f4ff', shadowBlur: 10, shadowColor: '#12f4ff' },
|
||||||
label: { show: false },
|
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(() => ({
|
const mapEvents = useMemo(() => ({
|
||||||
click: params => {
|
click: params => {
|
||||||
if (params && params.name) setSelectedAreaName(params.name);
|
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 (
|
return (
|
||||||
<div className="map-shell">
|
<div className="map-shell">
|
||||||
<EChart className="map-chart" option={mapOption} events={mapEvents} />
|
<EChart className="map-chart" option={mapOption} events={mapEvents} onInstance={handleMapInstance} />
|
||||||
{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>)}
|
{!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">
|
<div className="map-summary">
|
||||||
<strong>{selectedArea && selectedArea.name}</strong>
|
<strong>{selectedArea.name}</strong>
|
||||||
<span>项目数:{selectedArea && (selectedArea.projectCount || selectedArea.value)}</span>
|
{loading
|
||||||
<span>隐患:{selectedArea && selectedArea.hiddenDanger}</span>
|
? <><Skeleton width={70} height={14} /><Skeleton width={90} height={14} /></>
|
||||||
<span>整改率:{selectedArea && selectedArea.rectificationRate}</span>
|
: <>
|
||||||
|
<span>项目数:{selectedArea.evalProjectCount}</span>
|
||||||
|
<span>备案机构数:{selectedArea.filingOrgCount}</span>
|
||||||
|
</>}
|
||||||
</div>
|
</div>
|
||||||
<ul className="map-legend"><li>0-10</li><li>11-30</li><li>31-50</li><li>51-100</li></ul>
|
<ul className="map-legend"><li>0-10</li><li>11-30</li><li>31-50</li><li>51-100</li></ul>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
function TodoPanel({ data }) {
|
|
||||||
|
// ==================== 执业全过程管控 ====================
|
||||||
|
function TodoPanel({ data, loading }) {
|
||||||
|
const { processNodes } = data;
|
||||||
|
const skeletonNodes = Array.from({ length: 9 });
|
||||||
return (
|
return (
|
||||||
<Panel title="执业全过程管控" className="todo-panel">
|
<Panel title="执业全过程管控" className="todo-panel">
|
||||||
<div className="todo-grid">
|
<div className="todo-grid">
|
||||||
{data.todo.map((item, index) => {
|
{loading
|
||||||
|
? skeletonNodes.map((_, index) => {
|
||||||
const Icon = iconMap[index] || FileDoneOutlined;
|
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>;
|
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>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReviewLinePanel({ data }) {
|
// ==================== 评价类型趋势 ====================
|
||||||
const lineOption = useMemo(() => ({
|
const PERIOD_TABS = [
|
||||||
color: ['#0da5ff', '#29e487'],
|
{ 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 },
|
grid: { left: 34, right: 12, top: 28, bottom: 32 },
|
||||||
legend: { right: 0, top: 0, itemWidth: 14, itemHeight: 4, textStyle: { color: '#c6dcff', fontSize: 10 } },
|
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' } },
|
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 } },
|
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' } } },
|
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: [
|
series: seriesConfig,
|
||||||
{ 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 },
|
}, [trendBuckets, trendSeries]);
|
||||||
],
|
|
||||||
}), [data.reviewLine]);
|
return (
|
||||||
return <Panel title="评价类型趋势" className="line-panel"><div className="period-tabs"><span>年</span><span>季</span><span>月</span></div><EChart className="line-chart" option={lineOption} /></Panel>;
|
<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(() => ({
|
const pieOption = useMemo(() => ({
|
||||||
color: ['#1677ff', '#00c089', '#ff7a1a', '#f7cb16', '#ab5fde'],
|
color: ['#1677ff', '#00c089', '#ff7a1a', '#f7cb16', '#ab5fde'],
|
||||||
tooltip: { trigger: 'item', backgroundColor: 'rgba(3, 25, 66, .92)', borderColor: '#1aa7ff', textStyle: { color: '#dff7ff' } },
|
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 } },
|
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 }],
|
series: [{ type: 'pie', radius: ['32%', '68%'], center: ['28%', '56%'], label: { show: false }, data: reviewPieItems.map(item => ({ name: item.name, value: item.value })) }],
|
||||||
}), [data.reviewPie]);
|
}), [reviewPieItems]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel title="复盘评估改进提效" className="review-panel">
|
<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>
|
<div className="review-stats">
|
||||||
<EChart className="review-pie" option={pieOption} />
|
{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>
|
</Panel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProcessPanel({ data }) {
|
// ==================== 项目流程 ====================
|
||||||
|
function ProcessPanel({ data, loading }) {
|
||||||
|
const { processNodes } = data;
|
||||||
|
const skeletonNodes = Array.from({ length: 5 });
|
||||||
return (
|
return (
|
||||||
<Panel title="项目流程" className="process-panel">
|
<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>
|
</Panel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 主组件 ====================
|
||||||
export default function SupervisionCockpit() {
|
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 (
|
return (
|
||||||
<div className="cockpit-page">
|
<div className="cockpit-page">
|
||||||
<div className="cockpit-stage">
|
<div className="cockpit-stage">
|
||||||
<ScreenHeader data={data} />
|
<ScreenHeader data={data} />
|
||||||
<main className="cockpit-layout">
|
<main className="cockpit-layout">
|
||||||
<aside className="cockpit-left"><LifecyclePanel data={data} /><RiskPanel data={data} /></aside>
|
<aside className="cockpit-left">
|
||||||
<section className="cockpit-center"><KpiCards data={data} /><MapSection data={data} /><ProcessPanel data={data} /></section>
|
<LifecyclePanel data={data} loading={loading} />
|
||||||
<aside className="cockpit-right"><TodoPanel data={data} /><ReviewLinePanel data={data} /><ReviewProgressPanel data={data} /></aside>
|
<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>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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", ""),
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue