diff --git a/2_1banben/app.py b/2_1banben/app.py index 2ca5609..8632f14 100644 --- a/2_1banben/app.py +++ b/2_1banben/app.py @@ -11,7 +11,7 @@ from flask_apscheduler import APScheduler # ✅ 1. 核心模块引用 # ============================================================================== try: - # [新增] 导入配置类 + # 导入配置类 from config import Config # 数据库实例 @@ -23,13 +23,13 @@ try: # 核心业务逻辑 (爬虫) from services.core import execute_monitor_task - # [新增] 核心业务逻辑 (IoT) - 用于定时任务 + # 核心业务逻辑 (IoT) - 用于定时任务 from services.iot_api import sync_iot_data_service # 路由蓝图 try: from routes.api import api_bp as device_bp - # [新增] 导入保存逻辑,供定时任务复用 + # 导入保存逻辑,供定时任务复用 from routes.api import save_iot_cards_to_db, calculate_offset except ImportError: from routes.api import device_bp, save_iot_cards_to_db, calculate_offset @@ -40,22 +40,39 @@ except ImportError as e: # ============================================================================== -# 2. 路径计算 (辅助静态文件服务) +# 2. 路径计算 (核心修复:区分资源路径和数据路径) # ============================================================================== -# 注意:Config 类中已经处理了数据库路径,这里主要处理 web_dist 静态资源路径 -def get_base_path(): + +def get_paths(): + """ + 计算关键路径: + 1. resource_base: 用于存放 web_dist (打包后在临时目录) + 2. data_base: 用于存放数据库 (打包后在 exe 旁边) + """ if getattr(sys, 'frozen', False): - if hasattr(sys, '_MEIPASS'): - return sys._MEIPASS - else: - return os.path.dirname(os.path.abspath(sys.executable)) + # --- 打包环境 (PyInstaller) --- + + # 资源文件在临时解压目录 (sys._MEIPASS) + resource_base = sys._MEIPASS + + # 数据文件(数据库)在 exe 所在目录 (sys.executable 的父目录) + data_base = os.path.dirname(sys.executable) else: - return os.path.abspath(os.path.dirname(__file__)) + # --- 开发环境 --- + base = os.path.abspath(os.path.dirname(__file__)) + resource_base = base + data_base = base + + return resource_base, data_base -BASE_DIR = get_base_path() -STATIC_FOLDER = os.path.join(BASE_DIR, 'web_dist') -INSTANCE_FOLDER = os.path.join(BASE_DIR, 'instance') +# 获取路径 +RESOURCE_BASE, DATA_BASE = get_paths() + +# 定义具体文件夹路径 +STATIC_FOLDER = os.path.join(RESOURCE_BASE, 'web_dist') +# ⚠️ 关键:强制将 instance 文件夹定位到数据目录 (exe旁边),而不是临时目录 +INSTANCE_PATH = os.path.join(DATA_BASE, 'instance') # 修复 Windows MIME 类型 mimetypes.add_type('application/javascript', '.js') @@ -63,7 +80,7 @@ mimetypes.add_type('text/css', '.css') # ============================================================================== -# 3. 定时任务逻辑 (同时运行 爬虫 + IoT同步) +# 3. 定时任务逻辑 (保持不变) # ============================================================================== def auto_monitor_job(app): """定时任务具体执行逻辑""" @@ -109,7 +126,7 @@ def auto_monitor_job(app): except Exception as e: print(f"❌ [定时任务-爬虫] 异常: {e}") - # --- 任务 B: IoT 同步 (新增) --- + # --- 任务 B: IoT 同步 --- if sync_iot_data_service: try: # 1. 获取数据 @@ -136,21 +153,35 @@ def auto_monitor_job(app): # 4. Flask 应用工厂 # ============================================================================== def create_app(): - # 指定静态文件夹 - app = Flask(__name__, static_folder=STATIC_FOLDER) + # ⚠️ 关键修改:显式传入 instance_path,告诉 Flask 去哪里找/存 数据库文件 + app = Flask(__name__, static_folder=STATIC_FOLDER, instance_path=INSTANCE_PATH) CORS(app) - # 1. 确保 instance 目录存在 - if not os.path.exists(INSTANCE_FOLDER): - os.makedirs(INSTANCE_FOLDER, exist_ok=True) + # 1. 确保 instance 目录存在 (在 exe 旁边创建文件夹) + if not os.path.exists(app.instance_path): + try: + os.makedirs(app.instance_path, exist_ok=True) + print(f"📁 已创建数据目录: {app.instance_path}") + except OSError as e: + print(f"❌ 无法创建数据目录 {app.instance_path}: {e}") - # ========================================================== - # ✅ 2. 核心修复:加载 config.py 中的配置 - # ========================================================== + # 2. 加载配置 app.config.from_object(Config) - # 打印一下关键配置,确保 IoT 配置已加载 (调试用) - # print(f"DEBUG Config Loaded: IOT_APP_ID={app.config.get('IOT_APP_ID')}") + # ⚠️ 关键修改:强制重写数据库 URI,确保使用绝对路径 + # 即使 Config 里写了,这里也要确保它指向我们刚才计算出的 INSTANCE_PATH + db_name = 'monitor_data.db' # 你的数据库文件名 + db_path = os.path.join(app.instance_path, db_name) + + # Windows下路径分隔符处理,防止报错 + if sys.platform.startswith('win'): + app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{db_path}' + else: + app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:////{db_path}' + + app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + + print(f"💾 数据库路径锁定为: {db_path}") # 3. 初始化扩展 db.init_app(app) @@ -177,8 +208,9 @@ def create_app(): # ------------------------------------------------- @app.route('/') def serve_index(): - if not os.path.exists(os.path.join(app.static_folder, 'index.html')): - return "❌ 错误: 前端文件丢失 (web_dist/index.html)", 404 + 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') @app.route('/') @@ -193,6 +225,7 @@ def create_app(): return send_from_directory(app.static_folder, 'index.html') with app.app_context(): + # 自动创建表结构 db.create_all() return app @@ -202,5 +235,5 @@ if __name__ == '__main__': app = create_app() debug_mode = not getattr(sys, 'frozen', False) print("🚀 服务启动中...") - # 注意:use_reloader=False 防止定时任务执行两次 + # use_reloader=False 防止定时任务执行两次 app.run(host='0.0.0.0', port=5000, debug=debug_mode, use_reloader=False) \ No newline at end of file diff --git a/2_1banben/routes/api.py b/2_1banben/routes/api.py index d005c18..ed9ca8c 100644 --- a/2_1banben/routes/api.py +++ b/2_1banben/routes/api.py @@ -258,6 +258,25 @@ def devices_overview(): for d in devices: item = d.to_dict() + + # =========== 【新增修复】强制格式化时间 =========== + # 无论模型返回什么,这里都强制从数据库原始字段获取,并确保包含时分秒 + raw_time = d.latest_time + if raw_time: + # 1. 如果是 datetime 对象 (防万一) + if hasattr(raw_time, 'strftime'): + item['latest_time'] = raw_time.strftime("%Y-%m-%d %H:%M:%S") + # 2. 如果是字符串 + else: + s = str(raw_time).strip() + # 如果只有日期且用下划线 (如 2026_01_14),且没有冒号,则补全 + if '_' in s and ':' not in s: + item['latest_time'] = s.replace('_', '-') + " 00:00:00" + else: + # 已经是正常字符串(如 2026-01-14 13:49:26),直接使用 + item['latest_time'] = s + # =============================================== + parsed_content = {} if d.json_data: try: diff --git a/zhandianxinxi/光谱数据监控/src/App.vue b/zhandianxinxi/光谱数据监控/src/App.vue index 04c4e13..410e183 100644 --- a/zhandianxinxi/光谱数据监控/src/App.vue +++ b/zhandianxinxi/光谱数据监控/src/App.vue @@ -5,7 +5,7 @@ diff --git a/zhandianxinxi/光谱数据监控/src/views/Dashboard.vue b/zhandianxinxi/光谱数据监控/src/views/Dashboard.vue index 27e03ee..83d060b 100644 --- a/zhandianxinxi/光谱数据监控/src/views/Dashboard.vue +++ b/zhandianxinxi/光谱数据监控/src/views/Dashboard.vue @@ -291,40 +291,76 @@ const fetchData = async () => { const isOrphanIoT = (item.source === 'iot_card') const isWhitelist = !!item.is_whitelist - // 1. 数据时效处理 + // === 1. 智能时间解析与格式化 (增强版) === let diffDays = 0, diffHours = 0, isToday = false, validTime = false let timeStr = item.latest_time + // 默认显示原始值,稍后如果解析成功则覆盖它 + let displayTime = timeStr + if (timeStr && timeStr !== 'N/A') { - const cleanTime = timeStr.toString().replace(/_/g, '-') - const d = new Date(cleanTime) - if (!isNaN(d.getTime())) { + let d = null; + const str = timeStr.toString().trim(); + + // A. 尝试匹配标准格式: YYYY-MM-DD HH:mm:ss + const matchStandard = str.match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})$/); + + if (matchStandard) { + d = new Date( + parseInt(matchStandard[1]), + parseInt(matchStandard[2]) - 1, + parseInt(matchStandard[3]), + parseInt(matchStandard[4]), + parseInt(matchStandard[5]), + parseInt(matchStandard[6]) + ); + } else { + // B. 兜底逻辑:处理下划线或其他格式 (如 2026_01_14) + // 先把下划线全换成横杠 + let cleanStr = str.replace(/_/g, '-') + // 如果长度不够(只有日期),补全时间,防止 new Date 解析成 UTC 0点导致时差 + if (cleanStr.length <= 10) { + cleanStr += ' 00:00:00' + } + // 处理 T 分隔符 (ISO格式) + cleanStr = cleanStr.replace(' ', 'T') + d = new Date(cleanStr); + } + + // C. 如果解析成功,强制重新生成统一的显示字符串 + if (d && !isNaN(d.getTime())) { validTime = true isToday = d.toDateString() === now.toDateString() + const diff = now - d diffHours = (diff > 0 ? diff : 0) / (1000 * 3600) diffDays = diffHours / 24 + + // 🌟 核心修改点:生成标准显示格式 YYYY-MM-DD HH:mm:ss 🌟 + const y = d.getFullYear() + const m = String(d.getMonth() + 1).padStart(2, '0') + const dd = String(d.getDate()).padStart(2, '0') + const hh = String(d.getHours()).padStart(2, '0') + const mm = String(d.getMinutes()).padStart(2, '0') + const ss = String(d.getSeconds()).padStart(2, '0') + + // 这行代码保证了无论后端发什么,前端都显示得很漂亮 + displayTime = `${y}-${m}-${dd} ${hh}:${mm}:${ss}` } } - // 2. [恢复旧逻辑] 解析监测数值 (用于排序,虽然不显示但保留逻辑以免报错) + // 2. 解析监测数值 (保留旧逻辑) let currentValueNum = 0 if (item.current_value) { - // 尝试提取数字,例如 "1024.5 M" -> 1024.5 const match = String(item.current_value).match(/(\d+(\.\d+)?)/) - if (match) { - currentValueNum = parseFloat(match[0]) - } + if (match) currentValueNum = parseFloat(match[0]) } // 3. 流量与过期计算 let trafficNum = 0 let rawTraffic = item.usedTraffic if ((rawTraffic === undefined || rawTraffic === null) && item.json_data) { - try { - const j = JSON.parse(item.json_data) - rawTraffic = j.usedTraffic - } catch(e) {} + try { const j = JSON.parse(item.json_data); rawTraffic = j.usedTraffic } catch(e) {} } if (rawTraffic) { trafficNum = parseFloat(rawTraffic) @@ -341,21 +377,21 @@ const fetchData = async () => { } } - // 4. 状态判定与权重排序 (融合逻辑) + // 4. 状态判定 let statusColor = '#67C23A', statusLabel = '正常', statusType = 'normal', statusLabelColor = '#fff' let statusReason = '' - let sortWeight = diffHours // 基础权重为滞后小时数 + let sortWeight = diffHours if (item.is_maintaining) { statusColor = '#409EFF'; statusLabel = '维修中'; statusType = 'maintenance'; sortWeight = Number.MAX_SAFE_INTEGER; } else if (!validTime || item.status === 'offline') { statusLabel = '离线'; statusColor = '#F56C6C'; statusType = 'error'; - statusReason = validTime ? '设备离线' : '暂无数据(离线)'; + statusReason = validTime ? '设备离线' : '暂无数据'; sortWeight = 80000000; } else if (diffDays > 7) { statusLabel = '严重滞后'; statusColor = '#F56C6C'; statusType = 'error'; - statusReason = `严重滞后 ${Math.floor(diffDays)} 天`; + statusReason = `滞后 ${Math.floor(diffDays)} 天`; } else if (diffHours > 24) { statusLabel = '滞后'; statusColor = '#E6A23C'; statusType = 'warning'; statusReason = `滞后 ${Math.floor(diffDays)} 天`; @@ -365,7 +401,7 @@ const fetchData = async () => { sortWeight = 500; } else if (expireWarning) { statusLabel = '即将过期'; statusColor = '#E6A23C'; statusType = 'warning'; - statusReason = `卡片即将过期`; + statusReason = `即将过期`; sortWeight = 400; } else if (!isToday) { statusLabel = '昨日数据'; statusColor = '#FAC858'; statusType = 'slight-warning'; statusLabelColor = '#333'; @@ -376,6 +412,7 @@ const fetchData = async () => { return { ...item, + latest_time: displayTime, // <--- 这里使用了我们格式化好的漂亮时间 is_hidden: isHidden, isOrphanIoT, isBound,