"""中国邮政揽收系统 - Excel 自动填单工具 主程序入口,提供 Tkinter GUI 界面。 """ import asyncio import json import os import sys import threading from datetime import datetime from tkinter import ( BOTH, BOTTOM, DISABLED, END, HORIZONTAL, LEFT, NORMAL, RIGHT, VERTICAL, X, Y, BooleanVar, Button, Checkbutton, Entry, Frame, Label, LabelFrame, Listbox, Scrollbar, StringVar, Text, Tk, Toplevel, filedialog, messagebox, ttk, ) from excel_reader import read_input_excel, validate_required_columns from field_mapping import get_defaults, get_field_mapping, get_fixed_values, get_timing_config, load_config from log import user as _log_user # ─── 全局引擎引用 ────────────────────────────────────────── _engine = None _loop = None def _get_writable_path(name: str) -> str: """获取可写文件路径(config.json, session.json)。 开发模式:脚本所在目录。 打包模式:exe 所在目录(_MEIPASS 是只读的)。 """ if getattr(sys, 'frozen', False): return os.path.join(os.path.dirname(sys.executable), name) else: return os.path.join(os.path.dirname(os.path.abspath(__file__)), name) def _get_config_path(): return _get_writable_path("config.json") def _get_session_path(): return _get_writable_path("session.json") def _resource_path(relative_path): """获取只读资源文件路径(pickup.ico 等)。 开发模式:脚本所在目录。 打包模式:_MEIPASS(PyInstaller 解压临时目录)。 """ if getattr(sys, 'frozen', False): return os.path.join(sys._MEIPASS, relative_path) else: return os.path.join(os.path.dirname(os.path.abspath(__file__)), relative_path) # ─── 异步引擎后台线程 ────────────────────────────────────── def _run_async(coro): """在新线程中运行异步协程。""" global _loop _loop = asyncio.new_event_loop() asyncio.set_event_loop(_loop) _loop.run_until_complete(coro) def _run_in_thread(coro): """在后台线程启动异步任务。""" t = threading.Thread(target=_run_async, args=(coro,), daemon=True) t.start() return t # ─── 主窗口 ──────────────────────────────────────────────── class App: def __init__(self): self.root = Tk() self.root.title("自动填单工具 v2.0") self.root.geometry("900x650") self.root.minsize(800, 550) if sys.platform == "win32": self.root.iconbitmap(_resource_path("pickup.ico")) else: # 在 Linux 上什么都不做,或者用 .xbm 文件 # self.root.iconbitmap(_resource_path("pickup.xbm")) pass self.config = {} self.excel_path = StringVar() self.delay_var = StringVar(value="500") self.status_var = StringVar(value="就绪") self.engine_status_var = StringVar(value="未启动") self.running = False self.paused = False self.results = [] # 固定值编辑框变量 self.fv_product = StringVar() self.fv_customer = StringVar() self.fv_destination = StringVar() self.fv_force_destination = BooleanVar() self.fv_district_code = StringVar() self.fv_force_district = BooleanVar() self.fv_sender_name = StringVar() self.fv_force_sender_name = BooleanVar() self.fv_sender_addr = StringVar() self.fv_force_sender_addr = BooleanVar() # 搜索基础设施(懒加载) self._search_bridge = None self._search_sess = None self._search_api = None self._loading_config = False self._search_debounce = None # 产品/客户预缓存(batch 期间复用) self._product_cached_choice = None self._product_cached_detail = None self._customer_cached_choice = None self._customer_cached_detail = None self._sender_realname_cached = None self._build_ui() # 监听固定值输入框变化,触发产品/客户搜索 self.fv_product.trace_add("write", self._on_product_change) self.fv_customer.trace_add("write", self._on_customer_change) # 关闭窗口时清理 self.root.protocol("WM_DELETE_WINDOW", self._on_close) # 日志统一输出到 stderr(终端 / 控制台窗口可见) self._load_config() def _build_ui(self): """构建界面。""" # ── 配置区 ── config_frame = LabelFrame(self.root, text="配置", padx=10, pady=5) config_frame.pack(fill=X, padx=10, pady=5) # 引擎状态 row_status = Frame(config_frame) row_status.pack(fill=X, pady=2) Label(row_status, text="引擎状态:", width=10, anchor="e").pack(side="left") self.status_label = Label(row_status, textvariable=self.engine_status_var, fg="gray") self.status_label.pack(side="left", padx=5) # Excel 文件 row2 = Frame(config_frame) row2.pack(fill=X, pady=2) Label(row2, text="Excel文件:", width=10, anchor="e").pack(side="left") self.file_label = Label(row2, text="未选择", fg="gray", anchor="w") self.file_label.pack(side="left", fill=X, expand=True, padx=5) Button(row2, text="选择文件", command=self._pick_file, width=10).pack( side="right" ) # 延时设置 row3 = Frame(config_frame) row3.pack(fill=X, pady=2) Label(row3, text="间隔(毫秒):", width=10, anchor="e").pack(side="left") Entry(row3, textvariable=self.delay_var, width=8).pack(side="left", padx=5) Label(row3, text="(每单提交之间的等待时间)", fg="gray").pack(side="left") # 按钮 btn_frame = Frame(config_frame) btn_frame.pack(fill=X, pady=5) self.login_btn = Button( btn_frame, text="🔑 登录", command=self._do_login, width=8 ) self.login_btn.pack(side="left", padx=2) self.start_btn = Button( btn_frame, text="▶ 开始处理", command=self._start, bg="#4CAF50", fg="white", width=12, height=1 ) self.start_btn.pack(side="left", padx=2) self.pause_btn = Button( btn_frame, text="⏸ 暂停", command=self._pause, width=10, state=DISABLED ) self.pause_btn.pack(side="left", padx=2) self.stop_btn = Button( btn_frame, text="⏹ 停止", command=self._stop, width=10, state=DISABLED ) self.stop_btn.pack(side="left", padx=2) # 状态 Label( btn_frame, textvariable=self.status_var, fg="blue" ).pack(side="right", padx=10) # ── 固定值设置 ── fixed_frame = LabelFrame(self.root, text="固定值设置", padx=10, pady=5) fixed_frame.pack(fill=X, padx=10, pady=5) # Row 0: 产品 | 客户 r0 = Frame(fixed_frame) r0.pack(fill=X, pady=2) Label(r0, text="产品:", width=10, anchor="e").pack(side="left") self.fv_product_entry = Entry(r0, textvariable=self.fv_product, width=20) self.fv_product_entry.pack(side="left", padx=5) Label(r0, text="客户:", width=10, anchor="e").pack(side="left", padx=(30, 0)) self.fv_customer_entry = Entry(r0, textvariable=self.fv_customer, width=20) self.fv_customer_entry.pack(side="left", padx=5) # Row 1: 寄达地 | 无影响 r1 = Frame(fixed_frame) r1.pack(fill=X, pady=2) Label(r1, text="寄达地:", width=10, anchor="e").pack(side="left") Entry(r1, textvariable=self.fv_destination, width=20).pack(side="left", padx=5) Checkbutton(r1, text="强制覆盖", variable=self.fv_force_destination).pack(side="left", padx=5) Label(r1, text="无影响", fg="gray", font=("", 8)).pack(side="left") # Row 2: 寄达地区划代码 | 强制覆盖 | 直接决定寄达地 r2 = Frame(fixed_frame) r2.pack(fill=X, pady=2) Label(r2, text="寄达地区划代码:", width=12, anchor="e").pack(side="left") Entry(r2, textvariable=self.fv_district_code, width=20).pack(side="left", padx=5) Checkbutton(r2, text="强制覆盖", variable=self.fv_force_district).pack(side="left", padx=5) Label(r2, text="(直接决定寄达地)", fg="gray", font=("", 8)).pack(side="left") # Row 3: 寄件人姓名 | 强制覆盖 r3 = Frame(fixed_frame) r3.pack(fill=X, pady=2) Label(r3, text="寄件人姓名:", width=12, anchor="e").pack(side="left") Entry(r3, textvariable=self.fv_sender_name, width=20).pack(side="left", padx=5) Checkbutton(r3, text="强制覆盖", variable=self.fv_force_sender_name).pack(side="left", padx=5) # Row 4: 寄件地址 | 强制覆盖 r4 = Frame(fixed_frame) r4.pack(fill=X, pady=2) Label(r4, text="寄件地址:", width=12, anchor="e").pack(side="left") Entry(r4, textvariable=self.fv_sender_addr, width=20).pack(side="left", padx=5) Checkbutton(r4, text="强制覆盖", variable=self.fv_force_sender_addr).pack(side="left", padx=5) # ── 进度条 ── progress_frame = Frame(self.root) progress_frame.pack(fill=X, padx=10, pady=2) self.progress = ttk.Progressbar( progress_frame, mode="determinate", length=100 ) self.progress.pack(fill=X) self.progress_label = Label(progress_frame, text="", fg="gray") self.progress_label.pack() # ── 提交结果表格 ── result_frame = LabelFrame(self.root, text="提交结果", padx=10, pady=5) result_frame.pack(fill="both", expand=True, padx=10, pady=5) columns = ("单号", "处理状态", "重量", "产品", "客户", "寄达地区划代码", "寄件人姓名", "寄件地址") self.result_tree = ttk.Treeview(result_frame, columns=columns, show="headings", height=8) col_widths = [140, 80, 60, 110, 110, 110, 80, 160] for col, width in zip(columns, col_widths): self.result_tree.heading(col, text=col) self.result_tree.column(col, width=width, anchor="center") self.result_tree.pack(side="left", fill="both", expand=True) scroll_y = Scrollbar(result_frame, orient=VERTICAL, command=self.result_tree.yview) scroll_y.pack(side=RIGHT, fill=Y) self.result_tree.configure(yscrollcommand=scroll_y.set) # ─── 配置管理 ─────────────────────────────────────────── def _load_config(self): """加载配置文件。首次运行时弹出免责声明,同意后从 _MEIPASS 复制到 exe 目录。""" try: self._loading_config = True config_path = _get_config_path() # 首次运行:config.json 不存在,显示免责声明 if not os.path.exists(config_path): disclaimer_path = _resource_path("免责声明.txt") try: with open(disclaimer_path, "r", encoding="utf-8") as f: disclaimer_text = f.read() except Exception: disclaimer_text = "免责声明文件未找到。使用本软件即表示您同意承担相关责任。" agreed = [False] def on_agree(): agreed[0] = True win.destroy() def on_disagree(): win.destroy() win = Toplevel(self.root) win.title("免责声明") win.geometry("500x420") win.transient(self.root) win.grab_set() win.resizable(False, False) Label(win, text="首次启动 — 请阅读免责声明", font=("", 11, "bold"), pady=10).pack() text_frame = Frame(win) text_frame.pack(fill="both", expand=True, padx=15, pady=5) text = Text(text_frame, wrap="word", font=("", 10), state=DISABLED) text.pack(side="left", fill="both", expand=True) text.configure(state=NORMAL) text.insert("1.0", disclaimer_text) text.configure(state=DISABLED) btn_frame = Frame(win) btn_frame.pack(pady=12) Button(btn_frame, text="同意", command=on_agree, bg="#4CAF50", fg="white", width=10, height=1).pack(side="left", padx=10) Button(btn_frame, text="不同意", command=on_disagree, bg="red", fg="white", width=10).pack(side="left", padx=10) win.protocol("WM_DELETE_WINDOW", on_disagree) win.wait_window() if not agreed[0]: self.root.destroy() sys.exit(0) # 复制默认 config 到可写目录 bundled = _resource_path("config.json") if os.path.exists(bundled): import shutil shutil.copy2(bundled, config_path) self.config = load_config(config_path) timing = get_timing_config(self.config) delay_ms = timing.get("delay_between_rows_ms", 500) self.delay_var.set(str(delay_ms)) # 加载固定值到编辑框 fv = self.config.get("fixed_values", {}) self.fv_product.set(fv.get("产品名称", {}).get("value", "")) self.fv_customer.set(fv.get("客户名称", {}).get("value", "")) self.fv_destination.set(fv.get("寄达地", {}).get("value", "")) self.fv_force_destination.set(fv.get("寄达地", {}).get("force", False)) self.fv_district_code.set(fv.get("寄达地区划", {}).get("value", "")) self.fv_force_district.set(fv.get("寄达地区划", {}).get("force", False)) self.fv_sender_name.set(fv.get("寄件人", {}).get("value", "")) self.fv_force_sender_name.set(fv.get("寄件人", {}).get("force", False)) self.fv_sender_addr.set(fv.get("寄件地址", {}).get("value", "")) self.fv_force_sender_addr.set(fv.get("寄件地址", {}).get("force", False)) self._loading_config = False # 检查已保存的登录会话 self._check_session_status() except Exception as e: self._log(f"[error] 配置加载失败: {e}") messagebox.showwarning("配置错误", f"配置文件加载失败:\n{e}") def _check_session_status(self): """检查已保存的登录会话状态并更新 UI。""" try: from login_helper import load_session_info info = load_session_info() if info and len(info.get("cookies", [])) > 0: self.engine_status_var.set(f"已登录 ({len(info['cookies'])} cookies)") self.status_label.configure(fg="green") else: self.engine_status_var.set("未登录") self.status_label.configure(fg="orange") except Exception: self.engine_status_var.set("未登录") self.status_label.configure(fg="orange") # ─── 固定值保存 ──────────────────────────────────────── def _save_fixed_from_entries(self): """从编辑框保存固定值到 config.json。""" new_fixed = { "产品名称": {"value": self.fv_product.get().strip(), "force": False}, "客户名称": {"value": self.fv_customer.get().strip(), "force": False}, "寄达地": {"value": self.fv_destination.get().strip(), "force": self.fv_force_destination.get()}, "寄达地区划": {"value": self.fv_district_code.get().strip(), "force": self.fv_force_district.get()}, "寄件人": {"value": self.fv_sender_name.get().strip(), "force": self.fv_force_sender_name.get()}, "寄件地址": {"value": self.fv_sender_addr.get().strip(), "force": self.fv_force_sender_addr.get()}, } self.config["fixed_values"] = new_fixed config_path = _get_config_path() with open(config_path, "r", encoding="utf-8") as f: cfg = json.load(f) cfg["fixed_values"] = new_fixed with open(config_path, "w", encoding="utf-8") as f: json.dump(cfg, f, ensure_ascii=False, indent=4) def _add_result_row(self, result): """添加一行到结果表格。""" values = ( result.get("单号", ""), result.get("status", ""), result.get("重量", ""), result.get("产品", ""), result.get("客户", ""), result.get("寄达地区划代码", ""), result.get("寄件人姓名", ""), result.get("寄件地址", ""), ) self.result_tree.insert("", "end", values=values) # ─── 产品/客户搜索 ────────────────────────────────────── def _init_search_infra(self): """在登录后(或重新登录后)初始化搜索基础设施(token bridge + session + api)。""" # 销毁旧的基础设施(可能是过期 cookies 的 session) if self._search_bridge: self._search_bridge.stop() self._search_bridge = None self._search_sess = None self._search_api = None try: from pickup_api import TokenBridge, PickupSession, PickupAPI config = load_config(_get_config_path()) url = config.get("system", {}).get("url", "https://10.4.188.1/pickup-web/a/postwaybill/main") self._search_bridge = TokenBridge() self._search_bridge.start() self._search_sess = PickupSession(url) session_path = _get_session_path() if not self._search_sess.load_session(session_path): self._search_bridge.stop() self._search_bridge = None self._search_sess = None self._log("[error] 搜索初始化失败:未找到登录会话") return resources = self._search_sess.fetch_page_resources() minimal_html = ( f"
" ) self._search_bridge.initialize(minimal_html, resources["scripts"], resources["base_url"]) self._search_api = PickupAPI(self._search_sess, self._search_bridge) try: self._search_api.query_ecommerce("B") self._search_api.query_deliver_date() self._search_api.check_sheet_warning() except Exception: pass except Exception as e: self._log(f"[error] 搜索初始化失败: {e}") if self._search_bridge: self._search_bridge.stop() self._search_bridge = None self._search_sess = None def _on_product_change(self, *args): if self._loading_config: return if self._search_debounce: self.root.after_cancel(self._search_debounce) keyword = self.fv_product.get().strip() if not keyword or len(keyword) < 1: return self._search_debounce = self.root.after(300, lambda: self._do_product_search(keyword)) def _on_customer_change(self, *args): if self._loading_config: return if self._search_debounce: self.root.after_cancel(self._search_debounce) keyword = self.fv_customer.get().strip() if not keyword or len(keyword) < 1: return self._search_debounce = self.root.after(300, lambda: self._do_customer_search(keyword)) def _do_product_search(self, keyword: str): if self._search_api is None: return try: candidates = self._search_api.query_product(keyword) if not candidates: return names = [c.get("productName", "") for c in candidates] self._show_search_dropdown( self.fv_product_entry, names, lambda idx: self._on_product_selected(candidates[idx]), ) except Exception as e: self._log(f"[error] 产品搜索失败: {e}") def _do_customer_search(self, keyword: str): if self._search_api is None: return try: candidates = self._search_api.query_customer(keyword) if not candidates: return names = [ c.get("customerFullName", c.get("customerName", "")) for c in candidates ] self._show_search_dropdown( self.fv_customer_entry, names, lambda idx: self._on_customer_selected(candidates[idx]), ) except Exception as e: self._log(f"[error] 客户搜索失败: {e}") def _show_search_dropdown(self, entry: Entry, options: list[str], on_select): """在输入框正下方弹出下拉候选列表。""" popup = Toplevel(self.root) popup.overrideredirect(True) popup.attributes("-topmost", True) # 定位在输入框正下方 x = entry.winfo_rootx() y = entry.winfo_rooty() + entry.winfo_height() w = entry.winfo_width() popup.geometry(f"{w}x180+{x}+{y}") frame = Frame(popup, bd=1, relief="solid") frame.pack(fill="both", expand=True) listbox = Listbox(frame, font=("", 10), selectmode="browse", activestyle="none") scroll = Scrollbar(frame, orient=VERTICAL, command=listbox.yview) listbox.configure(yscrollcommand=scroll.set) scroll.pack(side=RIGHT, fill=Y) listbox.pack(side=LEFT, fill=BOTH, expand=True) for opt in options: listbox.insert(END, opt) def select(e=None): sel = listbox.curselection() if sel: popup.destroy() on_select(sel[0]) def cancel(e=None): popup.destroy() listbox.bind("