main
COT001\李旭光 2026-03-10 17:03:31 +08:00
parent ee351733fe
commit 3e4e040afd
16 changed files with 1144 additions and 366 deletions

View File

@ -1,4 +1,4 @@
{
"template": "F:\\PyPro\\PCM_Report\\configs\\600泵\\template.docx",
"template": "C:\\PPRO\\PCM_Report\\configs\\600泵\\template.docx",
"category_name": "600泵"
}

View File

@ -0,0 +1,78 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
添加保存状态字段到 experiments
用于跟踪实验数据自动保存的状态
"""
import sqlite3
from pathlib import Path
from logger import get_logger
logger = get_logger()
def add_save_status_columns():
"""添加 save_status 和 save_error 字段"""
try:
db_path = Path(__file__).parent / "experiments.db"
if not db_path.exists():
logger.error(f"数据库文件不存在: {db_path}")
return False
db = sqlite3.connect(str(db_path))
cur = db.cursor()
# 检查字段是否已存在
cur.execute("PRAGMA table_info(experiments)")
columns = [row[1] for row in cur.fetchall()]
logger.info(f"当前 experiments 表字段: {columns}")
# 添加 save_status 字段
if 'save_status' not in columns:
logger.info("添加 save_status 字段...")
cur.execute("""
ALTER TABLE experiments
ADD COLUMN save_status TEXT DEFAULT NULL
""")
logger.info("✅ save_status 字段添加成功")
else:
logger.info("save_status 字段已存在,跳过")
# 添加 save_error 字段
if 'save_error' not in columns:
logger.info("添加 save_error 字段...")
cur.execute("""
ALTER TABLE experiments
ADD COLUMN save_error TEXT DEFAULT NULL
""")
logger.info("✅ save_error 字段添加成功")
else:
logger.info("save_error 字段已存在,跳过")
db.commit()
# 验证字段已添加
cur.execute("PRAGMA table_info(experiments)")
columns_after = [row[1] for row in cur.fetchall()]
logger.info(f"更新后 experiments 表字段: {columns_after}")
db.close()
logger.info("✅ 数据库迁移完成")
return True
except Exception as e:
logger.error(f"❌ 添加字段失败: {e}", exc_info=True)
return False
if __name__ == "__main__":
print("开始添加保存状态字段...")
success = add_save_status_columns()
if success:
print("✅ 数据库迁移成功完成")
else:
print("❌ 数据库迁移失败,请查看日志")

View File

@ -2,7 +2,7 @@
"influx": {
"url": "http://10.0.5.232:8086",
"org": "MEASCON",
"token": "_jtoxcVDIbol2Uqt_vlhidut-EO0Xo0ZXea2UC5a5Bgotk836F0xPN4NSGY1jYI_WaBKRau4RyZ-g2XSFiNdXw==",
"token": "JPZMq2UP5ORhLq8CfsPbawl6k0MSDlJmEwMJ2uvR_TXqW5bUOWIYBQOSXkGNzDqOU3rnuGpIxGxrB_mlAF-EEw==",
"username": "PCM",
"password": "1842moon",
"landingUrl": "http://10.0.5.232:8086/orgs/b2542eeb72a3e614/dashboards/0f8ab8a328fe9000?lower=now%28%29+-+1h",
@ -13,106 +13,223 @@
"chart1": {
"type": "chart",
"label": "chart1",
"title": "",
"title": "chart1",
"value": "",
"dbQuery": "",
"chart": {}
"chart": {},
"influx": {
"bucket": "",
"measurement": "",
"fields": [],
"filters": {},
"timeRange": "-1h",
"aggregate": "",
"windowPeriod": ""
}
},
"chart2": {
"type": "chart",
"label": "chart2",
"title": "",
"title": "chart2",
"value": "",
"dbQuery": "",
"chart": {}
"chart": {},
"influx": {
"bucket": "",
"measurement": "",
"fields": [],
"filters": {},
"timeRange": "-1h",
"aggregate": "",
"windowPeriod": ""
}
},
"chart3": {
"type": "chart",
"label": "chart3",
"title": "",
"title": "chart3",
"value": "",
"dbQuery": "",
"chart": {}
"chart": {},
"influx": {
"bucket": "",
"measurement": "",
"fields": [],
"filters": {},
"timeRange": "-1h",
"aggregate": "",
"windowPeriod": ""
}
},
"chart4": {
"type": "chart",
"label": "chart4",
"title": "",
"title": "chart4",
"value": "",
"dbQuery": "",
"chart": {}
"chart": {},
"influx": {
"bucket": "",
"measurement": "",
"fields": [],
"filters": {},
"timeRange": "-1h",
"aggregate": "",
"windowPeriod": ""
}
},
"chart5": {
"type": "chart",
"label": "chart5",
"title": "",
"title": "chart5",
"value": "",
"dbQuery": "",
"chart": {}
"chart": {},
"influx": {
"bucket": "",
"measurement": "",
"fields": [],
"filters": {},
"timeRange": "-1h",
"aggregate": "",
"windowPeriod": ""
}
},
"chart6": {
"type": "chart",
"label": "chart6",
"title": "",
"title": "chart6",
"value": "",
"dbQuery": "",
"chart": {}
"chart": {},
"influx": {
"bucket": "",
"measurement": "",
"fields": [],
"filters": {},
"timeRange": "-1h",
"aggregate": "",
"windowPeriod": ""
}
},
"chart7": {
"type": "chart",
"label": "chart7",
"title": "",
"title": "chart7",
"value": "",
"dbQuery": "",
"chart": {}
"chart": {},
"influx": {
"bucket": "",
"measurement": "",
"fields": [],
"filters": {},
"timeRange": "-1h",
"aggregate": "",
"windowPeriod": ""
}
},
"chart8": {
"type": "chart",
"label": "chart8",
"title": "",
"title": "chart8",
"value": "",
"dbQuery": "",
"chart": {}
"chart": {},
"influx": {
"bucket": "",
"measurement": "",
"fields": [],
"filters": {},
"timeRange": "-1h",
"aggregate": "",
"windowPeriod": ""
}
},
"chart9": {
"type": "chart",
"label": "chart9",
"title": "",
"title": "chart9",
"value": "",
"dbQuery": "",
"chart": {}
"chart": {},
"influx": {
"bucket": "",
"measurement": "",
"fields": [],
"filters": {},
"timeRange": "-1h",
"aggregate": "",
"windowPeriod": ""
}
},
"chart10": {
"type": "chart",
"label": "chart10",
"title": "",
"title": "chart10",
"value": "",
"dbQuery": "",
"chart": {}
"chart": {},
"influx": {
"bucket": "",
"measurement": "",
"fields": [],
"filters": {},
"timeRange": "-1h",
"aggregate": "",
"windowPeriod": ""
}
},
"chart11": {
"type": "chart",
"label": "chart11",
"title": "",
"title": "chart11",
"value": "",
"dbQuery": "",
"chart": {}
"chart": {},
"influx": {
"bucket": "",
"measurement": "",
"fields": [],
"filters": {},
"timeRange": "-1h",
"aggregate": "",
"windowPeriod": ""
}
},
"chart12": {
"type": "chart",
"label": "chart12",
"title": "",
"title": "chart12",
"value": "",
"dbQuery": "",
"chart": {}
"chart": {},
"influx": {
"bucket": "",
"measurement": "",
"fields": [],
"filters": {},
"timeRange": "-1h",
"aggregate": "",
"windowPeriod": ""
}
},
"table1": {
"type": "table",
"label": "table1",
"title": "",
"title": "table1",
"value": "",
"dbQuery": "",
"chart": {},
"influx": {
"bucket": "",
"measurement": "",
"fields": [],
"filters": {},
"timeRange": "-1h",
"aggregate": "",
"windowPeriod": ""
},
"table": {
"firstColumn": "time",
"firstTitle": "",
@ -122,10 +239,19 @@
"table2": {
"type": "table",
"label": "table2",
"title": "",
"title": "table2",
"value": "",
"dbQuery": "",
"chart": {},
"influx": {
"bucket": "",
"measurement": "",
"fields": [],
"filters": {},
"timeRange": "-1h",
"aggregate": "",
"windowPeriod": ""
},
"table": {
"firstColumn": "time",
"firstTitle": "",
@ -135,10 +261,19 @@
"table3": {
"type": "table",
"label": "table3",
"title": "",
"title": "table3",
"value": "",
"dbQuery": "",
"chart": {},
"influx": {
"bucket": "",
"measurement": "",
"fields": [],
"filters": {},
"timeRange": "-1h",
"aggregate": "",
"windowPeriod": ""
},
"table": {
"firstColumn": "time",
"firstTitle": "",

View File

@ -0,0 +1,63 @@
{
"canvas": {
"x": 0.0,
"y": 0.0,
"w": 1920.0,
"h": 1080.0
},
"widgets": [
{
"widget_type": "label",
"x": 173.0,
"y": 210.0,
"w": 162.0,
"h": 62.0,
"z": 1.0,
"config": {
"fieldName": "主轴承#3",
"prefix": "主轴承温度",
"suffix": " ℃",
"fontSize": 16,
"color": "#FFFFFF"
}
},
{
"widget_type": "label",
"x": 46.0,
"y": 378.0,
"w": 162.0,
"h": 62.0,
"z": 1.0,
"config": {
"fieldName": "减速箱小轴承2",
"prefix": "减速箱小轴承温度",
"suffix": "℃",
"fontSize": 16,
"color": "#FFFFFF"
}
},
{
"widget_type": "image",
"x": 0.0,
"y": 0.0,
"w": 649.0,
"h": 864.0,
"z": 0.0,
"config": {
"imagePath": "D:/1-2.JPG"
}
},
{
"widget_type": "web",
"x": 649.0,
"y": 0.0,
"w": 886.0,
"h": 863.0,
"z": 0.0,
"config": {
"url": "http://127.0.0.1:8086/orgs/b2542eeb72a3e614/dashboards/0f8ab8a328fe9000?lower=now%28%29+-+1h",
"locked": true
}
}
]
}

Binary file not shown.

View File

@ -2,7 +2,7 @@
"influx": {
"url": "http://10.0.5.232:8086",
"org": "MEASCON",
"token": "_jtoxcVDIbol2Uqt_vlhidut-EO0Xo0ZXea2UC5a5Bgotk836F0xPN4NSGY1jYI_WaBKRau4RyZ-g2XSFiNdXw==",
"token": "JPZMq2UP5ORhLq8CfsPbawl6k0MSDlJmEwMJ2uvR_TXqW5bUOWIYBQOSXkGNzDqOU3rnuGpIxGxrB_mlAF-EEw==",
"username": "PCM",
"password": "1842moon",
"landingUrl": "http://10.0.5.232:8086/orgs/b2542eeb72a3e614/dashboards/0f8ab8a328fe9000?lower=now%28%29+-+1h",

View File

@ -0,0 +1,63 @@
{
"canvas": {
"x": 0.0,
"y": 0.0,
"w": 1920.0,
"h": 1080.0
},
"widgets": [
{
"widget_type": "label",
"x": 173.0,
"y": 210.0,
"w": 162.0,
"h": 62.0,
"z": 1.0,
"config": {
"fieldName": "主轴承#3",
"prefix": "主轴承温度",
"suffix": " ℃",
"fontSize": 16,
"color": "#FFFFFF"
}
},
{
"widget_type": "label",
"x": 46.0,
"y": 378.0,
"w": 162.0,
"h": 62.0,
"z": 1.0,
"config": {
"fieldName": "减速箱小轴承2",
"prefix": "减速箱小轴承温度",
"suffix": "℃",
"fontSize": 16,
"color": "#FFFFFF"
}
},
{
"widget_type": "image",
"x": 0.0,
"y": 0.0,
"w": 649.0,
"h": 864.0,
"z": 0.0,
"config": {
"imagePath": "D:/1-2.JPG"
}
},
{
"widget_type": "web",
"x": 649.0,
"y": 0.0,
"w": 886.0,
"h": 863.0,
"z": 0.0,
"config": {
"url": "http://127.0.0.1:8086/orgs/b2542eeb72a3e614/dashboards/0f8ab8a328fe9000?lower=now%28%29+-+1h",
"locked": true
}
}
]
}

File diff suppressed because one or more lines are too long

140
diagnose_experiment_list.py Normal file
View File

@ -0,0 +1,140 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
诊断实验列表显示问题
"""
import sqlite3
from pathlib import Path
from logger import get_logger
logger = get_logger()
def diagnose():
"""诊断实验列表问题"""
print("\n" + "="*80)
print("诊断实验列表显示问题")
print("="*80 + "\n")
try:
db_path = Path(__file__).parent / "experiments.db"
if not db_path.exists():
print(f"❌ 数据库文件不存在: {db_path}")
return
print(f"✅ 数据库文件存在: {db_path}\n")
db = sqlite3.connect(str(db_path))
cur = db.cursor()
# 1. 检查表结构
print("1. 检查 experiments 表结构")
print("-" * 80)
cur.execute("PRAGMA table_info(experiments)")
columns = cur.fetchall()
print(f"表字段数量: {len(columns)}")
print(f"{'序号':<5} {'字段名':<25} {'类型':<15} {'非空':<5} {'默认值'}")
print("-" * 80)
for col in columns:
cid, name, type_, notnull, default, pk = col
print(f"{cid:<5} {name:<25} {type_:<15} {notnull:<5} {str(default)}")
# 检查关键字段
column_names = [col[1] for col in columns]
has_save_status = 'save_status' in column_names
has_save_error = 'save_error' in column_names
print(f"\n✅ save_status 字段: {'存在' if has_save_status else '❌ 缺失'}")
print(f"✅ save_error 字段: {'存在' if has_save_error else '❌ 缺失'}")
# 2. 检查实验记录
print("\n2. 检查实验记录")
print("-" * 80)
cur.execute("SELECT COUNT(*) FROM experiments")
count = cur.fetchone()[0]
print(f"实验记录总数: {count}")
if count > 0:
# 显示最近的记录
print("\n最近5条实验记录:")
print("-" * 80)
if has_save_status and has_save_error:
cur.execute("""
SELECT id, work_order_no, start_ts, end_ts, save_status, save_error
FROM experiments
ORDER BY id DESC
LIMIT 5
""")
else:
cur.execute("""
SELECT id, work_order_no, start_ts, end_ts
FROM experiments
ORDER BY id DESC
LIMIT 5
""")
rows = cur.fetchall()
for row in rows:
if has_save_status and has_save_error:
eid, wo, st, et, save_status, save_error = row
print(f"ID: {eid}, 工单: {wo or 'N/A'}, 开始: {st or 'N/A'}, 结束: {et or 'N/A'}")
print(f" 保存状态: {save_status or 'NULL'}, 错误: {save_error or 'NULL'}")
else:
eid, wo, st, et = row
print(f"ID: {eid}, 工单: {wo or 'N/A'}, 开始: {st or 'N/A'}, 结束: {et or 'N/A'}")
# 3. 测试查询语句
print("\n3. 测试UI查询语句")
print("-" * 80)
try:
if has_save_status and has_save_error:
test_sql = """
SELECT id, start_ts, end_ts, work_order_no, process_name, part_no,
executor, remark, sqlserver_status, is_paused, is_terminated,
save_status, save_error
FROM experiments
ORDER BY id DESC
LIMIT 1
"""
else:
test_sql = """
SELECT id, start_ts, end_ts, work_order_no, process_name, part_no,
executor, remark, sqlserver_status, is_paused, is_terminated
FROM experiments
ORDER BY id DESC
LIMIT 1
"""
cur.execute(test_sql)
result = cur.fetchone()
if result:
print("✅ 查询成功")
print(f"返回字段数: {len(result)}")
else:
print("⚠️ 查询成功但无数据")
except Exception as e:
print(f"❌ 查询失败: {e}")
db.close()
print("\n" + "="*80)
print("诊断完成")
print("="*80)
# 给出建议
if not has_save_status or not has_save_error:
print("\n⚠️ 建议:运行 python add_save_status_columns.py 添加缺失的字段")
except Exception as e:
print(f"\n❌ 诊断过程出错: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
diagnose()

View File

@ -563,6 +563,7 @@ class ExperimentStateMonitor:
"""实验开始事件处理"""
try:
self._experiment_started = True
# 记录本地时间执行脚本时会自动转换为UTC
self._start_time_recorded = datetime.datetime.now().isoformat(timespec='seconds')
logger.info(
@ -590,6 +591,7 @@ class ExperimentStateMonitor:
return
self._experiment_ended = True
# 记录本地时间执行脚本时会自动转换为UTC
self._end_time_recorded = datetime.datetime.now().isoformat(timespec='seconds')
logger.info(
@ -786,55 +788,99 @@ class ExperimentStateMonitor:
)
def _execute_and_save_script_data(self) -> None:
"""执行动态脚本并保存返回数据到数据库"""
try:
from pathlib import Path
import json
logger.info(f"[脚本执行] 开始为实验{self.experiment_id}执行动态脚本")
# 获取实验配置
db_path = Path(__file__).parent / "experiments.db"
db = sqlite3.connect(str(db_path))
cur = db.cursor()
cur.execute(
"SELECT config_json, work_order_no FROM experiments WHERE id=?",
(self.experiment_id,)
)
result = cur.fetchone()
if not result:
logger.warning(f"[脚本执行] 实验{self.experiment_id}未找到配置")
"""执行动态脚本并保存返回数据到数据库(带重试机制)"""
max_retries = 3
retry_delay = 2 # 秒
for attempt in range(max_retries):
try:
from pathlib import Path
import json
logger.info(
f"[脚本执行] 开始为实验{self.experiment_id}执行动态脚本 "
f"(尝试 {attempt + 1}/{max_retries})"
)
# 获取实验配置和时间范围
db_path = Path(__file__).parent / "experiments.db"
db = sqlite3.connect(str(db_path))
cur = db.cursor()
cur.execute(
"SELECT config_json, work_order_no, start_ts, end_ts FROM experiments WHERE id=?",
(self.experiment_id,)
)
result = cur.fetchone()
if not result:
logger.warning(f"[脚本执行] 实验{self.experiment_id}未找到配置")
db.close()
self._mark_save_failed("未找到实验配置")
return
config_json, work_order_no, start_ts, end_ts = result
db.close()
return
config_json, work_order_no = result
db.close()
# 解析配置
from config_model import AppConfig
from tempfile import NamedTemporaryFile
with NamedTemporaryFile('w', delete=False, suffix='.json', encoding='utf-8') as tf:
tf.write(config_json)
snap_path = Path(tf.name)
config = AppConfig.load(snap_path)
# 执行脚本
from report_generator import _execute_experiment_script
script_data = _execute_experiment_script(config)
if script_data:
# 解析配置
from config_model import AppConfig
from tempfile import NamedTemporaryFile
import os
logger.info(f"[脚本执行] 实验{self.experiment_id}正在解析配置...")
with NamedTemporaryFile('w', delete=False, suffix='.json', encoding='utf-8') as tf:
tf.write(config_json)
snap_path = Path(tf.name)
config = AppConfig.load(snap_path)
logger.info(f"[脚本执行] 实验{self.experiment_id}配置解析成功")
# 设置环境变量(时间范围)
prev_start = os.environ.get("EXPERIMENT_START")
prev_end = os.environ.get("EXPERIMENT_END")
try:
if start_ts:
os.environ["EXPERIMENT_START"] = start_ts
logger.info(f"[脚本执行] 设置 EXPERIMENT_START={start_ts}")
if end_ts:
os.environ["EXPERIMENT_END"] = end_ts
logger.info(f"[脚本执行] 设置 EXPERIMENT_END={end_ts}")
# 执行脚本
logger.info(f"[脚本执行] 实验{self.experiment_id}正在执行动态脚本...")
from report_generator import _execute_experiment_script
script_data = _execute_experiment_script(config)
finally:
# 恢复环境变量
if prev_start is None:
os.environ.pop("EXPERIMENT_START", None)
else:
os.environ["EXPERIMENT_START"] = prev_start
if prev_end is None:
os.environ.pop("EXPERIMENT_END", None)
else:
os.environ["EXPERIMENT_END"] = prev_end
if not script_data:
logger.warning(f"[脚本执行] 实验{self.experiment_id}脚本未返回数据")
self._mark_save_failed("脚本未返回数据")
return
logger.info(
f"[脚本执行] 实验{self.experiment_id}脚本执行成功,"
f"返回数据字段: {list(script_data.keys())}"
)
# 保存脚本数据到 SQLite 数据库
logger.info(f"[脚本执行] 实验{self.experiment_id}正在保存数据到 SQLite...")
script_data_json = json.dumps(script_data, ensure_ascii=False)
db = sqlite3.connect(str(db_path))
cur = db.cursor()
cur.execute(
"UPDATE experiments SET script_data=? WHERE id=?",
"UPDATE experiments SET script_data=?, save_status='success' WHERE id=?",
(script_data_json, self.experiment_id)
)
db.commit()
@ -847,12 +893,57 @@ class ExperimentStateMonitor:
# 写入 SQL Server如果配置了
self._write_to_sqlserver(script_data, work_order_no, config)
else:
logger.warning(f"[脚本执行] 实验{self.experiment_id}脚本未返回数据")
# 成功保存,退出重试循环
logger.info(f"[脚本执行] ✅ 实验{self.experiment_id}数据保存完成")
return
except Exception as e:
is_last_attempt = (attempt == max_retries - 1)
if is_last_attempt:
# 最后一次尝试失败,记录错误并标记失败状态
error_msg = f"执行脚本失败(已重试{max_retries}次): {str(e)}"
logger.error(
f"[脚本执行] ❌ 实验{self.experiment_id}{error_msg}",
exc_info=True
)
self._mark_save_failed(error_msg)
else:
# 非最后一次尝试,等待后重试
logger.warning(
f"[脚本执行] ⚠️ 实验{self.experiment_id}执行失败(第{attempt + 1}次尝试),"
f"{retry_delay}秒后重试: {e}"
)
time.sleep(retry_delay)
def _mark_save_failed(self, error_message: str) -> None:
"""标记数据保存失败状态"""
try:
from pathlib import Path
db_path = Path(__file__).parent / "experiments.db"
db = sqlite3.connect(str(db_path))
cur = db.cursor()
# 更新保存状态为失败,并记录错误信息
cur.execute(
"""UPDATE experiments
SET save_status='failed',
save_error=?
WHERE id=?""",
(error_message, self.experiment_id)
)
db.commit()
db.close()
logger.error(
f"[保存失败] ❌ 实验{self.experiment_id}数据保存失败已标记: {error_message}"
)
except Exception as e:
logger.error(
f"[脚本执行] 实验{self.experiment_id}执行脚本失败: {e}",
f"[保存失败] 标记失败状态时出错: {e}",
exc_info=True
)

37
main.py
View File

@ -1,6 +1,7 @@
import os
import traceback
import sys
from single_instance import SingleInstance
# 在导入任何Qt模块之前设置Qt WebEngine环境变量
# 这样可以避免PySide6 6.8+版本在Windows上的DirectComposition和GPU崩溃问题
@ -19,18 +20,26 @@ os.environ["QT_OPENGL"] = "software"
os.environ["QTWEBENGINE_DISABLE_SANDBOX"] = "1"
if __name__ == "__main__":
try:
import ui_main
ui_main.run_app()
except Exception as e:
print("=" * 60)
print("程序崩溃!错误类型:", type(e).__name__)
print("错误信息:", str(e))
print("=" * 60)
traceback.print_exc()
print("=" * 60)
input("按Enter键退出...")
except SystemExit as e:
if e.code != 0:
print(f"程序异常退出,退出码: {e.code}")
with SingleInstance() as can_run:
if not can_run:
print("=" * 60)
print("程序已在运行中,不允许重复启动!")
print("=" * 60)
input("按Enter键退出...")
sys.exit(1)
try:
import ui_main
ui_main.run_app()
except Exception as e:
print("=" * 60)
print("程序崩溃!错误类型:", type(e).__name__)
print("错误信息:", str(e))
print("=" * 60)
traceback.print_exc()
print("=" * 60)
input("按Enter键退出...")
except SystemExit as e:
if e.code != 0:
print(f"程序异常退出,退出码: {e.code}")
input("按Enter键退出...")

44
single_instance.py Normal file
View File

@ -0,0 +1,44 @@
import os
import sys
import tempfile
from pathlib import Path
class SingleInstance:
def __init__(self, app_name="PCM_Report"):
self.lockfile = Path(tempfile.gettempdir()) / f"{app_name}.lock"
self.fp = None
def __enter__(self):
try:
if self.lockfile.exists():
# 尝试读取PID检查进程是否还在运行
try:
pid = int(self.lockfile.read_text().strip())
# 检查进程是否存在
if sys.platform == "win32":
import ctypes
kernel32 = ctypes.windll.kernel32
PROCESS_QUERY_INFORMATION = 0x0400
handle = kernel32.OpenProcess(PROCESS_QUERY_INFORMATION, 0, pid)
if handle:
kernel32.CloseHandle(handle)
return False # 进程存在,不允许启动
else:
os.kill(pid, 0) # Unix系统检查
return False
except (ValueError, ProcessLookupError, OSError):
# 进程不存在,删除旧锁文件
self.lockfile.unlink(missing_ok=True)
# 创建锁文件
self.lockfile.write_text(str(os.getpid()))
return True
except Exception:
return False
def __exit__(self, *args):
try:
if self.lockfile.exists():
self.lockfile.unlink()
except Exception:
pass

140
test_auto_save_fix.py Normal file
View File

@ -0,0 +1,140 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
测试实验结束后自动保存数据的修复方案
"""
import sqlite3
from pathlib import Path
from logger import get_logger
logger = get_logger()
def test_save_status_columns():
"""测试保存状态字段是否存在"""
try:
db_path = Path(__file__).parent / "experiments.db"
if not db_path.exists():
logger.error(f"数据库文件不存在: {db_path}")
return False
db = sqlite3.connect(str(db_path))
cur = db.cursor()
# 检查字段
cur.execute("PRAGMA table_info(experiments)")
columns = {row[1]: row[2] for row in cur.fetchall()}
logger.info(f"experiments 表字段: {list(columns.keys())}")
# 验证新字段
has_save_status = 'save_status' in columns
has_save_error = 'save_error' in columns
logger.info(f"save_status 字段存在: {has_save_status}")
logger.info(f"save_error 字段存在: {has_save_error}")
if not has_save_status or not has_save_error:
logger.warning("⚠️ 缺少保存状态字段,请运行 add_save_status_columns.py")
db.close()
return False
# 查询有结束时间但没有保存状态的实验
cur.execute("""
SELECT id, work_order_no, end_ts, save_status, save_error
FROM experiments
WHERE end_ts IS NOT NULL
ORDER BY id DESC
LIMIT 10
""")
rows = cur.fetchall()
logger.info(f"\n最近10个已结束的实验:")
logger.info(f"{'ID':<5} {'工单号':<15} {'结束时间':<20} {'保存状态':<10} {'错误信息'}")
logger.info("-" * 80)
for eid, work_order, end_ts, save_status, save_error in rows:
status_display = save_status or "未记录"
error_display = (save_error[:30] + "...") if save_error and len(save_error) > 30 else (save_error or "")
logger.info(f"{eid:<5} {work_order or 'N/A':<15} {end_ts or 'N/A':<20} {status_display:<10} {error_display}")
db.close()
logger.info("\n✅ 保存状态字段测试通过")
return True
except Exception as e:
logger.error(f"❌ 测试失败: {e}", exc_info=True)
return False
def test_monitor_retry_logic():
"""测试监控器的重试逻辑"""
logger.info("\n" + "="*80)
logger.info("测试监控器重试逻辑")
logger.info("="*80)
try:
from experiment_monitor import ExperimentStateMonitor
# 检查方法是否存在
has_mark_failed = hasattr(ExperimentStateMonitor, '_mark_save_failed')
has_execute_save = hasattr(ExperimentStateMonitor, '_execute_and_save_script_data')
logger.info(f"_mark_save_failed 方法存在: {has_mark_failed}")
logger.info(f"_execute_and_save_script_data 方法存在: {has_execute_save}")
if has_mark_failed and has_execute_save:
logger.info("✅ 监控器重试逻辑已实现")
return True
else:
logger.error("❌ 监控器缺少必要的方法")
return False
except Exception as e:
logger.error(f"❌ 测试失败: {e}", exc_info=True)
return False
def main():
"""运行所有测试"""
print("\n" + "="*80)
print("实验结束自动保存修复方案测试")
print("="*80 + "\n")
results = []
# 测试1: 数据库字段
print("测试1: 检查数据库保存状态字段...")
results.append(("数据库字段", test_save_status_columns()))
# 测试2: 监控器重试逻辑
print("\n测试2: 检查监控器重试逻辑...")
results.append(("监控器重试", test_monitor_retry_logic()))
# 汇总结果
print("\n" + "="*80)
print("测试结果汇总")
print("="*80)
for name, passed in results:
status = "✅ 通过" if passed else "❌ 失败"
print(f"{name:<20} {status}")
all_passed = all(passed for _, passed in results)
print("\n" + "="*80)
if all_passed:
print("✅ 所有测试通过!修复方案已正确实施")
else:
print("❌ 部分测试失败,请检查上述错误信息")
print("="*80 + "\n")
return all_passed
if __name__ == "__main__":
import sys
success = main()
sys.exit(0 if success else 1)

43
test_cot8888.py Normal file
View File

@ -0,0 +1,43 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
测试 COT8888 测试工单号功能
"""
from work_order_query import query_work_order
from logger import get_logger
logger = get_logger()
def test_cot8888():
"""测试 COT8888 工单号"""
print("\n" + "="*60)
print("测试 COT8888 测试工单号")
print("="*60 + "\n")
# 测试不同的大小写
test_cases = ['COT8888', 'cot8888', 'Cot8888']
for work_order_no in test_cases:
print(f"\n测试工单号: {work_order_no}")
print("-" * 40)
result = query_work_order(work_order_no)
if result:
print("✅ 查询成功!")
print(f" 工单号: {result.get('work_order_no')}")
print(f" 工序号: {result.get('process_no')}")
print(f" 工序名称: {result.get('process_name')}")
print(f" 零件号: {result.get('part_no')}")
print(f" 执行人: {result.get('executor')}")
else:
print("❌ 查询失败")
print("\n" + "="*60)
print("测试完成")
print("="*60 + "\n")
if __name__ == "__main__":
test_cot8888()

View File

@ -1128,6 +1128,9 @@ class MainWindow(QMainWindow):
self.logger = get_logger()
self.logger.info("MainWindow initialized")
# 自动检查并更新数据库结构
self._auto_migrate_database()
# 启动界面引用
self._splash = None
@ -1445,6 +1448,9 @@ class MainWindow(QMainWindow):
self._alarm_timer.timeout.connect(self._on_alarm_tick)
self._alarm_polling = False
self._alarm_timer.start()
# 启动 PCM_Viewer延迟执行确保主窗口完全初始化
QTimer.singleShot(1000, self._auto_start_pcm_viewer)
def _update_debug_mode_ui(self) -> None:
"""根据debug模式更新UI显示"""
@ -3982,6 +3988,45 @@ class MainWindow(QMainWindow):
db.close()
self._reload_experiments()
def _auto_migrate_database(self) -> None:
"""程序启动时自动检查并更新数据库结构"""
try:
db_path = APP_DIR / "experiments.db"
if not db_path.exists():
self.logger.info("[数据库迁移] 数据库文件不存在,跳过迁移")
return
db = sqlite3.connect(str(db_path))
cur = db.cursor()
# 检查是否需要添加 save_status 和 save_error 字段
cur.execute("PRAGMA table_info(experiments)")
columns = [row[1] for row in cur.fetchall()]
needs_migration = False
if 'save_status' not in columns:
self.logger.info("[数据库迁移] 添加 save_status 字段...")
cur.execute("ALTER TABLE experiments ADD COLUMN save_status TEXT DEFAULT NULL")
needs_migration = True
if 'save_error' not in columns:
self.logger.info("[数据库迁移] 添加 save_error 字段...")
cur.execute("ALTER TABLE experiments ADD COLUMN save_error TEXT DEFAULT NULL")
needs_migration = True
if needs_migration:
db.commit()
self.logger.info("[数据库迁移] ✅ 数据库结构更新完成")
else:
self.logger.info("[数据库迁移] 数据库结构已是最新,无需更新")
db.close()
except Exception as e:
self.logger.error(f"[数据库迁移] ❌ 自动迁移失败: {e}", exc_info=True)
def _reload_experiments(self) -> None:
# 保护:如果被后台线程误调用,立即切回主线程执行
try:
@ -4024,9 +4069,18 @@ class MainWindow(QMainWindow):
try:
db = sqlite3.connect(str(APP_DIR / "experiments.db"))
cur = db.cursor()
# 检查是否有新字段(向后兼容)
cur.execute("PRAGMA table_info(experiments)")
columns = [row[1] for row in cur.fetchall()]
has_save_fields = 'save_status' in columns and 'save_error' in columns
# 根据首页筛选条件拼接查询
base_sql = "SELECT id, start_ts, end_ts, work_order_no, process_name, part_no, executor, remark, sqlserver_status, is_paused, is_terminated FROM experiments"
# 根据首页筛选条件拼接查询(如果有新字段则包含)
if has_save_fields:
base_sql = "SELECT id, start_ts, end_ts, work_order_no, process_name, part_no, executor, remark, sqlserver_status, is_paused, is_terminated, save_status, save_error FROM experiments"
else:
base_sql = "SELECT id, start_ts, end_ts, work_order_no, process_name, part_no, executor, remark, sqlserver_status, is_paused, is_terminated FROM experiments"
conds: list[str] = []
params: list[str] = []
@ -4050,14 +4104,31 @@ class MainWindow(QMainWindow):
rows = []
self.exp_history_table.setRowCount(len(rows))
for r, (eid, st, et, work_order_no, process_name, part_no, executor, remark, sqlserver_status, is_paused, is_terminated) in enumerate(rows):
for r, row_data in enumerate(rows):
# 兼容新旧数据库结构
if has_save_fields:
eid, st, et, work_order_no, process_name, part_no, executor, remark, sqlserver_status, is_paused, is_terminated, save_status, save_error = row_data
else:
eid, st, et, work_order_no, process_name, part_no, executor, remark, sqlserver_status, is_paused, is_terminated = row_data
save_status = None
save_error = None
self.exp_history_table.setItem(r, 0, QTableWidgetItem(str(st or "")))
self.exp_history_table.setItem(r, 1, QTableWidgetItem(str(et or "")))
self.exp_history_table.setItem(r, 2, QTableWidgetItem(str(work_order_no or "")))
self.exp_history_table.setItem(r, 3, QTableWidgetItem(str(process_name or "")))
self.exp_history_table.setItem(r, 4, QTableWidgetItem(str(part_no or "")))
self.exp_history_table.setItem(r, 5, QTableWidgetItem(str(executor or "")))
self.exp_history_table.setItem(r, 6, QTableWidgetItem(str(remark or "")))
# 备注列:如果保存失败,添加警告标记
remark_text = str(remark or "")
if save_status == 'failed' and et:
remark_text = f"⚠️ 数据保存失败 | {remark_text}" if remark_text else "⚠️ 数据保存失败"
remark_item = QTableWidgetItem(remark_text)
if save_status == 'failed':
remark_item.setForeground(Qt.red)
remark_item.setToolTip(f"保存失败原因: {save_error or '未知错误'}")
self.exp_history_table.setItem(r, 6, remark_item)
# 数据库状态列
db_status_item = QTableWidgetItem(str(sqlserver_status or ""))
@ -4157,8 +4228,19 @@ class MainWindow(QMainWindow):
# 保存数据按钮(仅在实验已结束时显示)
if et: # 有结束时间
btn_save_data = QPushButton("保存数据")
btn_save_data.setStyleSheet("QPushButton { background-color: #1976d2; color: white; }")
# 根据保存状态设置按钮文本和样式(仅在有新字段时才判断状态)
if has_save_fields and save_status == 'failed':
btn_save_data = QPushButton("重试保存")
btn_save_data.setStyleSheet("QPushButton { background-color: #f44336; color: white; font-weight: bold; }")
btn_save_data.setToolTip(f"上次保存失败: {save_error or '未知错误'}\n点击重试")
elif has_save_fields and save_status == 'success':
btn_save_data = QPushButton("已保存")
btn_save_data.setStyleSheet("QPushButton { background-color: #4caf50; color: white; }")
btn_save_data.setToolTip("数据已保存,点击可重新保存")
else:
btn_save_data = QPushButton("保存数据")
btn_save_data.setStyleSheet("QPushButton { background-color: #1976d2; color: white; }")
btn_save_data.clicked.connect(lambda _=False, id=eid: self._execute_script_for_experiment(id))
w_save_data = QWidget(); les = QHBoxLayout(); les.setContentsMargins(0,0,0,0); les.addWidget(btn_save_data); les.addStretch(1); w_save_data.setLayout(les)
else:
@ -5494,30 +5576,103 @@ class MainWindow(QMainWindow):
QMessageBox.critical(self, "错误", f"实验台断电时发生异常: {str(e)}")
def _open_dashboard_viewer(self):
"""打开 PCM_Viewer 全屏展示"""
# 选择布局文件
file_path, _ = QFileDialog.getOpenFileName(
self,
"选择看板布局文件",
"",
"JSON文件 (*.json)"
)
if not file_path:
return
# 检查文件是否存在
from pathlib import Path
if not Path(file_path).exists():
QMessageBox.warning(self, "警告", "选择的文件不存在!")
return
# 启动 PCM_Viewer 子进程隐藏模式通过UDP通信
"""打开 PCM_Viewer 全屏展示仅发送UDP命令"""
try:
self._start_pcm_viewer(file_path)
# 第一步:让用户选择配置类型
# 传递上次选择的配置类型名称作为默认值
default_category_name = self._current_config_category.name if self._current_config_category else None
self.logger.info(f"打开数据展示,默认配置类型: {default_category_name}")
# 使用自定义标题和消息的配置类型选择对话框
from config_type_selector import ConfigTypeSelectorDialog
category = ConfigTypeSelectorDialog.select_config_type(
parent=self,
title="数据展示 - 选择展示类型",
message="请选择要展示的类型:",
default_category_name=default_category_name
)
if category is None:
# 用户取消了选择
self.logger.info("用户取消了数据展示类型选择")
self.statusBar().showMessage("已取消数据展示", 2000)
return
self.logger.info(f"用户为数据展示选择展示类型: {category.name}")
# 第二步构建dashboard.json的完整路径
# 路径格式: C:\PPRO\PCM_Report\configs\600泵\dashboard.json
dashboard_path = category.path / "dashboard.json"
# 检查文件是否存在
if not dashboard_path.exists():
QMessageBox.warning(self, "警告", f"展示类型 {category.name} 的dashboard.json文件不存在\n路径: {dashboard_path}")
self.logger.warning(f"dashboard.json不存在: {dashboard_path}")
return
# 第三步发送UDP命令
file_path = str(dashboard_path.resolve()) # 转换为绝对路径字符串
self._send_udp_command({
'action': 'show_and_fullscreen',
'path': file_path
})
self.logger.info(f"已发送显示命令: {file_path}")
self.statusBar().showMessage(f"✓ 已发送看板显示命令: {category.name}", 3000)
except Exception as e:
QMessageBox.critical(self, "错误", f"启动看板失败: {str(e)}")
self.logger.error(f"启动看板失败: {e}")
QMessageBox.critical(self, "错误", f"发送看板命令失败: {str(e)}")
self.logger.error(f"发送看板命令失败: {e}")
def _auto_start_pcm_viewer(self):
"""软件启动时自动启动 PCM_Viewer 可执行文件不发送UDP命令"""
import subprocess
import sys
import os
try:
# 获取当前可执行文件所在目录
if getattr(sys, 'frozen', False):
# 打包后的环境
app_dir = os.path.dirname(sys.executable)
else:
# 开发环境
app_dir = os.path.dirname(os.path.abspath(__file__))
# 尝试多个可能的路径
possible_paths = [
os.path.join(app_dir, "PCM_Viewer.exe"), # 同级目录
os.path.join(app_dir, "_internal", "PCM_Viewer.exe"), # _internal 目录
r"C:\PPro\PCM_Viewer\dist\PCM_Viewer.exe" # 开发环境备用路径
]
viewer_path = None
for path in possible_paths:
if os.path.exists(path):
viewer_path = path
break
if not viewer_path:
self.logger.warning(f"未找到 PCM_Viewer.exe尝试的路径: {possible_paths}")
return
# 检查 PCM_Viewer 是否已在运行
viewer_running = self._check_viewer_running()
if not viewer_running:
# 启动 PCM_Viewer不带参数正常启动
self.logger.info("自动启动 PCM_Viewer...")
subprocess.Popen(
[viewer_path],
shell=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=subprocess.CREATE_NO_WINDOW
)
self.logger.info("PCM_Viewer 已启动")
else:
self.logger.info("PCM_Viewer 已在运行,跳过启动")
except Exception as e:
self.logger.error(f"自动启动 PCM_Viewer 失败: {e}")
def _start_pcm_viewer(self, layout_path: str, udp_port: int = 9876):
"""启动 PCM_Viewer 并通过 UDP 发送显示命令
@ -5531,12 +5686,36 @@ class MainWindow(QMainWindow):
import subprocess
import time
import os
import sys
viewer_path = r"F:\PyPro\PCM_Viewer\dist\PCM_Viewer.exe"
# 获取当前可执行文件所在目录
if getattr(sys, 'frozen', False):
# 打包后的环境
app_dir = os.path.dirname(sys.executable)
else:
# 开发环境
app_dir = os.path.dirname(os.path.abspath(__file__))
# 尝试多个可能的路径
possible_paths = [
os.path.join(app_dir, "PCM_Viewer.exe"), # 同级目录
os.path.join(app_dir, "_internal", "PCM_Viewer.exe"), # _internal 目录
r"C:\PPro\PCM_Viewer\dist\PCM_Viewer.exe" # 开发环境备用路径
]
viewer_path = None
for path in possible_paths:
if os.path.exists(path):
viewer_path = path
break
if not viewer_path:
self.logger.warning(f"未找到 PCM_Viewer.exe尝试的路径: {possible_paths}")
return
# 检查 PCM_Viewer 是否已在运行通过UDP探测
viewer_running = self._check_viewer_running(udp_port)
print("viewer_running:", viewer_running)
if not viewer_running:
# 启动 PCM_Viewer隐藏模式
self.logger.info("启动 PCM_Viewer...")
@ -5559,23 +5738,25 @@ class MainWindow(QMainWindow):
self.logger.info(f"已发送显示命令: {layout_path}")
def _check_viewer_running(self, port: int = 9876) -> bool:
"""检查 PCM_Viewer 是否已在运行
"""检查 PCM_Viewer 是否已在运行(通过进程名检测)
Args:
port: UDP 端口
port: 保留参数兼容调用方签名
Returns:
bool: 是否运行中
"""
import socket
import subprocess
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(0.5)
# 发送探测命令
sock.sendto(b'ping', ('127.0.0.1', port))
sock.close()
return True
except:
result = subprocess.run(
['tasklist', '/fi', 'imagename eq PCM_Viewer.exe', '/fo', 'csv', '/nh'],
capture_output=True,
text=True,
creationflags=subprocess.CREATE_NO_WINDOW
)
return 'PCM_Viewer.exe' in result.stdout
except Exception as e:
self.logger.warning(f"进程检测失败,默认视为未运行: {e}")
return False
def _send_udp_command(self, command: dict, port: int = 9876):
@ -5897,10 +6078,11 @@ class MainWindow(QMainWindow):
try:
db = sqlite3.connect(str(APP_DIR / "experiments.db"))
cur = db.cursor()
# 设置 end_ts 为当前时间如果还没有is_terminated = 0保持正常状态
# 设置 end_ts 为当前本地时间如果还没有is_terminated = 0保持正常状态
end_time = datetime.datetime.now().isoformat(timespec="seconds")
cur.execute(
"UPDATE experiments SET end_ts = CASE WHEN end_ts IS NULL THEN datetime('now') ELSE end_ts END, is_terminated = 0 WHERE id = ?",
(exp_id,)
"UPDATE experiments SET end_ts = CASE WHEN end_ts IS NULL THEN ? ELSE end_ts END, is_terminated = 0 WHERE id = ?",
(end_time, exp_id)
)
db.commit()
db.close()

View File

@ -97,6 +97,17 @@ def query_work_order(work_order_no: str) -> Optional[Dict[str, str]]:
logger.warning("工单号为空")
return None
# 特殊测试工单号COT8888 - 自动返回假数据用于测试
if work_order_no.upper() == 'COT8888':
logger.info(f"[测试模式] 检测到测试工单号 {work_order_no},返回假数据")
return {
'work_order_no': work_order_no,
'process_no': 'TEST-8888',
'process_name': WORK_ORDER_DB_CONFIG.get('target_process_name', '泵空跑合'),
'part_no': 'TEST-PART-8888',
'executor': '测试人员'
}
# Debug模式返回假数据
if WORK_ORDER_DB_CONFIG.get('debug_mode', False):
logger.info(f"[DEBUG模式] 返回工单号 {work_order_no} 的假数据")