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():
"""获取 Vue 静态资源 dist 的路径"""
"""获取 Vue 静态资源 web_dist 的路径"""
if getattr(sys, 'frozen', False):
# PyInstaller 打包时,资源文件会被解压到 sys._MEIPASS 临时目录
# 我们需要在打包命令中指定 --add-data "dist;dist"
return os.path.join(sys._MEIPASS, 'dist')
# 我们需要在打包命令中指定 --add-data "web_dist;web_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 初始化 ---
# static_folder 指向 Vue 打包后的 dist 目录
# static_folder 指向 Vue 打包后的 web_dist 目录
# static_url_path='' 表示静态文件不需要 /static 前缀
dist_folder = get_static_path()
app = Flask(__name__, static_folder=dist_folder, static_url_path='')
@ -303,7 +303,7 @@ def serve_index():
@app.route('/<path:path>')
def serve_static_files(path):
# 尝试在 dist 目录寻找文件 (css, js, icons)
# 尝试在 web_dist 目录寻找文件 (css, js, icons)
file_path = os.path.join(app.static_folder, path)
if os.path.exists(file_path):
return send_from_directory(app.static_folder, path)
@ -323,5 +323,5 @@ if __name__ == "__main__":
scheduler.start()
# Host='0.0.0.0' 允许外部IP访问
# Port=5000 (确保 Windows 防火墙开放了此端口)
print("应用正在启动... 请确保 dist 文件夹与脚本/exe 同级或已被打包")
print("应用正在启动... 请确保 web_dist 文件夹与脚本/exe 同级或已被打包")
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():
"""获取 Vue 静态资源 dist 的路径"""
"""获取 Vue 静态资源 web_dist 的路径"""
if getattr(sys, 'frozen', False):
# PyInstaller 打包时,资源文件会被解压到 sys._MEIPASS 临时目录
# 我们需要在打包命令中指定 --add-data "dist;dist"
return os.path.join(sys._MEIPASS, 'dist')
# 我们需要在打包命令中指定 --add-data "web_dist;web_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 初始化 ---
# static_folder 指向 Vue 打包后的 dist 目录
# static_folder 指向 Vue 打包后的 web_dist 目录
# static_url_path='' 表示静态文件不需要 /static 前缀
dist_folder = get_static_path()
app = Flask(__name__, static_folder=dist_folder, static_url_path='')
@ -303,7 +303,7 @@ def serve_index():
@app.route('/<path:path>')
def serve_static_files(path):
# 尝试在 dist 目录寻找文件 (css, js, icons)
# 尝试在 web_dist 目录寻找文件 (css, js, icons)
file_path = os.path.join(app.static_folder, path)
if os.path.exists(file_path):
return send_from_directory(app.static_folder, path)
@ -323,5 +323,5 @@ if __name__ == "__main__":
scheduler.start()
# Host='0.0.0.0' 允许外部IP访问
# Port=5000 (确保 Windows 防火墙开放了此端口)
print("应用正在启动... 请确保 dist 文件夹与脚本/exe 同级或已被打包")
print("应用正在启动... 请确保 web_dist 文件夹与脚本/exe 同级或已被打包")
app.run(host='0.0.0.0', port=5000, debug=False, use_reloader=False)

View File

