#!/usr/bin/env python # -*- coding: utf-8 -*- """ 益选-OCR订单处理系统启动器 ----------------- 提供简单的图形界面,方便用户选择功能 """ import os import sys import time import subprocess import shutil import tkinter as tk from tkinter import messagebox, filedialog, scrolledtext, ttk, simpledialog from tkinter import font as tkfont from threading import Thread import datetime import time import pandas as pd import json import re import logging import queue from typing import Dict, List, Optional, Any from pathlib import Path # 导入自定义对话框工具 from app.core.utils.dialog_utils import show_custom_dialog, show_barcode_mapping_dialog, show_config_dialog from app.core.excel.converter import UnitConverter from app.config.settings import ConfigManager from app.core.utils.log_utils import set_log_level # 导入服务类 from app.services.ocr_service import OCRService from app.services.order_service import OrderService from app.services.tobacco_service import TobaccoService from app.services.processor_service import ProcessorService # 全局变量,用于跟踪任务状态 RUNNING_TASK = None THEME_MODE = "light" # 默认浅色主题 PROCESSOR_SERVICE = None # config_manager = ConfigManager() # 创建配置管理器实例 - 延迟初始化 # 定义浅色和深色主题颜色 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 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: pass 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: pass RECENT_LIST_WIDGET = None TOBACCO_PREVIEW_WINDOW = None 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: pass 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: pass def clear_recent_files(): try: s = load_user_settings() s['recent_files'] = [] save_user_settings(s) except Exception: pass def show_config_dialog(root, cfg: ConfigManager): settings = load_user_settings() dlg = tk.Toplevel(root) dlg.title("系统设置") dlg.geometry("560x540") 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)) # 操作按钮 btns = ttk.Frame(content) btns.grid(row=13, 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.save_config() except Exception: pass messagebox.showinfo("设置已保存","系统设置已更新并保存") dlg.destroy() except Exception as e: messagebox.showerror("保存失败", str(e)) def reload_suppliers(): try: global PROCESSOR_SERVICE 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=dlg.destroy).grid(row=0, column=1, sticky='e') ttk.Button(btns, text="保存", command=save_settings).grid(row=0, column=2, sticky='e', padx=6) 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): """设置运行状态""" if is_running: self.status_label.config(text="处理中...", foreground=THEMES[THEME_MODE]["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=THEMES[THEME_MODE]["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: pass def running(self): try: self.status_bar.set_running(True) except: pass def done(self): try: self.status_bar.set_running(False) self.status_bar.set_status("就绪") except: pass def run_command_with_logging(command, log_widget, status_bar=None, on_complete=None): """运行命令并将输出重定向到日志窗口""" global RUNNING_TASK # 如果已有任务在运行,提示用户 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) # 获取原始的stdout和stderr old_stdout = sys.stdout old_stderr = sys.stderr # 创建日志重定向器 log_redirector = LogRedirector(log_widget) # 设置环境变量,使用配置中的目录 env = os.environ.copy() try: 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: # 重定向stdout和stderr到日志重定向器 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()) # 直接打印到已重定向的stdout # 尝试从输出中提取进度信息 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)) # 如果处理成功且没有指定on_complete回调函数,则显示默认成功信息 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: # 恢复原始stdout和stderr sys.stdout = old_stdout sys.stderr = old_stderr # 任务完成,重置状态 RUNNING_TASK = None if status_bar: log_widget.after(0, lambda: status_bar.set_running(False)) # 在新线程中运行,避免UI阻塞 Thread(target=run_in_thread).start() def extract_progress_from_log(log_line): """从日志行中提取进度信息""" # 尝试匹配"处理批次 x/y"格式的进度信息 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 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处理结果预览""" # 提取处理的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 = f"{file_size / 1024:.1f} KB" if file_size < 1024*1024 else f"{file_size / (1024*1024):.1f} MB" 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: 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) # 处理成功提示 tk.Label(result_frame, text="采购单已成功合并!", font=("Arial", 12, "bold"), fg=THEMES[THEME_MODE]["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") # OCR处理结果 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") # Excel处理结果 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_match = re.search(r'未找到采购单文件', output) single_file_match = re.search(r'只有1个采购单文件', output) if no_files_match: 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: # 如果没有找到合并后的文件,但Excel处理成功,提供打开最新Excel文件的选项 if excel_match or no_files_match or single_file_match: # 找到输出目录中最新的采购单Excel文件 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 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: # default bg_color = "white" # 白色背景 fg_color = theme["primary_bg"] # 蓝色文字 hover_color = "#f0f8ff" # 浅蓝色悬停 border_color = theme["primary_bg"] # 蓝色边框 # 创建一个Frame来包装按钮 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) button.pack(fill=tk.BOTH, expand=True, padx=1, pady=1) return button_frame # 添加悬停效果 def on_enter(e): button.configure(bg=hover_color) def on_leave(e): button.configure(bg=bg_color) button.bind("", on_enter) button.bind("", on_leave) button_frame.bind("", on_enter) button_frame.bind("", on_leave) 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): """应用主题到小部件""" global THEME_MODE if theme_mode is None: theme_mode = THEME_MODE theme = THEMES[theme_mode] try: widget.configure(bg=theme["bg"], fg=theme["fg"]) except: 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: pass # 递归处理子部件的子部件 apply_theme(child, theme_mode) def ensure_directories(): """确保必要的目录结构存在""" directories = ["data/input", "data/output", "data/result", "data/temp", "logs"] for directory in directories: if not os.path.exists(directory): os.makedirs(directory, exist_ok=True) print(f"创建目录: {directory}") class LogRedirector: """日志重定向器,用于捕获命令输出并显示到界面""" def __init__(self, text_widget): self.text_widget = text_widget self.buffer = "" self.terminal = sys.__stdout__ # 保存原始的stdout引用 def write(self, string): self.buffer += string # 同时输出到终端 self.terminal.write(string) # 在UI线程中更新文本控件 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() # 确保终端也被刷新 # 全局日志队列,用于异步更新UI LOG_QUEUE = queue.Queue() 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: # 每100ms轮询一次 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: 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("", toggle_collapse) state_label.bind("", toggle_collapse) title_label.bind("", toggle_collapse) return content_frame, state_var 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: pass 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: pass pass 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: pass 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): """直接运行完整处理流程""" global RUNNING_TASK # 如果已有任务在运行,提示用户 if RUNNING_TASK is not None: messagebox.showinfo("任务进行中", "请等待当前任务完成后再执行新的操作。") return def run_in_thread(): global RUNNING_TASK 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, f"执行命令: 完整处理流程\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: # 直接调用main函数中的pipeline逻辑 from app.config.settings import ConfigManager from app.services.ocr_service import OCRService from app.services.order_service import OrderService import logging 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) # 1. OCR批量处理 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 = {} pjson = os.path.join("data", "output", "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: pass reporter.set("开始Excel处理...", 92) # 2. Excel处理 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: pass try: validate_unit_price_against_item_data(result, log_widget) except Exception: pass pass # 3. 可选的合并步骤(如果有多个采购单) reporter.set("检查是否需要合并采购单...", 80) 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") try: add_recent_file(merge_result) except Exception: pass 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") # 记录结束时间 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, f"完整处理流程执行完毕!\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) pass 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() 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服务实例 ocr_service = OCRService() # 执行批量OCR处理 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 = {} pjson = os.path.join("data", "output", "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: pass pass 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() # 执行Excel处理 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: pass 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: pass reporter.set("检查是否需要合并采购单...", 70) add_to_log(log_widget, "检查是否需要合并采购单...\n", "info") 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") else: add_to_log(log_widget, "采购单合并失败\n", "error") 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", "error") add_to_log(log_widget, "批量处理订单完成\n", "success") reporter.set("批量处理订单完成", 100) show_excel_result_preview(f"采购单已保存到: {result}\n") try: add_recent_file(result) except Exception: pass pass 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() # 执行合并处理(接入进度回调97%→100%) 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: pass try: validate_unit_price_against_item_data(result, log_widget) except Exception: pass pass 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_tobacco_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) # 创建烟草服务实例 config_manager = ConfigManager() tobacco_service = TobaccoService(config_manager) # 执行烟草订单处理 result = tobacco_service.process_tobacco_order() if result: add_to_log(log_widget, "烟草订单处理完成\n", "success") try: add_recent_file(result) except Exception: pass pass else: add_to_log(log_widget, "烟草订单处理失败\n", "error") except Exception as e: add_to_log(log_widget, f"烟草订单处理出错: {str(e)}\n", "error") finally: dispose_gui_logger() reporter.done() # 在新线程中运行 thread = Thread(target=run_in_thread) thread.daemon = True thread.start() def process_rongcheng_yigou_with_status(log_widget, status_bar): def run_in_thread(): try: reporter = ProgressReporter(status_bar) reporter.running() add_to_log(log_widget, "开始处理蓉城易购\n", "info") s = load_user_settings() out_dir = os.path.abspath(s.get('output_folder', 'data/output')) candidates = [] if os.path.exists(out_dir): for f in os.listdir(out_dir): if re.match(r'^订单\d+\.xlsx$', f.lower()): p = os.path.join(out_dir, f) candidates.append((p, os.path.getmtime(p))) if not candidates: add_to_log(log_widget, "未在输出目录找到蓉城易购订单文件\n", "warning") reporter.done() return candidates.sort(key=lambda x: x[1], reverse=True) src_path = candidates[0][0] from app.services.special_suppliers_service import SpecialSuppliersService service = SpecialSuppliersService(ConfigManager()) result = service.process_rongcheng_yigou( src_path, progress_cb=lambda p, m: (reporter.set(m, p), add_to_log(log_widget, f"{m}\n", "info")) ) if result: add_to_log(log_widget, f"处理完成: {result}\n", "success") add_recent_file(result) try: validate_unit_price_against_item_data(result, log_widget) except Exception: pass open_result_directory_from_settings() reporter.set("处理完成", 100) else: add_to_log(log_widget, "处理失败\n", "error") except Exception as e: add_to_log(log_widget, f"处理出错: {str(e)}\n", "error") finally: reporter.done() thread = Thread(target=run_in_thread) thread.daemon = True thread.start() 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() # 执行Excel处理 if file_path: try: add_recent_file(file_path) except Exception: pass 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: pass 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: pass try: validate_unit_price_against_item_data(result, log_widget) except Exception: pass pass 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 main(): """主函数""" try: # 确保必要的目录结构存在并转移旧目录内容 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() global THEME_MODE THEME_MODE = settings.get('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 欢欢欢") default_size = settings.get('window_size', "900x600") root.geometry("900x600") settings['window_size'] = "900x600" root.configure(bg=THEMES[THEME_MODE]["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: pass # 创建主容器 main_container = tk.Frame(root, bg=THEMES[THEME_MODE]["bg"]) main_container.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) # 隐藏主标题区域,减少间距 # 创建主内容区域 content_frame = tk.Frame(main_container, bg=THEMES[THEME_MODE]["bg"]) content_frame.pack(fill=tk.BOTH, expand=True) # 左侧控制面板 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) # 中间容器(拖拽处理 + 日志) mid_container = tk.Frame(content_frame, bg=THEMES[THEME_MODE]["bg"]) mid_container.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(5, 5), pady=5) # 顶部拖拽处理条(高度约120px) 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=THEMES[THEME_MODE]["card_bg"]) drag_panel_content.pack(fill=tk.X, padx=10, pady=6) # 中间日志面板 log_panel = create_card_frame(mid_container, "处理日志") log_panel.pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=(5, 5), pady=5) # 右侧设置与工具面板 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) # 日志文本区域 log_text = scrolledtext.ScrolledText( log_panel, wrap=tk.WORD, width=68, height=26, bg=THEMES[THEME_MODE]["log_bg"], fg=THEMES[THEME_MODE]["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=THEMES[THEME_MODE]["info"], font=("Consolas", 9, "bold")) log_text.tag_configure("time", foreground=THEMES[THEME_MODE]["secondary_bg"], font=("Consolas", 8)) log_text.tag_configure("separator", foreground=THEMES[THEME_MODE]["border"]) log_text.tag_configure("success", foreground=THEMES[THEME_MODE]["success"], font=("Consolas", 9, "bold")) log_text.tag_configure("error", foreground=THEMES[THEME_MODE]["error"], font=("Consolas", 9, "bold")) log_text.tag_configure("warning", foreground=THEMES[THEME_MODE]["warning"], font=("Consolas", 9, "bold")) log_text.tag_configure("info", foreground=THEMES[THEME_MODE]["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") # 创建状态栏 status_bar = StatusBar(root) status_bar.pack(side=tk.BOTTOM, fill=tk.X) # 左侧面板内容区域 panel_content = tk.Frame(left_panel, bg=THEMES[THEME_MODE]["card_bg"]) panel_content.pack(fill=tk.BOTH, expand=True, padx=10, pady=(5, 10)) # 拖拽处理(移动到中间容器顶部) dnd_section = tk.LabelFrame( drag_panel_content, bg=THEMES[THEME_MODE]["card_bg"], fg=THEMES[THEME_MODE]["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=THEMES[THEME_MODE]["card_bg"], highlightthickness=1, highlightbackground=THEMES[THEME_MODE]["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=THEMES[THEME_MODE]["info"] if active else THEMES[THEME_MODE]["border"]) except Exception: pass dnd_frame.bind('', lambda e: _set_highlight(True)) dnd_frame.bind('', lambda e: _set_highlight(False)) msg_row = tk.Frame(dnd_frame, bg=THEMES[THEME_MODE]["card_bg"]) msg_row.pack(fill=tk.X) if dnd_supported: tk.Label( msg_row, text="拖拽已启用:拖拽或点击此区域选择文件", bg=THEMES[THEME_MODE]["card_bg"], fg="#999999", justify="center" ).pack(fill=tk.X) else: tk.Label( msg_row, text="点击此区域选择文件;可安装拖拽支持", bg=THEMES[THEME_MODE]["card_bg"], fg="#999999", justify="center" ).pack(fill=tk.X) if not dnd_supported: btn_row = tk.Frame(dnd_frame, bg=THEMES[THEME_MODE]["card_bg"]) btn_row.pack(fill=tk.X) def copy_install(): try: root.clipboard_clear() root.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('', _click_select) msg_row.bind('', _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: dnd_frame.drop_target_register(DND_FILES) dnd_frame.dnd_bind('<>', _on_drop) except Exception: pass # 右侧面板内容区域 right_panel_content = tk.Frame(right_panel, bg=THEMES[THEME_MODE]["card_bg"]) right_panel_content.pack(fill=tk.BOTH, expand=True, padx=10, pady=(5, 10)) # 完整流程区 pipeline_section = tk.LabelFrame( panel_content, text="完整流程", bg=THEMES[THEME_MODE]["card_bg"], fg=THEMES[THEME_MODE]["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=THEMES[THEME_MODE]["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=THEMES[THEME_MODE]["card_bg"], fg=THEMES[THEME_MODE]["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=THEMES[THEME_MODE]["card_bg"]) core_buttons_frame.pack(fill=tk.X, padx=8, pady=6) # OCR处理按钮 # 核心功能按钮行1 core_row1 = tk.Frame(core_buttons_frame, bg=THEMES[THEME_MODE]["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)) # OCR功能区 ocr_section = tk.LabelFrame( panel_content, text="Excel处理", bg=THEMES[THEME_MODE]["card_bg"], fg=THEMES[THEME_MODE]["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=THEMES[THEME_MODE]["card_bg"]) ocr_buttons_frame.pack(fill=tk.X, padx=8, pady=6) # OCR按钮行1 ocr_row1 = tk.Frame(ocr_buttons_frame, bg=THEMES[THEME_MODE]["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)) # Excel处理区 excel_section = tk.LabelFrame( panel_content, text="特殊处理", bg=THEMES[THEME_MODE]["card_bg"], fg=THEMES[THEME_MODE]["fg"], font=("Microsoft YaHei UI", 10, "bold"), relief="flat", borderwidth=0 ) excel_section.pack(fill=tk.X, pady=(0, 8)) excel_buttons_frame = tk.Frame(excel_section, bg=THEMES[THEME_MODE]["card_bg"]) excel_buttons_frame.pack(fill=tk.X, padx=8, pady=6) # Excel按钮行1 excel_row1 = tk.Frame(excel_buttons_frame, bg=THEMES[THEME_MODE]["card_bg"]) excel_row1.pack(fill=tk.X, pady=3) # 蓉城易购 create_modern_button( excel_row1, "蓉城易购", lambda: process_rongcheng_yigou_with_status(log_text, status_bar), "primary", px_width=72, px_height=32 ).pack(side=tk.LEFT, padx=(0, 3)) # 烟草公司 create_modern_button( excel_row1, "烟草公司", lambda: process_tobacco_orders_with_status(log_text, status_bar), "primary", px_width=72, px_height=32 ).pack(side=tk.LEFT, padx=(3, 0)) # 列映射向导与模板管理入口已移至右侧系统设置区 # 工具功能区 tools_section = tk.LabelFrame( right_panel_content, text="快捷操作", bg=THEMES[THEME_MODE]["card_bg"], fg=THEMES[THEME_MODE]["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=THEMES[THEME_MODE]["card_bg"]) tools_buttons_frame.pack(fill=tk.X, padx=8, pady=6) # 工具按钮行1 tools_row1 = tk.Frame(tools_buttons_frame, bg=THEMES[THEME_MODE]["card_bg"]) tools_row1.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) # 拖拽/快速处理区(已移动至顶部) # 最近文件区域 recent_section = tk.LabelFrame( panel_content, text="最近文件", bg=THEMES[THEME_MODE]["card_bg"], fg=THEMES[THEME_MODE]["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=THEMES[THEME_MODE]["card_bg"]) recent_frame.pack(fill=tk.BOTH, padx=8, pady=6) recent_top = tk.Frame(recent_frame, bg=THEMES[THEME_MODE]["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('', _resize_recent_top) # 顶部操作区域移除“打开所选”按钮 # 边框矩形区域 recent_rect = tk.Frame(recent_top, bg=THEMES[THEME_MODE]["card_bg"], highlightbackground=THEMES[THEME_MODE]["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) global RECENT_LIST_WIDGET 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('', _open_selected_event) refresh_recent_list_widget() rf_btns = tk.Frame(recent_frame, bg=THEMES[THEME_MODE]["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)) # 清理工具改为竖排单列 create_modern_button(tools_buttons_frame, "合并订单", lambda: merge_orders_with_status(log_text, status_bar), "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=THEMES[THEME_MODE]["card_bg"], fg=THEMES[THEME_MODE]["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=THEMES[THEME_MODE]["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: open_template_manager(log_text), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3) # 保存设置并绑定关闭事件 def on_close(): try: w = root.winfo_width() h = root.winfo_height() settings['window_size'] = f"{w}x{h}" settings['theme_mode'] = 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: pass def add_to_log(log_widget, text, tag="normal"): """向日志队列添加文本,由 poll_log_queue 消费并更新 UI""" # 兼容性处理:如果 log_widget 是 None(例如在 headless 模式下运行部分逻辑) if log_widget is None: print(f"[{tag}] {text}", end="") return # 将日志信息和标签放入全局队列,实现异步更新 LOG_QUEUE.put((text, tag)) def select_file(log_widget, file_types=[("所有文件", "*.*")], title="选择文件"): """通用文件选择对话框""" 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 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) ocr_service = OCRService() add_to_log(log_widget, f"开始处理图片: {file_path}\n", "info") try: add_recent_file(file_path) except Exception: pass excel_path = ocr_service.process_image(file_path) if excel_path: add_to_log(log_widget, "图片OCR处理完成\n", "success") add_recent_file(excel_path) else: add_to_log(log_widget, "图片OCR处理失败\n", "error") 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: pass result = order_service.process_excel(file_path, progress_cb=lambda p: status_bar.set_status("Excel处理中...", p)) if result: add_to_log(log_widget, "Excel文件处理完成\n", "success") add_recent_file(result) try: validate_unit_price_against_item_data(result, log_widget) except Exception: pass else: add_to_log(log_widget, "Excel文件处理失败\n", "error") 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") def select_excel_file(log_widget): """选择Excel文件""" return select_file( log_widget, [("Excel文件", "*.xlsx *.xls"), ("所有文件", "*.*")], "选择Excel文件" ) 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: import tkinter.messagebox as mb 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} 条已省略)" mb.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") def clean_cache(log_widget): """清除处理缓存""" try: # 清除OCR缓存文件 cache_files = [ os.path.join("data", "processed_files.json"), os.path.join("data/output", "processed_files.json"), os.path.join("data/output", "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") # 清除日志文件中的active标记 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") # 重置全局状态 global RUNNING_TASK 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_input_directory_from_settings(): try: s = load_user_settings() path = os.path.abspath(s.get('input_folder', 'data/input')) if not os.path.exists(path): os.makedirs(path, exist_ok=True) os.startfile(path) except Exception as e: messagebox.showerror("错误", f"无法打开输入目录: {str(e)}") def open_output_directory_from_settings(): try: s = load_user_settings() path = os.path.abspath(s.get('output_folder', 'data/output')) if not os.path.exists(path): os.makedirs(path, exist_ok=True) os.startfile(path) except Exception as e: messagebox.showerror("错误", f"无法打开输出目录: {str(e)}") def open_result_directory_from_settings(): try: s = load_user_settings() path = os.path.abspath(s.get('result_folder', 'data/result')) if not os.path.exists(path): os.makedirs(path, exist_ok=True) os.startfile(path) except Exception as e: messagebox.showerror("错误", f"无法打开结果目录: {str(e)}") def open_template_manager(log_widget): try: import json import xlrd dlg = tk.Toplevel() dlg.title("模板管理与校验") dlg.geometry("780x540") center_window(dlg) try: dlg.lift() dlg.attributes('-topmost', True) dlg.after(200, lambda: dlg.attributes('-topmost', False)) dlg.focus_force() except Exception: pass frame = ttk.Frame(dlg) frame.pack(fill=tk.BOTH, expand=True, padx=12, pady=12) cfg_path = os.path.join("config","suppliers_config.json") suppliers = [] data = {"suppliers":[]} try: if os.path.exists(cfg_path): with open(cfg_path,'r',encoding='utf-8') as f: data = json.load(f) suppliers = [s.get('name','') for s in data.get('suppliers',[])] except Exception: pass ttk.Label(frame, text="选择供应商").pack(anchor='w') supplier_var = tk.StringVar() supplier_combo = ttk.Combobox(frame, textvariable=supplier_var, state='readonly', values=suppliers) supplier_combo.pack(fill=tk.X) if suppliers: supplier_var.set(suppliers[0]) ttk.Label(frame, text="模板文件").pack(anchor='w', pady=(8,0)) tpl_var = tk.StringVar() tpl_row = ttk.Frame(frame) tpl_row.pack(fill=tk.X, pady=6) ttk.Entry(tpl_row, textvariable=tpl_var).pack(side=tk.LEFT, fill=tk.X, expand=True) def choose_tpl(): p = filedialog.askopenfilename(title="选择模板文件", filetypes=[("Excel模板","*.xls *.xlsx")]) if p: tpl_var.set(os.path.relpath(p, os.getcwd()) if os.path.isabs(p) else p) try: dlg.lift() dlg.attributes('-topmost', True) dlg.after(200, lambda: dlg.attributes('-topmost', False)) dlg.focus_force() except Exception: pass ttk.Button(tpl_row, text="选择", command=choose_tpl).pack(side=tk.LEFT, padx=6) ttk.Label(frame, text="校验结果").pack(anchor='w', pady=(8,0)) result_text = scrolledtext.ScrolledText(frame, height=12) result_text.pack(fill=tk.BOTH, expand=True) def validate_tpl(): try: p = tpl_var.get() if not p: messagebox.showwarning("提示","请先选择模板文件") return ap = os.path.abspath(p) wb = xlrd.open_workbook(ap, formatting_info=True) sh = wb.sheet_by_index(0) headers = [] try: headers = [str(sh.cell_value(0, c)).strip() for c in range(sh.ncols)] except Exception: pass required = ["条码","采购量","赠送量","采购单价"] missing = [h for h in required if h not in headers] result_text.configure(state=tk.NORMAL) result_text.delete(1.0, tk.END) result_text.insert(tk.END, f"模板列: {headers}\n") if missing: result_text.insert(tk.END, f"缺少列: {missing}\n") else: result_text.insert(tk.END, "模板校验通过\n") result_text.configure(state=tk.DISABLED) except Exception as e: messagebox.showerror("校验失败", str(e)) def save_tpl(): try: if not supplier_var.get() or not tpl_var.get(): messagebox.showwarning("提示","请选择供应商并选择模板文件") return for s in data.get('suppliers',[]): if s.get('name') == supplier_var.get(): s['output_template'] = tpl_var.get() with open(cfg_path,'w',encoding='utf-8') as f: json.dump(data,f,ensure_ascii=False,indent=2) add_to_log(log_widget, "模板路径已保存\n", "success") messagebox.showinfo("成功","模板路径已保存") dlg.destroy() except Exception as e: messagebox.showerror("保存失败", str(e)) act = ttk.Frame(frame) act.pack(fill=tk.X, pady=8) ttk.Button(act, text="校验模板", command=validate_tpl).pack(side=tk.LEFT) ttk.Button(act, text="保存", command=save_tpl).pack(side=tk.LEFT, padx=8) ttk.Button(act, text="取消", command=dlg.destroy).pack(side=tk.RIGHT) except Exception as e: messagebox.showerror("模板管理错误", str(e)) 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, f"已清理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, f"已清理output目录\n", "info") # 不清理result目录(仅input/output) 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 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)) def show_tobacco_result_preview(returncode, output): """显示烟草订单处理结果预览""" # 只在成功时显示结果预览 if returncode != 0: return try: global TOBACCO_PREVIEW_WINDOW try: if TOBACCO_PREVIEW_WINDOW and TOBACCO_PREVIEW_WINDOW.winfo_exists(): TOBACCO_PREVIEW_WINDOW.lift() return except: 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 try: TOBACCO_PREVIEW_WINDOW = None except: pass try: preview.destroy() except: 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 = f"{file_size / 1024:.1f} KB" if file_size < 1024*1024 else f"{file_size / (1024*1024):.1f} MB" 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: 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请检查日志了解详细信息。" ) 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)}") def bind_keyboard_shortcuts(root, log_widget, status_bar): """绑定键盘快捷键""" # Ctrl+O - 处理单个图片 root.bind('', lambda e: process_single_image_with_status(log_widget, status_bar)) # Ctrl+E - 处理Excel文件 root.bind('', lambda e: process_excel_file_with_status(log_widget, status_bar)) # Ctrl+B - 批量处理 root.bind('', lambda e: batch_ocr_with_status(log_widget, status_bar)) # Ctrl+P - 完整流程 root.bind('', lambda e: run_pipeline_directly(log_widget, status_bar)) # Ctrl+M - 合并采购单 root.bind('', lambda e: merge_orders_with_status(log_widget, status_bar)) # Ctrl+T - 处理烟草订单 root.bind('', lambda e: process_tobacco_orders_with_status(log_widget, status_bar)) # F5 - 刷新/清除缓存 root.bind('', lambda e: clean_cache(log_widget)) # Escape - 退出 root.bind('', lambda e: root.quit() if messagebox.askyesno("确认退出", "确定要退出程序吗?") else None) # F1 - 显示快捷键帮助 root.bind('', 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: 合并采购单 Ctrl+T: 处理烟草订单 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)) if __name__ == "__main__": main() 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: pass 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 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('"')