11 Commits

18 changed files with 906 additions and 516 deletions

Binary file not shown.

View File

@ -1,49 +1,75 @@
import os import os
import sys
import json import json
import time
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, send_from_directory
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
# --- 初始化 Flask App --- # --- 配置日志 ---
app = Flask(__name__) logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
CORS(app) # 解决前端 Vite 5173 端口的跨域问题
# --- 数据库配置 (SQLite) ---
# 数据库文件将生成在 backend 目录下 # --- 关键路径处理函数 (适配 PyInstaller) ---
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///monitor_data.db' def get_base_path():
"""获取运行时及其所在目录适配开发环境和打包后的EXE环境"""
if getattr(sys, 'frozen', False):
# 如果是打包后的 exesys.executable 是 exe 的路径
return os.path.dirname(sys.executable)
# 开发环境下,是当前脚本的路径
return os.path.dirname(os.path.abspath(__file__))
def get_static_path():
"""获取 Vue 静态资源 dist 的路径"""
if getattr(sys, 'frozen', False):
# PyInstaller 打包时,资源文件会被解压到 sys._MEIPASS 临时目录
# 我们需要在打包命令中指定 --add-data "dist;dist"
return os.path.join(sys._MEIPASS, 'dist')
# 开发环境
return os.path.join(os.path.dirname(os.path.abspath(__file__)), 'dist')
# --- Flask 初始化 ---
# static_folder 指向 Vue 打包后的 dist 目录
# static_url_path='' 表示静态文件不需要 /static 前缀
dist_folder = get_static_path()
app = Flask(__name__, static_folder=dist_folder, static_url_path='')
CORS(app)
# --- 数据库配置 ---
# 确保数据库生成在 exe 同级目录下,而不是临时文件夹中
db_path = os.path.join(get_base_path(), 'monitor_data.db')
app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{db_path}'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['SCHEDULER_API_ENABLED'] = True
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)) # 106网站 / 82网站 source = db.Column(db.String(50))
name = db.Column(db.String(100)) # 设备名称/站点ID name = db.Column(db.String(100))
reason = db.Column(db.String(255)) # 状态描述 status = db.Column(db.String(50))
offset = db.Column(db.String(50)) # 日期偏移 (如: 滞后 2 天) reason = db.Column(db.String(255))
latest_time = db.Column(db.String(50)) # 最新文件日期 offset = db.Column(db.String(50))
check_time = db.Column(db.String(50)) # 本次检查时间 latest_time = db.Column(db.String(50))
content = db.Column(db.Text, nullable=True) # 专门存储 Tower_ 站点的 JSON 内容 check_time = db.Column(db.String(50))
content = db.Column(db.Text, nullable=True)
# 每次启动时确保表结构已建立
with app.app_context(): with app.app_context():
db.create_all() db.create_all()
# --- 基础配置 --- # --- 爬虫配置 (保持不变) ---
DATA_ROOT = "data"
FRPS_DIR = os.path.join(DATA_ROOT, "frps_106")
WEATHER_DIR = os.path.join(DATA_ROOT, "weather_82")
for d in [FRPS_DIR, WEATHER_DIR]:
os.makedirs(d, exist_ok=True)
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",
@ -56,35 +82,59 @@ CONFIG = {
} }
} }
is_running = False # 全局任务状态锁 is_running = False
# --- 通用工具函数 --- # --- 核心辅助函数 (保持不变) ---
def calculate_offset(latest_time_str):
def add_error_to_db(source, name, reason, latest_time="N/A", content=None): if not latest_time_str or latest_time_str == "N/A":
"""计算日期偏移并记录到数据库""" return "从未同步"
days_diff = "N/A"
if latest_time and latest_time != "N/A":
try: try:
# 兼容 2024_01_01 和 2024-01-01 格式 clean_date_str = str(latest_time_str).split()[0].replace('_', '-')
clean_date_str = str(latest_time).split()[0].replace('_', '-')
target_date = datetime.strptime(clean_date_str, "%Y-%m-%d").date() target_date = datetime.strptime(clean_date_str, "%Y-%m-%d").date()
today_date = datetime.now().date() diff = (datetime.now().date() - target_date).days
diff = (today_date - target_date).days if diff == 0: return "当天已同步"
days_diff = f"滞后 {diff}" if diff > 0 else "当天已同步" return f"滞后 {diff}"
except: except:
days_diff = "解析失败" return "时间解析失败"
log = ErrorLog(
source=source, def save_record(source, name, status, reason, latest_time="N/A", content=None):
name=name, record = MonitorRecord.query.filter_by(source=source, name=name).first()
reason=reason, now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
offset=days_diff, current_offset = calculate_offset(latest_time)
latest_time=latest_time,
check_time=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), if record:
content=content 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
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(log) 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}"
# --- 业务逻辑函数 (保持不变) ---
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)
return resp.text.strip().replace('"', '') if resp.status_code == 200 else None
except:
return None
def find_closest_item(items, is_date_level=True): def find_closest_item(items, is_date_level=True):
@ -92,7 +142,6 @@ def find_closest_item(items, is_date_level=True):
today = datetime.now() today = datetime.now()
scored_items = [] scored_items = []
for item in items: for item in items:
if not isinstance(item, dict): continue
name_val = item.get('name', '') name_val = item.get('name', '')
path_val = item.get('path', '') path_val = item.get('path', '')
target_str = name_val if name_val else path_val.split('/')[-1] target_str = name_val if name_val else path_val.split('/')[-1]
@ -101,10 +150,7 @@ def find_closest_item(items, is_date_level=True):
current_date = datetime.strptime(target_str, "%Y_%m_%d") current_date = datetime.strptime(target_str, "%Y_%m_%d")
else: else:
mod_str = item.get('modified', '') mod_str = item.get('modified', '')
if mod_str:
current_date = datetime.fromisoformat(mod_str.replace('Z', '+00:00')) current_date = datetime.fromisoformat(mod_str.replace('Z', '+00:00'))
else:
continue
diff = abs((today - current_date.replace(tzinfo=None)).total_seconds()) diff = abs((today - current_date.replace(tzinfo=None)).total_seconds())
scored_items.append((diff, item, target_str)) scored_items.append((diff, item, target_str))
except: except:
@ -114,200 +160,168 @@ def find_closest_item(items, is_date_level=True):
return scored_items[0] return scored_items[0]
def process_text_content(raw_content): def run_106_logic(active_set):
if not raw_content: return "" # (保持原样,省略以节省空间,直接用你原本的逻辑即可)
lines = str(raw_content).split('\n')
result, current = [], ""
for line in lines:
if " " in line:
current += line.strip()
else:
if current: result.append(current)
current = line.strip()
if current: result.append(current)
return "\n".join(result)
# --- 106 业务逻辑 ---
def get_106_dynamic_token(port):
url = f"http://106.75.72.40:{port}/api/login"
try:
resp = requests.post(url, json=CONFIG["106"]["login_payload"], timeout=10)
if resp.status_code == 200:
return resp.text.strip().replace('"', '')
except:
pass
return None
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")
try:
main_headers = {"Authorization": c["primary_auth"], "User-Agent": "Mozilla/5.0"} main_headers = {"Authorization": c["primary_auth"], "User-Agent": "Mozilla/5.0"}
resp = requests.get(c["base_url"], headers=main_headers, timeout=15) try:
if resp.status_code != 200: resp = requests.get(c["base_url"], headers=main_headers, timeout=20)
add_error_to_db("106网站", "主入口API", f"访问失败: HTTP {resp.status_code}") proxies = resp.json().get('proxies', [])
return for item in proxies:
name = item.get('name', '')
for item in resp.json().get('proxies', []):
name = item.get('name', 'Unknown')
if not name.lower().endswith('_data'): continue if not name.lower().endswith('_data'): continue
if "TOWER" not in name.upper(): continue
status = str(item.get('status', '')).lower().strip() if str(item.get('status')).lower() != 'online':
if status != 'online': key = save_record("106网站", name, "离线", f"设备状态: {item.get('status')}")
add_error_to_db("106网站", name, f"设备离线 ({status})") active_set.add(key)
continue continue
name_up = name.upper()
is_tower_underscore = "TOWER_" in name_up
is_tower_i = "TOWER" in name_up and not is_tower_underscore
if not (is_tower_underscore or is_tower_i): 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}
headers = {"Authorization": c["primary_auth"], "x-auth": token, "User-Agent": "Mozilla/5.0"} api_root = "/api/resources/Data/" if "TOWER_" in name.upper() else "/api/resources/data/"
api_root = "/api/resources/Data/" if is_tower_underscore else "/api/resources/data/" 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', []), True)
res2 = requests.get(f"http://106.75.72.40:{port}{api_root}", headers=headers, timeout=10)
best_date = find_closest_item(res2.json().get('items', []), is_date_level=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 "N/A") key = save_record("106网站", name, "正常", "未找到今日文件夹",
latest_time=best_date[2] if best_date else "N/A")
active_set.add(key)
continue continue
date_path = f"{api_root}{best_date[2]}/" date_path = f"{api_root}{best_date[2]}/"
res3 = 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(res3.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, "文件夹内无文件", best_date[2]) 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')}"
is_tower_i = "TOWER" in name.upper() and "TOWER_" not in name.upper()
if is_tower_i: if is_tower_i:
# TowerI 模式:.bin 文件存入磁盘 download_url = f"http://106.75.72.40:{port}/api/raw{full_path}"
raw_url = f"http://106.75.72.40:{port}/api/raw{full_path}" res3 = requests.get(download_url, headers=headers, timeout=20)
res4 = requests.get(raw_url, headers=headers, timeout=20) final_content = f"Binary Data Size: {len(res3.content)}"
if res4.status_code == 200:
save_path = os.path.join(FRPS_DIR, f"{name}_{today_str}.bin")
with open(save_path, 'wb') as f:
f.write(res4.content)
add_error_to_db("106网站", name, "运行正常 (Bin已存盘)", today_str)
else: else:
# Tower_ 模式JSON 内容存入数据库
file_api_url = f"http://106.75.72.40:{port}/api/resources{full_path}" file_api_url = f"http://106.75.72.40:{port}/api/resources{full_path}"
res4 = requests.get(file_api_url, headers=headers, timeout=20) res3 = requests.get(file_api_url, headers=headers, timeout=20)
file_json = res4.json() final_content = res3.json().get('content', '')
raw_content = file_json.get('content', '') if file_json else None key = save_record("106网站", name, "正常", "同步成功", latest_time=today_str, content=final_content)
if raw_content: active_set.add(key)
clean_content = process_text_content(raw_content)
add_error_to_db("106网站", name, "运行正常", today_str, content=clean_content)
else:
add_error_to_db("106网站", name, "JSON内容为空", best_date[2])
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 业务逻辑 --- def run_82_logic(active_set):
# (保持原样,直接用你原本的逻辑即可)
def run_82_logic():
c = CONFIG["82"] c = CONFIG["82"]
session = requests.Session() session = requests.Session()
today_fmt = datetime.now().strftime("%Y-%m-%d")
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')
stations = [s for s in stations if s and str(s).strip()] for sid in [s for s in stations if s]:
for sid in stations:
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),
headers={'Content-Type': 'text/plain'}, timeout=10) headers={'Content-Type': 'text/plain'}, timeout=10)
data = r.json() data = r.json()
if not data: if data:
add_error_to_db("82网站", sid, "返回 Null 数据") d_list = data.get('date', [])
continue latest = str(d_list[-1]) if d_list else "N/A"
key = save_record("82网站", sid, "正常", "同步成功", latest_time=latest,
latest = str(data.get('date', ['N/A'])[-1]) content=json.dumps(data, ensure_ascii=False))
status_msg = "当天已同步" if latest.startswith(today_fmt) else "数据滞后" active_set.add(key)
# 82网站数据通常直接存为文件备查记录入库 else:
add_error_to_db("82网站", sid, status_msg, latest) key = save_record("82网站", sid, "异常", "返回空数据")
active_set.add(key)
# 可选将82网站的完整JSON也存入content except:
# db.session.query(ErrorLog).filter_by(name=sid).update({"content": json.dumps(data, ensure_ascii=False)}) key = save_record("82网站", sid, "异常", "单个采集失败")
active_set.add(key)
except Exception as e: except Exception as e:
add_error_to_db("82网站", sid, f"请求异常: {str(e)}") logging.error(f"82 Global Error: {e}")
except Exception as e:
add_error_to_db("82网站", "初始化模块", str(e))
# --- 任务调度 --- def execute_monitor_task():
def background_worker():
global is_running global is_running
with app.app_context(): if is_running: return
try:
# 1. 覆盖逻辑:清空旧数据
ErrorLog.query.delete()
db.session.commit()
# 2. 执行爬虫
run_106_logic()
run_82_logic()
# 3. 最终提交
db.session.commit()
except Exception as e:
print(f"后台任务出错: {e}")
finally:
is_running = False
# --- API 路由 ---
@app.route('/api/run', methods=['POST'])
def trigger_run():
global is_running
if is_running:
return jsonify({"message": "Task already running"}), 400
is_running = True is_running = True
threading.Thread(target=background_worker).start() logging.info("Starting monitor task...")
return jsonify({"message": "Task started"}) 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/status', methods=['GET']) # --- API 路由 (保持不变) ---
def get_status(): @app.route('/api/run', methods=['POST'])
return jsonify({"is_running": is_running}) def manual_start():
if is_running: return jsonify({"status": "busy"}), 400
threading.Thread(target=execute_monitor_task).start()
return jsonify({"status": "started"})
@app.route('/api/logs', methods=['GET']) @app.route('/api/status')
def get_logs(): def status(): return jsonify({"is_running": is_running})
logs = ErrorLog.query.order_by(ErrorLog.source.desc()).all()
@app.route('/api/logs')
def logs():
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, } for l in data])
"latest_time": l.latest_time,
"check_time": l.check_time,
"content": l.content # --- 新增: 前端页面托管路由 ---
} for l in logs]) @app.route('/')
def serve_index():
return send_from_directory(app.static_folder, 'index.html')
@app.route('/<path:path>')
def serve_static_files(path):
# 尝试在 dist 目录寻找文件 (css, js, icons)
file_path = os.path.join(app.static_folder, path)
if os.path.exists(file_path):
return send_from_directory(app.static_folder, path)
# 如果找不到文件例如刷新页面时的路由返回index.html让Vue Router处理
return send_from_directory(app.static_folder, 'index.html')
# --- 调度器与启动 ---
@scheduler.task('cron', id='daily_job', hour=10, minute=0)
def auto_run_task():
with app.app_context():
threading.Thread(target=execute_monitor_task).start()
if __name__ == "__main__": if __name__ == "__main__":
# 使用 5000 端口,请确保前端 Vite 配置了正确的 Proxy scheduler.init_app(app)
app.run(host="0.0.0.0", port=5000, debug=True) scheduler.start()
# Host='0.0.0.0' 允许外部IP访问
# Port=5000 (确保 Windows 防火墙开放了此端口)
print("应用正在启动... 请确保 dist 文件夹与脚本/exe 同级或已被打包")
app.run(host='0.0.0.0', port=5000, debug=False, use_reloader=False)

