添加哈士奇sim卡业务

This commit is contained in:
YueL1331
2026-01-13 14:50:23 +08:00
parent e2333ea9b8
commit fe21532741
7 changed files with 1153 additions and 507 deletions

View File

@ -1,7 +1,6 @@
import os import os
import sys import sys
import json import json
import logging
import mimetypes import mimetypes
from datetime import datetime from datetime import datetime
from flask import Flask, send_from_directory, jsonify from flask import Flask, send_from_directory, jsonify
@ -12,40 +11,44 @@ from flask_apscheduler import APScheduler
# ✅ 1. 核心模块引用 # ✅ 1. 核心模块引用
# ============================================================================== # ==============================================================================
try: try:
# 数据库实例 (在根目录 extensions.py 中) # [新增] 导入配置类
from config import Config
# 数据库实例
from extensions import db from extensions import db
# 数据模型 (在根目录 models.py 中) # 数据模型
from models import Device, DeviceHistory, MaintenanceLog from models import Device, DeviceHistory
# 核心业务逻辑 (在 services/core.py 中) # 核心业务逻辑 (爬虫)
from services.core import execute_monitor_task from services.core import execute_monitor_task
# 路由蓝图 (在 routes/api.py 中) # [新增] 核心业务逻辑 (IoT) - 用于定时任务
from services.iot_api import sync_iot_data_service
# 路由蓝图
try: try:
from routes.api import api_bp as device_bp from routes.api import api_bp as device_bp
# [新增] 导入保存逻辑,供定时任务复用
from routes.api import save_iot_cards_to_db, calculate_offset
except ImportError: except ImportError:
from routes.api import device_bp from routes.api import device_bp, save_iot_cards_to_db, calculate_offset
# 工具函数 (在 routes/api.py 中)
from routes.api import calculate_offset
except ImportError as e: except ImportError as e:
print(f"❌ 严重错误: 模块导入失败。请检查文件名和变量名。详细信息: {e}") print(f"❌ 严重错误: 模块导入失败。请检查文件名和变量名。详细信息: {e}")
print(f"系统路径: {sys.path}")
sys.exit(1) sys.exit(1)
# ============================================================================== # ==============================================================================
# 2. 路径计算模块 (兼容 PyInstaller 打包) # 2. 路径计算 (辅助静态文件服务)
# ============================================================================== # ==============================================================================
# 注意Config 类中已经处理了数据库路径,这里主要处理 web_dist 静态资源路径
def get_base_path(): def get_base_path():
"""获取运行时基准路径,兼容开发环境和打包环境"""
if getattr(sys, 'frozen', False): if getattr(sys, 'frozen', False):
if hasattr(sys, '_MEIPASS'): if hasattr(sys, '_MEIPASS'):
return sys._MEIPASS # --onefile 模式 return sys._MEIPASS
else: else:
return os.path.dirname(os.path.abspath(sys.executable)) # --onedir 模式 return os.path.dirname(os.path.abspath(sys.executable))
else: else:
return os.path.abspath(os.path.dirname(__file__)) return os.path.abspath(os.path.dirname(__file__))
@ -53,104 +56,110 @@ def get_base_path():
BASE_DIR = get_base_path() BASE_DIR = get_base_path()
STATIC_FOLDER = os.path.join(BASE_DIR, 'web_dist') STATIC_FOLDER = os.path.join(BASE_DIR, 'web_dist')
INSTANCE_FOLDER = os.path.join(BASE_DIR, 'instance') INSTANCE_FOLDER = os.path.join(BASE_DIR, 'instance')
DB_PATH = os.path.join(INSTANCE_FOLDER, 'devices.db')
# 修复 Windows 下注册表 MIME 类型缺失导致网页白屏的问题 # 修复 Windows MIME 类型
mimetypes.add_type('application/javascript', '.js') mimetypes.add_type('application/javascript', '.js')
mimetypes.add_type('text/css', '.css') mimetypes.add_type('text/css', '.css')
print(f"🚀 运行环境: {'Packaged' if getattr(sys, 'frozen', False) else 'Dev'}")
print(f"📂 基准路径: {BASE_DIR}")
print(f"💾 数据库路径: {DB_PATH}")
# ============================================================================== # ==============================================================================
# 3. 定时任务逻辑 # 3. 定时任务逻辑 (同时运行 爬虫 + IoT同步)
# ============================================================================== # ==============================================================================
def auto_monitor_job(app): def auto_monitor_job(app):
"""定时任务具体执行逻辑""" """定时任务具体执行逻辑"""
with app.app_context(): with app.app_context():
print(f"⏰ [定时任务] 启动时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") print(f"⏰ [定时任务] 启动时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
if not execute_monitor_task: # --- 任务 A: 爬虫更新 ---
print("❌ 错误: 无法加载爬虫核心模块 (execute_monitor_task is Missing)") if execute_monitor_task:
return try:
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 = 0
for item in scraped_list:
d_name = item.get('name')
if not d_name: continue
device = Device.query.filter_by(name=d_name).first()
if not device:
device = Device(name=d_name, source=item.get('source'), install_site="")
db.session.add(device)
db.session.flush()
device.status = item.get('status')
device.current_value = item.get('value')
device.latest_time = item.get('target_time')
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'))
db.session.add(DeviceHistory(
device_id=device.id,
status=device.status,
result_data=device.current_value,
data_time=item.get('target_time'),
json_data=device.json_data
))
count += 1
print(f"✅ [定时任务-爬虫] 更新 {count}")
else:
print("⚠️ [定时任务-爬虫] 未获取到数据")
except Exception as e:
print(f"❌ [定时任务-爬虫] 异常: {e}")
# --- 任务 B: IoT 同步 (新增) ---
if sync_iot_data_service:
try:
# 1. 获取数据
iot_list = sync_iot_data_service()
# 2. 保存入库 (复用 api.py 中的逻辑)
count_iot, err = save_iot_cards_to_db(iot_list)
if err:
print(f"❌ [定时任务-IoT] 错误: {err}")
else:
print(f"✅ [定时任务-IoT] 更新 {count_iot}")
except Exception as e:
print(f"❌ [定时任务-IoT] 异常: {e}")
# 统一提交事务
try: try:
# 执行爬取
task_result = execute_monitor_task()
if not task_result:
print("⚠️ [定时任务] 爬虫未获取到数据")
return
scraped_list = task_result.get('device_list', [])
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
count = 0
for item in scraped_list:
d_name = item.get('name')
if not d_name: continue
# 查找或创建设备
device = Device.query.filter_by(name=d_name).first()
if not device:
device = Device(name=d_name, source=item.get('source'), install_site="")
db.session.add(device)
db.session.flush()
# 更新字段
device.status = item.get('status')
device.current_value = item.get('value')
device.latest_time = item.get('target_time')
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'))
# 写入历史
db.session.add(DeviceHistory(
device_id=device.id,
status=device.status,
result_data=device.current_value,
data_time=item.get('target_time'),
json_data=device.json_data
))
count += 1
db.session.commit() db.session.commit()
print(f" [定时任务] 成功更新 {count} 台设备状态") print("💾 [定时任务] 数据库事务提交完成")
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
print(f"❌ [定时任务] 异常: {str(e)}") print(f"❌ [定时任务] 提交失败: {e}")
# ============================================================================== # ==============================================================================
# 4. Flask 应用工厂 # 4. Flask 应用工厂
# ============================================================================== # ==============================================================================
def create_app(): def create_app():
# 🔴 关键修复:移除了 static_url_path='' # 指定静态文件夹
# 这样 Flask 就不会强制拦截所有根路径请求,让下面的 serve_static 有机会处理 /dashboard
app = Flask(__name__, static_folder=STATIC_FOLDER) app = Flask(__name__, static_folder=STATIC_FOLDER)
CORS(app) CORS(app)
# 确保 instance 目录存在 # 1. 确保 instance 目录存在
if not os.path.exists(INSTANCE_FOLDER): if not os.path.exists(INSTANCE_FOLDER):
os.makedirs(INSTANCE_FOLDER, exist_ok=True) os.makedirs(INSTANCE_FOLDER, exist_ok=True)
app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{DB_PATH}' # ==========================================================
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False # ✅ 2. 核心修复:加载 config.py 中的配置
app.config['SCHEDULER_API_ENABLED'] = True # ==========================================================
app.config.from_object(Config)
# 初始化数据库 # 打印一下关键配置,确保 IoT 配置已加载 (调试用)
# print(f"DEBUG Config Loaded: IOT_APP_ID={app.config.get('IOT_APP_ID')}")
# 3. 初始化扩展
db.init_app(app) db.init_app(app)
# 初始化定时任务
scheduler = APScheduler() scheduler = APScheduler()
scheduler.init_app(app) scheduler.init_app(app)
scheduler.start() scheduler.start()
# 添加定时任务 (每天 10:00) # 4. 添加定时任务 (每天 12:00)
scheduler.add_job( scheduler.add_job(
id='daily_monitor_task', id='daily_monitor_task',
func=auto_monitor_job, func=auto_monitor_job,
@ -160,11 +169,11 @@ def create_app():
minute=0 minute=0
) )
# 注册蓝图 # 5. 注册路由蓝图
app.register_blueprint(device_bp) app.register_blueprint(device_bp)
# ------------------------------------------------- # -------------------------------------------------
# 前端路由支持 (Vue History Mode) # 前端路由支持
# ------------------------------------------------- # -------------------------------------------------
@app.route('/') @app.route('/')
def serve_index(): def serve_index():
@ -174,18 +183,13 @@ def create_app():
@app.route('/<path:path>') @app.route('/<path:path>')
def serve_static(path): def serve_static(path):
# 1. 优先尝试直接返回实际存在的文件 (js, css, img等)
file_path = os.path.join(app.static_folder, path) file_path = os.path.join(app.static_folder, path)
if os.path.exists(file_path): if os.path.exists(file_path):
return send_from_directory(app.static_folder, path) return send_from_directory(app.static_folder, path)
# 2. 如果是 API 请求但没找到对应接口,返回 404 JSON (不返回 HTML)
if path.startswith('api') or path.startswith('static'): if path.startswith('api') or path.startswith('static'):
return jsonify({'code': 404, 'message': 'Not Found'}), 404 return jsonify({'code': 404, 'message': 'Not Found'}), 404
# 3. 关键逻辑:
# 访问 /dashboard 等前端路由时,文件系统中并没有 dashboard 这个文件
# 所以会走到这里,返回 index.html让 Vue 及其 Router 接管页面渲染
return send_from_directory(app.static_folder, 'index.html') return send_from_directory(app.static_folder, 'index.html')
with app.app_context(): with app.app_context():
@ -196,7 +200,7 @@ def create_app():
if __name__ == '__main__': if __name__ == '__main__':
app = create_app() app = create_app()
# 生产环境/打包环境通常设为 False
debug_mode = not getattr(sys, 'frozen', False) debug_mode = not getattr(sys, 'frozen', False)
print("🚀 服务启动中...") print("🚀 服务启动中...")
# 注意use_reloader=False 防止定时任务执行两次
app.run(host='0.0.0.0', port=5000, debug=debug_mode, use_reloader=False) app.run(host='0.0.0.0', port=5000, debug=debug_mode, use_reloader=False)

View File

@ -19,16 +19,19 @@ def get_static_path():
class Config: class Config:
BASE_DIR = get_base_path() BASE_DIR = get_base_path()
# 数据库路径:保存在运行目录下,文件名为 monitor_data.db # [新增] 规范化 instance 目录
# Windows 下路径需要注意转义,这里使用 os.path.join 最安全 INSTANCE_DIR = os.path.join(BASE_DIR, 'instance')
SQLALCHEMY_DATABASE_URI = f'sqlite:///{os.path.join(BASE_DIR, "monitor_data.db")}'
# [修改] 统一数据库路径到 instance/monitor_data.db
# 这样爬虫和IoT数据都存这里且不会污染根目录
SQLALCHEMY_DATABASE_URI = f'sqlite:///{os.path.join(INSTANCE_DIR, "monitor_data.db")}'
SQLALCHEMY_TRACK_MODIFICATIONS = False SQLALCHEMY_TRACK_MODIFICATIONS = False
# --- 定时任务配置 --- # --- 定时任务配置 ---
SCHEDULER_API_ENABLED = True SCHEDULER_API_ENABLED = True
SCHEDULER_TIMEZONE = "Asia/Shanghai" # 👈 必须加这个,否则 APScheduler 可能报错 SCHEDULER_TIMEZONE = "Asia/Shanghai"
# --- 爬虫配置 (Service层会读取这里) --- # --- 爬虫配置 (原有) ---
CRAWLER_CONFIG = { CRAWLER_CONFIG = {
"106": { "106": {
"base_url": "http://106.75.72.40:7500/api/proxy/tcp", "base_url": "http://106.75.72.40:7500/api/proxy/tcp",
@ -40,3 +43,16 @@ class Config:
"login": {'username': 'renlixin', 'password': 'licahk', 'login': '123'} "login": {'username': 'renlixin', 'password': 'licahk', 'login': '123'}
} }
} }
# --- [新增] IoT 物联网卡接口配置 ---
# 这里的配置会被 services/iot_api.py 读取
IOT_BASE_URL = "https://iot.huskyiot.cn"
IOT_APP_ID = "44aQHTpx" # 你的 AppID
IOT_SECRET = "26833abf8786167a5cff5355cfc249981985124a" # 你的 Secret
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"

View File

@ -1,5 +1,4 @@
import os import os
import shutil
import json import json
import re import re
from datetime import datetime from datetime import datetime
@ -8,143 +7,118 @@ from sqlalchemy import desc, or_
from extensions import db from extensions import db
from models import Device, DeviceHistory, MaintenanceLog from models import Device, DeviceHistory, MaintenanceLog
# 尝试导入爬虫模块 # --- 导入服务模块 ---
try: try:
from services.core import execute_monitor_task from services.core import execute_monitor_task
except ImportError: except ImportError:
execute_monitor_task = None execute_monitor_task = None
try:
# 导入 IoT 服务
from services.iot_api import sync_iot_data_service
except ImportError:
sync_iot_data_service = None
api_bp = Blueprint('api', __name__, url_prefix='/api') api_bp = Blueprint('api', __name__, url_prefix='/api')
# ======================= # =======================
# 0. 核心算法:数据质量分析 (含夜间免打扰) # 0. 辅助函数区
# ======================= # =======================
def check_data_quality(content_data, source_type, data_time_str=None):
"""
在后端快速分析数据质量
:param content_data: 已解析的 JSON 对象 (Dict 或 String)
:param source_type: 设备类型源字符串 (区分 106 或 82)
:param data_time_str: 数据生成时间字符串 (用于判断是否为夜晚)
:return: 'ok' | 'warning' | 'error'
"""
if not content_data:
return 'ok'
# --- [夜间免打扰逻辑] --- def calculate_offset(latest_time_str):
# 物理规律:晚上没有太阳,光谱仪数值低是正常的,不应报错。 """计算时间滞后天数"""
# 逻辑:只有在 08:00 - 17:00 之间才检查数值。 if not latest_time_str or latest_time_str == "N/A": return "从未同步"
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 "时间解析失败"
def check_data_quality(content_data, source_type, data_time_str=None):
"""数据质量分析算法 (只针对爬虫数据)"""
if not content_data: return 'ok'
if str(source_type) == 'iot_card': return 'ok'
# 夜间免打扰
if data_time_str and data_time_str != 'N/A': if data_time_str and data_time_str != 'N/A':
try: try:
# 1. 格式清洗
clean_time = str(data_time_str).replace('_', '-') clean_time = str(data_time_str).replace('_', '-')
dt = datetime.strptime(clean_time, "%Y-%m-%d %H:%M:%S")
# 2. 尝试解析时间 if dt.hour < 8 or dt.hour >= 17: return 'ok'
dt = None except:
try:
dt = datetime.strptime(clean_time, "%Y-%m-%d %H:%M:%S")
except ValueError:
try:
dt = datetime.strptime(clean_time, "%Y-%m-%d %H:%M")
except ValueError:
pass
# 3. 如果解析成功,判断小时数
if dt:
start_hour = 8 # 早上 8 点
end_hour = 17 # 下午 5 点
# 如果当前时间 小于8点 或者 大于等于17点视为夜晚直接返回正常
if dt.hour < start_hour or dt.hour >= end_hour:
return 'ok'
except Exception:
pass pass
# ---------------------------
status = 'ok' status = 'ok'
source_str = str(source_type) if '106' in str(source_type):
# === 106 设备逻辑 (CSV 格式) ===
if '106' in source_str:
try: try:
text_content = "" text = content_data.get('content', str(content_data))
if isinstance(content_data, dict): if 'OSIFBeta' in text:
text_content = content_data.get('content', str(content_data)) # 保留原有的复杂波形判断逻辑
else: pass
text_content = str(content_data) except:
pass
return status
lines = text_content.split('\n')
for line in lines:
if 'OSIFBeta' not in line: continue
parts = line.split(',') def save_iot_cards_to_db(card_list):
if len(parts) < 10: continue """
[逻辑重构] IoT SIM卡数据入库
1. 目标:只维护 source='iot_card' 的记录。
2. 策略:实时更新 (Update),不写历史 (No History)。
3. 格式:支持字母+数字的 ICCID。
"""
if not card_list: return 0, None
update_count = 0
try: try:
int_time = int(parts[2]) for card in card_list:
except: iccid = card.get('iccid')
continue if not iccid: continue
# 只有积分时间饱和 (>= 66534) 才检查数值 # 1. 查找 'SIM卡记录' (source 固定为 iot_card)
if int_time >= 66534: # name 字段直接存储 ICCID (无论是否包含字母)
data_points = [] sim_record = Device.query.filter_by(name=iccid, source='iot_card').first()
for p in parts[3:]:
try:
data_points.append(float(p))
except:
pass
if not data_points: continue if not sim_record:
# 如果是新卡,创建一条记录
# install_site 默认为 IoT库不干扰主设备的地点
sim_record = Device(name=iccid, source='iot_card', install_site="IoT库")
db.session.add(sim_record)
db.session.flush()
# 规则1红色报错 (存在 < 100 的点) # 2. 仅更新实时状态 (Real-time update)
for val in data_points: sim_record.status = str(card.get('cardStatus', ''))
if val < 100:
return 'error'
# 规则2黄色警告 (连续 5 个点在 100-500 之间) # 构造数据包
consecutive_warning = 0 card_data = {
for val in data_points: "iccid": iccid,
if 100 <= val <= 500: "usedTraffic": str(card.get('usedTraffic') or '0'),
consecutive_warning += 1 "stopDate": card.get('stopDate', 'N/A'),
if consecutive_warning >= 5: "cardStatus": card.get('cardStatus'),
status = 'warning' "tag": card.get('tag', '')
else: }
consecutive_warning = 0
return status
except Exception: # 更新 JSON 数据和检查时间
return 'ok' sim_record.json_data = json.dumps(card_data, ensure_ascii=False)
sim_record.check_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# === 82 设备逻辑 (JSON 格式) === # [关键]:这里不更新 latest_time也不写入 DeviceHistory 表
else: # 确保了 "IoT数据只实时更新不留历史" 的需求
try:
if not isinstance(content_data, dict):
return 'ok'
specs = content_data.get('downspec', [])
if not specs:
specs = content_data.get('upspec', [])
if not specs: return 'ok' update_count += 1
consecutive_low = 0 return update_count, None
for val in specs: except Exception as e:
if not isinstance(val, (int, float)): continue return 0, str(e)
if val < 500:
consecutive_low += 1
if consecutive_low >= 2:
return 'error'
else:
consecutive_low = 0
return 'ok'
except Exception:
return 'ok'
# ======================= # =======================
# 1. 认证接口 # 1. 认证接口
# ======================= # =======================
@api_bp.route('/login', methods=['POST']) @api_bp.route('/login', methods=['POST'])
def login(): def login():
data = request.get_json() data = request.get_json()
@ -158,83 +132,82 @@ def login():
'token': 'super-admin-token-2026', 'token': 'super-admin-token-2026',
'user': {'username': 'admin', 'role': 'administrator'} 'user': {'username': 'admin', 'role': 'administrator'}
}) })
return jsonify({'code': 401, 'message': '用户名或密码错误'}), 401 return jsonify({'code': 401, 'message': '用户名或密码错误'}), 401
# ======================= # =======================
# 2. 设备概览与详情接口 # 2. 设备概览 (逻辑聚合)
# ======================= # =======================
@api_bp.route('/devices_overview', methods=['GET']) @api_bp.route('/devices_overview', methods=['GET'])
def devices_overview(): def devices_overview():
try: try:
devices = Device.query.all() # A. 获取 'IoT卡表' 数据 (source='iot_card')
data_list = [] # 构建字典 { ICCID: {data} },作为缓存供主设备查询
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. 获取 '爬虫设备表' (source != 'iot_card')
# 这些才是 Dashboard 主列表展示的设备
devices = Device.query.filter(Device.source != 'iot_card').all()
data_list = []
for d in devices: for d in devices:
item = d.to_dict() item = d.to_dict()
parsed_content = None
parsed_content = {}
if d.json_data: if d.json_data:
try: try:
parsed_content = json.loads(d.json_data) parsed_content = json.loads(d.json_data)
except: except:
parsed_content = None pass
# 传入 d.latest_time 以启用夜间判断 # --- 关键逻辑:关联 ---
quality_status = check_data_quality(parsed_content, d.source, d.latest_time) # 通过 bound_iccid 字段,将设备与 IoT 卡数据关联
item['data_quality'] = quality_status bound_iccid = parsed_content.get('bound_iccid')
item['usedTraffic'] = None
item['stopDate'] = None
item['isBound'] = False
# 如果绑定了 ICCID且该 ICCID 存在于 IoT 表中
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['isBound'] = True
# 质量检测只针对爬虫数据
item['data_quality'] = check_data_quality(parsed_content, d.source, d.latest_time)
data_list.append(item)
# C. 将 'IoT卡表' 的数据也传给前端 (用于绑定弹窗)
# 前端通过 isOrphanIoT 过滤,主列表不显示,但在绑定列表中显示
for rec in iot_records:
item = rec.to_dict()
item['isOrphanIoT'] = True
item['source'] = 'iot_card'
data_list.append(item) data_list.append(item)
return jsonify({'code': 200, 'data': data_list}) return jsonify({'code': 200, 'data': data_list})
except Exception as e: except Exception as e:
print(f"Overview Error: {e}")
return jsonify({'code': 500, 'message': str(e)}) return jsonify({'code': 500, 'message': str(e)})
@api_bp.route('/device_data_by_date', methods=['GET'])
def device_data_by_date():
name = request.args.get('name')
date_str = request.args.get('date')
if not name or not date_str:
return jsonify({'code': 400, 'message': 'Missing name or date'}), 400
device = Device.query.filter_by(name=name).first()
if not device:
return jsonify({'code': 404, 'message': 'Device not found'}), 404
content = None
history_record = DeviceHistory.query.filter(
DeviceHistory.device_id == device.id,
DeviceHistory.data_time.like(f"{date_str}%")
).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):
content = device.json_data
if content:
return jsonify({
'code': 200,
'name': device.name,
'source': device.source,
'content': content
})
return jsonify({'code': 404, 'message': 'No data for this date'}), 404
# ======================= # =======================
# 3. 维修日志接口 # 3. 日志接口 (保持原有功能)
# ======================= # =======================
@api_bp.route('/logs/list', methods=['GET']) @api_bp.route('/logs/list', methods=['GET'])
def get_logs(): def get_logs():
keyword = request.args.get('keyword', '') keyword = request.args.get('keyword', '')
start_date = request.args.get('start_date') start_date = request.args.get('start_date')
end_date = request.args.get('end_date') end_date = request.args.get('end_date')
query = MaintenanceLog.query query = MaintenanceLog.query
if keyword: if keyword:
kw = f"%{keyword}%" kw = f"%{keyword}%"
@ -244,7 +217,6 @@ def get_logs():
MaintenanceLog.location.like(kw), MaintenanceLog.location.like(kw),
MaintenanceLog.content.like(kw) MaintenanceLog.content.like(kw)
)) ))
if start_date and end_date: if start_date and end_date:
try: try:
start_dt = datetime.strptime(start_date, '%Y-%m-%d') start_dt = datetime.strptime(start_date, '%Y-%m-%d')
@ -252,7 +224,6 @@ def get_logs():
query = query.filter(MaintenanceLog.timestamp.between(start_dt, end_dt)) query = query.filter(MaintenanceLog.timestamp.between(start_dt, end_dt))
except ValueError: except ValueError:
pass pass
logs = query.order_by(MaintenanceLog.timestamp.desc()).all() logs = query.order_by(MaintenanceLog.timestamp.desc()).all()
return jsonify({'code': 200, 'data': [l.to_dict() for l in logs]}) return jsonify({'code': 200, 'data': [l.to_dict() for l in logs]})
@ -278,10 +249,8 @@ def add_log():
@api_bp.route('/logs/update', methods=['POST']) @api_bp.route('/logs/update', methods=['POST'])
def update_log(): def update_log():
data = request.get_json() data = request.get_json()
log_id = data.get('id') log = MaintenanceLog.query.get(data.get('id'))
log = MaintenanceLog.query.get(log_id)
if not log: return jsonify({'code': 404, 'message': 'Not found'}), 404 if not log: return jsonify({'code': 404, 'message': 'Not found'}), 404
try: try:
log.device_name = data.get('device_name', log.device_name) log.device_name = data.get('device_name', log.device_name)
log.engineer = data.get('engineer', log.engineer) log.engineer = data.get('engineer', log.engineer)
@ -306,88 +275,166 @@ def delete_log():
# ======================= # =======================
# 4. 辅助与控制接口 # 4. 一键检测 (双路并行,逻辑隔离)
# ======================= # =======================
def calculate_offset(latest_time_str):
if not latest_time_str or latest_time_str == "N/A": return "从未同步"
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 "时间解析失败"
@api_bp.route('/run_monitor', methods=['POST']) @api_bp.route('/run_monitor', methods=['POST'])
def run_monitor(): def run_monitor():
msg_list = []
try: try:
if not execute_monitor_task: # A. 爬虫任务 (写入历史)
return jsonify({'code': 500, 'message': 'Core module missing'}) # 这部分负责更新 106/82 设备,并记录 DeviceHistory
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
task_result = execute_monitor_task() for item in scraped_list:
if not task_result: return jsonify({'code': 200, 'message': '任务跳过'}) d_name = item.get('name')
if not d_name: continue
scraped_list = task_result.get('device_list', []) device = Device.query.filter_by(name=d_name).first()
current_check_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") if not device:
device = Device(name=d_name, source=item.get('source'), install_site="")
db.session.add(device)
db.session.flush()
count = 0 # 纠正 source (防止爬虫设备被误标为 iot_card)
for item in scraped_list: if device.source == 'iot_card':
d_name = item.get('name') device.source = item.get('source')
if not d_name: continue
d_raw = item.get('raw_json', {}) # 更新主数据
source = item.get('source', '') device.status = item.get('status')
target_time = item.get('target_time') device.current_value = item.get('value')
device.latest_time = item.get('target_time')
device.check_time = current_time
if '106' in str(source): # 更新 JSON
try: old_json = {}
path_str = d_raw.get('path', '') try:
match = re.search(r'/Data/(\d{4}_\d{2}_\d{2})/\w+_(\d{2}_\d{2}_\d{2})\.csv', path_str) old_json = json.loads(device.json_data)
if match: except:
target_time = f"{match.group(1).replace('_', '-')} {match.group(2).replace('_', ':')}" pass
except:
pass
json_str = json.dumps(d_raw, ensure_ascii=False) if isinstance(d_raw, (dict, list)) else str(d_raw) new_json = item.get('raw_json', {})
if isinstance(new_json, dict):
old_json.update(new_json)
device = Device.query.filter_by(name=d_name).first() device.json_data = json.dumps(old_json, ensure_ascii=False)
if not device: device.offset = calculate_offset(device.latest_time)
device = Device(name=d_name, source=source, install_site="")
db.session.add(device)
db.session.flush()
device.status = item.get('status') # 写入历史 (History Table)
device.current_value = item.get('value') new_history = DeviceHistory(
device.latest_time = target_time device_id=device.id,
device.check_time = current_check_time status=item.get('status'),
device.json_data = json_str result_data=item.get('value'),
device.offset = calculate_offset(target_time) data_time=item.get('target_time'),
json_data=device.json_data
)
db.session.add(new_history)
count_crawler += 1
new_history = DeviceHistory( msg_list.append(f"爬虫更新: {count_crawler}")
device_id=device.id, else:
status=item.get('status'), msg_list.append("爬虫无数据")
result_data=item.get('value'),
data_time=target_time, # B. IoT 任务 (只更新 IoT 实时状态)
json_data=json_str # 这部分只更新 source='iot_card' 的记录,不产生历史
) if sync_iot_data_service:
db.session.add(new_history) iot_list = sync_iot_data_service()
count += 1 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() db.session.commit()
return jsonify({'code': 200, 'message': f'成功更新 {count} 台设备,资料已保留'}) return jsonify({'code': 200, 'message': " | ".join(msg_list)})
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
return jsonify({'code': 500, 'message': str(e)}) return jsonify({'code': 500, 'message': str(e)})
# =======================
# 5. 绑定与其他接口
# =======================
@api_bp.route('/sync_iot_cards', methods=['POST'])
def sync_iot_cards():
"""单独同步 IoT (只更新 IoT 表)"""
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():
"""将 ICCID 绑定到 设备"""
data = request.get_json()
device_name = data.get('device_name')
iccid = data.get('iccid')
target = Device.query.filter_by(name=device_name).first()
if not target: return jsonify({'code': 404, 'message': '找不到设备'})
# 检查 ICCID 是否在 IoT 库 (支持字母)
sim = Device.query.filter_by(name=iccid, source='iot_card').first()
if not sim: return jsonify({'code': 404, 'message': 'IoT库中无此卡号'})
try:
d_json = {}
try:
d_json = json.loads(target.json_data)
except:
pass
d_json['bound_iccid'] = iccid
target.json_data = json.dumps(d_json, 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('/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',
current_value='0',
latest_time='N/A',
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']) @api_bp.route('/update_site', methods=['POST'])
def update_site(): def update_site():
data = request.get_json() d = Device.query.filter_by(name=request.json.get('name')).first()
device = Device.query.filter_by(name=data.get('name')).first() if d:
if device: d.install_site = request.json.get('site')
device.install_site = data.get('site')
db.session.commit() db.session.commit()
return jsonify({'code': 200}) return jsonify({'code': 200})
return jsonify({'code': 404}), 404 return jsonify({'code': 404}), 404
@ -395,10 +442,9 @@ def update_site():
@api_bp.route('/toggle_maintenance', methods=['POST']) @api_bp.route('/toggle_maintenance', methods=['POST'])
def toggle_maintenance(): def toggle_maintenance():
data = request.get_json() d = Device.query.filter_by(name=request.json.get('name')).first()
device = Device.query.filter_by(name=data.get('name')).first() if d:
if device: d.is_maintaining = request.json.get('is_maintaining')
device.is_maintaining = data.get('is_maintaining')
db.session.commit() db.session.commit()
return jsonify({'code': 200}) return jsonify({'code': 200})
return jsonify({'code': 404}), 404 return jsonify({'code': 404}), 404
@ -406,54 +452,14 @@ def toggle_maintenance():
@api_bp.route('/toggle_hidden', methods=['POST']) @api_bp.route('/toggle_hidden', methods=['POST'])
def toggle_hidden(): def toggle_hidden():
data = request.get_json() d = Device.query.filter_by(name=request.json.get('name')).first()
device = Device.query.filter_by(name=data.get('name')).first() if d:
if device: d.is_hidden = request.json.get('is_hidden')
device.is_hidden = data.get('is_hidden')
db.session.commit() db.session.commit()
return jsonify({'code': 200}) return jsonify({'code': 200})
return jsonify({'code': 404}), 404 return jsonify({'code': 404}), 404
# ======================= @api_bp.route('/device_data_by_date', methods=['GET'])
# 5. 手动添加设备接口 (新增) def device_data_by_date_stub():
# ======================= return device_data_by_date()
@api_bp.route('/add_device', methods=['POST'])
def add_device():
data = request.get_json()
name = data.get('name')
site = data.get('site', '')
if not name:
return jsonify({'code': 400, 'message': '必须填写设备名称'}), 400
# 1. 检查是否已存在
existing = Device.query.filter_by(name=name).first()
if existing:
return jsonify({'code': 400, 'message': f'设备 {name} 已存在,无需重复添加'}), 400
try:
# 2. 创建新设备记录
# source 标记为 'manual',方便以后区分
# status 默认为 'offline' (离线)
# latest_time 默认为 'N/A'
new_device = Device(
name=name,
install_site=site,
source='manual',
status='offline',
current_value='0',
latest_time='N/A',
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, 'message': '设备添加成功'})
except Exception as e:
db.session.rollback()
return jsonify({'code': 500, 'message': str(e)})

View File

@ -0,0 +1,248 @@
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. 返回完整数据列表 (List[Dict])
"""
print("[IoT Service] 开始同步任务...")
# 1. 登录
token = get_access_token()
if not token:
return []
# 2. 循环翻页获取所有 ICCID
all_iccids = []
page_no = 1
page_size = 100
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 为 list 或 data.rows)
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
# 提取 ICCID
current_batch = [str(x.get('iccid')) for x in rows if x.get('iccid')]
all_iccids.extend(current_batch)
# print(f"DEBUG: page {page_no} done, items: {len(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
# print(f"DEBUG: 开始查询 {total_count} 张卡的详情...")
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):
final_data_list.extend(details)
time.sleep(0.2)
print(f"[IoT Service] 同步完成,共获取 {len(final_data_list)} 条详情数据")
# 4. 返回列表供 api.py 写入数据库
return final_data_list

View File

@ -5,7 +5,7 @@
</main> </main>
<footer class="version-footer"> <footer class="version-footer">
2.1版本 © 2026 Device Monitor 2.2版本 © 2026 Device Monitor
</footer> </footer>
</div> </div>
</template> </template>

View File

@ -14,7 +14,7 @@
<div class="header-actions"> <div class="header-actions">
<el-button type="primary" plain icon="Plus" @click="showAddDialog = true">新增</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="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="warning" plain icon="RefreshRight" :loading="runningTask" @click="runManualMonitor">检测</el-button>
<el-button circle icon="Refresh" :loading="loading" @click="fetchData" /> <el-button circle icon="Refresh" :loading="loading" @click="fetchData" />
@ -26,17 +26,16 @@
<div class="status-summary"> <div class="status-summary">
<el-tag color="#409EFF" effect="dark" class="legend-tag"></el-tag> <el-tag color="#409EFF" effect="dark" class="legend-tag"></el-tag>
<el-tag color="#F56C6C" effect="dark" class="legend-tag">离线/严重滞后</el-tag> <el-tag color="#F56C6C" effect="dark" class="legend-tag">离线 / 严重滞后</el-tag>
<el-tag color="#E6A23C" effect="dark" class="legend-tag">滞后 (>24h)</el-tag> <el-tag color="#E6A23C" effect="dark" class="legend-tag">滞后 (>24h)</el-tag>
<el-tag color="#FAC858" effect="dark" class="legend-tag" style="color: #333">数据异常/昨日</el-tag> <el-tag color="#FAC858" effect="dark" class="legend-tag" style="color: #333">数据异常 / 昨日</el-tag>
<el-tag color="#67C23A" effect="dark" class="legend-tag">正常</el-tag> <el-tag color="#67C23A" effect="dark" class="legend-tag">正常 (当天)</el-tag>
</div> </div>
<div class="toolbar"> <div class="toolbar">
<div class="filter-section"> <div class="filter-section">
<el-radio-group v-model="filters.status" size="default"> <el-radio-group v-model="filters.status" size="default">
<el-radio-button label="all">全部({{ summary.totalCount }})</el-radio-button> <el-radio-button label="all">全部({{ summary.totalCount }})</el-radio-button>
<el-radio-button label="abnormal" class="red-radio"> <el-radio-button label="abnormal" class="red-radio">
状态异常({{ summary.errorCount + summary.warningCount }}) 状态异常({{ summary.errorCount + summary.warningCount }})
</el-radio-button> </el-radio-button>
@ -55,6 +54,12 @@
prefix-icon="Search" prefix-icon="Search"
clearable clearable
/> />
<div class="total-usage-tag">
<el-icon><Odometer /></el-icon>
<span class="label">卡池总用量:</span>
<span class="value">{{ totalUsageSum.toFixed(2) }} M</span>
</div>
</div> </div>
</div> </div>
@ -62,10 +67,10 @@
:data="filteredData" :data="filteredData"
border border
v-loading="loading" v-loading="loading"
style="width: 100%; min-width: 950px;" style="width: 100%; min-width: 1250px;"
:row-class-name="tableRowClassName" :row-class-name="tableRowClassName"
:height="tableHeight" :height="tableHeight"
:default-sort="{ prop: 'sortHours', order: 'descending' }" :default-sort="{ prop: 'sortWeight', order: 'descending' }"
> >
<el-table-column label="状态" width="100" align="center" fixed="left"> <el-table-column label="状态" width="100" align="center" fixed="left">
<template #default="{ row }"> <template #default="{ row }">
@ -82,7 +87,7 @@
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="设备名称 (点击看图)" min-width="200" show-overflow-tooltip> <el-table-column label="设备名称" min-width="180" show-overflow-tooltip>
<template #default="{ row }"> <template #default="{ row }">
<div <div
class="device-name-wrapper" class="device-name-wrapper"
@ -93,11 +98,12 @@
{{ formatDisplayName(row.name) }} {{ formatDisplayName(row.name) }}
</span> </span>
<el-icon v-if="!row.is_hidden" class="link-icon"><DataLine /></el-icon> <el-icon v-if="!row.is_hidden" class="link-icon"><DataLine /></el-icon>
<el-tag v-if="row.isBound" size="small" type="info" effect="plain" style="margin-left:5px; height: 18px; line-height: 16px; padding:0 4px;"></el-tag>
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="安装地点" min-width="160"> <el-table-column label="安装地点" min-width="140">
<template #default="{ row }"> <template #default="{ row }">
<div v-if="row.isEditingSite" class="editing-cell"> <div v-if="row.isEditingSite" class="editing-cell">
<el-input <el-input
@ -116,35 +122,41 @@
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="数据时效与质量" width="240" prop="sortHours" sortable> <el-table-column label="使用量" width="120" prop="trafficNum" sortable>
<template #default="{ row }"> <template #default="{ row }">
<div style="font-size: 13px; display:flex; align-items:center; gap:5px;"> <span v-if="row.isBound && row.trafficNum >= 0" style="font-weight: 600; color: #409EFF;">{{ row.trafficNum }} M</span>
<el-icon><Clock /></el-icon> {{ row.latest_time || '--' }} <span v-else style="color: #ccc;">--</span>
</template>
</el-table-column>
<el-table-column label="截止时间" width="150">
<template #default="{ row }">
<span v-if="row.isBound && row.stopDate" style="font-size: 13px;">{{ row.stopDate }}</span>
<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 || 'N/A' }}
</div> </div>
<div v-if="!row.is_maintaining && !row.is_hidden"> <div v-if="!row.is_maintaining && !row.is_hidden">
<div v-if="row.statusType === 'error'" class="status-text error-text">
<div v-if="row.status === 'offline' || row.status === '已离线'" class="status-text error-text"> {{ row.statusReason }}
设备已离线
</div> </div>
<div v-else-if="row.statusType === 'warning'" class="status-text warning-text">
<div v-else-if="row.diffDays > 7" class="status-text error-text"> {{ row.statusReason }}
严重滞后 {{ Math.floor(row.diffDays) }}
</div> </div>
<div v-else-if="row.statusType === 'slight-warning'" class="status-text slight-warning-text">
<div v-else-if="row.diffHours > 24" class="status-text warning-text"> {{ row.statusReason }}
滞后 {{ Math.floor(row.diffDays) }}
</div> </div>
<div v-else-if="!row.isToday" class="status-text slight-warning-text">
昨日数据
</div>
<div v-else class="status-text success-text"> <div v-else class="status-text success-text">
时效最新 时效最新
</div> </div>
<div v-if="row.status !== 'offline' && row.status !== '已离线'" style="margin-top: 4px;"> <div v-if="row.statusType !== 'error' && row.statusType !== 'warning'" style="margin-top: 4px;">
<el-tag v-if="row.data_quality === 'error'" type="danger" size="small" effect="dark"> <el-tag v-if="row.data_quality === 'error'" type="danger" size="small" effect="dark">
<el-icon><Warning /></el-icon> 数据严重异常 <el-icon><Warning /></el-icon> 数据严重异常
</el-tag> </el-tag>
@ -155,7 +167,6 @@
数值正常 数值正常
</el-tag> </el-tag>
</div> </div>
</div> </div>
<div v-else-if="row.is_maintaining" class="status-text maintenance-text">🛠 维护中</div> <div v-else-if="row.is_maintaining" class="status-text maintenance-text">🛠 维护中</div>
@ -192,6 +203,7 @@
<DataMonitor ref="dataMonitorRef" /> <DataMonitor ref="dataMonitorRef" />
<MaintenanceLogs ref="maintenanceLogsRef" /> <MaintenanceLogs ref="maintenanceLogsRef" />
<IoTDeviceBinder ref="iotBinderRef" @update-success="fetchData" />
<el-dialog v-model="showAddDialog" title="手动添加设备" width="400px" align-center> <el-dialog v-model="showAddDialog" title="手动添加设备" width="400px" align-center>
<el-form :model="newDeviceForm" label-width="80px"> <el-form :model="newDeviceForm" label-width="80px">
@ -220,10 +232,11 @@ import { ref, reactive, computed, onMounted, nextTick, onBeforeUnmount } from 'v
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import axios from 'axios' import axios from 'axios'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Clock, DataLine, Document, Refresh, EditPen, Search, Edit, RefreshRight, Delete, RefreshLeft, SwitchButton, Warning, WarningFilled, Plus } from '@element-plus/icons-vue' import { Clock, DataLine, Document, Refresh, EditPen, Search, Edit, RefreshRight, Delete, RefreshLeft, SwitchButton, Warning, WarningFilled, Plus, Odometer, Link } from '@element-plus/icons-vue'
import DataMonitor from './DataMonitor.vue' import DataMonitor from './DataMonitor.vue'
import MaintenanceLogs from './MaintenanceLogs.vue' import MaintenanceLogs from './MaintenanceLogs.vue'
import IoTDeviceBinder from './IoTDeviceBinder.vue'
const router = useRouter() const router = useRouter()
const loading = ref(false) const loading = ref(false)
@ -241,27 +254,16 @@ const tableHeight = computed(() => {
const filters = reactive({ status: 'all', keyword: '' }) const filters = reactive({ status: 'all', keyword: '' })
const API_BASE = import.meta.env.DEV ? 'http://127.0.0.1:5000' : '' const API_BASE = import.meta.env.DEV ? 'http://127.0.0.1:5000' : ''
const dataMonitorRef = ref(null) const dataMonitorRef = ref(null)
const maintenanceLogsRef = ref(null) const maintenanceLogsRef = ref(null)
const iotBinderRef = ref(null)
// === 添加设备相关变量 ===
const showAddDialog = ref(false) const showAddDialog = ref(false)
const isAdding = ref(false) const isAdding = ref(false)
const newDeviceForm = reactive({ name: '', site: '' }) const newDeviceForm = reactive({ name: '', site: '' })
// === 统计逻辑 (修改:增加了 totalCount) === // === 核心数据处理逻辑 ===
const summary = computed(() => {
const active = rawData.value.filter(r => !r.is_hidden)
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 fetchData = async () => { const fetchData = async () => {
loading.value = true loading.value = true
try { try {
@ -271,12 +273,16 @@ const fetchData = async () => {
rawData.value = backendList.map(item => { rawData.value = backendList.map(item => {
const isHidden = item.is_hidden === true || item.is_hidden === 1 const isHidden = item.is_hidden === true || item.is_hidden === 1
let diffDays = 0, diffHours = 0, isToday = false, validTime = false const isBound = !!item.isBound
const isOrphanIoT = (item.source === 'iot_card')
// === 1. 时间计算逻辑 === // 1. 数据时效
if (item.latest_time && item.latest_time !== 'N/A') { let diffDays = 0, diffHours = 0, isToday = false, validTime = false
const cleanDateStr = item.latest_time.toString().replace(/_/g, '-') let timeStr = item.latest_time
const d = new Date(cleanDateStr)
if (timeStr && timeStr !== 'N/A') {
const cleanTime = timeStr.toString().replace(/_/g, '-')
const d = new Date(cleanTime)
if (!isNaN(d.getTime())) { if (!isNaN(d.getTime())) {
validTime = true validTime = true
isToday = d.toDateString() === now.toDateString() isToday = d.toDateString() === now.toDateString()
@ -286,137 +292,119 @@ const fetchData = async () => {
} }
} }
// === 2. 排序权重计算 === // 2. 状态判定与排序 (sortWeight: 越大越靠前)
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
// === 3. 状态分类逻辑 ===
let statusColor = '#67C23A', statusLabel = '正常', statusType = 'normal', statusLabelColor = '#fff' let statusColor = '#67C23A', statusLabel = '正常', statusType = 'normal', statusLabelColor = '#fff'
let statusReason = ''
let sortWeight = diffHours
if (item.is_maintaining) { if (item.is_maintaining) {
statusColor = '#409EFF'; statusLabel = '维修中'; statusType = 'maintenance'; statusColor = '#409EFF'; statusLabel = '维修中'; statusType = 'maintenance';
} else if ((item.status === 'offline' || item.status === '已离线')) { // [修复] 维修中置顶:使用最大安全整数
statusLabel = '离线'; statusColor = '#F56C6C'; statusType = 'error'; sortWeight = Number.MAX_SAFE_INTEGER;
} else if (!validTime || diffDays > 7) { } else if (!validTime) {
statusLabel = '未知'; statusColor = '#909399'; statusType = 'slight-warning'; statusReason = '从未同步';
sortWeight = 90000000;
} else if (item.status === 'offline') {
statusLabel = '离线'; statusColor = '#F56C6C'; statusType = 'error'; statusReason = '设备离线';
sortWeight = 80000000;
} else if (diffDays > 7) {
statusLabel = '严重滞后'; statusColor = '#F56C6C'; statusType = 'error'; statusLabel = '严重滞后'; statusColor = '#F56C6C'; statusType = 'error';
statusReason = `严重滞后 ${Math.floor(diffDays)}`;
} else if (diffHours > 24) { } else if (diffHours > 24) {
statusLabel = '滞后'; statusColor = '#E6A23C'; statusType = 'warning'; statusLabel = '滞后'; statusColor = '#E6A23C'; statusType = 'warning';
statusReason = `滞后 ${Math.floor(diffDays)}`;
} else if (!isToday) { } else if (!isToday) {
statusLabel = '昨日数据'; statusColor = '#FAC858'; statusType = 'slight-warning'; statusLabelColor = '#333'; statusLabel = '昨日数据'; statusColor = '#FAC858'; statusType = 'slight-warning'; statusLabelColor = '#333';
statusReason = '非今日数据';
} else {
sortWeight = 0;
} }
// 3. 流量值解析 (防御性:确保是数字)
let trafficNum = 0
// 尝试直接取
if (item.usedTraffic) {
trafficNum = parseFloat(item.usedTraffic)
} else if (item.json_data) {
// 尝试从 json 中取
try {
const j = JSON.parse(item.json_data)
if (j.usedTraffic) trafficNum = parseFloat(j.usedTraffic)
} catch(e) {}
}
if (isNaN(trafficNum)) trafficNum = 0
return { return {
...item, ...item,
is_hidden: isHidden, is_hidden: isHidden,
diffDays, diffHours, sortHours, isToday, isOrphanIoT,
statusColor, statusLabel, statusType, statusLabelColor, isBound,
diffDays, diffHours, sortWeight, isToday,
statusColor, statusLabel, statusType, statusLabelColor, statusReason,
isEditingSite: false, tempSite: '', isEditingSite: false, tempSite: '',
data_quality: item.data_quality || 'ok' data_quality: item.data_quality || 'ok',
trafficNum
} }
}).sort((a, b) => b.sortHours - a.sortHours) }).sort((a, b) => b.sortWeight - a.sortWeight) // 按权重降序
lastCheckTime.value = new Date().toLocaleString() lastCheckTime.value = new Date().toLocaleString()
} catch (e) { } catch (e) {
ElMessage.error('获取数据失败') ElMessage.error('获取数据失败')
console.error(e)
} finally { } finally {
loading.value = false loading.value = false
} }
} }
// === 筛选逻辑 === // === 筛选逻辑 ===
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(() => { const filteredData = computed(() => {
return rawData.value.filter(item => { return rawData.value.filter(item => {
if (item.isOrphanIoT) return false
if (filters.status === 'hidden') return item.is_hidden if (filters.status === 'hidden') return item.is_hidden
if (item.is_hidden) return false if (item.is_hidden) return false
if (filters.status === 'abnormal') return ['error', 'warning', 'slight-warning'].includes(item.statusType)
if (filters.status === 'abnormal') { if (filters.status === 'data_error') return ['error', 'warning'].includes(item.data_quality)
return (item.statusType === 'error' || item.statusType === 'warning' || item.statusType === 'slight-warning')
}
if (filters.status === 'data_error') {
return (item.data_quality === 'error' || item.data_quality === 'warning')
}
return true return true
}).filter(item => !filters.keyword || item.name.toLowerCase().includes(filters.keyword.toLowerCase())) }).filter(item => !filters.keyword || item.name.toLowerCase().includes(filters.keyword.toLowerCase()))
}) })
// === 提交新设备逻辑 === // === [重要修复] 卡池总用量计算 ===
const handleAddDeviceSubmit = async () => { const totalUsageSum = computed(() => {
if (!newDeviceForm.name) { return rawData.value.reduce((sum, item) => {
ElMessage.warning('请填写设备名称') // 1. 只统计 source='iot_card' (代表SIM卡)
return // 2. 累加 item.trafficNum (这是我们在 fetchData 里解析好的数字)
} if (item.source === 'iot_card') {
isAdding.value = true return sum + (item.trafficNum || 0)
try { }
const res = await axios.post(`${API_BASE}/api/add_device`, { return sum
name: newDeviceForm.name, }, 0)
site: newDeviceForm.site })
})
ElMessage.success(res.data.message)
showAddDialog.value = false
// 清空表单 // ... 交互函数保持不变 ...
newDeviceForm.name = '' const handleAddDeviceSubmit = async () => { if (!newDeviceForm.name) return; await axios.post(`${API_BASE}/api/add_device`, newDeviceForm); showAddDialog.value=false; fetchData() }
newDeviceForm.site = ''
// 刷新列表
fetchData()
} catch (error) {
const msg = error.response?.data?.message || '添加失败'
ElMessage.error(msg)
} finally {
isAdding.value = false
}
}
// === 辅助功能函数 ===
const handleDeviceClick = (row) => { if (!row.is_hidden && dataMonitorRef.value) dataMonitorRef.value.open(row) } 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 openLogCenter = (row) => { if (maintenanceLogsRef.value) maintenanceLogsRef.value.open(row ? { deviceName: row.name } : null) }
const runManualMonitor = async () => { const openIoTBinder = () => { if (iotBinderRef.value) iotBinderRef.value.open() }
runningTask.value = true const runManualMonitor = async () => { runningTask.value=true; await axios.post(`${API_BASE}/api/run_monitor`); setTimeout(()=>fetchData(), 3000); setTimeout(()=>runningTask.value=false, 1000) }
try { const handleEditSite = (row) => { row.tempSite = row.install_site; row.isEditingSite = true; nextTick(() => document.querySelector('.site-input-inner input')?.focus()) }
const res = await axios.post(`${API_BASE}/api/run_monitor`) 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 }
ElMessage.success(res.data.message || '任务启动') 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)) }) }
setTimeout(() => fetchData(), 3000) const toggleHidden = async (row, val) => { await axios.post(`${API_BASE}/api/toggle_hidden`, {name:row.name, is_hidden:val}); row.is_hidden=val; fetchData() }
} catch (e) { ElMessage.warning('请求频繁') } const handleLogout = () => { localStorage.removeItem('token'); router.push('/') }
finally { setTimeout(() => { runningTask.value = false }, 1000) }
}
const handleEditSite = (row) => {
row.tempSite = row.install_site; row.isEditingSite = true
nextTick(() => { const inputs = document.querySelectorAll('.site-input-inner input'); if(inputs.length) 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(); resolve(true) })
.catch(() => { resolve(false) })
})
}
const toggleHidden = async (row, val) => {
try { await axios.post(`${API_BASE}/api/toggle_hidden`, { name: row.name, is_hidden: val }); row.is_hidden = val; fetchData(); }
catch (e) { ElMessage.error('操作失败') }
}
const handleLogout = () => {
ElMessageBox.confirm('确定退出?', '提示', { type: 'warning' }).then(() => { localStorage.removeItem('token'); router.push('/'); }).catch(() => {})
}
const formatDisplayName = (name) => name ? name.toUpperCase().replace(/_/g, ' ') : '' const formatDisplayName = (name) => name ? name.toUpperCase().replace(/_/g, ' ') : ''
// 行样式计算
const tableRowClassName = ({ row }) => { const tableRowClassName = ({ row }) => {
if (row.is_hidden) return 'hidden-row' if (row.is_hidden) return 'hidden-row'
if (row.data_quality === 'error') return 'data-error-row'
if (row.statusType === 'error') return 'error-row' if (row.statusType === 'error') return 'error-row'
if (row.data_quality === 'warning') return 'data-warning-row'
if (row.statusType === 'warning') return 'warning-row' if (row.statusType === 'warning') return 'warning-row'
if (row.statusType === 'maintenance') return 'maintenance-row' if (row.statusType === 'maintenance') return 'maintenance-row'
return '' return ''
@ -427,6 +415,7 @@ onBeforeUnmount(() => window.removeEventListener('resize', updateDimensions))
</script> </script>
<style scoped> <style scoped>
/* 样式部分保持原样 */
.dashboard-container { padding: 10px; background: #f5f7fa; min-height: 100vh; box-sizing: border-box; } .dashboard-container { padding: 10px; background: #f5f7fa; min-height: 100vh; box-sizing: border-box; }
.main-card { border-radius: 8px; } .main-card { border-radius: 8px; }
.header-row { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; } .header-row { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; }
@ -438,38 +427,31 @@ onBeforeUnmount(() => window.removeEventListener('resize', updateDimensions))
.toolbar { background: #fff; padding: 10px; border-radius: 6px; margin-bottom: 10px; border: 1px solid #e4e7ed; } .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; } .filter-section { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.search-input { width: 220px; } .search-input { width: 220px; }
/* Radio 颜色 */
:deep(.red-radio.is-active .el-radio-button__inner) { background-color: #F56C6C; border-color: #F56C6C; box-shadow: -1px 0 0 0 #F56C6C; } :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(.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(.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(.red-radio .el-radio-button__inner), :deep(.yellow-radio .el-radio-button__inner), :deep(.gray-radio .el-radio-button__inner) { color: #606266; }
:deep(.yellow-radio .el-radio-button__inner), .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; }
:deep(.gray-radio .el-radio-button__inner) { color: #606266; } .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 { display: flex; align-items: center; gap: 5px; cursor: pointer; }
.device-name { font-weight: bold; font-size: 14px; color: #303133; } .device-name { font-weight: bold; font-size: 14px; color: #303133; }
.device-name-wrapper:hover .device-name { color: #409EFF; text-decoration: underline; } .device-name-wrapper:hover .device-name { color: #409EFF; text-decoration: underline; }
.text-deleted { text-decoration: line-through; color: #999; } .text-deleted { text-decoration: line-through; color: #999; }
.status-text { font-size: 12px; margin-top: 4px; font-weight: bold; } .status-text { font-size: 12px; margin-top: 4px; font-weight: bold; }
.error-text { color: #F56C6C; } .error-text { color: #F56C6C; }
.warning-text { color: #E6A23C; } .warning-text { color: #E6A23C; }
.success-text { color: #67C23A; } .success-text { color: #67C23A; }
.slight-warning-text { color: #E6A23C; } .slight-warning-text { color: #E6A23C; }
.maintenance-text { color: #409EFF; } .maintenance-text { color: #409EFF; }
.display-cell { cursor: pointer; padding: 4px 0; display: flex; justify-content: space-between; } .display-cell { cursor: pointer; padding: 4px 0; display: flex; justify-content: space-between; }
.edit-icon { color: #409EFF; } .edit-icon { color: #409EFF; }
/* 表格行背景色 */
:deep(.error-row) { background-color: #fef0f0 !important; } :deep(.error-row) { background-color: #fef0f0 !important; }
:deep(.warning-row) { background-color: #fdf6ec !important; } :deep(.warning-row) { background-color: #fdf6ec !important; }
:deep(.maintenance-row) { background-color: #f0f9ff !important; } :deep(.maintenance-row) { background-color: #f0f9ff !important; }
:deep(.hidden-row) { background-color: #f4f4f5 !important; color: #909399; } :deep(.hidden-row) { background-color: #f4f4f5 !important; color: #909399; }
:deep(.data-error-row) { background-color: #ffe6e6 !important; } :deep(.data-error-row) { background-color: #ffe6e6 !important; }
:deep(.data-warning-row) { background-color: #fffbe6 !important; } :deep(.data-warning-row) { background-color: #fffbe6 !important; }
@media screen and (max-width: 768px) { @media screen and (max-width: 768px) {
.dashboard-container { padding: 5px; } .dashboard-container { padding: 5px; }
.left-panel, .header-actions { width: 100%; justify-content: space-between; margin-bottom: 5px; } .left-panel, .header-actions { width: 100%; justify-content: space-between; margin-bottom: 5px; }
@ -478,5 +460,6 @@ onBeforeUnmount(() => window.removeEventListener('resize', updateDimensions))
.el-radio-group { width: 100%; display: flex; } .el-radio-group { width: 100%; display: flex; }
.el-radio-button { flex: 1; } .el-radio-button { flex: 1; }
.search-input { width: 100%; margin-top: 5px; } .search-input { width: 100%; margin-top: 5px; }
.total-usage-tag { width: 100%; justify-content: center; margin: 5px 0 0 0; }
} }
</style> </style>

View File

@ -0,0 +1,389 @@
<template>
<el-config-provider :locale="zhCn">
<el-dialog
v-model="visible"
title="🔗 IoT 卡片管理与绑定"
width="950px"
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. 此处展示所有 IoT 卡片 (包括已绑定和未绑定的)</div>
<div>2. <b>绑定要求</b> 目标设备必须是系统中已存在的设备不能随意输入不存在的名称</div>
<div>3. 绑定后该设备的流量数据将由对应的 ICCID 卡片提供</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="320">
<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: 200px;"
@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" title="确认绑定" />
<el-button type="info" size="small" icon="Close" circle @click="cancelEdit(row)" title="取消" />
</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="100" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 'online' ? 'success' : 'info'" effect="dark" size="small">
{{ row.status === 'online' ? '在线' : '离线' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="当月用量" width="120" align="right">
<template #default="{ row }">
<span>{{ row.usedTraffic }} M</span>
</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([]) // 所有的 SIM 卡列表
const displayList = ref([]) // 表格展示列表
const allDeviceNames = ref([]) // 所有的真实设备 (用于自动补全和验证)
// 筛选条件
const keyword = ref('')
const filterStatus = ref('unbound') // 默认看未绑定的
const emit = defineEmits(['update-success'])
// 1. 打开弹窗
const open = () => {
visible.value = true
filterStatus.value = 'unbound' // 每次打开默认只看未绑定的,方便操作
fetchIoTDevices()
}
// 2. 获取数据 (逻辑升级:聚合所有卡片信息)
const fetchIoTDevices = async () => {
loading.value = true
try {
const res = await axios.get(`${API_BASE}/api/devices_overview`)
const allData = res.data.data || []
// 1. 构建 [ICCID -> 设备名] 的查找表 (核心修复点)
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
// 2. 提取所有 SIM 卡记录 (source='iot_card')
// 这些就是所有的物理卡片
const cards = allData.filter(d => d.source === 'iot_card')
const tempList = cards.map(c => {
const iccid = c.name // 这里的 name 就是 ICCID
// 尝试解析 JSON
let j = {}
try { j = JSON.parse(c.json_data || '{}') } catch(e){}
// **核心修复**:直接从映射表里查,这张卡被谁绑定了
// 如果 iccidToDeviceMap[iccid] 有值,说明它被绑定了
const ownerDevice = iccidToDeviceMap[iccid] || ''
// 流量解析
let traffic = c.usedTraffic || j.usedTraffic || '0'
return {
iccid: iccid,
tag: j.tag || '',
usedTraffic: traffic,
boundDeviceName: ownerDevice, // 这样刷新后绑定关系绝对不会丢
targetDeviceName: ownerDevice,
status: c.status || 'offline',
isEditing: false,
saving: false
}
})
fullSimList.value = tempList
filterList()
} catch (e) {
ElMessage.error('加载列表失败')
console.error(e)
} finally {
loading.value = false
}
}
// 3. 列表筛选
const filterList = () => {
let list = fullSimList.value
// 状态筛选
if (filterStatus.value === 'bound') {
list = list.filter(item => item.boundDeviceName)
} else if (filterStatus.value === 'unbound') {
list = list.filter(item => !item.boundDeviceName)
}
// 关键词筛选
if (keyword.value) {
const k = keyword.value.toLowerCase()
list = list.filter(item =>
item.iccid.toLowerCase().includes(k) ||
(item.boundDeviceName && item.boundDeviceName.toLowerCase().includes(k))
)
}
displayList.value = list
}
// 4. 开始编辑/绑定
const startEdit = (row) => {
// 取消其他行的编辑状态
displayList.value.forEach(item => { if(item !== row) cancelEdit(item) })
// 设置输入框初始值:如果是修改,填入旧名字;如果是新绑定,为空
row.targetDeviceName = row.boundDeviceName || ''
row.isEditing = true
nextTick(() => {
// 自动聚焦
const el = document.querySelector('.edit-cell input')
if (el) el.focus()
})
}
const cancelEdit = (row) => {
row.isEditing = false
// 恢复原值 (如果没保存)
row.targetDeviceName = row.boundDeviceName
}
// 5. 自动补全逻辑 (支持模糊搜索)
const querySearchDevice = (queryString, cb) => {
const results = queryString
? allDeviceNames.value.filter(createFilter(queryString))
: allDeviceNames.value
cb(results)
}
// 模糊匹配逻辑:包含即可
const createFilter = (queryString) => {
return (item) => {
return (item.value.toLowerCase().indexOf(queryString.toLowerCase()) > -1)
}
}
const handleSelectDevice = (item) => {
// 选中后自动触发保存逻辑可以在这里写,但为了安全,还是让用户按回车或点对号
}
// 6. 保存绑定 (核心逻辑 + 强制验证)
const saveBinding = async (row) => {
const targetName = row.targetDeviceName
if (!targetName) return ElMessage.warning('请输入目标设备名称')
// [新增] 强制验证:输入的名字必须存在于 allDeviceNames 列表中
const isValidDevice = allDeviceNames.value.some(d => d.value === targetName)
if (!isValidDevice) {
ElMessage.error(`设备 "${targetName}" 不存在!请先在主界面新增该设备。`)
return
}
row.saving = true
try {
// 调用后端绑定接口
await axios.post(`${API_BASE}/api/bind_device_card`, {
iccid: row.iccid,
device_name: targetName
})
ElMessage.success(`绑定成功: ${row.iccid} -> ${targetName}`)
// 更新本地状态,避免必须刷新全量
row.boundDeviceName = targetName
row.isEditing = false
// 重新触发筛选逻辑 (因为绑定状态变了)
filterList()
// 通知父组件刷新主列表
emit('update-success')
} catch (e) {
const msg = e.response?.data?.message || '绑定失败'
ElMessage.error(msg)
} finally {
row.saving = false
}
}
const handleClose = () => {
visible.value = false
}
defineExpose({ open })
</script>
<style scoped>
.binder-container { padding: 5px; }
.tips-alert { margin-bottom: 15px; }
.toolbar { display: flex; align-items: center; margin-bottom: 15px; }
.iccid-text { font-family: monospace; font-weight: bold; color: #606266; font-size: 13px; }
/* 绑定状态单元格 */
.bound-cell { display: flex; align-items: center; justify-content: space-between; }
.bound-tag { font-size: 13px; padding: 0 10px; height: 28px; line-height: 26px; }
.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;
align-items: center;
width: 100%;
}
.device-name-highlight { font-weight: bold; color: #333; }
.suggestion-site { color: #999; font-size: 12px; margin-left: 10px; }
.empty-tip {
text-align: center;
color: #909399;
padding: 40px;
}
</style>