修改新增加文件数量的查询功能

This commit is contained in:
DXC
2026-02-03 17:15:42 +08:00
parent 195c3f8fa4
commit e093ae9633
12 changed files with 548 additions and 172 deletions

View File

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

View File

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

View File

@ -2,7 +2,10 @@ import os
import sys import sys
import json import json
import mimetypes import mimetypes
import logging
from datetime import datetime from datetime import datetime
import pytz # ✅ 必须引入:用于强制指定北京时间
from flask import Flask, send_from_directory, jsonify from flask import Flask, send_from_directory, jsonify
from flask_cors import CORS from flask_cors import CORS
from flask_apscheduler import APScheduler from flask_apscheduler import APScheduler
@ -11,142 +14,141 @@ from flask_apscheduler import APScheduler
# ✅ 1. 核心模块引用 # ✅ 1. 核心模块引用
# ============================================================================== # ==============================================================================
try: try:
# 导入配置类
from config import Config from config import Config
# 数据库实例
from extensions import db from extensions import db
# 数据模型
from models import Device, DeviceHistory from models import Device, DeviceHistory
# 核心业务逻辑 (爬虫)
from services.core import execute_monitor_task from services.core import execute_monitor_task
# 核心业务逻辑 (IoT) - 用于定时任务 # from services.iot_api import sync_iot_data_service # 如果不需要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 calculate_offset
from routes.api import save_iot_cards_to_db, calculate_offset
except ImportError: except ImportError:
from routes.api import device_bp, save_iot_cards_to_db, calculate_offset from routes.api import device_bp, calculate_offset
except ImportError as e: except ImportError as e:
print(f"❌ 严重错误: 模块导入失败。请检查文件名和变量名。详细信息: {e}") print(f"❌ 严重错误: 模块导入失败。详细信息: {e}")
sys.exit(1) sys.exit(1)
# ============================================================================== # ==============================================================================
# 2. 路径计算 (核心修复:区分资源路径和数据路径) # 2. 路径配置
# ============================================================================== # ==============================================================================
def get_paths(): def get_paths():
"""
计算关键路径:
1. resource_base: 用于存放 web_dist (打包后在临时目录)
2. data_base: 用于存放数据库 (打包后在 exe 旁边)
"""
if getattr(sys, 'frozen', False): if getattr(sys, 'frozen', False):
# --- 打包环境 (PyInstaller) ---
# 资源文件在临时解压目录 (sys._MEIPASS)
resource_base = sys._MEIPASS resource_base = sys._MEIPASS
# 数据文件(数据库)在 exe 所在目录 (sys.executable 的父目录)
data_base = os.path.dirname(sys.executable) data_base = os.path.dirname(sys.executable)
else: else:
# --- 开发环境 ---
base = os.path.abspath(os.path.dirname(__file__)) base = os.path.abspath(os.path.dirname(__file__))
resource_base = base resource_base = base
data_base = base data_base = base
return resource_base, data_base return resource_base, data_base
# 获取路径
RESOURCE_BASE, DATA_BASE = get_paths() RESOURCE_BASE, DATA_BASE = get_paths()
# 定义具体文件夹路径
STATIC_FOLDER = os.path.join(RESOURCE_BASE, 'web_dist') STATIC_FOLDER = os.path.join(RESOURCE_BASE, 'web_dist')
# ⚠️ 关键:强制将 instance 文件夹定位到数据目录 (exe旁边),而不是临时目录
INSTANCE_PATH = os.path.join(DATA_BASE, 'instance') INSTANCE_PATH = os.path.join(DATA_BASE, 'instance')
# 修复 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')
# ============================================================================== # ==============================================================================
# 3. 定时任务逻辑 (保持不变) # 3. 核心定时任务逻辑 (加强版)
# ============================================================================== # ==============================================================================
def auto_monitor_job(app): def auto_monitor_job(app):
"""定时任务具体执行逻辑""" """
每天 12:00 触发的爬虫任务
"""
# ✅ 强制使用应用上下文,确保数据库连接有效
with app.app_context(): with app.app_context():
print(f"⏰ [定时任务] 启动时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") # 获取当前北京时间用于日志
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}")
print(f"🚀 正在开始执行爬虫逻辑...")
if not execute_monitor_task:
print("❌ 错误: 未找到爬虫执行函数 (execute_monitor_task)")
return
# --- 任务 A: 爬虫更新 ---
if execute_monitor_task:
try: try:
# 1. 执行爬取
task_result = execute_monitor_task() task_result = execute_monitor_task()
if task_result:
if not task_result:
print("⚠️ [警告] 爬虫执行完毕,但返回空数据 (None)")
return
scraped_list = task_result.get('device_list', []) scraped_list = task_result.get('device_list', [])
print(f"📦 [数据获取] 爬虫返回了 {len(scraped_list)} 条设备数据")
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
count = 0 success_count = 0
# 2. 遍历入库
for item in scraped_list: for item in scraped_list:
d_name = item.get('name') d_name = item.get('name')
if not d_name: continue if not d_name: continue
# 查找或新建设备
device = Device.query.filter_by(name=d_name).first() device = Device.query.filter_by(name=d_name).first()
if not device: if not device:
print(f"🆕 发现新设备: {d_name},正在创建...")
device = Device(name=d_name, source=item.get('source'), install_site="") device = Device(name=d_name, source=item.get('source'), install_site="")
db.session.add(device) db.session.add(device)
db.session.flush() db.session.flush() # 立即获取 ID
# 更新设备状态表
device.status = item.get('status') device.status = item.get('status')
device.current_value = item.get('value') device.current_value = item.get('value')
device.latest_time = item.get('target_time') device.latest_time = item.get('target_time')
device.check_time = current_time device.check_time = current_time # 更新检查时间证明爬过了
# =========== ✅ 核心修复开始:防止丢失 bound_iccid =========== f_count = item.get('num_files', 0)
device.file_count = f_count
# 1. 准备容器:先读取数据库里现有的 JSON 数据 # 计算 offset
device.offset = calculate_offset(item.get('target_time'))
# JSON 字段合并逻辑
old_json = {} old_json = {}
try: try:
if device.json_data: if device.json_data:
old_json = json.loads(device.json_data) old_json = json.loads(device.json_data)
except Exception: except:
old_json = {} old_json = {}
# 2. 获取爬虫新数据
new_json = item.get('raw_json', {}) new_json = item.get('raw_json', {})
# 3. 合并数据:只更新新爬取到的字段,保留 old_json 里的 bound_iccid
if isinstance(new_json, dict): if isinstance(new_json, dict):
old_json.update(new_json) old_json.update(new_json)
# 4. 存回数据库
device.json_data = json.dumps(old_json, ensure_ascii=False) device.json_data = json.dumps(old_json, ensure_ascii=False)
# =========== ✅ 核心修复结束 =========== # ✅ 3. 写入历史记录 (这是数据留存的关键)
history_entry = DeviceHistory(
device.offset = calculate_offset(item.get('target_time'))
db.session.add(DeviceHistory(
device_id=device.id, device_id=device.id,
status=device.status, status=device.status,
result_data=device.current_value, result_data=device.current_value,
data_time=item.get('target_time'), data_time=item.get('target_time'), # 文件的时间
json_data=device.json_data json_data=device.json_data,
)) file_count=f_count,
count += 1 create_time=datetime.now() # 记录入库时的系统时间
print(f"✅ [定时任务-爬虫] 更新 {count}") )
else: db.session.add(history_entry)
print("⚠️ [定时任务-爬虫] 未获取到数据") success_count += 1
# ✅ 4. 显式提交事务
print(f"💾 正在提交事务到数据库...")
db.session.commit()
print(f"✅ [成功] 已更新 {success_count} 台设备,并写入历史记录。")
print(f"{'=' * 50}\n")
except Exception as e: except Exception as e:
print(f"❌ [定时任务-爬虫] 异常: {e}") db.session.rollback() # 出错回滚
print(f"❌ [严重异常] 定时任务执行失败: {e}")
import traceback import traceback
traceback.print_exc() traceback.print_exc()
@ -155,27 +157,18 @@ def auto_monitor_job(app):
# 4. Flask 应用工厂 # 4. Flask 应用工厂
# ============================================================================== # ==============================================================================
def create_app(): def create_app():
# ⚠️ 关键修改:显式传入 instance_path告诉 Flask 去哪里找/存 数据库文件
app = Flask(__name__, static_folder=STATIC_FOLDER, instance_path=INSTANCE_PATH) app = Flask(__name__, static_folder=STATIC_FOLDER, instance_path=INSTANCE_PATH)
CORS(app) CORS(app)
# 1. 确保 instance 目录存在 (在 exe 旁边创建文件夹) # 数据库路径配置
if not os.path.exists(app.instance_path): if not os.path.exists(app.instance_path):
try:
os.makedirs(app.instance_path, exist_ok=True) os.makedirs(app.instance_path, exist_ok=True)
print(f"📁 已创建数据目录: {app.instance_path}")
except OSError as e:
print(f"❌ 无法创建数据目录 {app.instance_path}: {e}")
# 2. 加载配置
app.config.from_object(Config) app.config.from_object(Config)
# ⚠️ 关键修改:强制重写数据库 URI确保使用绝对路径 db_name = 'monitor_data.db'
# 即使 Config 里写了,这里也要确保它指向我们刚才计算出的 INSTANCE_PATH
db_name = 'monitor_data.db' # 你的数据库文件名
db_path = os.path.join(app.instance_path, db_name) db_path = os.path.join(app.instance_path, db_name)
# Windows下路径分隔符处理防止报错
if sys.platform.startswith('win'): if sys.platform.startswith('win'):
app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{db_path}' app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{db_path}'
else: else:
@ -183,36 +176,43 @@ def create_app():
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
print(f"💾 数据库路径锁定为: {db_path}") # ✅ APScheduler 配置
app.config['SCHEDULER_API_ENABLED'] = True
app.config['SCHEDULER_TIMEZONE'] = "Asia/Shanghai" # 全局时区设置
# 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()
# 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,
args=[app], args=[app],
trigger='cron', trigger='cron',
hour=12, hour=12, # 每天 12 点
minute=0 minute=0,
second=0,
misfire_grace_time=3600, # 允许延迟1小时执行
timezone=pytz.timezone('Asia/Shanghai') # 再次强制指定时区
) )
# 5. 注册路由蓝图 # 打印一下确认任务已添加
print(f"📅 定时任务已锁定: 每天北京时间 12:00 执行")
app.register_blueprint(device_bp) app.register_blueprint(device_bp)
# ------------------------------------------------- # 手动触发测试接口 (保留以备不时之需)
# 前端路由支持 @app.route('/api/force_run')
# ------------------------------------------------- def force_run_task():
auto_monitor_job(app)
return jsonify({'code': 200, 'msg': '手动触发成功'})
# 前端路由
@app.route('/') @app.route('/')
def serve_index(): def serve_index():
index_path = os.path.join(app.static_folder, 'index.html')
if not os.path.exists(index_path):
return f"❌ 错误: 前端文件丢失 ({index_path})", 404
return send_from_directory(app.static_folder, 'index.html') return send_from_directory(app.static_folder, 'index.html')
@app.route('/<path:path>') @app.route('/<path:path>')
@ -220,14 +220,11 @@ def create_app():
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)
if path.startswith('api'):
if path.startswith('api') or path.startswith('static'):
return jsonify({'code': 404, 'message': 'Not Found'}), 404 return jsonify({'code': 404, 'message': 'Not Found'}), 404
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():
# 自动创建表结构
db.create_all() db.create_all()
return app return app
@ -236,6 +233,9 @@ def create_app():
if __name__ == '__main__': if __name__ == '__main__':
app = create_app() app = create_app()
debug_mode = not getattr(sys, 'frozen', False) debug_mode = not getattr(sys, 'frozen', False)
print("🚀 服务启动中...")
# use_reloader=False 防止定时任务执行两次 print("🚀 服务启动中 (24小时常驻模式)...")
# ✅ 关键设置: use_reloader=False
# 防止 Flask 的热重载功能启动两个进程,导致定时任务跑两遍或者被意外杀掉
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

