Files
ZDXX/zhandianxinxi/my-vue-app/src/App.vue
2026-01-07 11:27:20 +08:00

439 lines
17 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> 状态: 待命 (更新于: {{ lastUpdateTime }})
</span>
</div>
</div>
<el-button type="primary" :loading="isRunning" @click="handleManualRefresh" round icon="Refresh">全量同步</el-button>
</div>
</template>
<div class="status-summary">
<el-tag type="danger">红色离线/无数据/滞后>7</el-tag>
<el-tag type="warning" style="--el-tag-bg-color: #ff8c00; border-color: #ff8c00; color: #fff;">橘色滞后2-7</el-tag>
<el-tag type="warning">黄色滞后1-2 跨天未更新</el-tag>
<el-tag type="success">绿色今日已同步</el-tag>
</div>
<div class="toolbar">
<div class="filter-section">
<el-radio-group v-model="filters.site">
<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="搜索设备..." style="width: 200px; margin-left: 15px;" clearable />
</div>
<div class="action-section">
<el-checkbox v-model="showHidden" label="显示屏蔽" border style="margin-right: 10px"/>
<el-button type="warning" plain :disabled="selectedRows.length === 0" @click="hideSelected">屏蔽选中</el-button>
</div>
</div>
<el-table :data="sortedData" border height="600" v-loading="isRunning" @selection-change="val => selectedRows = val">
<el-table-column type="selection" width="50" align="center" />
<el-table-column prop="source" label="来源" width="100" />
<el-table-column label="名称" min-width="180">
<template #default="{ row }">
<el-link type="primary" underline="hover" @click="showDetails(row)" style="font-weight: bold; font-size: 15px;">
{{ formatDisplayName(row.name) }}
</el-link>
<el-tag v-if="isHidden(row.name)" type="info" size="small" style="margin-left:5px">已隐藏</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="140" align="center">
<template #default="{ row }">
<el-tag :style="getStatusTagStyle(row)" effect="dark">
{{ getStatusLabel(row) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="reason" label="实时详情">
<template #default="{ row }">
<span :style="{ color: getStatusColor(row), fontWeight: getLevel(row) > 1 ? 'bold' : 'normal' }">
{{ formatReason(row) }}
</span>
</template>
</el-table-column>
<el-table-column prop="offset" label="时效" width="100" />
<el-table-column prop="latest_time" label="同步日期" width="180" />
<el-table-column label="管理" width="80" v-if="showHidden">
<template #default="{ row }">
<el-button type="primary" link @click="restoreDevice(row.name)">恢复</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-drawer v-model="drawerVisible" title="设备数据分析详情" size="80%" @opened="initCharts">
<div v-if="activeDevice" class="drawer-content">
<div class="info-banner">
<el-descriptions :column="3" border size="small">
<el-descriptions-item label="设备名称">{{ formatDisplayName(activeDevice.name) }}</el-descriptions-item>
<el-descriptions-item label="所属来源">{{ activeDevice.source }}</el-descriptions-item>
<el-descriptions-item label="最后同步时间">{{ activeDevice.latest_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">
{{ isContentEmpty(activeDevice) ? '暂无有效的数据内容' : '未检测到符合格式的光谱数据' }}
</div>
<div v-for="(module, index) in currentChartModules" :key="index" class="chart-container">
<div class="chart-header" v-if="is106Site">
<span class="module-tag">型号: {{ module.model }}</span>
<span class="sn-tag">序列号: {{ module.sn }}</span>
</div>
<div :id="'chart-' + index" class="echart-box" :class="{ 'no-header': !is106Site }"></div>
</div>
</div>
</div>
</el-drawer>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
import axios from 'axios'
import * as echarts from 'echarts'
// 1. 基础响应式变量
const rawData = ref([])
const isRunning = ref(false)
const lastUpdateTime = ref('-')
const selectedRows = ref([])
const showHidden = ref(false)
const drawerVisible = ref(false)
const activeDevice = ref(null)
const filters = reactive({ site: 'all', keyword: '' })
const ignoredList = ref(JSON.parse(localStorage.getItem('hide_list') || '[]'))
// --- UI 格式化工具 ---
const formatDisplayName = (name) => {
if (!name) return ''
return name.split('_').map(part => {
const lower = part.toLowerCase()
return lower.charAt(0).toUpperCase() + lower.slice(1)
}).join('_')
}
// --- 核心逻辑 ---
const isContentEmpty = (row) => {
return !row.content ||
row.content.toString().trim() === '' ||
row.content === '{}' ||
row.content === '[]'
}
const isSameDay = (d1, d2) => {
return d1.getFullYear() === d2.getFullYear() &&
d1.getMonth() === d2.getMonth() &&
d1.getDate() === d2.getDate()
}
const parseFlexibleDate = (dateStr) => {
if (!dateStr) return null
try {
let cleanStr = dateStr.toString().replace(/[_/]/g, '-')
if (cleanStr.includes(' ')) {
const parts = cleanStr.split(' ')
if (parts[1] && parts[1].includes('-') && !parts[1].includes(':')) {
parts[1] = parts[1].replace(/-/g, ':')
}
cleanStr = parts.join(' ')
}
const d = new Date(cleanStr)
return isNaN(d.getTime()) ? null : d
} catch {
return null
}
}
const getLevel = (row) => {
const errorKeywords = ['离线', '失败', '无法访问', '无数据', '异常', 'Empty']
if (row.reason && errorKeywords.some(kw => row.reason.includes(kw))) return 4
if (isContentEmpty(row)) return 4
const last = parseFlexibleDate(row.latest_time)
if (!last) return 4
const now = new Date()
const diffHours = (now - last) / (1000 * 3600)
const diffDays = diffHours / 24
if (diffDays > 7) return 4
if (diffDays > 2) return 3
if (diffHours > 24) return 2
if (!isSameDay(now, last)) return 2
return 1
}
const getStatusLabel = (row) => {
const level = getLevel(row)
if (level === 4) {
if (isContentEmpty(row) && (!row.reason || !row.reason.includes('离线'))) return '数据缺失'
return '离线/异常'
}
if (level === 2) {
const last = parseFlexibleDate(row.latest_time)
const now = new Date()
if ((now - last) / (1000 * 3600) <= 24) return '待今日更新(<24h)'
return '滞后(1-2d)'
}
return ['','正常','滞后(1-2d)','滞后(2-7d)'][level]
}
const getStatusColor = (row) => {
const level = getLevel(row)
if (level === 1) return '#67C23A'
if (level === 2) return '#E6A23C'
if (level === 3) return '#ff8c00'
return '#F56C6C'
}
const getStatusTagStyle = (row) => {
const color = getStatusColor(row)
return { backgroundColor: color, borderColor: color, color: 'white', border: 'none' }
}
const formatReason = (row) => {
if (row.reason) return row.reason
const level = getLevel(row)
if (level === 4 && isContentEmpty(row)) return '❌ 数据内容为空'
if (level === 2 && getStatusLabel(row).includes('待今日更新')) return '⚠️ 数据仍为昨日'
return '同步正常'
}
const isHidden = (name) => ignoredList.value.includes(name)
const sortedData = computed(() => {
return rawData.value.filter(d => {
const sMatch = filters.site === 'all' || d.source.includes(filters.site)
const kMatch = d.name.toLowerCase().includes(filters.keyword.toLowerCase())
return sMatch && kMatch && (showHidden.value || !isHidden(d.name))
}).sort((a, b) => getLevel(b) - getLevel(a))
})
// --- 图表数据解析逻辑 (修正版) ---
const is106Site = computed(() => activeDevice.value?.source.includes('106'))
// 106 数据解析逻辑:增加对 65534/65535 的饱和值过滤
const chartModules106 = computed(() => {
if (!is106Site.value || isContentEmpty(activeDevice.value)) return []
const content = activeDevice.value.content
const modules = []
const infoRegex = /FS\d_Info,Model,([^,]+),SN,([^,]+).*?Wavelength,([\d\.,\s]+)/gs
let infoMap = []
let match
while ((match = infoRegex.exec(content)) !== null) {
infoMap.push({
model: match[1].trim(),
sn: match[2].trim(),
wavelengths: match[3].split(',').map(v => v.trim()).filter(v => v && !isNaN(v)).map(Number)
})
}
infoMap.forEach(info => {
const series = []
for (let p = 1; p <= 4; p++) {
const dataRegex = new RegExp(`${info.model}_P${p}[^0-9-]*([\\d\\.,\\s-]+)`, 'i')
const dataMatch = content.match(dataRegex)
if (dataMatch) {
const rawStr = dataMatch[1]
const vals = rawStr.split(',').map(v => {
const num = parseFloat(v)
if (isNaN(num)) return null
// 【核心修复】如果数值超过 65500 (通常是 65534/65535 饱和值)
// 视为 null 无效点。这能解决 20000 的数据被 65534 撑大的问题。
return num > 65500 ? null : num
})
// 过滤掉全是 null 的数组,避免报错
if (vals.some(v => v !== null)) {
series.push({ name: `通道 P${p}`, data: vals })
}
}
}
if (series.length > 0) {
modules.push({ model: info.model, sn: info.sn, xAxis: info.wavelengths, series: series })
}
})
return modules
})
// 82 数据解析逻辑 (保持逻辑不变)
const chartModules82 = computed(() => {
if (is106Site.value || isContentEmpty(activeDevice.value)) return []
try {
const data = JSON.parse(activeDevice.value.content)
if (!Array.isArray(data.wavelenth) || !Array.isArray(data.downspec) || !Array.isArray(data.upspec)) {
return []
}
return [{
title: formatDisplayName(activeDevice.value.name),
xAxis: data.wavelenth,
series: [
{ name: 'DownSpec (下行光谱)', data: data.downspec, color: '#409EFF' },
{ name: 'UpSpec (上行光谱)', data: data.upspec, color: '#67C23A' }
]
}]
} catch (e) {
return []
}
})
const currentChartModules = computed(() => {
return is106Site.value ? chartModules106.value : chartModules82.value
})
// --- ECharts 渲染 ---
const initCharts = () => {
nextTick(() => {
const modules = currentChartModules.value
if (modules.length === 0) return
modules.forEach((module, index) => {
const dom = document.getElementById(`chart-${index}`)
if (!dom) return
if (echarts.getInstanceByDom(dom)) echarts.getInstanceByDom(dom).dispose()
const chart = echarts.init(dom)
const defaultColors = ['#409EFF', '#67C23A', '#E6A23C', '#F56C6C']
const option = {
title: {
text: is106Site.value ? `SN: ${module.sn}` : module.title,
left: 'center',
top: is106Site.value ? 5 : 15,
textStyle: { fontSize: 14, color: '#606266' }
},
tooltip: { trigger: 'axis' },
legend: { top: is106Site.value ? '8%' : '12%' },
grid: { left: '3%', right: '4%', bottom: '3%', top: '22%', containLabel: true },
xAxis: { type: 'category', boundaryGap: false, data: module.xAxis, axisLabel: { color: '#909399', fontSize: 10 } },
yAxis: {
type: 'value',
scale: true,
// 核心点:紧贴 dataMin 和 dataMax
// 由于上面已经把 65535 变成了 null这里的 dataMax 将是 20000 左右
min: 'dataMin',
max: 'dataMax',
splitLine: { lineStyle: { type: 'dashed' } }
},
series: module.series.map((s, i) => {
const lineColor = s.color || defaultColors[i % 4]
return {
name: s.name,
type: 'line',
data: s.data,
// 连线策略spanGaps 为 false 表示遇到 null 断开,这样能清楚看到哪里数据过曝了
// 如果想连起来,可以改为 true
connectNulls: false,
smooth: true,
showSymbol: false,
lineStyle: { width: 2, color: lineColor },
areaStyle: {
color: new echarts.graphic.LinearGradient(0,0,0,1,[{offset:0,color:lineColor+'33'},{offset:1,color:'transparent'}])
}
}
})
}
chart.setOption(option)
window.addEventListener('resize', () => chart.resize())
})
})
}
// --- API 交互 (保持不变) ---
const fetchLogs = async () => {
try {
const res = await axios.get('/api/logs')
rawData.value = res.data
if (res.data.length) lastUpdateTime.value = res.data[0].check_time || new Date().toLocaleString()
} catch (e) {
console.error("获取日志失败", e)
}
}
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 (e) {
isRunning.value = false
}
}
const handleManualRefresh = async () => {
try {
isRunning.value = true
await axios.post('/api/run')
checkStatus()
} catch (e) {
isRunning.value = false
}
}
const showDetails = (row) => {
activeDevice.value = row
drawerVisible.value = true
}
const hideSelected = () => {
ignoredList.value = [...new Set([...ignoredList.value, ...selectedRows.value.map(r => r.name)])]
localStorage.setItem('hide_list', JSON.stringify(ignoredList.value))
selectedRows.value = []
}
const restoreDevice = (name) => {
ignoredList.value = ignoredList.value.filter(n => n !== name)
localStorage.setItem('hide_list', JSON.stringify(ignoredList.value))
}
onMounted(() => {
fetchLogs()
})
</script>
<style scoped>
.container { padding: 20px; max-width: 1400px; margin: 0 auto; background-color: #f5f7fa; min-height: 100vh; }
.header-row { display: flex; justify-content: space-between; align-items: center; }
.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; }
.drawer-content { padding: 0 20px 20px; }
.info-banner { margin-bottom: 20px; }
.section-title { border-left: 4px solid #409EFF; padding-left: 10px; margin: 25px 0 15px; font-size: 18px; display: flex; align-items: center; gap: 8px; color: #303133; }
.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; }
.module-tag { font-weight: bold; color: #409EFF; font-size: 15px; }
.sn-tag { font-size: 12px; color: #606266; background: #ecf5ff; padding: 3px 8px; border-radius: 4px; border: 1px solid #d9ecff; }
.echart-box { width: 100%; height: 380px; }
.echart-box.no-header { margin-top: 15px; }
.empty-hint { text-align: center; padding: 50px; color: #909399; font-style: italic; background: #fff; border-radius: 8px; }
:deep(.el-drawer__header) { margin-bottom: 0; padding: 15px 20px; background: #303133; color: #fff !important; }
:deep(.el-drawer__title) { color: #fff; font-weight: bold; }
:deep(.el-drawer__close-btn) { color: #fff; }
</style>