Files
ZDXX/zhandianxinxi/光谱数据监控/src/views/Dashboard.vue
DXC 736d644792 fix(frontend): 手动采集改为真实等待,去掉固定延时的假刷新
原实现:

    runningTask.value=true
    await axios.post(`${API_BASE}/api/run_monitor`)
    setTimeout(()=>fetchData(), 3000)
    setTimeout(()=>runningTask.value=false, 1000)

后端 run_monitor 里的 execute_monitor_task() 是同步阻塞的,105 台设备逐个登录、
请求目录、下载文件,实测要跑几分钟。原实现发完请求就固定等 3 秒刷新,此时爬虫
还在跑,拿到的是上一次的数据;1 秒后 loading 也复位了,用户以为跑完了 —— 这就是
“点检测没反应/数据不更新”的来源,和之前反复修的那类症状同源。

- 显式传 timeout: 600000(10 分钟),硬抗后端同步阻塞,避免请求先于爬虫断开。
- 改为 await fetchData(),等后端真正返回后再取数,不再用固定延时猜。
- try/catch/finally 收敛:成功提示、失败把后端 message 透出来、loading 一律复位。
- 开头加 runningTask 守卫,防止重复点击并发触发多次采集。

构建产物 web_dist 已同步更新(index-0c48fe24.js),按 .gitignore 不入库。
2026-09-15 17:42:51 +08:00