@ -10,10 +10,10 @@ def get_base_path():
def get_static_path(): def get_static_path():
"""获取 dist 静态资源路径""" """获取 web_dist 静态资源路径"""
if getattr(sys, 'frozen', False): if getattr(sys, 'frozen', False):
return os.path.join(sys._MEIPASS, 'dist') return os.path.join(sys._MEIPASS, 'web_dist')
return os.path.join(os.path.dirname(os.path.abspath(__file__)), 'dist') return os.path.join(os.path.dirname(os.path.abspath(__file__)), 'web_dist')
class Config: class Config:

View File

@ -1,7 +1,8 @@
# models.py
from datetime import datetime from datetime import datetime
import json
from extensions import db from extensions import db
class Device(db.Model): class Device(db.Model):
__tablename__ = 'devices' __tablename__ = 'devices'
@ -18,11 +19,17 @@ class Device(db.Model):
reason = db.Column(db.String(255)) reason = db.Column(db.String(255))
offset = db.Column(db.String(50)) offset = db.Column(db.String(50))
# ✅ 新增字段:文件数量
file_count = db.Column(db.Integer, default=0)
# 手动录入字段受保护run_monitor 不主动覆盖) # 手动录入字段受保护run_monitor 不主动覆盖)
install_site = db.Column(db.String(100), default="") install_site = db.Column(db.String(100), default="")
is_maintaining = db.Column(db.Boolean, default=False) is_maintaining = db.Column(db.Boolean, default=False)
is_hidden = db.Column(db.Boolean, default=False) is_hidden = db.Column(db.Boolean, default=False)
# 白名单字段 (根据上下文可能存在,补全以防万一)
is_whitelist = db.Column(db.Boolean, default=False)
def to_dict(self): def to_dict(self):
# 统一状态映射逻辑 # 统一状态映射逻辑
api_status = 'offline' if self.status in ['离线', '异常', '已离线'] else 'online' api_status = 'offline' if self.status in ['离线', '异常', '已离线'] else 'online'
@ -38,9 +45,12 @@ class Device(db.Model):
'install_site': self.install_site or '', 'install_site': self.install_site or '',
'is_maintaining': self.is_maintaining, 'is_maintaining': self.is_maintaining,
'is_hidden': self.is_hidden, 'is_hidden': self.is_hidden,
'offset': self.offset 'is_whitelist': self.is_whitelist,
'offset': self.offset,
'file_count': self.file_count # ✅ 返回给前端
} }
class DeviceHistory(db.Model): class DeviceHistory(db.Model):
__tablename__ = 'device_history' __tablename__ = 'device_history'
@ -53,8 +63,12 @@ class DeviceHistory(db.Model):
json_data = db.Column(db.Text) json_data = db.Column(db.Text)
file_path = db.Column(db.String(255)) file_path = db.Column(db.String(255))
# ✅ 新增字段:历史记录文件数量
file_count = db.Column(db.Integer, default=0)
recorded_at = db.Column(db.DateTime, default=datetime.now) recorded_at = db.Column(db.DateTime, default=datetime.now)
class MaintenanceLog(db.Model): class MaintenanceLog(db.Model):
__tablename__ = 'maintenance_logs' __tablename__ = 'maintenance_logs'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)