View File

@ -1,289 +0,0 @@
<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="toolbar">
<div class="filter-section">
<el-radio-group v-model="filters.site" size="default">
<el-radio-button label="all">全部来源</el-radio-button>
<el-radio-button label="106">106 代理</el-radio-button>
<el-radio-button label="82">82 气象站</el-radio-button>
</el-radio-group>
<el-input
v-model="filters.keyword"
placeholder="搜索设备名称..."
prefix-icon="Search"
style="width: 220px; margin-left: 20px;"
clearable
/>
</div>
<div class="action-section">
<el-checkbox v-model="showHidden" label="显示已屏蔽" border style="margin-right: 15px"/>
<el-button
type="warning"
plain
icon="Hide"
:disabled="selectedRows.length === 0"
@click="hideSelectedDevices"
>
屏蔽选中 ({{ selectedRows.length }})
</el-button>
</div>
</div>
<el-table
:data="sortedData"
style="width: 100%; margin-top: 20px;"
border
stripe
height="650"
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" align="center">
<template #default="scope">
<el-tag size="small" effect="plain">{{ scope.row.source }}</el-tag>
</template>
</el-table-column>
<el-table-column label="设备名称" min-width="220">
<template #default="scope">
<el-link type="primary" :underline="false" @click="showDetails(scope.row)" class="device-link">
{{ scope.row.name }}
</el-link>
<el-tag v-if="isHidden(scope.row.name)" type="info" size="small" style="margin-left:8px">已屏蔽</el-tag>
</template>
</el-table-column>
<el-table-column label="运行状态" width="140" align="center">
<template #default="{ row }">
<el-tag :type="getStatusType(row)" effect="dark" round>
{{ getStatusLabel(row) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="reason" label="状态详情" min-width="200">
<template #default="{ row }">
<span :style="{ color: getStatusColor(row) }">
{{ row.reason }}
</span>
</template>
</el-table-column>
<el-table-column prop="offset" label="数据时效" width="120" align="center" />
<el-table-column prop="latest_time" label="最新日期" width="160" align="center" />
<el-table-column label="管理" width="100" align="center" v-if="showHidden">
<template #default="{ row }">
<el-button v-if="isHidden(row.name)" type="info" link @click="restoreDevice(row.name)">恢复</el-button>
</template>
</el-table-column>
</el-table>
<div class="footer-stats">
<span>总监控: {{ rawData.length }}</span> |
<span style="color: #F56C6C">严重问题: {{ stats.critical }}</span> |
<span style="color: #E6A23C">滞后警告: {{ stats.warning }}</span>
</div>
</el-card>
<el-drawer
v-model="drawerVisible"
title="设备数据详情"
size="45%"
destroy-on-close
>
<div v-if="activeDevice" class="drawer-content">
<el-descriptions :title="`站点名称:${activeDevice.name}`" :column="1" border>
<el-descriptions-item label="所属来源">{{ activeDevice.source }}</el-descriptions-item>
<el-descriptions-item label="当前状态">
<el-tag :type="getStatusType(activeDevice)">{{ getStatusLabel(activeDevice) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="检测到最新时间">{{ activeDevice.latest_time }}</el-descriptions-item>
<el-descriptions-item label="异常原因">{{ activeDevice.reason }}</el-descriptions-item>
</el-descriptions>
<h3 style="margin: 25px 0 10px 0;">📦 原始 JSON 数据报文</h3>
<div class="json-container">
<json-viewer
v-if="parsedJson"
:value="parsedJson"
:expand-depth="5"
copyable
boxed
sort
/>
<el-empty v-else description="该站点暂无详细 JSON 数据内容" />
</div>
</div>
</el-drawer>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import axios from 'axios'
import { ElMessage } from 'element-plus'
// --- 数据响应式变量 ---
const rawData = ref([])
const isRunning = ref(false)
const lastUpdateTime = ref('-')
const selectedRows = ref([])
const showHidden = ref(false)
const drawerVisible = ref(false)
// 初始值设为 null模板中通过 v-if 保护
const activeDevice = ref(null)
const filters = reactive({ site: 'all', keyword: '' })
const ignoredDevices = ref(JSON.parse(localStorage.getItem('ignored_list') || '[]'))
// --- 逻辑处理 ---
const getStatusLevel = (row) => {
if (!row || !row.reason) return 'success'
if (row.reason.includes('离线') || row.reason.includes('失败')) return 'critical'
if (row.offset && row.offset.includes('滞后')) return 'warning'
return 'success'
}
const getStatusType = (row) => {
const level = getStatusLevel(row)
return level === 'critical' ? 'danger' : (level === 'warning' ? 'warning' : 'success')
}
const getStatusLabel = (row) => {
const level = getStatusLevel(row)
return level === 'critical' ? '连接异常' : (level === 'warning' ? '同步滞后' : '运行正常')
}
const getStatusColor = (row) => {
const level = getStatusLevel(row)
return level === 'critical' ? '#F56C6C' : (level === 'warning' ? '#E6A23C' : '#606266')
}
const isHidden = (name) => ignoredDevices.value.includes(name)
const sortedData = computed(() => {
let list = rawData.value.filter(item => {
const matchSite = filters.site === 'all' || item.source.includes(filters.site)
const matchKey = !filters.keyword || item.name.toLowerCase().includes(filters.keyword.toLowerCase())
const hideLogic = showHidden.value ? true : !isHidden(item.name)
return matchSite && matchKey && hideLogic
})
return list.sort((a, b) => {
const weight = { 'critical': 3, 'warning': 2, 'success': 1 }
return weight[getStatusLevel(b)] - weight[getStatusLevel(a)]
})
})
const stats = computed(() => ({
critical: rawData.value.filter(r => getStatusLevel(r) === 'critical').length,
warning: rawData.value.filter(r => getStatusLevel(r) === 'warning').length
}))
const parsedJson = computed(() => {
if (!activeDevice.value || !activeDevice.value.content) return null
try {
return JSON.parse(activeDevice.value.content)
} catch (e) {
return activeDevice.value.content
}
})
// --- 交互方法 ---
const showDetails = (row) => {
activeDevice.value = row
drawerVisible.value = true
}
const hideSelectedDevices = () => {
const names = selectedRows.value.map(r => r.name)
ignoredDevices.value = [...new Set([...ignoredDevices.value, ...names])]
localStorage.setItem('ignored_list', JSON.stringify(ignoredDevices.value))
ElMessage.success('已屏蔽选中设备')
}
const restoreDevice = (name) => {
ignoredDevices.value = ignoredDevices.value.filter(n => n !== name)
localStorage.setItem('ignored_list', JSON.stringify(ignoredDevices.value))
}
const fetchLogs = async () => {
try {
const res = await axios.get('/api/logs')
rawData.value = res.data
if (res.data.length > 0) lastUpdateTime.value = res.data[0].check_time
} catch (e) {
console.error("无法获取日志数据")
}
}
const checkStatus = async () => {
try {
const res = await axios.get('/api/status')
isRunning.value = res.data.is_running
if (isRunning.value) {
setTimeout(checkStatus, 3000)
} else {
fetchLogs()
}
} catch (e) {
isRunning.value = false
}
}
const handleManualRefresh = async () => {
if (isRunning.value) return
try {
await axios.post('/api/run')
isRunning.value = true
checkStatus()
} catch (e) {
ElMessage.error('启动同步失败,请检查后端连接')
}
}
onMounted(() => {
checkStatus()
fetchLogs()
})
</script>
<style scoped>
.container { padding: 20px; max-width: 1400px; margin: 0 auto; font-family: sans-serif; }
.header-row { display: flex; justify-content: space-between; align-items: center; }
.sys-title { margin: 0; color: #303133; }
.sys-status { font-size: 13px; color: #909399; margin-top: 5px; }
.toolbar { background: #f8f9fa; padding: 15px; border-radius: 8px; display: flex; justify-content: space-between; align-items: center; margin-top: 20px; border: 1px solid #ebeef5; }
.device-link { font-weight: bold; }
.footer-stats { margin-top: 20px; text-align: right; color: #606266; font-size: 14px; }
.json-container { border: 1px solid #eee; border-radius: 4px; overflow: hidden; }
.drawer-content { padding: 0 5px; }
</style>

View File

@ -1,16 +0,0 @@
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
'/api': {
target: 'http://127.0.0.1:5000', // 必须指向你的 Flask 地址
changeOrigin: true,
rewrite: (path) => path // 保持路径 /api 不变
}
}
}
})

View File

@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.1.0", "@element-plus/icons-vue": "^2.1.0",
"axios": "^1.5.1", "axios": "^1.5.1",
"echarts": "^6.0.0",
"element-plus": "^2.3.14", "element-plus": "^2.3.14",
"vue": "^3.3.4", "vue": "^3.3.4",
"vue-json-viewer": "^3.0.4" "vue-json-viewer": "^3.0.4"
@ -760,6 +761,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/echarts": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz",
"integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==",
"dependencies": {
"tslib": "2.3.0",
"zrender": "6.0.0"
}
},
"node_modules/element-plus": { "node_modules/element-plus": {
"version": "2.13.0", "version": "2.13.0",
"resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.13.0.tgz", "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.13.0.tgz",
@ -1177,6 +1187,11 @@
"resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz", "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz",
"integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==" "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q=="
}, },
"node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="
},
"node_modules/vite": { "node_modules/vite": {
"version": "4.5.0", "version": "4.5.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-4.5.0.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.0.tgz",
@ -1262,6 +1277,14 @@
"peerDependencies": { "peerDependencies": {
"vue": "^3.2.2" "vue": "^3.2.2"
} }
},
"node_modules/zrender": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz",
"integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==",
"dependencies": {
"tslib": "2.3.0"
}
} }
}, },
"dependencies": { "dependencies": {
@ -1719,6 +1742,15 @@
"gopd": "^1.2.0" "gopd": "^1.2.0"
} }
}, },
"echarts": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz",
"integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==",
"requires": {
"tslib": "2.3.0",
"zrender": "6.0.0"
}
},
"element-plus": { "element-plus": {
"version": "2.13.0", "version": "2.13.0",
"resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.13.0.tgz", "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.13.0.tgz",
@ -1999,6 +2031,11 @@
"resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz", "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz",
"integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==" "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q=="
}, },
"tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="
},
"vite": { "vite": {
"version": "4.5.0", "version": "4.5.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-4.5.0.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.0.tgz",
@ -2030,6 +2067,14 @@
"requires": { "requires": {
"clipboard": "^2.0.4" "clipboard": "^2.0.4"
} }
},
"zrender": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz",
"integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==",
"requires": {
"tslib": "2.3.0"
}
} }
} }
} }

