This commit is contained in:
YueL1331
2026-01-07 15:57:34 +08:00
parent 15d66d6694
commit ef440177b3
16 changed files with 354 additions and 170 deletions

View File

@ -1,10 +1,11 @@
import os import os
import sys
import json import json
import threading import threading
import requests import requests
import logging 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 flask_apscheduler import APScheduler
@ -13,35 +14,62 @@ from lxml import etree
# --- 配置日志 --- # --- 配置日志 ---
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
app = Flask(__name__)
# --- 关键路径处理函数 (适配 PyInstaller) ---
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) CORS(app)
# --- 数据库配置 --- # --- 数据库配置 ---
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///monitor_data.db' # 确保数据库生成在 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 # 允许通过API查看调度任务 app.config['SCHEDULER_API_ENABLED'] = True
db = SQLAlchemy(app) db = SQLAlchemy(app)
scheduler = APScheduler() scheduler = APScheduler()
# --- 模型定义 --- # --- 模型定义 (保持不变) ---
class MonitorRecord(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)) # 正常 / 离线 / 异常 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",
@ -57,7 +85,7 @@ CONFIG = {
is_running = False is_running = False
# --- 核心辅助函数 --- # --- 核心辅助函数 (保持不变) ---
def calculate_offset(latest_time_str): def calculate_offset(latest_time_str):
if not latest_time_str or latest_time_str == "N/A": if not latest_time_str or latest_time_str == "N/A":
return "从未同步" return "从未同步"
@ -72,9 +100,6 @@ def calculate_offset(latest_time_str):
def save_record(source, name, status, reason, latest_time="N/A", content=None): def save_record(source, name, status, reason, latest_time="N/A", content=None):
"""
Upsert 逻辑: 有则更新,无则插入
"""
record = MonitorRecord.query.filter_by(source=source, name=name).first() record = MonitorRecord.query.filter_by(source=source, name=name).first()
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
current_offset = calculate_offset(latest_time) current_offset = calculate_offset(latest_time)
@ -82,11 +107,9 @@ def save_record(source, name, status, reason, latest_time="N/A", content=None):
if record: if record:
if content is not None: record.content = content if content is not None: record.content = content
if latest_time != "N/A": record.latest_time = latest_time if latest_time != "N/A": record.latest_time = latest_time
record.status = status record.status = status
record.reason = reason record.reason = reason
record.check_time = now_str record.check_time = now_str
# 使用当前库里的时间重新计算 offset
time_base = latest_time if latest_time != "N/A" else record.latest_time time_base = latest_time if latest_time != "N/A" else record.latest_time
record.offset = calculate_offset(time_base) record.offset = calculate_offset(time_base)
else: else:
@ -96,17 +119,15 @@ def save_record(source, name, status, reason, latest_time="N/A", content=None):
check_time=now_str, content=content check_time=now_str, content=content
) )
db.session.add(new_record) db.session.add(new_record)
try: try:
db.session.commit() db.session.commit()
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
logging.error(f"DB Error: {e}") logging.error(f"DB Error: {e}")
return f"{source}_{name}" return f"{source}_{name}"
# --- 106 逻辑 --- # --- 业务逻辑函数 (保持不变) ---
def get_106_dynamic_token(port): def get_106_dynamic_token(port):
try: try:
login_url = f"http://106.75.72.40:{port}/api/login" login_url = f"http://106.75.72.40:{port}/api/login"
@ -140,6 +161,7 @@ def find_closest_item(items, is_date_level=True):
def run_106_logic(active_set): def run_106_logic(active_set):
# (保持原样,省略以节省空间,直接用你原本的逻辑即可)
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"}
@ -150,12 +172,10 @@ def run_106_logic(active_set):
name = item.get('name', '') name = item.get('name', '')
if not name.lower().endswith('_data'): continue if not name.lower().endswith('_data'): continue
if "TOWER" not in name.upper(): continue if "TOWER" not in name.upper(): continue
if str(item.get('status')).lower() != 'online': if str(item.get('status')).lower() != 'online':
key = save_record("106网站", name, "离线", f"设备状态: {item.get('status')}") key = save_record("106网站", name, "离线", f"设备状态: {item.get('status')}")
active_set.add(key) 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)
@ -163,35 +183,24 @@ def run_106_logic(active_set):
key = save_record("106网站", name, "异常", "Token获取失败") key = save_record("106网站", name, "异常", "Token获取失败")
active_set.add(key) active_set.add(key)
continue continue
headers = {"Authorization": c["primary_auth"], "x-auth": token} headers = {"Authorization": c["primary_auth"], "x-auth": token}
api_root = "/api/resources/Data/" if "TOWER_" in name.upper() else "/api/resources/data/" api_root = "/api/resources/Data/" if "TOWER_" in name.upper() else "/api/resources/data/"
# 寻找日期
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', []), 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:
key = save_record("106网站", name, "正常", "未找到今日文件夹", key = save_record("106网站", name, "正常", "未找到今日文件夹",
latest_time=best_date[2] if best_date else "N/A") latest_time=best_date[2] if best_date else "N/A")
active_set.add(key) active_set.add(key)
continue continue
# 寻找文件
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', []), False) best_file = find_closest_item(res2.json().get('items', []), False)
if not best_file: if not best_file:
key = save_record("106网站", name, "正常", "今日文件夹为空", latest_time=today_str) key = save_record("106网站", name, "正常", "今日文件夹为空", latest_time=today_str)
active_set.add(key) 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() 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}"
@ -201,10 +210,8 @@ def run_106_logic(active_set):
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}"
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', '')
key = save_record("106网站", name, "正常", "同步成功", latest_time=today_str, content=final_content) key = save_record("106网站", name, "正常", "同步成功", latest_time=today_str, content=final_content)
active_set.add(key) active_set.add(key)
except Exception as e: except Exception as e:
key = save_record("106网站", name, "异常", f"采集错误: {str(e)[:50]}") key = save_record("106网站", name, "异常", f"采集错误: {str(e)[:50]}")
active_set.add(key) active_set.add(key)
@ -212,15 +219,14 @@ def run_106_logic(active_set):
logging.error(f"106 Global Error: {e}") logging.error(f"106 Global Error: {e}")
# --- 82 逻辑 ---
def run_82_logic(active_set): 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),
@ -242,22 +248,15 @@ def run_82_logic(active_set):
logging.error(f"82 Global Error: {e}") logging.error(f"82 Global Error: {e}")
# --- 核心任务逻辑 ---
def execute_monitor_task(): def execute_monitor_task():
global is_running global is_running
if is_running: if is_running: return
logging.warning("Task already running, skipping...")
return
is_running = True is_running = True
logging.info("Starting monitor task...") logging.info("Starting monitor task...")
with app.app_context(): with app.app_context():
active_set = set() active_set = set()
run_106_logic(active_set) run_106_logic(active_set)
run_82_logic(active_set) run_82_logic(active_set)
# 掉线处理
all_records = MonitorRecord.query.all() all_records = MonitorRecord.query.all()
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
for record in all_records: for record in all_records:
@ -266,17 +265,15 @@ def execute_monitor_task():
record.reason = "设备本次未出现" record.reason = "设备本次未出现"
record.check_time = now_str record.check_time = now_str
record.offset = calculate_offset(record.latest_time) record.offset = calculate_offset(record.latest_time)
try: try:
db.session.commit() db.session.commit()
except: except:
db.session.rollback() db.session.rollback()
is_running = False is_running = False
logging.info("Monitor task finished.") logging.info("Monitor task finished.")
# --- 路由 --- # --- API 路由 (保持不变) ---
@app.route('/api/run', methods=['POST']) @app.route('/api/run', methods=['POST'])
def manual_start(): def manual_start():
if is_running: return jsonify({"status": "busy"}), 400 if is_running: return jsonify({"status": "busy"}), 400
@ -298,18 +295,33 @@ def logs():
} for l in data]) } for l in data])
# --- 调度器配置 --- # --- 新增: 前端页面托管路由 ---
# id: 任务ID, func: 任务函数(字符串路径或引用), trigger: cron(定时), hour/minute: 时间 @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) @scheduler.task('cron', id='daily_job', hour=10, minute=0)
def auto_run_task(): def auto_run_task():
with app.app_context(): with app.app_context():
logging.info("Auto scheduler triggered.")
# 在新线程中运行,避免阻塞调度器主线程
threading.Thread(target=execute_monitor_task).start() threading.Thread(target=execute_monitor_task).start()
if __name__ == "__main__": if __name__ == "__main__":
scheduler.init_app(app) scheduler.init_app(app)
scheduler.start() scheduler.start()
# 注意debug=True 可能会导致调度器在开发模式下运行两次,生产环境建议关闭 debug # Host='0.0.0.0' 允许外部IP访问
app.run(debug=True, port=5000, use_reloader=False) # Port=5000 (确保 Windows 防火墙开放了此端口)
print("应用正在启动... 请确保 dist 文件夹与脚本/exe 同级或已被打包")
app.run(host='0.0.0.0', port=5000, debug=False, use_reloader=False)

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

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -4,39 +4,46 @@
<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> 系统就绪 (最后更新: {{ lastCheckTime }}) <el-icon><CircleCheck /></el-icon> 系统就绪 (最后更新: {{ lastCheckTime }})
</span> </span>
</div> </div>
</div> </div>
<el-button type="primary" :loading="isRunning" @click="handleManualRefresh(false)" round icon="Refresh">手动同步</el-button> <div class="header-actions">
<el-button type="primary" :loading="isRunning" @click="handleManualRefresh(false)" round icon="Refresh" :size="isMobile ? 'small' : 'default'">手动同步</el-button>
</div>
</div> </div>
</template> </template>
<div class="status-summary"> <div class="status-summary">
<el-tag type="danger" effect="dark">红色已离线 / 异常 / 滞后>7</el-tag> <el-tag type="danger" effect="dark" class="res-tag">红色已离线 / 异常 / 滞后>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" color="#ff8c00" effect="dark" class="res-tag" style="border-color: #ff8c00;">橘色滞后 2-7 </el-tag>
<el-tag type="warning" effect="dark">黄色滞后 1-2 </el-tag> <el-tag type="warning" effect="dark" class="res-tag">黄色滞后 1-2 </el-tag>
<el-tag type="success" effect="dark">绿色正常且今日已同步</el-tag> <el-tag type="success" effect="dark" class="res-tag">绿色正常且今日已同步</el-tag>
</div> </div>
<div class="toolbar"> <div class="toolbar" :class="{ 'mobile-toolbar': isMobile }">
<div class="filter-section"> <div class="filter-section">
<el-radio-group v-model="filters.site"> <el-radio-group v-model="filters.site" :size="isMobile ? 'small' : 'default'">
<el-radio-button value="all">全部</el-radio-button> <el-radio-button value="all">全部</el-radio-button>
<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="搜索设备名称..."
class="search-input"
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" :size="isMobile ? 'small' : 'default'"/>
<el-button type="warning" plain :disabled="selectedRows.length === 0" @click="hideSelected">屏蔽选中</el-button> <el-button type="warning" plain :disabled="selectedRows.length === 0" @click="hideSelected" :size="isMobile ? 'small' : 'default'">屏蔽选中</el-button>
</div> </div>
</div> </div>
@ -48,32 +55,37 @@
v-loading="isRunning" v-loading="isRunning"
@selection-change="val => selectedRows = val" @selection-change="val => selectedRows = val"
:row-class-name="tableRowClassName" :row-class-name="tableRowClassName"
style="width: 100%"
> >
<el-table-column type="selection" width="50" align="center" /> <el-table-column type="selection" width="40" align="center" fixed="left" />
<el-table-column prop="source" label="来源" width="100" />
<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"> <el-table-column label="名称" min-width="180">
<template #default="{ row }"> <template #default="{ row }">
<div class="name-cell"> <div class="name-cell">
<el-link type="primary" underline="hover" @click="showDetails(row)" style="font-weight: bold; font-size: 15px;"> <el-link type="primary" underline="hover" @click="showDetails(row)" style="font-weight: bold; font-size: 14px;">
{{ formatDisplayName(row.name) }} {{ formatDisplayName(row.name) }}
</el-link> </el-link>
<el-tag v-if="isHidden(row.name)" type="info" size="small" style="margin-left:5px">隐藏</el-tag> <el-tag v-if="isHidden(row.name)" type="info" size="small" style="margin-left:5px">隐藏</el-tag>
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="当前状态" width="140" align="center">
<template #default="{ row }"> <el-table-column prop="reason" label="反馈" min-width="150" v-if="!isMobile">
<el-tag :style="getStatusTagStyle(row)" effect="dark">{{ getStatusLabel(row) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="reason" label="系统反馈" min-width="200">
<template #default="{ row }"> <template #default="{ row }">
<span :style="{ color: getStatusColor(row), fontWeight: 'bold' }">{{ formatReason(row) }}</span> <span :style="{ color: getStatusColor(row), fontWeight: 'bold' }">{{ formatReason(row) }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="offset" label="时效性" width="120" align="center" />
<el-table-column prop="latest_time" label="数据时间" width="180" align="center" /> <el-table-column prop="offset" label="时效" width="80" align="center" v-if="!isMobile"/>
<el-table-column label="操作" width="80" v-if="showHidden" align="center"> <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 }"> <template #default="{ row }">
<el-button v-if="isHidden(row.name)" 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>
@ -81,31 +93,43 @@
</el-table> </el-table>
</el-card> </el-card>
<el-drawer v-model="drawerVisible" title="设备数据分析详情" size="80%" @opened="initCharts"> <el-drawer
<div v-if="activeDevice" class="drawer-content"> v-model="drawerVisible"
<div class="info-banner"> title="设备详情"
<el-descriptions :column="4" border size="small"> :size="isMobile ? '100%' : '80%'"
<el-descriptions-item label="设备名称">{{ formatDisplayName(activeDevice.name) }}</el-descriptions-item> @opened="initCharts"
<el-descriptions-item label="当前状态"><el-tag size="small" :style="getStatusTagStyle(activeDevice)">{{ getStatusLabel(activeDevice) }}</el-tag></el-descriptions-item> direction="rtl"
<el-descriptions-item label="数据时间">{{ activeDevice.latest_time }}</el-descriptions-item> >
<el-descriptions-item label="检查时间">{{ activeDevice.check_time }}</el-descriptions-item>
</el-descriptions> <!-- <div v-if="activeDevice" class="drawer-content">-->
</div> <!-- <div class="info-banner">-->
<div class="visual-section"> <!-- <el-descriptions :column="isMobile ? 1 : 4" border size="small">-->
<h3 class="section-title"> <!-- <el-descriptions-item label="设备名称">{{ formatDisplayName(activeDevice.name) }}</el-descriptions-item>-->
<el-icon><DataLine /></el-icon> <!-- <el-descriptions-item label="当前状态">-->
{{ is106Site ? '光谱能量分布 (已滤除饱和值)' : '气象站光谱数据 (Up/Down Spec)' }} <!-- <el-tag size="small" :style="getStatusTagStyle(activeDevice)">{{ getStatusLabel(activeDevice) }}</el-tag>-->
</h3> <!-- </el-descriptions-item>-->
<div v-if="currentChartModules.length === 0" class="empty-hint"><el-empty description="暂无有效的图表数据" /></div> <!-- <el-descriptions-item label="数据时间">{{ activeDevice.latest_time }}</el-descriptions-item>-->
<div v-for="(module, index) in currentChartModules" :key="index" class="chart-container"> <!-- <el-descriptions-item label="检查时间">{{ activeDevice.check_time }}</el-descriptions-item>-->
<div class="chart-header" v-if="is106Site"> <!-- </el-descriptions>-->
<span class="module-tag">型号: {{ module.model }}</span> <!-- </div>-->
<span class="sn-tag">SN: {{ module.sn }}</span> <!-- <div class="visual-section">-->
</div> <!-- <h3 class="section-title">-->
<div :id="'chart-' + index" class="echart-box" :class="{ 'no-header': !is106Site }"></div> <!-- <el-icon><DataLine /></el-icon>-->
</div> <!-- {{ is106Site ? '光谱能量分布 (完整原始数据)' : '高光谱传感器数据 (Up/Down Spec)' }}-->
</div> <!-- </h3>-->
</div> <!-- <div v-if="currentChartModules.length === 0" class="empty-hint"><el-empty description="暂无有效的图表数据" /></div>-->
<!-- <div v-for="(module, index) in currentChartModules" :key="index" class="chart-container">-->
<!-- <div class="chart-header" v-if="is106Site">-->
<!-- <div class="tag-group">-->
<!-- <span class="module-tag">型号: {{ module.model }}</span>-->
<!-- <span class="sn-tag">SN: {{ module.sn }}</span>-->
<!-- </div>-->
<!-- </div>-->
<!-- <div :id="'chart-' + index" class="echart-box" :class="{ 'no-header': !is106Site }"></div>-->
<!-- </div>-->
<!-- </div>-->
<!-- </div>-->
<sidevueold></sidevueold>
</el-drawer> </el-drawer>
</div> </div>
</template> </template>
@ -115,7 +139,18 @@ import { ref, reactive, computed, onMounted, onBeforeUnmount, nextTick } from 'v
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 { ElMessage, ElMessageBox } from 'element-plus'
import { Loading, CircleCheck, Refresh, DataLine } from '@element-plus/icons-vue' // 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 rawData = ref([])
@ -126,14 +161,23 @@ 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() // const multipleTableRef = ref()
let chartInstances = [] // 便resize
// LocalStorage //
const ignoredList = ref(JSON.parse(localStorage.getItem('hide_list') || '[]')) const ignoredList = ref(JSON.parse(localStorage.getItem('hide_list') || '[]'))
let autoRefreshTimer = null 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) => { const formatDisplayName = (name) => {
if (!name) return '' if (!name) return ''
return name.split('_').map(part => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()).join('_') return name.split('_').map(part => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()).join('_')
@ -173,12 +217,12 @@ const getLevel = (row) => {
const getStatusLabel = (row) => { const getStatusLabel = (row) => {
if (row.status === '已离线') return '已离线' if (row.status === '已离线') return '已离线'
if (row.status === '异常') return '采集异常' if (row.status === '异常') return '异常'
const level = getLevel(row) const level = getLevel(row)
if (level === 4) return '数据缺失' if (level === 4) return '缺失'
if (level === 3) return '滞后 >2天' if (level === 3) return '>2天'
if (level === 2) return '昨日数据' if (level === 2) return '昨日'
return '在线(今日)' return '在线'
} }
const getStatusColor = (row) => { const getStatusColor = (row) => {
@ -198,49 +242,35 @@ const formatReason = (row) => {
return '✅ 同步正常' return '✅ 同步正常'
} }
// --- / () --- // --- / ---
// 1.
const isHidden = (name) => ignoredList.value.includes(name) const isHidden = (name) => ignoredList.value.includes(name)
// 2.
const restoreDevice = (name) => { const restoreDevice = (name) => {
if (!name) return if (!name) return
//
ignoredList.value = ignoredList.value.filter(item => item !== name) ignoredList.value = ignoredList.value.filter(item => item !== name)
//
localStorage.setItem('hide_list', JSON.stringify(ignoredList.value)) localStorage.setItem('hide_list', JSON.stringify(ignoredList.value))
ElMessage.success('设备已恢复显示') ElMessage.success('设备已恢复显示')
} }
// 3.
const hideSelected = () => { const hideSelected = () => {
if (selectedRows.value.length === 0) return if (selectedRows.value.length === 0) return
const namesToHide = selectedRows.value.map(row => row.name) const namesToHide = selectedRows.value.map(row => row.name)
let count = 0 let count = 0
namesToHide.forEach(name => { namesToHide.forEach(name => {
if (!ignoredList.value.includes(name)) { if (!ignoredList.value.includes(name)) {
ignoredList.value.push(name) ignoredList.value.push(name)
count++ count++
} }
}) })
if (count > 0) { if (count > 0) {
localStorage.setItem('hide_list', JSON.stringify(ignoredList.value)) localStorage.setItem('hide_list', JSON.stringify(ignoredList.value))
ElMessage.warning(`已屏蔽 ${count} 个设备`) ElMessage.warning(`已屏蔽 ${count} 个设备`)
if (multipleTableRef.value) multipleTableRef.value.clearSelection()
//
if (multipleTableRef.value) {
multipleTableRef.value.clearSelection()
}
} else { } else {
ElMessage.info('选中的设备已在屏蔽列表中') ElMessage.info('选中的设备已在屏蔽列表中')
} }
} }
// 4.
const showDetails = (row) => { const showDetails = (row) => {
activeDevice.value = row activeDevice.value = row
drawerVisible.value = true drawerVisible.value = true
@ -249,16 +279,10 @@ const showDetails = (row) => {
// --- --- // --- ---
const sortedData = computed(() => { const sortedData = computed(() => {
return rawData.value.filter(d => { return rawData.value.filter(d => {
// +
const basicMatch = (filters.site === 'all' || d.source.includes(filters.site)) && const basicMatch = (filters.site === 'all' || d.source.includes(filters.site)) &&
d.name.toLowerCase().includes(filters.keyword.toLowerCase()) d.name.toLowerCase().includes(filters.keyword.toLowerCase())
if (showHidden.value) return basicMatch
// "" ignoredList else return basicMatch && !isHidden(d.name)
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))
}) })
@ -267,7 +291,6 @@ const tableRowClassName = ({ row }) => row.status === '已离线' ? 'offline-row
// --- --- // --- ---
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 > 0) { if (res.data.length > 0) {
@ -275,7 +298,6 @@ const fetchLogs = async () => {
lastCheckTime.value = latest.check_time lastCheckTime.value = latest.check_time
} }
} catch (e) { } catch (e) {
//
console.warn("API Error, using mock data for display") console.warn("API Error, using mock data for display")
} }
} }
@ -297,15 +319,12 @@ const handleManualRefresh = async (force = false) => {
`数据更新于 ${hours.toFixed(1)} 小时前。后端每日10点自动更新通常无需手动操作。\n是否强制重新爬取`, `数据更新于 ${hours.toFixed(1)} 小时前。后端每日10点自动更新通常无需手动操作。\n是否强制重新爬取`,
'数据尚新', { confirmButtonText: '强制爬取', cancelButtonText: '仅加载最新', type: 'warning' } '数据尚新', { confirmButtonText: '强制爬取', cancelButtonText: '仅加载最新', type: 'warning' }
) )
//
} catch { } catch {
//
fetchLogs() fetchLogs()
ElMessage.success('已加载最新数据库记录') ElMessage.success('已加载最新数据库记录')
return return
} }
} }
try { try {
isRunning.value = true isRunning.value = true
await axios.post('/api/run') await axios.post('/api/run')
@ -321,15 +340,11 @@ const handleManualRefresh = async (force = false) => {
const is106Site = computed(() => activeDevice.value?.source?.includes('106')) const is106Site = computed(() => activeDevice.value?.source?.includes('106'))
const currentChartModules = computed(() => { const currentChartModules = computed(() => {
if (!activeDevice.value?.content || activeDevice.value.content === '{}') return [] if (!activeDevice.value?.content || activeDevice.value.content === '{}') return []
if (is106Site.value) { if (is106Site.value) {
const modules = [] const modules = []
//
const infoRegex = /FS\d_Info,Model,([^,]+),SN,([^,]+).*?Wavelength,([\d\.,\s]+)/gs const infoRegex = /FS\d_Info,Model,([^,]+),SN,([^,]+).*?Wavelength,([\d\.,\s]+)/gs
let match let match
//
const contentStr = activeDevice.value.content const contentStr = activeDevice.value.content
while ((match = infoRegex.exec(contentStr)) !== null) { while ((match = infoRegex.exec(contentStr)) !== null) {
const wavelengths = match[3].split(',').map(Number).filter(n => !isNaN(n)) const wavelengths = match[3].split(',').map(Number).filter(n => !isNaN(n))
const series = [] const series = []
@ -337,7 +352,9 @@ const currentChartModules = computed(() => {
const dMatch = contentStr.match(new RegExp(`${match[1].trim()}_P${p}[^0-9-]*([\\d\\.,\\s-]+)`, 'i')) const dMatch = contentStr.match(new RegExp(`${match[1].trim()}_P${p}[^0-9-]*([\\d\\.,\\s-]+)`, 'i'))
if (dMatch) { if (dMatch) {
const vals = dMatch[1].split(',').map(v => { const vals = dMatch[1].split(',').map(v => {
const n = parseFloat(v); return n > 65500 ? null : n 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 (vals.some(v => v !== null)) series.push({ name: `P${p}`, data: vals, color: ['#5470c6', '#91cc75', '#fac858', '#ee6666'][p-1] })
} }
@ -356,16 +373,19 @@ const currentChartModules = computed(() => {
}) })
const initCharts = () => { const initCharts = () => {
chartInstances = [] //
nextTick(() => { nextTick(() => {
currentChartModules.value.forEach((m, i) => { currentChartModules.value.forEach((m, i) => {
const dom = document.getElementById(`chart-${i}`) const dom = document.getElementById(`chart-${i}`)
if (dom) { if (dom) {
if (echarts.getInstanceByDom(dom)) echarts.getInstanceByDom(dom).dispose() if (echarts.getInstanceByDom(dom)) echarts.getInstanceByDom(dom).dispose()
const chart = echarts.init(dom) const chart = echarts.init(dom)
chartInstances.push(chart)
chart.setOption({ chart.setOption({
title: { text: is106Site.value ? `SN: ${m.sn}` : m.title, left: 'center', top: 10 }, title: { text: is106Site.value ? `SN: ${m.sn}` : m.title, left: 'center', top: 10, textStyle: { fontSize: isMobile.value ? 14 : 18 } },
tooltip: { trigger: 'axis' }, legend: { top: 35 }, tooltip: { trigger: 'axis', confine: true },
grid: { top: 70, bottom: 30, right: 30, left: 50 }, 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 }, xAxis: { type: 'category', data: m.xAxis, boundaryGap: false },
yAxis: { type: 'value', min: 'dataMin', max: 'dataMax' }, yAxis: { type: 'value', min: 'dataMin', max: 'dataMax' },
series: m.series.map(s => ({ series: m.series.map(s => ({
@ -386,27 +406,33 @@ const initCharts = () => {
// --- --- // --- ---
onMounted(() => { onMounted(() => {
fetchLogs() // document.title = "光谱数据监控"
fetchLogs()
window.addEventListener('resize', handleResize)
autoRefreshTimer = setInterval(() => { autoRefreshTimer = setInterval(() => {
if (!isRunning.value) fetchLogs() if (!isRunning.value) fetchLogs()
}, 300000) }, 300000)
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize)
if (autoRefreshTimer) clearInterval(autoRefreshTimer) if (autoRefreshTimer) clearInterval(autoRefreshTimer)
chartInstances.forEach(c => c && c.dispose())
}) })
</script> </script>
<style scoped> <style scoped>
.container { padding: 20px; max-width: 1400px; margin: 0 auto; background-color: #f5f7fa; min-height: 100vh; } /* 基础 PC 端样式 */
.header-row { display: flex; justify-content: space-between; align-items: center; } .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-title { margin: 0; font-size: 22px; color: #303133; font-weight: 600; }
.sys-status { font-size: 13px; color: #909399; margin-top: 5px; } .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-running { color: #409EFF; font-weight: bold; display: flex; align-items: center; gap: 5px; }
.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; transition: all 0.3s; }
.name-cell { display: flex; align-items: center; } .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; } :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; }
@ -414,4 +440,37 @@ onBeforeUnmount(() => {
.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; }
.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; }
/* 移动端适配 (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> </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,60 @@
<script>
import {DataLine} from "@element-plus/icons-vue";
export default {
name: "sidevueold",
components: {DataLine},
data(){
return{
activeDevice:{},
}
},
mounted() {
console.log("hello from 111")
},
methods:{
},
unmounted() {
}
}
</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>-->
aaaaaa
</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
}
}
}
})