1414 lines
39 KiB
Vue
1414 lines
39 KiB
Vue
<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">Socket</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 class="status-divider"></view>
|
||
<view class="status-item status-toggle" @click="toggleChartPause">
|
||
<text class="status-label">折线图</text>
|
||
<text
|
||
class="status-value status-time"
|
||
:class="isChartPaused ? 'status-异常' : 'status-通畅'"
|
||
>
|
||
{{ isChartPaused ? '已暂停刷新' : '实时刷新中' }}
|
||
</text>
|
||
</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.field">
|
||
<text class="metric-name">{{ it.label || getFieldLabel(it.field) }}</text>
|
||
<text class="metric-value">{{ getRealtimeValueBySource('FURNACE', 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('furnace')" :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 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="chart-box" v-if="currentPlan">
|
||
<view class="stats-table">
|
||
<view class="stats-header">
|
||
<text class="stats-col">当前钢卷</text>
|
||
<text class="stats-col">钢种</text>
|
||
<text class="stats-col">规格 (厚×宽)</text>
|
||
<text class="stats-col">计划长度</text>
|
||
<text class="stats-col">计划重量</text>
|
||
</view>
|
||
<view class="stats-row">
|
||
<text class="stats-col">{{ currentPlan.exitMatId || '—' }}</text>
|
||
<text class="stats-col">{{ currentPlan.steelGrade || '—' }}</text>
|
||
<text class="stats-col">
|
||
{{ formatNum(currentPlan.exitThickness) }} × {{ formatNum(currentPlan.exitWidth) }}
|
||
</text>
|
||
<text class="stats-col">{{ formatNum(currentPlan.exitLength) }}</text>
|
||
<text class="stats-col">{{ formatNum(currentPlan.exitWeight) }}</text>
|
||
</view>
|
||
</view>
|
||
<view class="history-tip" v-if="!currentPlan">
|
||
<text>暂无当前生产计划数据。</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<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="stats-table" v-if="reportDetails && reportDetails.length">
|
||
<view class="stats-header">
|
||
<text class="stats-col">成品卷号</text>
|
||
<text class="stats-col">原料卷号</text>
|
||
<text class="stats-col">班/组</text>
|
||
<text class="stats-col">规格 (厚×宽)</text>
|
||
<text class="stats-col">长度/重量</text>
|
||
</view>
|
||
<view
|
||
class="stats-row"
|
||
v-for="item in reportDetails"
|
||
:key="item.exitMatId + '_' + item.onlineTime"
|
||
>
|
||
<text class="stats-col">{{ item.exitMatId }}</text>
|
||
<text class="stats-col">{{ item.entryMatId }}</text>
|
||
<text class="stats-col">{{ item.groupNo || '—' }}/{{ item.shiftNo || '—' }}</text>
|
||
<text class="stats-col">
|
||
{{ formatNum(item.exitThickness) }} × {{ formatNum(item.exitWidth) }}
|
||
</text>
|
||
<text class="stats-col">
|
||
{{ formatNum(item.exitLength) }} m / {{ formatNum(item.actualWeight) }} t
|
||
</text>
|
||
</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="stats-table" v-if="stoppageList && stoppageList.length">
|
||
<view class="stats-header">
|
||
<text class="stats-col">开始时间</text>
|
||
<text class="stats-col">结束时间</text>
|
||
<text class="stats-col">持续时间</text>
|
||
<text class="stats-col">区域/设备</text>
|
||
<text class="stats-col">原因</text>
|
||
</view>
|
||
<view
|
||
class="stats-row"
|
||
v-for="item in stoppageList"
|
||
:key="item.stopid"
|
||
>
|
||
<text class="stats-col">{{ item.startDate }}</text>
|
||
<text class="stats-col">{{ item.endDate }}</text>
|
||
<text class="stats-col">{{ item.duration }}</text>
|
||
<text class="stats-col">
|
||
{{ item.area || '—' }}/{{ item.seton || '—' }}
|
||
</text>
|
||
<text class="stats-col">{{ item.remark || '—' }}</text>
|
||
</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: '—',
|
||
// 折线图实时刷新开关(避免查看某个点时被新数据刷掉)
|
||
isChartPaused: false,
|
||
|
||
// socket最新消息
|
||
latestMeasure: null,
|
||
|
||
// 设备定义(后端 DeviceEnum 全量)
|
||
deviceDefs: [],
|
||
|
||
// 字段元数据:fieldName -> {label, unit, description}
|
||
fieldMeta: {},
|
||
|
||
// device_field_trend: deviceCode -> { fieldName -> trendDTO }
|
||
fieldTrendMap: {},
|
||
|
||
// 懒加载:已订阅的设备列表
|
||
subscribedDeviceCodes: [],
|
||
|
||
// 实时数据卡片定义(按酸轧页组织,不按设备分块)
|
||
entryMetrics: [
|
||
{ field: 'entryCoilId', label: '入口卷号', unit: '' },
|
||
{ 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: [
|
||
{ field: 'phFurnaceTemperatureActual', label: 'PH炉温', unit: '℃' },
|
||
{ field: 'rtf1FurnaceTemperatureActual', label: 'RTF1炉温', unit: '℃' },
|
||
{ field: 'potTemperature', label: '锌锅温度', unit: '℃' },
|
||
{ field: 'potPower', label: '锌锅功率', 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: '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,
|
||
chartSeries: {
|
||
entry: { time: [], stripSpeed: [], tensionPorBr1: [], tensionBr1Br2: [], tensionBr2Br3: [] },
|
||
furnace: { time: [], phFurnaceTemperatureActual: [], rtf1FurnaceTemperatureActual: [], potTemperature: [] },
|
||
coat: { time: [], avrCoatingWeightTop: [], avrCoatingWeightBottom: [], airKnifePressure: [], stripSpeedTmExit: [] },
|
||
exit: { time: [], tensionBr8Br9: [], tensionBr9Tr: [], speedExitSection: [] }
|
||
},
|
||
|
||
// 生产统计(实绩)
|
||
currentPlan: null,
|
||
currentProcess: null,
|
||
reportSummary: null,
|
||
reportDetails: [],
|
||
reportLoading: false,
|
||
|
||
// 停机记录
|
||
stoppageList: [],
|
||
stoppageLoading: false,
|
||
stoppageMonth: '', // yyyy-MM
|
||
|
||
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' } }
|
||
},
|
||
|
||
socketClient: null,
|
||
fieldTrendSocket: null,
|
||
fieldTrendSubscribed: false
|
||
}
|
||
},
|
||
|
||
mounted() {
|
||
this.initSocket()
|
||
|
||
Promise.all([this.loadDeviceDefs(), this.loadFieldMeta(), this.loadHistoryForCharts()]).then(() => {})
|
||
|
||
this.updateLastTime()
|
||
|
||
// 周期性从后端快照刷新折线图数据(降低刷新频率,避免频繁打断用户查看)
|
||
// 这里用 60 秒刷一次,后端快照是每 5 分钟一条,折线图变化会比较平滑
|
||
this._historyTimer = setInterval(() => {
|
||
if (!this.isChartPaused) {
|
||
this.loadHistoryForCharts()
|
||
}
|
||
}, 60 * 1000)
|
||
},
|
||
|
||
computed: {
|
||
stoppageMonthLabel() {
|
||
if (!this.stoppageMonth) return '选择月份'
|
||
const parts = (this.stoppageMonth || '').split('-')
|
||
if (parts.length < 2) return this.stoppageMonth
|
||
const [y, m] = parts
|
||
return `${y}年${m}月`
|
||
}
|
||
},
|
||
|
||
watch: {
|
||
currentTab(newVal) {
|
||
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() {
|
||
if (this.socketClient) this.socketClient.close()
|
||
if (this.fieldTrendSocket) this.fieldTrendSocket.close()
|
||
if (this._historyTimer) {
|
||
clearInterval(this._historyTimer)
|
||
this._historyTimer = null
|
||
}
|
||
},
|
||
|
||
methods: {
|
||
async loadDeviceDefs() {
|
||
const res = await listDeviceEnumAll()
|
||
// 打印响应,排查是否拿到 data 数组(避免页面空白)
|
||
try {
|
||
console.log(res)
|
||
} catch (e) {
|
||
// ignore
|
||
}
|
||
this.deviceDefs = (res && res.data) || []
|
||
|
||
|
||
},
|
||
|
||
async loadFieldMeta() {
|
||
const res = await getDeviceFieldMetaAll()
|
||
// 打印响应,排查字段元数据是否加载成功
|
||
try {
|
||
const keys = res && res.data && typeof res.data === 'object' ? Object.keys(res.data).length : -1
|
||
console.log('[resp]/api/deviceFieldMeta/all:', {
|
||
code: res && res.code,
|
||
msg: res && res.msg,
|
||
dataType: res && res.data ? Object.prototype.toString.call(res.data) : 'null',
|
||
keyCount: keys
|
||
})
|
||
} catch (e) {
|
||
// ignore
|
||
}
|
||
this.fieldMeta = (res && res.data) || {}
|
||
},
|
||
|
||
// 加载历史快照,填充折线图的“历史部分”
|
||
async loadHistoryForCharts() {
|
||
try {
|
||
// 使用设备快照作为折线图唯一数据源,避免每条 socket 消息都触发重绘
|
||
// 每次加载前先清空本地缓存,防止重复累加
|
||
this.chartSeries = {
|
||
entry: { time: [], stripSpeed: [], tensionPorBr1: [], tensionBr1Br2: [], tensionBr2Br3: [] },
|
||
furnace: { time: [], phFurnaceTemperatureActual: [], rtf1FurnaceTemperatureActual: [], potTemperature: [] },
|
||
coat: { time: [], avrCoatingWeightTop: [], avrCoatingWeightBottom: [], airKnifePressure: [], stripSpeedTmExit: [] },
|
||
exit: { time: [], tensionBr8Br9: [], tensionBr9Tr: [], speedExitSection: [] }
|
||
}
|
||
|
||
const limit = this.chartMaxPoints
|
||
const tasks = []
|
||
|
||
const deviceMap = {
|
||
entry: { deviceCode: 'POR1', fields: ['stripSpeed', 'tensionPorBr1', 'tensionBr1Br2', 'tensionBr2Br3'] },
|
||
furnace: {
|
||
deviceCode: 'FUR2',
|
||
fields: ['phFurnaceTemperatureActual', 'rtf1FurnaceTemperatureActual', '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) => {
|
||
const rows = (res && res.data) || []
|
||
// 按时间升序
|
||
const list = rows.slice().reverse()
|
||
list.forEach((row) => {
|
||
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() {
|
||
// 实时测量数据 WebSocket
|
||
this.socketClient = createMeasureSocket({
|
||
type: 'track_measure',
|
||
onOpen: () => {
|
||
console.log('初始化socket成功')
|
||
this.isConnected = true
|
||
},
|
||
onClose: () => {
|
||
this.isConnected = false
|
||
},
|
||
onError: () => {
|
||
this.isConnected = false
|
||
},
|
||
onMessage: (data) => {
|
||
try {
|
||
// 兼容字符串 / 已解析对象两种情况,并打日志方便排查
|
||
let payload = null
|
||
if (typeof data === 'string') {
|
||
payload = JSON.parse(data)
|
||
} else if (data && typeof data === 'object') {
|
||
// uni 有时会包一层 { data: 'xxx' }
|
||
if (typeof data.data === 'string') {
|
||
payload = JSON.parse(data.data)
|
||
} else {
|
||
payload = data
|
||
}
|
||
}
|
||
if (!payload) return
|
||
this.latestMeasure = payload
|
||
// 实时卡片仍然使用最新测量值,折线图改为走后端快照,避免高频刷新
|
||
this.updateLastTime()
|
||
} catch (e) {
|
||
console.error('解析 track_measure 数据失败:', e, data)
|
||
}
|
||
}
|
||
})
|
||
|
||
this.socketClient.connect()
|
||
|
||
// 点位趋势 WebSocket(懒加载订阅)
|
||
this.fieldTrendSocket = createMeasureSocket({
|
||
type: 'device_field_trend',
|
||
onOpen: () => {
|
||
console.log('点位趋势 WebSocket 连接成功')
|
||
// 初次连接后先不订阅,等滚动/展开触发
|
||
this.fieldTrendSubscribed = true
|
||
this.sendFieldTrendSubscribe([])
|
||
},
|
||
onClose: () => {
|
||
console.log('点位趋势 WebSocket 连接关闭')
|
||
this.fieldTrendSubscribed = false
|
||
},
|
||
onError: () => {
|
||
console.error('点位趋势 WebSocket 连接错误')
|
||
this.fieldTrendSubscribed = false
|
||
},
|
||
onMessage: (data) => {
|
||
try {
|
||
const trend = JSON.parse(data)
|
||
if (!trend || !trend.deviceCode || !trend.fieldName) return
|
||
if (!this.fieldTrendMap[trend.deviceCode]) {
|
||
this.$set(this.fieldTrendMap, trend.deviceCode, {})
|
||
}
|
||
this.$set(this.fieldTrendMap[trend.deviceCode], trend.fieldName, trend)
|
||
} catch (e) {
|
||
console.error('解析点位趋势数据失败:', e)
|
||
}
|
||
}
|
||
})
|
||
|
||
this.fieldTrendSocket.connect()
|
||
},
|
||
|
||
// 加载生产统计 / 实绩数据
|
||
async loadReportData() {
|
||
if (this.reportLoading) return
|
||
this.reportLoading = true
|
||
try {
|
||
// 当前计划 & 当前工艺
|
||
try {
|
||
const planRes = await getCurrentPlan()
|
||
this.currentPlan = (planRes && planRes.data) || null
|
||
} catch (e) {
|
||
this.currentPlan = null
|
||
}
|
||
try {
|
||
const procRes = await getCurrentProcess()
|
||
this.currentProcess = (procRes && procRes.data) || null
|
||
} catch (e) {
|
||
this.currentProcess = null
|
||
}
|
||
|
||
// 实绩汇总 & 明细:不传筛选条件,后端按默认时间范围处理
|
||
try {
|
||
const sumRes = await getReportSummary({})
|
||
this.reportSummary = sumRes || null
|
||
} catch (e) {
|
||
this.reportSummary = null
|
||
}
|
||
try {
|
||
const detRes = await getReportDetails({})
|
||
this.reportDetails = (detRes && Array.isArray(detRes) ? detRes : []) || []
|
||
} catch (e) {
|
||
this.reportDetails = []
|
||
}
|
||
} finally {
|
||
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 })
|
||
this.stoppageList = (res && res.data) || []
|
||
} catch (e) {
|
||
this.stoppageList = []
|
||
} finally {
|
||
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()
|
||
},
|
||
|
||
// 滚动事件:节流触发订阅更新
|
||
onScroll() {
|
||
if (this._scrollTimer) return
|
||
this._scrollTimer = setTimeout(() => {
|
||
this._scrollTimer = null
|
||
this.updateVisibleDeviceSubscriptions()
|
||
}, 200)
|
||
},
|
||
|
||
// 计算当前可视区域内的设备,并向后端发送订阅(懒加载)
|
||
updateVisibleDeviceSubscriptions() {
|
||
const list = this.deviceDefs || []
|
||
if (!list.length) return
|
||
|
||
// 使用 selectorQuery 获取每个设备块的位置
|
||
const query = uni.createSelectorQuery().in(this)
|
||
list.forEach((d) => {
|
||
query.select('#device-' + d.deviceCode).boundingClientRect()
|
||
})
|
||
query.selectViewport().scrollOffset()
|
||
|
||
query.exec((res) => {
|
||
// res: [rect1, rect2, ..., viewport]
|
||
if (!res || res.length < 2) return
|
||
const viewport = res[res.length - 1] || {}
|
||
const scrollTop = viewport.scrollTop || 0
|
||
const windowHeight = viewport.windowHeight || 0
|
||
|
||
// 预加载阈值:提前 0.5 屏开始订阅
|
||
const preload = windowHeight * 0.5
|
||
const bottom = scrollTop + windowHeight + preload
|
||
|
||
// 找到“已经进入/即将进入”的最大索引
|
||
let maxIdx = -1
|
||
for (let i = 0; i < list.length; i++) {
|
||
const rect = res[i]
|
||
if (!rect) continue
|
||
const top = rect.top + scrollTop
|
||
if (top <= bottom) {
|
||
maxIdx = i
|
||
}
|
||
}
|
||
|
||
// 订阅规则:当前可视(含预加载)位置的设备及其以前全部设备
|
||
const nextList = maxIdx >= 0 ? list.slice(0, maxIdx + 1).map((d) => d.deviceCode) : []
|
||
|
||
if (JSON.stringify(nextList) === JSON.stringify(this.subscribedDeviceCodes || [])) return
|
||
this.subscribedDeviceCodes = nextList
|
||
this.sendFieldTrendSubscribe(nextList)
|
||
})
|
||
},
|
||
|
||
|
||
|
||
getFieldLabel(field) {
|
||
const meta = this.fieldMeta[field]
|
||
return (meta && meta.label) || field
|
||
},
|
||
|
||
getFieldUnit(field) {
|
||
const meta = this.fieldMeta[field]
|
||
return (meta && meta.unit) || ''
|
||
},
|
||
|
||
sendFieldTrendSubscribe(deviceCodes) {
|
||
if (!this.fieldTrendSocket || !this.fieldTrendSubscribed) return
|
||
this.fieldTrendSocket.send({
|
||
lazy: true,
|
||
deviceCodes: deviceCodes || []
|
||
})
|
||
},
|
||
|
||
// 折线图数据转换(仅结构转换,不做计算)
|
||
toLineChart(trend, fieldName) {
|
||
if (!trend) return { categories: [], series: [] }
|
||
return {
|
||
categories: trend.categories || [],
|
||
series: [
|
||
{
|
||
name: this.getFieldLabel(fieldName),
|
||
data: trend.data || []
|
||
}
|
||
]
|
||
}
|
||
},
|
||
|
||
// 根据 sourceType + 字段名取实时值(不按设备分块)
|
||
getRealtimeValueBySource(sourceType, field) {
|
||
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) {
|
||
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, {
|
||
phFurnaceTemperatureActual: furnace.phFurnaceTemperatureActual,
|
||
rtf1FurnaceTemperatureActual: furnace.rtf1FurnaceTemperatureActual,
|
||
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) {
|
||
const g = this.chartSeries[group]
|
||
if (!g) return
|
||
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 > this.chartMaxPoints) {
|
||
g.time.shift()
|
||
Object.keys(values).forEach((k) => {
|
||
if (g[k] && g[k].length > this.chartMaxPoints) g[k].shift()
|
||
})
|
||
}
|
||
},
|
||
|
||
// 生成多序列折线图数据
|
||
toGroupLineChart(group) {
|
||
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] || []
|
||
}))
|
||
}
|
||
},
|
||
|
||
// 切换折线图实时刷新状态
|
||
toggleChartPause() {
|
||
this.isChartPaused = !this.isChartPaused
|
||
},
|
||
|
||
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.$nextTick(() => {
|
||
this.updateVisibleDeviceSubscriptions()
|
||
})
|
||
this.updateLastTime()
|
||
} finally {
|
||
this.isRefreshing = false
|
||
}
|
||
},
|
||
|
||
updateLastTime() {
|
||
const now = new Date()
|
||
const hour = String(now.getHours()).padStart(2, '0')
|
||
const minute = String(now.getMinutes()).padStart(2, '0')
|
||
const second = String(now.getSeconds()).padStart(2, '0')
|
||
this.lastUpdateTime = `${hour}:${minute}:${second}`
|
||
},
|
||
|
||
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}`
|
||
},
|
||
|
||
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;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
</style>
|