Files
klp-mono/apps/hand-factory/components/lines/zinc1.vue

805 lines
19 KiB
Vue
Raw Normal View History

2025-10-27 13:21:43 +08:00
<template>
2025-10-31 19:10:08 +08:00
<view class="page-container">
2025-10-29 15:38:20 +08:00
<!-- 简洁标签栏 -->
<view class="tab-container">
2026-01-05 14:29:33 +08:00
<view
v-for="item in tabData"
2025-10-29 15:38:20 +08:00
: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>
2025-10-27 13:21:43 +08:00
</view>
2026-01-05 14:29:33 +08:00
2025-10-31 19:10:08 +08:00
<!-- 刷新按钮 -->
2026-01-05 14:29:33 +08:00
<view class="refresh-btn-fixed" @click="refreshAll">
<text class="refresh-icon" :class="{ rotating: isRefreshing }"></text>
</view>
2025-10-27 13:21:43 +08:00
2026-01-05 14:29:33 +08:00
<scroll-view scroll-y class="scroll-container" v-if="currentTab === 1" @scroll="onScroll">
<!-- 顶部状态栏 -->
<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>
2025-10-27 13:21:43 +08:00
</view>
2026-01-05 14:29:33 +08:00
<view v-for="dev in deviceDefs" :key="'device_' + dev.deviceCode" :id="'device-' + dev.deviceCode" class="device-section">
<view class="section-title">{{ dev.desc }}{{ dev.deviceCode }}</view>
<view v-if="fieldTrendMap[dev.deviceCode]">
<view v-for="fieldName in (dev.paramFields || [])" :key="dev.deviceCode + '_' + fieldName" class="field-chart-container">
<view v-if="fieldTrendMap[dev.deviceCode][fieldName]">
<view class="chart-header">
<text class="chart-title">{{ getFieldLabel(fieldName) }}</text>
<text class="chart-unit">{{ getFieldUnit(fieldName) }}</text>
</view>
<view class="chart-box">
<qiun-data-charts type="line" :chartData="toLineChart(fieldTrendMap[dev.deviceCode][fieldName], fieldName)" :opts="lineChartOpts" />
</view>
<view class="stats-grid">
<view class="stat-item"><text class="stat-label">AVG</text><text class="stat-value">{{ formatNum(fieldTrendMap[dev.deviceCode][fieldName].avg) }}</text></view>
<view class="stat-item"><text class="stat-label">MAX</text><text class="stat-value">{{ formatNum(fieldTrendMap[dev.deviceCode][fieldName].max) }}</text></view>
<view class="stat-item"><text class="stat-label">MIN</text><text class="stat-value">{{ formatNum(fieldTrendMap[dev.deviceCode][fieldName].min) }}</text></view>
<view class="stat-item"><text class="stat-label">LAST</text><text class="stat-value">{{ formatNum(fieldTrendMap[dev.deviceCode][fieldName].last) }}</text></view>
</view>
</view>
<view v-else class="placeholder-box">
<text class="placeholder-text">等待 {{ getFieldLabel(fieldName) }} 数据...</text>
</view>
</view>
</view>
<view v-else class="placeholder-box">
<text class="placeholder-text">等待 {{ dev.desc }} 数据...滚动到此处加载</text>
</view>
</view>
2025-10-27 13:21:43 +08:00
</scroll-view>
</view>
</template>
<script>
2026-01-05 14:29:33 +08:00
import { listDeviceEnumAll } from '@/api/pocket/deviceEnum'
import { getDeviceFieldMetaAll } from '@/api/pocket/deviceFieldMeta'
import { createMeasureSocket } from '@/utils/socketMeasure'
2025-10-31 14:50:19 +08:00
2025-10-27 13:21:43 +08:00
export default {
data() {
return {
2025-10-31 19:10:08 +08:00
currentTab: 1,
tabData: [
2026-01-05 14:29:33 +08:00
{ text: '实时监控', value: 1 }
2025-10-27 13:21:43 +08:00
],
2025-10-31 19:10:08 +08:00
isRefreshing: false,
2026-01-05 14:29:33 +08:00
isConnected: false,
lastUpdateTime: '—',
// socket最新消息
latestMeasure: null,
// 设备定义(后端 DeviceEnum 全量)
deviceDefs: [],
// 字段元数据fieldName -> {label, unit, description}
fieldMeta: {},
// device_field_trend: deviceCode -> { fieldName -> trendDTO }
fieldTrendMap: {},
// 懒加载:已订阅的设备列表
subscribedDeviceCodes: [],
2025-10-31 19:10:08 +08:00
lineChartOpts: {
2026-01-05 14:29:33 +08:00
color: ['#0066cc', '#409eff', '#66b1ff', '#a0cfff', '#d9ecff', '#ecf5ff'],
2025-10-31 19:10:08 +08:00
padding: [15, 15, 0, 15],
enableScroll: false,
2026-01-05 14:29:33 +08:00
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 },
2025-10-31 19:10:08 +08:00
yAxis: {
2026-01-05 14:29:33 +08:00
gridType: 'dash',
2025-10-31 19:10:08 +08:00
dashLength: 4,
2026-01-05 14:29:33 +08:00
gridColor: '#e4e7ed',
2025-10-31 19:10:08 +08:00
showTitle: true,
fontSize: 10,
2026-01-05 14:29:33 +08:00
data: [{ title: '数值' }]
2025-10-31 19:10:08 +08:00
},
2026-01-05 14:29:33 +08:00
extra: { line: { type: 'curve', width: 2, activeType: 'hollow' } }
2025-10-31 19:10:08 +08:00
},
2026-01-05 14:29:33 +08:00
socketClient: null,
fieldTrendSocket: null,
fieldTrendSubscribed: false,
subscribedDeviceCodes: []
}
2025-10-27 13:21:43 +08:00
},
2026-01-05 14:29:33 +08:00
2025-10-27 13:21:43 +08:00
mounted() {
2026-01-05 14:29:33 +08:00
this.initSocket()
Promise.all([this.loadDeviceDefs(), this.loadFieldMeta()]).then(() => {
// 页面初始化完成后,默认订阅首屏设备(懒加载)
this.$nextTick(() => {
this.updateVisibleDeviceSubscriptions()
})
})
this.updateLastTime()
2025-10-31 19:10:08 +08:00
},
2026-01-05 14:29:33 +08:00
2025-10-31 19:10:08 +08:00
beforeDestroy() {
2026-01-05 14:29:33 +08:00
if (this.socketClient) this.socketClient.close()
if (this.fieldTrendSocket) this.fieldTrendSocket.close()
2025-10-27 13:21:43 +08:00
},
2026-01-05 14:29:33 +08:00
2025-10-27 13:21:43 +08:00
methods: {
2026-01-05 14:29:33 +08:00
async loadDeviceDefs() {
const res = await listDeviceEnumAll()
this.deviceDefs = (res && res.data) || []
2025-10-31 19:10:08 +08:00
},
2026-01-05 14:29:33 +08:00
async loadFieldMeta() {
const res = await getDeviceFieldMetaAll()
this.fieldMeta = (res && res.data) || {}
2025-10-31 19:10:08 +08:00
},
2026-01-05 14:29:33 +08:00
initSocket() {
// 实时测量数据 WebSocket
this.socketClient = createMeasureSocket({
type: 'track_measure',
onOpen: () => {
this.isConnected = true
},
onClose: () => {
this.isConnected = false
},
onError: () => {
this.isConnected = false
},
onMessage: (data) => {
try {
this.latestMeasure = JSON.parse(data)
this.updateLastTime()
} catch (e) {
// ignore
}
2025-10-31 19:10:08 +08:00
}
})
2026-01-05 14:29:33 +08:00
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, {})
2025-10-31 19:10:08 +08:00
}
2026-01-05 14:29:33 +08:00
this.$set(this.fieldTrendMap[trend.deviceCode], trend.fieldName, trend)
} catch (e) {
console.error('解析点位趋势数据失败:', e)
2025-10-31 19:10:08 +08:00
}
2026-01-05 14:29:33 +08:00
}
2025-10-31 19:10:08 +08:00
})
2026-01-05 14:29:33 +08:00
this.fieldTrendSocket.connect()
},
// 滚动事件:节流触发订阅更新
onScroll() {
if (this._scrollTimer) return
this._scrollTimer = setTimeout(() => {
this._scrollTimer = null
this.updateVisibleDeviceSubscriptions()
}, 200)
2025-10-31 19:10:08 +08:00
},
2026-01-05 14:29:33 +08:00
// 计算当前可视区域内的设备,并向后端发送订阅(懒加载)
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
}
2025-10-31 14:50:19 +08:00
}
2026-01-05 14:29:33 +08:00
// 订阅规则:当前可视(含预加载)位置的设备及其以前全部设备
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)
2025-10-31 14:50:19 +08:00
})
},
2026-01-05 14:29:33 +08:00
getFieldLabel(field) {
const meta = this.fieldMeta[field]
return (meta && meta.label) || field
2025-10-31 19:10:08 +08:00
},
2026-01-05 14:29:33 +08:00
getFieldUnit(field) {
const meta = this.fieldMeta[field]
return (meta && meta.unit) || ''
2025-10-31 19:10:08 +08:00
},
2026-01-05 14:29:33 +08:00
sendFieldTrendSubscribe(deviceCodes) {
if (!this.fieldTrendSocket || !this.fieldTrendSubscribed) return
this.fieldTrendSocket.send({
lazy: true,
deviceCodes: deviceCodes || []
2025-10-31 19:10:08 +08:00
})
},
2026-01-05 14:29:33 +08:00
// 折线图数据转换(仅结构转换,不做计算)
toLineChart(trend, fieldName) {
if (!trend) return { categories: [], series: [] }
return {
categories: trend.categories || [],
series: [
{
name: this.getFieldLabel(fieldName),
data: trend.data || []
2025-10-31 14:50:19 +08:00
}
2026-01-05 14:29:33 +08:00
]
}
2025-10-31 14:50:19 +08:00
},
2026-01-05 14:29:33 +08:00
formatNum(v) {
if (v === null || v === undefined || Number.isNaN(Number(v))) return '—'
return Number(v).toFixed(2)
2025-10-31 14:50:19 +08:00
},
2026-01-05 14:29:33 +08:00
// 根据 DeviceEnum.sourceType 决定取哪个 message
pickSourceMsg(dev) {
const m = this.latestMeasure || {}
if (!dev || !dev.sourceType) return {}
switch (dev.sourceType) {
case 'ENTRY':
return m.appMeasureEntryMessage || {}
case 'FURNACE':
return m.appMeasureFurnaceMessage || {}
case 'COAT':
return m.appMeasureCoatMessage || {}
case 'EXIT':
return m.appMeasureExitMessage || {}
default:
return {}
}
2025-10-31 19:10:08 +08:00
},
2026-01-05 14:29:33 +08:00
getRealtimeFieldValue(dev, field) {
const msg = this.pickSourceMsg(dev)
const v = msg ? msg[field] : null
return this.formatValue(v)
},
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)
},
2025-10-27 13:21:43 +08:00
}
2026-01-05 14:29:33 +08:00
}
2025-10-27 13:21:43 +08:00
</script>
<style scoped lang="scss">
2025-10-31 19:10:08 +08:00
.page-container {
min-height: 100vh;
background: #f5f7fa;
}
2025-10-29 15:38:20 +08:00
.tab-container {
2025-10-31 19:10:08 +08:00
display: flex;
background: #fff;
border-bottom: 2rpx solid #e4e7ed;
2025-10-27 13:21:43 +08:00
}
2025-10-29 15:38:20 +08:00
.tab-item {
flex: 1;
text-align: center;
2025-10-31 19:10:08 +08:00
padding: 28rpx 0;
2025-10-29 15:38:20 +08:00
position: relative;
2026-01-05 14:29:33 +08:00
.tab-label {
font-size: 28rpx;
2025-10-31 19:10:08 +08:00
color: #606266;
2026-01-05 14:29:33 +08:00
font-weight: 400;
2025-10-31 19:10:08 +08:00
}
2026-01-05 14:29:33 +08:00
2025-10-31 19:10:08 +08:00
&.tab-active {
.tab-label {
color: #0066cc;
font-weight: 500;
}
2026-01-05 14:29:33 +08:00
}
2025-10-29 15:38:20 +08:00
2026-01-05 14:29:33 +08:00
.tab-indicator {
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
2025-10-31 19:10:08 +08:00
width: 60rpx;
height: 3rpx;
background: #0066cc;
}
2025-10-27 13:21:43 +08:00
}
2025-10-31 19:10:08 +08:00
.refresh-btn-fixed {
position: fixed;
right: 32rpx;
bottom: 120rpx;
width: 96rpx;
height: 96rpx;
background: #0066cc;
border-radius: 50%;
2025-10-27 13:21:43 +08:00
display: flex;
2025-10-31 19:10:08 +08:00
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 20rpx rgba(0, 102, 204, 0.4);
z-index: 999;
2026-01-05 14:29:33 +08:00
2025-10-31 19:10:08 +08:00
&:active {
opacity: 0.8;
transform: scale(0.95);
}
2025-10-27 13:21:43 +08:00
}
2025-10-31 19:10:08 +08:00
.refresh-icon {
font-size: 48rpx;
color: #fff;
display: block;
line-height: 1;
2026-01-05 14:29:33 +08:00
2025-10-31 19:10:08 +08:00
&.rotating {
animation: rotate 1s linear infinite;
}
2025-10-27 13:21:43 +08:00
}
2025-10-31 19:10:08 +08:00
@keyframes rotate {
2026-01-05 14:29:33 +08:00
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
2025-10-27 13:21:43 +08:00
}
2025-10-31 19:10:08 +08:00
.scroll-container {
height: calc(100vh - 96rpx);
padding: 24rpx;
2025-10-27 13:21:43 +08:00
}
2025-10-31 19:10:08 +08:00
.status-bar {
display: flex;
align-items: center;
2025-10-27 13:21:43 +08:00
background: #fff;
2025-10-31 19:10:08 +08:00
padding: 24rpx 32rpx;
margin-bottom: 24rpx;
border-radius: 8rpx;
border: 1rpx solid #e4e7ed;
2025-10-27 13:21:43 +08:00
}
2025-10-31 19:10:08 +08:00
.status-item {
flex: 1;
2025-10-27 13:21:43 +08:00
display: flex;
2025-10-31 19:10:08 +08:00
align-items: center;
justify-content: center;
gap: 16rpx;
}
2025-10-27 13:21:43 +08:00
2025-10-31 19:10:08 +08:00
.status-label {
font-size: 26rpx;
color: #909399;
2025-10-27 13:21:43 +08:00
}
2025-10-31 19:10:08 +08:00
.status-value {
font-size: 28rpx;
font-weight: 500;
color: #303133;
2026-01-05 14:29:33 +08:00
&.status-通畅 {
color: #67c23a;
}
&.status-异常 {
color: #f56c6c;
}
2025-10-31 19:10:08 +08:00
&.status-time {
color: #909399;
font-size: 24rpx;
2025-10-29 15:38:20 +08:00
}
2025-10-27 13:21:43 +08:00
}
2025-10-31 19:10:08 +08:00
.status-divider {
width: 1rpx;
height: 40rpx;
background: #e4e7ed;
2025-10-27 13:21:43 +08:00
}
2025-10-31 19:10:08 +08:00
.section {
margin-bottom: 24rpx;
}
.section-title {
font-size: 30rpx;
2025-10-27 13:21:43 +08:00
font-weight: 500;
2025-10-31 19:10:08 +08:00
color: #303133;
margin-bottom: 20rpx;
padding-left: 16rpx;
border-left: 4rpx solid #0066cc;
}
2026-01-05 14:29:33 +08:00
.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;
}
2025-10-31 19:10:08 +08:00
.metrics-grid-3 {
display: grid;
grid-template-columns: repeat(3, 1fr);
2026-01-05 14:29:33 +08:00
gap: 16rpx;
2025-10-29 15:38:20 +08:00
}
2025-10-31 19:10:08 +08:00
.metric-box {
2025-10-29 15:38:20 +08:00
background: #fff;
2025-10-31 19:10:08 +08:00
border: 1rpx solid #e4e7ed;
border-radius: 8rpx;
2026-01-05 14:29:33 +08:00
padding: 18rpx 12rpx;
2025-10-31 19:10:08 +08:00
text-align: center;
2025-10-29 15:38:20 +08:00
}
2025-10-31 19:10:08 +08:00
.metric-name {
display: block;
2026-01-05 14:29:33 +08:00
font-size: 22rpx;
2025-10-31 19:10:08 +08:00
color: #909399;
2026-01-05 14:29:33 +08:00
margin-bottom: 12rpx;
2025-10-31 19:10:08 +08:00
}
.metric-value {
display: block;
2026-01-05 14:29:33 +08:00
font-size: 44rpx;
font-weight: 600;
2025-10-31 19:10:08 +08:00
color: #0066cc;
margin-bottom: 8rpx;
line-height: 1;
2026-01-05 14:29:33 +08:00
&.small {
font-size: 34rpx;
}
2025-10-31 19:10:08 +08:00
}
.metric-unit {
display: block;
2026-01-05 14:29:33 +08:00
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 {
2025-10-31 19:10:08 +08:00
font-size: 22rpx;
color: #909399;
2026-01-05 14:29:33 +08:00
margin-left: auto;
2025-10-31 19:10:08 +08:00
}
.chart-box {
background: #fff;
border: 1rpx solid #e4e7ed;
border-radius: 8rpx;
padding: 24rpx 16rpx;
2025-10-27 13:21:43 +08:00
}
2025-12-07 12:54:39 +08:00
2026-01-05 14:29:33 +08:00
.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: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12rpx;
padding: 32rpx 24rpx;
text-align: center;
box-shadow: 0 4rpx 12rpx rgba(102, 126, 234, 0.3);
}
.overview-card:nth-child(2) {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
box-shadow: 0 4rpx 12rpx rgba(245, 87, 108, 0.3);
}
.overview-card:nth-child(3) {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
box-shadow: 0 4rpx 12rpx rgba(79, 172, 254, 0.3);
}
.overview-label {
display: block;
font-size: 24rpx;
color: rgba(255, 255, 255, 0.9);
margin-bottom: 16rpx;
}
.overview-value {
display: block;
font-size: 48rpx;
font-weight: 700;
color: #fff;
line-height: 1.2;
}
.overview-unit {
display: block;
font-size: 22rpx;
color: rgba(255, 255, 255, 0.8);
margin-top: 8rpx;
}
.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;
2025-12-07 12:54:39 +08:00
display: flex;
2026-01-05 14:29:33 +08:00
justify-content: space-between;
2025-12-07 12:54:39 +08:00
align-items: center;
}
2026-01-05 14:29:33 +08:00
.status-type {
font-size: 28rpx;
color: #606266;
font-weight: 500;
2025-12-07 12:54:39 +08:00
}
2026-01-05 14:29:33 +08:00
.status-count {
font-size: 36rpx;
2025-12-07 12:54:39 +08:00
font-weight: 600;
2026-01-05 14:29:33 +08:00
color: #0066cc;
2025-12-07 12:54:39 +08:00
}
2026-01-05 14:29:33 +08:00
.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;
2025-12-07 12:54:39 +08:00
}
2025-10-31 19:10:08 +08:00
</style>