Files
ZDXX/zhandianxinxi/光谱数据监控/src/views/Dashboard.vue
2026-01-08 15:15:05 +08:00

671 lines
20 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>
<el-icon><Clock /></el-icon> 最后更新: {{ lastCheckTime || '等待获取...' }}
</el-tag>
</div>
</div>
<div class="header-actions">
<el-button type="primary" plain icon="DataLine" @click="router.push({ name: 'CrawledData' })">
数据详情监控
</el-button>
<el-button type="info" plain icon="Document" @click="router.push({ name: 'MaintenanceLogs' })">
维修日志
</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>
</div>
</template>
<div v-if="(summary.hasError || summary.hasWarning) && filters.status !== 'hidden'" class="alert-section">
<el-alert
v-if="summary.hasError"
:title="`严重警告:检测到 ${summary.errorCount} 台设备离线或严重滞后(>7天)`"
type="error"
show-icon
effect="dark"
class="mb-2"
/>
<el-alert
v-if="summary.hasWarning"
:title="`风险提示:检测到 ${summary.warningCount} 台设备数据滞后(>24小时)`"
type="warning"
show-icon
effect="dark"
/>
</div>
<div class="status-summary">
<el-tag color="#409EFF" effect="dark" class="legend-tag" style="border:none">蓝色维修中 (置顶)</el-tag>
<el-tag color="#F56C6C" effect="dark" class="legend-tag" style="border:none">红色离线 / 滞后 > 7</el-tag>
<el-tag color="#E6A23C" effect="dark" class="legend-tag" style="border:none">橙色滞后 1 - 7</el-tag>
<el-tag color="#FAC858" effect="dark" class="legend-tag" style="border:none; color: #333">黄色滞后 &lt; 24小时</el-tag>
<el-tag color="#67C23A" effect="dark" class="legend-tag" style="border:none">绿色当天数据 (正常)</el-tag>
</div>
<div class="toolbar" :class="{ 'mobile-toolbar': isMobile }">
<div class="filter-section">
<el-radio-group v-model="filters.status" @change="fetchData">
<el-radio-button label="all">全部设备</el-radio-button>
<el-radio-button label="abnormal" class="red-radio">
异常关注 ({{ summary.errorCount + summary.warningCount }})
</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>
</div>
<el-table
:data="filteredData"
border
v-loading="loading"
style="width: 100%"
:row-class-name="tableRowClassName"
height="calc(100vh - 380px)"
:default-sort="{ prop: 'sortHours', order: 'descending' }"
>
<el-table-column label="当前状态" width="140" 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: 110px;"
: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 }">
<span class="device-name" :class="{ 'text-deleted': row.is_hidden }">
{{ formatDisplayName(row.name) }}
</span>
</template>
</el-table-column>
<el-table-column label="安装地点" min-width="180">
<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)"
ref="siteInputRef"
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="260" prop="sortHours" sortable>
<template #default="{ row }">
<div>
<el-icon><Clock /></el-icon> {{ row.latest_time || '无数据记录' }}
</div>
<div v-if="!row.is_maintaining && !row.is_hidden">
<div v-if="row.status === 'offline' || row.status === '已离线'" class="status-text error-text">
设备已离线 (无法连接)
</div>
<div v-else-if="row.diffDays > 7" class="status-text error-text">
严重滞后 {{ Math.floor(row.diffDays) }}
</div>
<div v-else-if="row.diffHours > 24" class="status-text warning-text">
数据滞后 {{ Math.floor(row.diffDays) }}
</div>
<div v-else-if="!row.isToday" class="status-text slight-warning-text">
非今日数据 (滞后 &lt; 24h)
</div>
<div v-else class="status-text success-text">
数据最新 ({{ row.diffHours < 1 ? '刚刚' : `${Math.floor(row.diffHours)}小时前` }})
</div>
</div>
<div v-else-if="row.is_maintaining" class="status-text maintenance-text">
🛠 维护期间忽略数据告警
</div>
</template>
</el-table-column>
<el-table-column label="操作控制" width="220" 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="openLogDialog(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>
<el-dialog v-model="logDialog.visible" title="📝 提交维修记录" width="500px">
<el-form :model="logDialog.form" label-position="top">
<el-form-item label="设备名称">
<el-tag>{{ formatDisplayName(logDialog.deviceName) }}</el-tag>
</el-form-item>
<el-form-item label="维修/故障描述">
<el-input
v-model="logDialog.form.content"
type="textarea"
:rows="3"
placeholder="例如设备离线重启或更换光纤..."
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="logDialog.visible = false">取消</el-button>
<el-button type="primary" @click="submitLog" :loading="logDialog.submitting">提交保存</el-button>
</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 } from 'element-plus'
import { Clock, DataLine, Document, Refresh, EditPen, Search, Edit, RefreshRight, Delete, RefreshLeft } from '@element-plus/icons-vue'
const router = useRouter()
const loading = ref(false)
const runningTask = ref(false)
const rawData = ref([])
const lastCheckTime = ref('')
const windowWidth = ref(window.innerWidth)
const isMobile = computed(() => windowWidth.value < 768)
const API_BASE = import.meta.env.DEV ? 'http://127.0.0.1:5000' : ''
const filters = reactive({ status: 'all', keyword: '' })
const logDialog = reactive({
visible: false,
submitting: false,
deviceName: '',
rowId: null,
form: { content: '' }
})
const summary = computed(() => {
const activeDevices = rawData.value.filter(r => !r.is_hidden)
const errors = activeDevices.filter(r => r.statusType === 'error').length
const warnings = activeDevices.filter(r => r.statusType === 'warning').length
const hidden = rawData.value.filter(r => r.is_hidden).length
return {
errorCount: errors,
warningCount: warnings,
hiddenCount: hidden,
hasError: errors > 0,
hasWarning: warnings > 0
}
})
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()
let processedData = backendList.map(item => {
const isHidden = item.is_hidden === true || item.is_hidden === 1
let diffDays = 0
let diffHours = 0
let isToday = false
let validTime = false
// 计算真实时间
if (item.latest_time && item.latest_time !== 'N/A') {
const cleanDateStr = item.latest_time.toString().replace(/_/g, '-')
const d = new Date(cleanDateStr)
if (!isNaN(d.getTime())) {
validTime = true
const diffTime = now - d
const safeDiff = diffTime > 0 ? diffTime : 0
diffHours = safeDiff / (1000 * 60 * 60)
diffDays = safeDiff / (1000 * 60 * 60 * 24)
isToday = d.toDateString() === now.toDateString()
}
}
// --- 核心排序逻辑修正 ---
// 目标顺序:维修 > 离线 > 无数据 > 滞后时间长 > 滞后时间短
// 我们用 sortHours 来控制这个顺序,数值越大越靠前
let sortHours = diffHours;
// 1. 维修中:给一个最大的安全整数,保证在所有排序中都是第一
if (item.is_maintaining) {
sortHours = Number.MAX_SAFE_INTEGER; // 9007199254740991绝对第一
}
// 2. 离线:给一个次大的数 (10亿)
else if (item.status === 'offline' || item.status === '已离线') {
sortHours = 1000000000;
}
// 3. 无数据但没报离线:给一个第三大的数 (5亿)
else if (!validTime) {
sortHours = 500000000;
}
// 4. 其他情况 sortHours 就是真实的 diffHours
let statusColor = '#67C23A'
let statusLabel = '正常在线'
let statusType = 'normal'
let sortWeight = 6
let statusLabelColor = '#fff'
if (item.is_maintaining) {
statusColor = '#409EFF'
statusLabel = '维修中'
statusType = 'maintenance'
sortWeight = 1
}
else if ((item.status === 'offline' || item.status === '已离线') || (!validTime || diffDays > 7)) {
if (item.status === 'offline' || item.status === '已离线') {
statusLabel = '🔴 设备离线'
} else {
statusLabel = '严重滞后'
}
statusColor = '#F56C6C'
statusType = 'error'
sortWeight = 2
}
else if (diffHours > 24) {
statusColor = '#E6A23C'
statusLabel = '数据滞后'
statusType = 'warning'
sortWeight = 3
}
else if (!isToday) {
statusColor = '#FAC858'
statusLabel = '昨日数据'
statusType = 'slight-warning'
statusLabelColor = '#333'
sortWeight = 4
}
else {
statusColor = '#67C23A'
statusLabel = '🟢 运行正常'
statusType = 'normal'
sortWeight = 5
}
return {
...item,
is_hidden: isHidden,
diffDays,
diffHours,
sortHours, // 排序专用字段
isToday,
statusColor,
statusLabel,
statusType,
sortWeight,
statusLabelColor,
isEditingSite: false,
tempSite: ''
}
})
// 默认排序逻辑(即便没有 el-table 排序,数组本身也应该是这个顺序)
processedData.sort((a, b) => {
// 数值越大越靠前 (Maintenance > Offline > NoData > Old > New)
return b.sortHours - a.sortHours
})
rawData.value = processedData
lastCheckTime.value = new Date().toLocaleString()
} catch (e) {
console.error(e)
ElMessage.error('获取数据失败,请检查后端服务')
} finally {
loading.value = false
}
}
const runManualMonitor = async () => {
runningTask.value = true
try {
const res = await axios.post(`${API_BASE}/api/run_monitor`)
ElMessage.success(res.data.message || '任务已启动,请稍后刷新查看')
setTimeout(() => fetchData(), 3000)
} catch (e) {
ElMessage.warning('任务启动过于频繁或服务异常')
} finally {
setTimeout(() => { runningTask.value = false }, 1000)
}
}
const filteredData = computed(() => {
return rawData.value.filter(item => {
if (filters.status === 'hidden') {
if (!item.is_hidden) return false
} else {
if (item.is_hidden) return false
if (filters.status === 'abnormal') {
if (item.statusType !== 'error' && item.statusType !== 'warning' && item.statusType !== 'slight-warning') return false
}
}
const keyMatch = !filters.keyword || (item.name && item.name.toLowerCase().includes(filters.keyword.toLowerCase()))
return keyMatch
})
})
const handleEditSite = (row) => {
row.tempSite = row.install_site
row.isEditingSite = true
nextTick(() => {
const inputs = document.querySelectorAll('.editing-cell input')
if(inputs.length) inputs[inputs.length-1].focus()
})
}
const saveSite = async (row) => {
if (!row.isEditingSite) return
const oldVal = row.install_site
const newVal = row.tempSite
row.install_site = newVal
row.isEditingSite = false
if (oldVal === newVal) return
try {
await axios.post(`${API_BASE}/api/update_site`, { name: row.name, site: newVal })
ElMessage.success('地点已更新')
} catch (e) {
row.install_site = oldVal
ElMessage.error('更新失败')
}
}
const handleMaintenanceBeforeChange = (row) => {
return new Promise((resolve) => {
const newVal = !row.is_maintaining
axios.post(`${API_BASE}/api/toggle_maintenance`, {
name: row.name,
is_maintaining: newVal
}).then(() => {
row.is_maintaining = newVal
fetchData()
ElMessage.success(newVal ? '已标记为维修中' : '已恢复监控模式')
resolve(true)
}).catch(() => {
ElMessage.error('操作失败')
resolve(false)
})
})
}
const toggleHidden = async (row, targetState) => {
try {
await axios.post(`${API_BASE}/api/toggle_hidden`, {
name: row.name,
is_hidden: targetState
})
row.is_hidden = targetState
ElMessage.success(targetState ? '设备已隐藏(移至回收站)' : '设备已恢复显示')
fetchData()
} catch (e) {
console.error(e)
ElMessage.error('操作失败,请检查后端')
}
}
const openLogDialog = (row) => {
logDialog.deviceName = row.name
logDialog.rowId = row.id
logDialog.form.content = ''
logDialog.visible = true
}
const submitLog = async () => {
if (!logDialog.form.content) return ElMessage.warning('请输入日志内容')
logDialog.submitting = true
try {
await axios.post(`${API_BASE}/api/logs/add`, {
device_name: logDialog.deviceName,
content: logDialog.form.content
})
ElMessage.success('日志已提交')
logDialog.visible = false
} catch (e) {
ElMessage.error('提交失败')
} finally {
logDialog.submitting = false
}
}
const formatDisplayName = (name) => name ? name.toUpperCase().replace(/_/g, ' ') : ''
const tableRowClassName = ({ row }) => {
if (row.is_hidden) return 'hidden-row'
if (row.statusType === 'maintenance') return 'maintenance-row'
if (row.statusType === 'error') return 'error-row'
if (row.statusType === 'warning') return 'warning-row'
if (row.statusType === 'slight-warning') return 'slight-warning-row'
return ''
}
onMounted(() => {
fetchData()
window.addEventListener('resize', () => windowWidth.value = window.innerWidth)
})
onBeforeUnmount(() => window.removeEventListener('resize', () => windowWidth.value = window.innerWidth))
</script>
<style scoped>
.dashboard-container {
padding: 20px;
max-width: 1600px;
margin: 0 auto;
min-height: 100vh;
background: #f5f7fa;
}
.header-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.sys-title {
margin: 0;
font-size: 24px;
color: #303133;
font-weight: 700;
}
.alert-section {
margin-bottom: 15px;
}
.mb-2 {
margin-bottom: 8px;
}
.status-summary {
margin: 10px 0;
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.legend-tag {
font-weight: bold;
}
.toolbar {
background: #fff;
padding: 15px;
border-radius: 6px;
margin-bottom: 15px;
border: 1px solid #e4e7ed;
}
.filter-section {
display: flex;
gap: 20px;
align-items: center;
}
.red-radio :deep(.el-radio-button__inner) {
color: #F56C6C;
font-weight: bold;
}
.gray-radio :deep(.el-radio-button__inner) {
color: #909399;
}
.search-input {
width: 250px;
}
.device-name {
font-weight: bold;
font-size: 15px;
color: #303133;
}
.text-deleted {
text-decoration: line-through;
color: #999;
}
.status-text {
font-size: 13px;
margin-top: 4px;
font-weight: bold;
display: flex;
align-items: center;
}
.maintenance-text { color: #409EFF; }
.error-text { color: #F56C6C; }
.warning-text { color: #E6A23C; }
.slight-warning-text { color: #dcb041; }
.success-text { color: #67C23A; }
.display-cell {
cursor: pointer;
padding: 5px;
display: flex;
align-items: center;
justify-content: space-between;
border-radius: 4px;
}
.display-cell:hover {
background: #d9ecff;
}
.edit-icon {
opacity: 0;
color: #409EFF;
}
.display-cell:hover .edit-icon {
opacity: 1;
}
.action-group {
display: flex;
gap: 10px;
justify-content: center;
align-items: center;
}
/* 表格背景色高亮 */
: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;
}
/* 灰色背景 */
@media screen and (max-width: 768px) {
.header-row {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.filter-section {
flex-direction: column;
align-items: stretch;
}
.search-input {
width: 100%;
min-height: 300px;
}
}
</style>