初始化项目 v2.0:中国邮政揽收系统自动填单工具
基于 HTTP API + Node.js token bridge 方案,不依赖浏览器自动化。
This commit is contained in:
@@ -0,0 +1,813 @@
|
||||
"""中国邮政揽收系统 - 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("<Double-1>", self._edit_fixed_cell)
|
||||
self.fixed_tree.bind("<ButtonRelease-1>", 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("<Return>", save_edit)
|
||||
edit.bind("<FocusOut>", 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("<Double-1>", 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()
|
||||
Reference in New Issue
Block a user