View File

@ -8,14 +8,15 @@
"build": "vite build" "build": "vite build"
}, },
"dependencies": { "dependencies": {
"vue": "^3.3.4",
"element-plus": "^2.3.14",
"axios": "^1.5.1",
"@element-plus/icons-vue": "^2.1.0", "@element-plus/icons-vue": "^2.1.0",
"axios": "^1.5.1",
"echarts": "^6.0.0",
"element-plus": "^2.3.14",
"vue": "^3.3.4",
"vue-json-viewer": "^3.0.4" "vue-json-viewer": "^3.0.4"
}, },
"devDependencies": { "devDependencies": {
"vite": "4.5.0", "@vitejs/plugin-vue": "4.5.0",
"@vitejs/plugin-vue": "4.5.0" "vite": "4.5.0"
} }
} }

View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,487 @@
<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 ref="siderold"></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 siderold = ref(null)
var tempdata=0
const showDetails = (row) => {
activeDevice.value = row
drawerVisible.value = true
nextTick(() => {
// 此时 siderold.value 才有值
// 使用 ?. 防止极个别情况下组件未挂载导致的报错
if (siderold.value) {
siderold.value.loaddata(row)
tempdata++;
} else {
console.warn("子组件尚未挂载")
}
})
}
// --- 过滤与排序 ---
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>