View File

@ -255,6 +255,7 @@ def devices_overview():
data_list = [] data_list = []
for d in devices: for d in devices:
# 关键d.to_dict() 在 models.py 中应包含 file_count
item = d.to_dict() item = d.to_dict()
# 强制格式化时间 # 强制格式化时间
@ -422,6 +423,10 @@ def run_monitor():
device.latest_time = target_time device.latest_time = target_time
device.check_time = current_time device.check_time = current_time
# ✅ [核心修改] 获取爬虫返回的文件数量并保存
f_count = item.get('num_files', 0)
device.file_count = f_count
old_json = {} old_json = {}
try: try:
if device.json_data: if device.json_data:
@ -436,12 +441,14 @@ def run_monitor():
device.json_data = json.dumps(old_json, ensure_ascii=False) device.json_data = json.dumps(old_json, ensure_ascii=False)
device.offset = calculate_offset(device.latest_time) device.offset = calculate_offset(device.latest_time)
# ✅ [核心修改] 写入历史记录时包含 file_count
new_history = DeviceHistory( new_history = DeviceHistory(
device_id=device.id, device_id=device.id,
status=item.get('status'), status=item.get('status'),
result_data=item.get('value'), result_data=item.get('value'),
data_time=target_time, data_time=target_time,
json_data=device.json_data json_data=device.json_data,
file_count=f_count # 确保历史数据也记录文件数
) )
db.session.add(new_history) db.session.add(new_history)
count_crawler += 1 count_crawler += 1
@ -637,3 +644,53 @@ def delete_log_entry():
db.session.commit() db.session.commit()
return jsonify({'code': 200}) return jsonify({'code': 200})
return jsonify({'code': 404}) return jsonify({'code': 404})
@api_bp.route('/device_history_list', methods=['GET'])
def get_device_history_list():
try:
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': '缺少设备名称'})
# 1. 找到设备ID
device = Device.query.filter_by(name=name).first()
if not device:
return jsonify({'code': 404, 'message': '设备不存在'})
# 2. 查询历史记录 (按时间倒序)
query = DeviceHistory.query.filter_by(device_id=device.id).order_by(desc(DeviceHistory.data_time))
# 3. 获取总数
total = query.count()
# 4. 分页切片
history_list = query.offset((page - 1) * limit).limit(limit).all()
# 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 "未知"
data.append({
'date': date_str,
'count': h.file_count or 0
})
return jsonify({
'code': 200,
'data': data,
'total': total,
'page': page,
'limit': limit
})
except Exception as e:
return jsonify({'code': 500, 'message': str(e)})