654 lines
29 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="dashboard-container">
<el-card shadow="never" class="main-card">
<template #header>
<div class="header-row">
<div class="left-panel">
<h2 class="sys-title">📡 光谱设备监控</h2>
<div class="sys-status">
<el-tag type="info" effect="plain" round size="small">
<el-icon><Clock /></el-icon> 更新: {{ lastCheckTime || '...' }}
</el-tag>
</div>
</div>
<div class="header-actions">
<el-button type="primary" plain icon="Plus" @click="showAddDialog = true">新增</el-button>
<el-button type="primary" plain icon="Link" @click="openIoTBinder">卡绑定</el-button>
<el-button type="info" plain icon="Document" @click="openLogCenter(null)">日志</el-button>
<el-button type="warning" plain icon="RefreshRight" :loading="runningTask" @click="runManualMonitor">检测</el-button>
<el-button circle icon="Refresh" :loading="loading" @click="fetchData" />
<div class="divider-mobile"></div>
<el-button type="danger" plain icon="SwitchButton" @click="handleLogout">退出</el-button>
</div>
</div>
</template>
<div class="status-summary">
<el-tag color="#409EFF" effect="dark" class="legend-tag">修</el-tag>
<el-tag color="#F56C6C" effect="dark" class="legend-tag">离线 / 严重滞后</el-tag>
<el-tag color="#E6A23C" effect="dark" class="legend-tag">滞后 / 流量超标</el-tag>
<el-tag color="#FAC858" effect="dark" class="legend-tag" style="color: #333">数据异常 / 昨日</el-tag>
<el-tag color="#67C23A" effect="dark" class="legend-tag">正常</el-tag>
</div>
<div class="toolbar">
<div class="filter-section">
<el-radio-group v-model="filters.status" size="default">
<el-radio-button label="all">全部({{ summary.totalCount }})</el-radio-button>
<el-radio-button label="abnormal" class="red-radio">
状态异常({{ summary.errorCount + summary.warningCount }})
</el-radio-button>
<el-radio-button label="data_error" class="yellow-radio">
数据异常({{ summary.dataErrorCount }})
</el-radio-button>
<el-radio-button label="hidden" class="gray-radio">
回收({{ summary.hiddenCount }})
</el-radio-button>
</el-radio-group>
<el-input
v-model="filters.keyword"
placeholder="搜索设备名称..."
class="search-input"
prefix-icon="Search"
clearable
/>
<div class="total-usage-tag">
<el-icon><Odometer /></el-icon>
<span class="label">卡池总用量:</span>
<span class="value">{{ totalUsageSum.toFixed(2) }} M</span>
</div>
</div>
</div>
<el-table
:data="filteredData"
border
v-loading="loading"
style="width: 100%; min-width: 1250px;"
:row-class-name="tableRowClassName"
:height="tableHeight"
:default-sort="{ prop: 'sortWeight', order: 'descending' }"
>
<el-table-column label="状态" width="100" align="center" fixed="left">
<template #default="{ row }">
<el-tag v-if="row.is_hidden" color="#909399" effect="dark" style="border:none; color:#fff;">隐藏</el-tag>
<el-tag
v-else
:color="row.statusColor"
effect="dark"
style="border:none; width: 80px;"
:style="{ color: row.statusLabelColor || '#fff' }"
>
{{ row.statusLabel }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="设备名称" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<div
class="device-name-wrapper"
:class="{ 'clickable-row': !row.is_hidden }"
@click="handleDeviceClick(row)"
>
<span class="device-name" :class="{ 'text-deleted': row.is_hidden }">
{{ formatDisplayName(row.name) }}
</span>
<el-icon v-if="!row.is_hidden" class="link-icon"><DataLine /></el-icon>
<el-tag v-if="row.isBound" size="small" type="info" effect="plain" style="margin-left:5px; height: 18px; line-height: 16px; padding:0 4px;">卡</el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="今日文件" width="120" align="center">
<template #default="{ row }">
<el-tooltip content="点击查看历史文件趋势" placement="top">
<div
class="file-count-cell"
@click="openHistoryDialog(row)"
:class="{ 'has-data': row.file_count > 0 }"
>
<el-tag v-if="row.file_count > 0" type="primary" effect="plain" round size="small">
{{ row.file_count }} 个
</el-tag>
<span v-else style="color: #ccc; font-size: 12px;">--</span>
<el-icon v-if="!row.is_hidden" class="history-icon"><Histogram /></el-icon>
</div>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="安装地点" min-width="140">
<template #default="{ row }">
<div v-if="row.isEditingSite" class="editing-cell">
<el-input
v-model="row.tempSite"
size="small"
@blur="saveSite(row)"
@keyup.enter="saveSite(row)"
class="site-input-inner"
placeholder="输入后回车"
/>
</div>
<div v-else class="display-cell" @click="handleEditSite(row)">
<span>{{ row.install_site || '点击填写' }}</span>
<el-icon class="edit-icon"><EditPen /></el-icon>
</div>
</template>
</el-table-column>
<el-table-column label="本月流量" width="130" prop="trafficNum" sortable>
<template #default="{ row }">
<div v-if="row.isBound">
<span :style="{ fontWeight: '600', color: row.trafficWarning ? '#E6A23C' : '#606266' }">
{{ row.trafficNum }} M
</span>
<el-tooltip v-if="row.trafficWarning" content="流量超标 (>=500M)" placement="top">
<el-icon color="#E6A23C" style="margin-left: 4px; cursor: help;"><Warning /></el-icon>
</el-tooltip>
</div>
<span v-else style="color: #ccc;">--</span>
</template>
</el-table-column>
<el-table-column label="卡状态" width="110" align="center">
<template #default="{ row }">
<div v-if="row.isBound">
<el-tag
:type="getCardStatusType(row.statusDesc)"
effect="light"
size="small"
style="font-weight: bold;"
>
{{ row.statusDesc || '未知' }}
</el-tag>
</div>
<span v-else style="color: #ccc;">--</span>
</template>
</el-table-column>
<el-table-column label="服务截止" width="140">
<template #default="{ row }">
<div v-if="row.isBound && row.stopDate">
<span :style="{ color: row.expireWarning ? '#E6A23C' : '#606266', fontWeight: row.expireWarning ? 'bold' : 'normal' }">
{{ row.stopDate }}
</span>
<el-tooltip v-if="row.expireWarning" content="即将过期 (<30天)" placement="top">
<el-icon color="#E6A23C" style="margin-left: 4px; cursor: help;"><Warning /></el-icon>
</el-tooltip>
</div>
<span v-else style="color: #ccc;">--</span>
</template>
</el-table-column>
<el-table-column label="数据时效与质量" width="260" prop="sortWeight" sortable>
<template #default="{ row }">
<div style="font-size: 13px; display:flex; align-items:center; gap:5px; color: #606266; margin-bottom: 4px;">
<el-icon><Clock /></el-icon> {{ row.latest_time || '尚未同步' }}
</div>
<div v-if="!row.is_maintaining && !row.is_hidden">
<div v-if="row.statusType === 'error'" class="status-text error-text">
⚠️ {{ row.statusReason }}
</div>
<div v-else-if="row.statusType === 'warning'" class="status-text warning-text">
⚠️ {{ row.statusReason }}
</div>
<div v-else-if="row.statusType === 'slight-warning'" class="status-text slight-warning-text">
⚠️ {{ row.statusReason }}
</div>
<div v-else class="status-text success-text">
✅ 状态正常
</div>
<div v-if="row.statusType !== 'error'" style="margin-top: 4px;">
<el-tag v-if="row.data_quality === 'error'" type="danger" size="small" effect="dark">
<el-icon><Warning /></el-icon> 数据严重异常
</el-tag>
<el-tag v-else-if="row.data_quality === 'warning'" type="warning" size="small" effect="dark">
<el-icon><WarningFilled /></el-icon> 数值警告
</el-tag>
<el-tag v-else-if="row.statusType !== 'warning' && row.statusType !== 'slight-warning'" type="success" size="small" effect="plain">
数值正常
</el-tag>
</div>
</div>
<div v-else-if="row.is_maintaining" class="status-text maintenance-text">🛠️ 维护中</div>
</template>
</el-table-column>
<el-table-column label="操作" width="200" align="center" fixed="right">
<template #default="{ row }">
<div class="action-group">
<template v-if="row.is_hidden">
<el-button type="success" plain size="small" icon="RefreshLeft" @click="toggleHidden(row, false)">恢复</el-button>
</template>
<template v-else>
<el-switch
v-model="row.is_maintaining"
inline-prompt
active-text="修"
inactive-text="行"
style="--el-switch-on-color: #409EFF;"
:before-change="() => handleMaintenanceBeforeChange(row)"
/>
<el-button type="primary" link icon="Edit" @click="openLogCenter(row)">日志</el-button>
<el-popconfirm title="确定隐藏?" @confirm="toggleHidden(row, true)">
<template #reference>
<el-button type="danger" link icon="Delete">隐藏</el-button>
</template>
</el-popconfirm>
</template>
</div>
</template>
</el-table-column>
</el-table>
</el-card>
<DataMonitor ref="dataMonitorRef" />
<MaintenanceLogs ref="maintenanceLogsRef" />
<IoTDeviceBinder ref="iotBinderRef" @update-success="fetchData" />
<FileHistoryDialog ref="fileHistoryRef" />
<el-dialog v-model="showAddDialog" title="手动添加设备" width="400px" align-center>
<el-form :model="newDeviceForm" label-width="80px">
<el-form-item label="设备名称">
<el-input v-model="newDeviceForm.name" placeholder="请输入唯一设备名" />
</el-form-item>
<el-form-item label="安装地点">
<el-input v-model="newDeviceForm.site" placeholder="可选填" />
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="showAddDialog = false">取消</el-button>
<el-button type="primary" :loading="isAdding" @click="handleAddDeviceSubmit">
确认添加
</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, nextTick, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import axios from 'axios'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Clock, DataLine, Document, Refresh, EditPen, Search, Edit, RefreshRight, Delete, RefreshLeft, SwitchButton, Warning, WarningFilled, Plus, Odometer, Link, Histogram } from '@element-plus/icons-vue'
import DataMonitor from './DataMonitor.vue'
import MaintenanceLogs from './MaintenanceLogs.vue'
import IoTDeviceBinder from './IoTDeviceBinder.vue'
// ✅ 引入新组件
import FileHistoryDialog from './FileHistoryDialog.vue'
const router = useRouter()
const loading = ref(false)
const runningTask = ref(false)
const rawData = ref([])
const lastCheckTime = ref('')
const windowHeight = ref(window.innerHeight)
const windowWidth = ref(window.innerWidth)
const tableHeight = computed(() => {
const isMobile = windowWidth.value < 768
const offset = isMobile ? 380 : 250
return windowHeight.value - offset
})
const filters = reactive({ status: 'all', keyword: '' })
const API_BASE = import.meta.env.DEV ? 'http://127.0.0.1:5000' : ''
const dataMonitorRef = ref(null)
const maintenanceLogsRef = ref(null)
const iotBinderRef = ref(null)
// ✅ 定义新的 ref
const fileHistoryRef = ref(null)
const showAddDialog = ref(false)
const isAdding = ref(false)
const newDeviceForm = reactive({ name: '', site: '' })
// === 辅助函数:根据中文状态返回 Tag 颜色 ===
const getCardStatusType = (status) => {
if (status === '在使用') return 'success' // 绿色
if (status === '停机' || status === '销户') return 'danger' // 红色
if (status === '停机保号' || status === '沉默期') return 'warning' // 黄色
if (status === '测试期') return 'info' // 灰色
return 'info' // 默认
}
// === 核心数据处理逻辑 ===
const fetchData = async () => {
loading.value = true
try {
const res = await axios.get(`${API_BASE}/api/devices_overview`)
const backendList = res.data.data || res.data
const now = new Date()
rawData.value = backendList.map(item => {
const isHidden = item.is_hidden === true || item.is_hidden === 1
const isBound = !!item.isBound
const isOrphanIoT = (item.source === 'iot_card')
const isWhitelist = !!item.is_whitelist
// === 1. 时间显示格式化 ===
// 滞后判定已统一由后端提供(stale_level / stale_days / offset),
// 这里只负责把 latest_time 渲染成统一格式,不再本地计算天数。
// 判定与渲染分家的原因见 services/time_utils.py 顶部说明。
const staleLevel = item.stale_level || 'unknown'
const staleDays = (typeof item.stale_days === 'number') ? item.stale_days : null
const validTime = staleLevel !== 'unknown'
let timeStr = item.latest_time
// 默认显示原始值,稍后如果解析成功则覆盖它
let displayTime = timeStr
if (timeStr && timeStr !== 'N/A') {
let d = null;
const str = timeStr.toString().trim();
// A. 尝试匹配标准格式: YYYY-MM-DD HH:mm:ss
const matchStandard = str.match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})$/);
if (matchStandard) {
d = new Date(
parseInt(matchStandard[1]),
parseInt(matchStandard[2]) - 1,
parseInt(matchStandard[3]),
parseInt(matchStandard[4]),
parseInt(matchStandard[5]),
parseInt(matchStandard[6])
);
} else {
// B. 兜底逻辑:处理下划线或其他格式 (如 2026_01_14)
// 先把下划线全换成横杠
let cleanStr = str.replace(/_/g, '-')
// 如果长度不够(只有日期),补全时间,防止 new Date 解析成 UTC 0点导致时差
if (cleanStr.length <= 10) {
cleanStr += ' 00:00:00'
}
// 处理 T 分隔符 (ISO格式)
cleanStr = cleanStr.replace(' ', 'T')
d = new Date(cleanStr);
}
// C. 解析成功则强制重新生成统一的显示格式 YYYY-MM-DD HH:mm:ss
if (d && !isNaN(d.getTime())) {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const dd = String(d.getDate()).padStart(2, '0')
const hh = String(d.getHours()).padStart(2, '0')
const mm = String(d.getMinutes()).padStart(2, '0')
const ss = String(d.getSeconds()).padStart(2, '0')
// 这行代码保证了无论后端发什么,前端都显示得很漂亮
displayTime = `${y}-${m}-${dd} ${hh}:${mm}:${ss}`
}
}
// 2. 解析监测数值 (保留旧逻辑)
let currentValueNum = 0
if (item.current_value) {
const match = String(item.current_value).match(/(\d+(\.\d+)?)/)
if (match) currentValueNum = parseFloat(match[0])
}
// 3. 流量与过期计算
let trafficNum = 0
let rawTraffic = item.usedTraffic
if ((rawTraffic === undefined || rawTraffic === null) && item.json_data) {
try { const j = JSON.parse(item.json_data); rawTraffic = j.usedTraffic } catch(e) {}
}
if (rawTraffic) {
trafficNum = parseFloat(rawTraffic)
if (isNaN(trafficNum)) trafficNum = 0
}
// === 修改处:恢复流量超标警告判断,用于标黄 ===
const trafficWarning = (trafficNum >= 500 && !isWhitelist)
let expireWarning = false
if (item.stopDate && item.stopDate !== 'N/A') {
const stopD = new Date(item.stopDate.replace(/_/g, '-'))
if (!isNaN(stopD.getTime())) {
const daysLeft = (stopD - now) / (1000 * 3600 * 24)
if (daysLeft < 30) expireWarning = true
}
}
// 4. 状态判定(分级由后端给出,这里只做映射,不再本地判天数)
let statusColor = '#67C23A', statusLabel = '正常', statusType = 'normal', statusLabelColor = '#fff'
let statusReason = ''
// 排序权重:问题设备排前面。staleDays * 24 与原 diffHours 同为小时量纲,
// 排序结果与原实现一致(同为“当天”的设备不再按小时细分,顺序退化为稳定序)
let sortWeight = (staleDays === null) ? 80000000 : staleDays * 24
if (item.is_maintaining) {
statusColor = '#409EFF'; statusLabel = '维修中'; statusType = 'maintenance';
sortWeight = Number.MAX_SAFE_INTEGER;
} else if (!validTime || item.status === 'offline') {
statusLabel = '离线'; statusColor = '#F56C6C'; statusType = 'error';
// 文案与后端 offset 统一:拿不到可解析的数据时间就说“从未同步”,
// 不再另造一套“暂无数据”的说法
statusReason = (item.status === 'offline') ? '设备离线' : (item.offset || '从未同步');
sortWeight = 80000000;
} else if (staleLevel === 'severe') {
statusLabel = '严重滞后'; statusColor = '#F56C6C'; statusType = 'error';
statusReason = item.offset || `滞后 ${staleDays} 天`;
} else if (staleLevel === 'lagging') {
statusLabel = '滞后'; statusColor = '#E6A23C'; statusType = 'warning';
statusReason = item.offset || `滞后 ${staleDays} 天`;
} else if (expireWarning) {
statusLabel = '即将过期'; statusColor = '#E6A23C'; statusType = 'warning';
statusReason = `即将过期`;
sortWeight = 400;
} else if (staleLevel === 'yesterday') {
statusLabel = '昨日数据'; statusColor = '#FAC858'; statusType = 'slight-warning'; statusLabelColor = '#333';
statusReason = '非今日数据';
} else {
sortWeight = 0;
}
return {
...item,
latest_time: displayTime,
is_hidden: isHidden,
isOrphanIoT,
isBound,
isWhitelist,
sortWeight, staleLevel, staleDays,
statusColor, statusLabel, statusType, statusLabelColor, statusReason,
isEditingSite: false, tempSite: '',
data_quality: item.data_quality || 'ok',
currentValueNum,
trafficNum,
trafficWarning,
expireWarning,
file_count: item.file_count || 0 // ✅ 绑定后端返回的文件数字段
}
}).sort((a, b) => b.sortWeight - a.sortWeight)
lastCheckTime.value = new Date().toLocaleString()
} catch (e) {
ElMessage.error('获取数据失败')
} finally {
loading.value = false
}
}
// === 筛选逻辑 ===
const summary = computed(() => {
const active = rawData.value.filter(r => !r.is_hidden && !r.isOrphanIoT)
return {
totalCount: active.length,
errorCount: active.filter(r => r.statusType === 'error').length,
warningCount: active.filter(r => r.statusType === 'warning').length,
hiddenCount: rawData.value.filter(r => r.is_hidden).length,
dataErrorCount: active.filter(r => r.data_quality === 'error' || r.data_quality === 'warning').length
}
})
const filteredData = computed(() => {
return rawData.value.filter(item => {
// 隐藏孤儿卡
if (item.isOrphanIoT) return false
if (filters.status === 'hidden') return item.is_hidden
if (item.is_hidden) return false
if (filters.status === 'abnormal') return ['error', 'warning', 'slight-warning'].includes(item.statusType)
if (filters.status === 'data_error') return ['error', 'warning'].includes(item.data_quality)
return true
}).filter(item => !filters.keyword || item.name.toLowerCase().includes(filters.keyword.toLowerCase()))
})
// === 卡池总用量 ===
const totalUsageSum = computed(() => {
return rawData.value.reduce((sum, item) => {
if (item.source === 'iot_card') {
return sum + (item.trafficNum || 0)
}
return sum
}, 0)
})
// === 交互函数 ===
// ✅ 新增:打开历史记录弹窗
const openHistoryDialog = (row) => {
if (row.is_hidden) return
if (fileHistoryRef.value) {
fileHistoryRef.value.open(row)
}
}
const handleAddDeviceSubmit = async () => { if (!newDeviceForm.name) return; await axios.post(`${API_BASE}/api/add_device`, newDeviceForm); showAddDialog.value=false; fetchData() }
const handleDeviceClick = (row) => { if (!row.is_hidden && dataMonitorRef.value) dataMonitorRef.value.open(row) }
const openLogCenter = (row) => { if (maintenanceLogsRef.value) maintenanceLogsRef.value.open(row ? { deviceName: row.name } : null) }
const openIoTBinder = () => { if (iotBinderRef.value) iotBinderRef.value.open() }
const runManualMonitor = async () => {
if (runningTask.value) return; // 防止重复点击
runningTask.value = true;
try {
// 后端 execute_monitor_task() 是同步阻塞的,105 台设备逐个登录+下载要跑几分钟,
// 必须给足超时,否则请求会先断开、爬虫却还在后台跑
await axios.post(`${API_BASE}/api/run_monitor`, {}, { timeout: 600000 });
ElMessage.success('手动采集完成!');
await fetchData(); // 等后端真正跑完再取数,不再用固定 setTimeout 瞎猜
} catch (error) {
console.error("手动采集失败:", error);
ElMessage.error('采集失败或超时:' + (error.response?.data?.message || error.message));
} finally {
runningTask.value = false; // 无论成功失败都关掉 loading
}
}
const handleEditSite = (row) => { row.tempSite = row.install_site; row.isEditingSite = true; nextTick(() => document.querySelector('.site-input-inner input')?.focus()) }
const saveSite = async (row) => { if(!row.isEditingSite)return; row.isEditingSite=false; await axios.post(`${API_BASE}/api/update_site`, {name:row.name, site:row.tempSite}); row.install_site=row.tempSite }
const handleMaintenanceBeforeChange = (row) => { return new Promise(r => { axios.post(`${API_BASE}/api/toggle_maintenance`, {name:row.name, is_maintaining:!row.is_maintaining}).then(() => {row.is_maintaining=!row.is_maintaining; fetchData(); r(true)}).catch(()=>r(false)) }) }
const toggleHidden = async (row, val) => { await axios.post(`${API_BASE}/api/toggle_hidden`, {name:row.name, is_hidden:val}); row.is_hidden=val; fetchData() }
const handleLogout = () => { localStorage.removeItem('token'); router.push('/') }
const formatDisplayName = (name) => name ? name.toUpperCase().replace(/_/g, ' ') : ''
// 行高亮逻辑 (融合了数据异常的高亮)
const tableRowClassName = ({ row }) => {
if (row.is_hidden) return 'hidden-row'
if (row.data_quality === 'error') return 'data-error-row' // 优先显示数值严重错误
if (row.statusType === 'error') return 'error-row'
if (row.data_quality === 'warning') return 'data-warning-row' // 数值警告
if (row.statusType === 'warning') return 'warning-row'
if (row.statusType === 'maintenance') return 'maintenance-row'
return ''
}
const updateDimensions = () => { windowHeight.value = window.innerHeight; windowWidth.value = window.innerWidth }
onMounted(() => { fetchData(); window.addEventListener('resize', updateDimensions) })
onBeforeUnmount(() => window.removeEventListener('resize', updateDimensions))
</script>
<style scoped>
.dashboard-container { padding: 10px; background: #f5f7fa; min-height: 100vh; box-sizing: border-box; }
.main-card { border-radius: 8px; }
.header-row { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; }
.sys-title { font-size: 20px; font-weight: 700; color: #303133; margin: 0; }
.left-panel { display: flex; align-items: center; gap: 10px; }
.header-actions { display: flex; gap: 8px; align-items: center; }
.status-summary { margin-bottom: 15px; display: flex; gap: 5px; flex-wrap: wrap; }
.legend-tag { font-weight: bold; border: none; font-size: 12px; }
.toolbar { background: #fff; padding: 10px; border-radius: 6px; margin-bottom: 10px; border: 1px solid #e4e7ed; }
.filter-section { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.search-input { width: 220px; }
:deep(.red-radio.is-active .el-radio-button__inner) { background-color: #F56C6C; border-color: #F56C6C; box-shadow: -1px 0 0 0 #F56C6C; }
:deep(.yellow-radio.is-active .el-radio-button__inner) { background-color: #E6A23C; border-color: #E6A23C; box-shadow: -1px 0 0 0 #E6A23C; }
:deep(.gray-radio.is-active .el-radio-button__inner) { background-color: #909399; border-color: #909399; box-shadow: -1px 0 0 0 #909399; }
:deep(.red-radio .el-radio-button__inner), :deep(.yellow-radio .el-radio-button__inner), :deep(.gray-radio .el-radio-button__inner) { color: #606266; }
.total-usage-tag { display: flex; align-items: center; gap: 5px; background: #f0f9ff; border: 1px solid #cce9ff; padding: 5px 12px; border-radius: 4px; color: #409EFF; margin-left: 5px; }
.total-usage-tag .label { font-size: 13px; font-weight: bold; }
.total-usage-tag .value { font-size: 14px; font-weight: 800; }
.device-name-wrapper { display: flex; align-items: center; gap: 5px; cursor: pointer; }
.device-name { font-weight: bold; font-size: 14px; color: #303133; }
.device-name-wrapper:hover .device-name { color: #409EFF; text-decoration: underline; }
.text-deleted { text-decoration: line-through; color: #999; }
.status-text { font-size: 12px; margin-top: 4px; font-weight: bold; }
.error-text { color: #F56C6C; }
.warning-text { color: #E6A23C; }
.success-text { color: #67C23A; }
.slight-warning-text { color: #E6A23C; }
.maintenance-text { color: #409EFF; }
.display-cell { cursor: pointer; padding: 4px 0; display: flex; justify-content: space-between; }
.edit-icon { color: #409EFF; }
:deep(.error-row) { background-color: #fef0f0 !important; }
:deep(.warning-row) { background-color: #fdf6ec !important; }
:deep(.maintenance-row) { background-color: #f0f9ff !important; }
:deep(.hidden-row) { background-color: #f4f4f5 !important; color: #909399; }
/* 增加原有代码的数据异常背景色 */
:deep(.data-error-row) { background-color: #ffe6e6 !important; }
:deep(.data-warning-row) { background-color: #fffbe6 !important; }
/* ✅ 新增样式 */
.file-count-cell {
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
transition: all 0.2s;
padding: 4px;
border-radius: 4px;
}
.file-count-cell:hover {
background-color: #f0f9ff;
}
.file-count-cell:hover .el-tag {
transform: scale(1.05);
}
.history-icon {
font-size: 14px;
color: #909399;
opacity: 0;
transition: opacity 0.2s;
}
.file-count-cell:hover .history-icon {
opacity: 1;
color: #409EFF;
}
@media screen and (max-width: 768px) {
.dashboard-container { padding: 5px; }
.left-panel, .header-actions { width: 100%; justify-content: space-between; margin-bottom: 5px; }
.header-actions .el-button { flex: 1; margin: 0 2px; }
.filter-section { justify-content: space-between; }
.el-radio-group { width: 100%; display: flex; }
.el-radio-button { flex: 1; }
.search-input { width: 100%; margin-top: 5px; }
.total-usage-tag { width: 100%; justify-content: center; margin: 5px 0 0 0; }
}
</style>