View File

Before

Width:  |  Height:  |  Size: 496 B

After

Width:  |  Height:  |  Size: 496 B

View File

@ -0,0 +1,39 @@
<script setup>
import {DataLine} from "@element-plus/icons-vue";
</script>
<template>
<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>
</template>
<style scoped>
</style>

View File

@ -0,0 +1,79 @@
<script>
import {DataLine} from "@element-plus/icons-vue";
import axios from 'axios'
export default {
name: "sidevueold",
components: {DataLine},
data(){
return{
activeDevice:{},
bbb:0,
date:"2022-10-11"
}
},
mounted() {
console.log("hello from 111")
},
props:{
id:0
},
methods:{
loaddata(a){
this.bbb=a;
console.log(a);
},
loadhistorydata(){
var dateselect=this.date;
//去后端获取数据 getdata(dateselect)
console.log("im getting data form back "+dateselect)
}
},
unmounted() {
}
}
</script>
<template>
<input type="date" v-model="date"/>
<button @click="loadhistorydata">获取历史数据</button>
{{bbb}}
<!-- <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>-->
</template>
<style scoped>
</style>

View File

@ -0,0 +1,30 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
// --- 强烈建议新增这一行 ---
// 这确保 index.html 引用 css/js 时使用相对路径,
// 避免 Flask 托管时出现找不到文件的 404 错误。
base: './',
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
// --- 关于这段 server 配置 ---
// 这里的配置仅在你自己电脑上写代码(npm run dev)时有效。
// 打包(npm run build)后,前端请求会直接发给同源的 Flask
// 所以这里填什么 IP 对打包后的程序没有影响,不用改。
server: {
proxy: {
'/api': {
target: 'http://127.0.0.1:5000',
changeOrigin: true
}
}
}
})