View File

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

View File

@ -1,8 +1,27 @@
# services/core.py # services/core.py
import logging import logging
import threading import threading
from .crawler_106 import run_106_logic import traceback
from .crawler_82 import run_82_logic from datetime import datetime
# 动态导入,防止文件缺失导致整个程序启动失败
try:
from .crawler_106 import run_106_logic
except ImportError:
print("⚠️ 警告: 未找到 crawler_106 模块")
def run_106_logic():
return []
try:
from .crawler_82 import run_82_logic
except ImportError:
print("⚠️ 警告: 未找到 crawler_82 模块")
def run_82_logic():
return []
task_lock = threading.Lock() task_lock = threading.Lock()
@ -12,26 +31,65 @@ def execute_monitor_task():
执行所有爬虫,返回一个大列表: 执行所有爬虫,返回一个大列表:
{'device_list': [item1, item2...], 'target_time': '...'} {'device_list': [item1, item2...], 'target_time': '...'}
""" """
# 1. 锁机制:防止任务重复运行
if task_lock.locked(): if task_lock.locked():
logging.warning(">>> 任务正在运行中,跳过") logging.warning(">>> 任务正在运行中,跳过")
print(">>> ⚠️ 任务正在运行中,本次请求跳过")
return None return None
with task_lock: with task_lock:
logging.info(">>> 开始执行监控任务...") logging.info(">>> 开始执行监控任务...")
print(f"--- [任务开始] {datetime.now().strftime('%H:%M:%S')} ---")
# 1. 获取 106 数据列表 all_results = []
# ==========================
# 2. 执行 106 爬虫
# ==========================
try:
list_106 = run_106_logic() list_106 = run_106_logic()
if list_106:
count = len(list_106)
print(f"✅ 106爬虫获取数据: {count}")
# 2. 获取 82 数据列表 # 🔍 [调试] 打印第一条数据,确认 num_files 是否存在
if count > 0:
first = list_106[0]
print(f" [调试检查] 106样本: {first.get('name')} | num_files={first.get('num_files')}")
all_results.extend(list_106)
else:
print("⚠️ 106爬虫未返回数据")
except Exception as e:
print(f"❌ 106爬虫执行失败: {e}")
traceback.print_exc()
# ==========================
# 3. 执行 82 爬虫
# ==========================
try:
list_82 = run_82_logic() list_82 = run_82_logic()
if list_82:
print(f"✅ 82爬虫获取数据: {len(list_82)}")
# 3. 合并 # 🛠️ [补全] 82爬虫没有文件数概念手动补0防止入库报错
combined_list = list_106 + list_82 for item in list_82:
if 'num_files' not in item:
item['num_files'] = 0
logging.info(f">>> 任务完成,共获取 {len(combined_list)} 条数据") all_results.extend(list_82)
except Exception as e:
print(f"❌ 82爬虫执行失败: {e}")
traceback.print_exc()
# ==========================
# 4. 汇总返回
# ==========================
logging.info(f">>> 任务完成,共获取 {len(all_results)} 条数据")
print(f"--- [任务结束] 总计获取: {len(all_results)} 台设备 ---")
return { return {
'device_list': combined_list, 'device_list': all_results,
'target_time': None, # 具体时间已在 item 里 'target_time': None, # 具体时间已在 item['target_time']
'temp_file_path': None # 废弃旧逻辑,文件路径已在 item 里 'temp_file_path': None # 废弃旧逻辑,文件路径已在 item['temp_file']
} }

