时间显示异常
This commit is contained in:
@ -11,7 +11,7 @@ from flask_apscheduler import APScheduler
|
|||||||
# ✅ 1. 核心模块引用
|
# ✅ 1. 核心模块引用
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
try:
|
try:
|
||||||
# [新增] 导入配置类
|
# 导入配置类
|
||||||
from config import Config
|
from config import Config
|
||||||
|
|
||||||
# 数据库实例
|
# 数据库实例
|
||||||
@ -23,13 +23,13 @@ try:
|
|||||||
# 核心业务逻辑 (爬虫)
|
# 核心业务逻辑 (爬虫)
|
||||||
from services.core import execute_monitor_task
|
from services.core import execute_monitor_task
|
||||||
|
|
||||||
# [新增] 核心业务逻辑 (IoT) - 用于定时任务
|
# 核心业务逻辑 (IoT) - 用于定时任务
|
||||||
from services.iot_api import sync_iot_data_service
|
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
|
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, 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 getattr(sys, 'frozen', False):
|
||||||
if hasattr(sys, '_MEIPASS'):
|
# --- 打包环境 (PyInstaller) ---
|
||||||
return sys._MEIPASS
|
|
||||||
else:
|
# 资源文件在临时解压目录 (sys._MEIPASS)
|
||||||
return os.path.dirname(os.path.abspath(sys.executable))
|
resource_base = sys._MEIPASS
|
||||||
|
|
||||||
|
# 数据文件(数据库)在 exe 所在目录 (sys.executable 的父目录)
|
||||||
|
data_base = os.path.dirname(sys.executable)
|
||||||
else:
|
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')
|
RESOURCE_BASE, DATA_BASE = get_paths()
|
||||||
INSTANCE_FOLDER = os.path.join(BASE_DIR, 'instance')
|
|
||||||
|
# 定义具体文件夹路径
|
||||||
|
STATIC_FOLDER = os.path.join(RESOURCE_BASE, 'web_dist')
|
||||||
|
# ⚠️ 关键:强制将 instance 文件夹定位到数据目录 (exe旁边),而不是临时目录
|
||||||
|
INSTANCE_PATH = os.path.join(DATA_BASE, 'instance')
|
||||||
|
|
||||||
# 修复 Windows MIME 类型
|
# 修复 Windows MIME 类型
|
||||||
mimetypes.add_type('application/javascript', '.js')
|
mimetypes.add_type('application/javascript', '.js')
|
||||||
@ -63,7 +80,7 @@ mimetypes.add_type('text/css', '.css')
|
|||||||
|
|
||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# 3. 定时任务逻辑 (同时运行 爬虫 + IoT同步)
|
# 3. 定时任务逻辑 (保持不变)
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
def auto_monitor_job(app):
|
def auto_monitor_job(app):
|
||||||
"""定时任务具体执行逻辑"""
|
"""定时任务具体执行逻辑"""
|
||||||
@ -109,7 +126,7 @@ def auto_monitor_job(app):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ [定时任务-爬虫] 异常: {e}")
|
print(f"❌ [定时任务-爬虫] 异常: {e}")
|
||||||
|
|
||||||
# --- 任务 B: IoT 同步 (新增) ---
|
# --- 任务 B: IoT 同步 ---
|
||||||
if sync_iot_data_service:
|
if sync_iot_data_service:
|
||||||
try:
|
try:
|
||||||
# 1. 获取数据
|
# 1. 获取数据
|
||||||
@ -136,21 +153,35 @@ 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)
|
app = Flask(__name__, static_folder=STATIC_FOLDER, instance_path=INSTANCE_PATH)
|
||||||
CORS(app)
|
CORS(app)
|
||||||
|
|
||||||
# 1. 确保 instance 目录存在
|
# 1. 确保 instance 目录存在 (在 exe 旁边创建文件夹)
|
||||||
if not os.path.exists(INSTANCE_FOLDER):
|
if not os.path.exists(app.instance_path):
|
||||||
os.makedirs(INSTANCE_FOLDER, exist_ok=True)
|
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. 加载配置
|
||||||
# ✅ 2. 核心修复:加载 config.py 中的配置
|
|
||||||
# ==========================================================
|
|
||||||
app.config.from_object(Config)
|
app.config.from_object(Config)
|
||||||
|
|
||||||
# 打印一下关键配置,确保 IoT 配置已加载 (调试用)
|
# ⚠️ 关键修改:强制重写数据库 URI,确保使用绝对路径
|
||||||
# print(f"DEBUG Config Loaded: IOT_APP_ID={app.config.get('IOT_APP_ID')}")
|
# 即使 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. 初始化扩展
|
# 3. 初始化扩展
|
||||||
db.init_app(app)
|
db.init_app(app)
|
||||||
@ -177,8 +208,9 @@ def create_app():
|
|||||||
# -------------------------------------------------
|
# -------------------------------------------------
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
def serve_index():
|
def serve_index():
|
||||||
if not os.path.exists(os.path.join(app.static_folder, 'index.html')):
|
index_path = os.path.join(app.static_folder, 'index.html')
|
||||||
return "❌ 错误: 前端文件丢失 (web_dist/index.html)", 404
|
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>')
|
||||||
@ -193,6 +225,7 @@ def create_app():
|
|||||||
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
|
||||||
@ -202,5 +235,5 @@ if __name__ == '__main__':
|
|||||||
app = create_app()
|
app = create_app()
|
||||||
debug_mode = not getattr(sys, 'frozen', False)
|
debug_mode = not getattr(sys, 'frozen', False)
|
||||||
print("🚀 服务启动中...")
|
print("🚀 服务启动中...")
|
||||||
# 注意:use_reloader=False 防止定时任务执行两次
|
# 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)
|
||||||
@ -258,6 +258,25 @@ def devices_overview():
|
|||||||
|
|
||||||
for d in devices:
|
for d in devices:
|
||||||
item = d.to_dict()
|
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 = {}
|
parsed_content = {}
|
||||||
if d.json_data:
|
if d.json_data:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="version-footer">
|
<footer class="version-footer">
|
||||||
2.2版本 © 2026 Device Monitor
|
2.4版本 © 2026 Device Monitor
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -291,40 +291,76 @@ const fetchData = async () => {
|
|||||||
const isOrphanIoT = (item.source === 'iot_card')
|
const isOrphanIoT = (item.source === 'iot_card')
|
||||||
const isWhitelist = !!item.is_whitelist
|
const isWhitelist = !!item.is_whitelist
|
||||||
|
|
||||||
// 1. 数据时效处理
|
// === 1. 智能时间解析与格式化 (增强版) ===
|
||||||
let diffDays = 0, diffHours = 0, isToday = false, validTime = false
|
let diffDays = 0, diffHours = 0, isToday = false, validTime = false
|
||||||
let timeStr = item.latest_time
|
let timeStr = item.latest_time
|
||||||
|
|
||||||
|
// 默认显示原始值,稍后如果解析成功则覆盖它
|
||||||
|
let displayTime = timeStr
|
||||||
|
|
||||||
if (timeStr && timeStr !== 'N/A') {
|
if (timeStr && timeStr !== 'N/A') {
|
||||||
const cleanTime = timeStr.toString().replace(/_/g, '-')
|
let d = null;
|
||||||
const d = new Date(cleanTime)
|
const str = timeStr.toString().trim();
|
||||||
if (!isNaN(d.getTime())) {
|
|
||||||
|
// A. 尝试匹配标准格式: YYYY-MM-DD HH:mm:ss
|
||||||
|
const matchStandard = str.match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})$/);
|
||||||
|
|
||||||
|
if (matchStandard) {
|
||||||
|
d = new Date(
|
||||||
|
parseInt(matchStandard[1]),
|
||||||
|
parseInt(matchStandard[2]) - 1,
|
||||||
|
parseInt(matchStandard[3]),
|
||||||
|
parseInt(matchStandard[4]),
|
||||||
|
parseInt(matchStandard[5]),
|
||||||
|
parseInt(matchStandard[6])
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// B. 兜底逻辑:处理下划线或其他格式 (如 2026_01_14)
|
||||||
|
// 先把下划线全换成横杠
|
||||||
|
let cleanStr = str.replace(/_/g, '-')
|
||||||
|
// 如果长度不够(只有日期),补全时间,防止 new Date 解析成 UTC 0点导致时差
|
||||||
|
if (cleanStr.length <= 10) {
|
||||||
|
cleanStr += ' 00:00:00'
|
||||||
|
}
|
||||||
|
// 处理 T 分隔符 (ISO格式)
|
||||||
|
cleanStr = cleanStr.replace(' ', 'T')
|
||||||
|
d = new Date(cleanStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// C. 如果解析成功,强制重新生成统一的显示字符串
|
||||||
|
if (d && !isNaN(d.getTime())) {
|
||||||
validTime = true
|
validTime = true
|
||||||
isToday = d.toDateString() === now.toDateString()
|
isToday = d.toDateString() === now.toDateString()
|
||||||
|
|
||||||
const diff = now - d
|
const diff = now - d
|
||||||
diffHours = (diff > 0 ? diff : 0) / (1000 * 3600)
|
diffHours = (diff > 0 ? diff : 0) / (1000 * 3600)
|
||||||
diffDays = diffHours / 24
|
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
|
let currentValueNum = 0
|
||||||
if (item.current_value) {
|
if (item.current_value) {
|
||||||
// 尝试提取数字,例如 "1024.5 M" -> 1024.5
|
|
||||||
const match = String(item.current_value).match(/(\d+(\.\d+)?)/)
|
const match = String(item.current_value).match(/(\d+(\.\d+)?)/)
|
||||||
if (match) {
|
if (match) currentValueNum = parseFloat(match[0])
|
||||||
currentValueNum = parseFloat(match[0])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 流量与过期计算
|
// 3. 流量与过期计算
|
||||||
let trafficNum = 0
|
let trafficNum = 0
|
||||||
let rawTraffic = item.usedTraffic
|
let rawTraffic = item.usedTraffic
|
||||||
if ((rawTraffic === undefined || rawTraffic === null) && item.json_data) {
|
if ((rawTraffic === undefined || rawTraffic === null) && item.json_data) {
|
||||||
try {
|
try { const j = JSON.parse(item.json_data); rawTraffic = j.usedTraffic } catch(e) {}
|
||||||
const j = JSON.parse(item.json_data)
|
|
||||||
rawTraffic = j.usedTraffic
|
|
||||||
} catch(e) {}
|
|
||||||
}
|
}
|
||||||
if (rawTraffic) {
|
if (rawTraffic) {
|
||||||
trafficNum = parseFloat(rawTraffic)
|
trafficNum = parseFloat(rawTraffic)
|
||||||
@ -341,21 +377,21 @@ const fetchData = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 状态判定与权重排序 (融合逻辑)
|
// 4. 状态判定
|
||||||
let statusColor = '#67C23A', statusLabel = '正常', statusType = 'normal', statusLabelColor = '#fff'
|
let statusColor = '#67C23A', statusLabel = '正常', statusType = 'normal', statusLabelColor = '#fff'
|
||||||
let statusReason = ''
|
let statusReason = ''
|
||||||
let sortWeight = diffHours // 基础权重为滞后小时数
|
let sortWeight = diffHours
|
||||||
|
|
||||||
if (item.is_maintaining) {
|
if (item.is_maintaining) {
|
||||||
statusColor = '#409EFF'; statusLabel = '维修中'; statusType = 'maintenance';
|
statusColor = '#409EFF'; statusLabel = '维修中'; statusType = 'maintenance';
|
||||||
sortWeight = Number.MAX_SAFE_INTEGER;
|
sortWeight = Number.MAX_SAFE_INTEGER;
|
||||||
} else if (!validTime || item.status === 'offline') {
|
} else if (!validTime || item.status === 'offline') {
|
||||||
statusLabel = '离线'; statusColor = '#F56C6C'; statusType = 'error';
|
statusLabel = '离线'; statusColor = '#F56C6C'; statusType = 'error';
|
||||||
statusReason = validTime ? '设备离线' : '暂无数据(离线)';
|
statusReason = validTime ? '设备离线' : '暂无数据';
|
||||||
sortWeight = 80000000;
|
sortWeight = 80000000;
|
||||||
} else if (diffDays > 7) {
|
} else if (diffDays > 7) {
|
||||||
statusLabel = '严重滞后'; statusColor = '#F56C6C'; statusType = 'error';
|
statusLabel = '严重滞后'; statusColor = '#F56C6C'; statusType = 'error';
|
||||||
statusReason = `严重滞后 ${Math.floor(diffDays)} 天`;
|
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)} 天`;
|
statusReason = `滞后 ${Math.floor(diffDays)} 天`;
|
||||||
@ -365,7 +401,7 @@ const fetchData = async () => {
|
|||||||
sortWeight = 500;
|
sortWeight = 500;
|
||||||
} else if (expireWarning) {
|
} else if (expireWarning) {
|
||||||
statusLabel = '即将过期'; statusColor = '#E6A23C'; statusType = 'warning';
|
statusLabel = '即将过期'; statusColor = '#E6A23C'; statusType = 'warning';
|
||||||
statusReason = `卡片即将过期`;
|
statusReason = `即将过期`;
|
||||||
sortWeight = 400;
|
sortWeight = 400;
|
||||||
} else if (!isToday) {
|
} else if (!isToday) {
|
||||||
statusLabel = '昨日数据'; statusColor = '#FAC858'; statusType = 'slight-warning'; statusLabelColor = '#333';
|
statusLabel = '昨日数据'; statusColor = '#FAC858'; statusType = 'slight-warning'; statusLabelColor = '#333';
|
||||||
@ -376,6 +412,7 @@ const fetchData = async () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
|
latest_time: displayTime, // <--- 这里使用了我们格式化好的漂亮时间
|
||||||
is_hidden: isHidden,
|
is_hidden: isHidden,
|
||||||
isOrphanIoT,
|
isOrphanIoT,
|
||||||
isBound,
|
isBound,
|
||||||
|
|||||||
Reference in New Issue
Block a user