修复屏蔽设备恢复效果,设定后端计时器每天10点刷新,同时设定前端刷新页面时间

This commit is contained in:
YueL1331
2026-01-07 13:14:41 +08:00
parent cbe6e884b5
commit 15d66d6694
2 changed files with 403 additions and 350 deletions

View File

@ -2,35 +2,46 @@ import os
import json import json
import threading import threading
import requests import requests
import logging
from datetime import datetime from datetime import datetime
from flask import Flask, jsonify from flask import Flask, jsonify
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from flask_cors import CORS from flask_cors import CORS
from flask_apscheduler import APScheduler
from lxml import etree from lxml import etree
# --- 配置日志 ---
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
app = Flask(__name__) app = Flask(__name__)
CORS(app) CORS(app)
# --- 数据库配置 --- # --- 数据库配置 ---
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///monitor_data.db' app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///monitor_data.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['SCHEDULER_API_ENABLED'] = True # 允许通过API查看调度任务
db = SQLAlchemy(app) db = SQLAlchemy(app)
scheduler = APScheduler()
class ErrorLog(db.Model): # --- 模型定义 ---
class MonitorRecord(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
source = db.Column(db.String(50)) source = db.Column(db.String(50))
name = db.Column(db.String(100)) name = db.Column(db.String(100))
status = db.Column(db.String(50)) # 正常 / 离线 / 异常
reason = db.Column(db.String(255)) reason = db.Column(db.String(255))
offset = db.Column(db.String(50)) offset = db.Column(db.String(50))
latest_time = db.Column(db.String(50)) latest_time = db.Column(db.String(50)) # 数据时间
check_time = db.Column(db.String(50)) check_time = db.Column(db.String(50)) # 爬取时间
content = db.Column(db.Text, nullable=True) content = db.Column(db.Text, nullable=True)
with app.app_context(): with app.app_context():
db.create_all() db.create_all()
# --- 爬虫配置 ---
CONFIG = { CONFIG = {
"106": { "106": {
"base_url": "http://106.75.72.40:7500/api/proxy/tcp", "base_url": "http://106.75.72.40:7500/api/proxy/tcp",
@ -46,35 +57,61 @@ CONFIG = {
is_running = False is_running = False
# --- 通用工具函数 --- # --- 核心辅助函数 ---
def add_error_to_db(source, name, reason, latest_time="N/A", content=None): def calculate_offset(latest_time_str):
days_diff = "N/A" if not latest_time_str or latest_time_str == "N/A":
if latest_time and latest_time != "N/A": return "从未同步"
try:
# 兼容 2024_05_20 和 2024-05-20 两种格式
clean_date_str = str(latest_time).split()[0].replace('_', '-')
target_date = datetime.strptime(clean_date_str, "%Y-%m-%d").date()
diff = (datetime.now().date() - target_date).days
days_diff = f"滞后 {diff}" if diff > 0 else "当天已同步"
except:
days_diff = "解析失败"
log = ErrorLog(
source=source, name=name, reason=reason, offset=days_diff,
latest_time=latest_time, content=content,
check_time=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
)
db.session.add(log)
# --- 106 专用辅助函数 ---
def get_106_dynamic_token(port):
login_url = f"http://106.75.72.40:{port}/api/login"
try: try:
clean_date_str = str(latest_time_str).split()[0].replace('_', '-')
target_date = datetime.strptime(clean_date_str, "%Y-%m-%d").date()
diff = (datetime.now().date() - target_date).days
if diff == 0: return "当天已同步"
return f"滞后 {diff}"
except:
return "时间解析失败"
def save_record(source, name, status, reason, latest_time="N/A", content=None):
"""
Upsert 逻辑: 有则更新,无则插入
"""
record = MonitorRecord.query.filter_by(source=source, name=name).first()
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
current_offset = calculate_offset(latest_time)
if record:
if content is not None: record.content = content
if latest_time != "N/A": record.latest_time = latest_time
record.status = status
record.reason = reason
record.check_time = now_str
# 使用当前库里的时间重新计算 offset
time_base = latest_time if latest_time != "N/A" else record.latest_time
record.offset = calculate_offset(time_base)
else:
new_record = MonitorRecord(
source=source, name=name, status=status, reason=reason,
offset=current_offset, latest_time=latest_time,
check_time=now_str, content=content
)
db.session.add(new_record)
try:
db.session.commit()
except Exception as e:
db.session.rollback()
logging.error(f"DB Error: {e}")
return f"{source}_{name}"
# --- 106 逻辑 ---
def get_106_dynamic_token(port):
try:
login_url = f"http://106.75.72.40:{port}/api/login"
resp = requests.post(login_url, json=CONFIG["106"]["login_payload"], timeout=10) resp = requests.post(login_url, json=CONFIG["106"]["login_payload"], timeout=10)
if resp.status_code == 200: return resp.text.strip().replace('"', '') if resp.status_code == 200 else None
return resp.text.strip().replace('"', '')
return None
except: except:
return None return None
@ -102,62 +139,60 @@ def find_closest_item(items, is_date_level=True):
return scored_items[0] return scored_items[0]
# --- 106 核心采集逻辑 --- def run_106_logic(active_set):
def run_106_logic():
c = CONFIG["106"] c = CONFIG["106"]
today_str = datetime.now().strftime("%Y_%m_%d") today_str = datetime.now().strftime("%Y_%m_%d")
main_headers = {"Authorization": c["primary_auth"], "User-Agent": "Mozilla/5.0"} main_headers = {"Authorization": c["primary_auth"], "User-Agent": "Mozilla/5.0"}
try: try:
resp = requests.get(c["base_url"], headers=main_headers, timeout=15) resp = requests.get(c["base_url"], headers=main_headers, timeout=20)
proxies = resp.json().get('proxies', []) proxies = resp.json().get('proxies', [])
for item in proxies: for item in proxies:
name = item.get('name', '') name = item.get('name', '')
# 严格过滤
if not name.lower().endswith('_data'): continue if not name.lower().endswith('_data'): continue
name_upper = name.upper() if "TOWER" not in name.upper(): continue
is_tower_underscore = "TOWER_" in name_upper
is_tower_i = "TOWER" in name_upper and not is_tower_underscore
if not (is_tower_underscore or is_tower_i): continue
# 状态检查
if str(item.get('status')).lower() != 'online': if str(item.get('status')).lower() != 'online':
add_error_to_db("106网站", name, f"离线({item.get('status')})") key = save_record("106网站", name, "离线", f"设备状态: {item.get('status')}")
active_set.add(key)
continue continue
try: try:
port = item.get('conf', {}).get('remote_port') port = item.get('conf', {}).get('remote_port')
token = get_106_dynamic_token(port) token = get_106_dynamic_token(port)
if not token: if not token:
add_error_to_db("106网站", name, "Token获取失败") key = save_record("106网站", name, "异常", "Token获取失败")
active_set.add(key)
continue continue
headers = {"Authorization": c["primary_auth"], "x-auth": token, "User-Agent": "Mozilla/5.0"} headers = {"Authorization": c["primary_auth"], "x-auth": token}
api_root = "/api/resources/Data/" if is_tower_underscore else "/api/resources/data/" api_root = "/api/resources/Data/" if "TOWER_" in name.upper() else "/api/resources/data/"
# Step 1: 寻找日期目录 # 寻找日期
res1 = requests.get(f"http://106.75.72.40:{port}{api_root}", headers=headers, timeout=10) res1 = requests.get(f"http://106.75.72.40:{port}{api_root}", headers=headers, timeout=10)
best_date = find_closest_item(res1.json().get('items', []), is_date_level=True) best_date = find_closest_item(res1.json().get('items', []), True)
if not best_date or best_date[2] != today_str: if not best_date or best_date[2] != today_str:
add_error_to_db("106网站", name, "未找到今日文件夹", best_date[2] if best_date else "") key = save_record("106网站", name, "正常", "未找到今日文件夹",
latest_time=best_date[2] if best_date else "N/A")
active_set.add(key)
continue continue
# Step 2: 寻找最新文件 # 寻找文件
date_path = f"{api_root}{best_date[2]}/" date_path = f"{api_root}{best_date[2]}/"
res2 = requests.get(f"http://106.75.72.40:{port}{date_path}", headers=headers, timeout=10) res2 = requests.get(f"http://106.75.72.40:{port}{date_path}", headers=headers, timeout=10)
best_file = find_closest_item(res2.json().get('items', []), is_date_level=False) best_file = find_closest_item(res2.json().get('items', []), False)
if not best_file: if not best_file:
add_error_to_db("106网站", name, "文件夹内无文件", today_str) key = save_record("106网站", name, "正常", "今日文件夹为空", latest_time=today_str)
active_set.add(key)
continue continue
# 获取内容
file_item = best_file[1] file_item = best_file[1]
full_path = file_item.get('path') or f"{date_path}{file_item.get('name')}" full_path = file_item.get('path') or f"{date_path}{file_item.get('name')}"
# Step 3: 获取内容 # 判断下载方式
final_content = "" is_tower_i = "TOWER" in name.upper() and "TOWER_" not in name.upper()
if is_tower_i: if is_tower_i:
download_url = f"http://106.75.72.40:{port}/api/raw{full_path}" download_url = f"http://106.75.72.40:{port}/api/raw{full_path}"
res3 = requests.get(download_url, headers=headers, timeout=20) res3 = requests.get(download_url, headers=headers, timeout=20)
@ -167,23 +202,25 @@ def run_106_logic():
res3 = requests.get(file_api_url, headers=headers, timeout=20) res3 = requests.get(file_api_url, headers=headers, timeout=20)
final_content = res3.json().get('content', '') final_content = res3.json().get('content', '')
add_error_to_db("106网站", name, "同步成功", today_str, content=final_content) key = save_record("106网站", name, "正常", "同步成功", latest_time=today_str, content=final_content)
active_set.add(key)
except Exception as e: except Exception as e:
add_error_to_db("106网站", name, f"采集异常: {str(e)}") key = save_record("106网站", name, "异常", f"采集错误: {str(e)[:50]}")
active_set.add(key)
except Exception as e: except Exception as e:
add_error_to_db("106网站", "全局错误", str(e)) logging.error(f"106 Global Error: {e}")
# --- 82 逻辑 (保持不变) --- # --- 82 逻辑 ---
def run_82_logic(): def run_82_logic(active_set):
c = CONFIG["82"] c = CONFIG["82"]
session = requests.Session() session = requests.Session()
try: try:
session.post(f"{c['base_url']}/login.php", data=c["login"], timeout=10) session.post(f"{c['base_url']}/login.php", data=c["login"], timeout=10)
resp = session.post(f"{c['base_url']}/GetStationList.php", timeout=10) resp = session.post(f"{c['base_url']}/GetStationList.php", timeout=10)
stations = etree.HTML(resp.content).xpath('//option/@value') stations = etree.HTML(resp.content).xpath('//option/@value')
for sid in [s for s in stations if s]: for sid in [s for s in stations if s]:
try: try:
r = session.post(f"{c['base_url']}/getLastWeatherData.php", data=str(sid), r = session.post(f"{c['base_url']}/getLastWeatherData.php", data=str(sid),
@ -192,30 +229,58 @@ def run_82_logic():
if data: if data:
d_list = data.get('date', []) d_list = data.get('date', [])
latest = str(d_list[-1]) if d_list else "N/A" latest = str(d_list[-1]) if d_list else "N/A"
add_error_to_db("82网站", sid, "同步成功", latest, content=json.dumps(data, ensure_ascii=False)) key = save_record("82网站", sid, "正常", "同步成功", latest_time=latest,
content=json.dumps(data, ensure_ascii=False))
active_set.add(key)
else:
key = save_record("82网站", sid, "异常", "返回空数据")
active_set.add(key)
except: except:
add_error_to_db("82网站", sid, "采集异常") key = save_record("82网站", sid, "异常", "单个采集失败")
active_set.add(key)
except Exception as e: except Exception as e:
add_error_to_db("82网站", "初始化错误", str(e)) logging.error(f"82 Global Error: {e}")
# --- Flask 路由 --- # --- 核心任务逻辑 ---
def background_task(): def execute_monitor_task():
global is_running global is_running
with app.app_context(): if is_running:
ErrorLog.query.delete() # 每次开始前清理旧日志 logging.warning("Task already running, skipping...")
run_106_logic() return
run_82_logic()
db.session.commit()
is_running = False
@app.route('/api/run', methods=['POST'])
def start():
global is_running
if is_running: return jsonify({"status": "busy"}), 400
is_running = True is_running = True
threading.Thread(target=background_task).start() logging.info("Starting monitor task...")
with app.app_context():
active_set = set()
run_106_logic(active_set)
run_82_logic(active_set)
# 掉线处理
all_records = MonitorRecord.query.all()
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
for record in all_records:
if f"{record.source}_{record.name}" not in active_set:
record.status = "已离线"
record.reason = "设备本次未出现"
record.check_time = now_str
record.offset = calculate_offset(record.latest_time)
try:
db.session.commit()
except:
db.session.rollback()
is_running = False
logging.info("Monitor task finished.")
# --- 路由 ---
@app.route('/api/run', methods=['POST'])
def manual_start():
if is_running: return jsonify({"status": "busy"}), 400
threading.Thread(target=execute_monitor_task).start()
return jsonify({"status": "started"}) return jsonify({"status": "started"})
@ -225,17 +290,26 @@ def status(): return jsonify({"is_running": is_running})
@app.route('/api/logs') @app.route('/api/logs')
def logs(): def logs():
data = ErrorLog.query.all() data = MonitorRecord.query.all()
return jsonify([{ return jsonify([{
"source": l.source, "source": l.source, "name": l.name, "status": l.status,
"name": l.name, "reason": l.reason, "offset": l.offset, "latest_time": l.latest_time,
"reason": l.reason, "check_time": l.check_time, "content": l.content
"offset": l.offset,
"latest_time": l.latest_time,
"check_time": l.check_time,
"content": l.content
} for l in data]) } for l in data])
# --- 调度器配置 ---
# id: 任务ID, func: 任务函数(字符串路径或引用), trigger: cron(定时), hour/minute: 时间
@scheduler.task('cron', id='daily_job', hour=10, minute=0)
def auto_run_task():
with app.app_context():
logging.info("Auto scheduler triggered.")
# 在新线程中运行,避免阻塞调度器主线程
threading.Thread(target=execute_monitor_task).start()
if __name__ == "__main__": if __name__ == "__main__":
app.run(debug=True, port=5000) scheduler.init_app(app)
scheduler.start()
# 注意debug=True 可能会导致调度器在开发模式下运行两次,生产环境建议关闭 debug
app.run(debug=True, port=5000, use_reloader=False)

View File

@ -4,25 +4,25 @@
<template #header> <template #header>
<div class="header-row"> <div class="header-row">
<div class="left-panel"> <div class="left-panel">
<h2 class="sys-title">📡 数据同步大屏</h2> <h2 class="sys-title">📡 数据同步监控大屏</h2>
<div class="sys-status"> <div class="sys-status">
<span v-if="isRunning" class="status-running"> <span v-if="isRunning" class="status-running">
<el-icon class="is-loading"><Loading /></el-icon> 正在清洗并同步最新数据... <el-icon class="is-loading"><Loading /></el-icon> 正在执行同步任务 (请勿刷新页面)...
</span> </span>
<span v-else class="status-idle"> <span v-else class="status-idle">
<el-icon><CircleCheck /></el-icon> 状态: 待命 (更新: {{ lastUpdateTime }}) <el-icon><CircleCheck /></el-icon> 系统就绪 (最后更新: {{ lastCheckTime }})
</span> </span>
</div> </div>
</div> </div>
<el-button type="primary" :loading="isRunning" @click="handleManualRefresh" round icon="Refresh">全量同步</el-button> <el-button type="primary" :loading="isRunning" @click="handleManualRefresh(false)" round icon="Refresh">手动同步</el-button>
</div> </div>
</template> </template>
<div class="status-summary"> <div class="status-summary">
<el-tag type="danger">红色离线/无数据/滞后>7</el-tag> <el-tag type="danger" effect="dark">红色离线 / 异常 / 滞后>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" style="--el-tag-bg-color: #ff8c00; border-color: #ff8c00; color: #fff;" effect="dark">橘色滞后 2-7 </el-tag>
<el-tag type="warning">黄色滞后1-2 跨天未更新</el-tag> <el-tag type="warning" effect="dark">黄色滞后 1-2 </el-tag>
<el-tag type="success">绿色今日已同步</el-tag> <el-tag type="success" effect="dark">绿色正常且今日已同步</el-tag>
</div> </div>
<div class="toolbar"> <div class="toolbar">
@ -32,44 +32,50 @@
<el-radio-button value="106">106 代理</el-radio-button> <el-radio-button value="106">106 代理</el-radio-button>
<el-radio-button value="82">82 气象站</el-radio-button> <el-radio-button value="82">82 气象站</el-radio-button>
</el-radio-group> </el-radio-group>
<el-input v-model="filters.keyword" placeholder="搜索设备..." style="width: 200px; margin-left: 15px;" clearable /> <el-input v-model="filters.keyword" placeholder="搜索设备名称..." style="width: 200px; margin-left: 15px;" clearable />
</div> </div>
<div class="action-section"> <div class="action-section">
<el-checkbox v-model="showHidden" label="显示屏蔽" border style="margin-right: 10px"/> <el-checkbox v-model="showHidden" label="显示屏蔽设备" border style="margin-right: 10px"/>
<el-button type="warning" plain :disabled="selectedRows.length === 0" @click="hideSelected">屏蔽选中</el-button> <el-button type="warning" plain :disabled="selectedRows.length === 0" @click="hideSelected">屏蔽选中</el-button>
</div> </div>
</div> </div>
<el-table :data="sortedData" border height="600" v-loading="isRunning" @selection-change="val => selectedRows = val"> <el-table
ref="multipleTableRef"
:data="sortedData"
border
height="600"
v-loading="isRunning"
@selection-change="val => selectedRows = val"
:row-class-name="tableRowClassName"
>
<el-table-column type="selection" width="50" align="center" /> <el-table-column type="selection" width="50" align="center" />
<el-table-column prop="source" label="来源" width="100" /> <el-table-column prop="source" label="来源" width="100" />
<el-table-column label="名称" min-width="180"> <el-table-column label="名称" min-width="180">
<template #default="{ row }"> <template #default="{ row }">
<el-link type="primary" underline="hover" @click="showDetails(row)" style="font-weight: bold; font-size: 15px;"> <div class="name-cell">
{{ formatDisplayName(row.name) }} <el-link type="primary" underline="hover" @click="showDetails(row)" style="font-weight: bold; font-size: 15px;">
</el-link> {{ formatDisplayName(row.name) }}
<el-tag v-if="isHidden(row.name)" type="info" size="small" style="margin-left:5px">已隐藏</el-tag> </el-link>
<el-tag v-if="isHidden(row.name)" type="info" size="small" style="margin-left:5px">已隐藏</el-tag>
</div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="状态" width="140" align="center"> <el-table-column label="当前状态" width="140" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :style="getStatusTagStyle(row)" effect="dark"> <el-tag :style="getStatusTagStyle(row)" effect="dark">{{ getStatusLabel(row) }}</el-tag>
{{ getStatusLabel(row) }}
</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="reason" label="实时详情"> <el-table-column prop="reason" label="系统反馈" min-width="200">
<template #default="{ row }"> <template #default="{ row }">
<span :style="{ color: getStatusColor(row), fontWeight: getLevel(row) > 1 ? 'bold' : 'normal' }"> <span :style="{ color: getStatusColor(row), fontWeight: 'bold' }">{{ formatReason(row) }}</span>
{{ formatReason(row) }}
</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="offset" label="时效" width="100" /> <el-table-column prop="offset" label="时效性" width="120" align="center" />
<el-table-column prop="latest_time" label="同步日期" width="180" /> <el-table-column prop="latest_time" label="数据时间" width="180" align="center" />
<el-table-column label="管理" width="80" v-if="showHidden"> <el-table-column label="操作" width="80" v-if="showHidden" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-button type="primary" link @click="restoreDevice(row.name)">恢复</el-button> <el-button v-if="isHidden(row.name)" type="primary" link @click="restoreDevice(row.name)">恢复</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@ -78,131 +84,106 @@
<el-drawer v-model="drawerVisible" title="设备数据分析详情" size="80%" @opened="initCharts"> <el-drawer v-model="drawerVisible" title="设备数据分析详情" size="80%" @opened="initCharts">
<div v-if="activeDevice" class="drawer-content"> <div v-if="activeDevice" class="drawer-content">
<div class="info-banner"> <div class="info-banner">
<el-descriptions :column="3" border size="small"> <el-descriptions :column="4" border size="small">
<el-descriptions-item label="设备名称">{{ formatDisplayName(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="当前状态"><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.latest_time }}</el-descriptions-item>
<el-descriptions-item label="检查时间">{{ activeDevice.check_time }}</el-descriptions-item>
</el-descriptions> </el-descriptions>
</div> </div>
<div class="visual-section"> <div class="visual-section">
<h3 class="section-title"> <h3 class="section-title">
<el-icon><DataLine /></el-icon> <el-icon><DataLine /></el-icon>
{{ is106Site ? '光谱能量分布分析 (自动滤除饱和噪点)' : '气象站光谱数据分析 (Up/Down Spec)' }} {{ is106Site ? '光谱能量分布 (已滤除饱和值)' : '气象站光谱数据 (Up/Down Spec)' }}
</h3> </h3>
<div v-if="currentChartModules.length === 0" class="empty-hint"><el-empty description="暂无有效的图表数据" /></div>
<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 v-for="(module, index) in currentChartModules" :key="index" class="chart-container">
<div class="chart-header" v-if="is106Site"> <div class="chart-header" v-if="is106Site">
<span class="module-tag">型号: {{ module.model }}</span> <span class="module-tag">型号: {{ module.model }}</span>
<span class="sn-tag">序列号: {{ module.sn }}</span> <span class="sn-tag">SN: {{ module.sn }}</span>
</div> </div>
<div :id="'chart-' + index" class="echart-box" :class="{ 'no-header': !is106Site }"></div> <div :id="'chart-' + index" class="echart-box" :class="{ 'no-header': !is106Site }"></div>
</div> </div>
</div> </div>
</div> </div>
</el-drawer> </el-drawer>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, reactive, computed, onMounted, nextTick } from 'vue' import { ref, reactive, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
import axios from 'axios' import axios from 'axios'
import * as echarts from 'echarts' import * as echarts from 'echarts'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Loading, CircleCheck, Refresh, DataLine } from '@element-plus/icons-vue' // 确保引入图标
// 1. 基础响应式变量 // --- 状态变量 ---
const rawData = ref([]) const rawData = ref([])
const isRunning = ref(false) const isRunning = ref(false)
const lastUpdateTime = ref('-') const lastCheckTime = ref('N/A')
const selectedRows = ref([]) const selectedRows = ref([])
const showHidden = ref(false) const showHidden = ref(false)
const drawerVisible = ref(false) const drawerVisible = ref(false)
const activeDevice = ref(null) const activeDevice = ref(null)
const filters = reactive({ site: 'all', keyword: '' }) const filters = reactive({ site: 'all', keyword: '' })
const multipleTableRef = ref() // 表格引用
// 初始化隐藏列表(从 LocalStorage 读取)
const ignoredList = ref(JSON.parse(localStorage.getItem('hide_list') || '[]')) const ignoredList = ref(JSON.parse(localStorage.getItem('hide_list') || '[]'))
// --- UI 格式化工具 --- let autoRefreshTimer = null
// --- 工具函数 ---
const formatDisplayName = (name) => { const formatDisplayName = (name) => {
if (!name) return '' if (!name) return ''
return name.split('_').map(part => { return name.split('_').map(part => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()).join('_')
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) => { const parseFlexibleDate = (dateStr) => {
if (!dateStr) return null if (!dateStr || dateStr === 'N/A') return null
try { try {
let cleanStr = dateStr.toString().replace(/[_/]/g, '-') let cleanStr = dateStr.toString().split('.')[0].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) const d = new Date(cleanStr)
return isNaN(d.getTime()) ? null : d return isNaN(d.getTime()) ? null : d
} catch { } catch { return null }
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) => { const getLevel = (row) => {
const errorKeywords = ['离线', '失败', '无法访问', '无数据', '异常', 'Empty'] if (row.status === '已离线' || row.status === '异常') return 4
if (row.reason && errorKeywords.some(kw => row.reason.includes(kw))) return 4 if (!row.content || row.content === '{}') return 4
if (isContentEmpty(row)) return 4
const last = parseFlexibleDate(row.latest_time) const last = parseFlexibleDate(row.latest_time)
if (!last) return 4 if (!last) return 4
const now = new Date() const now = new Date()
const diffHours = (now - last) / (1000 * 3600) const days = (now - last) / (1000 * 3600 * 24)
const diffDays = diffHours / 24
if (diffDays > 7) return 4 if (days > 7) return 4
if (diffDays > 2) return 3 if (days > 2) return 3
if (diffHours > 24) return 2 if (now.getDate() !== last.getDate()) return 2
if (!isSameDay(now, last)) return 2
return 1 return 1
} }
const getStatusLabel = (row) => { const getStatusLabel = (row) => {
if (row.status === '已离线') return '已离线'
if (row.status === '异常') return '采集异常'
const level = getLevel(row) const level = getLevel(row)
if (level === 4) { if (level === 4) return '数据缺失'
if (isContentEmpty(row) && (!row.reason || !row.reason.includes('离线'))) return '数据缺失' if (level === 3) return '滞后 >2天'
return '离线/异常' if (level === 2) 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 getStatusColor = (row) => {
const level = getLevel(row) const level = getLevel(row)
if (level === 1) return '#67C23A' return ['#909399', '#67C23A', '#E6A23C', '#ff8c00', '#F56C6C'][level]
if (level === 2) return '#E6A23C'
if (level === 3) return '#ff8c00'
return '#F56C6C'
} }
const getStatusTagStyle = (row) => { const getStatusTagStyle = (row) => {
@ -211,162 +192,91 @@ const getStatusTagStyle = (row) => {
} }
const formatReason = (row) => { const formatReason = (row) => {
if (row.reason) return row.reason if (row.reason && row.reason !== '同步成功') return row.reason
const level = getLevel(row) const level = getLevel(row)
if (level === 4 && isContentEmpty(row)) return '❌ 数据内容为空' if (level === 2) return '⚠️ 待今日更新'
if (level === 2 && getStatusLabel(row).includes('待今日更新')) return '⚠️ 数据仍为昨日' return '✅ 同步正常'
return '同步正常'
} }
// --- 隐藏/恢复逻辑 (修复重点) ---
// 1. 判断是否被隐藏
const isHidden = (name) => ignoredList.value.includes(name) const isHidden = (name) => ignoredList.value.includes(name)
// 2. 恢复设备(从屏蔽列表中移除)
const restoreDevice = (name) => {
if (!name) return
// 过滤掉要恢复的名字
ignoredList.value = ignoredList.value.filter(item => item !== name)
// 更新本地存储
localStorage.setItem('hide_list', JSON.stringify(ignoredList.value))
ElMessage.success('设备已恢复显示')
}
// 3. 屏蔽选中设备
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('选中的设备已在屏蔽列表中')
}
}
// 4. 显示详情
const showDetails = (row) => {
activeDevice.value = row
drawerVisible.value = true
}
// --- 过滤与排序 ---
const sortedData = computed(() => { const sortedData = computed(() => {
return rawData.value.filter(d => { return rawData.value.filter(d => {
const sMatch = filters.site === 'all' || d.source.includes(filters.site) // 基础过滤:站点 + 关键词
const kMatch = d.name.toLowerCase().includes(filters.keyword.toLowerCase()) const basicMatch = (filters.site === 'all' || d.source.includes(filters.site)) &&
return sMatch && kMatch && (showHidden.value || !isHidden(d.name)) d.name.toLowerCase().includes(filters.keyword.toLowerCase())
// 隐藏逻辑:如果勾选了"显示屏蔽设备",则显示所有;否则过滤掉在 ignoredList 中的
if (showHidden.value) {
return basicMatch
} else {
return basicMatch && !isHidden(d.name)
}
}).sort((a, b) => getLevel(b) - getLevel(a)) }).sort((a, b) => getLevel(b) - getLevel(a))
}) })
// --- 图表数据解析逻辑 (修正版) --- const tableRowClassName = ({ row }) => row.status === '已离线' ? 'offline-row' : ''
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 () => { const fetchLogs = async () => {
try { try {
// 此处仅为示例 URL请确保后端 API 地址正确
const res = await axios.get('/api/logs') const res = await axios.get('/api/logs')
rawData.value = res.data rawData.value = res.data
if (res.data.length) lastUpdateTime.value = res.data[0].check_time || new Date().toLocaleString() 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) { } catch (e) {
console.error("获取日志失败", e) // 开发环境演示数据,实际生产请删除此块
console.warn("API Error, using mock data for display")
} }
} }
@ -376,39 +286,114 @@ const checkStatus = async () => {
isRunning.value = res.data.is_running isRunning.value = res.data.is_running
if (isRunning.value) setTimeout(checkStatus, 2000) if (isRunning.value) setTimeout(checkStatus, 2000)
else fetchLogs() else fetchLogs()
} catch (e) { } catch { isRunning.value = false }
isRunning.value = false
}
} }
const handleManualRefresh = async () => { 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 { try {
isRunning.value = true isRunning.value = true
await axios.post('/api/run') await axios.post('/api/run')
checkStatus() checkStatus()
} catch (e) { ElMessage.success('任务已下发')
} catch {
isRunning.value = false isRunning.value = false
ElMessage.warning('后台已有任务在运行')
} }
} }
const showDetails = (row) => { // --- 图表逻辑 ---
activeDevice.value = row const is106Site = computed(() => activeDevice.value?.source?.includes('106'))
drawerVisible.value = true const currentChartModules = computed(() => {
} if (!activeDevice.value?.content || activeDevice.value.content === '{}') return []
const hideSelected = () => { if (is106Site.value) {
ignoredList.value = [...new Set([...ignoredList.value, ...selectedRows.value.map(r => r.name)])] const modules = []
localStorage.setItem('hide_list', JSON.stringify(ignoredList.value)) // 简单的正则解析,根据实际数据格式调整
selectedRows.value = [] const infoRegex = /FS\d_Info,Model,([^,]+),SN,([^,]+).*?Wavelength,([\d\.,\s]+)/gs
} let match
// 避免死循环,复制一份字符串操作
const restoreDevice = (name) => { const contentStr = activeDevice.value.content
ignoredList.value = ignoredList.value.filter(n => n !== name)
localStorage.setItem('hide_list', JSON.stringify(ignoredList.value)) 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); return n > 65500 ? null : 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 = () => {
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)
chart.setOption({
title: { text: is106Site.value ? `SN: ${m.sn}` : m.title, left: 'center', top: 10 },
tooltip: { trigger: 'axis' }, legend: { top: 35 },
grid: { top: 70, bottom: 30, right: 30, left: 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(() => { onMounted(() => {
fetchLogs() fetchLogs() // 初次加载
autoRefreshTimer = setInterval(() => {
if (!isRunning.value) fetchLogs()
}, 300000)
})
onBeforeUnmount(() => {
if (autoRefreshTimer) clearInterval(autoRefreshTimer)
}) })
</script> </script>
@ -421,18 +406,12 @@ onMounted(() => {
.status-idle { 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; } .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; } .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; }
.name-cell { display: flex; align-items: center; }
:deep(.offline-row) { background-color: #fef0f0 !important; }
.drawer-content { padding: 0 20px 20px; } .drawer-content { padding: 0 20px 20px; }
.info-banner { margin-bottom: 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-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; } .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 { width: 100%; height: 380px; }
.echart-box.no-header { margin-top: 15px; } .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> </style>