View File

@ -52,7 +52,7 @@ def run_106_logic():
"""返回 result_list, 每个元素是一个字典""" """返回 result_list, 每个元素是一个字典"""
results = [] results = []
print(">>> [106爬虫] 启动...") print(">>> [106爬虫] 启动...")
today_str = datetime.now().strftime("%Y_%m_%d") # today_str = datetime.now().strftime("%Y_%m_%d") # ❌ 移除严格的“今天”判断
main_headers = {"Authorization": CONFIG["primary_auth"], "User-Agent": "Mozilla/5.0"} main_headers = {"Authorization": CONFIG["primary_auth"], "User-Agent": "Mozilla/5.0"}
try: try:
@ -75,7 +75,8 @@ def run_106_logic():
'value': '', 'value': '',
'target_time': datetime.now().strftime("%Y-%m-%d %H:%M:%S"), 'target_time': datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
'raw_json': {}, 'raw_json': {},
'temp_file': None 'temp_file': None,
'num_files': 0 # ✅ 默认值
} }
if str(item.get('status')).lower() != 'online': if str(item.get('status')).lower() != 'online':
@ -96,29 +97,40 @@ def run_106_logic():
headers = {"Authorization": CONFIG["primary_auth"], "x-auth": token} headers = {"Authorization": CONFIG["primary_auth"], "x-auth": token}
api_root = "/api/resources/Data/" if is_tower_underscore else "/api/resources/data/" api_root = "/api/resources/Data/" if is_tower_underscore else "/api/resources/data/"
# 1. 获取日期列表
res1 = requests.get(f"http://106.75.72.40:{port}{api_root}", headers=headers, timeout=10) res1 = requests.get(f"http://106.75.72.40:{port}{api_root}", headers=headers, timeout=10)
best_date = find_closest_item(res1.json().get('items', []), True) best_date = find_closest_item(res1.json().get('items', []), True)
if not best_date or best_date[2] != today_str: # ✅ 修改点:如果找不到任何日期文件夹,才报错。否则,即使是旧日期也继续往下走。
data_packet['value'] = "未找到今日文件夹" if not best_date:
data_packet['target_time'] = best_date[2] if best_date else "N/A" data_packet['value'] = "未找到任何日期文件夹"
results.append(data_packet) results.append(data_packet)
continue continue
data_packet['target_time'] = best_date[2] # 实际数据时间 data_packet['target_time'] = best_date[2] # 记录找到的那个日期 (比如 2026_02_02)
date_path = f"{api_root}{best_date[2]}/" date_path = f"{api_root}{best_date[2]}/"
# 2. 请求具体日期的文件夹内容 (这一步能获取 numFiles)
res2 = requests.get(f"http://106.75.72.40:{port}{date_path}", headers=headers, timeout=10) res2 = requests.get(f"http://106.75.72.40:{port}{date_path}", headers=headers, timeout=10)
best_file = find_closest_item(res2.json().get('items', []), False) folder_data = res2.json() # 获取完整JSON
# ✅ 核心:提取 numFiles (只要请求成功,这里一定能拿到)
file_count = folder_data.get('numFiles', 0)
data_packet['num_files'] = file_count
print(f" -> {name}: 找到日期 {best_date[2]}, 文件数: {file_count}")
# 3. 找该文件夹里最新的文件
best_file = find_closest_item(folder_data.get('items', []), False)
if not best_file: if not best_file:
data_packet['value'] = "今日文件夹为空" data_packet['value'] = "文件夹为空" # 这种情况下 numFiles 应该是 0
results.append(data_packet) results.append(data_packet)
continue continue
file_item = best_file[1] file_item = best_file[1]
full_path = file_item.get('path') or f"{date_path}{file_item.get('name')}" full_path = file_item.get('path') or f"{date_path}{file_item.get('name')}"
# 核心逻辑:获取内容 # 4. 下载/读取内容逻辑
if is_tower_i: if is_tower_i:
# 下载二进制文件 # 下载二进制文件
download_url = f"http://106.75.72.40:{port}/api/raw{full_path}" download_url = f"http://106.75.72.40:{port}/api/raw{full_path}"
@ -129,9 +141,9 @@ def run_106_logic():
with open(temp_path, 'wb') as f: with open(temp_path, 'wb') as f:
f.write(res3.content) f.write(res3.content)
data_packet['temp_file'] = temp_path # 🔥 传递给API data_packet['temp_file'] = temp_path
data_packet['value'] = f"Binary Downloaded: {len(res3.content)} bytes" data_packet['value'] = f"Binary Downloaded: {len(res3.content)} bytes"
data_packet['raw_json'] = file_item # 用文件属性充当RawData data_packet['raw_json'] = file_item # 借用 file_item 充当 raw_json
else: else:
data_packet['status'] = '异常' data_packet['status'] = '异常'
data_packet['value'] = f"下载失败: {res3.status_code}" data_packet['value'] = f"下载失败: {res3.status_code}"
@ -141,7 +153,7 @@ def run_106_logic():
res3 = requests.get(file_api_url, headers=headers, timeout=20) res3 = requests.get(file_api_url, headers=headers, timeout=20)
try: try:
json_content = res3.json() json_content = res3.json()
data_packet['raw_json'] = json_content # 🔥 完整保存 data_packet['raw_json'] = json_content
data_packet['value'] = json_content.get('content', '') data_packet['value'] = json_content.get('content', '')
except: except:
data_packet['value'] = "JSON解析失败" data_packet['value'] = "JSON解析失败"

