Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 51deee1493 | |||
| f167bbc2f2 | |||
| 4cb503089e | |||
| fb52536898 | |||
| e093ae9633 | |||
| 195c3f8fa4 | |||
| cb567b2c7d | |||
| 9b7799b827 | |||
| f043983d24 | |||
| 9ebfd79414 | |||
| fe21532741 | |||
| e2333ea9b8 |
@ -26,17 +26,17 @@ def get_base_path():
|
|||||||
|
|
||||||
|
|
||||||
def get_static_path():
|
def get_static_path():
|
||||||
"""获取 Vue 静态资源 dist 的路径"""
|
"""获取 Vue 静态资源 web_dist 的路径"""
|
||||||
if getattr(sys, 'frozen', False):
|
if getattr(sys, 'frozen', False):
|
||||||
# PyInstaller 打包时,资源文件会被解压到 sys._MEIPASS 临时目录
|
# PyInstaller 打包时,资源文件会被解压到 sys._MEIPASS 临时目录
|
||||||
# 我们需要在打包命令中指定 --add-data "dist;dist"
|
# 我们需要在打包命令中指定 --add-data "web_dist;web_dist"
|
||||||
return os.path.join(sys._MEIPASS, 'dist')
|
return os.path.join(sys._MEIPASS, 'web_dist')
|
||||||
# 开发环境
|
# 开发环境
|
||||||
return os.path.join(os.path.dirname(os.path.abspath(__file__)), 'dist')
|
return os.path.join(os.path.dirname(os.path.abspath(__file__)), 'web_dist')
|
||||||
|
|
||||||
|
|
||||||
# --- Flask 初始化 ---
|
# --- Flask 初始化 ---
|
||||||
# static_folder 指向 Vue 打包后的 dist 目录
|
# static_folder 指向 Vue 打包后的 web_dist 目录
|
||||||
# static_url_path='' 表示静态文件不需要 /static 前缀
|
# static_url_path='' 表示静态文件不需要 /static 前缀
|
||||||
dist_folder = get_static_path()
|
dist_folder = get_static_path()
|
||||||
app = Flask(__name__, static_folder=dist_folder, static_url_path='')
|
app = Flask(__name__, static_folder=dist_folder, static_url_path='')
|
||||||
@ -303,7 +303,7 @@ def serve_index():
|
|||||||
|
|
||||||
@app.route('/<path:path>')
|
@app.route('/<path:path>')
|
||||||
def serve_static_files(path):
|
def serve_static_files(path):
|
||||||
# 尝试在 dist 目录寻找文件 (css, js, icons)
|
# 尝试在 web_dist 目录寻找文件 (css, js, icons)
|
||||||
file_path = os.path.join(app.static_folder, path)
|
file_path = os.path.join(app.static_folder, path)
|
||||||
if os.path.exists(file_path):
|
if os.path.exists(file_path):
|
||||||
return send_from_directory(app.static_folder, path)
|
return send_from_directory(app.static_folder, path)
|
||||||
@ -323,5 +323,5 @@ if __name__ == "__main__":
|
|||||||
scheduler.start()
|
scheduler.start()
|
||||||
# Host='0.0.0.0' 允许外部IP访问
|
# Host='0.0.0.0' 允许外部IP访问
|
||||||
# Port=5000 (确保 Windows 防火墙开放了此端口)
|
# Port=5000 (确保 Windows 防火墙开放了此端口)
|
||||||
print("应用正在启动... 请确保 dist 文件夹与脚本/exe 同级或已被打包")
|
print("应用正在启动... 请确保 web_dist 文件夹与脚本/exe 同级或已被打包")
|
||||||
app.run(host='0.0.0.0', port=5000, debug=False, use_reloader=False)
|
app.run(host='0.0.0.0', port=5000, debug=False, use_reloader=False)
|
||||||
|
|||||||
14
1.1/test1.py
14
1.1/test1.py
@ -26,17 +26,17 @@ def get_base_path():
|
|||||||
|
|
||||||
|
|
||||||
def get_static_path():
|
def get_static_path():
|
||||||
"""获取 Vue 静态资源 dist 的路径"""
|
"""获取 Vue 静态资源 web_dist 的路径"""
|
||||||
if getattr(sys, 'frozen', False):
|
if getattr(sys, 'frozen', False):
|
||||||
# PyInstaller 打包时,资源文件会被解压到 sys._MEIPASS 临时目录
|
# PyInstaller 打包时,资源文件会被解压到 sys._MEIPASS 临时目录
|
||||||
# 我们需要在打包命令中指定 --add-data "dist;dist"
|
# 我们需要在打包命令中指定 --add-data "web_dist;web_dist"
|
||||||
return os.path.join(sys._MEIPASS, 'dist')
|
return os.path.join(sys._MEIPASS, 'web_dist')
|
||||||
# 开发环境
|
# 开发环境
|
||||||
return os.path.join(os.path.dirname(os.path.abspath(__file__)), 'dist')
|
return os.path.join(os.path.dirname(os.path.abspath(__file__)), 'web_dist')
|
||||||
|
|
||||||
|
|
||||||
# --- Flask 初始化 ---
|
# --- Flask 初始化 ---
|
||||||
# static_folder 指向 Vue 打包后的 dist 目录
|
# static_folder 指向 Vue 打包后的 web_dist 目录
|
||||||
# static_url_path='' 表示静态文件不需要 /static 前缀
|
# static_url_path='' 表示静态文件不需要 /static 前缀
|
||||||
dist_folder = get_static_path()
|
dist_folder = get_static_path()
|
||||||
app = Flask(__name__, static_folder=dist_folder, static_url_path='')
|
app = Flask(__name__, static_folder=dist_folder, static_url_path='')
|
||||||
@ -303,7 +303,7 @@ def serve_index():
|
|||||||
|
|
||||||
@app.route('/<path:path>')
|
@app.route('/<path:path>')
|
||||||
def serve_static_files(path):
|
def serve_static_files(path):
|
||||||
# 尝试在 dist 目录寻找文件 (css, js, icons)
|
# 尝试在 web_dist 目录寻找文件 (css, js, icons)
|
||||||
file_path = os.path.join(app.static_folder, path)
|
file_path = os.path.join(app.static_folder, path)
|
||||||
if os.path.exists(file_path):
|
if os.path.exists(file_path):
|
||||||
return send_from_directory(app.static_folder, path)
|
return send_from_directory(app.static_folder, path)
|
||||||
@ -323,5 +323,5 @@ if __name__ == "__main__":
|
|||||||
scheduler.start()
|
scheduler.start()
|
||||||
# Host='0.0.0.0' 允许外部IP访问
|
# Host='0.0.0.0' 允许外部IP访问
|
||||||
# Port=5000 (确保 Windows 防火墙开放了此端口)
|
# Port=5000 (确保 Windows 防火墙开放了此端口)
|
||||||
print("应用正在启动... 请确保 dist 文件夹与脚本/exe 同级或已被打包")
|
print("应用正在启动... 请确保 web_dist 文件夹与脚本/exe 同级或已被打包")
|
||||||
app.run(host='0.0.0.0', port=5000, debug=False, use_reloader=False)
|
app.run(host='0.0.0.0', port=5000, debug=False, use_reloader=False)
|
||||||
|
|||||||
259
2_1banben/app.py
259
2_1banben/app.py
@ -1,9 +1,11 @@
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import json
|
import json
|
||||||
import logging
|
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
import pytz
|
||||||
|
|
||||||
from flask import Flask, send_from_directory, jsonify
|
from flask import Flask, send_from_directory, jsonify
|
||||||
from flask_cors import CORS
|
from flask_cors import CORS
|
||||||
from flask_apscheduler import APScheduler
|
from flask_apscheduler import APScheduler
|
||||||
@ -12,180 +14,258 @@ from flask_apscheduler import APScheduler
|
|||||||
# ✅ 1. 核心模块引用
|
# ✅ 1. 核心模块引用
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
try:
|
try:
|
||||||
# 数据库实例 (在根目录 extensions.py 中)
|
from config import Config
|
||||||
from extensions import db
|
from extensions import db
|
||||||
|
from models import Device, DeviceHistory
|
||||||
# 数据模型 (在根目录 models.py 中)
|
# 引入核心爬虫调度
|
||||||
from models import Device, DeviceHistory, MaintenanceLog
|
|
||||||
|
|
||||||
# 核心业务逻辑 (在 services/core.py 中)
|
|
||||||
from services.core import execute_monitor_task
|
from services.core import execute_monitor_task
|
||||||
|
|
||||||
# 路由蓝图 (在 routes/api.py 中)
|
try:
|
||||||
|
from services.iot_api import sync_iot_data_service
|
||||||
|
except ImportError:
|
||||||
|
sync_iot_data_service = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from routes.api import api_bp as device_bp
|
from routes.api import api_bp as device_bp
|
||||||
|
from routes.api import calculate_offset
|
||||||
except ImportError:
|
except ImportError:
|
||||||
|
# 兜底逻辑,防止缺失 calculate_offset 导致崩溃
|
||||||
|
def calculate_offset(target_time):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
from routes.api import device_bp
|
from routes.api import device_bp
|
||||||
|
|
||||||
# 工具函数 (在 routes/api.py 中)
|
|
||||||
from routes.api import calculate_offset
|
|
||||||
|
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
print(f"❌ 严重错误: 模块导入失败。请检查文件名和变量名。详细信息: {e}")
|
print(f"❌ [启动错误] 模块导入失败: {e}")
|
||||||
print(f"系统路径: {sys.path}")
|
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# 2. 智能路径配置
|
||||||
|
# ==============================================================================
|
||||||
|
RESOURCE_BASE = Config.BASE_DIR
|
||||||
|
INSTANCE_PATH = Config.INSTANCE_DIR
|
||||||
|
|
||||||
# ==============================================================================
|
|
||||||
# 2. 路径计算模块 (兼容 PyInstaller 打包)
|
def find_static_folder(base_path):
|
||||||
# ==============================================================================
|
"""
|
||||||
def get_base_path():
|
全能路径搜寻逻辑,适配 PyInstaller 打包环境
|
||||||
"""获取运行时基准路径,兼容开发环境和打包环境"""
|
"""
|
||||||
if getattr(sys, 'frozen', False):
|
if getattr(sys, 'frozen', False):
|
||||||
if hasattr(sys, '_MEIPASS'):
|
if hasattr(sys, '_MEIPASS'):
|
||||||
return sys._MEIPASS # --onefile 模式
|
mei_path = os.path.join(sys._MEIPASS, 'web_dist')
|
||||||
else:
|
if os.path.exists(os.path.join(mei_path, 'index.html')):
|
||||||
return os.path.dirname(os.path.abspath(sys.executable)) # --onedir 模式
|
return mei_path
|
||||||
else:
|
internal_path = os.path.join(base_path, '_internal', 'web_dist')
|
||||||
return os.path.abspath(os.path.dirname(__file__))
|
if os.path.exists(os.path.join(internal_path, 'index.html')):
|
||||||
|
return internal_path
|
||||||
|
|
||||||
|
path = os.path.join(base_path, 'web_dist')
|
||||||
|
if os.path.exists(os.path.join(path, 'index.html')):
|
||||||
|
return path
|
||||||
|
|
||||||
|
parent_path = os.path.join(os.path.dirname(base_path), 'web_dist')
|
||||||
|
if os.path.exists(os.path.join(parent_path, 'index.html')):
|
||||||
|
return parent_path
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
BASE_DIR = get_base_path()
|
STATIC_FOLDER = find_static_folder(RESOURCE_BASE)
|
||||||
STATIC_FOLDER = os.path.join(BASE_DIR, 'web_dist')
|
|
||||||
INSTANCE_FOLDER = os.path.join(BASE_DIR, 'instance')
|
|
||||||
DB_PATH = os.path.join(INSTANCE_FOLDER, 'devices.db')
|
|
||||||
|
|
||||||
# 修复 Windows 下注册表 MIME 类型缺失导致网页白屏的问题
|
|
||||||
mimetypes.add_type('application/javascript', '.js')
|
mimetypes.add_type('application/javascript', '.js')
|
||||||
mimetypes.add_type('text/css', '.css')
|
mimetypes.add_type('text/css', '.css')
|
||||||
|
|
||||||
print(f"🚀 运行环境: {'Packaged' if getattr(sys, 'frozen', False) else 'Dev'}")
|
|
||||||
print(f"📂 基准路径: {BASE_DIR}")
|
|
||||||
print(f"💾 数据库路径: {DB_PATH}")
|
|
||||||
|
|
||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# 3. 定时任务逻辑
|
# 3. 核心定时任务逻辑 (深度优化版)
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
def auto_monitor_job(app):
|
def auto_monitor_job(app):
|
||||||
"""定时任务具体执行逻辑"""
|
"""
|
||||||
|
[关键修复]
|
||||||
|
1. 使用 app.app_context() 确保线程中有 Flask 上下文
|
||||||
|
2. 使用 db.session.remove() 强制清理旧连接
|
||||||
|
3. 使用 db.session.merge() 确保对象状态被正确追踪
|
||||||
|
4. 增加详细日志,对比爬虫返回的数据与入库行为
|
||||||
|
"""
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
print(f"⏰ [定时任务] 启动时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
# A. 强制清理会话,确保线程获取的是全新的数据库连接
|
||||||
|
db.session.remove()
|
||||||
|
|
||||||
|
tz = pytz.timezone('Asia/Shanghai')
|
||||||
|
now_str = datetime.now(tz).strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
print(f"\n{'=' * 50}")
|
||||||
|
print(f"⏰ [定时任务启动] {now_str}")
|
||||||
|
|
||||||
if not execute_monitor_task:
|
if not execute_monitor_task:
|
||||||
print("❌ 错误: 无法加载爬虫核心模块 (execute_monitor_task is Missing)")
|
print("❌ 错误: execute_monitor_task 未定义")
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 执行爬取
|
# B. 执行爬虫
|
||||||
task_result = execute_monitor_task()
|
task_result = execute_monitor_task()
|
||||||
|
|
||||||
if not task_result:
|
if not task_result:
|
||||||
print("⚠️ [定时任务] 爬虫未获取到数据")
|
print("⚠️ [警告] 爬虫执行完毕,但返回空数据")
|
||||||
return
|
return
|
||||||
|
|
||||||
scraped_list = task_result.get('device_list', [])
|
scraped_list = task_result.get('device_list', [])
|
||||||
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
print(f"📦 [数据获取] 爬取到 {len(scraped_list)} 条设备数据")
|
||||||
|
|
||||||
|
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
stats = {'updated': 0, 'history': 0}
|
||||||
|
|
||||||
count = 0
|
|
||||||
for item in scraped_list:
|
for item in scraped_list:
|
||||||
d_name = item.get('name')
|
d_name = item.get('name')
|
||||||
if not d_name: continue
|
if not d_name: continue
|
||||||
|
|
||||||
# 查找或创建设备
|
# --- 1. 数据解包与默认值处理 ---
|
||||||
|
# 显式提取,防止 None 覆盖数据库现有的值(如果业务需要)
|
||||||
|
# 这里假设爬虫返回 None 就是要写入 None,或者空字符串
|
||||||
|
raw_status = item.get('status', '未知')
|
||||||
|
raw_value = item.get('value', '')
|
||||||
|
f_count = item.get('num_files', 0)
|
||||||
|
|
||||||
|
# 时间处理:必须有时间,否则用当前时间
|
||||||
|
target_date = item.get('target_time')
|
||||||
|
if not target_date:
|
||||||
|
target_date = current_time
|
||||||
|
|
||||||
|
raw_json = item.get('raw_json', {})
|
||||||
|
|
||||||
|
# [调试日志] 仅打印第一条或特定的设备,防止刷屏,但能帮你确认数据是否为空
|
||||||
|
# if '0025' in d_name:
|
||||||
|
# print(f" >>> [写入前检查] {d_name}: Value='{raw_value}' | Files={f_count}")
|
||||||
|
|
||||||
|
# --- 2. 数据库操作 (使用 Merge 机制) ---
|
||||||
|
# 先尝试查询
|
||||||
device = Device.query.filter_by(name=d_name).first()
|
device = Device.query.filter_by(name=d_name).first()
|
||||||
|
|
||||||
if not device:
|
if not device:
|
||||||
device = Device(name=d_name, source=item.get('source'), install_site="")
|
# 如果不存在,新建对象
|
||||||
|
device = Device(name=d_name, source=item.get('source', '自动爬虫'), install_site="")
|
||||||
db.session.add(device)
|
db.session.add(device)
|
||||||
db.session.flush()
|
db.session.flush() # 立即获取 ID
|
||||||
|
|
||||||
# 更新字段
|
# 更新字段
|
||||||
device.status = item.get('status')
|
device.status = raw_status
|
||||||
device.current_value = item.get('value')
|
device.current_value = raw_value
|
||||||
device.latest_time = item.get('target_time')
|
device.latest_time = target_date
|
||||||
device.check_time = current_time
|
device.check_time = current_time
|
||||||
device.json_data = json.dumps(item.get('raw_json', {}), ensure_ascii=False)
|
device.file_count = f_count
|
||||||
device.offset = calculate_offset(item.get('target_time'))
|
|
||||||
|
|
||||||
# 写入历史
|
# 计算 Offset
|
||||||
db.session.add(DeviceHistory(
|
try:
|
||||||
|
device.offset = calculate_offset(target_date)
|
||||||
|
except:
|
||||||
|
device.offset = 0
|
||||||
|
|
||||||
|
# JSON 数据合并
|
||||||
|
old_json = {}
|
||||||
|
try:
|
||||||
|
if device.json_data:
|
||||||
|
old_json = json.loads(device.json_data)
|
||||||
|
except:
|
||||||
|
old_json = {}
|
||||||
|
|
||||||
|
if isinstance(raw_json, dict):
|
||||||
|
old_json.update(raw_json)
|
||||||
|
|
||||||
|
device.json_data = json.dumps(old_json, ensure_ascii=False)
|
||||||
|
|
||||||
|
# [核心修复] 使用 merge 告诉 Session "这个对象归你管,请更新它"
|
||||||
|
# 这能解决后台线程中 "DetachedInstanceError" 或更新丢失的问题
|
||||||
|
db.session.merge(device)
|
||||||
|
stats['updated'] += 1
|
||||||
|
|
||||||
|
# --- 3. 写入历史记录 ---
|
||||||
|
history = DeviceHistory(
|
||||||
device_id=device.id,
|
device_id=device.id,
|
||||||
status=device.status,
|
status=raw_status,
|
||||||
result_data=device.current_value,
|
result_data=raw_value,
|
||||||
data_time=item.get('target_time'),
|
data_time=target_date,
|
||||||
|
file_count=f_count,
|
||||||
json_data=device.json_data
|
json_data=device.json_data
|
||||||
))
|
)
|
||||||
count += 1
|
db.session.add(history)
|
||||||
|
stats['history'] += 1
|
||||||
|
|
||||||
|
# C. 提交事务
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
print(f"✅ [定时任务] 成功更新 {count} 台设备状态")
|
print(f"✅ [入库成功] 设备更新: {stats['updated']} | 历史追加: {stats['history']}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
print(f"❌ [定时任务] 异常: {str(e)}")
|
print(f"❌ [严重异常] 数据写入失败: {e}")
|
||||||
|
# 打印堆栈以便排查
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
finally:
|
||||||
|
# D. 再次清理 Session,防止内存泄漏或污染下一次任务
|
||||||
|
db.session.remove()
|
||||||
|
print(f"{'=' * 50}\n")
|
||||||
|
|
||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# 4. Flask 应用工厂
|
# 4. Flask 应用工厂
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
def create_app():
|
def create_app():
|
||||||
# 🔴 关键修复:移除了 static_url_path=''
|
print(f"🔍 [前端路径锁定] {STATIC_FOLDER}")
|
||||||
# 这样 Flask 就不会强制拦截所有根路径请求,让下面的 serve_static 有机会处理 /dashboard
|
|
||||||
app = Flask(__name__, static_folder=STATIC_FOLDER)
|
|
||||||
|
|
||||||
|
app = Flask(__name__, static_folder=STATIC_FOLDER, instance_path=INSTANCE_PATH)
|
||||||
CORS(app)
|
CORS(app)
|
||||||
|
|
||||||
# 确保 instance 目录存在
|
if not os.path.exists(app.instance_path):
|
||||||
if not os.path.exists(INSTANCE_FOLDER):
|
os.makedirs(app.instance_path, exist_ok=True)
|
||||||
os.makedirs(INSTANCE_FOLDER, exist_ok=True)
|
|
||||||
|
|
||||||
app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{DB_PATH}'
|
app.config.from_object(Config)
|
||||||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
|
||||||
app.config['SCHEDULER_API_ENABLED'] = True
|
|
||||||
|
|
||||||
# 初始化数据库
|
# 初始化 DB
|
||||||
db.init_app(app)
|
db.init_app(app)
|
||||||
|
|
||||||
# 初始化定时任务
|
# 初始化调度器
|
||||||
scheduler = APScheduler()
|
scheduler = APScheduler()
|
||||||
scheduler.init_app(app)
|
scheduler.init_app(app)
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
|
|
||||||
# 添加定时任务 (每天 10:00)
|
# --- 添加定时任务 ---
|
||||||
|
# 注意:这里我们传递 [app] 作为参数,确保 job 函数内能获取到 app 上下文
|
||||||
scheduler.add_job(
|
scheduler.add_job(
|
||||||
id='daily_monitor_task',
|
id='daily_monitor_task',
|
||||||
func=auto_monitor_job,
|
func=auto_monitor_job,
|
||||||
args=[app],
|
args=[app],
|
||||||
trigger='cron',
|
trigger='cron',
|
||||||
hour=10,
|
hour=17,
|
||||||
minute=0
|
minute=00,
|
||||||
|
second=00,
|
||||||
|
misfire_grace_time=3600,
|
||||||
|
timezone=pytz.timezone('Asia/Shanghai')
|
||||||
)
|
)
|
||||||
|
print(f"📅 定时任务已锁定: 每天北京时间 17:00 执行")
|
||||||
|
|
||||||
# 注册蓝图
|
|
||||||
app.register_blueprint(device_bp)
|
app.register_blueprint(device_bp)
|
||||||
|
|
||||||
# -------------------------------------------------
|
@app.route('/api/force_run')
|
||||||
# 前端路由支持 (Vue History Mode)
|
def force_run_task():
|
||||||
# -------------------------------------------------
|
"""手动触发接口:复用同一个 auto_monitor_job 函数,确保逻辑一致"""
|
||||||
|
auto_monitor_job(app)
|
||||||
|
return jsonify({'code': 200, 'msg': '手动触发成功,请查看服务器日志'})
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
def serve_index():
|
def serve_index():
|
||||||
if not os.path.exists(os.path.join(app.static_folder, 'index.html')):
|
try:
|
||||||
return "❌ 错误: 前端文件丢失 (web_dist/index.html)", 404
|
return send_from_directory(app.static_folder, 'index.html')
|
||||||
return send_from_directory(app.static_folder, 'index.html')
|
except Exception:
|
||||||
|
return "Frontend Error", 404
|
||||||
|
|
||||||
@app.route('/<path:path>')
|
@app.route('/<path:path>')
|
||||||
def serve_static(path):
|
def serve_static(path):
|
||||||
# 1. 优先尝试直接返回实际存在的文件 (js, css, img等)
|
if path.startswith('api'):
|
||||||
|
return jsonify({'code': 404, 'message': 'API endpoint not found'}), 404
|
||||||
|
|
||||||
file_path = os.path.join(app.static_folder, path)
|
file_path = os.path.join(app.static_folder, path)
|
||||||
if os.path.exists(file_path):
|
if os.path.exists(file_path):
|
||||||
return send_from_directory(app.static_folder, path)
|
return send_from_directory(app.static_folder, path)
|
||||||
|
|
||||||
# 2. 如果是 API 请求但没找到对应接口,返回 404 JSON (不返回 HTML)
|
|
||||||
if path.startswith('api') or path.startswith('static'):
|
|
||||||
return jsonify({'code': 404, 'message': 'Not Found'}), 404
|
|
||||||
|
|
||||||
# 3. 关键逻辑:
|
|
||||||
# 访问 /dashboard 等前端路由时,文件系统中并没有 dashboard 这个文件
|
|
||||||
# 所以会走到这里,返回 index.html,让 Vue 及其 Router 接管页面渲染
|
|
||||||
return send_from_directory(app.static_folder, 'index.html')
|
return send_from_directory(app.static_folder, 'index.html')
|
||||||
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
@ -196,7 +276,8 @@ def create_app():
|
|||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
app = create_app()
|
app = create_app()
|
||||||
# 生产环境/打包环境通常设为 False
|
|
||||||
debug_mode = not getattr(sys, 'frozen', False)
|
debug_mode = not getattr(sys, 'frozen', False)
|
||||||
print("🚀 服务启动中...")
|
|
||||||
|
print(f"🚀 服务启动中... 数据库: {app.config['SQLALCHEMY_DATABASE_URI']}")
|
||||||
|
# 注意:use_reloader=False 防止调度器在 Debug 模式下运行两次
|
||||||
app.run(host='0.0.0.0', port=5000, debug=debug_mode, use_reloader=False)
|
app.run(host='0.0.0.0', port=5000, debug=debug_mode, use_reloader=False)
|
||||||
@ -5,30 +5,44 @@ import sys
|
|||||||
def get_base_path():
|
def get_base_path():
|
||||||
"""获取运行时路径 (兼容打包后的 exe 和开发环境)"""
|
"""获取运行时路径 (兼容打包后的 exe 和开发环境)"""
|
||||||
if getattr(sys, 'frozen', False):
|
if getattr(sys, 'frozen', False):
|
||||||
|
# 打包后:exe 所在目录
|
||||||
return os.path.dirname(sys.executable)
|
return os.path.dirname(sys.executable)
|
||||||
|
# 开发时:当前文件所在目录
|
||||||
return os.path.dirname(os.path.abspath(__file__))
|
return os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
|
||||||
def get_static_path():
|
def get_static_path():
|
||||||
"""获取 dist 静态资源路径"""
|
"""获取 web_dist 静态资源路径"""
|
||||||
if getattr(sys, 'frozen', False):
|
if getattr(sys, 'frozen', False):
|
||||||
return os.path.join(sys._MEIPASS, 'dist')
|
return os.path.join(sys._MEIPASS, 'web_dist')
|
||||||
return os.path.join(os.path.dirname(os.path.abspath(__file__)), 'dist')
|
return os.path.join(os.path.dirname(os.path.abspath(__file__)), 'web_dist')
|
||||||
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
BASE_DIR = get_base_path()
|
BASE_DIR = get_base_path()
|
||||||
|
|
||||||
# 数据库路径:保存在运行目录下,文件名为 monitor_data.db
|
# 规范化 instance 目录
|
||||||
# Windows 下路径需要注意转义,这里使用 os.path.join 最安全
|
INSTANCE_DIR = os.path.join(BASE_DIR, 'instance')
|
||||||
SQLALCHEMY_DATABASE_URI = f'sqlite:///{os.path.join(BASE_DIR, "monitor_data.db")}'
|
|
||||||
|
# 确保 instance 目录存在(防止第一次运行时报错)
|
||||||
|
if not os.path.exists(INSTANCE_DIR):
|
||||||
|
try:
|
||||||
|
os.makedirs(INSTANCE_DIR)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# [修改] 绝对路径拼接,并强制将 Windows 的 \ 转换为 /,避免 SQLite URI 报错
|
||||||
|
# 最终结果类似: sqlite:///D:/project/instance/monitor_data.db
|
||||||
|
_db_path = os.path.join(INSTANCE_DIR, "monitor_data.db").replace('\\', '/')
|
||||||
|
SQLALCHEMY_DATABASE_URI = f'sqlite:///{_db_path}'
|
||||||
|
|
||||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
|
|
||||||
# --- 定时任务配置 ---
|
# --- 定时任务配置 ---
|
||||||
SCHEDULER_API_ENABLED = True
|
SCHEDULER_API_ENABLED = True
|
||||||
SCHEDULER_TIMEZONE = "Asia/Shanghai" # 👈 必须加这个,否则 APScheduler 可能报错
|
SCHEDULER_TIMEZONE = "Asia/Shanghai"
|
||||||
|
|
||||||
# --- 爬虫配置 (Service层会读取这里) ---
|
# --- 爬虫配置 ---
|
||||||
CRAWLER_CONFIG = {
|
CRAWLER_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",
|
||||||
@ -39,4 +53,17 @@ class Config:
|
|||||||
"base_url": "http://82.156.1.111/weather/php",
|
"base_url": "http://82.156.1.111/weather/php",
|
||||||
"login": {'username': 'renlixin', 'password': 'licahk', 'login': '123'}
|
"login": {'username': 'renlixin', 'password': 'licahk', 'login': '123'}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --- IoT 物联网卡接口配置 ---
|
||||||
|
IOT_BASE_URL = "https://iot.huskyiot.cn"
|
||||||
|
IOT_APP_ID = "44aQHTpx"
|
||||||
|
IOT_SECRET = "26833abf8786167a5cff5355cfc249981985124a"
|
||||||
|
IOT_USERNAME = "yrsy"
|
||||||
|
IOT_PASSWORD = "123456789"
|
||||||
|
IOT_URL_LOGIN = "/iot-api/system/auth/v1/get/token"
|
||||||
|
IOT_URL_PAGE = "/iot-api/platform/v1/card-info/query/page"
|
||||||
|
IOT_URL_DETAIL = "/iot-api/platform/v1/card-info/query/batch-card-detail"
|
||||||
|
|
||||||
|
# [Debug] 打印路径确认
|
||||||
|
print(f"配置文件已加载,数据库路径: {SQLALCHEMY_DATABASE_URI}")
|
||||||
@ -1,7 +1,8 @@
|
|||||||
|
# models.py
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import json
|
|
||||||
from extensions import db
|
from extensions import db
|
||||||
|
|
||||||
|
|
||||||
class Device(db.Model):
|
class Device(db.Model):
|
||||||
__tablename__ = 'devices'
|
__tablename__ = 'devices'
|
||||||
|
|
||||||
@ -18,11 +19,17 @@ class Device(db.Model):
|
|||||||
reason = db.Column(db.String(255))
|
reason = db.Column(db.String(255))
|
||||||
offset = db.Column(db.String(50))
|
offset = db.Column(db.String(50))
|
||||||
|
|
||||||
|
# ✅ 新增字段:文件数量
|
||||||
|
file_count = db.Column(db.Integer, default=0)
|
||||||
|
|
||||||
# 手动录入字段(受保护,run_monitor 不主动覆盖)
|
# 手动录入字段(受保护,run_monitor 不主动覆盖)
|
||||||
install_site = db.Column(db.String(100), default="")
|
install_site = db.Column(db.String(100), default="")
|
||||||
is_maintaining = db.Column(db.Boolean, default=False)
|
is_maintaining = db.Column(db.Boolean, default=False)
|
||||||
is_hidden = db.Column(db.Boolean, default=False)
|
is_hidden = db.Column(db.Boolean, default=False)
|
||||||
|
|
||||||
|
# 白名单字段 (根据上下文可能存在,补全以防万一)
|
||||||
|
is_whitelist = db.Column(db.Boolean, default=False)
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
# 统一状态映射逻辑
|
# 统一状态映射逻辑
|
||||||
api_status = 'offline' if self.status in ['离线', '异常', '已离线'] else 'online'
|
api_status = 'offline' if self.status in ['离线', '异常', '已离线'] else 'online'
|
||||||
@ -38,9 +45,12 @@ class Device(db.Model):
|
|||||||
'install_site': self.install_site or '',
|
'install_site': self.install_site or '',
|
||||||
'is_maintaining': self.is_maintaining,
|
'is_maintaining': self.is_maintaining,
|
||||||
'is_hidden': self.is_hidden,
|
'is_hidden': self.is_hidden,
|
||||||
'offset': self.offset
|
'is_whitelist': self.is_whitelist,
|
||||||
|
'offset': self.offset,
|
||||||
|
'file_count': self.file_count # ✅ 返回给前端
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class DeviceHistory(db.Model):
|
class DeviceHistory(db.Model):
|
||||||
__tablename__ = 'device_history'
|
__tablename__ = 'device_history'
|
||||||
|
|
||||||
@ -53,8 +63,12 @@ class DeviceHistory(db.Model):
|
|||||||
json_data = db.Column(db.Text)
|
json_data = db.Column(db.Text)
|
||||||
file_path = db.Column(db.String(255))
|
file_path = db.Column(db.String(255))
|
||||||
|
|
||||||
|
# ✅ 新增字段:历史记录文件数量
|
||||||
|
file_count = db.Column(db.Integer, default=0)
|
||||||
|
|
||||||
recorded_at = db.Column(db.DateTime, default=datetime.now)
|
recorded_at = db.Column(db.DateTime, default=datetime.now)
|
||||||
|
|
||||||
|
|
||||||
class MaintenanceLog(db.Model):
|
class MaintenanceLog(db.Model):
|
||||||
__tablename__ = 'maintenance_logs'
|
__tablename__ = 'maintenance_logs'
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import os
|
import os
|
||||||
import shutil
|
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@ -8,18 +7,219 @@ from sqlalchemy import desc, or_
|
|||||||
from extensions import db
|
from extensions import db
|
||||||
from models import Device, DeviceHistory, MaintenanceLog
|
from models import Device, DeviceHistory, MaintenanceLog
|
||||||
|
|
||||||
# 尝试导入爬虫模块
|
# =========================================================
|
||||||
|
# 模块动态导入 (防止循环引用或缺失报错)
|
||||||
|
# =========================================================
|
||||||
try:
|
try:
|
||||||
from services.core import execute_monitor_task
|
from services.core import execute_monitor_task
|
||||||
except ImportError:
|
except ImportError:
|
||||||
execute_monitor_task = None
|
execute_monitor_task = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
from services.iot_api import sync_iot_data_service
|
||||||
|
except ImportError:
|
||||||
|
sync_iot_data_service = None
|
||||||
|
|
||||||
api_bp = Blueprint('api', __name__, url_prefix='/api')
|
api_bp = Blueprint('api', __name__, url_prefix='/api')
|
||||||
|
|
||||||
|
|
||||||
# =======================
|
# =========================================================
|
||||||
# 0. 认证接口
|
# 0. 核心算法区:数据质量分析与辅助函数
|
||||||
# =======================
|
# =========================================================
|
||||||
|
|
||||||
|
def calculate_offset(latest_time_str):
|
||||||
|
"""
|
||||||
|
计算时间滞后天数
|
||||||
|
用于前端展示设备数据是否过时
|
||||||
|
"""
|
||||||
|
if not latest_time_str or latest_time_str == "N/A":
|
||||||
|
return "从未同步"
|
||||||
|
try:
|
||||||
|
# 兼容处理 2026_01_13 和 2026-01-13 格式
|
||||||
|
clean = str(latest_time_str).split()[0].replace('_', '-')
|
||||||
|
target = datetime.strptime(clean, "%Y-%m-%d").date()
|
||||||
|
diff = (datetime.now().date() - target).days
|
||||||
|
return "当天" if diff == 0 else f"滞后 {diff} 天"
|
||||||
|
except:
|
||||||
|
return "时间解析失败"
|
||||||
|
|
||||||
|
|
||||||
|
def check_data_quality(content_data, source_type, data_time_str=None):
|
||||||
|
"""
|
||||||
|
数据质量分析算法 (融合版:旧版核心规则 + 新版夜间/IoT过滤)
|
||||||
|
用于判断设备状态颜色 (绿色ok/黄色warning/红色error)
|
||||||
|
"""
|
||||||
|
if not content_data:
|
||||||
|
return 'ok'
|
||||||
|
|
||||||
|
# 1. IoT 卡不需要检查数据质量
|
||||||
|
if str(source_type) == 'iot_card':
|
||||||
|
return 'ok'
|
||||||
|
|
||||||
|
# 2. 夜间免打扰逻辑 (08:00 - 17:00 之外不报错)
|
||||||
|
if data_time_str and data_time_str != 'N/A':
|
||||||
|
try:
|
||||||
|
clean_time = str(data_time_str).replace('_', '-')
|
||||||
|
dt = None
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(clean_time, "%Y-%m-%d %H:%M:%S")
|
||||||
|
except:
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(clean_time, "%Y-%m-%d %H:%M")
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if dt and (dt.hour < 8 or dt.hour >= 17):
|
||||||
|
return 'ok'
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 3. 数据异常判断逻辑
|
||||||
|
status = 'ok'
|
||||||
|
source_str = str(source_type)
|
||||||
|
|
||||||
|
# --- Type A: 106 设备逻辑 (CSV格式) ---
|
||||||
|
if '106' in source_str:
|
||||||
|
try:
|
||||||
|
text_content = ""
|
||||||
|
if isinstance(content_data, dict):
|
||||||
|
text_content = content_data.get('content', str(content_data))
|
||||||
|
else:
|
||||||
|
text_content = str(content_data)
|
||||||
|
|
||||||
|
if 'OSIFBeta' in text_content:
|
||||||
|
lines = text_content.split('\n') if '\n' in text_content else [text_content]
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
if 'OSIFBeta' not in line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
parts = line.split(',')
|
||||||
|
if len(parts) < 10:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
int_time = int(parts[2])
|
||||||
|
if int_time >= 66534:
|
||||||
|
data_points = []
|
||||||
|
for p in parts[3:]:
|
||||||
|
try:
|
||||||
|
data_points.append(float(p))
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not data_points:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for val in data_points:
|
||||||
|
if val < 100:
|
||||||
|
return 'error'
|
||||||
|
|
||||||
|
consecutive_warning = 0
|
||||||
|
for val in data_points:
|
||||||
|
if 100 <= val <= 500:
|
||||||
|
consecutive_warning += 1
|
||||||
|
if consecutive_warning >= 5:
|
||||||
|
status = 'warning'
|
||||||
|
else:
|
||||||
|
consecutive_warning = 0
|
||||||
|
except:
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
return 'ok'
|
||||||
|
|
||||||
|
# --- Type B: 82 设备逻辑 (JSON格式) ---
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
if not isinstance(content_data, dict):
|
||||||
|
return 'ok'
|
||||||
|
|
||||||
|
specs = content_data.get('downspec', [])
|
||||||
|
if not specs:
|
||||||
|
specs = content_data.get('upspec', [])
|
||||||
|
|
||||||
|
if specs and isinstance(specs, list):
|
||||||
|
consecutive_low = 0
|
||||||
|
for val in specs:
|
||||||
|
if not isinstance(val, (int, float)):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if val < 500:
|
||||||
|
consecutive_low += 1
|
||||||
|
if consecutive_low >= 2:
|
||||||
|
return 'error'
|
||||||
|
else:
|
||||||
|
consecutive_low = 0
|
||||||
|
return 'ok'
|
||||||
|
except Exception:
|
||||||
|
return 'ok'
|
||||||
|
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
def save_iot_cards_to_db(card_list):
|
||||||
|
"""
|
||||||
|
[核心修复] IoT数据入库逻辑 - 增量更新模式
|
||||||
|
这里负责将 iot_api.py 获取到的新字段保存到数据库的 JSON 字段中
|
||||||
|
"""
|
||||||
|
if not card_list: return 0, None
|
||||||
|
update_count = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
for card in card_list:
|
||||||
|
iccid = card.get('iccid') or card.get('card_id')
|
||||||
|
if not iccid: continue
|
||||||
|
|
||||||
|
# 1. 查找是否存在该 SIM 卡记录
|
||||||
|
sim_record = Device.query.filter_by(name=iccid, source='iot_card').first()
|
||||||
|
|
||||||
|
old_json = {}
|
||||||
|
|
||||||
|
if not sim_record:
|
||||||
|
sim_record = Device(name=iccid, source='iot_card', install_site="IoT库")
|
||||||
|
db.session.add(sim_record)
|
||||||
|
db.session.flush()
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
if sim_record.json_data:
|
||||||
|
old_json = json.loads(sim_record.json_data)
|
||||||
|
except:
|
||||||
|
old_json = {}
|
||||||
|
|
||||||
|
# 2. 准备需要更新的 API 数据
|
||||||
|
api_updates = {
|
||||||
|
"iccid": iccid,
|
||||||
|
"usedTraffic": str(card.get('usedTraffic') or '0'),
|
||||||
|
"stopDate": card.get('stopDate', 'N/A'),
|
||||||
|
"cardStatus": card.get('cardStatus'),
|
||||||
|
"tag": card.get('tag', ''),
|
||||||
|
|
||||||
|
# === [新增] 这里保存刚才在 iot_api.py 里生成的中文状态描述 ===
|
||||||
|
"statusDesc": card.get('statusDesc', '未知')
|
||||||
|
# ========================================================
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3. 合并数据 (保留 is_whitelist)
|
||||||
|
old_json.update(api_updates)
|
||||||
|
|
||||||
|
if 'is_whitelist' not in old_json:
|
||||||
|
old_json['is_whitelist'] = False
|
||||||
|
|
||||||
|
# 4. 更新数据库字段
|
||||||
|
sim_record.status = str(card.get('cardStatus', ''))
|
||||||
|
sim_record.json_data = json.dumps(old_json, ensure_ascii=False)
|
||||||
|
sim_record.check_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
update_count += 1
|
||||||
|
|
||||||
|
return update_count, None
|
||||||
|
except Exception as e:
|
||||||
|
return 0, str(e)
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# 1. 基础接口 (认证 & 概览)
|
||||||
|
# =========================================================
|
||||||
|
|
||||||
@api_bp.route('/login', methods=['POST'])
|
@api_bp.route('/login', methods=['POST'])
|
||||||
def login():
|
def login():
|
||||||
@ -34,24 +234,105 @@ def login():
|
|||||||
'token': 'super-admin-token-2026',
|
'token': 'super-admin-token-2026',
|
||||||
'user': {'username': 'admin', 'role': 'administrator'}
|
'user': {'username': 'admin', 'role': 'administrator'}
|
||||||
})
|
})
|
||||||
|
|
||||||
return jsonify({'code': 401, 'message': '用户名或密码错误'}), 401
|
return jsonify({'code': 401, 'message': '用户名或密码错误'}), 401
|
||||||
|
|
||||||
|
|
||||||
# =======================
|
|
||||||
# 1. 设备概览与详情接口
|
|
||||||
# =======================
|
|
||||||
|
|
||||||
@api_bp.route('/devices_overview', methods=['GET'])
|
@api_bp.route('/devices_overview', methods=['GET'])
|
||||||
def devices_overview():
|
def devices_overview():
|
||||||
try:
|
try:
|
||||||
devices = Device.query.all()
|
# A. 获取 IoT卡表
|
||||||
data_list = [d.to_dict() for d in devices]
|
iot_records = Device.query.filter_by(source='iot_card').all()
|
||||||
|
iot_map = {}
|
||||||
|
for rec in iot_records:
|
||||||
|
try:
|
||||||
|
j = json.loads(rec.json_data)
|
||||||
|
iot_map[rec.name] = j
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# B. 获取 真实设备
|
||||||
|
devices = Device.query.filter(Device.source != 'iot_card').all()
|
||||||
|
data_list = []
|
||||||
|
|
||||||
|
for d in devices:
|
||||||
|
# 关键:d.to_dict() 在 models.py 中应包含 file_count
|
||||||
|
item = d.to_dict()
|
||||||
|
|
||||||
|
# 强制格式化时间
|
||||||
|
raw_time = d.latest_time
|
||||||
|
if raw_time:
|
||||||
|
if hasattr(raw_time, 'strftime'):
|
||||||
|
item['latest_time'] = raw_time.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
else:
|
||||||
|
s = str(raw_time).strip()
|
||||||
|
if '_' in s and ':' not in s:
|
||||||
|
item['latest_time'] = s.replace('_', '-') + " 00:00:00"
|
||||||
|
else:
|
||||||
|
item['latest_time'] = s
|
||||||
|
|
||||||
|
parsed_content = {}
|
||||||
|
if d.json_data:
|
||||||
|
try:
|
||||||
|
parsed_content = json.loads(d.json_data)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# --- 绑定逻辑 ---
|
||||||
|
bound_iccid = parsed_content.get('bound_iccid')
|
||||||
|
|
||||||
|
item['usedTraffic'] = None
|
||||||
|
item['stopDate'] = None
|
||||||
|
item['statusDesc'] = None # 初始化字段
|
||||||
|
item['isBound'] = False
|
||||||
|
item['bound_iccid'] = bound_iccid
|
||||||
|
item['is_whitelist'] = False
|
||||||
|
|
||||||
|
# 如果有绑定,注入卡片信息
|
||||||
|
if bound_iccid and bound_iccid in iot_map:
|
||||||
|
card_info = iot_map[bound_iccid]
|
||||||
|
item['usedTraffic'] = card_info.get('usedTraffic')
|
||||||
|
item['stopDate'] = card_info.get('stopDate')
|
||||||
|
item['is_whitelist'] = card_info.get('is_whitelist', False)
|
||||||
|
|
||||||
|
# === [新增] 将绑定的卡片状态描述传给前端 ===
|
||||||
|
item['statusDesc'] = card_info.get('statusDesc')
|
||||||
|
# ======================================
|
||||||
|
|
||||||
|
item['isBound'] = True
|
||||||
|
|
||||||
|
item['data_quality'] = check_data_quality(parsed_content, d.source, d.latest_time)
|
||||||
|
|
||||||
|
data_list.append(item)
|
||||||
|
|
||||||
|
# C. IoT卡表数据 (用于卡池管理界面)
|
||||||
|
for rec in iot_records:
|
||||||
|
item = rec.to_dict()
|
||||||
|
try:
|
||||||
|
j = json.loads(rec.json_data)
|
||||||
|
except:
|
||||||
|
j = {}
|
||||||
|
|
||||||
|
item['usedTraffic'] = j.get('usedTraffic', '0')
|
||||||
|
item['stopDate'] = j.get('stopDate', '')
|
||||||
|
item['is_whitelist'] = j.get('is_whitelist', False)
|
||||||
|
|
||||||
|
# === [新增] 将卡池列表中的状态描述传给前端 ===
|
||||||
|
item['statusDesc'] = j.get('statusDesc', '未知')
|
||||||
|
# =======================================
|
||||||
|
|
||||||
|
item['isOrphanIoT'] = True
|
||||||
|
item['source'] = 'iot_card'
|
||||||
|
data_list.append(item)
|
||||||
|
|
||||||
return jsonify({'code': 200, 'data': data_list})
|
return jsonify({'code': 200, 'data': data_list})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'code': 500, 'message': str(e)})
|
return jsonify({'code': 500, 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# 2. 历史数据接口
|
||||||
|
# =========================================================
|
||||||
|
|
||||||
@api_bp.route('/device_data_by_date', methods=['GET'])
|
@api_bp.route('/device_data_by_date', methods=['GET'])
|
||||||
def device_data_by_date():
|
def device_data_by_date():
|
||||||
name = request.args.get('name')
|
name = request.args.get('name')
|
||||||
@ -65,14 +346,16 @@ def device_data_by_date():
|
|||||||
return jsonify({'code': 404, 'message': 'Device not found'}), 404
|
return jsonify({'code': 404, 'message': 'Device not found'}), 404
|
||||||
|
|
||||||
content = None
|
content = None
|
||||||
|
query_date = date_str.replace('_', '-')
|
||||||
|
|
||||||
history_record = DeviceHistory.query.filter(
|
history_record = DeviceHistory.query.filter(
|
||||||
DeviceHistory.device_id == device.id,
|
DeviceHistory.device_id == device.id,
|
||||||
DeviceHistory.data_time.like(f"{date_str}%")
|
DeviceHistory.data_time.like(f"{query_date}%")
|
||||||
).order_by(desc(DeviceHistory.id)).first()
|
).order_by(desc(DeviceHistory.id)).first()
|
||||||
|
|
||||||
if history_record:
|
if history_record:
|
||||||
content = history_record.json_data
|
content = history_record.json_data
|
||||||
elif device.latest_time and device.latest_time.startswith(date_str):
|
elif device.latest_time and device.latest_time.startswith(query_date):
|
||||||
content = device.json_data
|
content = device.json_data
|
||||||
|
|
||||||
if content:
|
if content:
|
||||||
@ -85,195 +368,329 @@ def device_data_by_date():
|
|||||||
return jsonify({'code': 404, 'message': 'No data for this date'}), 404
|
return jsonify({'code': 404, 'message': 'No data for this date'}), 404
|
||||||
|
|
||||||
|
|
||||||
# =======================
|
@api_bp.route('/device_data_by_date_stub', methods=['GET'])
|
||||||
# 2. 维修日志接口
|
def device_data_by_date_stub():
|
||||||
# =======================
|
return device_data_by_date()
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# 3. 核心控制接口 (检测 & 写入)
|
||||||
|
# =========================================================
|
||||||
|
|
||||||
|
@api_bp.route('/run_monitor', methods=['POST'])
|
||||||
|
def run_monitor():
|
||||||
|
msg_list = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
# --- A. 执行爬虫并入库 ---
|
||||||
|
if execute_monitor_task:
|
||||||
|
task_result = execute_monitor_task()
|
||||||
|
if task_result:
|
||||||
|
scraped_list = task_result.get('device_list', [])
|
||||||
|
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
count_crawler = 0
|
||||||
|
for item in scraped_list:
|
||||||
|
d_name = item.get('name')
|
||||||
|
if not d_name: continue
|
||||||
|
|
||||||
|
d_raw = item.get('raw_json', {})
|
||||||
|
source = item.get('source', '')
|
||||||
|
target_time = item.get('target_time')
|
||||||
|
|
||||||
|
if '106' in str(source):
|
||||||
|
try:
|
||||||
|
path_str = d_raw.get('path', '')
|
||||||
|
match = re.search(r'/Data/(\d{4}_\d{2}_\d{2})/\w+_(\d{2}_\d{2}_\d{2})\.csv', path_str)
|
||||||
|
if match:
|
||||||
|
date_part = match.group(1).replace('_', '-')
|
||||||
|
time_part = match.group(2).replace('_', ':')
|
||||||
|
target_time = f"{date_part} {time_part}"
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
device = Device.query.filter_by(name=d_name).first()
|
||||||
|
if not device:
|
||||||
|
device = Device(name=d_name, source=source, install_site="")
|
||||||
|
db.session.add(device)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
if device.source == 'iot_card':
|
||||||
|
device.source = source
|
||||||
|
|
||||||
|
device.status = item.get('status')
|
||||||
|
device.current_value = item.get('value')
|
||||||
|
device.latest_time = target_time
|
||||||
|
device.check_time = current_time
|
||||||
|
|
||||||
|
# ✅ [核心修改] 获取爬虫返回的文件数量并保存
|
||||||
|
f_count = item.get('num_files', 0)
|
||||||
|
device.file_count = f_count
|
||||||
|
|
||||||
|
old_json = {}
|
||||||
|
try:
|
||||||
|
if device.json_data:
|
||||||
|
old_json = json.loads(device.json_data)
|
||||||
|
except:
|
||||||
|
old_json = {}
|
||||||
|
|
||||||
|
new_json = d_raw if isinstance(d_raw, dict) else item.get('raw_json', {})
|
||||||
|
if isinstance(new_json, dict):
|
||||||
|
old_json.update(new_json)
|
||||||
|
|
||||||
|
device.json_data = json.dumps(old_json, ensure_ascii=False)
|
||||||
|
device.offset = calculate_offset(device.latest_time)
|
||||||
|
|
||||||
|
# ✅ [核心修改] 写入历史记录时包含 file_count
|
||||||
|
new_history = DeviceHistory(
|
||||||
|
device_id=device.id,
|
||||||
|
status=item.get('status'),
|
||||||
|
result_data=item.get('value'),
|
||||||
|
data_time=target_time,
|
||||||
|
json_data=device.json_data,
|
||||||
|
file_count=f_count # 确保历史数据也记录文件数
|
||||||
|
)
|
||||||
|
db.session.add(new_history)
|
||||||
|
count_crawler += 1
|
||||||
|
|
||||||
|
msg_list.append(f"爬虫更新: {count_crawler}")
|
||||||
|
else:
|
||||||
|
msg_list.append("爬虫无数据")
|
||||||
|
|
||||||
|
# --- B. 执行 IoT 同步 (写入数据库) ---
|
||||||
|
if sync_iot_data_service:
|
||||||
|
iot_list = sync_iot_data_service()
|
||||||
|
# 复用 save_iot_cards_to_db 保存包含 statusDesc 的新数据
|
||||||
|
c, e = save_iot_cards_to_db(iot_list)
|
||||||
|
if e:
|
||||||
|
msg_list.append(f"IoT错: {e}")
|
||||||
|
else:
|
||||||
|
msg_list.append(f"IoT更新: {c}")
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({'code': 200, 'message': " | ".join(msg_list)})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'code': 500, 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# 4. 白名单、绑定与设备管理
|
||||||
|
# =========================================================
|
||||||
|
|
||||||
|
@api_bp.route('/toggle_whitelist', methods=['POST'])
|
||||||
|
def toggle_whitelist():
|
||||||
|
data = request.get_json()
|
||||||
|
iccid = data.get('iccid')
|
||||||
|
is_whitelist = data.get('is_whitelist')
|
||||||
|
|
||||||
|
sim_record = Device.query.filter_by(name=iccid, source='iot_card').first()
|
||||||
|
if not sim_record:
|
||||||
|
return jsonify({'code': 404, 'message': '未找到该卡片'})
|
||||||
|
|
||||||
|
try:
|
||||||
|
j = json.loads(sim_record.json_data)
|
||||||
|
j['is_whitelist'] = is_whitelist
|
||||||
|
sim_record.json_data = json.dumps(j, ensure_ascii=False)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({'code': 200, 'message': '设置成功'})
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'code': 500, 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route('/sync_iot_cards', methods=['POST'])
|
||||||
|
def sync_iot_cards():
|
||||||
|
if not sync_iot_data_service: return jsonify({'code': 500, 'message': '服务缺失'}), 500
|
||||||
|
try:
|
||||||
|
iot_list = sync_iot_data_service()
|
||||||
|
c, e = save_iot_cards_to_db(iot_list)
|
||||||
|
if e: return jsonify({'code': 500, 'message': e}), 500
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({'code': 200, 'message': f'更新{c}张卡', 'data': iot_list})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'code': 500, 'message': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route('/bind_device_card', methods=['POST'])
|
||||||
|
def bind_device_card():
|
||||||
|
data = request.get_json()
|
||||||
|
target = Device.query.filter_by(name=data.get('device_name')).first()
|
||||||
|
if not target: return jsonify({'code': 404, 'message': '找不到设备'})
|
||||||
|
try:
|
||||||
|
d_json = {}
|
||||||
|
try:
|
||||||
|
d_json = json.loads(target.json_data)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
d_json['bound_iccid'] = data.get('iccid')
|
||||||
|
target.json_data = json.dumps(d_json, ensure_ascii=False)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({'code': 200, 'message': '绑定成功'})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'code': 500, 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route('/add_device', methods=['POST'])
|
||||||
|
def add_device():
|
||||||
|
data = request.get_json()
|
||||||
|
try:
|
||||||
|
new_device = Device(
|
||||||
|
name=data.get('name'),
|
||||||
|
install_site=data.get('site', ''),
|
||||||
|
source='manual',
|
||||||
|
status='offline',
|
||||||
|
check_time=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
json_data='{}',
|
||||||
|
is_hidden=0,
|
||||||
|
is_maintaining=0
|
||||||
|
)
|
||||||
|
db.session.add(new_device)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({'code': 200})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'code': 500, 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route('/update_site', methods=['POST'])
|
||||||
|
def update_site():
|
||||||
|
d = Device.query.filter_by(name=request.json.get('name')).first()
|
||||||
|
if d:
|
||||||
|
d.install_site = request.json.get('site')
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({'code': 200})
|
||||||
|
return jsonify({'code': 404})
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route('/toggle_maintenance', methods=['POST'])
|
||||||
|
def toggle_maintenance():
|
||||||
|
d = Device.query.filter_by(name=request.json.get('name')).first()
|
||||||
|
if d:
|
||||||
|
d.is_maintaining = request.json.get('is_maintaining')
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({'code': 200})
|
||||||
|
return jsonify({'code': 404})
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route('/toggle_hidden', methods=['POST'])
|
||||||
|
def toggle_hidden():
|
||||||
|
d = Device.query.filter_by(name=request.json.get('name')).first()
|
||||||
|
if d:
|
||||||
|
d.is_hidden = request.json.get('is_hidden')
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({'code': 200})
|
||||||
|
return jsonify({'code': 404})
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# 5. 日志管理接口 (CRUD)
|
||||||
|
# =========================================================
|
||||||
|
|
||||||
@api_bp.route('/logs/list', methods=['GET'])
|
@api_bp.route('/logs/list', methods=['GET'])
|
||||||
def get_logs():
|
def get_logs_list():
|
||||||
keyword = request.args.get('keyword', '')
|
keyword = request.args.get('keyword', '')
|
||||||
start_date = request.args.get('start_date')
|
|
||||||
end_date = request.args.get('end_date')
|
|
||||||
|
|
||||||
query = MaintenanceLog.query
|
query = MaintenanceLog.query
|
||||||
if keyword:
|
if keyword:
|
||||||
kw = f"%{keyword}%"
|
kw = f"%{keyword}%"
|
||||||
query = query.filter(or_(
|
query = query.filter(or_(
|
||||||
MaintenanceLog.device_name.like(kw),
|
MaintenanceLog.device_name.like(kw),
|
||||||
MaintenanceLog.engineer.like(kw),
|
MaintenanceLog.content.like(kw),
|
||||||
MaintenanceLog.location.like(kw),
|
MaintenanceLog.engineer.like(kw)
|
||||||
MaintenanceLog.content.like(kw)
|
|
||||||
))
|
))
|
||||||
|
|
||||||
if start_date and end_date:
|
|
||||||
try:
|
|
||||||
start_dt = datetime.strptime(start_date, '%Y-%m-%d')
|
|
||||||
end_dt = datetime.strptime(end_date, '%Y-%m-%d').replace(hour=23, minute=59, second=59)
|
|
||||||
query = query.filter(MaintenanceLog.timestamp.between(start_dt, end_dt))
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
logs = query.order_by(MaintenanceLog.timestamp.desc()).all()
|
logs = query.order_by(MaintenanceLog.timestamp.desc()).all()
|
||||||
return jsonify({'code': 200, 'data': [l.to_dict() for l in logs]})
|
return jsonify({'code': 200, 'data': [l.to_dict() for l in logs]})
|
||||||
|
|
||||||
|
|
||||||
@api_bp.route('/logs/add', methods=['POST'])
|
@api_bp.route('/logs/add', methods=['POST'])
|
||||||
def add_log():
|
def add_log_entry():
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
try:
|
try:
|
||||||
new_log = MaintenanceLog(
|
new_log = MaintenanceLog(
|
||||||
device_name=data.get('device_name', '未知设备'),
|
device_name=data.get('device_name', ''),
|
||||||
engineer=data.get('engineer', ''),
|
engineer=data.get('engineer', ''),
|
||||||
location=data.get('location', ''),
|
location=data.get('location', ''),
|
||||||
content=data.get('content', '')
|
content=data.get('content', '')
|
||||||
)
|
)
|
||||||
db.session.add(new_log)
|
db.session.add(new_log)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return jsonify({'code': 200, 'message': 'Log saved'})
|
return jsonify({'code': 200})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
db.session.rollback()
|
|
||||||
return jsonify({'code': 500, 'message': str(e)})
|
return jsonify({'code': 500, 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
@api_bp.route('/logs/update', methods=['POST'])
|
@api_bp.route('/logs/update', methods=['POST'])
|
||||||
def update_log():
|
def update_log_entry():
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
log_id = data.get('id')
|
log = MaintenanceLog.query.get(data.get('id'))
|
||||||
log = MaintenanceLog.query.get(log_id)
|
if not log: return jsonify({'code': 404})
|
||||||
if not log: return jsonify({'code': 404, 'message': 'Not found'}), 404
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
log.device_name = data.get('device_name', log.device_name)
|
log.device_name = data.get('device_name', log.device_name)
|
||||||
|
log.content = data.get('content', log.content)
|
||||||
log.engineer = data.get('engineer', log.engineer)
|
log.engineer = data.get('engineer', log.engineer)
|
||||||
log.location = data.get('location', log.location)
|
log.location = data.get('location', log.location)
|
||||||
log.content = data.get('content', log.content)
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return jsonify({'code': 200, 'message': 'Log updated'})
|
return jsonify({'code': 200})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
db.session.rollback()
|
return jsonify({'code': 500})
|
||||||
return jsonify({'code': 500, 'message': str(e)})
|
|
||||||
|
|
||||||
|
|
||||||
@api_bp.route('/logs/delete', methods=['POST'])
|
@api_bp.route('/logs/delete', methods=['POST'])
|
||||||
def delete_log():
|
def delete_log_entry():
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
log = MaintenanceLog.query.get(data.get('id'))
|
log = MaintenanceLog.query.get(data.get('id'))
|
||||||
if log:
|
if log:
|
||||||
db.session.delete(log)
|
db.session.delete(log)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return jsonify({'code': 200, 'message': 'Deleted'})
|
return jsonify({'code': 200})
|
||||||
return jsonify({'code': 404, 'message': 'Not found'}), 404
|
return jsonify({'code': 404})
|
||||||
|
|
||||||
|
|
||||||
# =======================
|
@api_bp.route('/device_history_list', methods=['GET'])
|
||||||
# 3. 辅助与控制接口 (核心修复逻辑)
|
def get_device_history_list():
|
||||||
# =======================
|
|
||||||
|
|
||||||
def calculate_offset(latest_time_str):
|
|
||||||
if not latest_time_str or latest_time_str == "N/A": return "从未同步"
|
|
||||||
try:
|
try:
|
||||||
clean = str(latest_time_str).split()[0].replace('_', '-')
|
name = request.args.get('name')
|
||||||
target = datetime.strptime(clean, "%Y-%m-%d").date()
|
page = int(request.args.get('page', 1))
|
||||||
diff = (datetime.now().date() - target).days
|
limit = int(request.args.get('limit', 10))
|
||||||
return "当天已同步" if diff == 0 else f"滞后 {diff} 天"
|
|
||||||
except:
|
|
||||||
return "时间解析失败"
|
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
return jsonify({'code': 400, 'message': '缺少设备名称'})
|
||||||
|
|
||||||
@api_bp.route('/run_monitor', methods=['POST'])
|
# 1. 找到设备ID
|
||||||
def run_monitor():
|
device = Device.query.filter_by(name=name).first()
|
||||||
try:
|
if not device:
|
||||||
if not execute_monitor_task:
|
return jsonify({'code': 404, 'message': '设备不存在'})
|
||||||
return jsonify({'code': 500, 'message': 'Core module missing'})
|
|
||||||
|
|
||||||
task_result = execute_monitor_task()
|
# 2. 查询历史记录 (按时间倒序)
|
||||||
if not task_result: return jsonify({'code': 200, 'message': '任务跳过'})
|
query = DeviceHistory.query.filter_by(device_id=device.id).order_by(desc(DeviceHistory.data_time))
|
||||||
|
|
||||||
scraped_list = task_result.get('device_list', [])
|
# 3. 获取总数
|
||||||
current_check_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
total = query.count()
|
||||||
|
|
||||||
count = 0
|
# 4. 分页切片
|
||||||
for item in scraped_list:
|
history_list = query.offset((page - 1) * limit).limit(limit).all()
|
||||||
d_name = item.get('name')
|
|
||||||
if not d_name: continue
|
|
||||||
|
|
||||||
d_raw = item.get('raw_json', {})
|
# 5. 格式化返回数据
|
||||||
source = item.get('source', '')
|
data = []
|
||||||
target_time = item.get('target_time')
|
for h in history_list:
|
||||||
|
# 简单处理日期格式,只取日期部分,或者保留完整时间视需求而定
|
||||||
|
# 这里假设 data_time 格式为 "YYYY-MM-DD HH:MM:SS" 或 "YYYY_MM_DD..."
|
||||||
|
date_str = h.data_time
|
||||||
|
if not date_str:
|
||||||
|
date_str = h.recorded_at.strftime("%Y-%m-%d %H:%M:%S") if h.recorded_at else "未知"
|
||||||
|
|
||||||
# 处理 106 路径时间
|
data.append({
|
||||||
if '106' in str(source):
|
'date': date_str,
|
||||||
try:
|
'count': h.file_count or 0
|
||||||
path_str = d_raw.get('path', '')
|
})
|
||||||
match = re.search(r'/Data/(\d{4}_\d{2}_\d{2})/\w+_(\d{2}_\d{2}_\d{2})\.csv', path_str)
|
|
||||||
if match:
|
|
||||||
target_time = f"{match.group(1).replace('_', '-')} {match.group(2).replace('_', ':')}"
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
json_str = json.dumps(d_raw, ensure_ascii=False) if isinstance(d_raw, (dict, list)) else str(d_raw)
|
return jsonify({
|
||||||
|
'code': 200,
|
||||||
|
'data': data,
|
||||||
|
'total': total,
|
||||||
|
'page': page,
|
||||||
|
'limit': limit
|
||||||
|
})
|
||||||
|
|
||||||
# --- 关键修改:先查询,后更新 ---
|
|
||||||
device = Device.query.filter_by(name=d_name).first()
|
|
||||||
if not device:
|
|
||||||
# 只有新设备才初始化静态字段
|
|
||||||
device = Device(name=d_name, source=source, install_site="")
|
|
||||||
db.session.add(device)
|
|
||||||
db.session.flush() # 获取 ID 供 History 使用
|
|
||||||
|
|
||||||
# 仅更新动态抓取的字段,保留手动填写的 install_site, is_maintaining, is_hidden
|
|
||||||
device.status = item.get('status')
|
|
||||||
device.current_value = item.get('value')
|
|
||||||
device.latest_time = target_time
|
|
||||||
device.check_time = current_check_time
|
|
||||||
device.json_data = json_str
|
|
||||||
device.offset = calculate_offset(target_time)
|
|
||||||
|
|
||||||
new_history = DeviceHistory(
|
|
||||||
device_id=device.id,
|
|
||||||
status=item.get('status'),
|
|
||||||
result_data=item.get('value'),
|
|
||||||
data_time=target_time,
|
|
||||||
json_data=json_str
|
|
||||||
)
|
|
||||||
db.session.add(new_history)
|
|
||||||
count += 1
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
return jsonify({'code': 200, 'message': f'成功更新 {count} 台设备,资料已保留'})
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
db.session.rollback()
|
return jsonify({'code': 500, 'message': str(e)})
|
||||||
return jsonify({'code': 500, 'message': str(e)})
|
|
||||||
|
|
||||||
|
|
||||||
@api_bp.route('/update_site', methods=['POST'])
|
|
||||||
def update_site():
|
|
||||||
data = request.get_json()
|
|
||||||
device = Device.query.filter_by(name=data.get('name')).first()
|
|
||||||
if device:
|
|
||||||
device.install_site = data.get('site')
|
|
||||||
db.session.commit()
|
|
||||||
return jsonify({'code': 200})
|
|
||||||
return jsonify({'code': 404}), 404
|
|
||||||
|
|
||||||
|
|
||||||
@api_bp.route('/toggle_maintenance', methods=['POST'])
|
|
||||||
def toggle_maintenance():
|
|
||||||
data = request.get_json()
|
|
||||||
device = Device.query.filter_by(name=data.get('name')).first()
|
|
||||||
if device:
|
|
||||||
device.is_maintaining = data.get('is_maintaining')
|
|
||||||
db.session.commit()
|
|
||||||
return jsonify({'code': 200})
|
|
||||||
return jsonify({'code': 404}), 404
|
|
||||||
|
|
||||||
|
|
||||||
@api_bp.route('/toggle_hidden', methods=['POST'])
|
|
||||||
def toggle_hidden():
|
|
||||||
data = request.get_json()
|
|
||||||
device = Device.query.filter_by(name=data.get('name')).first()
|
|
||||||
if device:
|
|
||||||
device.is_hidden = data.get('is_hidden')
|
|
||||||
db.session.commit()
|
|
||||||
return jsonify({'code': 200})
|
|
||||||
return jsonify({'code': 404}), 404
|
|
||||||
@ -7,11 +7,11 @@ web_bp = Blueprint('web', __name__)
|
|||||||
|
|
||||||
@web_bp.route('/')
|
@web_bp.route('/')
|
||||||
def index():
|
def index():
|
||||||
"""访问根路径时,返回 dist/index.html"""
|
"""访问根路径时,返回 web_dist/index.html"""
|
||||||
try:
|
try:
|
||||||
return send_from_directory(get_static_path(), 'index.html')
|
return send_from_directory(get_static_path(), 'index.html')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"前端资源未找到,请确认 dist 文件夹是否存在。错误信息: {e}", 404
|
return f"前端资源未找到,请确认 web_dist 文件夹是否存在。错误信息: {e}", 404
|
||||||
|
|
||||||
@web_bp.route('/<path:path>')
|
@web_bp.route('/<path:path>')
|
||||||
def static_files(path):
|
def static_files(path):
|
||||||
|
|||||||
@ -1,9 +1,31 @@
|
|||||||
# services/core.py
|
# services/core.py
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
from .crawler_106 import run_106_logic
|
import traceback
|
||||||
from .crawler_82 import run_82_logic
|
from datetime import datetime
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# 1. 动态导入模块
|
||||||
|
# ==============================================================================
|
||||||
|
try:
|
||||||
|
from .crawler_106 import run_106_logic
|
||||||
|
except ImportError as e:
|
||||||
|
print(f"⚠️ [系统警告] 无法导入 crawler_106: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def run_106_logic():
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
from .crawler_82 import run_82_logic
|
||||||
|
except ImportError as e:
|
||||||
|
print(f"⚠️ [系统警告] 无法导入 crawler_82: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def run_82_logic():
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 全局任务锁
|
||||||
task_lock = threading.Lock()
|
task_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
@ -12,26 +34,71 @@ def execute_monitor_task():
|
|||||||
执行所有爬虫,返回一个大列表:
|
执行所有爬虫,返回一个大列表:
|
||||||
{'device_list': [item1, item2...], 'target_time': '...'}
|
{'device_list': [item1, item2...], 'target_time': '...'}
|
||||||
"""
|
"""
|
||||||
|
# 1. 锁机制:防止任务重复运行
|
||||||
if task_lock.locked():
|
if task_lock.locked():
|
||||||
logging.warning(">>> 任务正在运行中,跳过")
|
logging.warning(">>> 任务正在运行中,跳过")
|
||||||
|
print(">>> ⚠️ [调度] 任务正在运行中,本次请求跳过")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
with task_lock:
|
with task_lock:
|
||||||
|
start_time = datetime.now()
|
||||||
logging.info(">>> 开始执行监控任务...")
|
logging.info(">>> 开始执行监控任务...")
|
||||||
|
print(f"--- [任务开始] {start_time.strftime('%H:%M:%S')} ---")
|
||||||
|
|
||||||
# 1. 获取 106 数据列表
|
all_results = []
|
||||||
list_106 = run_106_logic()
|
|
||||||
|
|
||||||
# 2. 获取 82 数据列表
|
# ==========================
|
||||||
list_82 = run_82_logic()
|
# 2. 执行 106 爬虫
|
||||||
|
# ==========================
|
||||||
|
try:
|
||||||
|
print(f">>> [106爬虫] 启动...")
|
||||||
|
list_106 = run_106_logic()
|
||||||
|
|
||||||
# 3. 合并
|
if list_106:
|
||||||
combined_list = list_106 + list_82
|
count = len(list_106)
|
||||||
|
print(f"✅ 106爬虫获取数据: {count} 条")
|
||||||
|
all_results.extend(list_106)
|
||||||
|
else:
|
||||||
|
print("⚠️ 106爬虫运行完成,但未返回任何数据 (空列表)")
|
||||||
|
|
||||||
logging.info(f">>> 任务完成,共获取 {len(combined_list)} 条数据")
|
except Exception as e:
|
||||||
|
print(f"❌ 106爬虫执行严重失败: {e}")
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
# ==========================
|
||||||
|
# 3. 执行 82 爬虫
|
||||||
|
# ==========================
|
||||||
|
try:
|
||||||
|
print(f">>> [82爬虫] 启动...")
|
||||||
|
list_82 = run_82_logic()
|
||||||
|
|
||||||
|
if list_82:
|
||||||
|
print(f"✅ 82爬虫获取数据: {len(list_82)} 条")
|
||||||
|
|
||||||
|
# 🛠️ [补全] 82爬虫没有文件数概念,手动补0,防止入库报错
|
||||||
|
for item in list_82:
|
||||||
|
if 'num_files' not in item:
|
||||||
|
item['num_files'] = 0
|
||||||
|
if 'status' not in item:
|
||||||
|
item['status'] = 'Unknown'
|
||||||
|
|
||||||
|
all_results.extend(list_82)
|
||||||
|
else:
|
||||||
|
print("⚠️ 82爬虫运行完成,但未返回数据")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 82爬虫执行严重失败: {e}")
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
# ==========================
|
||||||
|
# 4. 汇总返回
|
||||||
|
# ==========================
|
||||||
|
duration = (datetime.now() - start_time).total_seconds()
|
||||||
|
logging.info(f">>> 任务完成,共获取 {len(all_results)} 条数据")
|
||||||
|
print(f"--- [任务结束] 总耗时: {duration:.2f}秒 | 总计获取: {len(all_results)} 台设备 ---")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'device_list': combined_list,
|
'device_list': all_results,
|
||||||
'target_time': None, # 具体时间已在 item 里
|
'target_time': None, # 具体时间已在 item['target_time'] 里
|
||||||
'temp_file_path': None # 废弃旧逻辑,文件路径已在 item 里
|
'temp_file_path': None # 废弃旧逻辑
|
||||||
}
|
}
|
||||||
@ -9,6 +9,7 @@ CONFIG = Config.CRAWLER_CONFIG["106"]
|
|||||||
|
|
||||||
|
|
||||||
def get_temp_dir():
|
def get_temp_dir():
|
||||||
|
"""获取临时文件存储目录"""
|
||||||
base_dir = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
|
base_dir = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
|
||||||
temp_dir = os.path.join(base_dir, 'instance', 'temp')
|
temp_dir = os.path.join(base_dir, 'instance', 'temp')
|
||||||
if not os.path.exists(temp_dir):
|
if not os.path.exists(temp_dir):
|
||||||
@ -17,6 +18,7 @@ def get_temp_dir():
|
|||||||
|
|
||||||
|
|
||||||
def get_106_dynamic_token(port):
|
def get_106_dynamic_token(port):
|
||||||
|
"""获取动态登录 Token"""
|
||||||
try:
|
try:
|
||||||
login_url = f"http://106.75.72.40:{port}/api/login"
|
login_url = f"http://106.75.72.40:{port}/api/login"
|
||||||
resp = requests.post(login_url, json=CONFIG["login_payload"], timeout=10)
|
resp = requests.post(login_url, json=CONFIG["login_payload"], timeout=10)
|
||||||
@ -26,58 +28,82 @@ def get_106_dynamic_token(port):
|
|||||||
|
|
||||||
|
|
||||||
def find_closest_item(items, is_date_level=True):
|
def find_closest_item(items, is_date_level=True):
|
||||||
|
"""
|
||||||
|
在列表中找到与当前日期最接近的文件夹或文件
|
||||||
|
"""
|
||||||
if not items or not isinstance(items, list): return None
|
if not items or not isinstance(items, list): return None
|
||||||
today = datetime.now()
|
today = datetime.now()
|
||||||
scored_items = []
|
scored_items = []
|
||||||
|
|
||||||
for item in items:
|
for item in items:
|
||||||
name_val = item.get('name', '')
|
name_val = item.get('name', '')
|
||||||
path_val = item.get('path', '')
|
path_val = item.get('path', '')
|
||||||
|
# 如果是日期层级,名字通常是 2026_02_08 这种格式
|
||||||
target_str = name_val if name_val else path_val.split('/')[-1]
|
target_str = name_val if name_val else path_val.split('/')[-1]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if is_date_level:
|
if is_date_level:
|
||||||
|
# 解析文件夹日期格式: YYYY_MM_DD
|
||||||
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', '')
|
||||||
current_date = datetime.fromisoformat(mod_str.replace('Z', '+00:00'))
|
current_date = datetime.fromisoformat(mod_str.replace('Z', '+00:00'))
|
||||||
|
|
||||||
|
# 计算与当前时间的差距
|
||||||
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:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not scored_items: return None
|
if not scored_items: return None
|
||||||
|
# 按时间差排序,取最小的
|
||||||
scored_items.sort(key=lambda x: x[0])
|
scored_items.sort(key=lambda x: x[0])
|
||||||
return scored_items[0]
|
return scored_items[0]
|
||||||
|
|
||||||
|
|
||||||
def run_106_logic():
|
def run_106_logic():
|
||||||
"""返回 result_list, 每个元素是一个字典"""
|
"""
|
||||||
|
106 爬虫主逻辑
|
||||||
|
返回 result_list, 每个元素是一个字典
|
||||||
|
"""
|
||||||
results = []
|
results = []
|
||||||
print(">>> [106爬虫] 启动...")
|
print(">>> [106爬虫] 启动...")
|
||||||
today_str = datetime.now().strftime("%Y_%m_%d")
|
|
||||||
main_headers = {"Authorization": CONFIG["primary_auth"], "User-Agent": "Mozilla/5.0"}
|
main_headers = {"Authorization": CONFIG["primary_auth"], "User-Agent": "Mozilla/5.0"}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# 0. 获取代理列表 (设备列表)
|
||||||
resp = requests.get(CONFIG["base_url"], headers=main_headers, timeout=20)
|
resp = requests.get(CONFIG["base_url"], headers=main_headers, timeout=20)
|
||||||
proxies = resp.json().get('proxies', [])
|
proxies = resp.json().get('proxies', [])
|
||||||
|
|
||||||
for item in proxies:
|
for item in proxies:
|
||||||
name = item.get('name', '')
|
name = item.get('name', '')
|
||||||
|
# 过滤规则:必须以 _data 结尾
|
||||||
if not name.lower().endswith('_data'): continue
|
if not name.lower().endswith('_data'): continue
|
||||||
|
|
||||||
name_upper = name.upper()
|
name_upper = name.upper()
|
||||||
is_tower_underscore = "TOWER_" in name_upper
|
is_tower_underscore = "TOWER_" in name_upper
|
||||||
is_tower_i = "TOWER" in name_upper and not is_tower_underscore
|
is_tower_i = "TOWER" in name_upper and not is_tower_underscore
|
||||||
|
|
||||||
|
# 过滤规则:必须包含 TOWER 相关标识
|
||||||
if not (is_tower_underscore or is_tower_i): continue
|
if not (is_tower_underscore or is_tower_i): continue
|
||||||
|
|
||||||
# 构建基础数据包
|
# --- 构建基础数据包 ---
|
||||||
|
# 默认使用标准当前时间作为兜底,防止后续步骤失败时时间为空
|
||||||
|
current_standard_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
data_packet = {
|
data_packet = {
|
||||||
'source': '106网站',
|
'source': '106网站',
|
||||||
'name': name,
|
'name': name,
|
||||||
'status': '正常',
|
'status': '正常',
|
||||||
'value': '',
|
'value': '',
|
||||||
'target_time': datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
'target_time': current_standard_time,
|
||||||
'raw_json': {},
|
'raw_json': {},
|
||||||
'temp_file': None
|
'temp_file': None,
|
||||||
|
'num_files': 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 检查在线状态
|
||||||
if str(item.get('status')).lower() != 'online':
|
if str(item.get('status')).lower() != 'online':
|
||||||
data_packet['status'] = '离线'
|
data_packet['status'] = '离线'
|
||||||
data_packet['value'] = f"状态: {item.get('status')}"
|
data_packet['value'] = f"状态: {item.get('status')}"
|
||||||
@ -85,6 +111,7 @@ def run_106_logic():
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# 获取端口和 Token
|
||||||
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:
|
||||||
@ -96,31 +123,51 @@ def run_106_logic():
|
|||||||
headers = {"Authorization": CONFIG["primary_auth"], "x-auth": token}
|
headers = {"Authorization": CONFIG["primary_auth"], "x-auth": token}
|
||||||
api_root = "/api/resources/Data/" if is_tower_underscore else "/api/resources/data/"
|
api_root = "/api/resources/Data/" if is_tower_underscore else "/api/resources/data/"
|
||||||
|
|
||||||
|
# --- 1. 获取日期文件夹列表 ---
|
||||||
res1 = requests.get(f"http://106.75.72.40:{port}{api_root}", headers=headers, timeout=10)
|
res1 = requests.get(f"http://106.75.72.40:{port}{api_root}", headers=headers, timeout=10)
|
||||||
best_date = find_closest_item(res1.json().get('items', []), 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:
|
||||||
data_packet['value'] = "未找到今日文件夹"
|
data_packet['value'] = "未找到任何日期文件夹"
|
||||||
data_packet['target_time'] = best_date[2] if best_date else "N/A"
|
|
||||||
results.append(data_packet)
|
results.append(data_packet)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
data_packet['target_time'] = best_date[2] # 实际数据时间
|
# ==============================================================================
|
||||||
date_path = f"{api_root}{best_date[2]}/"
|
# ✅ [核心修复] 时间格式标准化
|
||||||
|
# 原逻辑: data_packet['target_time'] = best_date[2] (得到 "2026_02_08")
|
||||||
|
# 新逻辑: 将 "2026_02_08" 转换为 "2026-02-08 HH:MM:SS"
|
||||||
|
# ==============================================================================
|
||||||
|
raw_folder_name = best_date[2] # 例如 "2026_02_08"
|
||||||
|
formatted_date_part = raw_folder_name.replace('_', '-') # 变成 "2026-02-08"
|
||||||
|
current_time_part = datetime.now().strftime("%H:%M:%S")
|
||||||
|
|
||||||
|
# 覆盖默认时间,确保数据库存入的是标准时间戳格式
|
||||||
|
data_packet['target_time'] = f"{formatted_date_part} {current_time_part}"
|
||||||
|
|
||||||
|
date_path = f"{api_root}{raw_folder_name}/"
|
||||||
|
|
||||||
|
# --- 2. 请求具体日期的文件夹内容 (获取 numFiles) ---
|
||||||
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)
|
folder_data = res2.json()
|
||||||
|
|
||||||
|
file_count = folder_data.get('numFiles', 0)
|
||||||
|
data_packet['num_files'] = file_count
|
||||||
|
print(f" -> {name}: 找到日期 {formatted_date_part}, 文件数: {file_count}")
|
||||||
|
|
||||||
|
# --- 3. 找该文件夹里最新的文件 ---
|
||||||
|
best_file = find_closest_item(folder_data.get('items', []), False)
|
||||||
|
|
||||||
if not best_file:
|
if not best_file:
|
||||||
data_packet['value'] = "今日文件夹为空"
|
data_packet['value'] = "文件夹为空"
|
||||||
results.append(data_packet)
|
results.append(data_packet)
|
||||||
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')}"
|
||||||
|
|
||||||
# 核心逻辑:获取内容
|
# --- 4. 下载/读取内容逻辑 ---
|
||||||
if is_tower_i:
|
if is_tower_i:
|
||||||
# 下载二进制文件
|
# [二进制文件] 下载逻辑
|
||||||
download_url = f"http://106.75.72.40:{port}/api/raw{full_path}"
|
download_url = f"http://106.75.72.40:{port}/api/raw{full_path}"
|
||||||
res3 = requests.get(download_url, headers=headers, timeout=20, stream=True)
|
res3 = requests.get(download_url, headers=headers, timeout=20, stream=True)
|
||||||
if res3.status_code == 200:
|
if res3.status_code == 200:
|
||||||
@ -129,20 +176,21 @@ def run_106_logic():
|
|||||||
with open(temp_path, 'wb') as f:
|
with open(temp_path, 'wb') as f:
|
||||||
f.write(res3.content)
|
f.write(res3.content)
|
||||||
|
|
||||||
data_packet['temp_file'] = temp_path # 🔥 传递给API
|
data_packet['temp_file'] = temp_path
|
||||||
data_packet['value'] = f"Binary Downloaded: {len(res3.content)} bytes"
|
data_packet['value'] = f"Binary Downloaded: {len(res3.content)} bytes"
|
||||||
data_packet['raw_json'] = file_item # 用文件属性充当RawData
|
data_packet['raw_json'] = file_item # 借用 file_item 充当 raw_json
|
||||||
else:
|
else:
|
||||||
data_packet['status'] = '异常'
|
data_packet['status'] = '异常'
|
||||||
data_packet['value'] = f"下载失败: {res3.status_code}"
|
data_packet['value'] = f"下载失败: {res3.status_code}"
|
||||||
else:
|
else:
|
||||||
# JSON 内容
|
# [文本文件] 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}"
|
||||||
res3 = requests.get(file_api_url, headers=headers, timeout=20)
|
res3 = requests.get(file_api_url, headers=headers, timeout=20)
|
||||||
try:
|
try:
|
||||||
json_content = res3.json()
|
json_content = res3.json()
|
||||||
data_packet['raw_json'] = json_content # 🔥 完整保存
|
data_packet['raw_json'] = json_content
|
||||||
data_packet['value'] = json_content.get('content', '')
|
# 尝试提取 content 内容,如果没有则截取部分 JSON 字符串
|
||||||
|
data_packet['value'] = json_content.get('content', str(json_content)[:100])
|
||||||
except:
|
except:
|
||||||
data_packet['value'] = "JSON解析失败"
|
data_packet['value'] = "JSON解析失败"
|
||||||
|
|
||||||
@ -150,7 +198,7 @@ def run_106_logic():
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
data_packet['status'] = '异常'
|
data_packet['status'] = '异常'
|
||||||
data_packet['value'] = str(e)[:50]
|
data_packet['value'] = str(e)[:100]
|
||||||
results.append(data_packet)
|
results.append(data_packet)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
260
2_1banben/services/iot_api.py
Normal file
260
2_1banben/services/iot_api.py
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
import time
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 1. 配置获取 (从 Flask 全局配置读取)
|
||||||
|
# ==========================================
|
||||||
|
def get_config(key):
|
||||||
|
"""
|
||||||
|
优先从 Flask 应用上下文获取配置
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if current_app:
|
||||||
|
return current_app.config.get(key)
|
||||||
|
except RuntimeError:
|
||||||
|
# 如果在非 Flask 上下文运行(如单独调试),返回 None 或报错
|
||||||
|
print("[Warning] Not in Flask context")
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 2. 核心签名算法 (Java 兼容版)
|
||||||
|
# ==========================================
|
||||||
|
def generate_signature_final(params, is_json_body=False):
|
||||||
|
"""
|
||||||
|
签名公式: secret + appid + timestamp + paramData + secret -> MD5(lower)
|
||||||
|
"""
|
||||||
|
appid = get_config('IOT_APP_ID')
|
||||||
|
secret = get_config('IOT_SECRET')
|
||||||
|
|
||||||
|
# 1. 拷贝参数,避免修改原字典
|
||||||
|
params_copy = params.copy()
|
||||||
|
|
||||||
|
# 2. 移除不参与签名的字段 (timestamp, appid, signature)
|
||||||
|
# 注意:timestamp 在签名公式中是单独拼接的,不在 paramData 里
|
||||||
|
timestamp = str(params_copy.pop('timestamp', int(time.time() * 1000)))
|
||||||
|
if 'appid' in params_copy: params_copy.pop('appid')
|
||||||
|
if 'signature' in params_copy: params_copy.pop('signature')
|
||||||
|
|
||||||
|
# 3. 生成 paramData
|
||||||
|
param_data = ""
|
||||||
|
if is_json_body:
|
||||||
|
# POST JSON 模式: 无空格 JSON 字符串,按 key 排序
|
||||||
|
# separators=(',', ':') 去除默认的空格
|
||||||
|
param_data = json.dumps(params_copy, sort_keys=True, separators=(',', ':'), ensure_ascii=False)
|
||||||
|
else:
|
||||||
|
# GET 键值对模式: key=value 直接拼接 (注意:Java版没有 '&' 符号)
|
||||||
|
sorted_keys = sorted([k for k in params_copy.keys() if params_copy[k] is not None])
|
||||||
|
kv_list = [f"{k}={params_copy[k]}" for k in sorted_keys]
|
||||||
|
param_data = "".join(kv_list)
|
||||||
|
|
||||||
|
# 4. 拼接最终字符串
|
||||||
|
sign_str = f"{secret}{appid}{timestamp}{param_data}{secret}"
|
||||||
|
|
||||||
|
# 5. MD5 加密并转小写
|
||||||
|
return hashlib.md5(sign_str.encode('utf-8')).hexdigest().lower()
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 3. 业务接口封装
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
def get_access_token():
|
||||||
|
"""
|
||||||
|
登录获取 Token
|
||||||
|
"""
|
||||||
|
base_url = get_config('IOT_BASE_URL')
|
||||||
|
login_url = get_config('IOT_URL_LOGIN')
|
||||||
|
|
||||||
|
if not base_url or not login_url:
|
||||||
|
print("[IoT API] 配置缺失")
|
||||||
|
return None
|
||||||
|
|
||||||
|
url = base_url + login_url
|
||||||
|
payload = {
|
||||||
|
"username": get_config('IOT_USERNAME'),
|
||||||
|
"password": get_config('IOT_PASSWORD')
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# print(f"DEBUG: 正在登录 IoT 平台...")
|
||||||
|
res = requests.post(url, json=payload, timeout=10).json()
|
||||||
|
if res.get('code') == 0:
|
||||||
|
token = res['data']['accessToken']
|
||||||
|
return token
|
||||||
|
else:
|
||||||
|
print(f"[IoT API] 登录失败: {res.get('msg')}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[IoT API] 登录异常: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_iot_card_page(token, page_no=1, page_size=100):
|
||||||
|
"""
|
||||||
|
获取单页卡列表
|
||||||
|
"""
|
||||||
|
base_url = get_config('IOT_BASE_URL')
|
||||||
|
page_url = get_config('IOT_URL_PAGE')
|
||||||
|
url = base_url + page_url
|
||||||
|
|
||||||
|
timestamp = int(time.time() * 1000)
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"appid": get_config('IOT_APP_ID'),
|
||||||
|
"pageNo": page_no,
|
||||||
|
"pageSize": page_size,
|
||||||
|
"timestamp": timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
# 计算签名
|
||||||
|
sign = generate_signature_final(params, is_json_body=False)
|
||||||
|
params['signature'] = sign
|
||||||
|
|
||||||
|
headers = {'Authorization': f'Bearer {token}'}
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.get(url, params=params, headers=headers, timeout=15)
|
||||||
|
return resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[IoT API] 获取列表页失败 (Page {page_no}): {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_iot_card_details_batch(token, iccids):
|
||||||
|
"""
|
||||||
|
批量获取卡详情
|
||||||
|
"""
|
||||||
|
if not iccids: return None
|
||||||
|
|
||||||
|
base_url = get_config('IOT_BASE_URL')
|
||||||
|
detail_url = get_config('IOT_URL_DETAIL')
|
||||||
|
url = base_url + detail_url
|
||||||
|
|
||||||
|
timestamp = int(time.time() * 1000)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"iccids": iccids,
|
||||||
|
"timestamp": timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
# 计算签名 (POST JSON)
|
||||||
|
sign = generate_signature_final(payload, is_json_body=True)
|
||||||
|
payload['signature'] = sign
|
||||||
|
# 补回 timestamp 到 body 中,因为签名计算时 pop 掉了
|
||||||
|
payload['timestamp'] = timestamp
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'Authorization': f'Bearer {token}',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.post(url, json=payload, headers=headers, timeout=20)
|
||||||
|
return resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[IoT API] 获取详情失败: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 4. 主服务入口 (供 api.py 调用)
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
def sync_iot_data_service():
|
||||||
|
"""
|
||||||
|
执行完整的同步流程:
|
||||||
|
1. 登录
|
||||||
|
2. 遍历所有分页获取 ICCID
|
||||||
|
3. 批量查询详情
|
||||||
|
4. 解析 cardStatus 状态码
|
||||||
|
5. 返回完整数据列表 (List[Dict])
|
||||||
|
"""
|
||||||
|
print("[IoT Service] 开始同步任务...")
|
||||||
|
|
||||||
|
# ✅ 1. 定义状态码映射表 (根据提供的需求文档)
|
||||||
|
STATUS_MAP = {
|
||||||
|
"1": "测试期",
|
||||||
|
"2": "沉默期",
|
||||||
|
"3": "在使用",
|
||||||
|
"4": "停机",
|
||||||
|
"5": "停机保号",
|
||||||
|
"6": "销户"
|
||||||
|
}
|
||||||
|
|
||||||
|
token = get_access_token()
|
||||||
|
if not token:
|
||||||
|
return []
|
||||||
|
|
||||||
|
all_iccids = []
|
||||||
|
page_no = 1
|
||||||
|
page_size = 100
|
||||||
|
|
||||||
|
# ✅ 2. 循环翻页获取所有 ICCID
|
||||||
|
while True:
|
||||||
|
res = get_iot_card_page(token, page_no, page_size)
|
||||||
|
|
||||||
|
if not res or (res.get('code') != 0 and res.get('code') != 200):
|
||||||
|
print(f"[IoT Service] 列表获取结束或中断: {res.get('msg') if res else 'No Response'}")
|
||||||
|
break
|
||||||
|
|
||||||
|
data_field = res.get('data', {})
|
||||||
|
rows = []
|
||||||
|
if isinstance(data_field, list):
|
||||||
|
rows = data_field
|
||||||
|
elif isinstance(data_field, dict):
|
||||||
|
rows = data_field.get('rows', []) or data_field.get('list', [])
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
break
|
||||||
|
|
||||||
|
current_batch = [str(x.get('iccid')) for x in rows if x.get('iccid')]
|
||||||
|
all_iccids.extend(current_batch)
|
||||||
|
|
||||||
|
if len(rows) < page_size:
|
||||||
|
break
|
||||||
|
|
||||||
|
page_no += 1
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
total_count = len(all_iccids)
|
||||||
|
if total_count == 0:
|
||||||
|
print("[IoT Service] 未找到任何卡片")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# ✅ 3. 分批查询详情并处理状态
|
||||||
|
final_data_list = []
|
||||||
|
batch_size = 50
|
||||||
|
|
||||||
|
for i in range(0, total_count, batch_size):
|
||||||
|
batch_iccids = all_iccids[i: i + batch_size]
|
||||||
|
detail_res = get_iot_card_details_batch(token, batch_iccids)
|
||||||
|
|
||||||
|
if detail_res and (detail_res.get('code') == 0 or detail_res.get('code') == 200):
|
||||||
|
details = detail_res.get('data', [])
|
||||||
|
if isinstance(details, list):
|
||||||
|
|
||||||
|
# === 核心修改:增加状态解析逻辑 ===
|
||||||
|
for card in details:
|
||||||
|
# 获取原始状态码 (如 "3")
|
||||||
|
raw_status = str(card.get('cardStatus', ''))
|
||||||
|
|
||||||
|
# 匹配中文描述 (如 "在使用")
|
||||||
|
status_desc = STATUS_MAP.get(raw_status, "未知状态")
|
||||||
|
|
||||||
|
# 将描述写入新字段,前端可直接取用 card.statusDesc
|
||||||
|
card['statusDesc'] = status_desc
|
||||||
|
|
||||||
|
final_data_list.append(card)
|
||||||
|
# =================================
|
||||||
|
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
print(f"[IoT Service] 同步完成,共获取 {len(final_data_list)} 条详情数据")
|
||||||
|
return final_data_list
|
||||||
@ -5,7 +5,7 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="version-footer">
|
<footer class="version-footer">
|
||||||
2.1版本 © 2026 Device Monitor
|
2.5版本(加入每日数据个数) © 2026 Device Monitor
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -13,37 +13,34 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<el-button type="info" plain icon="Document" @click="openLogCenter(null)">
|
<el-button type="primary" plain icon="Plus" @click="showAddDialog = true">新增</el-button>
|
||||||
日志
|
<el-button type="primary" plain icon="Link" @click="openIoTBinder">卡绑定</el-button>
|
||||||
</el-button>
|
<el-button type="info" plain icon="Document" @click="openLogCenter(null)">日志</el-button>
|
||||||
<el-button type="warning" plain icon="RefreshRight" :loading="runningTask" @click="runManualMonitor">
|
<el-button type="warning" plain icon="RefreshRight" :loading="runningTask" @click="runManualMonitor">检测</el-button>
|
||||||
检测
|
|
||||||
</el-button>
|
|
||||||
<el-button circle icon="Refresh" :loading="loading" @click="fetchData" />
|
<el-button circle icon="Refresh" :loading="loading" @click="fetchData" />
|
||||||
|
|
||||||
<div class="divider-mobile"></div>
|
<div class="divider-mobile"></div>
|
||||||
|
<el-button type="danger" plain icon="SwitchButton" @click="handleLogout">退出</el-button>
|
||||||
<el-button type="danger" plain icon="SwitchButton" @click="handleLogout">
|
|
||||||
退出
|
|
||||||
</el-button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="status-summary">
|
<div class="status-summary">
|
||||||
<el-tag color="#409EFF" effect="dark" class="legend-tag">修</el-tag>
|
<el-tag color="#409EFF" effect="dark" class="legend-tag">修</el-tag>
|
||||||
<el-tag color="#F56C6C" effect="dark" class="legend-tag">离线/>7天</el-tag>
|
<el-tag color="#F56C6C" effect="dark" class="legend-tag">离线 / 严重滞后</el-tag>
|
||||||
<el-tag color="#E6A23C" effect="dark" class="legend-tag">滞后1-7天</el-tag>
|
<el-tag color="#E6A23C" effect="dark" class="legend-tag">滞后 / 流量超标</el-tag>
|
||||||
<el-tag color="#FAC858" effect="dark" class="legend-tag" style="color: #333">滞后24h</el-tag>
|
<el-tag color="#FAC858" effect="dark" class="legend-tag" style="color: #333">数据异常 / 昨日</el-tag>
|
||||||
<el-tag color="#67C23A" effect="dark" class="legend-tag">正常</el-tag>
|
<el-tag color="#67C23A" effect="dark" class="legend-tag">正常</el-tag>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<div class="filter-section">
|
<div class="filter-section">
|
||||||
<el-radio-group v-model="filters.status" @change="fetchData" size="default">
|
<el-radio-group v-model="filters.status" size="default">
|
||||||
<el-radio-button label="all">全部</el-radio-button>
|
<el-radio-button label="all">全部({{ summary.totalCount }})</el-radio-button>
|
||||||
<el-radio-button label="abnormal" class="red-radio">
|
<el-radio-button label="abnormal" class="red-radio">
|
||||||
异常({{ summary.errorCount + summary.warningCount }})
|
状态异常({{ summary.errorCount + summary.warningCount }})
|
||||||
|
</el-radio-button>
|
||||||
|
<el-radio-button label="data_error" class="yellow-radio">
|
||||||
|
数据异常({{ summary.dataErrorCount }})
|
||||||
</el-radio-button>
|
</el-radio-button>
|
||||||
<el-radio-button label="hidden" class="gray-radio">
|
<el-radio-button label="hidden" class="gray-radio">
|
||||||
回收({{ summary.hiddenCount }})
|
回收({{ summary.hiddenCount }})
|
||||||
@ -57,6 +54,12 @@
|
|||||||
prefix-icon="Search"
|
prefix-icon="Search"
|
||||||
clearable
|
clearable
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div class="total-usage-tag">
|
||||||
|
<el-icon><Odometer /></el-icon>
|
||||||
|
<span class="label">卡池总用量:</span>
|
||||||
|
<span class="value">{{ totalUsageSum.toFixed(2) }} M</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -64,10 +67,10 @@
|
|||||||
:data="filteredData"
|
:data="filteredData"
|
||||||
border
|
border
|
||||||
v-loading="loading"
|
v-loading="loading"
|
||||||
style="width: 100%; min-width: 950px;"
|
style="width: 100%; min-width: 1250px;"
|
||||||
:row-class-name="tableRowClassName"
|
:row-class-name="tableRowClassName"
|
||||||
:height="tableHeight"
|
:height="tableHeight"
|
||||||
:default-sort="{ prop: 'sortHours', order: 'descending' }"
|
:default-sort="{ prop: 'sortWeight', order: 'descending' }"
|
||||||
>
|
>
|
||||||
<el-table-column label="状态" width="100" align="center" fixed="left">
|
<el-table-column label="状态" width="100" align="center" fixed="left">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
@ -84,7 +87,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column label="设备名称 (点击看图)" min-width="200" show-overflow-tooltip>
|
<el-table-column label="设备名称" min-width="180" show-overflow-tooltip>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div
|
<div
|
||||||
class="device-name-wrapper"
|
class="device-name-wrapper"
|
||||||
@ -95,11 +98,31 @@
|
|||||||
{{ formatDisplayName(row.name) }}
|
{{ formatDisplayName(row.name) }}
|
||||||
</span>
|
</span>
|
||||||
<el-icon v-if="!row.is_hidden" class="link-icon"><DataLine /></el-icon>
|
<el-icon v-if="!row.is_hidden" class="link-icon"><DataLine /></el-icon>
|
||||||
|
<el-tag v-if="row.isBound" size="small" type="info" effect="plain" style="margin-left:5px; height: 18px; line-height: 16px; padding:0 4px;">卡</el-tag>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column label="安装地点" min-width="160">
|
<el-table-column label="今日文件" width="120" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tooltip content="点击查看历史文件趋势" placement="top">
|
||||||
|
<div
|
||||||
|
class="file-count-cell"
|
||||||
|
@click="openHistoryDialog(row)"
|
||||||
|
:class="{ 'has-data': row.file_count > 0 }"
|
||||||
|
>
|
||||||
|
<el-tag v-if="row.file_count > 0" type="primary" effect="plain" round size="small">
|
||||||
|
{{ row.file_count }} 个
|
||||||
|
</el-tag>
|
||||||
|
<span v-else style="color: #ccc; font-size: 12px;">--</span>
|
||||||
|
|
||||||
|
<el-icon v-if="!row.is_hidden" class="history-icon"><Histogram /></el-icon>
|
||||||
|
</div>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="安装地点" min-width="140">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div v-if="row.isEditingSite" class="editing-cell">
|
<div v-if="row.isEditingSite" class="editing-cell">
|
||||||
<el-input
|
<el-input
|
||||||
@ -118,16 +141,83 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column label="数据时效" width="220" prop="sortHours" sortable>
|
<el-table-column label="本月流量" width="130" prop="trafficNum" sortable>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div style="font-size: 13px;"><el-icon><Clock /></el-icon> {{ row.latest_time || '--' }}</div>
|
<div v-if="row.isBound">
|
||||||
<div v-if="!row.is_maintaining && !row.is_hidden">
|
<span :style="{ fontWeight: '600', color: row.trafficWarning ? '#E6A23C' : '#606266' }">
|
||||||
<div v-if="row.status === 'offline' || row.status === '已离线'" class="status-text error-text">⚠️ 设备已离线</div>
|
{{ row.trafficNum }} M
|
||||||
<div v-else-if="row.diffDays > 7" class="status-text error-text">⚠️ 严重滞后 {{ Math.floor(row.diffDays) }} 天</div>
|
</span>
|
||||||
<div v-else-if="row.diffHours > 24" class="status-text warning-text">⚠️ 滞后 {{ Math.floor(row.diffDays) }} 天</div>
|
<el-tooltip v-if="row.trafficWarning" content="流量超标 (>=500M)" placement="top">
|
||||||
<div v-else-if="!row.isToday" class="status-text slight-warning-text">⚠️ 昨日数据</div>
|
<el-icon color="#E6A23C" style="margin-left: 4px; cursor: help;"><Warning /></el-icon>
|
||||||
<div v-else class="status-text success-text">✅ 数据最新</div>
|
</el-tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
<span v-else style="color: #ccc;">--</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="卡状态" width="110" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div v-if="row.isBound">
|
||||||
|
<el-tag
|
||||||
|
:type="getCardStatusType(row.statusDesc)"
|
||||||
|
effect="light"
|
||||||
|
size="small"
|
||||||
|
style="font-weight: bold;"
|
||||||
|
>
|
||||||
|
{{ row.statusDesc || '未知' }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
<span v-else style="color: #ccc;">--</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="服务截止" width="140">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div v-if="row.isBound && row.stopDate">
|
||||||
|
<span :style="{ color: row.expireWarning ? '#E6A23C' : '#606266', fontWeight: row.expireWarning ? 'bold' : 'normal' }">
|
||||||
|
{{ row.stopDate }}
|
||||||
|
</span>
|
||||||
|
<el-tooltip v-if="row.expireWarning" content="即将过期 (<30天)" placement="top">
|
||||||
|
<el-icon color="#E6A23C" style="margin-left: 4px; cursor: help;"><Warning /></el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
</div>
|
||||||
|
<span v-else style="color: #ccc;">--</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="数据时效与质量" width="260" prop="sortWeight" sortable>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div style="font-size: 13px; display:flex; align-items:center; gap:5px; color: #606266; margin-bottom: 4px;">
|
||||||
|
<el-icon><Clock /></el-icon> {{ row.latest_time || '尚未同步' }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!row.is_maintaining && !row.is_hidden">
|
||||||
|
<div v-if="row.statusType === 'error'" class="status-text error-text">
|
||||||
|
⚠️ {{ row.statusReason }}
|
||||||
|
</div>
|
||||||
|
<div v-else-if="row.statusType === 'warning'" class="status-text warning-text">
|
||||||
|
⚠️ {{ row.statusReason }}
|
||||||
|
</div>
|
||||||
|
<div v-else-if="row.statusType === 'slight-warning'" class="status-text slight-warning-text">
|
||||||
|
⚠️ {{ row.statusReason }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="status-text success-text">
|
||||||
|
✅ 状态正常
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="row.statusType !== 'error'" style="margin-top: 4px;">
|
||||||
|
<el-tag v-if="row.data_quality === 'error'" type="danger" size="small" effect="dark">
|
||||||
|
<el-icon><Warning /></el-icon> 数据严重异常
|
||||||
|
</el-tag>
|
||||||
|
<el-tag v-else-if="row.data_quality === 'warning'" type="warning" size="small" effect="dark">
|
||||||
|
<el-icon><WarningFilled /></el-icon> 数值警告
|
||||||
|
</el-tag>
|
||||||
|
<el-tag v-else-if="row.statusType !== 'warning' && row.statusType !== 'slight-warning'" type="success" size="small" effect="plain">
|
||||||
|
数值正常
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-else-if="row.is_maintaining" class="status-text maintenance-text">🛠️ 维护中</div>
|
<div v-else-if="row.is_maintaining" class="status-text maintenance-text">🛠️ 维护中</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@ -162,6 +252,29 @@
|
|||||||
|
|
||||||
<DataMonitor ref="dataMonitorRef" />
|
<DataMonitor ref="dataMonitorRef" />
|
||||||
<MaintenanceLogs ref="maintenanceLogsRef" />
|
<MaintenanceLogs ref="maintenanceLogsRef" />
|
||||||
|
<IoTDeviceBinder ref="iotBinderRef" @update-success="fetchData" />
|
||||||
|
|
||||||
|
<FileHistoryDialog ref="fileHistoryRef" />
|
||||||
|
|
||||||
|
<el-dialog v-model="showAddDialog" title="手动添加设备" width="400px" align-center>
|
||||||
|
<el-form :model="newDeviceForm" label-width="80px">
|
||||||
|
<el-form-item label="设备名称">
|
||||||
|
<el-input v-model="newDeviceForm.name" placeholder="请输入唯一设备名" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="安装地点">
|
||||||
|
<el-input v-model="newDeviceForm.site" placeholder="可选填" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<span class="dialog-footer">
|
||||||
|
<el-button @click="showAddDialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="isAdding" @click="handleAddDeviceSubmit">
|
||||||
|
确认添加
|
||||||
|
</el-button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -170,11 +283,13 @@ import { ref, reactive, computed, onMounted, nextTick, onBeforeUnmount } from 'v
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Clock, DataLine, Document, Refresh, EditPen, Search, Edit, RefreshRight, Delete, RefreshLeft, SwitchButton } from '@element-plus/icons-vue'
|
import { Clock, DataLine, Document, Refresh, EditPen, Search, Edit, RefreshRight, Delete, RefreshLeft, SwitchButton, Warning, WarningFilled, Plus, Odometer, Link, Histogram } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
// 引入子组件
|
|
||||||
import DataMonitor from './DataMonitor.vue'
|
import DataMonitor from './DataMonitor.vue'
|
||||||
import MaintenanceLogs from './MaintenanceLogs.vue'
|
import MaintenanceLogs from './MaintenanceLogs.vue'
|
||||||
|
import IoTDeviceBinder from './IoTDeviceBinder.vue'
|
||||||
|
// ✅ 引入新组件
|
||||||
|
import FileHistoryDialog from './FileHistoryDialog.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@ -184,10 +299,8 @@ const lastCheckTime = ref('')
|
|||||||
const windowHeight = ref(window.innerHeight)
|
const windowHeight = ref(window.innerHeight)
|
||||||
const windowWidth = ref(window.innerWidth)
|
const windowWidth = ref(window.innerWidth)
|
||||||
|
|
||||||
// 计算表格高度:手机端预留更多空间给折行的头部
|
|
||||||
const tableHeight = computed(() => {
|
const tableHeight = computed(() => {
|
||||||
const isMobile = windowWidth.value < 768
|
const isMobile = windowWidth.value < 768
|
||||||
// 手机端头部元素堆叠,需要减去更多的高度
|
|
||||||
const offset = isMobile ? 380 : 250
|
const offset = isMobile ? 380 : 250
|
||||||
return windowHeight.value - offset
|
return windowHeight.value - offset
|
||||||
})
|
})
|
||||||
@ -197,24 +310,24 @@ const API_BASE = import.meta.env.DEV ? 'http://127.0.0.1:5000' : ''
|
|||||||
|
|
||||||
const dataMonitorRef = ref(null)
|
const dataMonitorRef = ref(null)
|
||||||
const maintenanceLogsRef = ref(null)
|
const maintenanceLogsRef = ref(null)
|
||||||
|
const iotBinderRef = ref(null)
|
||||||
|
// ✅ 定义新的 ref
|
||||||
|
const fileHistoryRef = ref(null)
|
||||||
|
|
||||||
const summary = computed(() => {
|
const showAddDialog = ref(false)
|
||||||
const activeDevices = rawData.value.filter(r => !r.is_hidden)
|
const isAdding = ref(false)
|
||||||
const errors = activeDevices.filter(r => r.statusType === 'error').length
|
const newDeviceForm = reactive({ name: '', site: '' })
|
||||||
const warnings = activeDevices.filter(r => r.statusType === 'warning').length
|
|
||||||
const hidden = rawData.value.filter(r => r.is_hidden).length
|
|
||||||
return { errorCount: errors, warningCount: warnings, hiddenCount: hidden }
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleLogout = () => {
|
// === 辅助函数:根据中文状态返回 Tag 颜色 ===
|
||||||
ElMessageBox.confirm('确定退出系统吗?', '提示', { type: 'warning' }).then(() => {
|
const getCardStatusType = (status) => {
|
||||||
localStorage.removeItem('isLoggedIn')
|
if (status === '在使用') return 'success' // 绿色
|
||||||
localStorage.removeItem('token')
|
if (status === '停机' || status === '销户') return 'danger' // 红色
|
||||||
router.push('/')
|
if (status === '停机保号' || status === '沉默期') return 'warning' // 黄色
|
||||||
ElMessage.success('已安全退出')
|
if (status === '测试期') return 'info' // 灰色
|
||||||
}).catch(() => {})
|
return 'info' // 默认
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === 核心数据处理逻辑 ===
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@ -222,46 +335,148 @@ const fetchData = async () => {
|
|||||||
const backendList = res.data.data || res.data
|
const backendList = res.data.data || res.data
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
|
||||||
let processedData = backendList.map(item => {
|
rawData.value = backendList.map(item => {
|
||||||
const isHidden = item.is_hidden === true || item.is_hidden === 1
|
const isHidden = item.is_hidden === true || item.is_hidden === 1
|
||||||
let diffDays = 0, diffHours = 0, isToday = false, validTime = false
|
const isBound = !!item.isBound
|
||||||
|
const isOrphanIoT = (item.source === 'iot_card')
|
||||||
|
const isWhitelist = !!item.is_whitelist
|
||||||
|
|
||||||
if (item.latest_time && item.latest_time !== 'N/A') {
|
// === 1. 智能时间解析与格式化 (增强版) ===
|
||||||
const cleanDateStr = item.latest_time.toString().replace(/_/g, '-')
|
let diffDays = 0, diffHours = 0, isToday = false, validTime = false
|
||||||
const d = new Date(cleanDateStr)
|
let timeStr = item.latest_time
|
||||||
if (!isNaN(d.getTime())) {
|
|
||||||
|
// 默认显示原始值,稍后如果解析成功则覆盖它
|
||||||
|
let displayTime = timeStr
|
||||||
|
|
||||||
|
if (timeStr && timeStr !== 'N/A') {
|
||||||
|
let d = null;
|
||||||
|
const str = timeStr.toString().trim();
|
||||||
|
|
||||||
|
// A. 尝试匹配标准格式: YYYY-MM-DD HH:mm:ss
|
||||||
|
const matchStandard = str.match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})$/);
|
||||||
|
|
||||||
|
if (matchStandard) {
|
||||||
|
d = new Date(
|
||||||
|
parseInt(matchStandard[1]),
|
||||||
|
parseInt(matchStandard[2]) - 1,
|
||||||
|
parseInt(matchStandard[3]),
|
||||||
|
parseInt(matchStandard[4]),
|
||||||
|
parseInt(matchStandard[5]),
|
||||||
|
parseInt(matchStandard[6])
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// B. 兜底逻辑:处理下划线或其他格式 (如 2026_01_14)
|
||||||
|
// 先把下划线全换成横杠
|
||||||
|
let cleanStr = str.replace(/_/g, '-')
|
||||||
|
// 如果长度不够(只有日期),补全时间,防止 new Date 解析成 UTC 0点导致时差
|
||||||
|
if (cleanStr.length <= 10) {
|
||||||
|
cleanStr += ' 00:00:00'
|
||||||
|
}
|
||||||
|
// 处理 T 分隔符 (ISO格式)
|
||||||
|
cleanStr = cleanStr.replace(' ', 'T')
|
||||||
|
d = new Date(cleanStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// C. 如果解析成功,强制重新生成统一的显示字符串
|
||||||
|
if (d && !isNaN(d.getTime())) {
|
||||||
validTime = true
|
validTime = true
|
||||||
const diffTime = now - d
|
|
||||||
const safeDiff = diffTime > 0 ? diffTime : 0
|
|
||||||
diffHours = safeDiff / (1000 * 60 * 60)
|
|
||||||
diffDays = safeDiff / (1000 * 60 * 60 * 24)
|
|
||||||
isToday = d.toDateString() === now.toDateString()
|
isToday = d.toDateString() === now.toDateString()
|
||||||
|
|
||||||
|
const diff = now - d
|
||||||
|
diffHours = (diff > 0 ? diff : 0) / (1000 * 3600)
|
||||||
|
diffDays = diffHours / 24
|
||||||
|
|
||||||
|
// 🌟 核心修改点:生成标准显示格式 YYYY-MM-DD HH:mm:ss 🌟
|
||||||
|
const y = d.getFullYear()
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||||
|
const dd = String(d.getDate()).padStart(2, '0')
|
||||||
|
const hh = String(d.getHours()).padStart(2, '0')
|
||||||
|
const mm = String(d.getMinutes()).padStart(2, '0')
|
||||||
|
const ss = String(d.getSeconds()).padStart(2, '0')
|
||||||
|
|
||||||
|
// 这行代码保证了无论后端发什么,前端都显示得很漂亮
|
||||||
|
displayTime = `${y}-${m}-${dd} ${hh}:${mm}:${ss}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let sortHours = diffHours;
|
// 2. 解析监测数值 (保留旧逻辑)
|
||||||
if (item.is_maintaining) sortHours = Number.MAX_SAFE_INTEGER;
|
let currentValueNum = 0
|
||||||
else if (item.status === 'offline' || item.status === '已离线') sortHours = 1000000000;
|
if (item.current_value) {
|
||||||
else if (!validTime) sortHours = 500000000;
|
const match = String(item.current_value).match(/(\d+(\.\d+)?)/)
|
||||||
|
if (match) currentValueNum = parseFloat(match[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 流量与过期计算
|
||||||
|
let trafficNum = 0
|
||||||
|
let rawTraffic = item.usedTraffic
|
||||||
|
if ((rawTraffic === undefined || rawTraffic === null) && item.json_data) {
|
||||||
|
try { const j = JSON.parse(item.json_data); rawTraffic = j.usedTraffic } catch(e) {}
|
||||||
|
}
|
||||||
|
if (rawTraffic) {
|
||||||
|
trafficNum = parseFloat(rawTraffic)
|
||||||
|
if (isNaN(trafficNum)) trafficNum = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 修改处:恢复流量超标警告判断,用于标黄 ===
|
||||||
|
const trafficWarning = (trafficNum >= 500 && !isWhitelist)
|
||||||
|
|
||||||
|
let expireWarning = false
|
||||||
|
if (item.stopDate && item.stopDate !== 'N/A') {
|
||||||
|
const stopD = new Date(item.stopDate.replace(/_/g, '-'))
|
||||||
|
if (!isNaN(stopD.getTime())) {
|
||||||
|
const daysLeft = (stopD - now) / (1000 * 3600 * 24)
|
||||||
|
if (daysLeft < 30) expireWarning = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 状态判定
|
||||||
let statusColor = '#67C23A', statusLabel = '正常', statusType = 'normal', statusLabelColor = '#fff'
|
let statusColor = '#67C23A', statusLabel = '正常', statusType = 'normal', statusLabelColor = '#fff'
|
||||||
|
let statusReason = ''
|
||||||
|
let sortWeight = diffHours
|
||||||
|
|
||||||
if (item.is_maintaining) {
|
if (item.is_maintaining) {
|
||||||
statusColor = '#409EFF'; statusLabel = '维修中'; statusType = 'maintenance';
|
statusColor = '#409EFF'; statusLabel = '维修中'; statusType = 'maintenance';
|
||||||
} else if ((item.status === 'offline' || item.status === '已离线') || (!validTime || diffDays > 7)) {
|
sortWeight = Number.MAX_SAFE_INTEGER;
|
||||||
statusLabel = '离线/滞后'; statusColor = '#F56C6C'; statusType = 'error';
|
} else if (!validTime || item.status === 'offline') {
|
||||||
|
statusLabel = '离线'; statusColor = '#F56C6C'; statusType = 'error';
|
||||||
|
statusReason = validTime ? '设备离线' : '暂无数据';
|
||||||
|
sortWeight = 80000000;
|
||||||
|
} else if (diffDays > 7) {
|
||||||
|
statusLabel = '严重滞后'; statusColor = '#F56C6C'; statusType = 'error';
|
||||||
|
statusReason = `滞后 ${Math.floor(diffDays)} 天`;
|
||||||
} else if (diffHours > 24) {
|
} else if (diffHours > 24) {
|
||||||
statusColor = '#E6A23C'; statusLabel = '数据滞后'; statusType = 'warning';
|
statusLabel = '滞后'; statusColor = '#E6A23C'; statusType = 'warning';
|
||||||
|
statusReason = `滞后 ${Math.floor(diffDays)} 天`;
|
||||||
|
} else if (expireWarning) {
|
||||||
|
statusLabel = '即将过期'; statusColor = '#E6A23C'; statusType = 'warning';
|
||||||
|
statusReason = `即将过期`;
|
||||||
|
sortWeight = 400;
|
||||||
} else if (!isToday) {
|
} else if (!isToday) {
|
||||||
statusColor = '#FAC858'; statusLabel = '昨日数据'; statusType = 'slight-warning'; statusLabelColor = '#333';
|
statusLabel = '昨日数据'; statusColor = '#FAC858'; statusType = 'slight-warning'; statusLabelColor = '#333';
|
||||||
|
statusReason = '非今日数据';
|
||||||
|
} else {
|
||||||
|
sortWeight = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...item, is_hidden: isHidden, diffDays, diffHours, sortHours, isToday,
|
...item,
|
||||||
statusColor, statusLabel, statusType, statusLabelColor, isEditingSite: false, tempSite: ''
|
latest_time: displayTime,
|
||||||
|
is_hidden: isHidden,
|
||||||
|
isOrphanIoT,
|
||||||
|
isBound,
|
||||||
|
isWhitelist,
|
||||||
|
diffDays, diffHours, sortWeight, isToday,
|
||||||
|
statusColor, statusLabel, statusType, statusLabelColor, statusReason,
|
||||||
|
isEditingSite: false, tempSite: '',
|
||||||
|
data_quality: item.data_quality || 'ok',
|
||||||
|
currentValueNum,
|
||||||
|
trafficNum,
|
||||||
|
trafficWarning,
|
||||||
|
expireWarning,
|
||||||
|
file_count: item.file_count || 0 // ✅ 绑定后端返回的文件数字段
|
||||||
}
|
}
|
||||||
})
|
}).sort((a, b) => b.sortWeight - a.sortWeight)
|
||||||
processedData.sort((a, b) => b.sortHours - a.sortHours)
|
|
||||||
rawData.value = processedData
|
|
||||||
lastCheckTime.value = new Date().toLocaleString()
|
lastCheckTime.value = new Date().toLocaleString()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ElMessage.error('获取数据失败')
|
ElMessage.error('获取数据失败')
|
||||||
@ -270,163 +485,154 @@ const fetchData = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeviceClick = (row) => { if (!row.is_hidden && dataMonitorRef.value) dataMonitorRef.value.open(row) }
|
// === 筛选逻辑 ===
|
||||||
const openLogCenter = (row) => { if (maintenanceLogsRef.value) maintenanceLogsRef.value.open(row ? { deviceName: row.name } : null) }
|
const summary = computed(() => {
|
||||||
const runManualMonitor = async () => {
|
const active = rawData.value.filter(r => !r.is_hidden && !r.isOrphanIoT)
|
||||||
runningTask.value = true
|
return {
|
||||||
try {
|
totalCount: active.length,
|
||||||
const res = await axios.post(`${API_BASE}/api/run_monitor`)
|
errorCount: active.filter(r => r.statusType === 'error').length,
|
||||||
ElMessage.success(res.data.message || '任务启动')
|
warningCount: active.filter(r => r.statusType === 'warning').length,
|
||||||
setTimeout(() => fetchData(), 3000)
|
hiddenCount: rawData.value.filter(r => r.is_hidden).length,
|
||||||
} catch (e) { ElMessage.warning('请求频繁') }
|
dataErrorCount: active.filter(r => r.data_quality === 'error' || r.data_quality === 'warning').length
|
||||||
finally { setTimeout(() => { runningTask.value = false }, 1000) }
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
const filteredData = computed(() => {
|
const filteredData = computed(() => {
|
||||||
return rawData.value.filter(item => {
|
return rawData.value.filter(item => {
|
||||||
|
// 隐藏孤儿卡
|
||||||
|
if (item.isOrphanIoT) return false
|
||||||
|
|
||||||
if (filters.status === 'hidden') return item.is_hidden
|
if (filters.status === 'hidden') return item.is_hidden
|
||||||
if (item.is_hidden) return false
|
if (item.is_hidden) return false
|
||||||
if (filters.status === 'abnormal') return (item.statusType === 'error' || item.statusType === 'warning' || item.statusType === 'slight-warning')
|
|
||||||
|
if (filters.status === 'abnormal') return ['error', 'warning', 'slight-warning'].includes(item.statusType)
|
||||||
|
if (filters.status === 'data_error') return ['error', 'warning'].includes(item.data_quality)
|
||||||
return true
|
return true
|
||||||
}).filter(item => !filters.keyword || item.name.toLowerCase().includes(filters.keyword.toLowerCase()))
|
}).filter(item => !filters.keyword || item.name.toLowerCase().includes(filters.keyword.toLowerCase()))
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleEditSite = (row) => {
|
// === 卡池总用量 ===
|
||||||
row.tempSite = row.install_site; row.isEditingSite = true
|
const totalUsageSum = computed(() => {
|
||||||
nextTick(() => {
|
return rawData.value.reduce((sum, item) => {
|
||||||
// 兼容性查找 input
|
if (item.source === 'iot_card') {
|
||||||
const inputs = document.querySelectorAll('.site-input-inner input')
|
return sum + (item.trafficNum || 0)
|
||||||
if (inputs.length > 0) inputs[inputs.length - 1].focus()
|
}
|
||||||
})
|
return sum
|
||||||
}
|
}, 0)
|
||||||
|
})
|
||||||
const saveSite = async (row) => {
|
|
||||||
if (!row.isEditingSite) return
|
// === 交互函数 ===
|
||||||
const oldVal = row.install_site; row.install_site = row.tempSite; row.isEditingSite = false
|
// ✅ 新增:打开历史记录弹窗
|
||||||
if (oldVal === row.tempSite) return
|
const openHistoryDialog = (row) => {
|
||||||
try {
|
if (row.is_hidden) return
|
||||||
await axios.post(`${API_BASE}/api/update_site`, { name: row.name, site: row.tempSite })
|
if (fileHistoryRef.value) {
|
||||||
ElMessage.success('已更新')
|
fileHistoryRef.value.open(row)
|
||||||
} catch (e) { row.install_site = oldVal; ElMessage.error('更新失败') }
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const handleMaintenanceBeforeChange = (row) => {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const newVal = !row.is_maintaining
|
|
||||||
axios.post(`${API_BASE}/api/toggle_maintenance`, { name: row.name, is_maintaining: newVal })
|
|
||||||
.then(() => { row.is_maintaining = newVal; fetchData(); ElMessage.success(newVal ? '已进入维修模式' : '已恢复'); resolve(true) })
|
|
||||||
.catch(() => { ElMessage.error('操作失败'); resolve(false) })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleHidden = async (row, targetState) => {
|
|
||||||
try {
|
|
||||||
await axios.post(`${API_BASE}/api/toggle_hidden`, { name: row.name, is_hidden: targetState })
|
|
||||||
row.is_hidden = targetState; fetchData(); ElMessage.success(targetState ? '已隐藏' : '已恢复')
|
|
||||||
} catch (e) { ElMessage.error('操作失败') }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleAddDeviceSubmit = async () => { if (!newDeviceForm.name) return; await axios.post(`${API_BASE}/api/add_device`, newDeviceForm); showAddDialog.value=false; fetchData() }
|
||||||
|
const handleDeviceClick = (row) => { if (!row.is_hidden && dataMonitorRef.value) dataMonitorRef.value.open(row) }
|
||||||
|
const openLogCenter = (row) => { if (maintenanceLogsRef.value) maintenanceLogsRef.value.open(row ? { deviceName: row.name } : null) }
|
||||||
|
const openIoTBinder = () => { if (iotBinderRef.value) iotBinderRef.value.open() }
|
||||||
|
const runManualMonitor = async () => { runningTask.value=true; await axios.post(`${API_BASE}/api/run_monitor`); setTimeout(()=>fetchData(), 3000); setTimeout(()=>runningTask.value=false, 1000) }
|
||||||
|
const handleEditSite = (row) => { row.tempSite = row.install_site; row.isEditingSite = true; nextTick(() => document.querySelector('.site-input-inner input')?.focus()) }
|
||||||
|
const saveSite = async (row) => { if(!row.isEditingSite)return; row.isEditingSite=false; await axios.post(`${API_BASE}/api/update_site`, {name:row.name, site:row.tempSite}); row.install_site=row.tempSite }
|
||||||
|
const handleMaintenanceBeforeChange = (row) => { return new Promise(r => { axios.post(`${API_BASE}/api/toggle_maintenance`, {name:row.name, is_maintaining:!row.is_maintaining}).then(() => {row.is_maintaining=!row.is_maintaining; fetchData(); r(true)}).catch(()=>r(false)) }) }
|
||||||
|
const toggleHidden = async (row, val) => { await axios.post(`${API_BASE}/api/toggle_hidden`, {name:row.name, is_hidden:val}); row.is_hidden=val; fetchData() }
|
||||||
|
const handleLogout = () => { localStorage.removeItem('token'); router.push('/') }
|
||||||
const formatDisplayName = (name) => name ? name.toUpperCase().replace(/_/g, ' ') : ''
|
const formatDisplayName = (name) => name ? name.toUpperCase().replace(/_/g, ' ') : ''
|
||||||
|
|
||||||
|
// 行高亮逻辑 (融合了数据异常的高亮)
|
||||||
const tableRowClassName = ({ row }) => {
|
const tableRowClassName = ({ row }) => {
|
||||||
if (row.is_hidden) return 'hidden-row'
|
if (row.is_hidden) return 'hidden-row'
|
||||||
if (row.statusType === 'maintenance') return 'maintenance-row'
|
if (row.data_quality === 'error') return 'data-error-row' // 优先显示数值严重错误
|
||||||
if (row.statusType === 'error') return 'error-row'
|
if (row.statusType === 'error') return 'error-row'
|
||||||
|
if (row.data_quality === 'warning') return 'data-warning-row' // 数值警告
|
||||||
if (row.statusType === 'warning') return 'warning-row'
|
if (row.statusType === 'warning') return 'warning-row'
|
||||||
|
if (row.statusType === 'maintenance') return 'maintenance-row'
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateDimensions = () => {
|
const updateDimensions = () => { windowHeight.value = window.innerHeight; windowWidth.value = window.innerWidth }
|
||||||
windowHeight.value = window.innerHeight
|
onMounted(() => { fetchData(); window.addEventListener('resize', updateDimensions) })
|
||||||
windowWidth.value = window.innerWidth
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
fetchData()
|
|
||||||
window.addEventListener('resize', updateDimensions)
|
|
||||||
})
|
|
||||||
onBeforeUnmount(() => window.removeEventListener('resize', updateDimensions))
|
onBeforeUnmount(() => window.removeEventListener('resize', updateDimensions))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.dashboard-container { padding: 10px; background: #f5f7fa; min-height: 100vh; box-sizing: border-box; }
|
.dashboard-container { padding: 10px; background: #f5f7fa; min-height: 100vh; box-sizing: border-box; }
|
||||||
.main-card { border-radius: 8px; overflow: visible; } /* overflow visible 确保下拉框不被遮挡 */
|
.main-card { border-radius: 8px; }
|
||||||
|
.header-row { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; }
|
||||||
/* 头部布局:默认 flex,手机端会自动调整 */
|
.sys-title { font-size: 20px; font-weight: 700; color: #303133; margin: 0; }
|
||||||
.header-row {
|
.left-panel { display: flex; align-items: center; gap: 10px; }
|
||||||
display: flex;
|
.header-actions { display: flex; gap: 8px; align-items: center; }
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
.sys-title { margin: 0; font-size: 20px; color: #303133; font-weight: 700; white-space: nowrap; }
|
|
||||||
.left-panel { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
|
||||||
.header-actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
|
||||||
|
|
||||||
/* 状态标签区 */
|
|
||||||
.status-summary { margin-bottom: 15px; display: flex; gap: 5px; flex-wrap: wrap; }
|
.status-summary { margin-bottom: 15px; display: flex; gap: 5px; flex-wrap: wrap; }
|
||||||
.legend-tag { font-weight: bold; border: none; font-size: 12px; }
|
.legend-tag { font-weight: bold; border: none; font-size: 12px; }
|
||||||
|
.toolbar { background: #fff; padding: 10px; border-radius: 6px; margin-bottom: 10px; border: 1px solid #e4e7ed; }
|
||||||
/* 工具栏区域 */
|
.filter-section { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||||
.toolbar {
|
.search-input { width: 220px; }
|
||||||
background: #fff;
|
:deep(.red-radio.is-active .el-radio-button__inner) { background-color: #F56C6C; border-color: #F56C6C; box-shadow: -1px 0 0 0 #F56C6C; }
|
||||||
padding: 10px;
|
:deep(.yellow-radio.is-active .el-radio-button__inner) { background-color: #E6A23C; border-color: #E6A23C; box-shadow: -1px 0 0 0 #E6A23C; }
|
||||||
border-radius: 6px;
|
:deep(.gray-radio.is-active .el-radio-button__inner) { background-color: #909399; border-color: #909399; box-shadow: -1px 0 0 0 #909399; }
|
||||||
margin-bottom: 10px;
|
:deep(.red-radio .el-radio-button__inner), :deep(.yellow-radio .el-radio-button__inner), :deep(.gray-radio .el-radio-button__inner) { color: #606266; }
|
||||||
border: 1px solid #e4e7ed;
|
.total-usage-tag { display: flex; align-items: center; gap: 5px; background: #f0f9ff; border: 1px solid #cce9ff; padding: 5px 12px; border-radius: 4px; color: #409EFF; margin-left: 5px; }
|
||||||
}
|
.total-usage-tag .label { font-size: 13px; font-weight: bold; }
|
||||||
.filter-section {
|
.total-usage-tag .value { font-size: 14px; font-weight: 800; }
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap; /* 允许换行 */
|
|
||||||
}
|
|
||||||
.search-input { width: 220px; transition: width 0.3s; }
|
|
||||||
|
|
||||||
/* 表格内元素 */
|
|
||||||
.device-name-wrapper { display: flex; align-items: center; gap: 5px; cursor: pointer; }
|
.device-name-wrapper { display: flex; align-items: center; gap: 5px; cursor: pointer; }
|
||||||
.device-name-wrapper:hover .device-name { color: #409EFF; text-decoration: underline; }
|
|
||||||
.device-name { font-weight: bold; font-size: 14px; color: #303133; }
|
.device-name { font-weight: bold; font-size: 14px; color: #303133; }
|
||||||
|
.device-name-wrapper:hover .device-name { color: #409EFF; text-decoration: underline; }
|
||||||
.text-deleted { text-decoration: line-through; color: #999; }
|
.text-deleted { text-decoration: line-through; color: #999; }
|
||||||
.status-text { font-size: 12px; margin-top: 4px; font-weight: bold; }
|
.status-text { font-size: 12px; margin-top: 4px; font-weight: bold; }
|
||||||
.error-text { color: #F56C6C; }
|
.error-text { color: #F56C6C; }
|
||||||
.warning-text { color: #E6A23C; }
|
.warning-text { color: #E6A23C; }
|
||||||
.success-text { color: #67C23A; }
|
.success-text { color: #67C23A; }
|
||||||
|
.slight-warning-text { color: #E6A23C; }
|
||||||
.maintenance-text { color: #409EFF; }
|
.maintenance-text { color: #409EFF; }
|
||||||
|
.display-cell { cursor: pointer; padding: 4px 0; display: flex; justify-content: space-between; }
|
||||||
.display-cell { cursor: pointer; padding: 4px 0; display: flex; align-items: center; justify-content: space-between; }
|
.edit-icon { color: #409EFF; }
|
||||||
.edit-icon { color: #409EFF; margin-left: 5px; }
|
|
||||||
|
|
||||||
/* 颜色行样式 */
|
|
||||||
:deep(.error-row) { background-color: #fef0f0 !important; }
|
:deep(.error-row) { background-color: #fef0f0 !important; }
|
||||||
:deep(.warning-row) { background-color: #fdf6ec !important; }
|
:deep(.warning-row) { background-color: #fdf6ec !important; }
|
||||||
:deep(.maintenance-row) { background-color: #f0f9ff !important; }
|
:deep(.maintenance-row) { background-color: #f0f9ff !important; }
|
||||||
:deep(.hidden-row) { background-color: #f4f4f5 !important; color: #909399; }
|
:deep(.hidden-row) { background-color: #f4f4f5 !important; color: #909399; }
|
||||||
|
/* 增加原有代码的数据异常背景色 */
|
||||||
|
:deep(.data-error-row) { background-color: #ffe6e6 !important; }
|
||||||
|
:deep(.data-warning-row) { background-color: #fffbe6 !important; }
|
||||||
|
|
||||||
|
/* ✅ 新增样式 */
|
||||||
|
.file-count-cell {
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 5px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.file-count-cell:hover {
|
||||||
|
background-color: #f0f9ff;
|
||||||
|
}
|
||||||
|
.file-count-cell:hover .el-tag {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
.history-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #909399;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
.file-count-cell:hover .history-icon {
|
||||||
|
opacity: 1;
|
||||||
|
color: #409EFF;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- 📱 移动端适配专用 CSS --- */
|
|
||||||
@media screen and (max-width: 768px) {
|
@media screen and (max-width: 768px) {
|
||||||
.dashboard-container { padding: 5px; }
|
.dashboard-container { padding: 5px; }
|
||||||
|
.left-panel, .header-actions { width: 100%; justify-content: space-between; margin-bottom: 5px; }
|
||||||
/* 标题和状态堆叠 */
|
.header-actions .el-button { flex: 1; margin: 0 2px; }
|
||||||
.left-panel { width: 100%; justify-content: space-between; margin-bottom: 5px; }
|
|
||||||
.sys-title { font-size: 18px; }
|
|
||||||
|
|
||||||
/* 按钮组撑满 */
|
|
||||||
.header-actions { width: 100%; justify-content: space-between; }
|
|
||||||
.header-actions .el-button { flex: 1; margin-left: 5px !important; margin-right: 5px !important; }
|
|
||||||
|
|
||||||
/* 分隔符 */
|
|
||||||
.divider-mobile { width: 1px; height: 20px; background: #dcdfe6; margin: 0 5px; }
|
|
||||||
|
|
||||||
/* 搜索框独占一行 */
|
|
||||||
.filter-section { justify-content: space-between; }
|
.filter-section { justify-content: space-between; }
|
||||||
.el-radio-group { width: 100%; display: flex; }
|
.el-radio-group { width: 100%; display: flex; }
|
||||||
.el-radio-button { flex: 1; }
|
.el-radio-button { flex: 1; }
|
||||||
:deep(.el-radio-button__inner) { width: 100%; padding: 8px 0; }
|
|
||||||
|
|
||||||
.search-input { width: 100%; margin-top: 5px; }
|
.search-input { width: 100%; margin-top: 5px; }
|
||||||
|
.total-usage-tag { width: 100%; justify-content: center; margin: 5px 0 0 0; }
|
||||||
/* 隐藏非关键按钮文字,节省空间 */
|
|
||||||
.el-button [class*="el-icon"] + span { display: inline-block; }
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
177
zhandianxinxi/光谱数据监控/src/views/FileHistoryDialog.vue
Normal file
177
zhandianxinxi/光谱数据监控/src/views/FileHistoryDialog.vue
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
:title="dialogTitle"
|
||||||
|
width="600px"
|
||||||
|
align-center
|
||||||
|
@closed="handleClose"
|
||||||
|
>
|
||||||
|
<div class="history-container">
|
||||||
|
<el-table
|
||||||
|
:data="paginatedData"
|
||||||
|
border
|
||||||
|
stripe
|
||||||
|
style="width: 100%"
|
||||||
|
v-loading="loading"
|
||||||
|
height="400"
|
||||||
|
>
|
||||||
|
<el-table-column prop="date" label="数据日期" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-icon><Calendar /></el-icon> {{ row.dateDisplay }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column prop="count" label="文件个数" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.count > 0 ? 'primary' : 'info'" effect="plain">
|
||||||
|
{{ row.count }} 个
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div class="pagination-wrapper">
|
||||||
|
<el-config-provider :locale="zhCn">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
:page-sizes="[10, 50, 100]"
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
:total="total"
|
||||||
|
background
|
||||||
|
/>
|
||||||
|
</el-config-provider>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<span class="dialog-footer">
|
||||||
|
<el-button @click="visible = false">关闭</el-button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import axios from 'axios'
|
||||||
|
import { Calendar } from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage, ElConfigProvider } from 'element-plus'
|
||||||
|
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const currentDeviceName = ref('')
|
||||||
|
const dialogTitle = ref('')
|
||||||
|
|
||||||
|
// 数据源
|
||||||
|
const allTableData = ref([]) // 存放去重、排序后的所有数据
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
const total = ref(0) // 这里的 total 是去重后的总天数
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.DEV ? 'http://127.0.0.1:5000' : ''
|
||||||
|
|
||||||
|
// 格式化工具:提取 YYYY-MM-DD
|
||||||
|
const getDayKey = (raw) => {
|
||||||
|
if (!raw) return ''
|
||||||
|
// 兼容 2026_02_03 和 2026-02-03 格式,且去掉时分秒
|
||||||
|
return raw.replace(/_/g, '-').split(' ')[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算属性:实现前端分页切片
|
||||||
|
// 这里的逻辑是:从“总数据”中,切出“当前页”需要显示的那几条
|
||||||
|
const paginatedData = computed(() => {
|
||||||
|
const start = (currentPage.value - 1) * pageSize.value
|
||||||
|
const end = start + pageSize.value
|
||||||
|
return allTableData.value.slice(start, end)
|
||||||
|
})
|
||||||
|
|
||||||
|
const open = (device) => {
|
||||||
|
if (!device || !device.name) return
|
||||||
|
currentDeviceName.value = device.name
|
||||||
|
dialogTitle.value = `📜 ${formatDisplayName(device.name)} - 历史文件记录`
|
||||||
|
visible.value = true
|
||||||
|
currentPage.value = 1
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
// 关键点:为了让前端能准确去重和排序,这里 limit 设得很大,意图拉取“全部”或“近期所有”数据
|
||||||
|
// 如果不拉全量数据,就无法保证跨页去重的准确性
|
||||||
|
const res = await axios.get(`${API_BASE}/api/device_history_list`, {
|
||||||
|
params: {
|
||||||
|
name: currentDeviceName.value,
|
||||||
|
page: 1,
|
||||||
|
limit: 1000 // 拉取足够多的数据在前端处理
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.data.code === 200) {
|
||||||
|
const rawList = res.data.data || []
|
||||||
|
|
||||||
|
// 1. 分组去重:同一天取 count 最大的
|
||||||
|
const dateMap = new Map()
|
||||||
|
|
||||||
|
rawList.forEach(item => {
|
||||||
|
const dayStr = getDayKey(item.date)
|
||||||
|
if (!dayStr) return
|
||||||
|
|
||||||
|
if (dateMap.has(dayStr)) {
|
||||||
|
const exist = dateMap.get(dayStr)
|
||||||
|
// 如果当前数据的 count 比已记录的大,就替换掉
|
||||||
|
if (item.count > exist.count) {
|
||||||
|
// 保留原始 item,并附加格式化好的日期方便展示
|
||||||
|
dateMap.set(dayStr, { ...item, dateDisplay: dayStr })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dateMap.set(dayStr, { ...item, dateDisplay: dayStr })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2. 转为数组
|
||||||
|
const processedList = Array.from(dateMap.values())
|
||||||
|
|
||||||
|
// 3. 强制排序:按日期字符串降序 (2026-02-03 -> 2026-02-01)
|
||||||
|
processedList.sort((a, b) => {
|
||||||
|
return b.dateDisplay.localeCompare(a.dateDisplay)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 4. 赋值
|
||||||
|
allTableData.value = processedList
|
||||||
|
|
||||||
|
// 5. 修正 Total:现在的 Total 是“有多少个不同的日期”,而不是后端的 raw total
|
||||||
|
total.value = processedList.length
|
||||||
|
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.data.message || '获取历史记录失败')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
ElMessage.error('网络请求异常')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
allTableData.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDisplayName = (name) => name ? name.toUpperCase().replace(/_/g, ' ') : ''
|
||||||
|
|
||||||
|
defineExpose({ open })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.history-container {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
.pagination-wrapper {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
346
zhandianxinxi/光谱数据监控/src/views/IoTDeviceBinder.vue
Normal file
346
zhandianxinxi/光谱数据监控/src/views/IoTDeviceBinder.vue
Normal file
@ -0,0 +1,346 @@
|
|||||||
|
<template>
|
||||||
|
<el-config-provider :locale="zhCn">
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
title="🔗 IoT 卡片管理与绑定"
|
||||||
|
width="1000px"
|
||||||
|
top="8vh"
|
||||||
|
destroy-on-close
|
||||||
|
append-to-body
|
||||||
|
@close="handleClose"
|
||||||
|
>
|
||||||
|
<div class="binder-container">
|
||||||
|
|
||||||
|
<div class="tips-alert">
|
||||||
|
<el-alert title="功能说明" type="info" show-icon :closable="false">
|
||||||
|
<template #default>
|
||||||
|
<div>1. <b>白名单置顶:</b> 开启白名单的卡片会自动排在列表最上方。</div>
|
||||||
|
<div>2. <b>绑定要求:</b> 目标设备必须是系统中已存在的设备。</div>
|
||||||
|
<div>3. <b>流量警告:</b> 白名单卡片即使流量超过 500M 也不会触发黄色警告。</div>
|
||||||
|
</template>
|
||||||
|
</el-alert>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-radio-group v-model="filterStatus" @change="filterList" style="margin-right: 15px;">
|
||||||
|
<el-radio-button label="all">全部卡片</el-radio-button>
|
||||||
|
<el-radio-button label="unbound">待绑定 (孤儿卡)</el-radio-button>
|
||||||
|
<el-radio-button label="bound">已绑定</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
|
||||||
|
<el-input
|
||||||
|
v-model="keyword"
|
||||||
|
placeholder="搜索 ICCID 或 设备名..."
|
||||||
|
style="width: 250px"
|
||||||
|
clearable
|
||||||
|
@input="filterList"
|
||||||
|
>
|
||||||
|
<template #prefix><el-icon><Search /></el-icon></template>
|
||||||
|
</el-input>
|
||||||
|
|
||||||
|
<el-button type="primary" plain icon="Refresh" @click="fetchIoTDevices" style="margin-left: auto;">刷新数据</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table
|
||||||
|
:data="displayList"
|
||||||
|
border
|
||||||
|
stripe
|
||||||
|
v-loading="loading"
|
||||||
|
height="500"
|
||||||
|
style="width: 100%;"
|
||||||
|
>
|
||||||
|
<el-table-column label="ICCID (卡号)" prop="iccid" width="240" sortable>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="iccid-text">{{ row.iccid }}</span>
|
||||||
|
<el-tag v-if="row.tag" size="small" type="info" style="margin-left:5px">{{ row.tag }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="关联设备状态" min-width="300">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div v-if="row.isEditing" class="edit-cell">
|
||||||
|
<el-autocomplete
|
||||||
|
v-model="row.targetDeviceName"
|
||||||
|
:fetch-suggestions="querySearchDevice"
|
||||||
|
placeholder="请输入并选择设备..."
|
||||||
|
size="small"
|
||||||
|
style="width: 180px;"
|
||||||
|
@select="handleSelectDevice"
|
||||||
|
@keyup.enter="saveBinding(row)"
|
||||||
|
ref="nameInputRef"
|
||||||
|
:trigger-on-focus="true"
|
||||||
|
clearable
|
||||||
|
>
|
||||||
|
<template #default="{ item }">
|
||||||
|
<div class="suggestion-item">
|
||||||
|
<span class="device-name-highlight">{{ item.value }}</span>
|
||||||
|
<span class="suggestion-site">{{ item.site }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-autocomplete>
|
||||||
|
<el-button type="success" size="small" icon="Check" circle @click="saveBinding(row)" :loading="row.saving" />
|
||||||
|
<el-button type="info" size="small" icon="Close" circle @click="cancelEdit(row)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="row.boundDeviceName" class="bound-cell">
|
||||||
|
<el-tag type="success" effect="plain" class="bound-tag">
|
||||||
|
<el-icon><Link /></el-icon> 已关联: {{ row.boundDeviceName }}
|
||||||
|
</el-tag>
|
||||||
|
<el-button type="primary" link size="small" icon="Edit" @click="startEdit(row)">修改</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="unbound-cell" @click="startEdit(row)">
|
||||||
|
<span class="placeholder-text">🔴 尚未关联,点击绑定...</span>
|
||||||
|
<el-icon class="edit-icon"><EditPen /></el-icon>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="当月用量" width="120" align="right" sortable prop="usedTrafficNum">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span :style="{ fontWeight: row.usedTrafficNum >= 500 && !row.isWhitelist ? 'bold' : 'normal', color: row.usedTrafficNum >= 500 && !row.isWhitelist ? '#E6A23C' : '#606266' }">
|
||||||
|
{{ row.usedTraffic }} M
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="白名单" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-switch
|
||||||
|
v-model="row.isWhitelist"
|
||||||
|
active-text="是"
|
||||||
|
inactive-text="否"
|
||||||
|
inline-prompt
|
||||||
|
:loading="row.whitelistLoading"
|
||||||
|
:before-change="() => toggleWhitelist(row)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<span class="dialog-footer">
|
||||||
|
<el-button @click="visible = false">关 闭</el-button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</el-config-provider>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, nextTick } from 'vue'
|
||||||
|
import axios from 'axios'
|
||||||
|
import { ElMessage, ElConfigProvider } from 'element-plus'
|
||||||
|
import { Search, Refresh, EditPen, Check, Close, Link } from '@element-plus/icons-vue'
|
||||||
|
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.DEV ? 'http://127.0.0.1:5000' : ''
|
||||||
|
const visible = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const fullSimList = ref([])
|
||||||
|
const displayList = ref([])
|
||||||
|
const allDeviceNames = ref([])
|
||||||
|
const keyword = ref('')
|
||||||
|
const filterStatus = ref('all')
|
||||||
|
|
||||||
|
const emit = defineEmits(['update-success'])
|
||||||
|
|
||||||
|
const open = () => {
|
||||||
|
visible.value = true
|
||||||
|
filterStatus.value = 'all'
|
||||||
|
fetchIoTDevices()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 本地排序逻辑
|
||||||
|
const applySort = () => {
|
||||||
|
fullSimList.value.sort((a, b) => {
|
||||||
|
// 1. 白名单 (True 在前)
|
||||||
|
if (a.isWhitelist !== b.isWhitelist) {
|
||||||
|
return a.isWhitelist ? -1 : 1
|
||||||
|
}
|
||||||
|
// 2. 绑定状态
|
||||||
|
const aBound = !!a.boundDeviceName
|
||||||
|
const bBound = !!b.boundDeviceName
|
||||||
|
if (aBound !== bBound) {
|
||||||
|
return aBound ? -1 : 1
|
||||||
|
}
|
||||||
|
// 3. 默认顺序
|
||||||
|
return a.iccid.localeCompare(b.iccid)
|
||||||
|
})
|
||||||
|
// 重新执行过滤以应用排序到显示列表
|
||||||
|
filterList()
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchIoTDevices = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await axios.get(`${API_BASE}/api/devices_overview`)
|
||||||
|
const allData = res.data.data || []
|
||||||
|
|
||||||
|
const iccidToDeviceMap = {}
|
||||||
|
const realDevices = []
|
||||||
|
|
||||||
|
allData.forEach(d => {
|
||||||
|
if (d.source !== 'iot_card') {
|
||||||
|
realDevices.push({ value: d.name, site: d.install_site || '未填地点' })
|
||||||
|
if (d.bound_iccid) {
|
||||||
|
iccidToDeviceMap[d.bound_iccid] = d.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
allDeviceNames.value = realDevices
|
||||||
|
|
||||||
|
const cards = allData.filter(d => d.source === 'iot_card')
|
||||||
|
|
||||||
|
const tempList = cards.map(c => {
|
||||||
|
const iccid = c.name
|
||||||
|
let j = {}
|
||||||
|
try { j = JSON.parse(c.json_data || '{}') } catch(e){}
|
||||||
|
const ownerDevice = iccidToDeviceMap[iccid] || ''
|
||||||
|
|
||||||
|
let traffic = c.usedTraffic || j.usedTraffic || '0'
|
||||||
|
let trafficNum = parseFloat(traffic) || 0
|
||||||
|
|
||||||
|
let isW = false
|
||||||
|
if (j.is_whitelist !== undefined) isW = j.is_whitelist
|
||||||
|
if (c.is_whitelist !== undefined) isW = c.is_whitelist
|
||||||
|
|
||||||
|
return {
|
||||||
|
iccid: iccid,
|
||||||
|
tag: j.tag || '',
|
||||||
|
usedTraffic: traffic,
|
||||||
|
usedTrafficNum: trafficNum,
|
||||||
|
boundDeviceName: ownerDevice,
|
||||||
|
targetDeviceName: ownerDevice,
|
||||||
|
status: c.status || 'offline',
|
||||||
|
isWhitelist: !!isW,
|
||||||
|
isEditing: false,
|
||||||
|
saving: false,
|
||||||
|
whitelistLoading: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
fullSimList.value = tempList
|
||||||
|
applySort()
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error('加载失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterList = () => {
|
||||||
|
let list = fullSimList.value
|
||||||
|
if (filterStatus.value === 'bound') list = list.filter(i => i.boundDeviceName)
|
||||||
|
if (filterStatus.value === 'unbound') list = list.filter(i => !i.boundDeviceName)
|
||||||
|
if (keyword.value) {
|
||||||
|
const k = keyword.value.toLowerCase()
|
||||||
|
list = list.filter(i => i.iccid.toLowerCase().includes(k) || (i.boundDeviceName && i.boundDeviceName.toLowerCase().includes(k)))
|
||||||
|
}
|
||||||
|
displayList.value = list
|
||||||
|
}
|
||||||
|
|
||||||
|
const startEdit = (row) => {
|
||||||
|
displayList.value.forEach(i => i.isEditing = false)
|
||||||
|
row.targetDeviceName = row.boundDeviceName || ''
|
||||||
|
row.isEditing = true
|
||||||
|
nextTick(() => document.querySelector('.edit-cell input')?.focus())
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelEdit = (row) => {
|
||||||
|
row.isEditing = false
|
||||||
|
row.targetDeviceName = row.boundDeviceName
|
||||||
|
}
|
||||||
|
|
||||||
|
const querySearchDevice = (qs, cb) => {
|
||||||
|
const res = qs ? allDeviceNames.value.filter(i => i.value.toLowerCase().indexOf(qs.toLowerCase()) > -1) : allDeviceNames.value
|
||||||
|
cb(res)
|
||||||
|
}
|
||||||
|
const handleSelectDevice = (item) => {}
|
||||||
|
|
||||||
|
const saveBinding = async (row) => {
|
||||||
|
const target = row.targetDeviceName
|
||||||
|
if (!target) return ElMessage.warning('请输入设备名')
|
||||||
|
const exists = allDeviceNames.value.some(d => d.value === target)
|
||||||
|
if (!exists) return ElMessage.error('设备不存在,请先新建设备')
|
||||||
|
|
||||||
|
row.saving = true
|
||||||
|
try {
|
||||||
|
await axios.post(`${API_BASE}/api/bind_device_card`, { iccid: row.iccid, device_name: target })
|
||||||
|
ElMessage.success('绑定成功')
|
||||||
|
row.boundDeviceName = target
|
||||||
|
row.isEditing = false
|
||||||
|
emit('update-success')
|
||||||
|
fetchIoTDevices()
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.response?.data?.message || '失败')
|
||||||
|
} finally {
|
||||||
|
row.saving = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
// [核心修复] 白名单切换逻辑
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
const toggleWhitelist = (row) => {
|
||||||
|
// 设置 Loading,防止重复点击
|
||||||
|
row.whitelistLoading = true
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// 预期的新状态 (当前状态取反)
|
||||||
|
const targetVal = !row.isWhitelist
|
||||||
|
|
||||||
|
axios.post(`${API_BASE}/api/toggle_whitelist`, {
|
||||||
|
iccid: row.iccid,
|
||||||
|
is_whitelist: targetVal
|
||||||
|
}).then(() => {
|
||||||
|
// 1. API 成功
|
||||||
|
ElMessage.success(targetVal ? '已加入白名单' : '已移出白名单')
|
||||||
|
|
||||||
|
// 2. 触发父组件更新 (Dashboard 计数等)
|
||||||
|
emit('update-success')
|
||||||
|
|
||||||
|
// 3. 关键:resolve(true) 会告诉 el-switch 组件可以切换视觉状态了
|
||||||
|
// 此时 Vue 会自动更新 v-model (即 row.isWhitelist) 的值
|
||||||
|
// 我们不需要在这里手动写 row.isWhitelist = targetVal
|
||||||
|
resolve(true)
|
||||||
|
|
||||||
|
row.whitelistLoading = false
|
||||||
|
|
||||||
|
// 4. 延迟触发排序
|
||||||
|
// 为什么要延迟?
|
||||||
|
// A. 等待 el-switch 动画播放
|
||||||
|
// B. 确保 v-model 的值已经确实更新到了 row 对象上
|
||||||
|
setTimeout(() => {
|
||||||
|
applySort()
|
||||||
|
}, 300)
|
||||||
|
|
||||||
|
}).catch(() => {
|
||||||
|
// 失败:El-switch 保持原状
|
||||||
|
ElMessage.error('操作失败')
|
||||||
|
row.whitelistLoading = false
|
||||||
|
reject(new Error('Failed'))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => { visible.value = false }
|
||||||
|
defineExpose({ open })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.binder-container { padding: 5px; }
|
||||||
|
.toolbar { display: flex; align-items: center; margin-bottom: 15px; }
|
||||||
|
.iccid-text { font-family: monospace; font-weight: bold; color: #606266; }
|
||||||
|
.bound-cell { display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.unbound-cell { cursor: pointer; color: #909399; font-style: italic; font-size: 13px; display:flex; align-items:center; justify-content:space-between; padding:4px 8px; border:1px dashed transparent; border-radius:4px; }
|
||||||
|
.unbound-cell:hover { background-color: #f0f9ff; border-color: #a0cfff; color: #409EFF; }
|
||||||
|
.edit-cell { display: flex; align-items: center; gap: 5px; }
|
||||||
|
.suggestion-item { display: flex; justify-content: space-between; width: 100%; }
|
||||||
|
.suggestion-site { color: #999; font-size: 12px; }
|
||||||
|
.empty-tip { text-align: center; color: #909399; padding: 40px; }
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user