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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 19:51:13 +08:00

486 lines
21 KiB
Python

#!/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