View File

@ -5,7 +5,7 @@
</main> </main>
<footer class="version-footer"> <footer class="version-footer">
2.4版本 © 2026 Device Monitor 2.5版本加入每日数据个数 © 2026 Device Monitor
</footer> </footer>
</div> </div>
</template> </template>

View File

@ -103,6 +103,25 @@
</template> </template>
</el-table-column> </el-table-column>
<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"> <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">
@ -235,6 +254,8 @@
<MaintenanceLogs ref="maintenanceLogsRef" /> <MaintenanceLogs ref="maintenanceLogsRef" />
<IoTDeviceBinder ref="iotBinderRef" @update-success="fetchData" /> <IoTDeviceBinder ref="iotBinderRef" @update-success="fetchData" />
<FileHistoryDialog ref="fileHistoryRef" />
<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">
<el-form-item label="设备名称"> <el-form-item label="设备名称">
@ -262,11 +283,13 @@ import { ref, reactive, computed, onMounted, nextTick, onBeforeUnmount } from 'v
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import axios from 'axios' import axios from 'axios'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Clock, DataLine, Document, Refresh, EditPen, Search, Edit, RefreshRight, Delete, RefreshLeft, SwitchButton, Warning, WarningFilled, Plus, Odometer, Link } from '@element-plus/icons-vue' import { Clock, DataLine, Document, Refresh, EditPen, Search, Edit, RefreshRight, Delete, RefreshLeft, SwitchButton, Warning, WarningFilled, Plus, Odometer, Link, Histogram } from '@element-plus/icons-vue'
import DataMonitor from './DataMonitor.vue' import DataMonitor from './DataMonitor.vue'
import MaintenanceLogs from './MaintenanceLogs.vue' import MaintenanceLogs from './MaintenanceLogs.vue'
import IoTDeviceBinder from './IoTDeviceBinder.vue' import IoTDeviceBinder from './IoTDeviceBinder.vue'
// ✅ 引入新组件
import FileHistoryDialog from './FileHistoryDialog.vue'
const router = useRouter() const router = useRouter()
const loading = ref(false) const loading = ref(false)
@ -288,6 +311,8 @@ 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 iotBinderRef = ref(null)
// ✅ 定义新的 ref
const fileHistoryRef = ref(null)
const showAddDialog = ref(false) const showAddDialog = ref(false)
const isAdding = ref(false) const isAdding = ref(false)
@ -422,10 +447,6 @@ const fetchData = async () => {
} else if (diffHours > 24) { } else if (diffHours > 24) {
statusLabel = '滞后'; statusColor = '#E6A23C'; statusType = 'warning'; statusLabel = '滞后'; statusColor = '#E6A23C'; statusType = 'warning';
statusReason = `滞后 ${Math.floor(diffDays)}`; statusReason = `滞后 ${Math.floor(diffDays)}`;
// === 注意:这里没有把 trafficWarning 加入到 sortWeight 或 statusType 的改变逻辑中 ===
// 从而实现了“只标黄文字,不改变行状态,不置顶”
} else if (expireWarning) { } else if (expireWarning) {
statusLabel = '即将过期'; statusColor = '#E6A23C'; statusType = 'warning'; statusLabel = '即将过期'; statusColor = '#E6A23C'; statusType = 'warning';
statusReason = `即将过期`; statusReason = `即将过期`;
@ -451,7 +472,8 @@ const fetchData = async () => {
currentValueNum, currentValueNum,
trafficNum, trafficNum,
trafficWarning, trafficWarning,
expireWarning expireWarning,
file_count: item.file_count || 0 // ✅ 绑定后端返回的文件数字段
} }
}).sort((a, b) => b.sortWeight - a.sortWeight) }).sort((a, b) => b.sortWeight - a.sortWeight)
@ -500,6 +522,14 @@ const totalUsageSum = computed(() => {
}) })
// === 交互函数 === // === 交互函数 ===
// ✅ 新增:打开历史记录弹窗
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 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 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) }
@ -567,6 +597,34 @@ onBeforeUnmount(() => window.removeEventListener('resize', updateDimensions))
: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; }
/* ✅ 新增样式 */
.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;
}
@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; }

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>