@ -1,9 +1,11 @@
import os
import sys
import json
import logging
import mimetypes
import logging
from datetime import datetime
import pytz
from flask import Flask, send_from_directory, jsonify
from flask_cors import CORS
from flask_apscheduler import APScheduler
@ -12,180 +14,258 @@ from flask_apscheduler import APScheduler
# ✅ 1. 核心模块引用
# ==============================================================================
try:
# 数据库实例 (在根目录 extensions.py 中)
from config import Config
from extensions import db
# 数据模型 (在根目录 models.py 中)
from models import Device, DeviceHistory, MaintenanceLog
# 核心业务逻辑 (在 services/core.py 中)
from models import Device, DeviceHistory
# 引入核心爬虫调度
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:
from routes.api import api_bp as device_bp
from routes.api import calculate_offset
except ImportError:
# 兜底逻辑,防止缺失 calculate_offset 导致崩溃
def calculate_offset(target_time):
return 0
from routes.api import device_bp
# 工具函数 (在 routes/api.py 中)
from routes.api import calculate_offset
except ImportError as e:
print(f"严重错误: 模块导入失败。请检查文件名和变量名。详细信息: {e}")
print(f"系统路径: {sys.path}")
print(f"[启动错误] 模块导入失败: {e}")
sys.exit(1)
# ==============================================================================
# 2. 智能路径配置
# ==============================================================================
RESOURCE_BASE = Config.BASE_DIR
INSTANCE_PATH = Config.INSTANCE_DIR
# ==============================================================================
# 2. 路径计算模块 (兼容 PyInstaller 打包)
# ==============================================================================
def get_base_path():
"""获取运行时基准路径,兼容开发环境和打包环境"""
def find_static_folder(base_path):
"""
全能路径搜寻逻辑,适配 PyInstaller 打包环境
"""
if getattr(sys, 'frozen', False):
if hasattr(sys, '_MEIPASS'):
return sys._MEIPASS # --onefile 模式
else:
return os.path.dirname(os.path.abspath(sys.executable)) # --onedir 模式
else:
return os.path.abspath(os.path.dirname(__file__))
mei_path = os.path.join(sys._MEIPASS, 'web_dist')
if os.path.exists(os.path.join(mei_path, 'index.html')):
return mei_path
internal_path = os.path.join(base_path, '_internal', 'web_dist')
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 = 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 类型缺失导致网页白屏的问题
STATIC_FOLDER = find_static_folder(RESOURCE_BASE)
mimetypes.add_type('application/javascript', '.js')
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):
"""定时任务具体执行逻辑"""
"""
[关键修复]
1. 使用 app.app_context() 确保线程中有 Flask 上下文
2. 使用 db.session.remove() 强制清理旧连接
3. 使用 db.session.merge() 确保对象状态被正确追踪
4. 增加详细日志,对比爬虫返回的数据与入库行为
"""
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:
print("❌ 错误: 无法加载爬虫核心模块 (execute_monitor_task is Missing)")
print("❌ 错误: execute_monitor_task 未定义")
return
try:
# 执行爬
# B. 执行爬
task_result = execute_monitor_task()
if not task_result:
print("⚠️ [定时任务] 爬虫未获取到数据")
print("⚠️ [警告] 爬虫执行完毕,但返回空数据")
return
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:
d_name = item.get('name')
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()
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.flush()
db.session.flush() # 立即获取 ID
# 更新字段
device.status = item.get('status')
device.current_value = item.get('value')
device.latest_time = item.get('target_time')
device.status = raw_status
device.current_value = raw_value
device.latest_time = target_date
device.check_time = current_time
device.json_data = json.dumps(item.get('raw_json', {}), ensure_ascii=False)
device.offset = calculate_offset(item.get('target_time'))
device.file_count = f_count
# 写入历史
db.session.add(DeviceHistory(
# 计算 Offset
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,
status=device.status,
result_data=device.current_value,
data_time=item.get('target_time'),
status=raw_status,
result_data=raw_value,
data_time=target_date,
file_count=f_count,
json_data=device.json_data
))
count += 1
)
db.session.add(history)
stats['history'] += 1
# C. 提交事务
db.session.commit()
print(f"✅ [定时任务] 成功更新 {count} 台设备状态")
print(f"✅ [入库成功] 设备更新: {stats['updated']} | 历史追加: {stats['history']}")
except Exception as e:
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 应用工厂
# ==============================================================================
def create_app():
# 🔴 关键修复:移除了 static_url_path=''
# 这样 Flask 就不会强制拦截所有根路径请求,让下面的 serve_static 有机会处理 /dashboard
app = Flask(__name__, static_folder=STATIC_FOLDER)
print(f"🔍 [前端路径锁定] {STATIC_FOLDER}")
app = Flask(__name__, static_folder=STATIC_FOLDER, instance_path=INSTANCE_PATH)
CORS(app)
# 确保 instance 目录存在
if not os.path.exists(INSTANCE_FOLDER):
os.makedirs(INSTANCE_FOLDER, exist_ok=True)
if not os.path.exists(app.instance_path):
os.makedirs(app.instance_path, exist_ok=True)
app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{DB_PATH}'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['SCHEDULER_API_ENABLED'] = True
app.config.from_object(Config)
# 初始化数据库
# 初始化 DB
db.init_app(app)
# 初始化定时任务
# 初始化调度器
scheduler = APScheduler()
scheduler.init_app(app)
scheduler.start()
# 添加定时任务 (每天 10:00)
# --- 添加定时任务 ---
# 注意:这里我们传递 [app] 作为参数,确保 job 函数内能获取到 app 上下文
scheduler.add_job(
id='daily_monitor_task',
func=auto_monitor_job,
args=[app],
trigger='cron',
hour=10,
minute=0
hour=17,
minute=00,
second=00,
misfire_grace_time=3600,
timezone=pytz.timezone('Asia/Shanghai')
)
print(f"📅 定时任务已锁定: 每天北京时间 17:00 执行")
# 注册蓝图
app.register_blueprint(device_bp)
# -------------------------------------------------
# 前端路由支持 (Vue History Mode)
# -------------------------------------------------
@app.route('/api/force_run')
def force_run_task():
"""手动触发接口:复用同一个 auto_monitor_job 函数,确保逻辑一致"""
auto_monitor_job(app)
return jsonify({'code': 200, 'msg': '手动触发成功,请查看服务器日志'})
@app.route('/')
def serve_index():
if not os.path.exists(os.path.join(app.static_folder, 'index.html')):
return "❌ 错误: 前端文件丢失 (web_dist/index.html)", 404
return send_from_directory(app.static_folder, 'index.html')
try:
return send_from_directory(app.static_folder, 'index.html')
except Exception:
return "Frontend Error", 404
@app.route('/<path: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)
if os.path.exists(file_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')
with app.app_context():
@ -196,7 +276,8 @@ def create_app():
if __name__ == '__main__':
app = create_app()
# 生产环境/打包环境通常设为 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)

View File

@ -5,30 +5,44 @@ import sys
def get_base_path():
"""获取运行时路径 (兼容打包后的 exe 和开发环境)"""
if getattr(sys, 'frozen', False):
# 打包后exe 所在目录
return os.path.dirname(sys.executable)
# 开发时:当前文件所在目录
return os.path.dirname(os.path.abspath(__file__))
def get_static_path():
"""获取 dist 静态资源路径"""
"""获取 web_dist 静态资源路径"""
if getattr(sys, 'frozen', False):
return os.path.join(sys._MEIPASS, 'dist')
return os.path.join(os.path.dirname(os.path.abspath(__file__)), 'dist')
return os.path.join(sys._MEIPASS, 'web_dist')
return os.path.join(os.path.dirname(os.path.abspath(__file__)), 'web_dist')
class Config:
BASE_DIR = get_base_path()
# 数据库路径:保存在运行目录下,文件名为 monitor_data.db
# Windows 下路径需要注意转义,这里使用 os.path.join 最安全
SQLALCHEMY_DATABASE_URI = f'sqlite:///{os.path.join(BASE_DIR, "monitor_data.db")}'
# 规范化 instance 目录
INSTANCE_DIR = os.path.join(BASE_DIR, 'instance')
# 确保 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
# --- 定时任务配置 ---
SCHEDULER_API_ENABLED = True
SCHEDULER_TIMEZONE = "Asia/Shanghai" # 👈 必须加这个,否则 APScheduler 可能报错
SCHEDULER_TIMEZONE = "Asia/Shanghai"
# --- 爬虫配置 (Service层会读取这里) ---
# --- 爬虫配置 ---
CRAWLER_CONFIG = {
"106": {
"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",
"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
import json
from extensions import db
class Device(db.Model):
__tablename__ = 'devices'
@ -18,11 +19,17 @@ class Device(db.Model):
reason = db.Column(db.String(255))
offset = db.Column(db.String(50))
# ✅ 新增字段:文件数量
file_count = db.Column(db.Integer, default=0)
# 手动录入字段受保护run_monitor 不主动覆盖)
install_site = db.Column(db.String(100), default="")
is_maintaining = 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):
# 统一状态映射逻辑
api_status = 'offline' if self.status in ['离线', '异常', '已离线'] else 'online'
@ -38,9 +45,12 @@ class Device(db.Model):
'install_site': self.install_site or '',
'is_maintaining': self.is_maintaining,
'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):
__tablename__ = 'device_history'
@ -53,8 +63,12 @@ class DeviceHistory(db.Model):
json_data = db.Column(db.Text)
file_path = db.Column(db.String(255))
# ✅ 新增字段:历史记录文件数量
file_count = db.Column(db.Integer, default=0)
recorded_at = db.Column(db.DateTime, default=datetime.now)
class MaintenanceLog(db.Model):
__tablename__ = 'maintenance_logs'
id = db.Column(db.Integer, primary_key=True)

View File

@ -1,5 +1,4 @@
import os
import shutil
import json
import re
from datetime import datetime
@ -8,18 +7,219 @@ from sqlalchemy import desc, or_
from extensions import db
from models import Device, DeviceHistory, MaintenanceLog
# 尝试导入爬虫模块
# =========================================================
# 模块动态导入 (防止循环引用或缺失报错)
# =========================================================
try:
from services.core import execute_monitor_task
except ImportError:
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')
# =======================
# 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'])
def login():
@ -34,24 +234,105 @@ def login():
'token': 'super-admin-token-2026',
'user': {'username': 'admin', 'role': 'administrator'}
})
return jsonify({'code': 401, 'message': '用户名或密码错误'}), 401
# =======================
# 1. 设备概览与详情接口
# =======================
@api_bp.route('/devices_overview', methods=['GET'])
def devices_overview():
try:
devices = Device.query.all()
data_list = [d.to_dict() for d in devices]
# A. 获取 IoT卡表
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})
except Exception as e:
return jsonify({'code': 500, 'message': str(e)})
# =========================================================
# 2. 历史数据接口
# =========================================================
@api_bp.route('/device_data_by_date', methods=['GET'])
def device_data_by_date():
name = request.args.get('name')
@ -65,14 +346,16 @@ def device_data_by_date():
return jsonify({'code': 404, 'message': 'Device not found'}), 404
content = None
query_date = date_str.replace('_', '-')
history_record = DeviceHistory.query.filter(
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()
if history_record:
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
if content:
@ -85,195 +368,329 @@ def device_data_by_date():
return jsonify({'code': 404, 'message': 'No data for this date'}), 404
# =======================
# 2. 维修日志接口
# =======================
@api_bp.route('/device_data_by_date_stub', methods=['GET'])
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'])
def get_logs():
def get_logs_list():
keyword = request.args.get('keyword', '')
start_date = request.args.get('start_date')
end_date = request.args.get('end_date')
query = MaintenanceLog.query
if keyword:
kw = f"%{keyword}%"
query = query.filter(or_(
MaintenanceLog.device_name.like(kw),
MaintenanceLog.engineer.like(kw),
MaintenanceLog.location.like(kw),
MaintenanceLog.content.like(kw)
MaintenanceLog.content.like(kw),
MaintenanceLog.engineer.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()
return jsonify({'code': 200, 'data': [l.to_dict() for l in logs]})
@api_bp.route('/logs/add', methods=['POST'])
def add_log():
def add_log_entry():
data = request.get_json()
try:
new_log = MaintenanceLog(
device_name=data.get('device_name', '未知设备'),
device_name=data.get('device_name', ''),
engineer=data.get('engineer', ''),
location=data.get('location', ''),
content=data.get('content', '')
)
db.session.add(new_log)
db.session.commit()
return jsonify({'code': 200, 'message': 'Log saved'})
return jsonify({'code': 200})
except Exception as e:
db.session.rollback()
return jsonify({'code': 500, 'message': str(e)})
@api_bp.route('/logs/update', methods=['POST'])
def update_log():
def update_log_entry():
data = request.get_json()
log_id = data.get('id')
log = MaintenanceLog.query.get(log_id)
if not log: return jsonify({'code': 404, 'message': 'Not found'}), 404
log = MaintenanceLog.query.get(data.get('id'))
if not log: return jsonify({'code': 404})
try:
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.location = data.get('location', log.location)
log.content = data.get('content', log.content)
db.session.commit()
return jsonify({'code': 200, 'message': 'Log updated'})
return jsonify({'code': 200})
except Exception as e:
db.session.rollback()
return jsonify({'code': 500, 'message': str(e)})
return jsonify({'code': 500})
@api_bp.route('/logs/delete', methods=['POST'])
def delete_log():
def delete_log_entry():
data = request.get_json()
log = MaintenanceLog.query.get(data.get('id'))
if log:
db.session.delete(log)
db.session.commit()
return jsonify({'code': 200, 'message': 'Deleted'})
return jsonify({'code': 404, 'message': 'Not found'}), 404
return jsonify({'code': 200})
return jsonify({'code': 404})
# =======================
# 3. 辅助与控制接口 (核心修复逻辑)
# =======================
def calculate_offset(latest_time_str):
if not latest_time_str or latest_time_str == "N/A": return "从未同步"
@api_bp.route('/device_history_list', methods=['GET'])
def get_device_history_list():
try:
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 "时间解析失败"
name = request.args.get('name')
page = int(request.args.get('page', 1))
limit = int(request.args.get('limit', 10))
if not name:
return jsonify({'code': 400, 'message': '缺少设备名称'})
@api_bp.route('/run_monitor', methods=['POST'])
def run_monitor():
try:
if not execute_monitor_task:
return jsonify({'code': 500, 'message': 'Core module missing'})
# 1. 找到设备ID
device = Device.query.filter_by(name=name).first()
if not device:
return jsonify({'code': 404, 'message': '设备不存在'})
task_result = execute_monitor_task()
if not task_result: return jsonify({'code': 200, 'message': '任务跳过'})
# 2. 查询历史记录 (按时间倒序)
query = DeviceHistory.query.filter_by(device_id=device.id).order_by(desc(DeviceHistory.data_time))
scraped_list = task_result.get('device_list', [])
current_check_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# 3. 获取总数
total = query.count()
count = 0
for item in scraped_list:
d_name = item.get('name')
if not d_name: continue
# 4. 分页切片
history_list = query.offset((page - 1) * limit).limit(limit).all()
d_raw = item.get('raw_json', {})
source = item.get('source', '')
target_time = item.get('target_time')
# 5. 格式化返回数据
data = []
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 路径时间
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:
target_time = f"{match.group(1).replace('_', '-')} {match.group(2).replace('_', ':')}"
except:
pass
data.append({
'date': date_str,
'count': h.file_count or 0
})
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:
db.session.rollback()
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
return jsonify({'code': 500, 'message': str(e)})

View File

@ -7,11 +7,11 @@ web_bp = Blueprint('web', __name__)
@web_bp.route('/')
def index():
"""访问根路径时,返回 dist/index.html"""
"""访问根路径时,返回 web_dist/index.html"""
try:
return send_from_directory(get_static_path(), 'index.html')
except Exception as e:
return f"前端资源未找到,请确认 dist 文件夹是否存在。错误信息: {e}", 404
return f"前端资源未找到,请确认 web_dist 文件夹是否存在。错误信息: {e}", 404
@web_bp.route('/<path:path>')
def static_files(path):

View File

@ -1,9 +1,31 @@
# services/core.py
import logging
import threading
from .crawler_106 import run_106_logic
from .crawler_82 import run_82_logic
import traceback
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()
@ -12,26 +34,71 @@ def execute_monitor_task():
执行所有爬虫,返回一个大列表:
{'device_list': [item1, item2...], 'target_time': '...'}
"""
# 1. 锁机制:防止任务重复运行
if task_lock.locked():
logging.warning(">>> 任务正在运行中,跳过")
print(">>> ⚠️ [调度] 任务正在运行中,本次请求跳过")
return None
with task_lock:
start_time = datetime.now()
logging.info(">>> 开始执行监控任务...")
print(f"--- [任务开始] {start_time.strftime('%H:%M:%S')} ---")
# 1. 获取 106 数据列表
list_106 = run_106_logic()
all_results = []
# 2. 获取 82 数据列表
list_82 = run_82_logic()
# ==========================
# 2. 执行 106 爬虫
# ==========================
try:
print(f">>> [106爬虫] 启动...")
list_106 = run_106_logic()
# 3. 合并
combined_list = list_106 + list_82
if list_106:
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 {
'device_list': combined_list,
'target_time': None, # 具体时间已在 item 里
'temp_file_path': None # 废弃旧逻辑,文件路径已在 item 里
'device_list': all_results,
'target_time': None, # 具体时间已在 item['target_time']
'temp_file_path': None # 废弃旧逻辑
}

View File

@ -9,6 +9,7 @@ CONFIG = Config.CRAWLER_CONFIG["106"]
def get_temp_dir():
"""获取临时文件存储目录"""
base_dir = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
temp_dir = os.path.join(base_dir, 'instance', 'temp')
if not os.path.exists(temp_dir):
@ -17,6 +18,7 @@ def get_temp_dir():
def get_106_dynamic_token(port):
"""获取动态登录 Token"""
try:
login_url = f"http://106.75.72.40:{port}/api/login"
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):
"""
在列表中找到与当前日期最接近的文件夹或文件
"""
if not items or not isinstance(items, list): return None
today = datetime.now()
scored_items = []
for item in items:
name_val = item.get('name', '')
path_val = item.get('path', '')
# 如果是日期层级,名字通常是 2026_02_08 这种格式
target_str = name_val if name_val else path_val.split('/')[-1]
try:
if is_date_level:
# 解析文件夹日期格式: YYYY_MM_DD
current_date = datetime.strptime(target_str, "%Y_%m_%d")
else:
# 解析文件修改时间
mod_str = item.get('modified', '')
current_date = datetime.fromisoformat(mod_str.replace('Z', '+00:00'))
# 计算与当前时间的差距
diff = abs((today - current_date.replace(tzinfo=None)).total_seconds())
scored_items.append((diff, item, target_str))
except:
continue
if not scored_items: return None
# 按时间差排序,取最小的
scored_items.sort(key=lambda x: x[0])
return scored_items[0]
def run_106_logic():
"""返回 result_list, 每个元素是一个字典"""
"""
106 爬虫主逻辑
返回 result_list, 每个元素是一个字典
"""
results = []
print(">>> [106爬虫] 启动...")
today_str = datetime.now().strftime("%Y_%m_%d")
main_headers = {"Authorization": CONFIG["primary_auth"], "User-Agent": "Mozilla/5.0"}
try:
# 0. 获取代理列表 (设备列表)
resp = requests.get(CONFIG["base_url"], headers=main_headers, timeout=20)
proxies = resp.json().get('proxies', [])
for item in proxies:
name = item.get('name', '')
# 过滤规则:必须以 _data 结尾
if not name.lower().endswith('_data'): continue
name_upper = name.upper()
is_tower_underscore = "TOWER_" in name_upper
is_tower_i = "TOWER" in name_upper and not is_tower_underscore
# 过滤规则:必须包含 TOWER 相关标识
if not (is_tower_underscore or is_tower_i): continue
# 构建基础数据包
# --- 构建基础数据包 ---
# 默认使用标准当前时间作为兜底,防止后续步骤失败时时间为空
current_standard_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
data_packet = {
'source': '106网站',
'name': name,
'status': '正常',
'value': '',
'target_time': datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
'target_time': current_standard_time,
'raw_json': {},
'temp_file': None
'temp_file': None,
'num_files': 0
}
# 检查在线状态
if str(item.get('status')).lower() != 'online':
data_packet['status'] = '离线'
data_packet['value'] = f"状态: {item.get('status')}"
@ -85,6 +111,7 @@ def run_106_logic():
continue
try:
# 获取端口和 Token
port = item.get('conf', {}).get('remote_port')
token = get_106_dynamic_token(port)
if not token:
@ -96,31 +123,51 @@ def run_106_logic():
headers = {"Authorization": CONFIG["primary_auth"], "x-auth": token}
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)
best_date = find_closest_item(res1.json().get('items', []), True)
if not best_date or best_date[2] != today_str:
data_packet['value'] = "未找到今日文件夹"
data_packet['target_time'] = best_date[2] if best_date else "N/A"
if not best_date:
data_packet['value'] = "未找到任何日期文件夹"
results.append(data_packet)
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)
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:
data_packet['value'] = "今日文件夹为空"
data_packet['value'] = "文件夹为空"
results.append(data_packet)
continue
file_item = best_file[1]
full_path = file_item.get('path') or f"{date_path}{file_item.get('name')}"
# 核心逻辑:获取内容
# --- 4. 下载/读取内容逻辑 ---
if is_tower_i:
# 下载二进制文件
# [二进制文件] 下载逻辑
download_url = f"http://106.75.72.40:{port}/api/raw{full_path}"
res3 = requests.get(download_url, headers=headers, timeout=20, stream=True)
if res3.status_code == 200:
@ -129,20 +176,21 @@ def run_106_logic():
with open(temp_path, 'wb') as f:
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['raw_json'] = file_item # 用文件属性充当RawData
data_packet['raw_json'] = file_item # 借用 file_item 充当 raw_json
else:
data_packet['status'] = '异常'
data_packet['value'] = f"下载失败: {res3.status_code}"
else:
# JSON 内容
# [文本文件] JSON 解析逻辑
file_api_url = f"http://106.75.72.40:{port}/api/resources{full_path}"
res3 = requests.get(file_api_url, headers=headers, timeout=20)
try:
json_content = res3.json()
data_packet['raw_json'] = json_content # 🔥 完整保存
data_packet['value'] = json_content.get('content', '')
data_packet['raw_json'] = json_content
# 尝试提取 content 内容,如果没有则截取部分 JSON 字符串
data_packet['value'] = json_content.get('content', str(json_content)[:100])
except:
data_packet['value'] = "JSON解析失败"
@ -150,7 +198,7 @@ def run_106_logic():
except Exception as e:
data_packet['status'] = '异常'
data_packet['value'] = str(e)[:50]
data_packet['value'] = str(e)[:100]
results.append(data_packet)
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>
<footer class="version-footer">
2.1版本 © 2026 Device Monitor
2.5版本加入每日数据个数 © 2026 Device Monitor
</footer>
</div>
</template>

View File

@ -13,37 +13,34 @@
</div>
<div class="header-actions">
<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>
<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 type="info" plain icon="Document" @click="openLogCenter(null)">日志</el-button>
<el-button type="warning" plain icon="RefreshRight" :loading="runningTask" @click="runManualMonitor">检测</el-button>
<el-button circle icon="Refresh" :loading="loading" @click="fetchData" />
<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>
</template>
<div class="status-summary">
<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="#E6A23C" effect="dark" class="legend-tag">滞后1-7</el-tag>
<el-tag color="#FAC858" effect="dark" class="legend-tag" style="color: #333">滞后24h</el-tag>
<el-tag color="#F56C6C" effect="dark" class="legend-tag">离线 / 严重滞后</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">数据异常 / 昨日</el-tag>
<el-tag color="#67C23A" effect="dark" class="legend-tag">正常</el-tag>
</div>
<div class="toolbar">
<div class="filter-section">
<el-radio-group v-model="filters.status" @change="fetchData" size="default">
<el-radio-button label="all">全部</el-radio-button>
<el-radio-group v-model="filters.status" size="default">
<el-radio-button label="all">全部({{ summary.totalCount }})</el-radio-button>
<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 label="hidden" class="gray-radio">
回收({{ summary.hiddenCount }})
@ -57,6 +54,12 @@
prefix-icon="Search"
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>
@ -64,10 +67,10 @@
:data="filteredData"
border
v-loading="loading"
style="width: 100%; min-width: 950px;"
style="width: 100%; min-width: 1250px;"
:row-class-name="tableRowClassName"
:height="tableHeight"
:default-sort="{ prop: 'sortHours', order: 'descending' }"
:default-sort="{ prop: 'sortWeight', order: 'descending' }"
>
<el-table-column label="状态" width="100" align="center" fixed="left">
<template #default="{ row }">
@ -84,7 +87,7 @@
</template>
</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 }">
<div
class="device-name-wrapper"
@ -95,11 +98,31 @@
{{ formatDisplayName(row.name) }}
</span>
<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>
</template>
</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 }">
<div v-if="row.isEditingSite" class="editing-cell">
<el-input
@ -118,16 +141,83 @@
</template>
</el-table-column>
<el-table-column label="数据时效" width="220" prop="sortHours" sortable>
<el-table-column label="本月流量" width="130" prop="trafficNum" sortable>
<template #default="{ row }">
<div style="font-size: 13px;"><el-icon><Clock /></el-icon> {{ row.latest_time || '--' }}</div>
<div v-if="!row.is_maintaining && !row.is_hidden">
<div v-if="row.status === 'offline' || row.status === '已离线'" class="status-text error-text"> 设备已离线</div>
<div v-else-if="row.diffDays > 7" class="status-text error-text"> 严重滞后 {{ Math.floor(row.diffDays) }} </div>
<div v-else-if="row.diffHours > 24" class="status-text warning-text"> 滞后 {{ Math.floor(row.diffDays) }} </div>
<div v-else-if="!row.isToday" class="status-text slight-warning-text"> 昨日数据</div>
<div v-else class="status-text success-text"> 数据最新</div>
<div v-if="row.isBound">
<span :style="{ fontWeight: '600', color: row.trafficWarning ? '#E6A23C' : '#606266' }">
{{ row.trafficNum }} M
</span>
<el-tooltip v-if="row.trafficWarning" content="流量超标 (>=500M)" 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="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>
</template>
</el-table-column>
@ -162,6 +252,29 @@
<DataMonitor ref="dataMonitorRef" />
<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>
</template>
@ -170,11 +283,13 @@ import { ref, reactive, computed, onMounted, nextTick, onBeforeUnmount } from 'v
import { useRouter } from 'vue-router'
import axios from 'axios'
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 MaintenanceLogs from './MaintenanceLogs.vue'
import IoTDeviceBinder from './IoTDeviceBinder.vue'
// ✅ 引入新组件
import FileHistoryDialog from './FileHistoryDialog.vue'
const router = useRouter()
const loading = ref(false)
@ -184,10 +299,8 @@ const lastCheckTime = ref('')
const windowHeight = ref(window.innerHeight)
const windowWidth = ref(window.innerWidth)
// 计算表格高度:手机端预留更多空间给折行的头部
const tableHeight = computed(() => {
const isMobile = windowWidth.value < 768
// 手机端头部元素堆叠,需要减去更多的高度
const offset = isMobile ? 380 : 250
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 maintenanceLogsRef = ref(null)
const iotBinderRef = ref(null)
// ✅ 定义新的 ref
const fileHistoryRef = ref(null)
const summary = computed(() => {
const activeDevices = rawData.value.filter(r => !r.is_hidden)
const errors = activeDevices.filter(r => r.statusType === 'error').length
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 showAddDialog = ref(false)
const isAdding = ref(false)
const newDeviceForm = reactive({ name: '', site: '' })
const handleLogout = () => {
ElMessageBox.confirm('确定退出系统吗?', '提示', { type: 'warning' }).then(() => {
localStorage.removeItem('isLoggedIn')
localStorage.removeItem('token')
router.push('/')
ElMessage.success('已安全退出')
}).catch(() => {})
// === 辅助函数:根据中文状态返回 Tag 颜色 ===
const getCardStatusType = (status) => {
if (status === '在使用') return 'success' // 绿色
if (status === '停机' || status === '销户') return 'danger' // 红色
if (status === '停机保号' || status === '沉默期') return 'warning' // 黄色
if (status === '测试期') return 'info' // 灰色
return 'info' // 默认
}
// === 核心数据处理逻辑 ===
const fetchData = async () => {
loading.value = true
try {
@ -222,46 +335,148 @@ const fetchData = async () => {
const backendList = res.data.data || res.data
const now = new Date()
let processedData = backendList.map(item => {
rawData.value = backendList.map(item => {
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') {
const cleanDateStr = item.latest_time.toString().replace(/_/g, '-')
const d = new Date(cleanDateStr)
if (!isNaN(d.getTime())) {
// === 1. 智能时间解析与格式化 (增强版) ===
let diffDays = 0, diffHours = 0, isToday = false, validTime = false
let timeStr = item.latest_time
// 默认显示原始值,稍后如果解析成功则覆盖它
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
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()
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;
if (item.is_maintaining) sortHours = Number.MAX_SAFE_INTEGER;
else if (item.status === 'offline' || item.status === '已离线') sortHours = 1000000000;
else if (!validTime) sortHours = 500000000;
// 2. 解析监测数值 (保留旧逻辑)
let currentValueNum = 0
if (item.current_value) {
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 statusReason = ''
let sortWeight = diffHours
if (item.is_maintaining) {
statusColor = '#409EFF'; statusLabel = '维修中'; statusType = 'maintenance';
} else if ((item.status === 'offline' || item.status === '已离线') || (!validTime || diffDays > 7)) {
statusLabel = '离线/滞后'; statusColor = '#F56C6C'; statusType = 'error';
sortWeight = Number.MAX_SAFE_INTEGER;
} 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) {
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) {
statusColor = '#FAC858'; statusLabel = '昨日数据'; statusType = 'slight-warning'; statusLabelColor = '#333';
statusLabel = '昨日数据'; statusColor = '#FAC858'; statusType = 'slight-warning'; statusLabelColor = '#333';
statusReason = '非今日数据';
} else {
sortWeight = 0;
}
return {
...item, is_hidden: isHidden, diffDays, diffHours, sortHours, isToday,
statusColor, statusLabel, statusType, statusLabelColor, isEditingSite: false, tempSite: ''
...item,
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 // ✅ 绑定后端返回的文件数字段
}
})
processedData.sort((a, b) => b.sortHours - a.sortHours)
rawData.value = processedData
}).sort((a, b) => b.sortWeight - a.sortWeight)
lastCheckTime.value = new Date().toLocaleString()
} catch (e) {
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 runManualMonitor = async () => {
runningTask.value = true
try {
const res = await axios.post(`${API_BASE}/api/run_monitor`)
ElMessage.success(res.data.message || '任务启动')
setTimeout(() => fetchData(), 3000)
} catch (e) { ElMessage.warning('请求频繁') }
finally { setTimeout(() => { runningTask.value = false }, 1000) }
}
// === 筛选逻辑 ===
const summary = computed(() => {
const active = rawData.value.filter(r => !r.is_hidden && !r.isOrphanIoT)
return {
totalCount: active.length,
errorCount: active.filter(r => r.statusType === 'error').length,
warningCount: active.filter(r => r.statusType === 'warning').length,
hiddenCount: rawData.value.filter(r => r.is_hidden).length,
dataErrorCount: active.filter(r => r.data_quality === 'error' || r.data_quality === 'warning').length
}
})
const filteredData = computed(() => {
return rawData.value.filter(item => {
// 隐藏孤儿卡
if (item.isOrphanIoT) return false
if (filters.status === 'hidden') return item.is_hidden
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
}).filter(item => !filters.keyword || item.name.toLowerCase().includes(filters.keyword.toLowerCase()))
})
const handleEditSite = (row) => {
row.tempSite = row.install_site; row.isEditingSite = true
nextTick(() => {
// 兼容性查找 input
const inputs = document.querySelectorAll('.site-input-inner input')
if (inputs.length > 0) inputs[inputs.length - 1].focus()
})
}
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
try {
await axios.post(`${API_BASE}/api/update_site`, { name: row.name, site: row.tempSite })
ElMessage.success('已更新')
} 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 totalUsageSum = computed(() => {
return rawData.value.reduce((sum, item) => {
if (item.source === 'iot_card') {
return sum + (item.trafficNum || 0)
}
return sum
}, 0)
})
// === 交互函数 ===
// ✅ 新增:打开历史记录弹窗
const openHistoryDialog = (row) => {
if (row.is_hidden) return
if (fileHistoryRef.value) {
fileHistoryRef.value.open(row)
}
}
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 tableRowClassName = ({ 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.data_quality === 'warning') return 'data-warning-row' // 数值警告
if (row.statusType === 'warning') return 'warning-row'
if (row.statusType === 'maintenance') return 'maintenance-row'
return ''
}
const updateDimensions = () => {
windowHeight.value = window.innerHeight
windowWidth.value = window.innerWidth
}
onMounted(() => {
fetchData()
window.addEventListener('resize', updateDimensions)
})
const updateDimensions = () => { windowHeight.value = window.innerHeight; windowWidth.value = window.innerWidth }
onMounted(() => { fetchData(); window.addEventListener('resize', updateDimensions) })
onBeforeUnmount(() => window.removeEventListener('resize', updateDimensions))
</script>
<style scoped>
.dashboard-container { padding: 10px; background: #f5f7fa; min-height: 100vh; box-sizing: border-box; }
.main-card { border-radius: 8px; overflow: visible; } /* overflow visible 确保下拉框不被遮挡 */
/* 头部布局:默认 flex手机端会自动调整 */
.header-row {
display: flex;
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; }
/* 状态标签区 */
.main-card { border-radius: 8px; }
.header-row { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; }
.sys-title { font-size: 20px; font-weight: 700; color: #303133; margin: 0; }
.left-panel { display: flex; align-items: center; gap: 10px; }
.header-actions { display: flex; gap: 8px; align-items: center; }
.status-summary { margin-bottom: 15px; display: flex; gap: 5px; flex-wrap: wrap; }
.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; /* 允许换行 */
}
.search-input { width: 220px; transition: width 0.3s; }
/* 表格内元素 */
.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; }
.search-input { width: 220px; }
:deep(.red-radio.is-active .el-radio-button__inner) { background-color: #F56C6C; border-color: #F56C6C; box-shadow: -1px 0 0 0 #F56C6C; }
:deep(.yellow-radio.is-active .el-radio-button__inner) { background-color: #E6A23C; border-color: #E6A23C; box-shadow: -1px 0 0 0 #E6A23C; }
:deep(.gray-radio.is-active .el-radio-button__inner) { background-color: #909399; border-color: #909399; box-shadow: -1px 0 0 0 #909399; }
:deep(.red-radio .el-radio-button__inner), :deep(.yellow-radio .el-radio-button__inner), :deep(.gray-radio .el-radio-button__inner) { color: #606266; }
.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; }
.total-usage-tag .value { font-size: 14px; font-weight: 800; }
.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-wrapper:hover .device-name { color: #409EFF; text-decoration: underline; }
.text-deleted { text-decoration: line-through; color: #999; }
.status-text { font-size: 12px; margin-top: 4px; font-weight: bold; }
.error-text { color: #F56C6C; }
.warning-text { color: #E6A23C; }
.success-text { color: #67C23A; }
.slight-warning-text { color: #E6A23C; }
.maintenance-text { color: #409EFF; }
.display-cell { cursor: pointer; padding: 4px 0; display: flex; align-items: center; justify-content: space-between; }
.edit-icon { color: #409EFF; margin-left: 5px; }
/* 颜色行样式 */
.display-cell { cursor: pointer; padding: 4px 0; display: flex; justify-content: space-between; }
.edit-icon { color: #409EFF; }
:deep(.error-row) { background-color: #fef0f0 !important; }
:deep(.warning-row) { background-color: #fdf6ec !important; }
:deep(.maintenance-row) { background-color: #f0f9ff !important; }
: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) {
.dashboard-container { padding: 5px; }
/* 标题和状态堆叠 */
.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; }
/* 搜索框独占一行 */
.left-panel, .header-actions { width: 100%; justify-content: space-between; margin-bottom: 5px; }
.header-actions .el-button { flex: 1; margin: 0 2px; }
.filter-section { justify-content: space-between; }
.el-radio-group { width: 100%; display: flex; }
.el-radio-button { flex: 1; }
:deep(.el-radio-button__inner) { width: 100%; padding: 8px 0; }
.search-input { width: 100%; margin-top: 5px; }
/* 隐藏非关键按钮文字,节省空间 */
.el-button [class*="el-icon"] + span { display: inline-block; }
.total-usage-tag { width: 100%; justify-content: center; margin: 5px 0 0 0; }
}
</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>