#!/usr/bin/env python3 """Local web UI for railway invoice automation.""" from __future__ import annotations import base64 import cgi import csv import json import os import re import sys import shutil import subprocess import threading import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import urlparse import invoice_tool import calibrate PROJECT_ROOT = invoice_tool.PROJECT_ROOT PYTHON = sys.executable RUNNER: subprocess.Popen[str] | None = None RUNNER_LOCK = threading.Lock() RUNNER_LOG = PROJECT_ROOT / "work" / "web_runner.log" RUNNER_STOPPED_BY_USER: dict[str, object] | None = None UPLOAD_DIR = PROJECT_ROOT / "data" / "input" def clear_runner_log(reason: str = "") -> None: RUNNER_LOG.parent.mkdir(parents=True, exist_ok=True) header = f"[log cleared] {reason}\n" if reason else "" RUNNER_LOG.write_text(header, encoding="utf-8") def csv_rows(path: Path) -> list[dict[str, str]]: if not path.exists(): return [] with path.open("r", encoding="utf-8-sig", newline="") as fh: return list(csv.DictReader(fh)) def backup_runtime_tables(label: str = "start") -> dict[str, object]: """启动批处理前备份运行期三张表。""" stamp = time.strftime("%Y%m%d-%H%M%S") backup_dir = PROJECT_ROOT / "archive" / "runtime-backups" / f"{stamp}-{label}" copied: list[str] = [] for path in [invoice_tool.PROGRESS_CSV, invoice_tool.QR_ORDER_CSV, invoice_tool.PAGE_QUEUE_CSV]: if not path.exists(): continue backup_dir.mkdir(parents=True, exist_ok=True) dest = backup_dir / path.name shutil.copy2(path, dest) copied.append(str(dest)) return {"dir": str(backup_dir), "files": copied} def write_json(handler: BaseHTTPRequestHandler, data: object, status: int = 200) -> None: body = json.dumps(data, ensure_ascii=False).encode("utf-8") handler.send_response(status) handler.send_header("Content-Type", "application/json; charset=utf-8") handler.send_header("Content-Length", str(len(body))) handler.end_headers() handler.wfile.write(body) def device_status() -> dict[str, object]: state = invoice_tool.run_adb(["get-state"], check=False) connected = state.returncode == 0 and state.stdout.strip() == "device" status: dict[str, object] = { "connected": connected, "state": state.stdout.strip() or "unknown", "current_package": "", "current_activity": "", "in_12306": False, "message": "手机已连接" if connected else (state.stderr.strip() or state.stdout.strip() or "未检测到手机"), } if not connected: return status window = invoice_tool.run_adb(["shell", "dumpsys", "window"], check=False) focus_lines = [line.strip() for line in window.stdout.splitlines() if "mCurrentFocus=" in line or "mFocusedApp=" in line] focus_text = " ".join(focus_lines) match = re.search(r"u0\s+([^/\s]+)/([^\s}]+)", focus_text) if match: status["current_package"] = match.group(1) status["current_activity"] = match.group(2) status["in_12306"] = match.group(1) == "com.MobileTicket" status["message"] = "当前在 12306" if status["in_12306"] else f"当前在 {match.group(1)}" else: status["message"] = "手机已连接,未识别当前前台 App" return status PROGRESS_LABELS = { "completed": {"text": "已开票", "tone": "ok", "reason": ""}, "no_ticket": {"text": "无可开票", "tone": "muted", "reason": "12306 未查询到可开发票客票"}, "in_progress": {"text": "处理中", "tone": "info", "reason": ""}, "failed": {"text": "失败", "tone": "bad", "reason": "上次运行未完成"}, "error": {"text": "运行错误", "tone": "bad", "reason": "脚本异常"}, "pending": {"text": "未操作", "tone": "pending", "reason": ""}, } MATCH_REASON = { "no_match": "名单里没有这个脱敏名", "ambiguous": "同名多人,无法唯一匹配", "no_button": "未识别到「开具」按钮", } def label_for_queue_row(row: dict[str, str], progress_by_key: dict, progress_by_masked: dict) -> dict[str, str]: masked = row.get("masked_name", "") real = row.get("real_name", "") id8 = row.get("id_last8", "") queue_status = row.get("queue_status", "") match_status = row.get("match_status", "") progress_row: dict[str, str] | None = None if real and id8: progress_row = progress_by_key.get((real, id8)) if progress_row is None and masked: progress_row = progress_by_masked.get(masked) pstatus = progress_row.get("status", "") if progress_row else "" progress_reason = progress_row.get("reason", "") if progress_row else "" if pstatus == "completed": return {"display": "已开票", "tone": "ok", "reason": progress_reason or "总进度表已完成"} if pstatus == "no_ticket": return {"display": "无可开票", "tone": "muted", "reason": progress_reason or "12306 未查询到可开发票客票"} if pstatus == "in_progress" or queue_status == "clicked": return {"display": "处理中", "tone": "info", "reason": progress_reason or "脚本正在处理这行"} if pstatus == "failed": return {"display": "失败", "tone": "bad", "reason": progress_reason or "上次运行未完成"} if pstatus == "error": return {"display": "运行错误", "tone": "bad", "reason": progress_reason or "脚本异常"} if match_status in MATCH_REASON: return {"display": "识别问题", "tone": "warn", "reason": MATCH_REASON[match_status]} if match_status == "runtime_error": return {"display": "运行错误", "tone": "bad", "reason": "上次处理异常"} if match_status == "skip_progress": return {"display": "已处理", "tone": "muted", "reason": "进度表已记录,跳过"} if match_status == "resume_in_progress": return {"display": "继续中", "tone": "info", "reason": "上次未完成,本次继续"} if queue_status == "pending": return {"display": "待处理", "tone": "pending", "reason": ""} if queue_status == "skipped": return {"display": "跳过", "tone": "muted", "reason": match_status or ""} return {"display": queue_status or "未知", "tone": "muted", "reason": match_status or ""} def label_for_progress_status(status: str) -> dict[str, str]: return PROGRESS_LABELS.get(status, {"text": status or "未操作", "tone": "pending", "reason": ""}) def status_payload() -> dict[str, object]: roster = csv_rows(invoice_tool.ROSTER_CSV) progress = csv_rows(invoice_tool.PROGRESS_CSV) queue_raw = csv_rows(invoice_tool.PAGE_QUEUE_CSV) qr_order_raw = csv_rows(invoice_tool.QR_ORDER_CSV) by_person_key = { (row.get("real_name", ""), row.get("id_last8", "")): row for row in progress if row.get("real_name") or row.get("id_last8") } by_real_name = {row.get("real_name", ""): row for row in progress if row.get("real_name")} by_masked = {row.get("masked_name", ""): row for row in progress if row.get("masked_name")} roster_status = [] counts = {"total": len(roster), "completed": 0, "failed": 0, "in_progress": 0, "pending": 0} queue = [] for row in queue_raw: label = label_for_queue_row(row, by_person_key, by_masked) item = dict(row) item["display_status"] = label["display"] item["display_tone"] = label["tone"] item["display_reason"] = label["reason"] queue.append(item) qr_order = [] for row in qr_order_raw: real = row.get("real_name", "") id8 = row.get("id_last8", "") masked = row.get("masked_name", "") progress_row = by_person_key.get((real, id8)) if (real or id8) else None if progress_row is None and masked: progress_row = by_masked.get(masked) status = progress_row.get("status", "pending") if progress_row else "pending" if progress_row is None and not (real or id8): label = {"text": "名单未匹配", "tone": "warn", "reason": ""} status = "no_match" else: label = label_for_progress_status(status) item = dict(row) item["status"] = status item["display_status"] = label["text"] item["display_tone"] = label["tone"] item["display_reason"] = progress_row.get("reason", label.get("reason", "")) if progress_row else label.get("reason", "") item["updated_at"] = progress_row.get("updated_at", "") if progress_row else "" qr_order.append(item) counts["no_ticket"] = 0 for idx, person in enumerate(roster): person_key = (person.get("姓名", ""), person.get("身份证后8位", "")) progress_row = by_person_key.get(person_key) or by_real_name.get(person.get("姓名", "")) status = progress_row.get("status", "pending") if progress_row else "pending" label = PROGRESS_LABELS.get(status, {"text": status or "未操作", "tone": "pending", "reason": ""}) if status == "completed": counts["completed"] += 1 elif status == "no_ticket": counts["no_ticket"] += 1 elif status in {"failed", "error"}: counts["failed"] += 1 elif status == "in_progress": counts["in_progress"] += 1 else: counts["pending"] += 1 # progress 行号 = 开票处理顺序;未在 progress 的用大数排最后 progress_idx = next((i for i, r in enumerate(progress) if r is progress_row), len(progress)) roster_status.append( { "name": person.get("姓名", ""), "id_last8": person.get("身份证后8位", ""), "status": status, "display_status": label["text"], "display_tone": label["tone"], "masked_name": progress_row.get("masked_name", "") if progress_row else "", "updated_at": progress_row.get("updated_at", "") if progress_row else "", "reason": progress_row.get("reason", "") if progress_row else label.get("reason", ""), "_progress_idx": progress_idx, "_roster_idx": idx, } ) # 完全按开票处理顺序(progress 行号)排列,未处理的按名单顺序放最后 roster_status.sort(key=lambda r: (r.get("_progress_idx", 0), r.get("_roster_idx", 0))) for r in roster_status: r.pop("_progress_idx", None) r.pop("_roster_idx", None) with RUNNER_LOCK: running = RUNNER is not None and RUNNER.poll() is None return_code = None if RUNNER is None else RUNNER.poll() stopped_by_user = bool( RUNNER_STOPPED_BY_USER and RUNNER is not None and RUNNER_STOPPED_BY_USER.get("pid") == RUNNER.pid ) log_tail = "" if RUNNER_LOG.exists(): log_tail = "\n".join(RUNNER_LOG.read_text(encoding="utf-8", errors="ignore").splitlines()[-80:]) runner_status = "running" if running else "stopped" if (not running) and return_code not in (None, 0) and not stopped_by_user: runner_status = "failed" config = invoice_tool.load_config(invoice_tool.CONFIG_LOCAL_PATH) return { "counts": counts, "roster": roster_status, "queue": queue, "qr_order": qr_order, "progress": progress, "runner": {"running": running, "status": runner_status, "return_code": return_code, "stopped_by_user": stopped_by_user, "log_tail": log_tail}, "device": device_status(), "config": { "company_title": config.company_title, "tax_id": config.tax_id, "receiver_email": config.receiver_email, "invoice_output_dir": config.invoice_output_dir, "invoice_success_action": config.invoice_success_action, "invoice_output_absolute_dir": str(invoice_tool.project_path(config.invoice_output_dir)), }, "files": { "roster": str(invoice_tool.ROSTER_CSV), "progress": str(invoice_tool.PROGRESS_CSV), "queue": str(invoice_tool.PAGE_QUEUE_CSV), "qr_order": str(invoice_tool.QR_ORDER_CSV), }, } def save_invoice_settings(invoice_success_action: str, invoice_output_dir: str) -> dict[str, object]: allowed_actions = {"download", "email", "continue"} action = invoice_success_action if invoice_success_action in allowed_actions else "download" output_dir = invoice_output_dir.strip() or "output/invoices" config_path = invoice_tool.CONFIG_LOCAL_PATH data: dict[str, object] = {} if config_path.exists(): data = json.loads(config_path.read_text(encoding="utf-8")) data["invoice_success_action"] = action data["invoice_output_dir"] = output_dir config_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") project_dir = invoice_tool.project_path(output_dir) project_dir.mkdir(parents=True, exist_ok=True) return {"saved": True, "invoice_success_action": action, "invoice_output_dir": output_dir, "absolute_dir": str(project_dir)} def start_runner(max_batches: int, max_actions: int, send_email: bool, fast: bool, dry_run: bool = False, scroll_top_first: bool = True, mode: str = "h5", invoice_success_action: str = "", invoice_output_dir: str = "", final_retry_pass: bool = True) -> dict[str, object]: global RUNNER, RUNNER_STOPPED_BY_USER with RUNNER_LOCK: if RUNNER is not None and RUNNER.poll() is None: return {"started": False, "reason": "runner_already_running"} backup = backup_runtime_tables("start") RUNNER_STOPPED_BY_USER = None RUNNER_LOG.parent.mkdir(parents=True, exist_ok=True) RUNNER_LOG.write_text("", encoding="utf-8") log_fh = RUNNER_LOG.open("a", encoding="utf-8") if backup["files"]: log_fh.write(f"[prelude] runtime_backup dir={backup['dir']} files={len(backup['files'])}\n") log_fh.flush() prelude: list[str] = [] if scroll_top_first: top_result = subprocess.run( [PYTHON, "scripts/invoice_tool.py", "android-page-top"], cwd=PROJECT_ROOT, capture_output=True, text=True, check=False, ) log_fh.write(f"[prelude] android-page-top rc={top_result.returncode}\n{top_result.stdout}{top_result.stderr}\n") log_fh.flush() prelude.append(f"scroll_top: {top_result.stdout.strip().splitlines()[-1] if top_result.stdout.strip() else 'no_output'}") if mode == "h5": sub_cmd = "android-h5-page-run" else: sub_cmd = "android-vision-page-run" cmd = [PYTHON, "-u", "scripts/invoice_tool.py", sub_cmd, "--max-batches", str(max_batches)] if mode == "h5": cmd.append("--no-resume-after-last-progress") if max_actions > 0: cmd += ["--max-actions", str(max_actions)] if send_email: cmd.append("--send-email") if invoice_success_action: cmd += ["--invoice-success-action", invoice_success_action] if invoice_output_dir: cmd += ["--invoice-output-dir", invoice_output_dir] if fast: cmd.append("--fast") if dry_run: cmd.append("--dry-run") if not final_retry_pass: cmd.append("--no-final-retry-pass") RUNNER = subprocess.Popen(cmd, cwd=PROJECT_ROOT, stdout=log_fh, stderr=subprocess.STDOUT, text=True) return {"started": True, "pid": RUNNER.pid, "cmd": cmd, "prelude": prelude, "mode": mode, "backup": backup} def stop_runner() -> dict[str, object]: global RUNNER, RUNNER_STOPPED_BY_USER with RUNNER_LOCK: if RUNNER is None or RUNNER.poll() is not None: return {"stopped": False, "reason": "runner_not_running"} RUNNER_STOPPED_BY_USER = {"pid": RUNNER.pid, "at": time.strftime("%Y-%m-%d %H:%M:%S")} RUNNER.terminate() for _ in range(10): if RUNNER.poll() is not None: return {"stopped": True, "stopped_by_user": True} time.sleep(0.2) RUNNER.kill() return {"stopped": True, "killed": True, "stopped_by_user": True} def build_vision_page_queue() -> dict[str, object]: """用视觉 API 截屏识别当前页乘客,匹配名单+进度,返回队列数据。""" try: parsed = invoice_tool.vision_list_rows() except Exception as exc: return {"error": str(exc), "rows": [], "meta": f"视觉识别失败: {exc}"} if not parsed.get("page_ok"): return {"error": "not_on_list_page", "rows": [], "meta": "当前不在扫码开票单列表页"} rows = parsed.get("rows") or [] roster = invoice_tool.read_roster() skip_names = invoice_tool.progress_names({"completed", "no_ticket", "failed", "in_progress"}) queue = [] for row in rows: masked = row.get("masked_name", "") id_first = row.get("id_first", "") or "" id_last = row.get("id_last", "") or "" real = "" id8 = "" if masked in skip_names: status, tone, reason = "已处理", "muted", "进度表已完成或跳过" else: matches = invoice_tool.match_roster(masked, roster, id_first, id_last) if not matches and (id_first or id_last): matches = invoice_tool.match_roster(masked, roster) if len(matches) == 1: status, tone, reason = "待处理", "pending", f"匹配: {matches[0].get('姓名', '')}" real = matches[0].get("姓名", "") id8 = matches[0].get("身份证后8位", "") elif len(matches) > 1: status, tone, reason = "识别问题", "warn", f"同名 {len(matches)} 人" else: status, tone, reason = "识别问题", "warn", "不在名单中" queue.append({ "masked_name": masked, "real_name": real, "id_last8": id8, "id_first": id_first, "id_last": id_last, "display_status": status, "display_tone": tone, "display_reason": reason, }) # 同步写入 page_queue CSV,防止定时 refresh() 覆盖 now = invoice_tool.datetime.now().strftime("%Y-%m-%d %H:%M:%S") csv_rows = [] for q in queue: csv_rows.append({ "page_id": f"vision_{now}", "masked_name": q["masked_name"], "id_first": q.get("id_first", ""), "id_last": q.get("id_last", ""), "real_name": q.get("real_name", ""), "id_last8": q.get("id_last8", ""), "button_bounds": "", "match_status": "matched" if q["display_tone"] == "pending" else "skip_progress", "queue_status": "pending" if q["display_tone"] == "pending" else "skipped", "attempts": "0", "updated_at": now, }) invoice_tool.write_page_queue(csv_rows) return { "rows": queue, "meta": f"本页 {len(rows)} 人 · scroll={parsed.get('scroll_position', '?')} · pending={sum(1 for q in queue if q['display_tone'] == 'pending')}", "scroll_position": parsed.get("scroll_position"), } def launch_railway_app() -> dict[str, object]: result = invoice_tool.run_adb( ["shell", "monkey", "-p", "com.MobileTicket", "-c", "android.intent.category.LAUNCHER", "1"], check=False, ) if result.returncode == 0: time.sleep(1) return { "return_code": result.returncode, "stdout": result.stdout.strip(), "stderr": result.stderr.strip(), "package": "com.MobileTicket", "device": device_status(), } INDEX_HTML = r""" 铁路系统发票批量开票

