106网站图像展示,未完成版

This commit is contained in:
YueL1331
2026-01-07 11:27:20 +08:00
parent 2c2f9e43e3
commit fa66da3ff5

View File

@ -18,6 +18,13 @@
</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">
@ -39,23 +46,27 @@
<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;">
{{ row.name }}
{{ 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="100" align="center">
<el-table-column label="状态" width="140" align="center">
<template #default="{ row }">
<el-tag :type="getStatusType(row)" effect="dark">{{ getStatusLabel(row) }}</el-tag>
<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) }">{{ row.reason }}</span>
<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="160" />
<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>
@ -68,30 +79,31 @@
<div v-if="activeDevice" class="drawer-content">
<div class="info-banner">
<el-descriptions :column="3" border size="small">
<el-descriptions-item label="设备名称">{{ activeDevice.name }}</el-descriptions-item>
<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 v-if="is106Site" class="visual-section">
<h3 class="section-title"><el-icon><DataLine /></el-icon> 光谱能量分布分析</h3>
<div v-if="chartModules.length === 0" class="empty-hint">未检测到可解析的光谱数据模块</div>
<div v-for="(module, index) in chartModules" :key="index" class="chart-container">
<div class="chart-header">
<span class="module-tag">{{ module.title }}</span>
<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"></div>
<div :id="'chart-' + index" class="echart-box" :class="{ 'no-header': !is106Site }"></div>
</div>
</div>
<div v-else class="raw-section">
<h3 class="section-title"><el-icon><Document /></el-icon> 原始数据报文</h3>
<div class="json-box">
<json-viewer :value="parsedData" copyable boxed :expand-depth="4" />
</div>
</div>
</div>
</el-drawer>
</div>
@ -102,22 +114,110 @@ import { ref, reactive, computed, onMounted, nextTick } from 'vue'
import axios from 'axios'
import * as echarts from 'echarts'
// 基础状态与原有逻辑保持一致
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)
// 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') || '[]'))
const getLevel = (row) => {
if (row.reason.includes('离线') || row.reason.includes('失败')) return 3
try {
const last = new Date(row.latest_time.replace(/_/g, '-'))
return (new Date() - last) / (1000 * 3600 * 24) > 3 ? 3 : 1
} catch { return 3 }
// --- 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 getStatusType = (row) => ['','success','warning','danger'][getLevel(row)]
const getStatusLabel = (row) => ['','正常','滞后','异常'][getLevel(row)]
const getStatusColor = (row) => ['','#67C23A','#E6A23C','#F56C6C'][getLevel(row)]
// --- 核心逻辑 ---
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(() => {
@ -128,168 +228,211 @@ const sortedData = computed(() => {
}).sort((a, b) => getLevel(b) - getLevel(a))
})
// --- 图表数据解析逻辑 (修正版) ---
const is106Site = computed(() => activeDevice.value?.source.includes('106'))
// 106 数据解析逻辑完善
const chartModules = computed(() => {
if (!is106Site.value || !activeDevice.value?.content) return []
// 106 数据解析逻辑:增加对 65534/65535 的饱和值过滤
const chartModules106 = computed(() => {
if (!is106Site.value || isContentEmpty(activeDevice.value)) return []
const content = activeDevice.value.content
const modules = []
const blocks = content.match(/(FS\d_Info.*?)(?=FS\d_Info|$)/gs)
if (blocks) {
blocks.forEach(block => {
const snMatch = block.match(/SN,(\d+)/)
const modelMatch = block.match(/Model,([^,]+)/)
const wavelengthMatch = block.match(/Wavelength,([\d\.,]+)/)
if (wavelengthMatch) {
const wavelengths = wavelengthMatch[1].split(',').filter(v => v.trim()).map(Number)
const series = []
const modelName = modelMatch ? modelMatch[1] : 'ISIF'
for (let i = 1; i <= 4; i++) {
const pRegex = new RegExp(`${modelName}_P${i},([\\d\\.,valid]+)`, 'i')
const pMatch = block.match(pRegex)
if (pMatch) {
const values = pMatch[1].split(',').map(v => parseFloat(v)).filter(v => !isNaN(v))
if (values.length > 0) series.push({ name: `通道 P${i}`, data: values })
}
}
if (series.length > 0) {
modules.push({ title: block.split(',')[0], sn: snMatch?.[1] || 'Unknown', xAxis: wavelengths, series })
}
}
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
})
// 图表美化与极简坐标轴
const initCharts = () => {
if (!is106Site.value) return
nextTick(() => {
chartModules.value.forEach((module, index) => {
const chartDom = document.getElementById(`chart-${index}`)
if (!chartDom) return
const myChart = echarts.init(chartDom)
const allValues = module.series.flatMap(s => s.data)
const maxV = Math.max(...allValues); const minV = Math.min(...allValues)
const midV = ((maxV + minV) / 2).toFixed(2)
const waveMax = Math.max(...module.xAxis); const waveMin = Math.min(...module.xAxis)
const waveMid = module.xAxis[Math.floor(module.xAxis.length / 2)]
const colors = [
['#409EFF', 'rgba(64, 158, 255, 0.1)'],
['#67C23A', 'rgba(103, 194, 58, 0.1)'],
['#E6A23C', 'rgba(230, 162, 60, 0.1)'],
['#F56C6C', 'rgba(245, 108, 108, 0.1)']
// 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 = {
backgroundColor: '#fff',
tooltip: { trigger: 'axis', backgroundColor: 'rgba(255, 255, 255, 0.9)', textStyle: { color: '#333' } },
grid: { left: '8%', right: '8%', top: '15%', bottom: '15%', containLabel: false },
xAxis: {
type: 'category',
data: module.xAxis,
axisLine: { lineStyle: { color: '#DCDFE6' } },
axisTick: { show: false },
axisLabel: {
interval: 0,
formatter: (value) => {
const num = Number(value)
if (num === waveMin || num === waveMax || num === waveMid) return num + 'nm'
return ''
},
color: '#909399', fontSize: 11
}
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',
splitLine: { show: false },
axisLine: { show: true, lineStyle: { color: '#DCDFE6' } },
axisLabel: {
formatter: (value) => {
if (value === 0) return '0'
const v = Number(value).toFixed(2)
if (v == maxV.toFixed(2)) return `Max:${v}`
if (v == minV.toFixed(2)) return `Min:${v}`
if (v == midV) return `Mid:${v}`
return ''
},
color: '#909399', fontSize: 11
}
scale: true,
// 核心点:紧贴 dataMin 和 dataMax
// 由于上面已经把 65535 变成了 null这里的 dataMax 将是 20000 左右
min: 'dataMin',
max: 'dataMax',
splitLine: { lineStyle: { type: 'dashed' } }
},
series: module.series.map((s, i) => ({
name: s.name,
type: 'line',
data: s.data,
smooth: true,
showSymbol: false,
lineStyle: { width: 3, color: colors[i % 4][0] },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: colors[i % 4][1] },
{ offset: 1, color: 'transparent' }
])
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'}])
}
}
}))
})
}
myChart.setOption(option)
chart.setOption(option)
window.addEventListener('resize', () => chart.resize())
})
})
}
// 通用方法
const showDetails = (row) => { activeDevice.value = row; drawerVisible.value = true }
const parsedData = computed(() => {
if (!activeDevice.value?.content) return null
try { return JSON.parse(activeDevice.value.content) } catch { return { raw: activeDevice.value.content } }
})
// --- 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))
}
const fetchLogs = async () => {
const res = await axios.get('/api/logs')
rawData.value = res.data; if (res.data.length) lastUpdateTime.value = res.data[0].check_time
}
const checkStatus = async () => {
const res = await axios.get('/api/status')
isRunning.value = res.data.is_running
isRunning.value ? setTimeout(checkStatus, 2000) : fetchLogs()
}
const handleManualRefresh = async () => { await axios.post('/api/run'); isRunning.value = true; checkStatus() }
onMounted(() => { checkStatus(); fetchLogs() })
onMounted(() => {
fetchLogs()
})
</script>
<style scoped>
.container { padding: 20px; max-width: 1400px; margin: 0 auto; }
.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; }
.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; }
.toolbar { background: #fff; padding: 15px 20px; border-radius: 8px; display: flex; justify-content: space-between; margin: 20px 0; border: 1px solid #ebeef5; }
.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: 25px; }
.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 #f0f0f0; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 12px rgba(0,0,0,0.03); }
.chart-header { background: #fafafa; padding: 12px 20px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #f0f0f0; }
.module-tag { font-weight: bold; color: #409EFF; }
.sn-tag { font-size: 12px; color: #909399; background: #eee; padding: 2px 8px; border-radius: 4px; }
.echart-box { width: 100%; height: 450px; }
.empty-hint { text-align: center; padding: 50px; color: #909399; font-style: italic; }
.json-box { border: 1px solid #eee; background: #fafafa; border-radius: 8px; padding: 15px; }
.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>