Files
lc_frontend/src/views/Home/Index12.vue
2025-12-03 10:23:44 +08:00

1929 lines
52 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">
<el-icon class="back-arrow" @click="returnToHeadquarters">
<ArrowLeft />
</el-icon>
<div class="region-name-clickable" @click="openParkSelector">
{{ selectedRegion }}
<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-table">
<div class="distribution-title">园区风险分布</div>
<div class="table-wrapper">
<table class="risk-table">
<thead>
<tr>
<th>园区</th>
<th></th>
<th>一般</th>
<th>较大</th>
<th>重大</th>
</tr>
</thead>
<tbody>
<tr v-for="item in parkRiskDistribution" :key="item.park">
<td>{{ item.park }}</td>
<td>{{ item.low || '' }}</td>
<td>{{ item.general }}</td>
<td>{{ item.moderate }}</td>
<td>{{ item.major }}</td>
</tr>
</tbody>
</table>
</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-grid">
<div class="distribution-title">园区整改状态</div>
<div class="grid-wrapper">
<!-- 第一列园区名称 -->
<div class="grid-column">
<div class="grid-header empty-header"></div>
<div class="grid-park-name" v-for="item in parkRectificationStatus" :key="'park-' + item.park">
{{ item.park }}
</div>
</div>
<!-- 已逾期列 -->
<div class="grid-column">
<div class="grid-header status-overdue">已逾期</div>
<div class="grid-number status-overdue" v-for="item in parkRectificationStatus" :key="'overdue-' + item.park">
{{ item.overdue }}
</div>
</div>
<!-- 处理中列 -->
<div class="grid-column">
<div class="grid-header status-processing">处理中</div>
<div class="grid-number status-processing" v-for="item in parkRectificationStatus" :key="'processing-' + item.park">
{{ item.processing }}
</div>
</div>
<!-- 已处理列 -->
<div class="grid-column">
<div class="grid-header status-processed">已处理</div>
<div class="grid-number status-processed" v-for="item in parkRectificationStatus" :key="'processed-' + item.park">
{{ item.processed }}
</div>
</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 parkOperationDistribution" :key="item.park">
<span class="region-name">{{ item.park }}</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 parkDrillProgress" :key="item.park">
<span class="region-name">{{ item.park }}</span>
<div class="progress-bar-wrapper">
<div class="progress-bar" :style="{ width: item.percent, background: 'linear-gradient(90deg, #10b981 0%, #34d399 100%)' }"></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 parkTrainingProgress" :key="item.park">
<span class="region-name">{{ item.park }}</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="parkSelectorVisible" :modelSelected="selectedPark" :regions="parkOption"
@change="onParkChange" />
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { Refresh, ArrowLeft } from '@element-plus/icons-vue'
import Echart from '@/components/Echart/src/Echart.vue'
import type { EChartsOption } from 'echarts'
import dayjs from 'dayjs'
import {
getOutsourcingManagementDataRegion,
getHighRiskManagementDataRegion,
getEmergencyPlanManagementDataRegion,
getTrainingManagementDataRegion,
getRiskManagementDataRegion,
getHiddenDangerManagementDataRegion,
getHiddenDangerManagementDataRegionWeek,
getHiddenDangerManagementDataRegionMonth
} from '@/api'
import RegionSelector from '@/views/screen/components/RegionSelector.vue'
import { getTableList } from '@/api/design/report'
defineOptions({ name: 'Home12' })
// 类型定义
interface ParkItem {
name: string
code: string
}
interface DistributionItem {
region?: string
park?: string
level?: string
type?: string
count: number
percent?: string
color: string
}
interface ParkRiskItem {
park: string
low: number | string
general: number
moderate: number
major: number
}
interface ParkRectificationItem {
park: string
overdue: number
processing: number
processed: number
}
const router = useRouter()
const route = useRoute()
// 区域和园区选择相关
const selectedRegion = ref<string>('')
const selectedPark = ref<string>('')
const parkSelectorVisible = ref<boolean>(false)
const parkOption = ref<ParkItem[]>([])
// 时间选择相关 - 默认当前月起止,如果路由中有日期范围参数则使用
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 riskTotal = ref<number>(0)
const riskDistribution = ref<DistributionItem[]>([])
const parkRiskDistribution = ref<ParkRiskItem[]>([])
// 隐患管理数据
const hiddenDangerTrend = ref<any[]>([])
const parkRectificationStatus = ref<ParkRectificationItem[]>([])
// 高危作业数据
const highRiskTotal = ref<number>(0)
const operationTypeDistribution = ref<DistributionItem[]>([])
const parkOperationDistribution = ref<DistributionItem[]>([])
// 应急预案数据
const emergencyPlanTotal = ref<number>(0)
const emergencyPlanCompleted = ref<number>(0)
const parkDrillProgress = ref<DistributionItem[]>([])
// 安全培训数据
const trainingBarData = ref<{ regions: string[]; trainingCount: number[]; participants: number[] }>({
regions: [],
trainingCount: [],
participants: []
})
const parkTrainingProgress = ref<DistributionItem[]>([])
// 区域颜色配置
const regionColors = ['#3b82f6', '#8b5cf6', '#06b6d4', '#10b981', '#f59e0b', '#ef4444', '#ec4899']
// 作业类型颜色配置
const operationTypeColors: Record<string, string> = {
'动火作业': '#f59e0b',
'高处作业': '#8b5cf6',
'临时用电': '#3b82f6',
'有限空间': '#ec4899',
'动土作业': '#10b981',
'吊装作业': '#ef4444'
}
// 初始化区域数据 - 加载园区列表
const initRegionData = async () => {
try {
if (typeof route.query.region === 'string') {
selectedRegion.value = route.query.region
}
// 加载园区列表
const { records } = await getTableList('park_info_list')
if (records && records.length > 0) {
// 根据regionCode过滤园区
const regionCode = route.query.regionCode as string
const parkMap = new Map<string, ParkItem>()
records
.filter((el: any) => el.region_id == regionCode)
.forEach((el: any) => {
if (!parkMap.has(el.park_name)) {
parkMap.set(el.park_name, {
name: el.park_name,
code: el.park_code
})
}
})
parkOption.value = Array.from(parkMap.values())
}
} catch (error) {
console.error('初始化区域数据失败:', error)
}
}
// 返回总部页面
const returnToHeadquarters = () => {
router.push({
path: '/index',
query: {
sDate: dateRange.value[0],
eDate: dateRange.value[1]
}
})
}
// 打开园区选择器
const openParkSelector = (): void => {
parkSelectorVisible.value = true
}
// 园区选择变化 - 跳转到园区页面
const onParkChange = (item: ParkItem): void => {
selectedPark.value = item.name
parkSelectorVisible.value = false
router.push({
path: '/park',
query: {
region: selectedRegion.value,
regionCode: route.query.regionCode as string,
park: item.name,
parkCode: item.code,
sDate: dateRange.value[0],
eDate: dateRange.value[1]
}
})
}
// 日期变化
const handleDateChange = () => {
refreshData()
}
// 刷新数据
const refreshData = () => {
initData()
}
// 初始化外协管理数据
const initOutsourcingData = async () => {
try {
const response = await getOutsourcingManagementDataRegion({
sDate: dateRange.value[0],
eDate: dateRange.value[1],
regiodId: route.query.regionCode as string,
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)
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 initRiskData = async () => {
try {
const response = await getRiskManagementDataRegion({
sDate: dateRange.value[0],
eDate: dateRange.value[1],
regiodId: route.query.regionCode as string,
pageNo: 1,
pageSize: 10000
})
console.log('区域风险管理接口返回:', response)
const records = response?.records || []
if (records && records.length > 0) {
// 按风险等级分组统计,用于环形图
const levelMap = new Map<string, number>()
// 按园区和风险等级分组统计,用于表格
const parkLevelMap = new Map<string, { low: number; general: number; moderate: number; major: number }>()
records.forEach((item: any) => {
const level = item.name || ''
const park = item.area || ''
const count = Number(item.total || 0)
// 统计风险等级分布
if (level) {
levelMap.set(level, (levelMap.get(level) || 0) + count)
}
// 统计园区风险分布
if (park) {
if (!parkLevelMap.has(park)) {
parkLevelMap.set(park, { low: 0, general: 0, moderate: 0, major: 0 })
}
const parkData = parkLevelMap.get(park)!
if (level === '低' || level === '低风险') {
parkData.low += count
} else if (level === '一般' || level === '一般风险') {
parkData.general += count
} else if (level === '较大' || level === '较大风险') {
parkData.moderate += count
} else if (level === '重大' || level === '重大风险') {
parkData.major += count
}
}
})
// 计算总数
const total = Array.from(levelMap.values()).reduce((sum, count) => sum + count, 0)
riskTotal.value = total
// 处理风险等级分布数据(用于环形图)
const allLevels = [
{ key: '低', level: '低风险', count: 0, percent: '0%', color: '#10b981' },
{ key: '一般', level: '一般风险', count: 0, percent: '0%', color: '#f59e0b' },
{ key: '较大', level: '较大风险', count: 0, percent: '0%', color: '#ef4444' },
{ key: '重大', level: '重大风险', count: 0, percent: '0%', color: '#dc2626' }
]
riskDistribution.value = allLevels.map(defaultItem => {
const count = levelMap.get(defaultItem.key) || levelMap.get(defaultItem.level) || 0
const percent = total > 0 ? ((count / total) * 100).toFixed(1) + '%' : '0%'
return {
level: defaultItem.level,
count,
percent,
color: defaultItem.color
}
})
// 处理园区风险分布表
parkRiskDistribution.value = Array.from(parkLevelMap.entries())
.map(([park, data]) => ({
park,
low: data.low > 0 ? data.low : '',
general: data.general,
moderate: data.moderate,
major: data.major
}))
.sort((a, b) => {
// 按总数降序排序
const totalA = (typeof a.low === 'number' ? a.low : 0) + a.general + a.moderate + a.major
const totalB = (typeof b.low === 'number' ? b.low : 0) + b.general + b.moderate + b.major
return totalB - totalA
})
console.log('处理后的区域风险管理数据:', {
total: riskTotal.value,
levelDistribution: riskDistribution.value,
parkDistribution: parkRiskDistribution.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' }
]
parkRiskDistribution.value = []
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' }
]
parkRiskDistribution.value = []
}
}
// 根据日期范围选择隐患管理接口
const getHiddenDangerApiRegion = (startDate: string, endDate: string) => {
const start = dayjs(startDate)
const end = dayjs(endDate)
const daysDiff = end.diff(start, 'day') + 1
if (daysDiff <= 7) {
return getHiddenDangerManagementDataRegion
} else if (daysDiff <= 30) {
return getHiddenDangerManagementDataRegionWeek
} else {
return getHiddenDangerManagementDataRegionMonth
}
}
// 初始化隐患管理数据
const initHiddenDangerData = async () => {
try {
// 根据日期范围选择接口
const apiFunc = getHiddenDangerApiRegion(dateRange.value[0], dateRange.value[1])
const response = await apiFunc({
sDate: dateRange.value[0],
eDate: dateRange.value[1],
regiodId: route.query.regionCode as string,
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 parkStatusMap = new Map<string, { overdue: number; processing: number; processed: number }>()
records.forEach((item: any) => {
const dayname = item.dayname || ''
const level = item.name || ''
const status = item.status || ''
const park = item.area || ''
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 (park) {
if (!parkStatusMap.has(park)) {
parkStatusMap.set(park, { overdue: 0, processing: 0, processed: 0 })
}
const parkStatus = parkStatusMap.get(park)!
if (status === '已逾期') {
parkStatus.overdue += count
} else if (status === '处理中') {
parkStatus.processing += count
} else if (status === '已处理') {
parkStatus.processed += count
}
}
})
// 转换为数组并按日期排序
hiddenDangerTrend.value = Array.from(trendMap.entries())
.map(([date, counts]) => ({ date, ...counts }))
.sort((a, b) => {
// 处理日期排序:如果是"10月"这种格式,提取月份数字
const numA = parseInt(a.date.replace(/[^0-9]/g, '')) || 0
const numB = parseInt(b.date.replace(/[^0-9]/g, '')) || 0
// 如果是"日"格式(如"10日"),也处理
const dayA = parseInt(a.date.replace('日', '')) || 0
const dayB = parseInt(b.date.replace('日', '')) || 0
return (numA || dayA) - (numB || dayB)
})
// 转换为园区整改状态数组
parkRectificationStatus.value = Array.from(parkStatusMap.entries())
.map(([park, status]) => ({
park,
overdue: status.overdue,
processing: status.processing,
processed: status.processed
}))
.sort((a, b) => {
// 按总数降序排序
const totalA = a.overdue + a.processing + a.processed
const totalB = b.overdue + b.processing + b.processed
return totalB - totalA
})
console.log('处理后的区域隐患管理数据:', {
trend: hiddenDangerTrend.value,
parkStatus: parkRectificationStatus.value
})
} else {
hiddenDangerTrend.value = []
parkRectificationStatus.value = []
console.log('区域隐患管理无数据')
}
} catch (error) {
console.error('获取区域隐患管理数据失败:', error)
hiddenDangerTrend.value = []
parkRectificationStatus.value = []
}
}
// 初始化高危作业数据
const initHighRiskData = async () => {
try {
const response = await getHighRiskManagementDataRegion({
sDate: dateRange.value[0],
eDate: dateRange.value[1],
regiodId: route.query.regionCode as string,
pageNo: 1,
pageSize: 10000
})
console.log('区域高危作业接口返回:', response)
const records = response?.records || []
if (records && records.length > 0) {
// 按作业类型分组统计,用于环形图
const typeMap = new Map<string, number>()
// 按园区分组统计,用于园区分布列表
const parkMap = new Map<string, number>()
records.forEach((item: any) => {
const itemType = item.item || ''
const park = item.area || item.park || ''
const count = Number(item.total || 0)
// 统计作业类型
if (itemType) {
typeMap.set(itemType, (typeMap.get(itemType) || 0) + count)
}
// 统计园区分布
if (park) {
parkMap.set(park, (parkMap.get(park) || 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)
// 处理园区分布数据
parkOperationDistribution.value = Array.from(parkMap.entries())
.map(([park, count], index) => ({
region: park,
park: park,
count,
color: regionColors[index % regionColors.length]
}))
.sort((a, b) => b.count - a.count)
console.log('处理后的区域高危作业数据:', {
total: highRiskTotal.value,
typeDistribution: operationTypeDistribution.value,
parkDistribution: parkOperationDistribution.value
})
} else {
highRiskTotal.value = 0
operationTypeDistribution.value = []
parkOperationDistribution.value = []
console.log('区域高危作业无数据')
}
} catch (error) {
console.error('获取区域高危作业数据失败:', error)
highRiskTotal.value = 0
operationTypeDistribution.value = []
parkOperationDistribution.value = []
}
}
// 初始化应急预案数据
const initEmergencyPlanData = async () => {
try {
const response = await getEmergencyPlanManagementDataRegion({
sDate: dateRange.value[0],
eDate: dateRange.value[1],
regiodId: route.query.regionCode as string,
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 parkMap = new Map<string, { total: number; completed: number }>()
records.forEach((item: any) => {
const park = item.area || item.park || ''
const count = Number(item.total || 0)
const status = item.status || ''
const isCompleted = status.includes('完成') || status.includes('已执行')
if (park) {
if (!parkMap.has(park)) {
parkMap.set(park, { total: 0, completed: 0 })
}
const parkData = parkMap.get(park)!
parkData.total += count
if (isCompleted) {
parkData.completed += count
}
}
})
// 转换为数组并计算完成率
parkDrillProgress.value = Array.from(parkMap.entries())
.map(([park, data], index) => {
const percent = data.total > 0
? ((data.completed / data.total) * 100).toFixed(0) + '%'
: '0%'
return {
region: park,
park: park,
percent,
count: 0,
color: regionColors[index % regionColors.length]
}
})
.sort((a, b) => {
const percentA = parseFloat(a.percent)
const percentB = parseFloat(b.percent)
return percentB - percentA
})
console.log('处理后的区域应急预案数据:', {
total: emergencyPlanTotal.value,
completed: emergencyPlanCompleted.value,
parkProgress: parkDrillProgress.value
})
} else {
emergencyPlanTotal.value = 0
emergencyPlanCompleted.value = 0
parkDrillProgress.value = []
console.log('区域应急预案无数据')
}
} catch (error) {
console.error('获取区域应急预案数据失败:', error)
emergencyPlanTotal.value = 0
emergencyPlanCompleted.value = 0
parkDrillProgress.value = []
}
}
// 初始化安全培训数据
const initSafetyTrainingData = async () => {
try {
const response = await getTrainingManagementDataRegion({
sDate: dateRange.value[0],
eDate: dateRange.value[1],
regiodId: route.query.regionCode as string,
pageNo: 1,
pageSize: 10000
})
console.log('区域安全培训接口返回:', response)
const records = response?.records || []
if (records && records.length > 0) {
// 按园区分组统计
const parkMap = new Map<string, { trainingCount: number; participants: number }>()
records.forEach((item: any) => {
// 只统计有园区字段的记录
const park = item.area || item.park || ''
if (!park) {
return
}
const trainingCount = Number(item.plannum || 0) // 计划数量作为培训次数
const participants = Number(item.exenum || 0) // 执行数量作为参与人次
if (!parkMap.has(park)) {
parkMap.set(park, { trainingCount: 0, participants: 0 })
}
const parkData = parkMap.get(park)!
parkData.trainingCount += trainingCount
parkData.participants += participants
})
// 转换为数组并排序
const parkDataArray = Array.from(parkMap.entries())
.map(([park, data], index) => ({
park,
...data,
// 计算完成率(参与人次 / 培训次数 * 100%
percent: data.trainingCount > 0
? ((data.participants / data.trainingCount) * 100).toFixed(0) + '%'
: '0%',
color: regionColors[index % regionColors.length]
}))
.sort((a, b) => {
// 按培训次数降序排序
return b.trainingCount - a.trainingCount
})
// 更新柱状图数据
trainingBarData.value = {
regions: parkDataArray.map(item => item.park),
trainingCount: parkDataArray.map(item => item.trainingCount),
participants: parkDataArray.map(item => item.participants)
}
// 更新园区培训完成率数据
parkTrainingProgress.value = parkDataArray.map(item => ({
region: item.park,
park: item.park,
percent: item.percent,
count: 0,
color: item.color
}))
console.log('处理后的区域安全培训数据:', {
barData: trainingBarData.value,
parkProgress: parkTrainingProgress.value
})
} else {
trainingBarData.value = {
regions: [],
trainingCount: [],
participants: []
}
parkTrainingProgress.value = []
console.log('区域安全培训无数据')
}
} catch (error) {
console.error('获取区域安全培训数据失败:', error)
trainingBarData.value = {
regions: [],
trainingCount: [],
participants: []
}
parkTrainingProgress.value = []
}
}
// 外协管理环形图配置
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: () => `${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: () => `${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.map(item => ({
value: item.count,
name: item.level,
itemStyle: { color: item.color }
}))
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)
return {
tooltip: { trigger: 'axis' },
legend: {
data: ['一般隐患', '重大隐患'],
top: 10
},
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: {
type: 'category',
boundaryGap: false,
data: dates
},
yAxis: { type: 'value' },
series: [
{
name: '一般隐患',
type: 'line',
data: generalData,
itemStyle: { color: '#f59e0b' },
smooth: true
},
{
name: '重大隐患',
type: 'line',
data: majorData,
itemStyle: { color: '#ef4444' },
smooth: true
}
]
}
})
// 高危作业环形图配置
const highRiskChartOption = computed<EChartsOption>(() => {
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: operationTypeDistribution.value.map(item => ({
value: item.count,
name: item.type,
itemStyle: { color: item.color }
}))
}]
}
})
// 应急预案环形图配置
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'
},
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 initData = async () => {
await initRegionData()
initOutsourcingData()
initRiskData()
initHiddenDangerData()
initHighRiskData()
initEmergencyPlanData()
initSafetyTrainingData()
}
onMounted(() => {
initData()
})
</script>
<style lang="scss" scoped>
.dashboard-container {
padding: 20px;
background-color: #f5f5f5;
min-height: 100vh;
box-sizing: border-box;
overflow-x: hidden;
}
.header-container {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
padding: 15px 20px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.header-left {
display: flex;
align-items: center;
gap: 10px;
}
.back-arrow {
font-size: 20px;
color: #3b82f6;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
color: #2563eb;
transform: translateX(-2px);
}
}
.region-name-clickable {
font-size: 16px;
font-weight: 600;
color: #333;
padding: 8px 16px;
background: #3b82f6;
color: white;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
gap: 5px;
transition: all 0.3s ease;
&:hover {
background: #2563eb;
}
span {
font-size: 18px;
line-height: 1;
}
}
.header-title {
font-size: 20px;
font-weight: bold;
color: #333;
margin: 0;
flex: 1;
text-align: center;
}
.header-right {
display: flex;
align-items: center;
gap: 15px;
min-width: 0;
}
.date-range-wrapper {
:deep(.el-date-editor) {
max-width: 100%;
}
}
.refresh-btn {
flex-shrink: 0;
}
.content-container {
width: 100%;
box-sizing: border-box;
}
.card-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
margin-bottom: 20px;
width: 100%;
box-sizing: border-box;
}
.dashboard-card {
background: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 1px solid #e5e7eb;
}
.card-title {
font-size: 16px;
font-weight: bold;
color: #333;
display: flex;
align-items: center;
gap: 8px;
}
.card-icon {
font-size: 18px;
}
.manage-btn {
color: #3b82f6;
padding: 0;
}
.card-content {
display: flex;
flex-direction: column;
gap: 15px;
}
.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;
}
.region-distribution,
.risk-distribution-table,
.rectification-status-grid,
.park-operation-distribution,
.regional-progress {
margin-top: 10px;
}
.distribution-title {
font-size: 14px;
font-weight: bold;
color: #333;
margin-bottom: 10px;
}
.distribution-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.distribution-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.region-name {
flex: 1;
color: #666;
}
.region-count {
color: #333;
font-weight: 500;
}
.region-percent {
color: #999;
}
.table-wrapper {
overflow-x: auto;
}
.risk-table,
.status-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
thead {
background-color: #f9fafb;
}
th, td {
padding: 8px;
text-align: left;
border-bottom: 1px solid #e5e7eb;
}
th {
font-weight: bold;
color: #333;
}
td {
color: #666;
}
}
// 九宫格样式
.rectification-status-grid {
.grid-wrapper {
display: flex;
gap: 0;
justify-content: flex-start;
}
.grid-column {
display: flex;
flex-direction: column;
align-items: center;
min-width: 0;
&:first-child {
align-items: flex-start;
margin-right: 20px;
flex: 0 0 auto;
min-width: 80px;
}
&:not(:first-child) {
flex: 1;
min-width: 60px;
}
}
.grid-header {
font-size: 14px;
font-weight: bold;
margin-bottom: 12px;
height: 20px;
line-height: 20px;
&.empty-header {
visibility: hidden;
}
&.status-overdue {
color: #ef4444;
}
&.status-processing {
color: #f59e0b;
}
&.status-processed {
color: #10b981;
}
}
.grid-park-name {
font-size: 13px;
color: #333;
margin-bottom: 8px;
text-align: left;
height: 24px;
line-height: 24px;
&:last-child {
margin-bottom: 0;
}
}
.grid-number {
font-size: 16px;
font-weight: 500;
margin-bottom: 8px;
text-align: center;
height: 24px;
line-height: 24px;
&:last-child {
margin-bottom: 0;
}
&.status-overdue {
color: #ef4444;
}
&.status-processing {
color: #f59e0b;
}
&.status-processed {
color: #10b981;
}
}
}
.high-risk-top {
display: flex;
align-items: center;
gap: 12px;
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;
}
.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%);
}
}
@media (max-width: 1400px) {
.card-row {
grid-template-columns: repeat(2, 1fr);
}
}
@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) {
.card-row {
grid-template-columns: 1fr;
}
.header-container {
flex-direction: column;
gap: 15px;
}
.header-title {
text-align: center;
}
.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%;
}
}
</style>