铁路系统发票批量开票

名单导入 → 手机页面识别 → 自动开票 → 进度记录

读取中 手机状态读取中 当前 App 读取中 📱 设备校准

运行控制台

只调整本地 UI 布局,所有按钮和接口保持原功能。

① 名单 Roster
② 参数与执行 Run
③ 二维码 QR
📋 粘贴二维码截图
每组换新二维码时,先粘贴截图保存到手机,再点“重新进入开票页”

保存设置

配置成功后的处理方式和本地保存目录。

填写说明保存目录可填相对项目路径或绝对路径。

0名单人数
0已开票
0无可开票
0失败
0处理中
0未操作

当前页队列

状态原因脱敏名姓名后8位

运行日志


      

本二维码核查表 下载本表

按当前二维码列表出现顺序展示,方便跑完后人工核查失败项。

#状态脱敏名姓名身份证后8位更新时间

总进度 下载进度表

状态姓名脱敏名身份证后8位更新时间
""" class Handler(BaseHTTPRequestHandler): def log_message(self, fmt: str, *args: object) -> None: return def do_HEAD(self) -> None: parsed = urlparse(self.path) if parsed.path == "/": body = INDEX_HTML.encode("utf-8") self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Cache-Control", "no-cache, no-store, must-revalidate") self.send_header("Pragma", "no-cache") self.send_header("Expires", "0") self.send_header("Content-Length", str(len(body))) self.end_headers() elif parsed.path == "/api/progress-csv" and invoice_tool.PROGRESS_CSV.exists(): self.send_response(200) self.send_header("Content-Type", "text/csv; charset=utf-8") self.send_header("Content-Disposition", f'attachment; filename="{invoice_tool.PROGRESS_CSV.name}"') self.send_header("Content-Length", str(invoice_tool.PROGRESS_CSV.stat().st_size)) self.end_headers() elif parsed.path == "/api/qr-order-csv" and invoice_tool.QR_ORDER_CSV.exists(): self.send_response(200) self.send_header("Content-Type", "text/csv; charset=utf-8") self.send_header("Content-Disposition", f'attachment; filename="{invoice_tool.QR_ORDER_CSV.name}"') self.send_header("Content-Length", str(invoice_tool.QR_ORDER_CSV.stat().st_size)) self.end_headers() elif parsed.path == "/api/status": self.send_response(200) self.send_header("Content-Type", "application/json; charset=utf-8") self.end_headers() elif parsed.path in {"/api/decode-qr", "/api/save-qr-image", "/api/open-on-phone", "/api/reopen-qr", "/api/vision-page-queue", "/api/mark-completed"}: self.send_response(200) self.send_header("Content-Type", "application/json; charset=utf-8") self.end_headers() else: self.send_response(404) self.end_headers() def do_GET(self) -> None: parsed = urlparse(self.path) if parsed.path == "/": body = INDEX_HTML.encode("utf-8") self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) elif parsed.path == "/calibrate": body = CALIBRATE_HTML.encode("utf-8") self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) elif parsed.path == "/api/calibrate/steps": write_json(self, calibrate.get_steps()) elif parsed.path == "/api/calibrate/screen": w, h = calibrate.get_screen_size() write_json(self, {"width": w, "height": h}) elif parsed.path == "/api/calibrate/capture-status": write_json(self, calibrate.get_capture_status()) elif parsed.path == "/api/calibrate/profile": write_json(self, calibrate.load_device_profile()) elif parsed.path.startswith("/api/calibrate/screenshot"): from urllib.parse import parse_qs qs = parse_qs(parsed.query) sid = qs.get("id", [""])[0] img = calibrate.CALIBRATION_DIR / f"{sid}.png" if img.exists(): data = img.read_bytes() self.send_response(200) self.send_header("Content-Type", "image/png") self.send_header("Content-Length", str(len(data))) self.end_headers() self.wfile.write(data) else: write_json(self, {"error": "not_found"}, 404) elif parsed.path == "/api/status": write_json(self, status_payload()) elif parsed.path == "/api/device-status": write_json(self, device_status()) elif parsed.path == "/api/progress-csv": self.send_file(invoice_tool.PROGRESS_CSV, "text/csv; charset=utf-8") elif parsed.path == "/api/qr-order-csv": self.send_file(invoice_tool.QR_ORDER_CSV, "text/csv; charset=utf-8") else: write_json(self, {"error": "not_found"}, 404) def do_POST(self) -> None: parsed = urlparse(self.path) try: if parsed.path == "/api/upload-roster": self.handle_upload_roster() elif parsed.path == "/api/start": body = self.read_json() write_json(self, start_runner( int(body.get("max_batches", 200)), int(body.get("max_actions", 0)), bool(body.get("send_email", True)), bool(body.get("fast", True)), bool(body.get("dry_run", False)), bool(body.get("scroll_top_first", True)), str(body.get("mode", "h5")), str(body.get("invoice_success_action", "")), str(body.get("invoice_output_dir", "")), bool(body.get("final_retry_pass", True)), )) elif parsed.path == "/api/stop": write_json(self, stop_runner()) elif parsed.path == "/api/settings": body = self.read_json() write_json(self, save_invoice_settings( str(body.get("invoice_success_action", "download")), str(body.get("invoice_output_dir", "output/invoices")), )) elif parsed.path == "/api/launch-12306": result = launch_railway_app() write_json(self, result, 200 if result["return_code"] == 0 else 500) elif parsed.path == "/api/build-queue": result = subprocess.run([PYTHON, "scripts/invoice_tool.py", "android-build-page-queue"], cwd=PROJECT_ROOT, capture_output=True, text=True, check=False) write_json(self, {"return_code": result.returncode, "stdout": result.stdout, "stderr": result.stderr}, 200 if result.returncode == 0 else 500) elif parsed.path == "/api/next-page": result = subprocess.run([PYTHON, "scripts/invoice_tool.py", "android-page-next", "--rebuild"], cwd=PROJECT_ROOT, capture_output=True, text=True, check=False) write_json(self, {"return_code": result.returncode, "stdout": result.stdout, "stderr": result.stderr}, 200 if result.returncode == 0 else 500) elif parsed.path == "/api/scroll-top": result = subprocess.run([PYTHON, "scripts/invoice_tool.py", "android-page-top"], cwd=PROJECT_ROOT, capture_output=True, text=True, check=False) write_json(self, {"return_code": result.returncode, "stdout": result.stdout, "stderr": result.stderr}, 200 if result.returncode == 0 else 500) elif parsed.path == "/api/decode-qr": self.handle_decode_qr() elif parsed.path == "/api/save-qr-image": self.handle_save_qr_image() elif parsed.path == "/api/open-on-phone": self.handle_open_on_phone() elif parsed.path == "/api/reopen-qr": clear_runner_log("reopen qr page") body = self.read_json() qr_url = str(body.get("qr_url", "")).strip() if qr_url: invoice_tool.save_qr_url(qr_url) if body.get("agent_id_last8"): saved = invoice_tool.save_agent_id_last8(str(body.get("agent_id_last8", ""))) print(f"[web-ui] saved agent_id_last8={saved}", flush=True) result = invoice_tool.reopen_qr_url() write_json(self, { "reopened": result, "error": "自动扫码失败:请确认已粘贴二维码截图,手机在 12306 首页或电子发票相关页面" if not result else "", "device": device_status(), }) elif parsed.path == "/api/vision-page-queue": write_json(self, build_vision_page_queue()) elif parsed.path == "/api/mark-completed": self.handle_mark_completed() elif parsed.path == "/api/calibrate": self.handle_calibrate() else: write_json(self, {"error": "not_found"}, 404) except Exception as exc: write_json(self, {"error": str(exc)}, 500) def read_json(self) -> dict[str, object]: length = int(self.headers.get("Content-Length", "0")) if not length: return {} return json.loads(self.rfile.read(length).decode("utf-8")) def send_file(self, path: Path, content_type: str) -> None: if not path.exists(): write_json(self, {"error": "file_not_found", "file": str(path)}, 404) return body = path.read_bytes() self.send_response(200) self.send_header("Content-Type", content_type) self.send_header("Content-Disposition", f'attachment; filename="{path.name}"') self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def handle_upload_roster(self) -> None: form = cgi.FieldStorage(fp=self.rfile, headers=self.headers, environ={"REQUEST_METHOD": "POST", "CONTENT_TYPE": self.headers.get("Content-Type", "")}) item = form["file"] filename = Path(item.filename or "roster.xlsx").name UPLOAD_DIR.mkdir(parents=True, exist_ok=True) dest = UPLOAD_DIR / filename with dest.open("wb") as fh: shutil.copyfileobj(item.file, fh) result = subprocess.run([PYTHON, "scripts/invoice_tool.py", "import-roster", "--input", str(dest)], cwd=PROJECT_ROOT, capture_output=True, text=True, check=False) write_json(self, {"file": str(dest), "return_code": result.returncode, "stdout": result.stdout, "stderr": result.stderr}, 200 if result.returncode == 0 else 500) def handle_decode_qr(self) -> None: body = self.read_json() image_b64 = str(body.get("image", "")).strip() if not image_b64: write_json(self, {"results": [], "error": "未收到图片数据"}, 400) return # 去掉 data URL 前缀 (data:image/png;base64,...) if "," in image_b64 and image_b64.startswith("data:"): image_b64 = image_b64.split(",", 1)[1] try: image_bytes = base64.b64decode(image_b64) except Exception: write_json(self, {"results": [], "error": "图片数据解码失败,请重试"}, 400) return invoice_tool.save_qr_image_bytes(image_bytes) result = invoice_tool.decode_qr_image(image_bytes) # 保存解码结果,供脚本自动重进使用 if result.get("results"): invoice_tool.save_qr_url(result["results"][0]) result["qr_pushed"] = invoice_tool.generate_and_push_qr() result["saved"] = True else: result["image_saved"] = True write_json(self, dict(result)) def handle_save_qr_image(self) -> None: body = self.read_json() image_b64 = str(body.get("image", "")).strip() if not image_b64: write_json(self, {"saved": False, "error": "未收到图片数据"}, 400) return if "," in image_b64 and image_b64.startswith("data:"): image_b64 = image_b64.split(",", 1)[1] try: image_bytes = base64.b64decode(image_b64) except Exception: write_json(self, {"saved": False, "error": "图片数据解码失败,请重试"}, 400) return path = invoice_tool.save_qr_image_bytes(image_bytes) clear_runner_log("new qr image pasted") write_json(self, {"saved": True, "image_saved": True, "path": str(path), "size": path.stat().st_size}) def handle_mark_completed(self) -> None: body = self.read_json() rows = body.get("rows") or [] marked = 0 for item in rows: masked = item.get("masked", "") real = item.get("real", "") id8 = item.get("id8", "") if masked or real: invoice_tool.update_progress(masked, real, id8, "completed") marked += 1 write_json(self, {"marked": marked}) def handle_open_on_phone(self) -> None: body = self.read_json() url = str(body.get("url", "")).strip() if not url: write_json(self, {"return_code": -1, "stdout": "", "stderr": "未提供 URL", "device": device_status()}, 400) return adb_result = invoice_tool.run_adb( ["shell", "am", "start", "-a", "android.intent.action.VIEW", "-d", url], check=False, ) time.sleep(0.5) write_json(self, { "return_code": adb_result.returncode, "stdout": adb_result.stdout.strip(), "stderr": adb_result.stderr.strip(), "device": device_status(), }, 200 if adb_result.returncode == 0 else 500) def handle_calibrate(self) -> None: body = self.read_json() action = str(body.get("action", "")) if action == "capture": step_id = str(body.get("step_id", "")) write_json(self, calibrate.capture_screenshot(step_id)) elif action == "analyze": step_id = str(body.get("step_id", "")) if step_id == "*": write_json(self, calibrate.analyze_all()) else: write_json(self, calibrate.analyze_step(step_id)) elif action == "save": write_json(self, calibrate.save_device_profile(calibrate.analyze_all())) else: write_json(self, {"error": "unknown_action"}, 400) CALIBRATE_HTML = r""" 设备校准 · 铁路发票

📱 设备校准 · 屏幕适配

← 点击左侧步骤开始校准
""" def main() -> None: print(f"UI starting: cwd={Path.cwd()} pid={os.getpid()} python={sys.executable}", flush=True) invoice_tool.ensure_dirs() server = ThreadingHTTPServer(("127.0.0.1", 8765), Handler) print("UI: http://127.0.0.1:8765", flush=True) server.serve_forever() if __name__ == "__main__": main()