"""中国邮政揽收系统 - 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, 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 # ─── 全局引擎引用 ────────────────────────────────────────── _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 _get_session_path(): return _resource_path("session.json") # ─── 异步引擎后台线程 ────────────────────────────────────── 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="3") self.status_var = StringVar(value="就绪") self.engine_status_var = StringVar(value="未启动") self.running = False self.paused = False self.results = [] self._build_ui() # 关闭窗口时清理 self.root.protocol("WM_DELETE_WINDOW", self._on_close) # 将 log 模块输出重定向到 GUI 日志窗口 from log import set_gui_callback set_gui_callback(self._log) 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) self.config_btn = Button( btn_frame, text="编辑配置", command=self._edit_config, width=10 ) self.config_btn.pack(side="right", padx=2) # 状态 Label( btn_frame, textvariable=self.status_var, fg="blue" ).pack(side="right", padx=10) # ── 固定值设置 ── fixed_frame = LabelFrame(self.root, text="固定值设置(优先级:强制覆盖 > API获取值 > 固定值 > 表格 > 默认值)", padx=5, pady=5) fixed_frame.pack(fill=X, padx=10, pady=5) tree_f = Frame(fixed_frame) tree_f.pack(fill=X) columns = ("field", "value", "force") self.fixed_tree = ttk.Treeview(tree_f, columns=columns, show="headings", height=4) self.fixed_tree.heading("field", text="字段名") self.fixed_tree.heading("value", text="固定值") self.fixed_tree.heading("force", text="强制覆盖") self.fixed_tree.column("field", width=100) self.fixed_tree.column("value", width=260) self.fixed_tree.column("force", width=70, anchor="center") self.fixed_tree.pack(side="left", fill=X, expand=True) btn_f = Frame(fixed_frame) btn_f.pack(fill=X, pady=2) Button(btn_f, text="添加", command=self._add_fixed_value, width=6).pack(side="left", padx=2) Button(btn_f, text="删除", command=lambda: [self.fixed_tree.delete(i) for i in self.fixed_tree.selection()], width=6).pack(side="left", padx=2) Button(btn_f, text="保存", command=self._save_fixed_values, width=6, bg="#4CAF50", fg="white").pack(side="left", padx=2) Label(btn_f, text="双击编辑值 | 单击'强制覆盖'列切换 | 留空则不生效", fg="gray").pack(side="left", padx=5) self.fixed_tree.bind("", self._edit_fixed_cell) self.fixed_tree.bind("", self._toggle_force) # ── 进度条 ── 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() # ── 日志区 ── log_frame = LabelFrame(self.root, text="运行日志", padx=10, pady=5) log_frame.pack(fill="both", expand=True, padx=10, pady=5) self.log_text = Text(log_frame, wrap="word", state=DISABLED, font=("Consolas", 9)) self.log_text.pack(side="left", fill="both", expand=True) scroll_y = Scrollbar(log_frame, orient=VERTICAL, command=self.log_text.yview) scroll_y.pack(side=RIGHT, fill=Y) self.log_text.configure(yscrollcommand=scroll_y.set) # ─── 配置管理 ─────────────────────────────────────────── def _load_config(self): """加载配置文件。首次运行时从 _MEIPASS 复制到 exe 目录。""" try: config_path = _get_config_path() # 打包模式首次运行:从 _MEIPASS 复制 config.json 到可写目录 if getattr(sys, 'frozen', False) and not os.path.exists(config_path): bundled = os.path.join(sys._MEIPASS, "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_sec = timing.get("delay_between_rows_ms", 3000) // 1000 self.delay_var.set(str(delay_sec)) # 加载固定值到表格 for item in self.fixed_tree.get_children(): self.fixed_tree.delete(item) for field, cfg in self.config.get("fixed_values", {}).items(): val = cfg.get("value", "") force = "✓" if cfg.get("force") else "" self.fixed_tree.insert("", "end", values=(field, val, force)) self._log("配置加载成功") # 检查已保存的登录会话 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 _edit_config(self): """打开配置编辑窗口。""" config_path = _get_config_path() # os.startfile(config_path) if sys.platform == "win32" else None win = Toplevel(self.root) win.title("编辑配置") win.geometry("700x500") text = Text(win, wrap="none", font=("Consolas", 10)) text.pack(fill="both", expand=True, padx=5, pady=5) with open(config_path, "r", encoding="utf-8") as f: text.insert("1.0", f.read()) def save(): new_config = text.get("1.0", "end-1c") try: json.loads(new_config) # 验证 JSON with open(config_path, "w", encoding="utf-8") as f: f.write(new_config) self._load_config() win.destroy() self._log("配置已保存并重新加载") except json.JSONDecodeError as e: messagebox.showerror("JSON 错误", f"配置格式错误:\n{e}") Button(win, text="保存", command=save, bg="#4CAF50", fg="white").pack( side="bottom", pady=5 ) # ─── 固定值设置 ──────────────────────────────────────── def _edit_fixed_cell(self, event): """双击编辑固定值单元格(跳过强制覆盖列)。""" region = self.fixed_tree.identify_region(event.x, event.y) if region != "cell": return column = self.fixed_tree.identify_column(event.x) if column == "#3": # 强制覆盖列不双击编辑,单击切换 return item = self.fixed_tree.identify_row(event.y) if not item: return col_idx = int(column.replace("#", "")) - 1 old_val = self.fixed_tree.item(item, "values")[col_idx] edit = Entry(self.root) edit.insert(0, old_val) edit.select_range(0, "end") bbox = self.fixed_tree.bbox(item, column) if bbox: edit.place(x=bbox[0] + 185, y=bbox[1] + 310, width=bbox[2], height=bbox[3]) def save_edit(e=None): vals = list(self.fixed_tree.item(item, "values")) vals[col_idx] = edit.get() self.fixed_tree.item(item, values=vals) edit.destroy() edit.bind("", save_edit) edit.bind("", lambda e: edit.destroy()) edit.focus_set() def _add_fixed_value(self): """添加新的固定值行(默认不强制覆盖)。""" self.fixed_tree.insert("", "end", values=("新固定值", "", "")) def _toggle_force(self, event): """单击'强制覆盖'列切换开关状态。""" region = self.fixed_tree.identify_region(event.x, event.y) if region != "cell": return column = self.fixed_tree.identify_column(event.x) if column != "#3": # force 是第3列 return item = self.fixed_tree.identify_row(event.y) if not item: return vals = list(self.fixed_tree.item(item, "values")) vals[2] = "" if vals[2] == "✓" else "✓" self.fixed_tree.item(item, values=vals) def _save_fixed_values(self): """保存固定值到 config.json(新格式:{value, force})。""" new_fixed = {} for item in self.fixed_tree.get_children(): vals = self.fixed_tree.item(item, "values") field_name = vals[0].strip() if field_name: new_fixed[field_name] = { "value": vals[1].strip() if len(vals) > 1 else "", "force": vals[2] == "✓" if len(vals) > 2 else False, } 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) self._log(f"固定值已保存: {len(new_fixed)} 项") # ─── 文件选择 ─────────────────────────────────────────── def _pick_file(self): path = filedialog.askopenfilename( title="选择 Excel 文件", filetypes=[("Excel 文件", "*.xlsx"), ("所有文件", "*.*")], ) if path: self.excel_path.set(path) self.file_label.configure(text=path, fg="black") def _on_close(self): """关闭窗口;atexit 会强制清理 Node.js 子进程。""" global _engine if _engine: _engine.request_stop() self.root.destroy() # ─── 控制按钮 ─────────────────────────────────────────── def _do_login(self): """弹窗让用户粘贴一键导出的 cookies JSON。""" from login_helper import parse_json_and_save, load_session_info win = Toplevel(self.root) win.title("登录揽收系统") win.geometry("620x360") win.transient(self.root) win.grab_set() Label(win, text="登录揽收系统", font=("", 12, "bold"), pady=8).pack() Label(win, text="使用 Cookies 导出工具,将 10.4.188.1 的所有 cookies 导出为 JSON,粘贴到下方:").pack(anchor="w", padx=10) json_text = Text(win, height=12, wrap="word", font=("Consolas", 9)) json_text.pack(fill=BOTH, expand=True, padx=10, pady=5) def do_save(): try: n = parse_json_and_save(json_text.get("1.0", "end-1c")) self._log(f"已保存 {n} 个 cookies 到 session.json") self.engine_status_var.set("已登录") self.status_label.configure(fg="green") self._check_session_status() win.destroy() except Exception as e: messagebox.showerror("解析失败", f"JSON 解析失败:\n{e}", parent=win) btn_f = Frame(win) btn_f.pack(pady=10) Button(btn_f, text="保存并关闭", command=do_save, bg="#4CAF50", fg="white", width=15, height=1).pack() info = load_session_info() if info: Label(win, text=f"当前已保存: {len(info.get('cookies', []))} 个 cookies", fg="gray").pack() def _start(self): if self.running: return if not self.excel_path.get(): messagebox.showwarning("提示", "请先选择 Excel 文件") return try: rows, headers = read_input_excel(self.excel_path.get()) except Exception as e: messagebox.showerror("读取错误", f"读取 Excel 失败:\n{e}") return if not rows: messagebox.showwarning("提示", "Excel 中没有数据") return # 验证必要字段是否有数据来源(固定值 > Excel > 默认值) fixed_values = get_fixed_values(self.config) defaults = get_defaults(self.config) missing = validate_required_columns(headers, fixed_values, defaults) if missing: msg = ( f"以下字段在所有来源中均未找到值:\n" + "\n".join(f" - {c}(请填入Excel / 固定值 / 默认值任一)" for c in missing) ) messagebox.showwarning("缺少必要字段", msg) return self.running = True self.paused = False self._set_controls_state("running") # 创建结果工作簿 self.results = [] # 更新配置中的延时 try: delay_ms = int(self.delay_var.get()) * 1000 self.config.setdefault("timing", {})["delay_between_rows_ms"] = delay_ms except ValueError: pass self._log(f"开始处理 {len(rows)} 条记录...") self.progress["maximum"] = len(rows) self.progress["value"] = 0 self.progress_label.configure(text=f"0 / {len(rows)}") # 在后台线程启动异步引擎 _run_in_thread(self._run_engine(rows, headers)) async def _run_engine(self, rows, headers): """后台运行引擎(在 async 线程中执行)。""" global _engine try: from engine import PickupEngine field_mapping = get_field_mapping(self.config) defaults = get_defaults(self.config) timing = get_timing_config(self.config) fixed_values = get_fixed_values(self.config) _engine = PickupEngine( url="https://10.4.188.1/pickup-web/a/postwaybill/main", field_mapping=field_mapping, defaults=defaults, timing=timing, log_callback=self._log_async, fixed_values=fixed_values, choose_callback=self._choose_callback, confirm_callback=self._confirm_callback, ) self._update_status("启动中...") try: await _engine.start() except Exception as e: self._log_async(f"[error] 启动失败: {e}") self._update_status("启动失败") await _engine.stop() return # 验证 engine.start() 中已加载了 session,且包含关键 cookie if not _engine._sess or not _engine._sess._http.cookies: self._log_async("[提示] 未找到登录会话,请点击'登录'按钮完成登录后再开始处理") self._update_status("未登录") await _engine.stop() return cookie_names = {c.name for c in _engine._sess._http.cookies} missing = [n for n in ("JSESSIONID", "jdpt.session.id") if n not in cookie_names] if missing: self._log_async(f"[提示] 缺少关键 cookie: {', '.join(missing)},请重新登录并手动填写") self._update_status("未登录") await _engine.stop() return # 初始化页面资源和 token bridge try: await _engine.begin_processing() self._update_status("后台运行中") self._log_async("HTTP API 引擎已就绪") except Exception as e: self._log_async(f"[error] 初始化失败: {e}") self._update_status("启动失败") await _engine.stop() return for i, row in enumerate(rows): if _engine._stop_requested: break result = await _engine.process_single_row(row) self.results.append(result) # 更新进度 self.root.after(0, lambda cur=i+1, total=len(rows): self._update_progress(cur, total)) # 行间延时 delay = timing.get("delay_between_rows_ms", 3000) / 1000 if i < len(rows) - 1: await asyncio.sleep(delay) self._log_async(f"\n处理完成!共处理 {len(self.results)} 条记录") except Exception as e: self._log_async(f"[error] 引擎异常: {e}") import traceback traceback.print_exc() finally: if _engine: await _engine.stop() self.root.after(0, self._on_finished) def _update_progress(self, current, total): self.progress["value"] = current self.progress_label.configure(text=f"{current} / {total}") self.status_var.set(f"处理中: {current}/{total}") def _on_finished(self): global _engine self.running = False self.paused = False _engine = None self._set_controls_state("stopped") self.status_var.set("就绪") self._log("处理结束") def _pause(self): global _engine if not self.running: return if self.paused: self.paused = False if _engine: _engine.resume() self.pause_btn.configure(text="⏸ 暂停") self.status_var.set("处理中...") self._log("已恢复") else: self.paused = True if _engine: _engine.request_pause() self.pause_btn.configure(text="▶ 继续") self.status_var.set("已暂停") self._log("已暂停") def _stop(self): global _engine self.running = False if _engine: _engine.request_stop() self._set_controls_state("stopped") self.status_var.set("正在停止...") self._log("用户停止") def _set_controls_state(self, state): if state == "running": self.start_btn.configure(state=DISABLED) self.pause_btn.configure(state=NORMAL) self.stop_btn.configure(state=NORMAL) else: self.start_btn.configure(state=NORMAL) self.pause_btn.configure(state=DISABLED, text="⏸ 暂停") self.stop_btn.configure(state=DISABLED) # ─── 日志 ─────────────────────────────────────────────── def _log(self, msg): """线程安全的日志输出。""" self.root.after(0, self._log_sync, msg) def _log_async(self, msg): """从异步线程调用(直接入队到 GUI 线程)。""" self.root.after(0, self._log_sync, msg) def _update_status(self, status: str): """更新引擎状态显示。""" color_map = {"后台运行中": "green", "启动失败": "red", "未登录": "orange"} self.root.after(0, lambda: self.engine_status_var.set(status)) self.root.after(0, lambda s=status: self.status_label.configure( fg=color_map.get(s, "gray") )) async def _choose_callback(self, title: str, options: list[str]) -> int | None: """弹出选择框让用户从 autocomplete 结果中选择。返回索引或 None。""" loop = asyncio.get_event_loop() future = loop.create_future() result = {"index": None} def show(): win = Toplevel(self.root) win.title(title) win.geometry("400x300") win.transient(self.root) win.grab_set() Label(win, text=f"请选择{title},选择后批量复用:", pady=10).pack() frame = Frame(win) frame.pack(fill="both", expand=True, padx=10) listbox = Listbox(frame) 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 i, opt in enumerate(options): listbox.insert(END, f"[{i+1}] {opt}") def on_select(): sel = listbox.curselection() if sel: result["index"] = sel[0] win.destroy() listbox.bind("", lambda e: on_select()) # 关闭窗口 → result["index"] 为 None → engine 停止 batch def on_cancel(): result["index"] = None win.destroy() btn_frame = Frame(win) btn_frame.pack(pady=10) Button(btn_frame, text="确认选择", command=on_select, bg="#4CAF50", fg="white", width=12).pack(side=LEFT, padx=5) Button(btn_frame, text="取消", command=on_cancel, bg="red", fg="white", width=8).pack(side=LEFT, padx=5) win.protocol("WM_DELETE_WINDOW", on_cancel) listbox.focus_set() # 等窗口关闭后通知 async 线程 def wait_then_resolve(): win.wait_window() loop.call_soon_threadsafe(lambda: future.set_result(result["index"])) threading.Thread(target=wait_then_resolve, daemon=True).start() self.root.after(0, show) return await future async def _confirm_callback(self, fields: dict[str, str]) -> bool: """弹出确认框显示表单数据,用户确认后返回 True。""" loop = asyncio.get_event_loop() future = loop.create_future() result = {"confirmed": False} def show(): win = Toplevel(self.root) win.title("确认提交信息 - 确认后自动批量处理") win.geometry("450x400") win.transient(self.root) win.grab_set() Label(win, text="请确认以下信息无误后点击确认:", pady=10, font=("", 10, "bold")).pack() frame = Frame(win) frame.pack(fill=BOTH, expand=True, padx=20, pady=5) text = Text(frame, wrap="word", font=("Consolas", 10), height=16) text.pack(side=LEFT, fill=BOTH, expand=True) scroll = Scrollbar(frame, command=text.yview) scroll.pack(side=RIGHT, fill=Y) text.configure(yscrollcommand=scroll.set) # 系统标记(不展示) from engine import _SYSTEM_FLAGS sys_keys = set(_SYSTEM_FLAGS.keys()) # 关键字段:始终展示,值也翻译为中文 from field_mapping import reverse_translate key_fields = [ ("产品名称", "bizProductName", ""), ("客户名称", "senders", ""), ("客户代码", "senderNo", ""), ("邮件号", "waybillNo", ""), ("重量(g)", "realWeight", ""), ("寄达地", "receiverArriveOrgName", ""), ("收件人", "receiverLinker", ""), ("收件人手机", "receiverMobile", ""), ("收件人地址", "receiverAddr", ""), ("寄件人", "senderLinker", ""), ("寄件人手机", "senderMobile", ""), ("寄件人地址", "senderAddr", ""), ("运输类型", "transferType", "运输类型"), ("内件属性", "contentsAttribute", "内件属性"), ("电商平台", "ecommerceNo", "电商平台"), ("付款方式", "paymentMode", "付款方式"), ("内件品名", "reserved5", ""), ("保价金额", "insuranceAmount", ""), ("代收货款", "codAmount", ""), ("备注", "senderNotes", ""), ] if not isinstance(fields, dict): text.insert(END, f"错误: 表单数据异常 ({type(fields).__name__})") text.configure(state=DISABLED) return shown = set() for label, key, col_name in key_fields: val = fields.get(key, "") or "空" if col_name and val != "空": val = reverse_translate(col_name, val) text.insert(END, f"{label}: {val}\n") shown.add(key) # 展示其余非系统标记的有值字段 for k, v in fields.items(): if v and k not in shown and k not in sys_keys: text.insert(END, f"{k}: {v}\n") text.configure(state=DISABLED) btn_frame = Frame(win) btn_frame.pack(pady=10) Button(btn_frame, text="确认提交", command=lambda: (result.__setitem__("confirmed", True), win.destroy()), bg="#4CAF50", fg="white", width=12, height=1).pack(side=LEFT, padx=10) Button(btn_frame, text="取消", command=win.destroy, bg="red", fg="white", width=10).pack(side=LEFT, padx=10) def wait_then_resolve(): win.wait_window() loop.call_soon_threadsafe(lambda: future.set_result(result["confirmed"])) threading.Thread(target=wait_then_resolve, daemon=True).start() self.root.after(0, show) return await future def _log_sync(self, msg): timestamp = datetime.now().strftime("%H:%M:%S") line = f"[{timestamp}] {msg}\n" self.log_text.configure(state=NORMAL) self.log_text.insert(END, line) self.log_text.see(END) self.log_text.configure(state=DISABLED) # ─── 入口 ────────────────────────────────────────────────── def main(): app = App() app.root.mainloop() if __name__ == "__main__": main()