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

1885 lines
50 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="returnToRegion">
<ArrowLeft />
</el-icon>
<div class="park-name">{{ selectedPark }}</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 supplierDistribution" :key="item.supplier">
<span class="dot" :style="{ backgroundColor: item.color }"></span>
<span class="region-name">{{ item.supplier }}</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 locationRiskDistribution" :key="item.location">
<td>{{ item.location }}</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-company-name" v-for="item in companyRectificationStatus" :key="'company-' + item.company">
{{ item.company }}
</div>
</div>
<!-- 已逾期列 -->
<div class="grid-column">
<div class="grid-header status-overdue">已逾期</div>
<div class="grid-number status-overdue" v-for="item in companyRectificationStatus" :key="'overdue-' + item.company">
{{ 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 companyRectificationStatus" :key="'processing-' + item.company">
{{ 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 companyRectificationStatus" :key="'processed-' + item.company">
{{ 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 companyOperationDistribution" :key="item.company">
<span class="region-name">{{ item.company }}</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="recent-drill-details">
<div class="distribution-title">近期演练详情</div>
<div class="drill-details-list">
<div class="drill-detail-item" v-for="item in recentDrillDetails" :key="item.id">
<div class="drill-detail-info">
<div class="drill-detail-name">{{ item.name }}</div>
<div class="drill-detail-date">{{ item.date }}</div>
</div>
<div class="drill-detail-status" :class="item.status === '已完成' ? 'completed' : 'planned'">
{{ item.status }}
</div>
</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 companyTrainingProgress" :key="item.company">
<span class="region-name">{{ item.company }}</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>
</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 {
getOutsourcingManagementDataPark,
getHighRiskManagementDataPark,
getEmergencyPlanManagementDataPark,
getTrainingManagementDataPark,
getRiskManagementDataPark,
getHiddenDangerManagementDataPark,
getHiddenDangerManagementDataParkWeek,
getHiddenDangerManagementDataParkMonth
} from '@/api'
defineOptions({ name: 'Home13' })
// 类型定义
interface DistributionItem {
supplier?: string
company?: string
location?: string
level?: string
type?: string
count: number
percent?: string
color: string
}
interface LocationRiskItem {
location: string
low: number | string
general: number
moderate: number
major: number
}
interface CompanyRectificationItem {
company: string
overdue: number
processing: number
processed: number
}
interface RecentDrillItem {
id: string
name: string
date: string
status: string
}
const router = useRouter()
const route = useRoute()
// 园区名称 - 从路由参数获取
const selectedPark = ref<string>('')
// 时间选择相关 - 默认当前月起止,如果路由中有日期范围参数则使用
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 supplierDistribution = ref<DistributionItem[]>([])
// 风险管理数据
const riskTotal = ref<number>(0)
const riskDistribution = ref<DistributionItem[]>([])
const locationRiskDistribution = ref<LocationRiskItem[]>([])
// 隐患管理数据
const hiddenDangerTrend = ref<any[]>([])
const companyRectificationStatus = ref<CompanyRectificationItem[]>([])
// 高危作业数据
const highRiskTotal = ref<number>(0)
const operationTypeDistribution = ref<DistributionItem[]>([])
const companyOperationDistribution = ref<DistributionItem[]>([])
// 应急预案数据
const emergencyPlanTotal = ref<number>(0)
const emergencyPlanCompleted = ref<number>(0)
const recentDrillDetails = ref<RecentDrillItem[]>([])
// 安全培训数据
const trainingBarData = ref<{ regions: string[]; trainingCount: number[]; participants: number[] }>({
regions: [],
trainingCount: [],
participants: []
})
const companyTrainingProgress = ref<DistributionItem[]>([])
// 区域颜色配置
const regionColors = ['#3b82f6', '#8b5cf6', '#06b6d4', '#10b981', '#f59e0b', '#ef4444', '#ec4899']
// 作业类型颜色配置
const operationTypeColors: Record<string, string> = {
'动火作业': '#f59e0b',
'高处作业': '#8b5cf6',
'临时用电': '#3b82f6',
'有限空间': '#ec4899',
'动土作业': '#10b981',
'吊装作业': '#ef4444'
}
// 初始化园区名称
const initParkName = () => {
if (typeof route.query.park === 'string') {
selectedPark.value = route.query.park
} else {
selectedPark.value = '雄安园区'
}
}
// 返回区域页面
const returnToRegion = () => {
router.push({
path: '/region',
query: {
region: route.query.region,
regionCode: route.query.regionCode,
sDate: dateRange.value[0],
eDate: dateRange.value[1]
}
})
}
// 日期变化
const handleDateChange = () => {
refreshData()
}
// 刷新数据
const refreshData = () => {
initData()
}
// 初始化外协管理数据
const initOutsourcingData = async () => {
try {
const response = await getOutsourcingManagementDataPark({
sDate: dateRange.value[0],
eDate: dateRange.value[1],
campusId: route.query.parkCode as string,
park: route.query.park 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
supplierDistribution.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 {
supplier: item.name || `供应商${String.fromCharCode(65 + index)}`,
count,
percent,
color
}
})
console.log('处理后的园区外协管理数据:', {
total: outsourcingTotal.value,
distribution: supplierDistribution.value
})
} else {
outsourcingTotal.value = 0
supplierDistribution.value = []
console.log('园区外协管理无数据')
}
} catch (error) {
console.error('获取园区外协管理数据失败:', error)
outsourcingTotal.value = 0
supplierDistribution.value = []
}
}
// 初始化风险管理数据
const initRiskData = async () => {
try {
const response = await getRiskManagementDataPark({
sDate: dateRange.value[0],
eDate: dateRange.value[1],
campusId: route.query.parkCode as string,
park: route.query.park 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 locationLevelMap = new Map<string, { low: number; general: number; moderate: number; major: number }>()
records.forEach((item: any) => {
const level = item.name || ''
const location = item.area || ''
const count = Number(item.total || 0)
// 统计风险等级分布
if (level) {
levelMap.set(level, (levelMap.get(level) || 0) + count)
}
// 统计地点风险分布
if (location) {
if (!locationLevelMap.has(location)) {
locationLevelMap.set(location, { low: 0, general: 0, moderate: 0, major: 0 })
}
const locationData = locationLevelMap.get(location)!
if (level === '低' || level === '低风险') {
locationData.low += count
} else if (level === '一般' || level === '一般风险') {
locationData.general += count
} else if (level === '较大' || level === '较大风险') {
locationData.moderate += count
} else if (level === '重大' || level === '重大风险') {
locationData.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
}
})
// 处理地点风险分布表
locationRiskDistribution.value = Array.from(locationLevelMap.entries())
.map(([location, data]) => ({
location,
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,
locationDistribution: locationRiskDistribution.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' }
]
locationRiskDistribution.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' }
]
locationRiskDistribution.value = []
}
}
// 根据日期范围选择隐患管理接口
const getHiddenDangerApiPark = (startDate: string, endDate: string) => {
const start = dayjs(startDate)
const end = dayjs(endDate)
const daysDiff = end.diff(start, 'day') + 1
if (daysDiff <= 7) {
return getHiddenDangerManagementDataPark
} else if (daysDiff <= 30) {
return getHiddenDangerManagementDataParkWeek
} else {
return getHiddenDangerManagementDataParkMonth
}
}
// 初始化隐患管理数据
const initHiddenDangerData = async () => {
try {
// 根据日期范围选择接口
const apiFunc = getHiddenDangerApiPark(dateRange.value[0], dateRange.value[1])
const response = await apiFunc({
sDate: dateRange.value[0],
eDate: dateRange.value[1],
campusId: route.query.parkCode as string,
park: route.query.park 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 companyStatusMap = 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 company = 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 (company) {
if (!companyStatusMap.has(company)) {
companyStatusMap.set(company, { overdue: 0, processing: 0, processed: 0 })
}
const companyStatus = companyStatusMap.get(company)!
if (status === '已逾期') {
companyStatus.overdue += count
} else if (status === '处理中') {
companyStatus.processing += count
} else if (status === '已处理') {
companyStatus.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)
})
// 转换为公司整改状态数组
companyRectificationStatus.value = Array.from(companyStatusMap.entries())
.map(([company, status]) => ({
company,
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,
companyStatus: companyRectificationStatus.value
})
} else {
hiddenDangerTrend.value = []
companyRectificationStatus.value = []
console.log('园区隐患管理无数据')
}
} catch (error) {
console.error('获取园区隐患管理数据失败:', error)
hiddenDangerTrend.value = []
companyRectificationStatus.value = []
}
}
// 初始化高危作业数据
const initHighRiskData = async () => {
try {
const response = await getHighRiskManagementDataPark({
sDate: dateRange.value[0],
eDate: dateRange.value[1],
campusId: route.query.parkCode as string,
park: route.query.park 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 companyMap = new Map<string, number>()
records.forEach((item: any) => {
const itemType = item.item || ''
const company = item.area || ''
const count = Number(item.total || 0)
// 统计作业类型
if (itemType) {
typeMap.set(itemType, (typeMap.get(itemType) || 0) + count)
}
// 统计公司分布使用area字段
if (company) {
companyMap.set(company, (companyMap.get(company) || 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)
// 处理供应商分布数据
companyOperationDistribution.value = Array.from(companyMap.entries())
.map(([company, count], index) => ({
company,
count,
color: regionColors[index % regionColors.length]
}))
.sort((a, b) => b.count - a.count)
console.log('处理后的园区高危作业数据:', {
total: highRiskTotal.value,
typeDistribution: operationTypeDistribution.value,
companyDistribution: companyOperationDistribution.value
})
} else {
highRiskTotal.value = 0
operationTypeDistribution.value = []
companyOperationDistribution.value = []
console.log('园区高危作业无数据')
}
} catch (error) {
console.error('获取园区高危作业数据失败:', error)
highRiskTotal.value = 0
operationTypeDistribution.value = []
companyOperationDistribution.value = []
}
}
// 初始化应急预案数据
const initEmergencyPlanData = async () => {
try {
const response = await getEmergencyPlanManagementDataPark({
sDate: dateRange.value[0],
eDate: dateRange.value[1],
campusId: route.query.parkCode as string,
park: route.query.park 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
// 提取最近的演练详情(取前几条记录)
recentDrillDetails.value = records
.slice(0, 10) // 最多显示10条
.map((item: any, index: number) => ({
id: String(item.row_id || index + 1),
name: item.name || item.plan_name || `演练${index + 1}`,
date: item.date || item.plan_date || '',
status: item.status || '计划中'
}))
console.log('处理后的园区应急预案数据:', {
total: emergencyPlanTotal.value,
completed: emergencyPlanCompleted.value,
recentDrills: recentDrillDetails.value
})
} else {
emergencyPlanTotal.value = 0
emergencyPlanCompleted.value = 0
recentDrillDetails.value = []
console.log('园区应急预案无数据')
}
} catch (error) {
console.error('获取园区应急预案数据失败:', error)
emergencyPlanTotal.value = 0
emergencyPlanCompleted.value = 0
recentDrillDetails.value = []
}
}
// 初始化安全培训数据
const initSafetyTrainingData = async () => {
try {
const response = await getTrainingManagementDataPark({
sDate: dateRange.value[0],
eDate: dateRange.value[1],
campusId: route.query.parkCode as string,
park: route.query.park as string,
pageNo: 1,
pageSize: 10000
})
console.log('园区安全培训接口返回:', response)
const records = response?.records || []
if (records && records.length > 0) {
// 按供应商/公司分组统计
const companyMap = new Map<string, { trainingCount: number; participants: number }>()
records.forEach((item: any) => {
// 使用area字段作为公司名称
const company = item.area || ''
if (!company) {
return
}
const trainingCount = Number(item.plannum || 0) // 计划数量作为培训次数
const participants = Number(item.exenum || 0) // 执行数量作为参与人次
if (!companyMap.has(company)) {
companyMap.set(company, { trainingCount: 0, participants: 0 })
}
const companyData = companyMap.get(company)!
companyData.trainingCount += trainingCount
companyData.participants += participants
})
// 转换为数组并排序
const companyDataArray = Array.from(companyMap.entries())
.map(([company, data], index) => ({
company,
...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: companyDataArray.map(item => item.company),
trainingCount: companyDataArray.map(item => item.trainingCount),
participants: companyDataArray.map(item => item.participants)
}
// 更新供应商培训完成率数据
companyTrainingProgress.value = companyDataArray.map(item => ({
company: item.company,
percent: item.percent,
count: 0,
color: item.color
}))
console.log('处理后的园区安全培训数据:', {
barData: trainingBarData.value,
companyProgress: companyTrainingProgress.value
})
} else {
trainingBarData.value = {
regions: [],
trainingCount: [],
participants: []
}
companyTrainingProgress.value = []
console.log('园区安全培训无数据')
}
} catch (error) {
console.error('获取园区安全培训数据失败:', error)
trainingBarData.value = {
regions: [],
trainingCount: [],
participants: []
}
companyTrainingProgress.value = []
}
}
// 外协管理环形图配置
const outsourcingChartOption = computed<EChartsOption>(() => {
const chartData = supplierDistribution.value.map(item => ({
value: item.count,
name: item.supplier,
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', max: 45 },
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 () => {
initParkName()
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);
}
}
.park-name {
padding: 8px 16px;
background: #3b82f6;
color: white;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
}
.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,
.recent-drill-details,
.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-company-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;
}
.recent-drill-details {
margin-top: 15px;
}
.drill-details-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.drill-detail-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
background-color: #f9fafb;
border-radius: 4px;
}
.drill-detail-info {
flex: 1;
}
.drill-detail-name {
font-size: 13px;
font-weight: 500;
color: #333;
margin-bottom: 4px;
}
.drill-detail-date {
font-size: 12px;
color: #999;
}
.drill-detail-status {
font-size: 13px;
font-weight: 500;
&.completed {
color: #10b981;
}
&.planned {
color: #f59e0b;
}
}
.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>