Files
lc_frontend/src/views/Home/Index10.vue
2025-11-29 15:34:30 +08:00

1873 lines
51 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="dashboard-container">
<!-- 顶部标题栏 -->
<div class="header-container">
<div class="header-left">
<div class="back-button" @click="openRegionSelector">
{{ currentView }}
<span>···</span>
</div>
</div>
<h1 class="header-title">集团视角数据看板</h1>
<div class="header-right">
<div class="date-range-wrapper">
<el-date-picker v-model="dateRange" type="daterange" range-separator="至" start-placeholder="开始日期"
end-placeholder="结束日期" format="YYYY-MM-DD" value-format="YYYY-MM-DD" @change="handleDateChange" />
</div>
<el-button type="primary" :icon="Refresh" @click="refreshData" class="refresh-btn">
刷新数据
</el-button>
</div>
</div>
<!-- 主内容区 - 6个卡片 -->
<div class="content-container">
<!-- 第一行外协管理风险管理隐患管理 -->
<div class="card-row">
<!-- 外协管理卡片 -->
<div class="dashboard-card">
<div class="card-header">
<div class="card-title">
<span class="card-icon">👥</span>
外协管理
</div>
<!-- <el-button type="text" class="manage-btn">管理</el-button> -->
</div>
<div class="card-content">
<div class="donut-chart-wrapper">
<Echart :options="outsourcingChartOption" width="100%" height="200px" />
</div>
<div class="region-distribution">
<div class="distribution-title">区域分布</div>
<div class="distribution-list">
<div class="distribution-item" v-for="item in outsourcingDistribution" :key="item.region">
<span class="dot" :style="{ backgroundColor: item.color }"></span>
<span class="region-name">{{ item.region }}</span>
<span class="region-count">{{ item.count }}</span>
<span class="region-percent">({{ item.percent }})</span>
</div>
</div>
</div>
</div>
</div>
<!-- 风险管理卡片 -->
<div class="dashboard-card">
<div class="card-header">
<div class="card-title">
<span class="card-icon">🛡</span>
风险管理
</div>
<!-- <el-button type="text" class="manage-btn">管理</el-button> -->
</div>
<div class="card-content">
<div class="donut-chart-wrapper">
<Echart :options="riskChartOption" width="100%" height="200px" />
</div>
<div class="risk-distribution">
<div class="distribution-title">风险等级分布</div>
<div class="distribution-list">
<div class="distribution-item" v-for="item in riskDistribution" :key="item.level">
<span class="dot" :style="{ backgroundColor: item.color }"></span>
<span class="region-name">{{ item.level }}</span>
<span class="region-count">{{ item.count }}</span>
<span class="region-percent">({{ item.percent }})</span>
</div>
</div>
</div>
</div>
</div>
<!-- 隐患管理卡片 -->
<div class="dashboard-card">
<div class="card-header">
<div class="card-title">
<span class="card-icon warning"></span>
隐患管理
</div>
<!-- <el-button type="text" class="manage-btn">管理</el-button> -->
</div>
<div class="card-content">
<div class="line-chart-wrapper">
<Echart :options="hiddenDangerChartOption" width="100%" height="180px" />
</div>
<div class="rectification-status">
<div class="distribution-title">整改状态</div>
<div class="status-list">
<div class="status-item" v-for="item in rectificationStatus" :key="item.status">
<span class="dot" :style="{ backgroundColor: item.color }"></span>
<span class="status-name">{{ item.status }}</span>
<span class="status-count">{{ item.count }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 第二行高危作业应急预案安全培训 -->
<div class="card-row">
<!-- 高危作业卡片 -->
<div class="dashboard-card">
<div class="card-header">
<div class="card-title">
<span class="card-icon">🚧</span>
高危作业
</div>
<!-- <el-button type="text" class="manage-btn">管理</el-button> -->
</div>
<div class="card-content">
<div class="high-risk-top">
<div class="donut-chart-wrapper-small">
<Echart :options="highRiskChartOption" width="100%" height="280px" />
</div>
<div class="operation-type-list">
<div class="operation-type-item" v-for="item in operationTypeDistribution" :key="item.type">
<span class="dot" :style="{ backgroundColor: item.color }"></span>
<span class="operation-name">{{ item.type }}</span>
<div class="operation-bar-wrapper">
<div class="operation-bar" :style="{ width: item.percent, backgroundColor: item.color }"></div>
</div>
<span class="operation-percent">{{ item.count }}</span>
</div>
</div>
</div>
<div class="park-operation-distribution">
<div class="distribution-title">区域作业分布</div>
<div class="distribution-list">
<div class="distribution-item" v-for="item in operationDistribution" :key="item.region">
<span class="region-name">{{ item.region }}</span>
<span class="region-count">{{ item.count }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 应急预案卡片 -->
<div class="dashboard-card">
<div class="card-header">
<div class="card-title">
<span class="card-icon">📄</span>
应急预案
</div>
<!-- <el-button type="text" class="manage-btn">管理</el-button> -->
</div>
<div class="card-content">
<div class="emergency-plan-top">
<div class="progress-chart-wrapper">
<Echart :options="emergencyPlanChartOption" width="100%" height="280px" />
</div>
<div class="drill-info">
<div class="drill-item">
<span>应完成演练</span>
<span class="drill-number">{{ emergencyPlanTotal }}</span>
</div>
<div class="drill-item">
<span>已完成演练</span>
<span class="drill-number">{{ emergencyPlanCompleted }}</span>
</div>
</div>
</div>
<div class="regional-progress">
<div class="distribution-title">区域演练完成率</div>
<div class="progress-list">
<div class="progress-item" v-for="item in regionalDrillProgress" :key="item.region">
<span class="region-name">{{ item.region }}</span>
<div class="progress-bar-wrapper">
<div class="progress-bar" :style="{ width: item.percent, backgroundColor: '#10b981' }"></div>
</div>
<span class="progress-percent">{{ item.percent }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 安全培训卡片 -->
<div class="dashboard-card">
<div class="card-header">
<div class="card-title">
<span class="card-icon">📚</span>
安全培训
</div>
<!-- <el-button type="text" class="manage-btn">管理</el-button> -->
</div>
<div class="card-content">
<div class="bar-chart-wrapper">
<Echart :options="safetyTrainingChartOption" width="100%" height="180px" />
</div>
<div class="regional-progress">
<div class="distribution-title">区域培训完成率</div>
<div class="progress-list">
<div class="progress-item" v-for="item in regionalTrainingProgress" :key="item.region">
<span class="region-name">{{ item.region }}</span>
<div class="progress-bar-wrapper">
<div class="progress-bar" :style="{ width: item.percent }"></div>
</div>
<span class="progress-percent">{{ item.percent }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 区域选择弹窗 -->
<RegionSelector v-model="regionSelectorVisible" :modelSelected="selectedRegion" :regions="regionOption"
@change="onRegionChange" />
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { Refresh } from '@element-plus/icons-vue'
import Echart from '@/components/Echart/src/Echart.vue'
import RegionSelector from '@/views/screen/components/RegionSelector.vue'
import { getTableList } from '@/api/design/report'
import type { EChartsOption } from 'echarts'
import dayjs from 'dayjs'
import {
getHiddenDangerManagementData,
getHiddenDangerManagementDataWeek,
getHiddenDangerManagementDataMonth,
getOutsourcingManagementData,
getRiskManagementData,
getHighRiskManagementData,
getEmergencyPlanManagementData,
getTrainingManagementData
} from '@/api'
defineOptions({ name: 'Home10' })
// 类型定义
interface RegionItem {
name: string
code: string
}
interface DistributionItem {
region?: string
level?: string
status?: string
count: number
percent?: string
color: string
}
const router = useRouter()
const route = useRoute()
// 区域选择相关
const currentView = ref('总部')
const regionSelectorVisible = ref(false)
const selectedRegion = ref('')
const regionOption = ref<RegionItem[]>([])
// 时间选择相关 - 默认当前月起止,如果路由中有日期范围参数则使用
const getCurrentMonthRange = () => {
const start = dayjs().startOf('month').format('YYYY-MM-DD')
const end = dayjs().endOf('month').format('YYYY-MM-DD')
return [start, end]
}
// 从路由参数读取日期范围,如果没有则使用默认值
const getInitialDateRange = () => {
const sDate = route.query.sDate as string
const eDate = route.query.eDate as string
if (sDate && eDate) {
return [sDate, eDate]
}
return getCurrentMonthRange()
}
const dateRange = ref(getInitialDateRange())
// 外协管理数据
const outsourcingTotal = ref<number>(0) // 外协人员总数
const outsourcingDistribution = ref<DistributionItem[]>([])
// 区域颜色配置
const regionColors = ['#3b82f6', '#8b5cf6', '#06b6d4', '#10b981', '#f59e0b', '#ef4444', '#ec4899']
// 初始化外协管理数据
const initOutsourcingData = async () => {
try {
const response = await getOutsourcingManagementData({
sDate: dateRange.value[0],
eDate: dateRange.value[1],
pageNo: 1,
pageSize: 10000
})
console.log('外协管理接口返回:', response)
// axios封装后response就是 { code: 0, data: {...}, msg: "" }
// 所以records在 response.data.records
const records = response?.records || []
if (records && records.length > 0) {
// 计算总人数从records中累加total
const total = records.reduce((sum: number, item: any) => {
return sum + Number(item.total || 0)
}, 0)
outsourcingTotal.value = total
// 处理区域分布数据
outsourcingDistribution.value = records.map((item: any, index: number) => {
const count = Number(item.total || 0)
const percent = total > 0 ? ((count / total) * 100).toFixed(1) + '%' : '0%'
const color = regionColors[index % regionColors.length]
return {
region: item.name,
count,
percent,
color
}
})
console.log('处理后的外协管理数据:', {
total: outsourcingTotal.value,
distribution: outsourcingDistribution.value
})
} else {
// 如果没有数据,重置为空
outsourcingTotal.value = 0
outsourcingDistribution.value = []
console.log('外协管理无数据')
}
} catch (error) {
console.error('获取外协管理数据失败:', error)
// 如果接口失败,重置为空
outsourcingTotal.value = 0
outsourcingDistribution.value = []
}
}
// 风险管理数据
const riskTotal = ref<number>(0) // 风险总数
const riskDistribution = ref<DistributionItem[]>([])
// 风险等级映射和颜色配置
const riskLevelMap: Record<string, { name: string; color: string }> = {
'低': { name: '低风险', color: '#10b981' },
'低风险': { name: '低风险', color: '#10b981' },
'一般': { name: '一般风险', color: '#f59e0b' },
'一般风险': { name: '一般风险', color: '#f59e0b' },
'较大': { name: '较大风险', color: '#ef4444' },
'较大风险': { name: '较大风险', color: '#ef4444' },
'重大': { name: '重大风险', color: '#dc2626' },
'重大风险': { name: '重大风险', color: '#dc2626' }
}
// 隐患管理数据
const hiddenDangerTrend = ref<Array<{ date: string; general: number; major: number }>>([])
const rectificationStatus = ref<DistributionItem[]>([
{ status: '已逾期', count: 0, color: '#ef4444' },
{ status: '处理中', count: 0, color: '#f59e0b' },
{ status: '已处理', count: 0, color: '#10b981' }
])
// 高危作业数据
const highRiskTotal = ref<number>(0) // 高危作业总数
const operationTypeDistribution = ref<Array<{ type: string; count: number; percent: string; color: string }>>([]) // 作业类型分布(用于环形图)
const operationDistribution = ref<Array<{ region: string; count: number }>>([]) // 区域作业分布
// 作业类型颜色配置
const operationTypeColors: Record<string, string> = {
'动火作业': '#f59e0b',
'高处作业': '#8b5cf6',
'临时用电': '#3b82f6',
'有限空间': '#ec4899',
'动土作业': '#10b981',
'吊装作业': '#ef4444'
}
// 应急预案数据
const emergencyPlanTotal = ref<number>(0) // 应完成演练总数
const emergencyPlanCompleted = ref<number>(0) // 已完成演练总数
const regionalDrillProgress = ref<Array<{ region: string; percent: string }>>([
{ region: '华北区域', percent: '85%' },
{ region: '华东区域', percent: '75%' },
{ region: '华南区域', percent: '70%' }
])
// 安全培训数据
const trainingBarData = ref<{ regions: string[]; trainingCount: number[]; participants: number[] }>({
regions: [],
trainingCount: [],
participants: []
})
const regionalTrainingProgress = ref<Array<{ region: string; percent: string }>>([])
// 外协管理环形图配置
const outsourcingChartOption = computed<EChartsOption>(() => {
// 准备图表数据
const chartData = outsourcingDistribution.value.map(item => ({
value: item.count,
name: item.region,
itemStyle: { color: item.color }
}))
// 如果没有数据,显示空状态
if (chartData.length === 0 || outsourcingTotal.value === 0) {
return {
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b}: {c} ({d}%)'
},
series: [
{
name: '外协人员',
type: 'pie',
radius: ['55%', '75%'],
center: ['50%', '50%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 0,
borderColor: 'transparent',
borderWidth: 0
},
label: {
show: true,
position: 'center',
formatter: () => {
return `${outsourcingTotal.value}\n外协人员总数`
},
fontSize: 16,
fontWeight: 'bold',
color: '#333'
},
data: [
{ value: 1, name: '暂无数据', itemStyle: { color: '#e5e7eb' } }
]
}
]
}
}
return {
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b}: {c} ({d}%)'
},
series: [
{
name: '外协人员',
type: 'pie',
radius: ['55%', '75%'],
center: ['50%', '50%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 0,
borderColor: 'transparent',
borderWidth: 0
},
label: {
show: true,
position: 'center',
formatter: () => {
return `${outsourcingTotal.value}\n外协人员总数`
},
fontSize: 16,
fontWeight: 'bold',
color: '#333'
},
emphasis: {
label: {
show: true,
fontSize: 18,
fontWeight: 'bold'
}
},
data: chartData
}
]
}
})
// 风险管理环形图配置
const riskChartOption = computed<EChartsOption>(() => {
const chartData = riskDistribution.value
.filter(item => item.count > 0) // 只显示有数据的风险等级
.map(item => ({
value: item.count,
name: item.level,
itemStyle: { color: item.color }
}))
// 如果没有数据,显示空状态
if (chartData.length === 0 || riskTotal.value === 0) {
return {
tooltip: { trigger: 'item', formatter: '{a} <br/>{b}: {c} ({d}%)' },
series: [{
name: '风险',
type: 'pie',
radius: ['55%', '75%'],
center: ['50%', '50%'],
avoidLabelOverlap: false,
itemStyle: { borderRadius: 0, borderColor: 'transparent', borderWidth: 0 },
label: {
show: true,
position: 'center',
formatter: () => `${riskTotal.value}\n风险总数`,
fontSize: 16,
fontWeight: 'bold',
color: '#333'
},
data: [{ value: 1, name: '暂无数据', itemStyle: { color: '#e5e7eb' } }]
}]
}
}
return {
tooltip: { trigger: 'item', formatter: '{a} <br/>{b}: {c} ({d}%)' },
series: [{
name: '风险',
type: 'pie',
radius: ['55%', '75%'],
center: ['50%', '50%'],
avoidLabelOverlap: false,
itemStyle: { borderRadius: 0, borderColor: 'transparent', borderWidth: 0 },
label: {
show: true,
position: 'center',
formatter: () => `${riskTotal.value}\n风险总数`,
fontSize: 16,
fontWeight: 'bold',
color: '#333'
},
emphasis: { label: { show: true, fontSize: 18, fontWeight: 'bold' } },
data: chartData
}]
}
})
// 隐患管理折线图配置
const hiddenDangerChartOption = computed<EChartsOption>(() => {
// 提取日期和对应的数据
const dates = hiddenDangerTrend.value.map(item => item.date)
const generalData = hiddenDangerTrend.value.map(item => item.general)
const majorData = hiddenDangerTrend.value.map(item => item.major)
// 计算Y轴最大值向上取整到最近的10的倍数
const maxValue = Math.max(
...generalData,
...majorData,
10 // 最小值为10避免图表显示过小
)
const yAxisMax = Math.ceil(maxValue / 10) * 10 || 10
return {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross'
}
},
legend: {
data: ['一般隐患', '重大隐患'],
top: 10,
textStyle: {
fontSize: 12
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
top: '25%',
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
data: dates.length > 0 ? dates : []
},
yAxis: {
type: 'value',
max: yAxisMax
},
series: [
{
name: '一般隐患',
type: 'line',
smooth: true,
data: generalData.length > 0 ? generalData : [],
itemStyle: { color: '#f59e0b' },
lineStyle: { color: '#f59e0b', width: 2 },
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(245, 158, 11, 0.3)' },
{ offset: 1, color: 'rgba(245, 158, 11, 0.05)' }
]
}
}
},
{
name: '重大隐患',
type: 'line',
smooth: true,
data: majorData.length > 0 ? majorData : [],
itemStyle: { color: '#ef4444' },
lineStyle: { color: '#ef4444', width: 2 },
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(239, 68, 68, 0.3)' },
{ offset: 1, color: 'rgba(239, 68, 68, 0.05)' }
]
}
}
}
]
}
})
// 高危作业环形图配置
const highRiskChartOption = computed<EChartsOption>(() => {
const chartData = operationTypeDistribution.value
.filter(item => item.count > 0) // 只显示有数据的作业类型
.map(item => ({
value: item.count,
name: item.type,
itemStyle: { color: item.color }
}))
// 如果没有数据,显示空状态
if (chartData.length === 0 || highRiskTotal.value === 0) {
return {
tooltip: { trigger: 'item', formatter: '{a} <br/>{b}: {c} ({d}%)' },
series: [{
name: '高危作业',
type: 'pie',
radius: ['45%', '65%'],
center: ['50%', '50%'],
avoidLabelOverlap: false,
itemStyle: { borderRadius: 0, borderColor: 'transparent', borderWidth: 0 },
label: {
show: true,
position: 'center',
formatter: () => `${highRiskTotal.value}\n累计作业`,
fontSize: 15,
fontWeight: 'bold',
color: '#333'
},
data: [{ value: 1, name: '暂无数据', itemStyle: { color: '#e5e7eb' } }]
}]
}
}
return {
tooltip: { trigger: 'item', formatter: '{a} <br/>{b}: {c} ({d}%)' },
series: [{
name: '高危作业',
type: 'pie',
radius: ['49%', '61%'],
center: ['50%', '50%'],
avoidLabelOverlap: false,
itemStyle: { borderRadius: 0, borderColor: 'transparent', borderWidth: 0 },
label: {
show: true,
position: 'center',
formatter: () => `${highRiskTotal.value}\n累计作业`,
fontSize: 15,
fontWeight: 'bold',
color: '#333'
},
emphasis: { label: { show: true, fontSize: 20, fontWeight: 'bold' } },
data: chartData
}]
}
})
// 应急预案环形进度图配置
const emergencyPlanChartOption = computed<EChartsOption>(() => {
const percent = emergencyPlanTotal.value > 0
? ((emergencyPlanCompleted.value / emergencyPlanTotal.value) * 100).toFixed(0)
: 0
return {
tooltip: { trigger: 'item' },
series: [{
name: '演练完成率',
type: 'pie',
radius: ['49%', '61%'],
center: ['50%', '50%'],
avoidLabelOverlap: false,
itemStyle: { borderRadius: 0, borderColor: 'transparent', borderWidth: 0, color: '#10b981' },
label: {
show: true,
position: 'center',
formatter: () => `${percent}%\n演练完成率`,
fontSize: 15,
fontWeight: 'bold',
color: '#333'
},
emphasis: { label: { show: true, fontSize: 20, fontWeight: 'bold' } },
data: [
{ value: emergencyPlanCompleted.value, name: '已完成', itemStyle: { color: '#10b981' } },
{ value: emergencyPlanTotal.value - emergencyPlanCompleted.value, name: '未完成', itemStyle: { color: '#e5e7eb' } }
]
}]
}
})
// 安全培训柱状图配置
const safetyTrainingChartOption = computed<EChartsOption>(() => {
const regions = trainingBarData.value.regions || []
const trainingCount = trainingBarData.value.trainingCount || []
const participants = trainingBarData.value.participants || []
// 计算堆叠后的最大值(培训次数 + 参与人次)
const stackedValues = trainingCount.map((count, index) => count + (participants[index] || 0))
const maxValue = Math.max(...stackedValues, 10) // 最小值为10避免图表显示过小
const yAxisMax = Math.ceil(maxValue / 10) * 10 || 10
return {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
},
formatter: (params: any) => {
let result = `${params[0].axisValue}<br/>`
params.forEach((item: any) => {
result += `${item.marker}${item.seriesName}: ${item.value}<br/>`
})
return result
}
},
legend: {
data: ['培训次数', '参与人次'],
top: 10,
textStyle: {
fontSize: 12,
color: '#666'
},
itemGap: 20
},
grid: {
left: '6%',
right: '4%',
bottom: '0',
top: '25%',
containLabel: true
},
xAxis: {
type: 'category',
data: regions.length > 0 ? regions : [],
axisLine: {
lineStyle: {
color: '#d1d5db'
}
},
axisLabel: {
color: '#666',
fontSize: 12
}
},
yAxis: {
type: 'value',
max: yAxisMax,
axisLine: {
show: false
},
axisTick: {
show: false
},
axisLabel: {
color: '#666',
fontSize: 12
},
splitLine: {
lineStyle: {
color: '#f3f4f6',
type: 'dashed'
}
}
},
series: [
{
name: '培训次数',
type: 'bar',
stack: 'total',
data: trainingCount.length > 0 ? trainingCount : [],
barWidth: 32,
itemStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: '#3b82f6' },
{ offset: 1, color: '#2563eb' }
]
},
borderRadius: [0, 0, 0, 0]
},
label: {
show: false
},
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowColor: 'rgba(59, 130, 246, 0.5)'
}
}
},
{
name: '参与人次',
type: 'bar',
stack: 'total',
data: participants.length > 0 ? participants : [],
barWidth: 32,
itemStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: '#84cc16' },
{ offset: 1, color: '#65a30d' }
]
},
borderRadius: [6, 6, 0, 0]
},
label: {
show: false
},
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowColor: 'rgba(132, 204, 22, 0.5)'
}
}
}
]
}
})
// 方法定义
const openRegionSelector = (): void => {
regionSelectorVisible.value = true
}
const onRegionChange = (item: RegionItem): void => {
selectedRegion.value = item.name
router.push({
path: '/region',
query: {
region: item.name,
regionCode: item.code,
sDate: dateRange.value[0],
eDate: dateRange.value[1]
}
})
}
const handleDateChange = async (value: string[] | null) => {
if (value) {
console.log('日期范围变化:', value)
await refreshData()
}
}
const refreshData = async () => {
initData()
}
// 初始化区域数据
const initRegionData = async () => {
try {
const { records } = await getTableList('park_info_list')
if (records && records.length > 0) {
// 去重region字段使用Map来确保唯一性
const regionMap = new Map<string, RegionItem>()
records.forEach((el: any) => {
if (!regionMap.has(el.region)) {
regionMap.set(el.region, {
name: el.region,
code: el.region_id
})
}
})
// 转换为数组
regionOption.value = Array.from(regionMap.values())
}
} catch (error) {
console.error('初始化区域数据失败:', error)
}
}
// 初始化风险管理数据
const initRiskData = async () => {
try {
const response = await getRiskManagementData({
sDate: dateRange.value[0],
eDate: dateRange.value[1],
pageNo: 1,
pageSize: 10000
})
console.log('风险管理接口返回:', response)
const records = response?.records || []
if (records && records.length > 0) {
// 计算风险总数
const total = records.reduce((sum: number, item: any) => {
return sum + Number(item.total || 0)
}, 0)
riskTotal.value = total
// 处理风险等级分布数据
riskDistribution.value = records.map((item: any) => {
const count = Number(item.total || 0)
const percent = total > 0 ? ((count / total) * 100).toFixed(1) + '%' : '0%'
// 映射风险等级名称和颜色
const levelKey = item.name || ''
const levelConfig = riskLevelMap[levelKey] || { name: levelKey || '未知', color: '#9ca3af' }
return {
level: levelConfig.name,
count,
percent,
color: levelConfig.color
}
})
// 如果没有数据确保包含所有四个风险等级显示为0
const allLevels = [
{ level: '低风险', count: 0, percent: '0%', color: '#10b981' },
{ level: '一般风险', count: 0, percent: '0%', color: '#f59e0b' },
{ level: '较大风险', count: 0, percent: '0%', color: '#ef4444' },
{ level: '重大风险', count: 0, percent: '0%', color: '#dc2626' }
]
// 合并数据如果某个等级有数据就使用数据没有就保留0
const mergedDistribution = allLevels.map(defaultItem => {
const existingItem = riskDistribution.value.find(item => item.level === defaultItem.level)
return existingItem || defaultItem
})
riskDistribution.value = mergedDistribution
console.log('处理后的风险管理数据:', {
total: riskTotal.value,
distribution: riskDistribution.value
})
} else {
// 如果没有数据,设置为默认值
riskTotal.value = 0
riskDistribution.value = [
{ level: '低风险', count: 0, percent: '0%', color: '#10b981' },
{ level: '一般风险', count: 0, percent: '0%', color: '#f59e0b' },
{ level: '较大风险', count: 0, percent: '0%', color: '#ef4444' },
{ level: '重大风险', count: 0, percent: '0%', color: '#dc2626' }
]
console.log('风险管理无数据')
}
} catch (error) {
console.error('获取风险管理数据失败:', error)
// 如果接口失败,设置为默认值
riskTotal.value = 0
riskDistribution.value = [
{ level: '低风险', count: 0, percent: '0%', color: '#10b981' },
{ level: '一般风险', count: 0, percent: '0%', color: '#f59e0b' },
{ level: '较大风险', count: 0, percent: '0%', color: '#ef4444' },
{ level: '重大风险', count: 0, percent: '0%', color: '#dc2626' }
]
}
}
// 根据日期范围判断应该调用哪个接口
const getHiddenDangerApi = (startDate: string, endDate: string) => {
const start = dayjs(startDate)
const end = dayjs(endDate)
const daysDiff = end.diff(start, 'day') + 1 // 包含起始和结束日期
// 如果日期范围 <= 7天使用"天"接口
if (daysDiff <= 7) {
return getHiddenDangerManagementData
}
// 如果日期范围 <= 30天使用"周"接口
else if (daysDiff <= 30) {
return getHiddenDangerManagementDataWeek
}
// 如果日期范围 > 30天使用"月"接口
else {
return getHiddenDangerManagementDataMonth
}
}
const initDangerData = async () => {
try {
// 根据日期范围选择接口
const apiFunc = getHiddenDangerApi(dateRange.value[0], dateRange.value[1])
const response = await apiFunc({
sDate: dateRange.value[0],
eDate: dateRange.value[1],
pageNo: 1,
pageSize: 10000
})
console.log('隐患管理接口返回:', response)
const records = response?.records || []
if (records && records.length > 0) {
// 按日期和等级分组统计,用于折线图
const trendMap = new Map<string, { general: number; major: number }>()
// 按状态统计,用于整改状态列表
const statusMap = new Map<string, number>()
records.forEach((item: any) => {
const dayname = item.dayname || ''
const level = item.name || ''
const status = item.status || ''
const count = Number(item.total || 0)
// 统计趋势数据(按日期和等级)
if (dayname) {
if (!trendMap.has(dayname)) {
trendMap.set(dayname, { general: 0, major: 0 })
}
const trend = trendMap.get(dayname)!
if (level === '一般' || level === '一般隐患') {
trend.general += count
} else if (level === '重大' || level === '重大隐患') {
trend.major += count
}
}
// 统计状态数据
if (status) {
statusMap.set(status, (statusMap.get(status) || 0) + count)
}
})
// 转换为数组并按日期排序
hiddenDangerTrend.value = Array.from(trendMap.entries())
.map(([date, counts]) => ({ date, ...counts }))
.sort((a, b) => {
// 提取日期中的数字部分进行排序
const numA = parseInt(a.date.replace('日', '')) || 0
const numB = parseInt(b.date.replace('日', '')) || 0
return numA - numB
})
// 更新整改状态数据
rectificationStatus.value = [
{
status: '已逾期',
count: statusMap.get('已逾期') || 0,
color: '#ef4444'
},
{
status: '处理中',
count: statusMap.get('处理中') || 0,
color: '#f59e0b'
},
{
status: '已处理',
count: statusMap.get('已处理') || 0,
color: '#10b981'
}
]
console.log('处理后的隐患管理数据:', {
trend: hiddenDangerTrend.value,
status: rectificationStatus.value
})
} else {
// 如果没有数据,重置为空
hiddenDangerTrend.value = []
rectificationStatus.value = [
{ status: '已逾期', count: 0, color: '#ef4444' },
{ status: '处理中', count: 0, color: '#f59e0b' },
{ status: '已处理', count: 0, color: '#10b981' }
]
console.log('隐患管理无数据')
}
} catch (error) {
console.error('获取隐患管理数据失败:', error)
// 如果接口失败,重置为空
hiddenDangerTrend.value = []
rectificationStatus.value = [
{ status: '已逾期', count: 0, color: '#ef4444' },
{ status: '处理中', count: 0, color: '#f59e0b' },
{ status: '已处理', count: 0, color: '#10b981' }
]
}
}
const initHighRiskData = async () => {
try {
const response = await getHighRiskManagementData({
sDate: dateRange.value[0],
eDate: dateRange.value[1],
pageNo: 1,
pageSize: 10000
})
console.log('高危作业接口返回:', response)
const records = response?.records || []
if (records && records.length > 0) {
// 按作业类型分组统计,用于环形图
const typeMap = new Map<string, number>()
// 按区域分组统计,用于区域分布列表
const areaMap = new Map<string, number>()
records.forEach((item: any) => {
const itemType = item.item || ''
const area = item.area || ''
const count = Number(item.total || 0)
// 统计作业类型
if (itemType) {
typeMap.set(itemType, (typeMap.get(itemType) || 0) + count)
}
// 统计区域分布
if (area) {
areaMap.set(area, (areaMap.get(area) || 0) + count)
}
})
// 计算总数
const total = Array.from(typeMap.values()).reduce((sum, count) => sum + count, 0)
highRiskTotal.value = total
// 处理作业类型分布数据
operationTypeDistribution.value = Array.from(typeMap.entries())
.map(([type, count]) => ({
type,
count,
percent: total > 0 ? ((count / total) * 100).toFixed(1) + '%' : '0%',
color: operationTypeColors[type] || '#9ca3af' // 如果没有配置颜色,使用灰色
}))
.sort((a, b) => b.count - a.count) // 按数量降序排序
// 处理区域分布数据
operationDistribution.value = Array.from(areaMap.entries())
.map(([region, count]) => ({
region,
count
}))
.sort((a, b) => b.count - a.count) // 按数量降序排序
console.log('处理后的高危作业数据:', {
total: highRiskTotal.value,
typeDistribution: operationTypeDistribution.value,
areaDistribution: operationDistribution.value
})
} else {
// 如果没有数据,重置为空
highRiskTotal.value = 0
operationTypeDistribution.value = []
operationDistribution.value = []
console.log('高危作业无数据')
}
} catch (error) {
console.error('获取高危作业数据失败:', error)
// 如果接口失败,重置为空
highRiskTotal.value = 0
operationTypeDistribution.value = []
operationDistribution.value = []
}
}
const initEmergencyPlanData = async () => {
try {
const response = await getEmergencyPlanManagementData({
sDate: dateRange.value[0],
eDate: dateRange.value[1],
pageNo: 1,
pageSize: 10000
})
console.log('应急预案接口返回:', response)
const records = response?.records || []
if (records && records.length > 0) {
// 统计应完成演练总数所有记录的total总和
const total = records.reduce((sum: number, item: any) => {
return sum + Number(item.total || 0)
}, 0)
emergencyPlanTotal.value = total
// 统计已完成演练数(根据状态判断)
// 可能的完成状态:已完成、已完成演练、已执行等
const completedCount = records.reduce((sum: number, item: any) => {
const status = item.status || ''
const count = Number(item.total || 0)
// 判断是否已完成:包含"完成"或"已执行"的状态
if (status.includes('完成') || status.includes('已执行')) {
return sum + count
}
return sum
}, 0)
emergencyPlanCompleted.value = completedCount
// 按区域统计演练完成率
const areaMap = new Map<string, { total: number; completed: number }>()
records.forEach((item: any) => {
const area = item.area || ''
const count = Number(item.total || 0)
const status = item.status || ''
const isCompleted = status.includes('完成') || status.includes('已执行')
if (area) {
if (!areaMap.has(area)) {
areaMap.set(area, { total: 0, completed: 0 })
}
const areaData = areaMap.get(area)!
areaData.total += count
if (isCompleted) {
areaData.completed += count
}
}
})
// 转换为数组并计算完成率
regionalDrillProgress.value = Array.from(areaMap.entries())
.map(([region, data]) => {
const percent = data.total > 0
? ((data.completed / data.total) * 100).toFixed(0) + '%'
: '0%'
return { region, percent }
})
.sort((a, b) => {
// 按完成率降序排序
const percentA = parseFloat(a.percent)
const percentB = parseFloat(b.percent)
return percentB - percentA
})
console.log('处理后的应急预案数据:', {
total: emergencyPlanTotal.value,
completed: emergencyPlanCompleted.value,
regionalProgress: regionalDrillProgress.value
})
} else {
// 如果没有数据,重置为空
emergencyPlanTotal.value = 0
emergencyPlanCompleted.value = 0
regionalDrillProgress.value = []
console.log('应急预案无数据')
}
} catch (error) {
console.error('获取应急预案数据失败:', error)
// 如果接口失败,重置为空
emergencyPlanTotal.value = 0
emergencyPlanCompleted.value = 0
regionalDrillProgress.value = []
}
}
const initTrainingData = async () => {
try {
const response = await getTrainingManagementData({
sDate: dateRange.value[0],
eDate: dateRange.value[1],
pageNo: 1,
pageSize: 10000
})
console.log('安全培训接口返回:', response)
const records = response?.records || []
if (records && records.length > 0) {
// 按区域分组统计
const areaMap = new Map<string, { trainingCount: number; participants: number }>()
records.forEach((item: any) => {
// 只统计有区域字段的记录
if (!item.area) {
return
}
const area = item.area
const trainingCount = Number(item.plannum || 0) // 计划数量作为培训次数
const participants = Number(item.exenum || 0) // 执行数量作为参与人次
if (!areaMap.has(area)) {
areaMap.set(area, { trainingCount: 0, participants: 0 })
}
const areaData = areaMap.get(area)!
areaData.trainingCount += trainingCount
areaData.participants += participants
})
// 转换为数组并排序
const areaDataArray = Array.from(areaMap.entries())
.map(([region, data]) => ({
region,
...data,
// 计算完成率(参与人次 / 培训次数 * 100%
percent: data.trainingCount > 0
? ((data.participants / data.trainingCount) * 100).toFixed(0) + '%'
: '0%'
}))
.sort((a, b) => {
// 按培训次数降序排序
return b.trainingCount - a.trainingCount
})
// 更新柱状图数据
trainingBarData.value = {
regions: areaDataArray.map(item => item.region),
trainingCount: areaDataArray.map(item => item.trainingCount),
participants: areaDataArray.map(item => item.participants)
}
// 更新区域培训完成率数据
regionalTrainingProgress.value = areaDataArray.map(item => ({
region: item.region,
percent: item.percent
}))
console.log('处理后的安全培训数据:', {
barData: trainingBarData.value,
regionalProgress: regionalTrainingProgress.value
})
} else {
// 如果没有数据,重置为空
trainingBarData.value = {
regions: [],
trainingCount: [],
participants: []
}
regionalTrainingProgress.value = []
console.log('安全培训无数据')
}
} catch (error) {
console.error('获取安全培训数据失败:', error)
// 如果接口失败,重置为空
trainingBarData.value = {
regions: [],
trainingCount: [],
participants: []
}
regionalTrainingProgress.value = []
}
}
const initData = async () => {
await initRegionData()
initOutsourcingData()
initRiskData()
initDangerData()
initHighRiskData()
initEmergencyPlanData()
initTrainingData()
}
onMounted(async () => {
initData()
})
</script>
<style lang="scss" scoped>
.dashboard-container {
width: 100%;
min-height: 100vh;
background: #f5f7fa;
padding: 20px;
box-sizing: border-box;
// 确保内容不会贴边
max-width: 100%;
overflow-x: hidden;
}
.header-container {
display: flex;
align-items: center;
justify-content: space-between;
background: white;
padding: 16px 24px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
.header-left {
// flex: 1;
display: flex;
align-items: center;
.back-button {
display: inline-flex;
align-items: center;
padding: 8px 16px;
background: #f3f4f6;
border: 1px solid #e5e7eb;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s;
font-size: 14px;
color: #333;
user-select: none;
span {
margin-left: 8px;
font-size: 18px;
color: #6b7280;
}
&:hover {
background: #e5e7eb;
border-color: #d1d5db;
}
&:active {
transform: scale(0.98);
}
}
}
.header-title {
flex: 1;
text-align: center;
font-size: 20px;
font-weight: bold;
color: #1f2937;
margin: 0;
}
.header-right {
flex: 1;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 16px;
min-width: 0; // 防止flex子元素溢出
.date-range-wrapper {
:deep(.el-date-editor) {
width: 280px;
max-width: 100%;
}
}
.refresh-btn {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0; // 防止按钮被压缩
}
}
}
.content-container {
display: flex;
flex-direction: column;
gap: 20px;
width: 100%;
box-sizing: border-box;
}
.card-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
width: 100%;
box-sizing: border-box;
@media (max-width: 1400px) {
grid-template-columns: repeat(2, 1fr);
}
@media (max-width: 768px) {
grid-template-columns: 1fr;
gap: 16px;
}
}
@media (max-width: 1200px) {
.high-risk-top,
.emergency-plan-top {
gap: 10px;
}
.donut-chart-wrapper-small,
.progress-chart-wrapper {
min-width: 160px;
}
.operation-type-list,
.drill-info {
min-width: 140px;
max-width: 180px;
}
}
@media (max-width: 768px) {
.high-risk-top,
.emergency-plan-top {
flex-direction: column;
align-items: stretch;
}
.donut-chart-wrapper-small,
.progress-chart-wrapper {
min-width: 100%;
max-width: 100%;
height: 220px;
min-height: 220px;
}
.operation-type-list,
.drill-info {
min-width: 100%;
max-width: 100%;
}
}
.dashboard-card {
background: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
transition: box-shadow 0.3s;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #e5e7eb;
.card-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: bold;
color: #1f2937;
.card-icon {
font-size: 18px;
&.warning {
color: #ef4444;
}
}
}
.manage-btn {
color: #3b82f6;
padding: 4px 8px;
font-size: 14px;
transition: color 0.3s;
&:hover {
color: #2563eb;
}
}
}
.card-content {
flex: 1;
display: flex;
flex-direction: column;
}
}
.donut-chart-wrapper,
.donut-chart-wrapper-small {
height: 280px;
min-height: 280px;
}
.line-chart-wrapper,
.bar-chart-wrapper {
height: 200px;
background: linear-gradient(180deg, #faf9ff 0%, #ffffff 100%);
border-radius: 8px;
padding: 8px;
box-sizing: border-box;
}
.progress-chart-wrapper {
height: 280px;
min-height: 280px;
}
.high-risk-top {
display: flex;
align-items: center;
gap: 15px;
margin-bottom: 15px;
}
.donut-chart-wrapper-small {
flex: 0.8;
min-width: 150px;
max-width: 45%;
display: flex;
align-items: center;
}
.operation-type-list {
flex: 0.7;
display: flex;
flex-direction: column;
gap: 8px;
min-width: 140px;
max-width: 170px;
justify-content: center;
flex-shrink: 0;
}
.operation-type-item {
display: flex;
align-items: center;
gap: 8px;
}
.operation-name {
width: 55px;
font-size: 12px;
color: #666;
flex-shrink: 0;
}
.operation-bar-wrapper {
flex: 1;
height: 12px;
background-color: #e5e7eb;
border-radius: 6px;
overflow: hidden;
}
.operation-bar {
height: 100%;
border-radius: 6px;
}
.operation-percent {
font-size: 12px;
color: #666;
font-weight: 500;
min-width: 40px;
text-align: right;
flex-shrink: 0;
}
.emergency-plan-top {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 15px;
}
.progress-chart-wrapper {
flex: 0.8;
min-width: 150px;
max-width: 45%;
display: flex;
align-items: center;
}
.region-distribution,
.risk-distribution,
.rectification-status,
.operation-distribution,
.park-operation-distribution,
.regional-progress {
margin-top: 10px;
.distribution-title {
font-size: 14px;
font-weight: 600;
color: #374151;
margin-bottom: 12px;
}
.distribution-list,
.status-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.distribution-item,
.status-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #6b7280;
padding: 4px 0;
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.region-name,
.status-name {
flex: 1;
color: #374151;
}
.region-count,
.status-count {
color: #1f2937;
font-weight: 500;
margin-left: auto;
}
.region-percent {
color: #9ca3af;
margin-left: 4px;
}
}
}
.drill-info {
display: flex;
flex-direction: column;
gap: 8px;
flex: 0.7;
min-width: 140px;
max-width: 170px;
justify-content: center;
flex-shrink: 0;
}
.drill-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 10px;
background-color: #f0fdf4;
border-radius: 4px;
font-size: 12px;
color: #666;
}
.drill-number {
font-size: 16px;
font-weight: bold;
color: #10b981;
}
.progress-list {
display: flex;
flex-direction: column;
gap: 14px;
margin-top: 12px;
}
.progress-item {
display: flex;
align-items: center;
gap: 12px;
font-size: 13px;
padding: 4px 0;
.region-name {
width: 90px;
color: #374151;
flex-shrink: 0;
font-weight: 500;
}
.progress-bar-wrapper {
flex: 1;
height: 10px;
background: #f3f4f6;
border-radius: 5px;
overflow: hidden;
position: relative;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.05);
}
.progress-bar {
height: 100%;
border-radius: 5px;
transition: width 0.5s ease;
background: linear-gradient(90deg, #8b5cf6 0%, #a78bfa 100%);
box-shadow: 0 2px 4px rgba(139, 92, 246, 0.3);
position: relative;
overflow: hidden;
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.3) 50%,
transparent 100%
);
animation: shimmer 2s infinite;
}
}
.progress-percent {
min-width: 45px;
text-align: right;
color: #7c3aed;
font-weight: 600;
flex-shrink: 0;
font-size: 13px;
}
}
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
</style>