Files
ZDXX/zhandianxinxi/光谱数据监控/src/App.vue
YueL1331 ef440177b3 test
2026-01-07 15:57:34 +08:00

477 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

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

<template>
<div class="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">
<span v-if="isRunning" class="status-running">
<el-icon class="is-loading"><Loading /></el-icon> 正在执行同步任务...
</span>
<span v-else class="status-idle">
<el-icon><CircleCheck /></el-icon> 系统就绪 (最后更新: {{ lastCheckTime }})
</span>
</div>
</div>
<div class="header-actions">
<el-button type="primary" :loading="isRunning" @click="handleManualRefresh(false)" round icon="Refresh" :size="isMobile ? 'small' : 'default'">手动同步</el-button>
</div>
</div>
</template>
<div class="status-summary">
<el-tag type="danger" effect="dark" class="res-tag">红色已离线 / 异常 / 滞后>7</el-tag>
<el-tag type="warning" color="#ff8c00" effect="dark" class="res-tag" style="border-color: #ff8c00;">橘色滞后 2-7 </el-tag>
<el-tag type="warning" effect="dark" class="res-tag">黄色滞后 1-2 </el-tag>
<el-tag type="success" effect="dark" class="res-tag">绿色正常且今日已同步</el-tag>
</div>
<div class="toolbar" :class="{ 'mobile-toolbar': isMobile }">
<div class="filter-section">
<el-radio-group v-model="filters.site" :size="isMobile ? 'small' : 'default'">
<el-radio-button value="all">全部</el-radio-button>
<el-radio-button value="106">106 塔上光谱仪</el-radio-button>
<el-radio-button value="82">82 高光谱传感器</el-radio-button>
</el-radio-group>
<el-input
v-model="filters.keyword"
placeholder="搜索设备名称..."
class="search-input"
clearable
/>
</div>
<div class="action-section">
<el-checkbox v-model="showHidden" label="显示屏蔽" border style="margin-right: 10px" :size="isMobile ? 'small' : 'default'"/>
<el-button type="warning" plain :disabled="selectedRows.length === 0" @click="hideSelected" :size="isMobile ? 'small' : 'default'">屏蔽选中</el-button>
</div>
</div>
<el-table
ref="multipleTableRef"
:data="sortedData"
border
height="600"
v-loading="isRunning"
@selection-change="val => selectedRows = val"
:row-class-name="tableRowClassName"
style="width: 100%"
>
<el-table-column type="selection" width="40" align="center" fixed="left" />
<el-table-column label="状态" :width="isMobile ? 90 : 120" align="center">
<template #default="{ row }">
<el-tag :style="getStatusTagStyle(row)" effect="dark" size="small">{{ getStatusLabel(row) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="名称" min-width="180">
<template #default="{ row }">
<div class="name-cell">
<el-link type="primary" underline="hover" @click="showDetails(row)" style="font-weight: bold; font-size: 14px;">
{{ formatDisplayName(row.name) }}
</el-link>
<el-tag v-if="isHidden(row.name)" type="info" size="small" style="margin-left:5px">隐藏</el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="reason" label="反馈" min-width="150" v-if="!isMobile">
<template #default="{ row }">
<span :style="{ color: getStatusColor(row), fontWeight: 'bold' }">{{ formatReason(row) }}</span>
</template>
</el-table-column>
<el-table-column prop="offset" label="时效" width="80" align="center" v-if="!isMobile"/>
<el-table-column prop="latest_time" label="数据时间" width="170" align="center" />
<el-table-column label="操作" width="70" v-if="showHidden" align="center" fixed="right">
<template #default="{ row }">
<el-button v-if="isHidden(row.name)" type="primary" link @click="restoreDevice(row.name)">恢复</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-drawer
v-model="drawerVisible"
title="设备详情"
:size="isMobile ? '100%' : '80%'"
@opened="initCharts"
direction="rtl"
>
<!-- <div v-if="activeDevice" class="drawer-content">-->
<!-- <div class="info-banner">-->
<!-- <el-descriptions :column="isMobile ? 1 : 4" border size="small">-->
<!-- <el-descriptions-item label="设备名称">{{ formatDisplayName(activeDevice.name) }}</el-descriptions-item>-->
<!-- <el-descriptions-item label="当前状态">-->
<!-- <el-tag size="small" :style="getStatusTagStyle(activeDevice)">{{ getStatusLabel(activeDevice) }}</el-tag>-->
<!-- </el-descriptions-item>-->
<!-- <el-descriptions-item label="数据时间">{{ activeDevice.latest_time }}</el-descriptions-item>-->
<!-- <el-descriptions-item label="检查时间">{{ activeDevice.check_time }}</el-descriptions-item>-->
<!-- </el-descriptions>-->
<!-- </div>-->
<!-- <div class="visual-section">-->
<!-- <h3 class="section-title">-->
<!-- <el-icon><DataLine /></el-icon>-->
<!-- {{ is106Site ? '光谱能量分布 (完整原始数据)' : '高光谱传感器数据 (Up/Down Spec)' }}-->
<!-- </h3>-->
<!-- <div v-if="currentChartModules.length === 0" class="empty-hint"><el-empty description="暂无有效的图表数据" /></div>-->
<!-- <div v-for="(module, index) in currentChartModules" :key="index" class="chart-container">-->
<!-- <div class="chart-header" v-if="is106Site">-->
<!-- <div class="tag-group">-->
<!-- <span class="module-tag">型号: {{ module.model }}</span>-->
<!-- <span class="sn-tag">SN: {{ module.sn }}</span>-->
<!-- </div>-->
<!-- </div>-->
<!-- <div :id="'chart-' + index" class="echart-box" :class="{ 'no-header': !is106Site }"></div>-->
<!-- </div>-->
<!-- </div>-->
<!-- </div>-->
<sidevueold></sidevueold>
</el-drawer>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
import axios from 'axios'
import * as echarts from 'echarts'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Loading, CircleCheck, Refresh, DataLine } from '@element-plus/icons-vue'
import sidevueold from "./sidevueold.vue"
// --- 响应式布局状态 ---
const windowWidth = ref(window.innerWidth)
const isMobile = computed(() => windowWidth.value < 768)
// 窗口大小监听函数
const handleResize = () => {
windowWidth.value = window.innerWidth
// 触发图表重绘
chartInstances.forEach(chart => chart && chart.resize())
}
// --- 状态变量 ---
const rawData = ref([])
const isRunning = ref(false)
const lastCheckTime = ref('N/A')
const selectedRows = ref([])
const showHidden = ref(false)
const drawerVisible = ref(false)
const activeDevice = ref(null)
const filters = reactive({ site: 'all', keyword: '' })
const multipleTableRef = ref()
let chartInstances = [] // 存储图表实例以便resize
// 初始化隐藏列表
const ignoredList = ref(JSON.parse(localStorage.getItem('hide_list') || '[]'))
let autoRefreshTimer = null
// --- 工具函数 ---
const formatSource = (source) => {
if (!source) return ''
const s = source.toString()
if (s.includes('106')) return '106 塔上光谱仪'
if (s.includes('82')) return '82 高光谱传感器'
return s
}
const formatDisplayName = (name) => {
if (!name) return ''
return name.split('_').map(part => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()).join('_')
}
const parseFlexibleDate = (dateStr) => {
if (!dateStr || dateStr === 'N/A') return null
try {
let cleanStr = dateStr.toString().split('.')[0].replace(/[_/]/g, '-')
const d = new Date(cleanStr)
return isNaN(d.getTime()) ? null : d
} catch { return null }
}
const getHoursDiff = (dateStr) => {
if (!dateStr || dateStr === 'N/A') return 999
const lastDate = parseFlexibleDate(dateStr)
if (!lastDate) return 999
return (new Date() - lastDate) / (1000 * 3600)
}
// --- 业务逻辑 (状态与排序) ---
const getLevel = (row) => {
if (row.status === '已离线' || row.status === '异常') return 4
if (!row.content || row.content === '{}') return 4
const last = parseFlexibleDate(row.latest_time)
if (!last) return 4
const now = new Date()
const days = (now - last) / (1000 * 3600 * 24)
if (days > 7) return 4
if (days > 2) return 3
if (now.getDate() !== last.getDate()) return 2
return 1
}
const getStatusLabel = (row) => {
if (row.status === '已离线') return '已离线'
if (row.status === '异常') return '异常'
const level = getLevel(row)
if (level === 4) return '缺失'
if (level === 3) return '>2天'
if (level === 2) return '昨日'
return '在线'
}
const getStatusColor = (row) => {
const level = getLevel(row)
return ['#909399', '#67C23A', '#E6A23C', '#ff8c00', '#F56C6C'][level]
}
const getStatusTagStyle = (row) => {
const color = getStatusColor(row)
return { backgroundColor: color, borderColor: color, color: 'white', border: 'none' }
}
const formatReason = (row) => {
if (row.reason && row.reason !== '同步成功') return row.reason
const level = getLevel(row)
if (level === 2) return '⚠️ 待今日更新'
return '✅ 同步正常'
}
// --- 隐藏/恢复逻辑 ---
const isHidden = (name) => ignoredList.value.includes(name)
const restoreDevice = (name) => {
if (!name) return
ignoredList.value = ignoredList.value.filter(item => item !== name)
localStorage.setItem('hide_list', JSON.stringify(ignoredList.value))
ElMessage.success('设备已恢复显示')
}
const hideSelected = () => {
if (selectedRows.value.length === 0) return
const namesToHide = selectedRows.value.map(row => row.name)
let count = 0
namesToHide.forEach(name => {
if (!ignoredList.value.includes(name)) {
ignoredList.value.push(name)
count++
}
})
if (count > 0) {
localStorage.setItem('hide_list', JSON.stringify(ignoredList.value))
ElMessage.warning(`已屏蔽 ${count} 个设备`)
if (multipleTableRef.value) multipleTableRef.value.clearSelection()
} else {
ElMessage.info('选中的设备已在屏蔽列表中')
}
}
const showDetails = (row) => {
activeDevice.value = row
drawerVisible.value = true
}
// --- 过滤与排序 ---
const sortedData = computed(() => {
return rawData.value.filter(d => {
const basicMatch = (filters.site === 'all' || d.source.includes(filters.site)) &&
d.name.toLowerCase().includes(filters.keyword.toLowerCase())
if (showHidden.value) return basicMatch
else return basicMatch && !isHidden(d.name)
}).sort((a, b) => getLevel(b) - getLevel(a))
})
const tableRowClassName = ({ row }) => row.status === '已离线' ? 'offline-row' : ''
// --- 刷新逻辑 ---
const fetchLogs = async () => {
try {
const res = await axios.get('/api/logs')
rawData.value = res.data
if (res.data.length > 0) {
const latest = res.data.reduce((prev, curr) => (prev.check_time > curr.check_time) ? prev : curr)
lastCheckTime.value = latest.check_time
}
} catch (e) {
console.warn("API Error, using mock data for display")
}
}
const checkStatus = async () => {
try {
const res = await axios.get('/api/status')
isRunning.value = res.data.is_running
if (isRunning.value) setTimeout(checkStatus, 2000)
else fetchLogs()
} catch { isRunning.value = false }
}
const handleManualRefresh = async (force = false) => {
const hours = getHoursDiff(lastCheckTime.value)
if (!force && hours < 6) {
try {
await ElMessageBox.confirm(
`数据更新于 ${hours.toFixed(1)} 小时前。后端每日10点自动更新通常无需手动操作。\n是否强制重新爬取`,
'数据尚新', { confirmButtonText: '强制爬取', cancelButtonText: '仅加载最新', type: 'warning' }
)
} catch {
fetchLogs()
ElMessage.success('已加载最新数据库记录')
return
}
}
try {
isRunning.value = true
await axios.post('/api/run')
checkStatus()
ElMessage.success('任务已下发')
} catch {
isRunning.value = false
ElMessage.warning('后台已有任务在运行')
}
}
// --- 图表逻辑 ---
const is106Site = computed(() => activeDevice.value?.source?.includes('106'))
const currentChartModules = computed(() => {
if (!activeDevice.value?.content || activeDevice.value.content === '{}') return []
if (is106Site.value) {
const modules = []
const infoRegex = /FS\d_Info,Model,([^,]+),SN,([^,]+).*?Wavelength,([\d\.,\s]+)/gs
let match
const contentStr = activeDevice.value.content
while ((match = infoRegex.exec(contentStr)) !== null) {
const wavelengths = match[3].split(',').map(Number).filter(n => !isNaN(n))
const series = []
for (let p = 1; p <= 4; p++) {
const dMatch = contentStr.match(new RegExp(`${match[1].trim()}_P${p}[^0-9-]*([\\d\\.,\\s-]+)`, 'i'))
if (dMatch) {
const vals = dMatch[1].split(',').map(v => {
const n = parseFloat(v);
// 修改点 2不再判断 n > 65500直接返回原始值 n
return n;
})
if (vals.some(v => v !== null)) series.push({ name: `P${p}`, data: vals, color: ['#5470c6', '#91cc75', '#fac858', '#ee6666'][p-1] })
}
}
if (series.length) modules.push({ model: match[1], sn: match[2], xAxis: wavelengths, series })
}
return modules
} else {
try {
const d = JSON.parse(activeDevice.value.content)
return d.wavelenth ? [{ title: activeDevice.value.name, xAxis: d.wavelenth, series: [
{ name: 'DownSpec', data: d.downspec, color: '#409EFF' }, { name: 'UpSpec', data: d.upspec, color: '#67C23A' }
]}] : []
} catch { return [] }
}
})
const initCharts = () => {
chartInstances = [] // 清空旧实例引用
nextTick(() => {
currentChartModules.value.forEach((m, i) => {
const dom = document.getElementById(`chart-${i}`)
if (dom) {
if (echarts.getInstanceByDom(dom)) echarts.getInstanceByDom(dom).dispose()
const chart = echarts.init(dom)
chartInstances.push(chart)
chart.setOption({
title: { text: is106Site.value ? `SN: ${m.sn}` : m.title, left: 'center', top: 10, textStyle: { fontSize: isMobile.value ? 14 : 18 } },
tooltip: { trigger: 'axis', confine: true },
legend: { top: 35, type: 'scroll' },
grid: { top: 70, bottom: 30, right: isMobile.value ? 10 : 30, left: isMobile.value ? 40 : 50 },
xAxis: { type: 'category', data: m.xAxis, boundaryGap: false },
yAxis: { type: 'value', min: 'dataMin', max: 'dataMax' },
series: m.series.map(s => ({
name: s.name,
type: 'line',
data: s.data,
connectNulls: false,
smooth: true,
showSymbol: false,
lineStyle: { width: 2, color: s.color },
areaStyle: { opacity: 0.1, color: s.color }
}))
})
}
})
})
}
// --- 生命周期 ---
onMounted(() => {
document.title = "光谱数据监控"
fetchLogs()
window.addEventListener('resize', handleResize)
autoRefreshTimer = setInterval(() => {
if (!isRunning.value) fetchLogs()
}, 300000)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize)
if (autoRefreshTimer) clearInterval(autoRefreshTimer)
chartInstances.forEach(c => c && c.dispose())
})
</script>
<style scoped>
/* 基础 PC 端样式 */
.container { padding: 20px; max-width: 1400px; margin: 0 auto; background-color: #f5f7fa; min-height: 100vh; transition: all 0.3s; }
.header-row { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; }
.sys-title { margin: 0; font-size: 22px; color: #303133; font-weight: 600; }
.sys-status { font-size: 13px; color: #909399; margin-top: 5px; }
.status-running { color: #409EFF; font-weight: bold; display: flex; align-items: center; gap: 5px; }
.status-idle { display: flex; align-items: center; gap: 5px; }
.status-summary { margin: 15px 0; display: flex; gap: 10px; flex-wrap: wrap; }
.toolbar { background: #fff; padding: 15px 20px; border-radius: 8px; display: flex; justify-content: space-between; margin-bottom: 20px; border: 1px solid #ebeef5; align-items: center; transition: all 0.3s; }
.filter-section { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
.name-cell { display: flex; align-items: center; flex-wrap: wrap; gap: 5px;}
:deep(.offline-row) { background-color: #fef0f0 !important; }
.drawer-content { padding: 0 20px 20px; }
.info-banner { margin-bottom: 20px; }
.chart-container { margin-bottom: 30px; border: 1px solid #e4e7ed; border-radius: 8px; overflow: hidden; background: #fff; }
.chart-header { background: #fafafa; padding: 10px 20px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #e4e7ed; }
.echart-box { width: 100%; height: 380px; }
.echart-box.no-header { margin-top: 15px; }
/* 移动端适配 (Screen < 768px) */
@media screen and (max-width: 768px) {
.container { padding: 10px; }
/* 头部调整 */
.header-row { flex-direction: column; align-items: flex-start; }
.left-panel { width: 100%; margin-bottom: 10px; }
.header-actions { width: 100%; display: flex; justify-content: flex-end; }
.sys-title { font-size: 18px; }
/* 状态标签调整 */
.status-summary { gap: 5px; }
.res-tag { font-size: 11px; height: 24px; padding: 0 5px; }
/* 工具栏调整 */
.mobile-toolbar { flex-direction: column; align-items: stretch; padding: 15px 10px; }
.filter-section { flex-direction: column; align-items: stretch; width: 100%; }
.search-input { width: 100% !important; margin-left: 0 !important; margin-top: 5px; }
.action-section {
display: flex;
justify-content: space-between;
margin-top: 15px;
padding-top: 10px;
border-top: 1px dashed #ebeef5;
}
/* Drawer 内部调整 */
.drawer-content { padding: 0 10px 20px; }
.chart-header { flex-direction: column; align-items: flex-start; gap: 5px; }
.echart-box { height: 300px; }
}
</style>