12 Commits

14 changed files with 2126 additions and 483 deletions

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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}")

View File

@ -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)

View File

@ -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

View File

@ -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):

View File

@ -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 # 废弃旧逻辑
} }

View File

@ -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:

View 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

View File

@ -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>

View File

@ -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>

View 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>

View 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>