Files
klp-mono/apps/hand-factory/components/lines/zinc1.vue
2026-01-15 20:18:37 +08:00

1927 lines
56 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

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>
<view class="page-container">
<!-- 简洁标签栏 -->
<view class="tab-container">
<view
v-for="item in tabData"
:key="item.value"
@click="currentTab = item.value"
class="tab-item"
:class="{ 'tab-active': currentTab === item.value }"
>
<text class="tab-label">{{ item.text }}</text>
<view class="tab-indicator" v-if="currentTab === item.value"></view>
</view>
</view>
<!-- 刷新按钮 -->
<view class="refresh-btn-fixed" @click="refreshAll">
<text class="refresh-icon" :class="{ rotating: isRefreshing }"></text>
</view>
<!-- Tab1实时监控 -->
<scroll-view
scroll-y
class="scroll-container"
v-if="currentTab === 1"
>
<!-- 顶部状态栏 -->
<view class="status-bar">
<view class="status-item">
<text class="status-label">网络状态</text>
<text class="status-value" :class="isConnected ? 'status-通畅' : 'status-异常'">{{ isConnected ? '已连接' : '未连接' }}</text>
</view>
<view class="status-divider"></view>
<view class="status-item">
<text class="status-label">更新</text>
<text class="status-value status-time">{{ lastUpdateTime }}</text>
</view>
<view class="status-divider"></view>
<view class="status-item">
<text class="status-label">设备数</text>
<text class="status-value">{{ deviceDefs.length }}</text>
</view>
</view>
<!-- 带钢位置和速度模块 -->
<view class="section">
<view class="section-title">带钢状态</view>
<view class="strip-status-grid">
<!-- 带钢位置卡片 -->
<view class="strip-status-card">
<text class="strip-status-label">带钢位置</text>
<text class="strip-status-value">{{ getRealtimeValueBySource('ENTRY', 'stripLocation') }}</text>
<text class="strip-status-unit">m</text>
<view class="progress-container">
<view class="progress-bar">
<view class="progress-fill" :style="{ width: stripLocationProgress + '%' }"></view>
</view>
<text class="progress-text">{{ stripLocationProgress.toFixed(1) }}%</text>
</view>
</view>
<!-- 带钢速度卡片 -->
<view class="strip-status-card">
<text class="strip-status-label">带钢速度</text>
<text class="strip-status-value">{{ getRealtimeValueBySource('ENTRY', 'stripSpeed') }}</text>
<text class="strip-status-unit">m/min</text>
</view>
</view>
<!-- 带钢速度折线图 -->
<view class="chart-box">
<qiun-data-charts type="line" :chartData="stripSpeedChartData" :opts="stripSpeedChartOpts" />
</view>
</view>
<!-- 实时数据卡片 + 统计图按酸轧页风格不按设备分块 -->
<view class="section">
<view class="section-title">入口段</view>
<view class="metrics-grid-3">
<view class="metric-box" v-for="it in entryMetrics" :key="'entry_' + it.field">
<text class="metric-name">{{ it.label || getFieldLabel(it.field) }}</text>
<text class="metric-value">{{ getRealtimeValueBySource('ENTRY', it.field) }}</text>
<text class="metric-unit">{{ it.unit || getFieldUnit(it.field) }}</text>
</view>
</view>
<view class="chart-box">
<qiun-data-charts type="line" :chartData="toGroupLineChart('entry')" :opts="lineChartOpts" />
</view>
</view>
<view class="section">
<view class="section-title">退火炉段</view>
<view class="metrics-grid-3">
<view class="metric-box" v-for="it in furnaceMetrics" :key="'furnace_' + it.actualField">
<text class="metric-name">{{ it.label || getFieldLabel(it.actualField) }}</text>
<text class="metric-value">{{ getRealtimeValueBySource('FURNACE', it.actualField) }}</text>
<text class="metric-unit">{{ it.unit || getFieldUnit(it.actualField) }}</text>
<text class="metric-set-value" v-if="it.setField">
设定: {{ getRealtimeValueBySource('FURNACE', it.setField) }}
</text>
</view>
</view>
</view>
<view class="section">
<view class="section-title">后处理/涂层段</view>
<view class="metrics-grid-3">
<view class="metric-box" v-for="it in coatMetrics" :key="'coat_' + it.field">
<text class="metric-name">{{ it.label || getFieldLabel(it.field) }}</text>
<text class="metric-value">{{ getRealtimeValueBySource('COAT', it.field) }}</text>
<text class="metric-unit">{{ it.unit || getFieldUnit(it.field) }}</text>
</view>
</view>
<view class="chart-box">
<qiun-data-charts type="line" :chartData="toGroupLineChart('coat')" :opts="lineChartOpts" />
</view>
</view>
<view class="section">
<view class="section-title">出口段</view>
<view class="metrics-grid-3">
<view class="metric-box" v-for="it in exitMetrics" :key="'exit_' + it.field">
<text class="metric-name">{{ it.label || getFieldLabel(it.field) }}</text>
<text class="metric-value">{{ getRealtimeValueBySource('EXIT', it.field) }}</text>
<text class="metric-unit">{{ it.unit || getFieldUnit(it.field) }}</text>
</view>
</view>
<view class="chart-box">
<qiun-data-charts type="line" :chartData="toGroupLineChart('exit')" :opts="lineChartOpts" />
</view>
</view>
</scroll-view>
<!-- 生产统计 / 实绩 -->
<scroll-view scroll-y class="scroll-container" v-if="currentTab === 2">
<view class="section">
<view class="section-title">生产实绩汇总</view>
<view class="overview-grid" v-if="reportSummary">
<view class="overview-card">
<text class="overview-label">钢卷总数</text>
<text class="overview-value">{{ reportSummary.coilCount || 0 }}</text>
<text class="overview-unit"></text>
</view>
<view class="overview-card">
<text class="overview-label">总实际重量</text>
<text class="overview-value">{{ formatNum(reportSummary.totalActualWeight) }}</text>
<text class="overview-unit">t</text>
</view>
<view class="overview-card">
<text class="overview-label">成材率</text>
<text class="overview-value">
{{ reportSummary.yieldRate != null ? (reportSummary.yieldRate * 100).toFixed(1) : '—' }}
</text>
<text class="overview-unit">%</text>
</view>
</view>
<view class="chart-box" v-else>
<text class="placeholder-text">暂无实绩汇总数据</text>
</view>
</view>
<view class="section">
<view class="section-title">生产实绩明细</view>
<view class="detail-cards" v-if="reportDetails && reportDetails.length">
<view
class="detail-card"
v-for="(item, index) in reportDetails"
:key="(item.exitMatId || '') + '_' + (item.onlineTime || index) + '_' + index"
>
<view class="detail-card-header">
<text class="detail-card-title">成品卷号{{ item.exitMatId }}</text>
</view>
<view class="detail-card-body">
<view class="detail-row">
<text class="detail-label">原料卷号</text>
<text class="detail-value">{{ item.entryMatId || '—' }}</text>
</view>
<view class="detail-row">
<text class="detail-label">/</text>
<text class="detail-value">{{ item.groupNo || '—' }} / {{ item.shiftNo || '—' }}</text>
</view>
<view class="detail-row">
<text class="detail-label">规格</text>
<text class="detail-value">
{{ formatNum(item.exitThickness) }} × {{ formatNum(item.exitWidth) }}
</text>
</view>
<view class="detail-row">
<text class="detail-label">长度</text>
<text class="detail-value">{{ formatNum(item.exitLength) }} m</text>
</view>
<view class="detail-row">
<text class="detail-label">重量</text>
<text class="detail-value">{{ formatNum(item.actualWeight) }} t</text>
</view>
</view>
</view>
</view>
<view class="chart-box" v-else>
<text class="placeholder-text">暂无生产明细记录</text>
</view>
</view>
</scroll-view>
<!-- 停机统计 / 停机记录 -->
<scroll-view scroll-y class="scroll-container" v-if="currentTab === 3">
<view class="section">
<view class="section-title">停机记录</view>
<!-- 月份选择器 -->
<view class="stoppage-filter">
<picker mode="date" fields="month" :value="stoppageMonth" @change="onStoppageMonthChange">
<view class="stoppage-month-btn">
<text class="stoppage-month-text">{{ stoppageMonthLabel }}</text>
</view>
</picker>
</view>
<view class="stoppage-cards" v-if="stoppageList && stoppageList.length">
<view
class="stoppage-card"
v-for="(item, index) in stoppageList"
:key="item.stopid || index"
>
<view class="stoppage-card-header">
<text class="stoppage-card-title">停机记录 #{{ index + 1 }}</text>
<text class="stoppage-card-duration">{{ formatDuration(item.duration) }}</text>
</view>
<view class="stoppage-card-body">
<view class="stoppage-row">
<text class="stoppage-label">开始时间</text>
<text class="stoppage-value">{{ item.startDate || '—' }}</text>
</view>
<view class="stoppage-row">
<text class="stoppage-label">结束时间</text>
<text class="stoppage-value">{{ item.endDate || '—' }}</text>
</view>
<view class="stoppage-row">
<text class="stoppage-label">区域</text>
<text class="stoppage-value">{{ item.area || '—' }}</text>
</view>
<view class="stoppage-row">
<text class="stoppage-label">设备</text>
<text class="stoppage-value">{{ item.seton || '—' }}</text>
</view>
<view class="stoppage-row" v-if="item.remark">
<text class="stoppage-label">原因</text>
<text class="stoppage-value stoppage-remark">{{ item.remark }}</text>
</view>
</view>
</view>
</view>
<view class="chart-box" v-else>
<text class="placeholder-text">暂无停机记录</text>
</view>
</view>
</scroll-view>
</view>
</template>
<script>
import { listDeviceEnumAll } from '@/api/pocket/deviceEnum'
import { getDeviceFieldMetaAll } from '@/api/pocket/deviceFieldMeta'
import { listDeviceSnapshotLatest } from '@/api/pocket/deviceSnapshot'
import { getCurrentPlan, getCurrentProcess } from '@/api/business/dashboard'
import { getReportSummary, getReportDetails } from '@/api/business/report'
import { listStoppage } from '@/api/business/stoppage'
import { createMeasureSocket } from '@/utils/socketMeasure'
export default {
data() {
return {
currentTab: 1,
tabData: [
{ text: '实时监控', value: 1 },
{ text: '生产统计', value: 2 },
{ text: '停机统计', value: 3 }
],
isRefreshing: false,
isConnected: false,
lastUpdateTime: '—',
// 记录起始时间,用于计算运行时长
startTime: null,
// socket数据是否已补充到折线图仅补充一次
socketDataAppended: false,
// socket最新消息
latestMeasure: null,
// 设备定义(后端 DeviceEnum 全量)
deviceDefs: [],
// 字段元数据fieldName -> {label, unit, description}
fieldMeta: {},
// 实时数据卡片定义(按酸轧页组织,不按设备分块)
entryMetrics: [
{ field: 'stripLocation', label: '带钢位置', unit: 'm' },
{ field: 'stripSpeed', label: '带钢速度', unit: 'm/min' },
{ field: 'tensionPorBr1', label: '入口张力 POR-BR1', unit: 'daN' },
{ field: 'tensionBr1Br2', label: '张力 BR1-BR2', unit: 'daN' },
{ field: 'tensionBr2Br3', label: '张力 BR2-BR3', unit: 'daN' }
],
furnaceMetrics: [
{ actualField: 'jcf1FurnaceTemperatureActual', setField: 'jcf1FurnaceTemperatureSet', label: 'JCF1炉温', unit: '℃' },
{ actualField: 'jcf2FurnaceTemperatureActual', setField: 'jcf2FurnaceTemperatureSet', label: 'JCF2炉温', unit: '℃' },
{ actualField: 'lbzFurnaceTemperatureActual', setField: 'lbzFurnaceTemperatureSet', label: 'LBZ炉温', unit: '℃' },
{ actualField: 'lthFurnaceTemperatureActual', setField: 'lthFurnaceTemperatureSet', label: 'LTH炉温', unit: '℃' },
{ actualField: 'nof1FurnaceTemperatureActual', setField: 'nof1FurnaceTemperatureSet', label: 'NOF1炉温', unit: '℃' },
{ actualField: 'nof2FurnaceTemperatureActual', setField: 'nof2FurnaceTemperatureSet', label: 'NOF2炉温', unit: '℃' },
{ actualField: 'nof3FurnaceTemperatureActual', setField: 'nof3FurnaceTemperatureSet', label: 'NOF3炉温', unit: '℃' },
{ actualField: 'nof4FurnaceTemperatureActual', setField: 'nof4FurnaceTemperatureSet', label: 'NOF4炉温', unit: '℃' },
{ actualField: 'nof5FurnaceTemperatureActual', setField: 'nof5FurnaceTemperatureSet', label: 'NOF5炉温', unit: '℃' },
{ actualField: 'phFurnaceTemperatureActual', setField: null, label: 'PH炉温', unit: '℃' },
{ actualField: 'rtf1FurnaceTemperatureActual', setField: 'rtf1FurnaceTemperatureSet', label: 'RTF1炉温', unit: '℃' },
{ actualField: 'rtf2FurnaceTemperatureActual', setField: 'rtf2FurnaceTemperatureSet', label: 'RTF2炉温', unit: '℃' },
{ actualField: 'sfFurnaceTemperatureActual', setField: 'sfFurnaceTemperatureSet', label: 'SF炉温', unit: '℃' },
{ actualField: 'tdsFurnaceTemperatureActual', setField: 'tdsFurnaceTemperatureSet', label: 'TDS炉温', unit: '℃' },
],
coatMetrics: [
{ field: 'avrCoatingWeightTop', label: '上层平均涂层重量', unit: 'g/m²' },
{ field: 'avrCoatingWeightBottom', label: '下层平均涂层重量', unit: 'g/m²' },
{ field: 'airKnifePressure', label: '气刀压力', unit: '' },
{ field: 'stripSpeedTmExit', label: 'TM出口速度', unit: 'm/min' }
],
exitMetrics: [
{ field: 'alkaliConcentration', label: '碱液浓度', unit: '' },
{ field: 'alkaliTemperature', label: '碱液温度', unit: '℃' },
{ field: 'celCapacity', label: '电池容量', unit: '' },
{ field: 'celLength', label: '电池长度', unit: 'm' },
{ field: 'cleaningCurrent', label: '清洗电流', unit: 'A' },
{ field: 'cleaningVoltage', label: '清洗电压', unit: 'V' },
{ field: 'dryingTemperature', label: '干燥温度', unit: '℃' },
{ field: 'entryCoilId', label: '入口卷号', unit: '' },
{ field: 'hotAirFlow', label: '热风流量', unit: '' },
{ field: 'hotAirPressure', label: '热风压力', unit: '' },
{ field: 'payOffReelNumber', label: '放卷卷号', unit: '' },
{ field: 'rinseConductivity', label: '冲洗电导率', unit: '' },
{ field: 'rinseTemperature', label: '冲洗温度', unit: '℃' },
{ field: 'stripLocation', label: '带钢位置', unit: 'm' },
{ field: 'stripSpeed', label: '带钢速度', unit: 'm/min' },
{ field: 'tensionBr1Br2', label: '张力 BR1-BR2', unit: 'daN' },
{ field: 'tensionBr2Br3', label: '张力 BR2-BR3', unit: 'daN' },
{ field: 'tensionPorBr1', label: '入口张力 POR-BR1', unit: 'daN' },
{ field: 'tensionBr8Br9', label: '张力 BR8-BR9', unit: 'daN' },
{ field: 'tensionBr9Tr', label: '张力 BR9-TR', unit: 'daN' },
{ field: 'speedExitSection', label: '出口速度', unit: 'm/min' },
{ field: 'coilLength', label: '钢卷长度', unit: 'm' }
],
// 前端历史缓存(打开页面即可出趋势)
chartMaxPoints: 60,
// 带钢速度折线图最大点数(单独配置)
stripSpeedMaxPoints: 30,
chartSeries: {
entry: { time: [], stripSpeed: [], tensionPorBr1: [], tensionBr1Br2: [], tensionBr2Br3: [] },
furnace: {
time: [],
jcf1FurnaceTemperatureActual: [], jcf2FurnaceTemperatureActual: [],
lbzFurnaceTemperatureActual: [], lthFurnaceTemperatureActual: [],
nof1FurnaceTemperatureActual: [], nof2FurnaceTemperatureActual: [],
nof3FurnaceTemperatureActual: [], nof4FurnaceTemperatureActual: [],
nof5FurnaceTemperatureActual: [],
phFurnaceTemperatureActual: [],
rtf1FurnaceTemperatureActual: [], rtf2FurnaceTemperatureActual: [],
sfFurnaceTemperatureActual: [], tdsFurnaceTemperatureActual: [],
potTemperature: []
},
coat: { time: [], avrCoatingWeightTop: [], avrCoatingWeightBottom: [], airKnifePressure: [], stripSpeedTmExit: [] },
exit: { time: [], tensionBr8Br9: [], tensionBr9Tr: [], speedExitSection: [] },
// 带钢速度实时折线图(持续刷新)
stripSpeed: { time: [], stripSpeed: [] }
},
// 生产统计(实绩)
currentPlan: null,
currentProcess: null,
reportSummary: null,
reportDetails: [],
reportLoading: false,
// 停机记录
stoppageList: [],
stoppageLoading: false,
stoppageMonth: '', // yyyy-MM
// 组件销毁标志,用于防止异步操作在组件销毁后更新数据
isDestroyed: false,
lineChartOpts: {
color: ['#0066cc', '#409eff', '#66b1ff', '#a0cfff', '#d9ecff', '#ecf5ff'],
padding: [15, 15, 0, 15],
enableScroll: false,
legend: { show: true, position: 'top', fontSize: 10, lineHeight: 14, itemGap: 6 },
dataLabel: false,
dataPointShape: false,
xAxis: { disableGrid: true, rotateLabel: true, itemCount: 5, labelCount: 5, fontSize: 10 },
yAxis: {
gridType: 'dash',
dashLength: 4,
gridColor: '#e4e7ed',
showTitle: true,
fontSize: 10,
data: [{ title: '数值' }]
},
extra: { line: { type: 'curve', width: 2, activeType: 'hollow' } }
},
// 带钢速度折线图配置Y轴固定为100-150完全禁用动画
stripSpeedChartOpts: {
color: ['#0066cc'],
padding: [15, 15, 0, 15],
enableScroll: false,
legend: { show: true, position: 'top', fontSize: 10, lineHeight: 14, itemGap: 6 },
dataLabel: false,
dataPointShape: false,
animation: false, // 禁用动画,直接显示到值上
animationDuration: 0, // 动画时长为0确保无动画效果
xAxis: { disableGrid: true, rotateLabel: true, itemCount: 5, labelCount: 5, fontSize: 10 },
yAxis: {
gridType: 'dash',
dashLength: 4,
gridColor: '#e4e7ed',
showTitle: true,
fontSize: 10,
data: [{
title: '速度(m/min)',
min: 80,
max: 150
}]
},
extra: { line: { type: 'curve', width: 2, activeType: 'hollow' } }
},
socketClient: null
}
},
mounted() {
if (this.isDestroyed) return
// 记录起始时间
this.startTime = new Date()
this.initSocket()
Promise.all([this.loadDeviceDefs(), this.loadFieldMeta(), this.loadHistoryForCharts()]).then(() => {
// Promise完成后检查组件是否已销毁
if (this.isDestroyed) return
}).catch(() => {
// 错误处理中也要检查
if (this.isDestroyed) return
})
this.updateLastTime()
},
computed: {
stoppageMonthLabel() {
if (this.isDestroyed) return '选择月份'
if (!this.stoppageMonth) return '选择月份'
const parts = (this.stoppageMonth || '').split('-')
if (parts.length < 2) return this.stoppageMonth
const [y, m] = parts
return `${y}${m}`
},
// 带钢位置进度(位置/3000
stripLocationProgress() {
if (this.isDestroyed) return 0
const m = this.latestMeasure || {}
const entry = m.appMeasureEntryMessage || {}
const location = entry.stripLocation !== null && entry.stripLocation !== undefined
? parseFloat(entry.stripLocation) || 0
: 0
const maxLocation = 3000
const progress = (location / maxLocation) * 100
return Math.min(Math.max(progress, 0), 100)
},
// 带钢速度折线图数据(使用计算属性,确保数据引用稳定)
stripSpeedChartData() {
if (this.isDestroyed) return { categories: [], series: [] }
const g = this.chartSeries.stripSpeed
if (!g) return { categories: [], series: [] }
// 返回新对象,但保持数组引用稳定,避免触发重新初始化
const timeArray = g.time || []
const speedArray = g.stripSpeed || []
return {
categories: timeArray,
series: [{
name: '带钢速度',
data: speedArray
}]
}
}
},
watch: {
currentTab(newVal) {
if (this.isDestroyed) return
if (newVal === 2) {
this.loadReportData()
} else if (newVal === 3) {
// 默认当前月
if (!this.stoppageMonth) {
const now = new Date()
const y = now.getFullYear()
const m = String(now.getMonth() + 1).padStart(2, '0')
this.stoppageMonth = `${y}-${m}`
}
this.loadStoppageData()
}
}
},
beforeDestroy() {
// 先设置销毁标志,防止异步回调访问已销毁的组件
this.isDestroyed = true
// 设置闭包中的销毁标志必须在关闭socket之前且要立即执行
// 这会取消所有待执行的异步更新
if (this._clearSocketFlag) {
try {
this._clearSocketFlag()
} catch (e) {
// ignore
}
// 清空引用,防止再次调用
this._clearSocketFlag = null
}
// 清理定时器
if (this._scrollTimer) {
clearTimeout(this._scrollTimer)
this._scrollTimer = null
}
// 关闭socket连接并清空回调必须立即执行
if (this.socketClient) {
try {
// 先清空回调,防止关闭过程中触发回调
if (this.socketClient.clearCallbacks) {
this.socketClient.clearCallbacks()
}
// 立即关闭socket
this.socketClient.close()
} catch (e) {
// ignore
}
// 立即清空引用
this.socketClient = null
}
// 清空所有响应式数据引用,防止后续访问
try {
this.latestMeasure = null
this.chartSeries = null
} catch (e) {
// ignore
}
},
methods: {
async loadDeviceDefs() {
const res = await listDeviceEnumAll()
if (this.isDestroyed) return
this.deviceDefs = (res && res.data) || []
},
async loadFieldMeta() {
const res = await getDeviceFieldMetaAll()
if (this.isDestroyed) return
this.fieldMeta = (res && res.data) || {}
},
// 加载历史快照,填充折线图的"历史部分"
async loadHistoryForCharts() {
try {
// 使用设备快照作为折线图唯一数据源,避免每条 socket 消息都触发重绘
// 每次加载前先清空本地缓存,防止重复累加
// 注意stripSpeed 数据不清空,保持实时更新连续性
this.chartSeries = {
entry: { time: [], stripSpeed: [], tensionPorBr1: [], tensionBr1Br2: [], tensionBr2Br3: [] },
furnace: {
time: [],
jcf1FurnaceTemperatureActual: [], jcf2FurnaceTemperatureActual: [],
lbzFurnaceTemperatureActual: [], lthFurnaceTemperatureActual: [],
nof1FurnaceTemperatureActual: [], nof2FurnaceTemperatureActual: [],
nof3FurnaceTemperatureActual: [], nof4FurnaceTemperatureActual: [],
nof5FurnaceTemperatureActual: [],
phFurnaceTemperatureActual: [],
rtf1FurnaceTemperatureActual: [], rtf2FurnaceTemperatureActual: [],
sfFurnaceTemperatureActual: [], tdsFurnaceTemperatureActual: [],
potTemperature: []
},
coat: { time: [], avrCoatingWeightTop: [], avrCoatingWeightBottom: [], airKnifePressure: [], stripSpeedTmExit: [] },
exit: { time: [], tensionBr8Br9: [], tensionBr9Tr: [], speedExitSection: [] },
// stripSpeed 保持现有数据,不清空,确保实时更新连续性
stripSpeed: this.chartSeries.stripSpeed || { time: [], stripSpeed: [] }
}
const limit = this.chartMaxPoints
const tasks = []
const deviceMap = {
entry: { deviceCode: 'POR1', fields: ['stripSpeed', 'tensionPorBr1', 'tensionBr1Br2', 'tensionBr2Br3'] },
furnace: {
deviceCode: 'FUR2',
fields: [
'jcf1FurnaceTemperatureActual', 'jcf2FurnaceTemperatureActual',
'lbzFurnaceTemperatureActual', 'lthFurnaceTemperatureActual',
'nof1FurnaceTemperatureActual', 'nof2FurnaceTemperatureActual',
'nof3FurnaceTemperatureActual', 'nof4FurnaceTemperatureActual',
'nof5FurnaceTemperatureActual',
'phFurnaceTemperatureActual',
'rtf1FurnaceTemperatureActual', 'rtf2FurnaceTemperatureActual',
'sfFurnaceTemperatureActual', 'tdsFurnaceTemperatureActual',
'potTemperature'
]
},
coat: {
deviceCode: 'COAT',
fields: ['avrCoatingWeightTop', 'avrCoatingWeightBottom', 'airKnifePressure', 'stripSpeedTmExit']
},
exit: { deviceCode: 'TR', fields: ['tensionBr8Br9', 'tensionBr9Tr', 'speedExitSection'] }
}
Object.keys(deviceMap).forEach((group) => {
const cfg = deviceMap[group]
tasks.push(
listDeviceSnapshotLatest({ limit, deviceCode: cfg.deviceCode })
.then((res) => {
if (this.isDestroyed) return
const rows = (res && res.data) || []
// 按时间升序
const list = rows.slice().reverse()
list.forEach((row) => {
if (this.isDestroyed) return
const t = (row.createTime || '').slice(11, 19) // HH:mm:ss
let snap = {}
try {
snap = row.snapshotData ? JSON.parse(row.snapshotData) : {}
} catch (e) {
snap = {}
}
const values = {}
cfg.fields.forEach((f) => {
values[f] = snap[f]
})
this.pushSeries(group, t, values)
})
})
.catch(() => {})
)
})
await Promise.all(tasks)
} catch (e) {
// ignore
}
},
initSocket() {
// 使用闭包保存组件引用和销毁标志,避免直接访问 this
const vm = this
let isDestroyed = false
// 保存所有待执行的异步操作ID用于取消
const pendingUpdates = []
// 实时测量数据 WebSocket
this.socketClient = createMeasureSocket({
type: 'track_measure',
onOpen: () => {
if (isDestroyed) return
try {
vm.isConnected = true
} catch (e) {
// 组件已销毁,忽略错误
}
},
onClose: () => {
if (isDestroyed) return
try {
vm.isConnected = false
} catch (e) {
// 组件已销毁,忽略错误
}
},
onError: () => {
if (isDestroyed) return
try {
vm.isConnected = false
} catch (e) {
// 组件已销毁,忽略错误
}
},
onMessage: (data) => {
// 在函数最开始就检查,避免任何后续操作
if (isDestroyed) return
// 双重检查:如果组件实例不存在,直接返回
if (!vm) {
isDestroyed = true
return
}
// 使用 try-catch 包裹整个函数,防止任何错误
try {
// 兼容字符串 / 已解析对象两种情况
let payload = null
if (typeof data === 'string') {
payload = JSON.parse(data)
} else if (data && typeof data === 'object') {
if (typeof data.data === 'string') {
payload = JSON.parse(data.data)
} else {
payload = data
}
}
// 再次检查,防止在解析过程中组件被销毁
if (!payload || isDestroyed || !vm) return
// 使用 requestAnimationFrame 延迟更新
// 如果组件已销毁requestAnimationFrame 回调不会执行
const updateFrame = () => {
// 多重检查:确保组件仍然存在
if (isDestroyed || !vm) return
// 检查 Vue 实例状态 - 使用更安全的方式
try {
// 检查组件是否真的还存在且可用
if (!vm || typeof vm !== 'object') {
isDestroyed = true
return
}
// 尝试访问 Vue 实例的内部属性来验证
if (vm._isDestroyed === true || vm._isBeingDestroyed === true) {
isDestroyed = true
return
}
} catch (e) {
isDestroyed = true
return
}
// 安全地更新数据 - 使用 Object.defineProperty 或直接赋值
try {
// 直接赋值,但确保组件存在
if (vm && !vm._isDestroyed && !vm._isBeingDestroyed) {
vm.latestMeasure = payload
if (!vm.socketDataAppended) {
vm.appendChartPoint(payload)
vm.socketDataAppended = true
}
vm.appendStripSpeedPoint(payload)
vm.updateLastTime()
}
} catch (e) {
// 如果更新失败,设置销毁标志
isDestroyed = true
}
}
// 使用 requestAnimationFrame 或 setTimeout并保存ID以便取消
let frameId = null
if (typeof requestAnimationFrame !== 'undefined') {
frameId = requestAnimationFrame(updateFrame)
pendingUpdates.push(frameId)
} else {
frameId = setTimeout(updateFrame, 0)
pendingUpdates.push(frameId)
}
} catch (e) {
// 解析错误或其他错误,静默处理
if (!isDestroyed) {
try {
console.error('解析 track_measure 数据失败:', e)
} catch (err) {
// 忽略日志错误
}
}
}
}
})
// 保存清空标志的函数引用
this._clearSocketFlag = () => {
isDestroyed = true
// 取消所有待执行的异步更新
pendingUpdates.forEach(id => {
try {
if (typeof cancelAnimationFrame !== 'undefined') {
cancelAnimationFrame(id)
} else {
clearTimeout(id)
}
} catch (e) {
// ignore
}
})
pendingUpdates.length = 0
}
this.socketClient.connect()
},
// 加载生产统计 / 实绩数据
async loadReportData() {
if (this.reportLoading) return
this.reportLoading = true
try {
// 当前计划 & 当前工艺
try {
const planRes = await getCurrentPlan()
if (this.isDestroyed) return
this.currentPlan = (planRes && planRes.data) || null
} catch (e) {
if (this.isDestroyed) return
this.currentPlan = null
}
try {
const procRes = await getCurrentProcess()
if (this.isDestroyed) return
this.currentProcess = (procRes && procRes.data) || null
} catch (e) {
if (this.isDestroyed) return
this.currentProcess = null
}
// 实绩汇总 & 明细:不传筛选条件,后端按默认时间范围处理
let summaryData = null
let detailsData = []
try {
const sumRes = await getReportSummary({})
if (this.isDestroyed) return
// 处理可能的多种返回结构:直接数据、{data: {...}}、{rows: [...], total: ...}
if (sumRes) {
if (sumRes.data && typeof sumRes.data === 'object' && !Array.isArray(sumRes.data)) {
summaryData = sumRes.data
} else {
summaryData = sumRes
}
}
} catch (e) {
if (this.isDestroyed) return
console.error('加载实绩汇总失败:', e)
}
try {
const detRes = await getReportDetails({})
if (this.isDestroyed) return
// 处理可能的多种返回结构
if (detRes) {
if (Array.isArray(detRes)) {
detailsData = detRes
} else if (detRes.data && Array.isArray(detRes.data)) {
detailsData = detRes.data
} else if (detRes.rows && Array.isArray(detRes.rows)) {
detailsData = detRes.rows
}
}
} catch (e) {
if (this.isDestroyed) return
console.error('加载实绩明细失败:', e)
}
// 统一处理汇总数据修正coilCount
if (this.isDestroyed) return
if (summaryData) {
// 如果coilCount不存在或看起来像是页数通常页数很小尝试从其他字段获取
if (summaryData.total !== undefined &&
(summaryData.coilCount === undefined ||
(summaryData.coilCount < 10 && summaryData.total > summaryData.coilCount))) {
// 优先使用total字段
summaryData.coilCount = summaryData.total
} else if (detailsData.length > 0 &&
(summaryData.coilCount === undefined || summaryData.coilCount < detailsData.length)) {
// 如果total不存在使用明细数组长度作为总数
summaryData.coilCount = detailsData.length
}
this.reportSummary = summaryData
} else {
this.reportSummary = null
}
this.reportDetails = detailsData
} finally {
if (!this.isDestroyed) {
this.reportLoading = false
}
}
},
// 加载停机记录
async loadStoppageData() {
if (this.stoppageLoading) return
this.stoppageLoading = true
try {
// 计算当月起止日期yyyy-MM-dd
const month = this.stoppageMonth
let startDate = ''
let endDate = ''
if (month) {
const [y, m] = month.split('-').map((s) => Number(s))
const first = new Date(y, m - 1, 1)
const last = new Date(y, m, 0)
const pad = (n) => String(n).padStart(2, '0')
startDate = `${first.getFullYear()}-${pad(first.getMonth() + 1)}-${pad(first.getDate())}`
endDate = `${last.getFullYear()}-${pad(last.getMonth() + 1)}-${pad(last.getDate())}`
}
const res = await listStoppage({ startDate, endDate })
if (this.isDestroyed) return
this.stoppageList = (res && res.data) || []
} catch (e) {
if (this.isDestroyed) return
this.stoppageList = []
} finally {
if (!this.isDestroyed) {
this.stoppageLoading = false
}
}
},
onStoppageMonthChange(e) {
// e.detail.value 为 yyyy-MM-dd这里截取前 7 位当作月份
const v = e.detail && e.detail.value
this.stoppageMonth = v ? v.slice(0, 7) : ''
this.loadStoppageData()
},
getFieldLabel(field) {
if (this.isDestroyed) return field || ''
const meta = this.fieldMeta[field]
return (meta && meta.label) || field
},
getFieldUnit(field) {
if (this.isDestroyed) return ''
const meta = this.fieldMeta[field]
return (meta && meta.unit) || ''
},
// 根据 sourceType + 字段名取实时值(不按设备分块)
getRealtimeValueBySource(sourceType, field) {
if (this.isDestroyed) return '—'
const m = this.latestMeasure || {}
let msg = {}
switch (sourceType) {
case 'ENTRY':
msg = m.appMeasureEntryMessage || {}
break
case 'FURNACE':
msg = m.appMeasureFurnaceMessage || {}
break
case 'COAT':
msg = m.appMeasureCoatMessage || {}
break
case 'EXIT':
msg = m.appMeasureExitMessage || {}
break
default:
msg = {}
}
const v = msg ? msg[field] : null
if (typeof v === 'string') return v || '—'
return this.formatValue(v)
},
// 追加一帧到前端历史缓存(用于统计折线图)
appendChartPoint(payload) {
if (this.isDestroyed) return
const now = new Date()
const hh = String(now.getHours()).padStart(2, '0')
const mm = String(now.getMinutes()).padStart(2, '0')
const ss = String(now.getSeconds()).padStart(2, '0')
const t = `${hh}:${mm}:${ss}`
const entry = payload.appMeasureEntryMessage || {}
const furnace = payload.appMeasureFurnaceMessage || {}
const coat = payload.appMeasureCoatMessage || {}
const exit = payload.appMeasureExitMessage || {}
this.pushSeries('entry', t, {
stripSpeed: entry.stripSpeed,
tensionPorBr1: entry.tensionPorBr1,
tensionBr1Br2: entry.tensionBr1Br2,
tensionBr2Br3: entry.tensionBr2Br3
})
this.pushSeries('furnace', t, {
jcf1FurnaceTemperatureActual: furnace.jcf1FurnaceTemperatureActual,
jcf2FurnaceTemperatureActual: furnace.jcf2FurnaceTemperatureActual,
lbzFurnaceTemperatureActual: furnace.lbzFurnaceTemperatureActual,
lthFurnaceTemperatureActual: furnace.lthFurnaceTemperatureActual,
nof1FurnaceTemperatureActual: furnace.nof1FurnaceTemperatureActual,
nof2FurnaceTemperatureActual: furnace.nof2FurnaceTemperatureActual,
nof3FurnaceTemperatureActual: furnace.nof3FurnaceTemperatureActual,
nof4FurnaceTemperatureActual: furnace.nof4FurnaceTemperatureActual,
nof5FurnaceTemperatureActual: furnace.nof5FurnaceTemperatureActual,
phFurnaceTemperatureActual: furnace.phFurnaceTemperatureActual,
rtf1FurnaceTemperatureActual: furnace.rtf1FurnaceTemperatureActual,
rtf2FurnaceTemperatureActual: furnace.rtf2FurnaceTemperatureActual,
sfFurnaceTemperatureActual: furnace.sfFurnaceTemperatureActual,
tdsFurnaceTemperatureActual: furnace.tdsFurnaceTemperatureActual,
potTemperature: furnace.potTemperature
})
this.pushSeries('coat', t, {
avrCoatingWeightTop: coat.avrCoatingWeightTop,
avrCoatingWeightBottom: coat.avrCoatingWeightBottom,
airKnifePressure: coat.airKnifePressure,
stripSpeedTmExit: coat.stripSpeedTmExit
})
this.pushSeries('exit', t, {
tensionBr8Br9: exit.tensionBr8Br9,
tensionBr9Tr: exit.tensionBr9Tr,
speedExitSection: exit.speedExitSection
})
},
pushSeries(group, time, values) {
if (this.isDestroyed) return
const g = this.chartSeries[group]
if (!g) return
// 带钢速度折线图使用单独的最大点数限制
const maxPoints = group === 'stripSpeed' ? this.stripSpeedMaxPoints : this.chartMaxPoints
// 直接修改数组内容,保持数组引用不变,避免触发图表重新初始化
g.time.push(time)
Object.keys(values).forEach((k) => {
if (!g[k]) return
const raw = values[k]
const num = raw === null || raw === undefined || raw === '' ? null : Number(raw)
g[k].push(Number.isFinite(num) ? num : null)
})
// 如果超过最大点数,去掉最早的数据
if (g.time.length > maxPoints) {
g.time.shift()
Object.keys(values).forEach((k) => {
if (g[k] && g[k].length > maxPoints) {
g[k].shift()
}
})
}
},
// 生成多序列折线图数据
toGroupLineChart(group) {
if (this.isDestroyed) return { categories: [], series: [] }
const g = this.chartSeries[group]
if (!g) return { categories: [], series: [] }
const seriesKeys = Object.keys(g).filter((k) => k !== 'time')
return {
categories: g.time || [],
series: seriesKeys.map((k) => ({
name: this.getFieldLabel(k),
data: g[k] || []
}))
}
},
// 追加带钢速度点到折线图(持续刷新)
appendStripSpeedPoint(payload) {
if (this.isDestroyed) return
const entry = payload.appMeasureEntryMessage || {}
if (entry && entry.stripSpeed !== null && entry.stripSpeed !== undefined) {
const now = new Date()
const hh = String(now.getHours()).padStart(2, '0')
const mm = String(now.getMinutes()).padStart(2, '0')
const ss = String(now.getSeconds()).padStart(2, '0')
const t = `${hh}:${mm}:${ss}`
const speed = Number(entry.stripSpeed)
if (Number.isFinite(speed)) {
this.pushSeries('stripSpeed', t, { stripSpeed: speed })
}
}
},
formatNum(v) {
if (v === null || v === undefined || Number.isNaN(Number(v))) return '—'
return Number(v).toFixed(2)
},
// 不再按设备分块展示pickSourceMsg/getRealtimeFieldValue 已弃用
// 快捷导航(已移除)
async refreshAll() {
if (this.isRefreshing) return
this.isRefreshing = true
try {
await Promise.all([this.loadDeviceDefs(), this.loadFieldMeta()])
this.updateLastTime()
} finally {
if (!this.isDestroyed) {
this.isRefreshing = false
}
}
},
updateLastTime() {
// 如果没有起始时间,使用当前时间作为起始时间
if (!this.startTime) {
this.startTime = new Date()
}
const now = new Date()
const diff = now - this.startTime // 时间差(毫秒)
// 计算天数、小时数、分钟数
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
// 格式化显示
let timeStr = ''
if (days > 0) {
timeStr += `${days}`
}
if (hours > 0 || days > 0) {
timeStr += `${hours}小时`
}
timeStr += `${minutes}分钟`
this.lastUpdateTime = timeStr || '0分钟'
},
formatTime(str) {
if (!str) return ''
const d = new Date(str)
const h = String(d.getHours()).padStart(2, '0')
const m = String(d.getMinutes()).padStart(2, '0')
return `${h}:${m}`
},
// 格式化持续时间:将分钟数转换为"X天X小时X分钟"格式
formatDuration(minutes) {
if (minutes === null || minutes === undefined || minutes === '') return '—'
const totalMinutes = Math.floor(Number(minutes))
if (isNaN(totalMinutes) || totalMinutes < 0) return '—'
const days = Math.floor(totalMinutes / (24 * 60))
const hours = Math.floor((totalMinutes % (24 * 60)) / 60)
const mins = Math.floor(totalMinutes % 60)
let result = ''
if (days > 0) {
result += `${days}`
}
if (hours > 0 || days > 0) {
result += `${hours}小时`
}
if (mins > 0 || result === '') {
result += `${mins}分钟`
}
return result || '0分钟'
},
formatValue(v) {
if (v === null || v === undefined || v === '') return '—'
const n = Number(v)
if (Number.isNaN(n)) return '—'
return n.toFixed(2)
},
}
}
</script>
<style scoped lang="scss">
.page-container {
min-height: 100vh;
background: #f5f7fa;
}
.tab-container {
display: flex;
background: #fff;
border-bottom: 2rpx solid #e4e7ed;
}
.tab-item {
flex: 1;
text-align: center;
padding: 28rpx 0;
position: relative;
.tab-label {
font-size: 28rpx;
color: #606266;
font-weight: 400;
}
&.tab-active {
.tab-label {
color: #0066cc;
font-weight: 500;
}
}
.tab-indicator {
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 60rpx;
height: 3rpx;
background: #0066cc;
}
}
.refresh-btn-fixed {
position: fixed;
right: 32rpx;
bottom: 120rpx;
width: 96rpx;
height: 96rpx;
background: #0066cc;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 20rpx rgba(0, 102, 204, 0.4);
z-index: 999;
&:active {
opacity: 0.8;
transform: scale(0.95);
}
}
.refresh-icon {
font-size: 48rpx;
color: #fff;
display: block;
line-height: 1;
&.rotating {
animation: rotate 1s linear infinite;
}
}
/* 快速导航菜单(固定在左下角,模仿 acidity.vue */
.nav-menu-fixed {
position: fixed;
left: 32rpx;
bottom: 120rpx;
z-index: 998;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12rpx;
}
.nav-toggle {
width: 96rpx;
height: 96rpx;
background: #409eff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 20rpx rgba(64, 158, 255, 0.4);
transition: all 0.3s ease;
&:active {
opacity: 0.8;
transform: scale(0.95);
}
}
.nav-toggle-icon {
font-size: 48rpx;
color: #fff;
display: block;
line-height: 1;
}
.nav-items {
display: flex;
flex-direction: column;
gap: 8rpx;
animation: slideUp 0.3s ease;
max-height: 60vh;
overflow: auto;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.nav-item {
background: #fff;
border: 2rpx solid #409eff;
border-radius: 8rpx;
padding: 16rpx 24rpx;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 12rpx rgba(64, 158, 255, 0.2);
transition: all 0.2s ease;
&:active {
background: #f0f9ff;
transform: scale(0.95);
}
}
.nav-label {
font-size: 26rpx;
color: #409eff;
font-weight: 500;
white-space: nowrap;
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.scroll-container {
height: calc(100vh - 96rpx);
padding: 24rpx;
}
.placeholder-box {
background: #fff;
border: 1rpx dashed #dcdfe6;
border-radius: 12rpx;
padding: 28rpx 20rpx;
margin-bottom: 18rpx;
}
.placeholder-text {
font-size: 24rpx;
color: #909399;
}
.status-bar {
display: flex;
align-items: center;
background: #fff;
padding: 24rpx 32rpx;
margin-bottom: 24rpx;
border-radius: 8rpx;
border: 1rpx solid #e4e7ed;
}
.status-item {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 16rpx;
}
.status-label {
font-size: 26rpx;
color: #909399;
}
.status-value {
font-size: 28rpx;
font-weight: 500;
color: #303133;
&.status-通畅 {
color: #67c23a;
}
&.status-异常 {
color: #f56c6c;
}
&.status-time {
color: #909399;
font-size: 24rpx;
}
}
.status-divider {
width: 1rpx;
height: 40rpx;
background: #e4e7ed;
}
.section {
margin-bottom: 24rpx;
}
.section-title {
font-size: 30rpx;
font-weight: 500;
color: #303133;
margin-bottom: 20rpx;
padding-left: 16rpx;
border-left: 4rpx solid #0066cc;
}
.strip-status-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16rpx;
margin-bottom: 20rpx;
}
.strip-status-card {
background: #fff;
border: 1rpx solid #e4e7ed;
border-radius: 8rpx;
padding: 24rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.strip-status-label {
font-size: 24rpx;
color: #909399;
margin-bottom: 12rpx;
}
.strip-status-value {
font-size: 48rpx;
font-weight: 600;
color: #0066cc;
line-height: 1;
margin-bottom: 8rpx;
}
.strip-status-unit {
font-size: 20rpx;
color: #909399;
margin-bottom: 16rpx;
}
.progress-container {
width: 100%;
margin-top: 8rpx;
}
.progress-bar {
width: 100%;
height: 8rpx;
background: #e4e7ed;
border-radius: 4rpx;
overflow: hidden;
margin-bottom: 8rpx;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #0066cc 0%, #409eff 100%);
border-radius: 4rpx;
transition: width 0.3s ease;
}
.progress-text {
font-size: 20rpx;
color: #606266;
text-align: center;
display: block;
}
.device-card {
background: #fff;
border: 1rpx solid #e4e7ed;
border-radius: 12rpx;
padding: 20rpx;
margin-bottom: 18rpx;
}
.device-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12rpx;
}
.device-header-left {
display: flex;
flex-direction: column;
}
.device-title {
font-size: 30rpx;
font-weight: 600;
color: #303133;
}
.device-sub {
font-size: 22rpx;
color: #909399;
margin-top: 6rpx;
}
.device-toggle {
font-size: 24rpx;
color: #0066cc;
font-weight: 500;
}
.metrics-grid-3 {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16rpx;
}
.metric-box {
background: #fff;
border: 1rpx solid #e4e7ed;
border-radius: 8rpx;
padding: 18rpx 12rpx;
text-align: center;
}
.metric-name {
display: block;
font-size: 22rpx;
color: #909399;
margin-bottom: 12rpx;
}
.metric-value {
display: block;
font-size: 44rpx;
font-weight: 600;
color: #0066cc;
margin-bottom: 8rpx;
line-height: 1;
&.small {
font-size: 34rpx;
}
}
.metric-unit {
display: block;
font-size: 20rpx;
color: #909399;
}
.metric-set-value {
display: block;
font-size: 20rpx;
color: #909399;
margin-top: 4rpx;
}
.device-actions {
display: flex;
justify-content: flex-end;
margin-top: 14rpx;
}
.action-btn {
padding: 12rpx 18rpx;
border: 2rpx solid #0066cc;
border-radius: 8rpx;
}
.action-text {
font-size: 24rpx;
color: #0066cc;
font-weight: 500;
}
.history-tip {
font-size: 24rpx;
color: #909399;
margin-bottom: 12rpx;
}
.history-strong {
color: #303133;
font-weight: 600;
}
.field-selector {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
align-items: center;
margin-bottom: 12rpx;
}
.field-chip {
padding: 10rpx 14rpx;
border-radius: 999rpx;
border: 2rpx solid #dcdfe6;
background: #fff;
}
.field-chip.active {
border-color: #0066cc;
background: #f0f9ff;
}
.chip-text {
font-size: 22rpx;
color: #606266;
}
.field-chip.active .chip-text {
color: #0066cc;
font-weight: 500;
}
.field-tip {
font-size: 22rpx;
color: #909399;
margin-left: auto;
}
.chart-box {
background: #fff;
border: 1rpx solid #e4e7ed;
border-radius: 8rpx;
padding: 24rpx 16rpx;
}
.raw-box {
background: #fff;
border: 1rpx solid #e4e7ed;
border-radius: 8rpx;
padding: 24rpx 16rpx;
}
.overview-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16rpx;
}
.overview-card {
background: #fff;
border-radius: 12rpx;
padding: 32rpx 24rpx;
text-align: center;
border: 1rpx solid #e4e7ed;
}
.overview-card:nth-child(2) {
background: #fff;
}
.overview-card:nth-child(3) {
background: #fff;
}
.overview-label {
display: block;
font-size: 24rpx;
color: #909399;
margin-bottom: 16rpx;
}
.overview-value {
display: block;
font-size: 48rpx;
font-weight: 700;
color: #303133;
line-height: 1.2;
}
.overview-unit {
display: block;
font-size: 22rpx;
color: #909399;
margin-top: 8rpx;
}
.stoppage-filter {
margin: 0 0 20rpx 0;
display: flex;
justify-content: flex-end;
}
.stoppage-month-btn {
padding: 12rpx 24rpx;
border-radius: 999rpx;
border: 1rpx solid #dcdfe6;
background: #fff;
}
.stoppage-month-text {
font-size: 24rpx;
color: #606266;
}
.status-distribution {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16rpx;
}
.status-item-card {
background: #fff;
border: 1rpx solid #e4e7ed;
border-radius: 12rpx;
padding: 24rpx;
display: flex;
justify-content: space-between;
align-items: center;
}
.status-type {
font-size: 28rpx;
color: #606266;
font-weight: 500;
}
.status-count {
font-size: 36rpx;
font-weight: 600;
color: #0066cc;
}
.stats-table {
background: #fff;
border: 1rpx solid #e4e7ed;
border-radius: 8rpx;
overflow: hidden;
}
.stats-header {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1fr 1fr;
background: #f5f7fa;
padding: 20rpx 16rpx;
border-bottom: 1rpx solid #e4e7ed;
}
.stats-row {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1fr 1fr;
padding: 20rpx 16rpx;
border-bottom: 1rpx solid #e4e7ed;
}
.stats-row:last-child {
border-bottom: none;
}
.stats-col {
font-size: 24rpx;
color: #606266;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.stats-header .stats-col {
font-weight: 600;
color: #303133;
}
.detail-cards {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.detail-card {
background: #fff;
border: 1rpx solid #e4e7ed;
border-radius: 12rpx;
overflow: hidden;
transition: all 0.3s ease;
}
.detail-card:active {
transform: scale(0.98);
box-shadow: 0 4rpx 12rpx rgba(0, 102, 204, 0.1);
}
.detail-card-header {
background: linear-gradient(135deg, #0066cc 0%, #409eff 100%);
padding: 20rpx 24rpx;
border-bottom: 1rpx solid #e4e7ed;
}
.detail-card-title {
font-size: 28rpx;
font-weight: 600;
color: #fff;
}
.detail-card-body {
padding: 24rpx;
display: flex;
flex-direction: column;
gap: 16rpx;
}
.detail-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12rpx 0;
border-bottom: 1rpx solid #f5f7fa;
}
.detail-row:last-child {
border-bottom: none;
}
.detail-label {
font-size: 24rpx;
color: #909399;
font-weight: 400;
min-width: 120rpx;
}
.detail-value {
font-size: 26rpx;
color: #303133;
font-weight: 500;
text-align: right;
flex: 1;
}
.stoppage-cards {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.stoppage-card {
background: #fff;
border: 1rpx solid #e4e7ed;
border-radius: 12rpx;
overflow: hidden;
transition: all 0.3s ease;
}
.stoppage-card:active {
transform: scale(0.98);
box-shadow: 0 4rpx 12rpx rgba(245, 108, 108, 0.1);
}
.stoppage-card-header {
background: linear-gradient(135deg, #f56c6c 0%, #ff7875 100%);
padding: 20rpx 24rpx;
border-bottom: 1rpx solid #e4e7ed;
display: flex;
justify-content: space-between;
align-items: center;
}
.stoppage-card-title {
font-size: 28rpx;
font-weight: 600;
color: #fff;
}
.stoppage-card-duration {
font-size: 26rpx;
font-weight: 600;
color: #fff;
background: rgba(255, 255, 255, 0.2);
padding: 6rpx 16rpx;
border-radius: 20rpx;
}
.stoppage-card-body {
padding: 24rpx;
display: flex;
flex-direction: column;
gap: 16rpx;
}
.stoppage-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 12rpx 0;
border-bottom: 1rpx solid #f5f7fa;
}
.stoppage-row:last-child {
border-bottom: none;
}
.stoppage-label {
font-size: 24rpx;
color: #909399;
font-weight: 400;
min-width: 120rpx;
}
.stoppage-value {
font-size: 26rpx;
color: #303133;
font-weight: 500;
text-align: right;
flex: 1;
}
.stoppage-remark {
color: #606266;
font-weight: 400;
word-break: break-all;
white-space: normal;
text-align: left;
}
</style>