feat: 益选 OCR 订单处理系统初始提交

- 智能供应商识别(蓉城易购/烟草/杨碧月/通用)
- 百度 OCR 表格识别集成
- 规则引擎(列映射/数据清洗/单位转换/规格推断)
- 条码映射管理与云端同步(Gitea REST API)
- 云端同步支持:条码映射、供应商配置、商品资料、采购模板
- 拖拽一键处理(图片→OCR→Excel→合并)
- 191 个单元测试
- 移除无用的模板管理功能
- 清理 IDE 产物目录

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-04 19:51:13 +08:00
commit e4d62df7e3
78 changed files with 15257 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
"""益选-OCR订单处理系统 UI 模块"""
+565
View File
@@ -0,0 +1,565 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""业务操作处理模块"""
import os
import time
import datetime
import json
import logging
import tkinter as tk
from tkinter import messagebox
from threading import Thread
from app.config.settings import ConfigManager
from app.services.ocr_service import OCRService
from app.services.order_service import OrderService
from app.core.utils.log_utils import get_logger
from .logging_ui import add_to_log, init_gui_logger, dispose_gui_logger, GUILogHandler
from .ui_widgets import ProgressReporter
from .error_utils import show_error_dialog, get_error_suggestion
logger = get_logger(__name__)
from .result_previews import show_ocr_result_preview, show_excel_result_preview, show_merge_result_preview
from .user_settings import add_recent_file
from .command_runner import get_running_task, set_running_task
from .file_operations import select_file, select_excel_file, validate_unit_price_against_item_data
def _ask_and_merge_purchase_orders(order_service, log_widget, add_to_recent=False):
"""弹窗询问是否合并采购单,返回合并结果路径或 None。
用于 run_pipeline_directly 和 batch_process_orders_with_status 的共享逻辑。
"""
try:
purchase_orders = order_service.get_purchase_orders()
if len(purchase_orders) == 0:
add_to_log(log_widget, "没有找到采购单文件,跳过合并步骤\n", "info")
elif len(purchase_orders) == 1:
add_to_log(log_widget, f"只有1个采购单文件,无需合并: {os.path.basename(purchase_orders[0])}\n", "info")
else:
add_to_log(log_widget, f"找到{len(purchase_orders)}个采购单文件\n", "info")
file_list = "\n".join([f"{os.path.basename(f)}" for f in purchase_orders])
merge_choice = messagebox.askyesnocancel(
"采购单合并选择",
f"发现{len(purchase_orders)}个采购单文件:\n\n{file_list}\n\n是否需要合并这些采购单?\n\n• 选择'':合并所有采购单\n• 选择'':保持文件分离\n• 选择'取消':跳过此步骤",
icon='question'
)
if merge_choice is True:
add_to_log(log_widget, "开始合并采购单...\n", "info")
merge_result = order_service.merge_all_purchase_orders()
if merge_result:
add_to_log(log_widget, "采购单合并完成\n", "success")
if add_to_recent:
try:
add_recent_file(merge_result)
except Exception as e:
logger.debug(f"添加最近文件失败: {e}")
return merge_result
else:
add_to_log(log_widget, "合并失败\n", "warning")
elif merge_choice is False:
add_to_log(log_widget, "用户选择不合并采购单,保持文件分离\n", "info")
else:
add_to_log(log_widget, "用户取消合并操作\n", "info")
except Exception as e:
add_to_log(log_widget, f"合并过程出现问题: {str(e)}\n", "warning")
return None
def process_single_image_with_status(log_widget, status_bar):
status_bar.set_status("选择图片中...")
file_path = select_file(log_widget, [("图片文件", "*.jpg *.jpeg *.png *.bmp"), ("所有文件", "*.*")], "选择图片")
if not file_path:
status_bar.set_status("操作已取消")
add_to_log(log_widget, "未选择文件,操作已取消\n", "warning")
return
def run_in_thread():
try:
status_bar.set_running(True)
status_bar.set_status("开始处理图片...")
gui_handler = GUILogHandler(log_widget)
gui_handler.setLevel(logging.INFO)
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
gui_handler.setFormatter(formatter)
root_logger = logging.getLogger()
for handler in root_logger.handlers[:]:
if isinstance(handler, logging.StreamHandler):
root_logger.removeHandler(handler)
root_logger.addHandler(gui_handler)
root_logger.setLevel(logging.INFO)
ocr_service = OCRService()
add_to_log(log_widget, f"开始处理图片: {file_path}\n", "info")
try:
add_recent_file(file_path)
except Exception as e:
logger.debug(f"添加最近文件失败: {e}")
excel_path = ocr_service.process_image(file_path)
if excel_path:
add_to_log(log_widget, "图片OCR处理完成\n", "success")
preview_output = f"采购单已保存到: {excel_path}\n"
show_excel_result_preview(preview_output)
try:
add_recent_file(excel_path)
except Exception as e:
logger.debug(f"添加最近文件失败: {e}")
else:
add_to_log(log_widget, "图片OCR处理失败\n", "error")
except Exception as e:
add_to_log(log_widget, f"处理单个图片时出错: {str(e)}\n", "error")
sugg = get_error_suggestion(str(e))
if sugg:
show_error_dialog("OCR处理错误", str(e), sugg)
finally:
try:
root_logger = logging.getLogger()
for handler in root_logger.handlers[:]:
if isinstance(handler, GUILogHandler):
root_logger.removeHandler(handler)
handler.close()
except Exception as e:
logger.debug(f"清理日志处理器失败: {e}")
status_bar.set_running(False)
status_bar.set_status("就绪")
thread = Thread(target=run_in_thread)
thread.daemon = True
thread.start()
def run_pipeline_directly(log_widget, status_bar):
"""直接运行完整处理流程"""
if get_running_task() is not None:
messagebox.showinfo("任务进行中", "请等待当前任务完成后再执行新的操作。")
return
def run_in_thread():
set_running_task("pipeline")
if status_bar:
status_bar.set_running(True)
status_bar.set_status("开始完整处理流程...")
start_time = datetime.datetime.now()
start_perf = time.perf_counter()
log_widget.configure(state=tk.NORMAL)
log_widget.delete(1.0, tk.END)
log_widget.insert(tk.END, "执行命令: 完整处理流程\n", "command")
log_widget.insert(tk.END, f"开始时间: {start_time.strftime('%Y-%m-%d %H:%M:%S')}\n", "time")
log_widget.insert(tk.END, "=" * 50 + "\n\n", "separator")
log_widget.configure(state=tk.DISABLED)
try:
config = ConfigManager()
gui_handler = init_gui_logger(log_widget)
ocr_service = OCRService(config)
order_service = OrderService(config)
reporter = ProgressReporter(status_bar)
reporter.running()
reporter.set("开始OCR批量处理...", 10)
total, success = ocr_service.batch_process(progress_cb=lambda p: reporter.set("OCR处理中...", p))
if total == 0:
add_to_log(log_widget, "没有找到需要处理的图片\n", "warning")
if status_bar:
status_bar.set_status("未找到图片文件")
return
elif success == 0:
add_to_log(log_widget, "OCR处理没有成功处理任何新文件\n", "warning")
else:
add_to_log(log_widget, f"OCR处理完成,共处理 {success}/{total} 个文件\n", "success")
try:
processed_map = {}
config = ConfigManager()
pjson = config.get('Paths', 'processed_record', fallback='data/processed_files.json')
if os.path.exists(pjson):
with open(pjson, 'r', encoding='utf-8') as f:
processed_map = json.load(f)
outputs = list(processed_map.values())
for p in outputs[-10:]:
if p:
add_recent_file(os.path.abspath(p))
except Exception as e:
logger.debug(f"加载已处理文件记录失败: {e}")
reporter.set("开始Excel处理...", 92)
add_to_log(log_widget, "开始Excel处理...\n", "info")
result = order_service.process_excel()
if not result:
add_to_log(log_widget, "Excel处理失败\n", "error")
else:
add_to_log(log_widget, "Excel处理完成\n", "success")
try:
add_recent_file(result)
except Exception as e:
logger.debug(f"添加最近文件失败: {e}")
try:
validate_unit_price_against_item_data(result, log_widget)
except Exception as e:
logger.debug(f"单价校验失败: {e}")
reporter.set("检查是否需要合并采购单...", 80)
_ask_and_merge_purchase_orders(order_service, log_widget, add_to_recent=True)
end_time = datetime.datetime.now()
duration_sec = max(0.0, time.perf_counter() - start_perf)
add_to_log(log_widget, f"\n{'=' * 50}\n", "separator")
add_to_log(log_widget, "完整处理流程执行完毕!\n", "success")
add_to_log(log_widget, f"结束时间: {end_time.strftime('%Y-%m-%d %H:%M:%S')}\n", "time")
add_to_log(log_widget, f"耗时: {duration_sec:.2f}\n", "time")
reporter.set("处理完成", 100)
except Exception as e:
add_to_log(log_widget, f"执行过程中发生错误: {str(e)}\n", "error")
import traceback
add_to_log(log_widget, f"详细错误信息: {traceback.format_exc()}\n", "error")
finally:
dispose_gui_logger()
reporter.done()
set_running_task(None)
if status_bar:
status_bar.set_running(False)
status_bar.set_status("就绪")
thread = Thread(target=run_in_thread)
thread.daemon = True
thread.start()
def batch_ocr_with_status(log_widget, status_bar):
"""OCR批量识别"""
def run_in_thread():
try:
reporter = ProgressReporter(status_bar)
reporter.running()
reporter.set("正在进行OCR批量识别...", 10)
add_to_log(log_widget, "开始OCR批量识别\n", "info")
init_gui_logger(log_widget)
ocr_service = OCRService()
result = ocr_service.batch_process()
if result:
add_to_log(log_widget, "OCR批量识别完成\n", "success")
show_ocr_result_preview("OCR批量识别成功完成")
reporter.set("批量识别完成", 100)
try:
processed_map = {}
config = ConfigManager()
pjson = config.get('Paths', 'processed_record', fallback='data/processed_files.json')
if os.path.exists(pjson):
with open(pjson, 'r', encoding='utf-8') as f:
processed_map = json.load(f)
outputs = list(processed_map.values())
for p in outputs[-10:]:
if p:
add_recent_file(p)
inputs = list(processed_map.keys())
for p in inputs[-10:]:
if p:
add_recent_file(p)
except Exception as e:
logger.debug(f"加载已处理文件记录失败: {e}")
else:
add_to_log(log_widget, "OCR批量识别失败\n", "error")
except Exception as e:
add_to_log(log_widget, f"OCR批量识别出错: {str(e)}\n", "error")
sugg = get_error_suggestion(str(e))
if sugg:
show_error_dialog("OCR处理错误", str(e), sugg)
finally:
dispose_gui_logger()
reporter.done()
thread = Thread(target=run_in_thread)
thread.daemon = True
thread.start()
def batch_process_orders_with_status(log_widget, status_bar):
"""批量处理订单(仅Excel处理,包含合并确认)"""
def run_in_thread():
try:
reporter = ProgressReporter(status_bar)
reporter.running()
reporter.set("正在批量处理订单...", 10)
add_to_log(log_widget, "开始批量处理订单\n", "info")
init_gui_logger(log_widget)
order_service = OrderService()
add_to_log(log_widget, "开始Excel处理...\n", "info")
try:
latest_input = order_service.get_latest_excel()
if latest_input:
add_recent_file(latest_input)
except Exception as e:
logger.debug(f"获取最新Excel失败: {e}")
result = order_service.process_excel(progress_cb=lambda p: reporter.set("Excel处理中...", p))
if result:
add_to_log(log_widget, "Excel处理完成\n", "success")
try:
validate_unit_price_against_item_data(result, log_widget)
except Exception as e:
logger.debug(f"单价校验失败: {e}")
reporter.set("检查是否需要合并采购单...", 70)
add_to_log(log_widget, "检查是否需要合并采购单...\n", "info")
_ask_and_merge_purchase_orders(order_service, log_widget)
add_to_log(log_widget, "批量处理订单完成\n", "success")
reporter.set("批量处理订单完成", 100)
show_excel_result_preview(f"采购单已保存到: {result}\n")
try:
add_recent_file(result)
except Exception as e:
logger.debug(f"添加最近文件失败: {e}")
else:
add_to_log(log_widget, "批量处理订单失败\n", "error")
except Exception as e:
add_to_log(log_widget, f"批量处理订单时出错: {str(e)}\n", "error")
sugg = get_error_suggestion(str(e))
if sugg:
show_error_dialog("Excel处理错误", str(e), sugg)
finally:
dispose_gui_logger()
reporter.done()
thread = Thread(target=run_in_thread)
thread.daemon = True
thread.start()
def merge_orders_with_status(log_widget, status_bar):
"""合并采购单"""
def run_in_thread():
try:
reporter = ProgressReporter(status_bar)
reporter.running()
reporter.set("正在合并采购单...", 10)
add_to_log(log_widget, "开始合并采购单\n", "info")
init_gui_logger(log_widget)
order_service = OrderService()
result = order_service.merge_all_purchase_orders(progress_cb=lambda p: reporter.set("合并处理中...", p))
if result:
add_to_log(log_widget, "采购单合并完成\n", "success")
show_merge_result_preview(f"已保存到: {result}\n")
try:
add_recent_file(result)
except Exception as e:
logger.debug(f"添加最近文件失败: {e}")
try:
validate_unit_price_against_item_data(result, log_widget)
except Exception as e:
logger.debug(f"单价校验失败: {e}")
else:
add_to_log(log_widget, "采购单合并失败\n", "error")
except Exception as e:
add_to_log(log_widget, f"采购单合并出错: {str(e)}\n", "error")
sugg = get_error_suggestion(str(e))
if sugg:
show_error_dialog("合并错误", str(e), sugg)
finally:
dispose_gui_logger()
reporter.done()
thread = Thread(target=run_in_thread)
thread.daemon = True
thread.start()
def process_excel_file_with_status(log_widget, status_bar):
"""处理Excel文件"""
def run_in_thread():
try:
status_bar.set_running(True)
status_bar.set_status("选择Excel文件中...")
file_path = select_excel_file(log_widget)
if file_path:
status_bar.set_status("开始处理Excel文件...")
add_to_log(log_widget, f"开始处理Excel文件: {file_path}\n", "info")
else:
status_bar.set_status("操作已取消")
add_to_log(log_widget, "未选择文件,操作已取消\n", "warning")
return
init_gui_logger(log_widget)
order_service = OrderService()
if file_path:
try:
add_recent_file(file_path)
except Exception as e:
logger.debug(f"添加最近文件失败: {e}")
result = order_service.process_excel(file_path, progress_cb=lambda p: status_bar.set_status("Excel处理中...", p))
else:
try:
latest_input = order_service.get_latest_excel()
if latest_input:
add_recent_file(latest_input)
except Exception as e:
logger.debug(f"获取最新Excel失败: {e}")
result = order_service.process_excel(progress_cb=lambda p: status_bar.set_status("Excel处理中...", p))
if result:
add_to_log(log_widget, "Excel文件处理完成\n", "success")
show_excel_result_preview(f"采购单已保存到: {result}\n")
try:
add_recent_file(result)
except Exception as e:
logger.debug(f"添加最近文件失败: {e}")
try:
validate_unit_price_against_item_data(result, log_widget)
except Exception as e:
logger.debug(f"单价校验失败: {e}")
else:
add_to_log(log_widget, "Excel文件处理失败\n", "error")
except Exception as e:
add_to_log(log_widget, f"Excel文件处理出错: {str(e)}\n", "error")
msg = str(e)
suggestion = None
if 'openpyxl' in msg or 'engine' in msg:
suggestion = "安装依赖:pip install openpyxl"
elif 'xlrd' in msg:
suggestion = "安装依赖:pip install xlrd"
if suggestion:
show_error_dialog("Excel处理错误", msg, suggestion)
finally:
dispose_gui_logger()
status_bar.set_running(False)
status_bar.set_status("就绪")
thread = Thread(target=run_in_thread)
thread.daemon = True
thread.start()
def process_dropped_file(log_widget, status_bar, file_path):
try:
ext = os.path.splitext(file_path)[1].lower()
if ext in ['.jpg', '.jpeg', '.png', '.bmp']:
def _run_img():
try:
reporter = ProgressReporter(status_bar)
reporter.running()
init_gui_logger(log_widget)
add_to_log(log_widget, f"开始一键处理图片: {file_path}\n", "info")
try:
add_recent_file(file_path)
except Exception as e:
logger.debug(f"添加最近文件失败: {e}")
# 步骤1: OCR识别
reporter.set("OCR识别中...", 10)
ocr_service = OCRService()
excel_path = ocr_service.process_image(file_path)
if not excel_path:
add_to_log(log_widget, "图片OCR处理失败\n", "error")
return
add_to_log(log_widget, f"OCR识别完成: {excel_path}\n", "success")
# 步骤2: Excel处理
reporter.set("Excel处理中...", 40)
order_service = OrderService()
result = order_service.process_excel(excel_path, progress_cb=lambda p: reporter.set("Excel处理中...", p))
if not result:
add_to_log(log_widget, "Excel处理失败\n", "error")
return
add_to_log(log_widget, f"Excel处理完成: {result}\n", "success")
try:
add_recent_file(result)
except Exception as e:
logger.debug(f"添加最近文件失败: {e}")
try:
validate_unit_price_against_item_data(result, log_widget)
except Exception as e:
logger.debug(f"单价校验失败: {e}")
# 步骤3: 合并采购单
reporter.set("检查合并采购单...", 80)
_ask_and_merge_purchase_orders(order_service, log_widget, add_to_recent=True)
reporter.set("处理完成", 100)
add_to_log(log_widget, "一键处理完成!\n", "success")
finally:
dispose_gui_logger()
reporter.done()
t = Thread(target=_run_img)
t.daemon = True
t.start()
elif ext in ['.xlsx', '.xls']:
def _run_xls():
try:
reporter = ProgressReporter(status_bar)
reporter.running()
init_gui_logger(log_widget)
order_service = OrderService()
add_to_log(log_widget, f"开始一键处理Excel文件: {file_path}\n", "info")
try:
add_recent_file(file_path)
except Exception as e:
logger.debug(f"添加最近文件失败: {e}")
# 步骤1: Excel处理
reporter.set("Excel处理中...", 20)
result = order_service.process_excel(file_path, progress_cb=lambda p: reporter.set("Excel处理中...", p))
if not result:
add_to_log(log_widget, "Excel文件处理失败\n", "error")
return
add_to_log(log_widget, f"Excel处理完成: {result}\n", "success")
try:
add_recent_file(result)
except Exception as e:
logger.debug(f"添加最近文件失败: {e}")
try:
validate_unit_price_against_item_data(result, log_widget)
except Exception as e:
logger.debug(f"单价校验失败: {e}")
# 步骤2: 合并采购单
reporter.set("检查合并采购单...", 80)
_ask_and_merge_purchase_orders(order_service, log_widget, add_to_recent=True)
reporter.set("处理完成", 100)
add_to_log(log_widget, "一键处理完成!\n", "success")
finally:
dispose_gui_logger()
reporter.done()
t = Thread(target=_run_xls)
t.daemon = True
t.start()
else:
add_to_log(log_widget, f"不支持的文件类型: {file_path}\n", "warning")
except Exception as e:
add_to_log(log_widget, f"处理拖拽文件失败: {str(e)}\n", "error")
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""条码映射编辑模块"""
from tkinter import messagebox
from app.core.excel.converter import UnitConverter
from app.core.utils.dialog_utils import show_barcode_mapping_dialog
from .logging_ui import add_to_log
def edit_barcode_mappings(log_widget):
"""编辑条码映射配置"""
try:
add_to_log(log_widget, "正在加载条码映射配置...\n", "info")
unit_converter = UnitConverter()
current_mappings = unit_converter.special_barcodes
def save_mappings(new_mappings):
success = unit_converter.update_barcode_mappings(new_mappings)
if success:
add_to_log(log_widget, f"成功保存条码映射配置,共{len(new_mappings)}\n", "success")
else:
add_to_log(log_widget, "保存条码映射配置失败\n", "error")
show_barcode_mapping_dialog(None, save_mappings, current_mappings)
except Exception as e:
add_to_log(log_widget, f"编辑条码映射时出错: {str(e)}\n", "error")
messagebox.showerror("错误", f"编辑条码映射时出错: {str(e)}")
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""命令执行器模块"""
import os
import sys
import time
import subprocess
import datetime
import re
import tkinter as tk
from tkinter import messagebox
from threading import Thread
from .logging_ui import LogRedirector
from .result_previews import show_result_preview
# 任务状态跟踪
_RUNNING_TASK = None
def get_running_task():
return _RUNNING_TASK
def set_running_task(val):
global _RUNNING_TASK
_RUNNING_TASK = val
def run_command_with_logging(command, log_widget, status_bar=None, on_complete=None):
"""运行命令并将输出重定向到日志窗口"""
if _RUNNING_TASK is not None:
messagebox.showinfo("任务进行中", "请等待当前任务完成后再执行新的操作。")
return
def run_in_thread():
global _RUNNING_TASK
_RUNNING_TASK = command
if status_bar:
status_bar.set_running(True)
start_time = datetime.datetime.now()
start_perf = time.perf_counter()
log_widget.configure(state=tk.NORMAL)
log_widget.delete(1.0, tk.END)
log_widget.insert(tk.END, f"执行命令: {' '.join(command)}\n", "command")
log_widget.insert(tk.END, f"开始时间: {start_time.strftime('%Y-%m-%d %H:%M:%S')}\n", "time")
log_widget.insert(tk.END, "=" * 50 + "\n\n", "separator")
log_widget.configure(state=tk.DISABLED)
old_stdout = sys.stdout
old_stderr = sys.stderr
log_redirector = LogRedirector(log_widget)
env = os.environ.copy()
try:
from app.config.settings import ConfigManager
cfg = ConfigManager()
env["OCR_OUTPUT_DIR"] = cfg.get_path('Paths', 'output_folder', fallback='data/output', create=True)
env["OCR_INPUT_DIR"] = cfg.get_path('Paths', 'input_folder', fallback='data/input', create=True)
env["OCR_TEMP_DIR"] = cfg.get_path('Paths', 'temp_folder', fallback='data/temp', create=True)
except Exception:
env["OCR_OUTPUT_DIR"] = os.path.abspath("data/output")
env["OCR_INPUT_DIR"] = os.path.abspath("data/input")
env["OCR_TEMP_DIR"] = os.path.abspath("data/temp")
env["OCR_LOG_LEVEL"] = "DEBUG"
try:
sys.stdout = log_redirector
sys.stderr = log_redirector
print("日志重定向已启动,现在同时输出到终端和GUI")
process = subprocess.Popen(
command,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
universal_newlines=True,
env=env
)
output_data = []
for line in process.stdout:
output_data.append(line)
print(line.rstrip())
if status_bar:
progress = extract_progress_from_log(line)
if progress is not None:
log_widget.after(0, lambda p=progress: status_bar.set_status(f"处理中: {p}%完成", p))
process.wait()
end_time = datetime.datetime.now()
duration_sec = max(0.0, time.perf_counter() - start_perf)
print(f"\n{'=' * 50}")
print(f"执行完毕!返回码: {process.returncode}")
print(f"结束时间: {end_time.strftime('%Y-%m-%d %H:%M:%S')}")
print(f"耗时: {duration_sec:.2f}")
output_text = ''.join(output_data)
is_pipeline = "pipeline" in command
no_merge_files = "未找到采购单文件" in output_text
single_file = "只有1个采购单文件" in output_text
if is_pipeline and (no_merge_files or single_file):
print("完整流程中没有需要合并的文件,但其他步骤执行成功,视为成功完成")
if status_bar:
log_widget.after(0, lambda: status_bar.set_status("处理完成", 100))
log_widget.after(0, lambda: show_result_preview(command, output_text))
else:
if on_complete:
log_widget.after(0, lambda: on_complete(process.returncode, output_text))
elif process.returncode == 0:
if status_bar:
log_widget.after(0, lambda: status_bar.set_status("处理完成", 100))
log_widget.after(0, lambda: show_result_preview(command, output_text))
else:
if status_bar:
log_widget.after(0, lambda: status_bar.set_status(f"处理失败 (返回码: {process.returncode})", 0))
log_widget.after(0, lambda: messagebox.showerror("操作失败", f"处理失败,返回码:{process.returncode}"))
except Exception as e:
print(f"\n执行出错: {str(e)}")
if status_bar:
log_widget.after(0, lambda: status_bar.set_status(f"执行出错: {str(e)}", 0))
log_widget.after(0, lambda: messagebox.showerror("执行错误", f"执行命令时出错: {str(e)}"))
finally:
sys.stdout = old_stdout
sys.stderr = old_stderr
_RUNNING_TASK = None
if status_bar:
log_widget.after(0, lambda: status_bar.set_running(False))
Thread(target=run_in_thread).start()
def extract_progress_from_log(log_line):
"""从日志行中提取进度信息"""
batch_match = re.search(r'处理批次 (\d+)/(\d+)', log_line)
if batch_match:
current = int(batch_match.group(1))
total = int(batch_match.group(2))
return int(current / total * 100)
percent_match = re.search(r'(\d+)%', log_line)
if percent_match:
return int(percent_match.group(1))
return None
+207
View File
@@ -0,0 +1,207 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""系统设置对话框模块"""
import os
import tkinter as tk
from tkinter import messagebox, filedialog, ttk
from app.config.settings import ConfigManager
from .user_settings import load_user_settings, save_user_settings
from .ui_widgets import center_window
from app.core.utils.dialog_utils import show_cloud_sync_dialog
# 模块级状态
_PROCESSOR_SERVICE = None
def show_config_dialog(root, cfg: ConfigManager):
global _PROCESSOR_SERVICE
settings = load_user_settings()
dlg = tk.Toplevel(root)
dlg.title("系统设置")
dlg.geometry("560x680")
center_window(dlg)
content = ttk.Frame(dlg)
content.pack(fill=tk.BOTH, expand=True, padx=16, pady=16)
for i in range(2):
content.columnconfigure(i, weight=1)
# 当前值
log_level_val = tk.StringVar(value=settings.get('log_level', 'INFO'))
max_workers_val = tk.StringVar(value=str(settings.get('concurrency_max_workers', cfg.getint('Performance', 'max_workers', 4))))
batch_size_val = tk.StringVar(value=str(settings.get('concurrency_batch_size', cfg.getint('Performance', 'batch_size', 5))))
template_path_val = tk.StringVar(value=settings.get('template_path', os.path.join(cfg.get('Paths', 'template_folder', 'templates'), cfg.get('Templates', 'purchase_order', '银豹-采购单模板.xls'))))
input_dir_val = tk.StringVar(value=settings.get('input_folder', cfg.get('Paths', 'input_folder', 'data/input')))
output_dir_val = tk.StringVar(value=settings.get('output_folder', cfg.get('Paths', 'output_folder', 'data/output')))
result_dir_val = tk.StringVar(value=settings.get('result_folder', 'data/result'))
def add_row(row, label_text, widget):
ttk.Label(content, text=label_text).grid(row=row, column=0, sticky='w', padx=4, pady=6)
widget.grid(row=row, column=1, sticky='ew', padx=4, pady=6)
# 日志级别
lvl = ttk.Combobox(content, textvariable=log_level_val, values=['DEBUG', 'INFO', 'WARNING', 'ERROR'], state='readonly')
add_row(0, "日志级别", lvl)
# 并发参数
maxw_entry = ttk.Entry(content, textvariable=max_workers_val)
add_row(1, "最大并发(max_workers)", maxw_entry)
batch_entry = ttk.Entry(content, textvariable=batch_size_val)
add_row(2, "批次大小(batch_size)", batch_entry)
# 模板路径
tpl_frame = ttk.Frame(content)
tpl_entry = ttk.Entry(tpl_frame, textvariable=template_path_val)
tpl_entry.pack(side=tk.LEFT, fill=tk.X, expand=True)
def _select_template():
p = filedialog.askopenfilename(title="选择模板文件", filetypes=[("Excel模板", "*.xls *.xlsx"), ("所有文件", "*.*")])
if p:
try:
template_path_val.set(os.path.relpath(p, os.getcwd()))
except Exception:
template_path_val.set(p)
ttk.Button(tpl_frame, text="选择", command=_select_template).pack(side=tk.LEFT, padx=6)
add_row(3, "采购单模板文件", tpl_frame)
# 目录
def dir_row(row_idx, label, var):
f = ttk.Frame(content)
e = ttk.Entry(f, textvariable=var)
e.pack(side=tk.LEFT, fill=tk.X, expand=True)
def _select_dir():
d = filedialog.askdirectory(title=f"选择{label}")
if d:
try:
var.set(os.path.relpath(d, os.getcwd()))
except Exception:
var.set(d)
ttk.Button(f, text="选择", command=_select_dir).pack(side=tk.LEFT, padx=6)
add_row(row_idx, label, f)
dir_row(4, "输入目录", input_dir_val)
dir_row(5, "输出目录", output_dir_val)
dir_row(6, "结果目录", result_dir_val)
api_key_val = tk.StringVar(value=settings.get('api_key', cfg.get('API', 'api_key', '')))
secret_key_val = tk.StringVar(value=settings.get('secret_key', cfg.get('API', 'secret_key', '')))
timeout_val = tk.StringVar(value=str(settings.get('timeout', cfg.getint('API', 'timeout', 30))))
max_retries_val = tk.StringVar(value=str(settings.get('max_retries', cfg.getint('API', 'max_retries', 3))))
retry_delay_val = tk.StringVar(value=str(settings.get('retry_delay', cfg.getint('API', 'retry_delay', 2))))
api_url_val = tk.StringVar(value=settings.get('api_url', cfg.get('API', 'api_url', '')))
api_key_entry = ttk.Entry(content, textvariable=api_key_val)
add_row(7, "API Key", api_key_entry)
secret_key_entry = ttk.Entry(content, textvariable=secret_key_val)
secret_key_entry.configure(show='*')
add_row(8, "Secret Key", secret_key_entry)
add_row(9, "Timeout", ttk.Entry(content, textvariable=timeout_val))
add_row(10, "Max Retries", ttk.Entry(content, textvariable=max_retries_val))
add_row(11, "Retry Delay", ttk.Entry(content, textvariable=retry_delay_val))
add_row(12, "API URL", ttk.Entry(content, textvariable=api_url_val))
# ---- Gitea 云端同步配置 ----
ttk.Separator(content).grid(row=13, column=0, columnspan=2, sticky='ew', pady=8)
ttk.Label(content, text="云端同步 (Gitea)", font=("Arial", 10, "bold")).grid(row=14, column=0, sticky='w', padx=4, pady=4)
gitea_url_val = tk.StringVar(value=cfg.get('Gitea', 'base_url', fallback='https://gitea.94kan.cn'))
gitea_owner_val = tk.StringVar(value=cfg.get('Gitea', 'owner', fallback='houhuan'))
gitea_repo_val = tk.StringVar(value=cfg.get('Gitea', 'repo', fallback='yixuan-sync-data'))
gitea_token_val = tk.StringVar(value=cfg.get('Gitea', 'token', fallback=''))
add_row(15, "Gitea 地址", ttk.Entry(content, textvariable=gitea_url_val))
add_row(16, "仓库所有者", ttk.Entry(content, textvariable=gitea_owner_val))
add_row(17, "仓库名称", ttk.Entry(content, textvariable=gitea_repo_val))
gitea_token_entry = ttk.Entry(content, textvariable=gitea_token_val, show='*')
add_row(18, "Access Token", gitea_token_entry)
# 操作按钮
btns = ttk.Frame(content)
btns.grid(row=19, column=0, columnspan=2, sticky='ew', pady=10)
btns.columnconfigure(0, weight=1)
def save_settings():
try:
s = load_user_settings()
s['log_level'] = log_level_val.get()
s['concurrency_max_workers'] = int(max_workers_val.get() or '4')
s['concurrency_batch_size'] = int(batch_size_val.get() or '5')
tp = template_path_val.get()
inp = input_dir_val.get()
outp = output_dir_val.get()
resp = result_dir_val.get()
try:
if tp:
tp = os.path.relpath(tp, os.getcwd()) if os.path.isabs(tp) else tp
if inp:
inp = os.path.relpath(inp, os.getcwd()) if os.path.isabs(inp) else inp
if outp:
outp = os.path.relpath(outp, os.getcwd()) if os.path.isabs(outp) else outp
if resp:
resp = os.path.relpath(resp, os.getcwd()) if os.path.isabs(resp) else resp
except Exception:
pass
s['template_path'] = tp
s['input_folder'] = inp
s['output_folder'] = outp
s['result_folder'] = resp
save_user_settings(s)
try:
from app.core.utils.log_utils import set_log_level
set_log_level(s['log_level'])
except Exception:
pass
try:
tpl_path = s['template_path']
tpl_dir = os.path.dirname(tpl_path)
tpl_name = os.path.basename(tpl_path)
cfg.update('Paths', 'template_folder', tpl_dir)
cfg.update('Templates', 'purchase_order', tpl_name)
try:
cfg.update('Paths', 'template_file', os.path.join(tpl_dir, tpl_name))
except Exception:
pass
cfg.update('Paths', 'input_folder', s['input_folder'])
cfg.update('Paths', 'output_folder', s['output_folder'])
cfg.update('Performance', 'max_workers', s['concurrency_max_workers'])
cfg.update('Performance', 'batch_size', s['concurrency_batch_size'])
cfg.update('API', 'api_key', api_key_val.get())
cfg.update('API', 'secret_key', secret_key_val.get())
cfg.update('API', 'timeout', timeout_val.get())
cfg.update('API', 'max_retries', max_retries_val.get())
cfg.update('API', 'retry_delay', retry_delay_val.get())
cfg.update('API', 'api_url', api_url_val.get())
cfg.update('Gitea', 'base_url', gitea_url_val.get())
cfg.update('Gitea', 'owner', gitea_owner_val.get())
cfg.update('Gitea', 'repo', gitea_repo_val.get())
cfg.update('Gitea', 'token', gitea_token_val.get())
cfg.save_config()
except Exception:
pass
messagebox.showinfo("设置已保存", "系统设置已更新并保存")
dlg.destroy()
except Exception as e:
messagebox.showerror("保存失败", str(e))
def reload_suppliers():
global _PROCESSOR_SERVICE
try:
from app.services.processor_service import ProcessorService
if _PROCESSOR_SERVICE is None:
_PROCESSOR_SERVICE = ProcessorService(ConfigManager())
_PROCESSOR_SERVICE.reload_processors()
messagebox.showinfo("已重新加载", "供应商处理器已重新加载并应用最新配置")
except Exception as e:
messagebox.showerror("重新加载失败", str(e))
ttk.Button(btns, text="重新加载供应商配置", command=reload_suppliers).grid(row=0, column=0, sticky='w')
ttk.Button(btns, text="云端同步", command=lambda: show_cloud_sync_dialog(dlg)).grid(row=0, column=1, sticky='w', padx=6)
ttk.Button(btns, text="取消", command=dlg.destroy).grid(row=0, column=2, sticky='e')
ttk.Button(btns, text="保存", command=save_settings).grid(row=0, column=3, sticky='e', padx=6)
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""错误处理工具模块"""
from tkinter import messagebox
from typing import Optional
from app.core.utils.log_utils import get_logger
logger = get_logger(__name__)
def show_error_dialog(title: str, message: str, suggestion: Optional[str] = None):
try:
full_msg = message
if suggestion:
full_msg = f"{message}\n\n建议操作:\n- {suggestion}"
messagebox.showerror(title, full_msg)
except Exception as e:
logger.debug(f"显示错误对话框失败: {e}")
def get_error_suggestion(message: str) -> Optional[str]:
msg = (message or "").lower()
if 'openpyxl' in msg or ('engine' in msg and 'xlsx' in msg):
return '安装依赖:pip install openpyxl'
if 'xlrd' in msg or ('engine' in msg and 'xls' in msg):
return '安装依赖:pip install xlrd'
if 'timeout' in msg or 'timed out' in msg:
return '检查网络,增大API超时时间或稍后重试'
if 'invalid access_token' in msg or 'access token' in msg:
return '刷新百度OCR令牌或检查api_key/secret_key'
if '429' in msg or 'too many requests' in msg:
return '降低识别频率或稍后重试'
if '模板文件不存在' in msg or ('no such file' in msg and '模板' in msg):
return '在系统设置中选择正确的模板文件路径'
if '没有找到采购单' in msg or '未在 data/result 目录下找到采购单' in msg:
return '确认data/result目录内存在采购单文件'
if 'permission denied' in msg:
return '以管理员权限运行或更改目录写入权限'
return None
+207
View File
@@ -0,0 +1,207 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""文件与目录操作模块"""
import os
import json
import tkinter as tk
from tkinter import messagebox, filedialog, scrolledtext
from .logging_ui import add_to_log
from .ui_widgets import center_window
from app.config.settings import ConfigManager
def select_file(log_widget, file_types=None, title="选择文件"):
"""通用文件选择对话框"""
if file_types is None:
file_types = [("所有文件", "*.*")]
file_path = filedialog.askopenfilename(title=title, filetypes=file_types)
if file_path:
add_to_log(log_widget, f"已选择文件: {file_path}\n", "info")
return file_path
def select_excel_file(log_widget):
"""选择Excel文件"""
return select_file(
log_widget,
[("Excel文件", "*.xlsx *.xls"), ("所有文件", "*.*")],
"选择Excel文件"
)
def ensure_directories():
"""确保必要的目录结构存在"""
config = ConfigManager()
directories = [
config.get('Paths', 'input_folder', fallback='data/input'),
config.get('Paths', 'output_folder', fallback='data/output'),
'data/result',
config.get('Paths', 'temp_folder', fallback='data/temp'),
'logs'
]
for directory in directories:
if not os.path.exists(directory):
os.makedirs(directory, exist_ok=True)
print(f"创建目录: {directory}")
def clean_cache(log_widget):
"""清除处理缓存"""
from .command_runner import set_running_task
try:
config = ConfigManager()
processed_record = config.get('Paths', 'processed_record', fallback='data/processed_files.json')
output_folder = config.get('Paths', 'output_folder', fallback='data/output')
cache_files = [
processed_record,
os.path.join(output_folder, "processed_files.json"),
os.path.join(output_folder, "merged_files.json")
]
for cache_file in cache_files:
if os.path.exists(cache_file):
os.remove(cache_file)
add_to_log(log_widget, f"已清除缓存文件: {cache_file}\n", "success")
temp_dir = os.path.join("data/temp")
if os.path.exists(temp_dir):
for file in os.listdir(temp_dir):
file_path = os.path.join(temp_dir, file)
try:
if os.path.isfile(file_path):
os.remove(file_path)
add_to_log(log_widget, f"已清除临时文件: {file_path}\n", "info")
except Exception as e:
add_to_log(log_widget, f"清除文件时出错: {file_path}, 错误: {str(e)}\n", "error")
log_dir = "logs"
if os.path.exists(log_dir):
for file in os.listdir(log_dir):
if file.endswith(".active"):
file_path = os.path.join(log_dir, file)
try:
os.remove(file_path)
add_to_log(log_widget, f"已清除活动日志标记: {file_path}\n", "info")
except Exception as e:
add_to_log(log_widget, f"清除文件时出错: {file_path}, 错误: {str(e)}\n", "error")
set_running_task(None)
add_to_log(log_widget, "缓存清除完成,系统将重新处理所有文件\n", "success")
messagebox.showinfo("缓存清除", "缓存已清除,系统将重新处理所有文件。")
except Exception as e:
add_to_log(log_widget, f"清除缓存时出错: {str(e)}\n", "error")
messagebox.showerror("错误", f"清除缓存时出错: {str(e)}")
def open_result_directory():
try:
result_dir = os.path.abspath("data/result")
if not os.path.exists(result_dir):
os.makedirs(result_dir, exist_ok=True)
os.startfile(result_dir)
except Exception as e:
messagebox.showerror("错误", f"无法打开结果目录: {str(e)}")
def _open_directory_from_settings(settings_key, default_path, label):
"""通用的从用户设置读取路径并打开目录"""
from .user_settings import load_user_settings
try:
s = load_user_settings()
path = os.path.abspath(s.get(settings_key, default_path))
if not os.path.exists(path):
os.makedirs(path, exist_ok=True)
os.startfile(path)
except Exception as e:
messagebox.showerror("错误", f"无法打开{label}: {str(e)}")
def open_input_directory_from_settings():
_open_directory_from_settings('input_folder', 'data/input', '输入目录')
def open_output_directory_from_settings():
_open_directory_from_settings('output_folder', 'data/output', '输出目录')
def open_result_directory_from_settings():
_open_directory_from_settings('result_folder', 'data/result', '结果目录')
def clean_data_files(log_widget):
"""清理数据文件(仅清理input和output目录)"""
try:
if not messagebox.askyesno("确认清理", "确定要清理input和output目录的文件吗?这将删除所有输入和输出数据。"):
add_to_log(log_widget, "操作已取消\n", "info")
return
files_cleaned = 0
input_dir = "data/input"
if os.path.exists(input_dir):
for file in os.listdir(input_dir):
file_path = os.path.join(input_dir, file)
if os.path.isfile(file_path):
os.remove(file_path)
files_cleaned += 1
add_to_log(log_widget, "已清理input目录\n", "info")
output_dir = "data/output"
if os.path.exists(output_dir):
for file in os.listdir(output_dir):
file_path = os.path.join(output_dir, file)
if os.path.isfile(file_path):
os.remove(file_path)
files_cleaned += 1
add_to_log(log_widget, "已清理output目录\n", "info")
add_to_log(log_widget, f"清理完成,共清理 {files_cleaned} 个文件\n", "success")
messagebox.showinfo("清理完成", f"已成功清理 {files_cleaned} 个文件")
except Exception as e:
add_to_log(log_widget, f"清理数据文件时出错: {str(e)}\n", "error")
messagebox.showerror("错误", f"清理数据文件时出错: {str(e)}")
def clean_result_files(log_widget):
try:
if not messagebox.askyesno("确认清理", "确定要清理result目录的文件吗?这将删除所有已生成的采购单文件。"):
add_to_log(log_widget, "操作已取消\n", "info")
return
count = 0
result_dir = "data/result"
if os.path.exists(result_dir):
for file in os.listdir(result_dir):
file_path = os.path.join(result_dir, file)
if os.path.isfile(file_path):
os.remove(file_path)
count += 1
add_to_log(log_widget, f"已清理result目录,共 {count} 个文件\n", "success")
messagebox.showinfo("清理完成", f"已清理result目录 {count} 个文件")
except Exception as e:
add_to_log(log_widget, f"清理result目录时出错: {str(e)}\n", "error")
messagebox.showerror("错误", f"清理result目录时出错: {str(e)}")
def validate_unit_price_against_item_data(result_path: str, log_widget=None):
try:
from app.services.order_service import OrderService
service = OrderService()
bad_results = service.validate_unit_price(result_path)
if bad_results:
display_count = min(len(bad_results), 10)
msg = f"存在{len(bad_results)}条单价与商品资料进货价差异超过1元:\n" + "\n".join(bad_results[:display_count])
if len(bad_results) > 10:
msg += f"\n...(其余 {len(bad_results) - 10} 条已省略)"
messagebox.showwarning("单价校验提示", msg)
if log_widget is not None:
add_to_log(log_widget, f"单价校验发现{len(bad_results)}条差异>1元\n", "warning")
else:
if log_widget is not None:
add_to_log(log_widget, "单价校验通过(差异<=1元)\n", "success")
except Exception as e:
if log_widget is not None:
add_to_log(log_widget, f"单价校验出错: {str(e)}\n", "error")
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""GUI日志处理模块"""
import logging
import queue
import sys
import tkinter as tk
# 全局日志队列,用于异步更新UI
LOG_QUEUE = queue.Queue()
class LogRedirector:
"""日志重定向器,用于捕获命令输出并显示到界面"""
def __init__(self, text_widget):
self.text_widget = text_widget
self.buffer = ""
self.terminal = sys.__stdout__
def write(self, string):
self.buffer += string
self.terminal.write(string)
self.text_widget.after(0, self.update_text_widget)
def update_text_widget(self):
self.text_widget.configure(state=tk.NORMAL)
if self.buffer.strip():
if any(marker in self.buffer.lower() for marker in ["错误", "error", "失败", "异常", "exception"]):
self.text_widget.insert(tk.END, self.buffer, "error")
elif any(marker in self.buffer.lower() for marker in ["警告", "warning"]):
self.text_widget.insert(tk.END, self.buffer, "warning")
elif any(marker in self.buffer.lower() for marker in ["成功", "success", "完成", "成功处理"]):
self.text_widget.insert(tk.END, self.buffer, "success")
elif any(marker in self.buffer.lower() for marker in ["info", "信息", "开始", "处理中"]):
self.text_widget.insert(tk.END, self.buffer, "info")
else:
self.text_widget.insert(tk.END, self.buffer, "normal")
else:
self.text_widget.insert(tk.END, self.buffer)
self.text_widget.see(tk.END)
self.text_widget.configure(state=tk.DISABLED)
self.buffer = ""
def flush(self):
self.terminal.flush()
class GUILogHandler(logging.Handler):
"""自定义日志处理器,将日志放入队列,由GUI主线程定时消费"""
def __init__(self, text_widget):
super().__init__()
self.text_widget = text_widget
def emit(self, record):
try:
msg = self.format(record)
if record.levelno >= logging.ERROR:
tag = "error"
elif record.levelno >= logging.WARNING:
tag = "warning"
elif record.levelno >= logging.INFO:
tag = "info"
else:
tag = "normal"
LOG_QUEUE.put((msg + "\n", tag))
except Exception:
self.handleError(record)
def poll_log_queue(text_widget):
"""定期从队列中读取日志并更新UI"""
try:
updated = False
while not LOG_QUEUE.empty():
msg, tag = LOG_QUEUE.get_nowait()
text_widget.configure(state=tk.NORMAL)
text_widget.insert(tk.END, msg, tag)
updated = True
if updated:
text_widget.see(tk.END)
text_widget.configure(state=tk.DISABLED)
except Exception:
pass
finally:
text_widget.after(100, lambda: poll_log_queue(text_widget))
def init_gui_logger(text_widget, level=logging.INFO):
handler = GUILogHandler(text_widget)
handler.setLevel(level)
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
handler.setFormatter(formatter)
root_logger = logging.getLogger()
for h in root_logger.handlers[:]:
if isinstance(h, logging.StreamHandler):
root_logger.removeHandler(h)
if not any(isinstance(h, GUILogHandler) for h in root_logger.handlers):
root_logger.addHandler(handler)
root_logger.setLevel(level)
return handler
def dispose_gui_logger():
root_logger = logging.getLogger()
for handler in root_logger.handlers[:]:
if isinstance(handler, GUILogHandler):
root_logger.removeHandler(handler)
try:
handler.close()
except Exception:
pass
def add_to_log(log_widget, text, tag="normal"):
"""向日志队列添加文本,由 poll_log_queue 消费并更新 UI"""
if log_widget is None:
print(f"[{tag}] {text}", end="")
return
LOG_QUEUE.put((text, tag))
+485
View File
@@ -0,0 +1,485 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""主窗口模块"""
import os
import sys
import subprocess
import tkinter as tk
from tkinter import messagebox, filedialog, scrolledtext
from app.config.settings import ConfigManager
from app.core.utils.log_utils import set_log_level
from .theme import THEMES, get_theme_mode, set_theme_mode, create_modern_button, create_card_frame
from .logging_ui import add_to_log, poll_log_queue
from .ui_widgets import StatusBar
from .user_settings import (
load_user_settings, save_user_settings, refresh_recent_list_widget,
_extract_path_from_recent_item, clear_recent_files, RECENT_LIST_WIDGET,
)
from .file_operations import (
ensure_directories, open_result_directory, clean_cache,
clean_data_files, clean_result_files,
)
from .action_handlers import (
process_single_image_with_status, run_pipeline_directly,
batch_ocr_with_status, batch_process_orders_with_status,
merge_orders_with_status, process_excel_file_with_status,
process_dropped_file,
)
from .config_dialog import show_config_dialog
from .barcode_editor import edit_barcode_mappings
from .shortcuts import bind_keyboard_shortcuts
from app.core.utils.dialog_utils import show_cloud_sync_dialog
def _init_window():
"""初始化窗口、主题和设置,返回 (root, theme, settings, dnd_supported)"""
ensure_directories()
dnd_supported = False
try:
from tkinterdnd2 import TkinterDnD, DND_FILES
root = TkinterDnD.Tk()
dnd_supported = True
except Exception:
root = tk.Tk()
settings = load_user_settings()
theme_mode = settings.get('theme_mode', get_theme_mode())
set_theme_mode(theme_mode)
try:
cfg_for_title = ConfigManager()
ver = cfg_for_title.get('App', 'version', fallback='dev')
root.title(f"益选-OCR订单处理系统 v{ver} by 欢欢欢")
except Exception:
root.title("益选-OCR订单处理系统 by 欢欢欢")
root.geometry("900x600")
settings['window_size'] = "900x600"
theme = THEMES[get_theme_mode()]
root.configure(bg=theme["bg"])
try:
log_level = settings.get('log_level')
if log_level:
set_log_level(log_level)
concurrency = settings.get('concurrency_max_workers')
if concurrency:
cfg = ConfigManager()
cfg.update('Performance', 'max_workers', str(concurrency))
cfg.save_config()
except Exception:
pass
try:
root.iconbitmap(default="")
except Exception:
pass
return root, theme, settings, dnd_supported
def _create_left_panel(content_frame, theme, log_text, status_bar):
"""创建左侧面板:完整流程、OCR处理、Excel处理、最近文件"""
left_panel = create_card_frame(content_frame)
left_panel.pack(side=tk.LEFT, fill=tk.BOTH, expand=False, padx=(0, 5), pady=5)
left_panel.configure(width=160)
panel_content = tk.Frame(left_panel, bg=theme["card_bg"])
panel_content.pack(fill=tk.BOTH, expand=True, padx=10, pady=(5, 10))
# 完整流程区
pipeline_section = tk.LabelFrame(
panel_content, text="完整流程", bg=theme["card_bg"], fg=theme["fg"],
font=("Microsoft YaHei UI", 10, "bold"), relief="flat", borderwidth=0
)
pipeline_section.pack(fill=tk.X, pady=(0, 8))
pipeline_frame = tk.Frame(pipeline_section, bg=theme["card_bg"])
pipeline_frame.pack(fill=tk.X, padx=8, pady=6)
create_modern_button(pipeline_frame, "一键处理", lambda: run_pipeline_directly(log_text, status_bar), "primary", px_width=150, px_height=32).pack(anchor='w', pady=3)
# OCR处理区
core_section = tk.LabelFrame(
panel_content, text="OCR处理", bg=theme["card_bg"], fg=theme["fg"],
font=("Microsoft YaHei UI", 10, "bold"), relief="flat", borderwidth=0
)
core_section.pack(fill=tk.X, pady=(0, 8))
core_buttons_frame = tk.Frame(core_section, bg=theme["card_bg"])
core_buttons_frame.pack(fill=tk.X, padx=8, pady=6)
core_row1 = tk.Frame(core_buttons_frame, bg=theme["card_bg"])
core_row1.pack(fill=tk.X, pady=3)
create_modern_button(core_row1, "批量识别", lambda: batch_ocr_with_status(log_text, status_bar), "primary", px_width=72, px_height=32).pack(side=tk.LEFT, padx=(0, 3))
create_modern_button(core_row1, "单个识别", lambda: process_single_image_with_status(log_text, status_bar), "primary", px_width=72, px_height=32).pack(side=tk.LEFT, padx=(3, 0))
# Excel处理区
ocr_section = tk.LabelFrame(
panel_content, text="Excel处理", bg=theme["card_bg"], fg=theme["fg"],
font=("Microsoft YaHei UI", 10, "bold"), relief="flat", borderwidth=0
)
ocr_section.pack(fill=tk.X, pady=(0, 8))
ocr_buttons_frame = tk.Frame(ocr_section, bg=theme["card_bg"])
ocr_buttons_frame.pack(fill=tk.X, padx=8, pady=6)
ocr_row1 = tk.Frame(ocr_buttons_frame, bg=theme["card_bg"])
ocr_row1.pack(fill=tk.X, pady=3)
create_modern_button(ocr_row1, "批量处理", lambda: batch_process_orders_with_status(log_text, status_bar), "primary", px_width=72, px_height=32).pack(side=tk.LEFT, padx=(0, 3))
create_modern_button(ocr_row1, "单个处理", lambda: process_excel_file_with_status(log_text, status_bar), "primary", px_width=72, px_height=32).pack(side=tk.LEFT, padx=(3, 0))
# 最近文件区
_create_recent_files_section(panel_content, theme, log_text)
def _create_recent_files_section(parent, theme, log_text):
"""创建最近文件列表区域"""
recent_section = tk.LabelFrame(
parent, text="最近文件", bg=theme["card_bg"], fg=theme["fg"],
font=("Microsoft YaHei UI", 10, "bold"), relief="flat", borderwidth=0
)
recent_section.pack(fill=tk.BOTH, pady=(0, 12))
recent_frame = tk.Frame(recent_section, bg=theme["card_bg"])
recent_frame.pack(fill=tk.BOTH, padx=8, pady=6)
recent_top = tk.Frame(recent_frame, bg=theme["card_bg"])
recent_top.pack(fill=tk.X)
def _resize_recent_top(e):
try:
h = int(e.height * 0.75)
recent_top.configure(height=h)
except Exception:
pass
try:
recent_top.pack_propagate(False)
except Exception:
pass
recent_frame.bind('<Configure>', _resize_recent_top)
recent_rect = tk.Frame(recent_top, bg=theme["card_bg"], highlightbackground=theme["border"], highlightthickness=1)
recent_rect.pack(fill=tk.BOTH, expand=True)
recent_list = tk.Listbox(recent_rect, height=12)
recent_scrollbar = tk.Scrollbar(recent_rect)
recent_list.configure(yscrollcommand=recent_scrollbar.set)
recent_scrollbar.configure(command=recent_list.yview)
recent_list.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
recent_scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
import app.ui.user_settings as _us_mod
_us_mod.RECENT_LIST_WIDGET = recent_list
def _open_selected_event(evt=None):
try:
idxs = recent_list.curselection()
if not idxs:
return
p = _extract_path_from_recent_item(recent_list.get(idxs[0]))
if os.path.exists(p):
os.startfile(p)
else:
messagebox.showwarning("文件不存在", p)
except Exception as e:
messagebox.showerror("打开失败", str(e))
recent_list.bind('<Double-Button-1>', _open_selected_event)
refresh_recent_list_widget()
rf_btns = tk.Frame(recent_frame, bg=theme["card_bg"])
rf_btns.pack(fill=tk.X, pady=6)
def clear_list():
clear_recent_files()
recent_list.delete(0, tk.END)
create_modern_button(rf_btns, "清空列表", clear_list, "primary", px_width=72, px_height=32).pack(side=tk.LEFT, padx=(3, 0))
def purge_invalid():
try:
kept = []
for i in range(recent_list.size()):
item = recent_list.get(i)
p = _extract_path_from_recent_item(item)
if os.path.exists(p):
kept.append(p)
try:
kept_sorted = sorted(kept, key=lambda p: os.path.getmtime(p), reverse=True)
except Exception:
kept_sorted = kept
s = load_user_settings()
s['recent_files'] = kept_sorted
save_user_settings(s)
recent_list.delete(0, tk.END)
for i, p in enumerate(s['recent_files'][:recent_list.size() or len(s['recent_files'])], start=1):
recent_list.insert(tk.END, f"{i}. {p}")
refresh_recent_list_widget()
add_to_log(log_text, "已清理无效的最近文件条目\n", "success")
except Exception as e:
messagebox.showerror("清理失败", str(e))
create_modern_button(rf_btns, "清理无效", purge_invalid, "primary", px_width=72, px_height=32).pack(side=tk.LEFT, padx=(3, 0))
def _create_right_panel(content_frame, theme, log_text, root):
"""创建右侧面板:快捷操作、系统设置"""
right_panel = create_card_frame(content_frame)
right_panel.pack(side=tk.RIGHT, fill=tk.BOTH, expand=False, padx=(5, 0), pady=5)
right_panel.configure(width=380)
right_panel_content = tk.Frame(right_panel, bg=theme["card_bg"])
right_panel_content.pack(fill=tk.BOTH, expand=True, padx=10, pady=(5, 10))
# 工具功能区
tools_section = tk.LabelFrame(
right_panel_content, text="快捷操作", bg=theme["card_bg"], fg=theme["fg"],
font=("Microsoft YaHei UI", 10, "bold"), relief="flat", borderwidth=0
)
tools_section.pack(fill=tk.X, pady=(0, 8))
tools_buttons_frame = tk.Frame(tools_section, bg=theme["card_bg"])
tools_buttons_frame.pack(fill=tk.X, padx=8, pady=6)
tk.Frame(tools_buttons_frame, bg=theme["card_bg"]).pack(fill=tk.X, pady=3)
create_modern_button(tools_buttons_frame, "打开结果目录", lambda: open_result_directory(), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3)
create_modern_button(tools_buttons_frame, "打开输出目录", lambda: os.startfile(os.path.abspath("data/output")), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3)
create_modern_button(tools_buttons_frame, "打开输入目录", lambda: os.startfile(os.path.abspath("data/input")), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3)
create_modern_button(tools_buttons_frame, "合并订单", lambda: merge_orders_with_status(log_text, StatusBar(root)), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3)
create_modern_button(tools_buttons_frame, "清除缓存", lambda: clean_cache(log_text), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3)
create_modern_button(tools_buttons_frame, "清理input/out文件", lambda: clean_data_files(log_text), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3)
create_modern_button(tools_buttons_frame, "清理result文件", lambda: clean_result_files(log_text), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3)
# 系统设置区
settings_section = tk.LabelFrame(
right_panel_content, text="系统设置", bg=theme["card_bg"], fg=theme["fg"],
font=("Microsoft YaHei UI", 10, "bold"), relief="flat", borderwidth=0
)
settings_section.pack(fill=tk.X, pady=(0, 8))
settings_buttons_frame = tk.Frame(settings_section, bg=theme["card_bg"])
settings_buttons_frame.pack(fill=tk.X, padx=8, pady=6)
create_modern_button(settings_buttons_frame, "系统设置", lambda: show_config_dialog(root, ConfigManager()), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3)
create_modern_button(settings_buttons_frame, "条码映射", lambda: edit_barcode_mappings(log_text), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3)
create_modern_button(settings_buttons_frame, "云端同步", lambda: show_cloud_sync_dialog(root), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3)
def _setup_drag_area(mid_container, theme, dnd_supported, log_text, status_bar):
"""创建拖拽/点击选择文件区域"""
drag_panel = create_card_frame(mid_container)
drag_panel.pack(side=tk.TOP, fill=tk.X, padx=(5, 5), pady=(0, 5))
drag_panel_content = tk.Frame(drag_panel, bg=theme["card_bg"])
drag_panel_content.pack(fill=tk.X, padx=10, pady=6)
dnd_section = tk.LabelFrame(
drag_panel_content, bg=theme["card_bg"], fg=theme["fg"],
font=("Microsoft YaHei UI", 10, "bold"), relief="flat", borderwidth=0
)
dnd_section.pack(fill=tk.X, pady=(0, 0))
dnd_frame = tk.Frame(dnd_section, bg=theme["card_bg"], highlightthickness=1, highlightbackground=theme["border"])
dnd_frame.configure(height=60)
dnd_frame.pack(fill=tk.X, padx=8, pady=6)
try:
dnd_frame.pack_propagate(False)
except Exception:
pass
def _set_highlight(active: bool):
try:
dnd_frame.configure(highlightbackground=theme["info"] if active else theme["border"])
except Exception:
pass
dnd_frame.bind('<Enter>', lambda e: _set_highlight(True))
dnd_frame.bind('<Leave>', lambda e: _set_highlight(False))
msg_row = tk.Frame(dnd_frame, bg=theme["card_bg"])
msg_row.pack(fill=tk.X)
if dnd_supported:
tk.Label(
msg_row, text="拖拽已启用:拖拽或点击此区域选择文件",
bg=theme["card_bg"], fg="#999999", justify="center"
).pack(fill=tk.X)
else:
tk.Label(
msg_row, text="点击此区域选择文件;可安装拖拽支持",
bg=theme["card_bg"], fg="#999999", justify="center"
).pack(fill=tk.X)
if not dnd_supported:
btn_row = tk.Frame(dnd_frame, bg=theme["card_bg"])
btn_row.pack(fill=tk.X)
def copy_install():
try:
mid_container.winfo_toplevel().clipboard_clear()
mid_container.winfo_toplevel().clipboard_append("pip install tkinterdnd2")
messagebox.showinfo("已复制", "已复制安装命令:pip install tkinterdnd2")
except Exception as e:
messagebox.showwarning("复制失败", str(e))
create_modern_button(btn_row, "复制安装命令", copy_install, "primary", px_width=132, px_height=28).pack(side=tk.RIGHT)
def install_and_restart():
try:
add_to_log(log_text, "开始安装拖拽支持库 tkinterdnd2...\n", "info")
cmd = [sys.executable, "-m", "pip", "install", "tkinterdnd2"]
result = subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
add_to_log(log_text, result.stdout + "\n", "info")
add_to_log(log_text, "安装成功,准备重启程序以启用拖拽...\n", "success")
if messagebox.askyesno("安装完成", "已安装拖拽支持,是否立即重启应用?"):
os.execl(sys.executable, sys.executable, *sys.argv)
except subprocess.CalledProcessError as e:
add_to_log(log_text, f"安装失败: {e.stderr}\n", "error")
messagebox.showerror("安装失败", f"安装输出:\n{e.stderr}")
except Exception as e:
add_to_log(log_text, f"安装失败: {str(e)}\n", "error")
messagebox.showerror("安装失败", str(e))
create_modern_button(btn_row, "一键安装拖拽", install_and_restart, "primary", px_width=132, px_height=28).pack(side=tk.RIGHT, padx=(3, 0))
# 点击拖拽框选择文件
def _click_select(evt=None):
try:
files = filedialog.askopenfilenames(
title="选择图片或Excel文件",
filetypes=[
("支持文件", "*.xlsx *.xls *.jpg *.jpeg *.png *.bmp"),
("Excel", "*.xlsx *.xls"),
("图片", "*.jpg *.jpeg *.png *.bmp"),
("所有文件", "*.*"),
]
)
if not files:
return
for p in files:
process_dropped_file(log_text, status_bar, p)
except Exception as e:
messagebox.showerror("选择失败", str(e))
dnd_frame.bind('<Button-1>', _click_select)
msg_row.bind('<Button-1>', _click_select)
if dnd_supported:
def _on_drop(event):
try:
data = event.data
paths = []
buf = ""
in_brace = False
for ch in data:
if ch == '{':
in_brace = True
buf = ""
elif ch == '}':
in_brace = False
paths.append(buf)
buf = ""
elif ch == ' ' and not in_brace:
if buf:
paths.append(buf)
buf = ""
else:
buf += ch
if buf:
paths.append(buf)
for p in paths:
process_dropped_file(log_text, status_bar, p)
except Exception as e:
add_to_log(log_text, f"拖拽处理失败: {str(e)}\n", "error")
try:
from tkinterdnd2 import DND_FILES
dnd_frame.drop_target_register(DND_FILES)
dnd_frame.dnd_bind('<<Drop>>', _on_drop)
except Exception:
pass
def _create_log_panel(mid_container, theme):
"""创建中间日志面板,返回 log_text widget"""
log_panel = create_card_frame(mid_container, "处理日志")
log_panel.pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=(5, 5), pady=5)
log_text = scrolledtext.ScrolledText(
log_panel, wrap=tk.WORD, width=68, height=26,
bg=theme["log_bg"], fg=theme["log_fg"],
font=("Consolas", 9), state=tk.DISABLED,
relief="flat", borderwidth=0
)
log_text.pack(fill=tk.BOTH, expand=True, padx=10, pady=(5, 10))
log_text.tag_configure("command", foreground=theme["info"], font=("Consolas", 9, "bold"))
log_text.tag_configure("time", foreground=theme["secondary_bg"], font=("Consolas", 8))
log_text.tag_configure("separator", foreground=theme["border"])
log_text.tag_configure("success", foreground=theme["success"], font=("Consolas", 9, "bold"))
log_text.tag_configure("error", foreground=theme["error"], font=("Consolas", 9, "bold"))
log_text.tag_configure("warning", foreground=theme["warning"], font=("Consolas", 9, "bold"))
log_text.tag_configure("info", foreground=theme["info"], font=("Consolas", 9))
poll_log_queue(log_text)
add_to_log(log_text, "欢迎使用 益选-OCR订单处理系统 v1.1.0\n", "success")
add_to_log(log_text, "系统已就绪,请选择相应功能进行操作。\n\n", "info")
add_to_log(log_text, "功能说明:\n", "command")
add_to_log(log_text, "• 完整处理流程:一键完成OCR识别和Excel处理\n", "info")
add_to_log(log_text, "• 批量处理订单:批量处理多个订单文件\n", "info")
add_to_log(log_text, "• 处理烟草订单:专门处理烟草类订单\n", "info")
add_to_log(log_text, "• 合并订单:将多个订单合并为一个文件\n\n", "info")
add_to_log(log_text, "请将需要处理的图片文件放入 data/input 目录中。\n", "warning")
add_to_log(log_text, "OCR识别结果保存在 data/output 目录,处理完成的订单保存在 result 目录中。\n\n", "warning")
add_to_log(log_text, "=" * 50 + "\n\n", "separator")
return log_text
def main():
"""主函数"""
try:
root, theme, settings, dnd_supported = _init_window()
# 主容器
main_container = tk.Frame(root, bg=theme["bg"])
main_container.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
content_frame = tk.Frame(main_container, bg=theme["bg"])
content_frame.pack(fill=tk.BOTH, expand=True)
# 中间容器(拖拽区 + 日志区)
mid_container = tk.Frame(content_frame, bg=theme["bg"])
mid_container.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(5, 5), pady=5)
log_text = _create_log_panel(mid_container, theme)
# 状态栏
status_bar = StatusBar(root)
status_bar.pack(side=tk.BOTTOM, fill=tk.X)
# 左侧面板
_create_left_panel(content_frame, theme, log_text, status_bar)
# 右侧面板
_create_right_panel(content_frame, theme, log_text, root)
# 拖拽区域
_setup_drag_area(mid_container, theme, dnd_supported, log_text, status_bar)
# 快捷键 + 关闭事件
def on_close():
try:
w = root.winfo_width()
h = root.winfo_height()
settings['window_size'] = f"{w}x{h}"
settings['theme_mode'] = get_theme_mode()
save_user_settings(settings)
except Exception:
pass
root.destroy()
root.protocol("WM_DELETE_WINDOW", on_close)
bind_keyboard_shortcuts(root, log_text, status_bar)
root.mainloop()
except Exception as e:
import traceback
error_msg = f"程序启动失败: {str(e)}\n详细错误信息:\n{traceback.format_exc()}"
print(error_msg)
try:
import tkinter.messagebox as mb
mb.showerror("启动错误", f"程序启动失败:\n{str(e)}")
except Exception:
pass
+371
View File
@@ -0,0 +1,371 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""处理结果预览对话框模块"""
import os
import re
import datetime
import tkinter as tk
from tkinter import messagebox, scrolledtext
from .theme import THEMES, get_theme_mode, apply_theme
from .ui_widgets import center_window
from app.core.utils.file_utils import format_file_size
TOBACCO_PREVIEW_WINDOW = None
def show_result_preview(command, output):
"""显示处理结果预览"""
if "ocr" in command:
show_ocr_result_preview(output)
elif "excel" in command:
show_excel_result_preview(output)
elif "merge" in command:
show_merge_result_preview(output)
elif "pipeline" in command:
show_pipeline_result_preview(output)
else:
messagebox.showinfo("处理完成", "操作已成功完成!\n请在data/output目录查看结果。")
def show_ocr_result_preview(output):
"""显示OCR处理结果预览"""
files_match = re.search(r'找到 (\d+) 个图片文件,其中 (\d+) 个未处理', output)
processed_match = re.search(r'所有图片处理完成, 总计: (\d+), 成功: (\d+)', output)
if processed_match:
total = int(processed_match.group(1))
success = int(processed_match.group(2))
preview = tk.Toplevel()
preview.title("OCR处理结果")
preview.geometry("400x300")
preview.resizable(False, False)
center_window(preview)
tk.Label(preview, text="OCR处理完成", font=("Arial", 16, "bold")).pack(pady=10)
result_frame = tk.Frame(preview)
result_frame.pack(pady=10, fill=tk.BOTH, expand=True)
tk.Label(result_frame, text=f"总共处理: {total} 个文件", font=("Arial", 12)).pack(anchor=tk.W, padx=20, pady=5)
tk.Label(result_frame, text=f"成功处理: {success} 个文件", font=("Arial", 12)).pack(anchor=tk.W, padx=20, pady=5)
tk.Label(result_frame, text=f"失败数量: {total - success} 个文件", font=("Arial", 12)).pack(anchor=tk.W, padx=20, pady=5)
if success == total:
result_text = "全部处理成功!"
result_color = "#28a745"
elif success > total * 0.8:
result_text = "大部分处理成功。"
result_color = "#ffc107"
else:
result_text = "处理失败较多,请检查日志。"
result_color = "#dc3545"
tk.Label(result_frame, text=result_text, font=("Arial", 12, "bold"), fg=result_color).pack(pady=10)
button_frame = tk.Frame(preview)
button_frame.pack(pady=10)
tk.Button(button_frame, text="查看输出文件", command=lambda: os.startfile(os.path.abspath("data/output"))).pack(side=tk.LEFT, padx=10)
tk.Button(button_frame, text="关闭", command=preview.destroy).pack(side=tk.LEFT, padx=10)
else:
messagebox.showinfo("OCR处理完成", "OCR处理已完成,请在data/output目录查看结果。")
def show_excel_result_preview(output):
"""显示Excel处理结果预览"""
extract_match = re.search(r'提取到 (\d+) 个商品信息', output)
file_match = re.search(r'采购单已保存到: (.+?)(?:\n|$)', output)
if extract_match and file_match:
products_count = int(extract_match.group(1))
output_file = file_match.group(1)
preview = tk.Toplevel()
preview.title("Excel处理结果")
preview.geometry("450x320")
preview.resizable(False, False)
center_window(preview)
tk.Label(preview, text="Excel处理完成", font=("Arial", 16, "bold")).pack(pady=10)
result_frame = tk.Frame(preview)
result_frame.pack(pady=10, fill=tk.BOTH, expand=True)
tk.Label(result_frame, text=f"提取商品数量: {products_count}", font=("Arial", 12)).pack(anchor=tk.W, padx=20, pady=5)
tk.Label(result_frame, text=f"输出文件: {os.path.basename(output_file)}", font=("Arial", 12)).pack(anchor=tk.W, padx=20, pady=5)
tk.Label(result_frame, text="采购单已成功生成!", font=("Arial", 12, "bold"), fg="#28a745").pack(pady=10)
file_frame = tk.Frame(result_frame, relief=tk.GROOVE, borderwidth=1)
file_frame.pack(fill=tk.X, padx=15, pady=5)
tk.Label(file_frame, text="文件信息", font=("Arial", 10, "bold")).pack(anchor=tk.W, padx=10, pady=5)
try:
file_size = os.path.getsize(output_file)
file_time = datetime.datetime.fromtimestamp(os.path.getmtime(output_file))
size_text = format_file_size(file_size)
tk.Label(file_frame, text=f"文件大小: {size_text}", font=("Arial", 10)).pack(anchor=tk.W, padx=10, pady=2)
tk.Label(file_frame, text=f"创建时间: {file_time.strftime('%Y-%m-%d %H:%M:%S')}", font=("Arial", 10)).pack(anchor=tk.W, padx=10, pady=2)
except Exception:
tk.Label(file_frame, text="无法获取文件信息", font=("Arial", 10)).pack(anchor=tk.W, padx=10, pady=2)
button_frame = tk.Frame(preview)
button_frame.pack(pady=10)
tk.Button(button_frame, text="打开文件", command=lambda: os.startfile(output_file)).pack(side=tk.LEFT, padx=5)
tk.Button(button_frame, text="打开所在文件夹", command=lambda: os.startfile(os.path.dirname(output_file))).pack(side=tk.LEFT, padx=5)
tk.Button(button_frame, text="关闭", command=preview.destroy).pack(side=tk.LEFT, padx=5)
else:
messagebox.showinfo("Excel处理完成", "Excel处理已完成,请在data/output目录查看结果。")
def show_merge_result_preview(output):
"""显示合并结果预览"""
merged_match = re.search(r'合并了 (\d+) 个采购单', output)
product_match = re.search(r'共处理 (\d+) 个商品', output)
output_match = re.search(r'已保存到: (.+?)(?:\n|$)', output)
if merged_match and output_match:
merged_count = int(merged_match.group(1))
product_count = int(product_match.group(1)) if product_match else 0
output_file = output_match.group(1)
preview = tk.Toplevel()
preview.title("采购单合并结果")
preview.geometry("450x300")
preview.resizable(False, False)
apply_theme(preview)
tk.Label(preview, text="采购单合并完成", font=("Arial", 16, "bold")).pack(pady=10)
result_frame = tk.Frame(preview)
result_frame.pack(pady=10, fill=tk.BOTH, expand=True)
tk.Label(result_frame, text=f"合并采购单数量: {merged_count}", font=("Arial", 12)).pack(anchor=tk.W, padx=20, pady=5)
tk.Label(result_frame, text=f"处理商品数量: {product_count}", font=("Arial", 12)).pack(anchor=tk.W, padx=20, pady=5)
tk.Label(result_frame, text=f"输出文件: {os.path.basename(output_file)}", font=("Arial", 12)).pack(anchor=tk.W, padx=20, pady=5)
theme = THEMES[get_theme_mode()]
tk.Label(result_frame, text="采购单已成功合并!", font=("Arial", 12, "bold"), fg=theme["success"]).pack(pady=10)
button_frame = tk.Frame(preview)
button_frame.pack(pady=10)
tk.Button(button_frame, text="打开文件", command=lambda: os.startfile(output_file)).pack(side=tk.LEFT, padx=10)
tk.Button(button_frame, text="打开所在文件夹", command=lambda: os.startfile(os.path.dirname(output_file))).pack(side=tk.LEFT, padx=10)
tk.Button(button_frame, text="关闭", command=preview.destroy).pack(side=tk.LEFT, padx=10)
else:
messagebox.showinfo("采购单合并完成", "采购单合并已完成,请在data/output目录查看结果。")
def show_pipeline_result_preview(output):
"""显示完整流程结果预览"""
ocr_match = re.search(r'所有图片处理完成, 总计: (\d+), 成功: (\d+)', output)
excel_match = re.search(r'提取到 (\d+) 个商品信息', output)
output_file_match = re.search(r'采购单已保存到: (.+?)(?:\n|$)', output)
preview = tk.Toplevel()
preview.title("完整流程处理结果")
preview.geometry("500x400")
preview.resizable(False, False)
center_window(preview)
tk.Label(preview, text="完整处理流程已完成", font=("Arial", 16, "bold")).pack(pady=10)
no_files_match = re.search(r'未找到可合并的文件', output)
if no_files_match:
tk.Label(preview, text="未找到可合并的文件,但其他步骤已成功执行", font=("Arial", 12)).pack(pady=0)
result_frame = tk.Frame(preview)
result_frame.pack(pady=10, fill=tk.BOTH, expand=True)
result_text = scrolledtext.ScrolledText(result_frame, wrap=tk.WORD, height=15, width=60)
result_text.pack(fill=tk.BOTH, expand=True, padx=15, pady=5)
result_text.configure(state=tk.NORMAL)
result_text.insert(tk.END, "===== 流程执行结果 =====\n\n", "title")
result_text.insert(tk.END, "步骤1: OCR识别\n", "step")
if ocr_match:
total = int(ocr_match.group(1))
success = int(ocr_match.group(2))
result_text.insert(tk.END, f" 处理图片: {total}\n", "info")
result_text.insert(tk.END, f" 成功识别: {success}\n", "info")
if success == total:
result_text.insert(tk.END, " 结果: 全部识别成功\n", "success")
else:
result_text.insert(tk.END, f" 结果: 部分识别成功 ({success}/{total})\n", "warning")
else:
result_text.insert(tk.END, " 结果: 无OCR处理或处理信息不完整\n", "warning")
result_text.insert(tk.END, "\n步骤2: Excel处理\n", "step")
if excel_match:
products = int(excel_match.group(1))
result_text.insert(tk.END, f" 提取商品: {products}\n", "info")
result_text.insert(tk.END, " 结果: 成功生成采购单\n", "success")
if output_file_match:
output_file = output_file_match.group(1)
result_text.insert(tk.END, f" 输出文件: {os.path.basename(output_file)}\n", "info")
else:
result_text.insert(tk.END, " 结果: 无Excel处理或处理信息不完整\n", "warning")
result_text.insert(tk.END, "\n===== 整体评估 =====\n", "title")
has_errors = "错误" in output or "失败" in output
no_files_match2 = re.search(r'未找到采购单文件', output)
single_file_match = re.search(r'只有1个采购单文件', output)
if no_files_match2:
result_text.insert(tk.END, "没有找到可合并的文件,但处理流程已成功完成。\n", "warning")
result_text.insert(tk.END, "可以选择打开Excel文件或查看输出文件夹。\n", "info")
elif single_file_match:
result_text.insert(tk.END, "只有一个采购单文件,无需合并,处理流程已成功完成。\n", "warning")
result_text.insert(tk.END, "可以选择打开生成的Excel文件。\n", "info")
elif ocr_match and excel_match and not has_errors:
result_text.insert(tk.END, "流程完整执行成功!\n", "success")
elif ocr_match or excel_match:
result_text.insert(tk.END, "流程部分执行成功,请检查日志获取详情。\n", "warning")
else:
result_text.insert(tk.END, "流程执行可能存在问题,请查看详细日志。\n", "error")
result_text.tag_configure("title", font=("Arial", 12, "bold"))
result_text.tag_configure("step", font=("Arial", 11, "bold"))
result_text.tag_configure("info", font=("Arial", 10))
result_text.tag_configure("success", font=("Arial", 10, "bold"), foreground="#28a745")
result_text.tag_configure("warning", font=("Arial", 10, "bold"), foreground="#ffc107")
result_text.tag_configure("error", font=("Arial", 10, "bold"), foreground="#dc3545")
result_text.configure(state=tk.DISABLED)
button_frame = tk.Frame(preview)
button_frame.pack(pady=10)
if output_file_match:
output_file = output_file_match.group(1)
tk.Button(button_frame, text="打开Excel文件", command=lambda: os.startfile(output_file)).pack(side=tk.LEFT, padx=10)
else:
if excel_match or no_files_match or single_file_match:
output_dir = os.path.abspath("data/output")
excel_files = [f for f in os.listdir(output_dir) if f.startswith('采购单_') and (f.endswith('.xls') or f.endswith('.xlsx'))]
if excel_files:
excel_files.sort(key=lambda x: os.path.getmtime(os.path.join(output_dir, x)), reverse=True)
latest_file = os.path.join(output_dir, excel_files[0])
tk.Button(button_frame, text="打开最新Excel文件",
command=lambda: os.startfile(latest_file)).pack(side=tk.LEFT, padx=10)
tk.Button(button_frame, text="查看输出文件夹", command=lambda: os.startfile(os.path.abspath("data/output"))).pack(side=tk.LEFT, padx=10)
tk.Button(button_frame, text="关闭", command=preview.destroy).pack(side=tk.LEFT, padx=10)
def show_tobacco_result_preview(returncode, output):
"""显示烟草订单处理结果预览"""
global TOBACCO_PREVIEW_WINDOW
if returncode != 0:
return
try:
try:
if TOBACCO_PREVIEW_WINDOW and TOBACCO_PREVIEW_WINDOW.winfo_exists():
TOBACCO_PREVIEW_WINDOW.lift()
return
except Exception:
TOBACCO_PREVIEW_WINDOW = None
result_file = None
order_time = "(未知)"
total_amount = "(未知)"
items_count = 0
abs_path_match = re.search(r'烟草订单处理完成,绝对路径: (.+)(?:\n|$)', output)
if abs_path_match:
result_file = abs_path_match.group(1).strip()
for line in output.split('\n'):
if "烟草公司订单处理成功" in line and "订单时间" in line:
time_match = re.search(r'订单时间: ([^,]+)', line)
amount_match = re.search(r'总金额: ([^,]+)', line)
items_match = re.search(r'处理条目: (\d+)', line)
if time_match:
order_time = time_match.group(1).strip()
if amount_match:
total_amount = amount_match.group(1).strip()
if items_match:
items_count = int(items_match.group(1).strip())
if not result_file or not os.path.exists(result_file):
default_path = os.path.abspath("data/output/银豹采购单_烟草公司.xls")
if os.path.exists(default_path):
result_file = default_path
preview = tk.Toplevel()
preview.title("烟草订单处理结果")
preview.geometry("450x320")
preview.resizable(False, False)
TOBACCO_PREVIEW_WINDOW = preview
def _close_preview():
global TOBACCO_PREVIEW_WINDOW
TOBACCO_PREVIEW_WINDOW = None
try:
preview.destroy()
except Exception:
pass
preview.protocol("WM_DELETE_WINDOW", _close_preview)
center_window(preview)
tk.Label(preview, text="烟草订单处理完成", font=("Arial", 16, "bold")).pack(pady=10)
result_frame = tk.Frame(preview)
result_frame.pack(pady=10, fill=tk.BOTH, expand=True)
tk.Label(result_frame, text=f"订单时间: {order_time}", font=("Arial", 12)).pack(anchor=tk.W, padx=20, pady=5)
tk.Label(result_frame, text=f"订单总金额: {total_amount}", font=("Arial", 12)).pack(anchor=tk.W, padx=20, pady=5)
tk.Label(result_frame, text=f"处理商品数量: {items_count}", font=("Arial", 12)).pack(anchor=tk.W, padx=20, pady=5)
if result_file and os.path.exists(result_file):
tk.Label(result_frame, text=f"输出文件: {os.path.basename(result_file)}", font=("Arial", 12)).pack(anchor=tk.W, padx=20, pady=5)
tk.Label(result_frame, text="银豹采购单已成功生成!", font=("Arial", 12, "bold"), fg="#28a745").pack(pady=10)
file_frame = tk.Frame(result_frame, relief=tk.GROOVE, borderwidth=1)
file_frame.pack(fill=tk.X, padx=15, pady=5)
tk.Label(file_frame, text="文件信息", font=("Arial", 10, "bold")).pack(anchor=tk.W, padx=10, pady=5)
try:
file_size = os.path.getsize(result_file)
file_time = datetime.datetime.fromtimestamp(os.path.getmtime(result_file))
size_text = format_file_size(file_size)
tk.Label(file_frame, text=f"文件大小: {size_text}", font=("Arial", 10)).pack(anchor=tk.W, padx=10, pady=2)
tk.Label(file_frame, text=f"创建时间: {file_time.strftime('%Y-%m-%d %H:%M:%S')}", font=("Arial", 10)).pack(anchor=tk.W, padx=10, pady=2)
except Exception:
tk.Label(file_frame, text="无法获取文件信息", font=("Arial", 10)).pack(anchor=tk.W, padx=10, pady=2)
button_frame = tk.Frame(preview)
button_frame.pack(pady=10)
tk.Button(button_frame, text="打开文件", command=lambda: os.startfile(result_file)).pack(side=tk.LEFT, padx=5)
tk.Button(button_frame, text="打开所在文件夹", command=lambda: os.startfile(os.path.dirname(result_file))).pack(side=tk.LEFT, padx=5)
tk.Button(button_frame, text="关闭", command=_close_preview).pack(side=tk.LEFT, padx=5)
else:
tk.Label(result_frame, text="未找到输出文件", font=("Arial", 12)).pack(anchor=tk.W, padx=20, pady=5)
tk.Label(result_frame, text="请检查data/output目录", font=("Arial", 12, "bold"), fg="#dc3545").pack(pady=10)
button_frame = tk.Frame(preview)
button_frame.pack(pady=10)
tk.Button(button_frame, text="打开输出目录", command=lambda: os.startfile(os.path.abspath("data/output"))).pack(side=tk.LEFT, padx=5)
tk.Button(button_frame, text="关闭", command=_close_preview).pack(side=tk.LEFT, padx=5)
preview.lift()
preview.attributes('-topmost', True)
preview.after_idle(lambda: preview.attributes('-topmost', False))
except Exception as e:
messagebox.showerror(
"处理异常",
f"显示预览时发生错误: {e}\n请检查日志了解详细信息。"
)
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""键盘快捷键模块"""
import tkinter as tk
from tkinter import messagebox
from .ui_widgets import center_window
from .action_handlers import (
process_single_image_with_status,
process_excel_file_with_status,
batch_ocr_with_status,
run_pipeline_directly,
merge_orders_with_status,
)
from .file_operations import clean_cache
def bind_keyboard_shortcuts(root, log_widget, status_bar):
"""绑定键盘快捷键"""
root.bind('<Control-o>', lambda e: process_single_image_with_status(log_widget, status_bar))
root.bind('<Control-e>', lambda e: process_excel_file_with_status(log_widget, status_bar))
root.bind('<Control-b>', lambda e: batch_ocr_with_status(log_widget, status_bar))
root.bind('<Control-p>', lambda e: run_pipeline_directly(log_widget, status_bar))
root.bind('<Control-m>', lambda e: merge_orders_with_status(log_widget, status_bar))
root.bind('<F5>', lambda e: clean_cache(log_widget))
root.bind('<Escape>', lambda e: root.quit() if messagebox.askyesno("确认退出", "确定要退出程序吗?") else None)
root.bind('<F1>', lambda e: show_shortcuts_help())
def show_shortcuts_help():
"""显示快捷键帮助对话框"""
help_dialog = tk.Toplevel()
help_dialog.title("快捷键帮助")
help_dialog.geometry("400x450")
center_window(help_dialog)
tk.Label(help_dialog, text="键盘快捷键", font=("Arial", 16, "bold")).pack(pady=10)
help_text = tk.Text(help_dialog, wrap=tk.WORD, width=50, height=20)
help_text.pack(padx=20, pady=10, fill=tk.BOTH, expand=True)
shortcuts = """
Ctrl+O: 处理单个图片
Ctrl+E: 处理Excel文件
Ctrl+B: OCR批量识别
Ctrl+P: 完整处理流程
Ctrl+M: 合并采购单
F5: 清除处理缓存
Esc: 退出程序
"""
help_text.insert(tk.END, shortcuts)
help_text.configure(state=tk.DISABLED)
tk.Button(help_dialog, text="确定", command=help_dialog.destroy).pack(pady=10)
help_dialog.lift()
help_dialog.attributes('-topmost', True)
help_dialog.after_idle(lambda: help_dialog.attributes('-topmost', False))
+193
View File
@@ -0,0 +1,193 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""主题管理模块"""
import tkinter as tk
from tkinter import scrolledtext, ttk
# 私有主题模式变量
_theme_mode = "light"
# 浅色和深色主题颜色
THEMES = {
"light": {
"bg": "#f8f9fa",
"fg": "#212529",
"button_bg": "#ffffff",
"button_fg": "#495057",
"button_hover": "#e9ecef",
"primary_bg": "#007bff",
"primary_fg": "#ffffff",
"secondary_bg": "#6c757d",
"secondary_fg": "#ffffff",
"log_bg": "#ffffff",
"log_fg": "#212529",
"highlight_bg": "#007bff",
"highlight_fg": "#ffffff",
"border": "#dee2e6",
"success": "#28a745",
"error": "#dc3545",
"warning": "#ffc107",
"info": "#17a2b8",
"card_bg": "#ffffff",
"shadow": "#00000010"
},
"dark": {
"bg": "#1a1a1a",
"fg": "#e9ecef",
"button_bg": "#343a40",
"button_fg": "#e9ecef",
"button_hover": "#495057",
"primary_bg": "#0d6efd",
"primary_fg": "#ffffff",
"secondary_bg": "#6c757d",
"secondary_fg": "#ffffff",
"log_bg": "#212529",
"log_fg": "#e9ecef",
"highlight_bg": "#0d6efd",
"highlight_fg": "#ffffff",
"border": "#495057",
"success": "#198754",
"error": "#dc3545",
"warning": "#ffc107",
"info": "#0dcaf0",
"card_bg": "#2d3748",
"shadow": "#00000030"
}
}
def get_theme_mode() -> str:
return _theme_mode
def set_theme_mode(mode: str):
global _theme_mode
_theme_mode = mode
def create_modern_button(parent, text, command, style="primary", width=None, height=None, px_width=None, px_height=None):
"""创建现代化样式的按钮"""
theme = THEMES[_theme_mode]
if style == "primary":
bg_color = "white"
fg_color = theme["primary_bg"]
hover_color = "#f0f8ff"
border_color = theme["primary_bg"]
elif style == "secondary":
bg_color = theme["secondary_bg"]
fg_color = theme["secondary_fg"]
hover_color = theme["button_hover"]
border_color = theme["secondary_bg"]
else:
bg_color = "white"
fg_color = theme["primary_bg"]
hover_color = "#f0f8ff"
border_color = theme["primary_bg"]
button_frame = tk.Frame(parent, bg=border_color, highlightthickness=0)
button_frame.configure(relief="flat", bd=0)
if px_width or px_height:
try:
w = px_width if px_width else button_frame.winfo_reqwidth()
h = px_height if px_height else 32
button_frame.configure(width=w, height=h)
button_frame.pack_propagate(False)
except Exception:
pass
button = tk.Button(
button_frame,
text=text,
command=command,
bg=bg_color,
fg=fg_color,
font=("Microsoft YaHei UI", 8),
relief="flat",
bd=0,
padx=14,
pady=4,
anchor="center",
cursor="hand2",
activebackground=hover_color,
activeforeground=fg_color
)
if width:
button.configure(width=width)
else:
button.configure(width=12)
if height is not None:
button.configure(height=height)
else:
button.configure(height=1)
if height:
button.configure(height=height)
# 悬停效果
def on_enter(e):
button.configure(bg=hover_color)
def on_leave(e):
button.configure(bg=bg_color)
button.bind("<Enter>", on_enter)
button.bind("<Leave>", on_leave)
button_frame.bind("<Enter>", on_enter)
button_frame.bind("<Leave>", on_leave)
button.pack(fill=tk.BOTH, expand=True, padx=1, pady=1)
return button_frame
def create_card_frame(parent, title=None):
"""创建卡片样式的框架"""
theme = THEMES[_theme_mode]
card = tk.Frame(
parent,
bg=theme["card_bg"],
relief="flat",
borderwidth=1,
highlightbackground=theme["border"],
highlightthickness=1
)
if title:
title_label = tk.Label(
card,
text=title,
bg=theme["card_bg"],
fg=theme["fg"],
font=("Microsoft YaHei UI", 10, "bold")
)
title_label.pack(pady=(6, 3))
return card
def apply_theme(widget, theme_mode=None):
"""应用主题到小部件"""
if theme_mode is None:
theme_mode = _theme_mode
theme = THEMES[theme_mode]
try:
widget.configure(bg=theme["bg"], fg=theme["fg"])
except Exception:
pass
for child in widget.winfo_children():
if isinstance(child, tk.Button) and not isinstance(child, ttk.Button):
child.configure(bg=theme["button_bg"], fg=theme["button_fg"])
elif isinstance(child, scrolledtext.ScrolledText):
child.configure(bg=theme["log_bg"], fg=theme["log_fg"])
else:
try:
child.configure(bg=theme["bg"], fg=theme["fg"])
except Exception:
pass
apply_theme(child, theme_mode)
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""UI控件模块 - StatusBar、ProgressReporter、可折叠框架等"""
import tkinter as tk
from tkinter import ttk
from .theme import THEMES, get_theme_mode
class StatusBar(tk.Frame):
"""状态栏,显示当前系统状态和进度"""
def __init__(self, master, **kwargs):
super().__init__(master, **kwargs)
self.configure(height=25, relief=tk.SUNKEN, borderwidth=1)
self.status_label = tk.Label(self, text="就绪", anchor=tk.W, padx=5)
self.status_label.pack(side=tk.LEFT, fill=tk.X, expand=True)
self.progress = ttk.Progressbar(self, orient=tk.HORIZONTAL, length=200, mode='determinate')
self.progress.pack(side=tk.RIGHT, padx=5, pady=2)
self.progress.pack_forget()
def set_status(self, text, progress=None):
"""设置状态栏文本和进度"""
self.status_label.config(text=text)
if progress is not None and 0 <= progress <= 100:
self.progress.pack(side=tk.RIGHT, padx=5, pady=2)
self.progress.config(value=progress)
else:
self.progress.pack_forget()
def set_running(self, is_running=True):
"""设置运行状态"""
theme = THEMES[get_theme_mode()]
if is_running:
self.status_label.config(text="处理中...", foreground=theme["info"])
self.progress.pack(side=tk.RIGHT, padx=5, pady=2)
self.progress.config(mode='indeterminate')
self.progress.start()
else:
self.status_label.config(text="就绪", foreground=theme["fg"])
self.progress.stop()
self.progress.pack_forget()
class ProgressReporter:
def __init__(self, status_bar: StatusBar):
self.status_bar = status_bar
def set(self, text: str, percent: int = None):
try:
if percent is not None:
self.status_bar.set_status(text, percent)
else:
self.status_bar.set_status(text)
except Exception:
pass
def running(self):
try:
self.status_bar.set_running(True)
except Exception:
pass
def done(self):
try:
self.status_bar.set_running(False)
self.status_bar.set_status("就绪")
except Exception:
pass
def create_collapsible_frame(parent, title, initial_state=True):
"""创建可折叠的面板"""
frame = tk.Frame(parent)
frame.pack(fill=tk.X, pady=5)
title_frame = tk.Frame(frame)
title_frame.pack(fill=tk.X)
state_var = tk.BooleanVar(value=initial_state)
indicator = "" if initial_state else ""
state_label = tk.Label(title_frame, text=indicator, font=("Arial", 10, "bold"))
state_label.pack(side=tk.LEFT, padx=5)
title_label = tk.Label(title_frame, text=title, font=("Arial", 11, "bold"))
title_label.pack(side=tk.LEFT, padx=5)
content_frame = tk.Frame(frame)
if initial_state:
content_frame.pack(fill=tk.X, padx=20, pady=5)
def toggle_collapse(event=None):
current_state = state_var.get()
new_state = not current_state
state_var.set(new_state)
state_label.config(text="" if new_state else "")
if new_state:
content_frame.pack(fill=tk.X, padx=20, pady=5)
else:
content_frame.pack_forget()
title_frame.bind("<Button-1>", toggle_collapse)
state_label.bind("<Button-1>", toggle_collapse)
title_label.bind("<Button-1>", toggle_collapse)
return content_frame, state_var
def center_window(window):
"""使窗口居中显示"""
window.update_idletasks()
width = window.winfo_width()
height = window.winfo_height()
x = (window.winfo_screenwidth() // 2) - (width // 2)
y = (window.winfo_screenheight() // 2) - (height // 2)
window.geometry('{}x{}+{}+{}'.format(width, height, x, y))
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""用户设置与最近文件管理模块"""
import os
import json
import re
import tkinter as tk
from typing import Dict, List, Any
from app.core.utils.log_utils import get_logger
logger = get_logger(__name__)
RECENT_LIST_WIDGET = None
def load_user_settings():
try:
path = os.path.abspath(os.path.join('data', 'user_settings.json'))
if os.path.exists(path):
with open(path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
logger.debug(f"加载用户设置失败: {e}")
return {}
def save_user_settings(settings: Dict[str, Any]):
try:
os.makedirs('data', exist_ok=True)
path = os.path.abspath(os.path.join('data', 'user_settings.json'))
with open(path, 'w', encoding='utf-8') as f:
json.dump(settings, f, ensure_ascii=False, indent=2)
except Exception as e:
logger.debug(f"保存用户设置失败: {e}")
def get_recent_files() -> List[str]:
s = load_user_settings()
items = s.get('recent_files', [])
if not isinstance(items, list):
return []
def _allowed(p: str) -> bool:
try:
if not isinstance(p, str) or not os.path.isfile(p):
return False
ext = os.path.splitext(p)[1].lower()
return ext in {'.xlsx', '.xls', '.jpg', '.jpeg', '.png', '.bmp'}
except Exception:
return False
kept = [p for p in items if _allowed(p)]
if not kept:
candidates = []
for d in ['data/output', 'data/result']:
try:
if os.path.exists(d):
for name in os.listdir(d):
p = os.path.join(d, name)
if _allowed(p):
candidates.append(p)
except Exception:
pass
if candidates:
kept = candidates
try:
kept_sorted = sorted(kept, key=lambda p: os.path.getmtime(p), reverse=True)
except Exception:
kept_sorted = kept
if kept_sorted != items or len(kept_sorted) != len(items):
s['recent_files'] = kept_sorted[:20]
save_user_settings(s)
return kept_sorted[:10]
def refresh_recent_list_widget():
try:
global RECENT_LIST_WIDGET
if RECENT_LIST_WIDGET is None:
return
RECENT_LIST_WIDGET.delete(0, tk.END)
for i, p in enumerate(get_recent_files(), start=1):
RECENT_LIST_WIDGET.insert(tk.END, f"{i}. {p}")
except Exception as e:
logger.debug(f"刷新最近文件列表失败: {e}")
def _extract_path_from_recent_item(s: str) -> str:
try:
m = re.match(r'^(\d+)\.\s+(.*)$', s)
p = m.group(2) if m else s
return p.strip().strip('"')
except Exception:
return s.strip().strip('"')
def add_recent_file(path: str) -> None:
try:
if not path:
return
try:
if not os.path.isfile(path):
return
ext = os.path.splitext(path)[1].lower()
if ext not in {'.xlsx', '.xls', '.jpg', '.jpeg', '.png', '.bmp'}:
return
except Exception:
return
s = load_user_settings()
items = s.get('recent_files', [])
items = [p for p in items if p != path]
items.insert(0, path)
s['recent_files'] = items[:20]
save_user_settings(s)
refresh_recent_list_widget()
except Exception as e:
logger.debug(f"添加最近文件失败: {e}")
def clear_recent_files():
try:
s = load_user_settings()
s['recent_files'] = []
save_user_settings(s)
except Exception as e:
logger.debug(f"清空最近文件失败: {e}")