autotrain/scripts/web_ui.py
xinxin6623 9eb6778a6f feat: web UI enhancements, device calibration, phone control manual, and skills directory
- web_ui.py: major refactor with improved runner, mode selection, progress tracking
- scripts: add device_runtime, device_calibrate, calibration_trace, record_android_flow
- docs: add phone-control-manual, update device-adaptation and windows-migration
- invoice_tool: enhance H5 automation flow and visual fallback
- calibrate.py: expand calibration capabilities
- add skills/ and tests/ directories
- remove obsolete .opencode/skills/invoice-wrapup

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 22:14:56 +08:00

1878 lines
100 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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
import device_runtime
PROJECT_ROOT = invoice_tool.PROJECT_ROOT
PYTHON = sys.executable
RUNNER: subprocess.Popen[str] | None = None
RUNNER_LOCK = threading.RLock()
RUNNER_LOG = PROJECT_ROOT / "work" / "web_runner.log"
RUNNER_STOPPED_BY_USER: dict[str, object] | None = None
RECORDER: subprocess.Popen[str] | None = None
RECORDER_SESSION: Path | None = None
RECORDER_LOG = PROJECT_ROOT / "work" / "phone_recording.log"
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 reset_current_round() -> dict[str, object]:
"""归档本轮开票记录 + output 交付目录,然后清空以便下一轮从头开始。
- roster / tasks / agent_id_last8 不动
- runner 若在跑则拒绝
- 归档到 archive/round-resets/<ts>/,同时保存被清的清单
"""
with RUNNER_LOCK:
if RUNNER is not None and RUNNER.poll() is None:
return {"reset": False, "reason": "runner_running", "message": "运行中不能清理,请先停止"}
stamp = time.strftime("%Y%m%d-%H%M%S")
archive_dir = PROJECT_ROOT / "archive" / "round-resets" / stamp
archived: list[str] = []
removed: list[str] = []
runtime_paths = [
invoice_tool.PROGRESS_CSV,
invoice_tool.PAGE_QUEUE_CSV,
invoice_tool.QR_ORDER_CSV,
invoice_tool.LAST_QR_URL,
invoice_tool.LAST_QR_IMAGE,
RUNNER_LOG,
]
for path in runtime_paths:
if not path.exists():
continue
archive_dir.mkdir(parents=True, exist_ok=True)
dest = archive_dir / path.name
shutil.copy2(path, dest)
archived.append(str(dest))
try:
path.unlink()
removed.append(str(path))
except OSError:
pass
config = invoice_tool.load_config(invoice_tool.CONFIG_LOCAL_PATH)
output_dir = invoice_tool.project_path(config.invoice_output_dir)
output_archived = ""
if output_dir.exists() and any(output_dir.iterdir()):
archive_dir.mkdir(parents=True, exist_ok=True)
dest = archive_dir / f"output-{output_dir.name}"
shutil.move(str(output_dir), str(dest))
output_dir.mkdir(parents=True, exist_ok=True)
output_archived = str(dest)
removed.append(str(output_dir))
clear_runner_log(f"round reset -> {archive_dir}")
return {
"reset": True,
"archive_dir": str(archive_dir) if archived or output_archived else "",
"archived_files": archived,
"output_archived": output_archived,
"removed": removed,
}
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]:
profile_status = calibrate.get_device_status()
if profile_status.get("status") == "unavailable":
return {
"connected": False,
"state": "unknown",
"current_package": "",
"current_activity": "",
"in_12306": False,
"message": str(profile_status.get("error") or "未检测到手机"),
"profile": profile_status,
}
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 "未检测到手机"),
"profile": profile_status,
}
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
def connect_wireless_device(host: str, port: object) -> dict[str, object]:
result = device_runtime.connect_wireless(host, port)
result["device"] = device_status()
if not result["connected"]:
result["message"] = result["stderr"] or result["stdout"] or "未连接成功:请确认手机与电脑同一 WiFi并已开启网络 ADB 调试。"
return result
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"]
matched_progress = by_person_key.get((row.get("real_name", ""), row.get("id_last8", ""))) or by_masked.get(row.get("masked_name", ""))
item["ticket_count"] = matched_progress.get("ticket_count", "") if matched_progress else ""
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 ""
item["ticket_count"] = progress_row.get("ticket_count", "") if progress_row else ""
qr_order.append(item)
qr_seq_by_person_key = {
(row.get("real_name", ""), row.get("id_last8", "")): int(row.get("seq", 0) or 0)
for row in qr_order_raw
if row.get("real_name") and row.get("id_last8")
}
qr_seq_by_masked = {
row.get("masked_name", ""): int(row.get("seq", 0) or 0)
for row in qr_order_raw
if row.get("masked_name")
}
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
masked = progress_row.get("masked_name", "") if progress_row else ""
qr_seq = qr_seq_by_person_key.get(person_key)
if qr_seq is None and masked:
qr_seq = qr_seq_by_masked.get(masked)
if qr_seq is None:
qr_seq = 999999
roster_status.append(
{
"seq": qr_seq if qr_seq != 999999 else "",
"name": person.get("姓名", ""),
"id_last8": person.get("身份证后8位", ""),
"status": status,
"display_status": label["text"],
"display_tone": label["tone"],
"masked_name": masked,
"ticket_count": progress_row.get("ticket_count", "") 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", ""),
"_qr_seq": qr_seq,
"_roster_idx": idx,
}
)
roster_status.sort(key=lambda r: (r.get("_qr_seq", 999999), r.get("_roster_idx", 0)))
for r in roster_status:
r.pop("_qr_seq", 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"}
if RECORDER is not None and RECORDER.poll() is None:
return {"started": False, "reason": "recording_running", "message": "正在录制人工演示,请先停止录制再启动批处理"}
profile = calibrate.get_device_status()
if not dry_run and profile.get("status") != "verified":
return {
"started": False,
"reason": "device_not_verified",
"message": "当前手机尚未完成校准,请先进入“设备校准”完成全部页面验证。",
"profile": profile,
}
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 start_automatic_calibration(masked_name: str) -> dict[str, object]:
"""对用户在当前列表页明确选择的一人执行一次真实全流程校准。"""
global RUNNER, RUNNER_STOPPED_BY_USER
masked_name = masked_name.strip()
if not masked_name:
return {"started": False, "reason": "missing_masked_name"}
with RUNNER_LOCK:
if RUNNER is not None and RUNNER.poll() is None:
return {"started": False, "reason": "runner_already_running"}
if RECORDER is not None and RECORDER.poll() is None:
return {"started": False, "reason": "recording_running", "message": "正在录制人工演示,请先停止录制"}
backup = backup_runtime_tables("device-calibration")
RUNNER_STOPPED_BY_USER = None
log_path = PROJECT_ROOT / "work" / "device_calibration.log"
log_fh = log_path.open("w", encoding="utf-8")
cmd = [PYTHON, "-u", "scripts/device_calibrate.py", "--masked-name", masked_name]
RUNNER = subprocess.Popen(cmd, cwd=PROJECT_ROOT, stdout=log_fh, stderr=subprocess.STDOUT, text=True)
return {"started": True, "pid": RUNNER.pid, "cmd": cmd, "backup": backup, "log": str(log_path)}
def recording_status() -> dict[str, object]:
with RUNNER_LOCK:
running = RECORDER is not None and RECORDER.poll() is None
return {
"running": running,
"pid": RECORDER.pid if running and RECORDER is not None else None,
"session": str(RECORDER_SESSION.relative_to(PROJECT_ROOT)) if RECORDER_SESSION else "",
"return_code": None if RECORDER is None else RECORDER.poll(),
}
def start_phone_recording() -> dict[str, object]:
"""开始被动录制,用户可以通过手机或 scrcpy 自行完成演示。"""
global RECORDER, RECORDER_SESSION
with RUNNER_LOCK:
if RECORDER is not None and RECORDER.poll() is None:
return {"started": False, "reason": "recording_already_running", **recording_status()}
if RUNNER is not None and RUNNER.poll() is None:
return {"started": False, "reason": "runner_running", "message": "批处理运行中,不能同时录制演示"}
try:
device_runtime.require_single_device()
except device_runtime.DeviceError as exc:
return {"started": False, "reason": "device_unavailable", "message": str(exc)}
stamp = time.strftime("%Y%m%d-%H%M%S")
RECORDER_SESSION = PROJECT_ROOT / "work" / "replay" / f"{stamp}_manual"
RECORDER_LOG.parent.mkdir(parents=True, exist_ok=True)
log_fh = RECORDER_LOG.open("w", encoding="utf-8")
RECORDER = subprocess.Popen(
[PYTHON, "-u", "scripts/record_android_flow.py", "--output", str(RECORDER_SESSION)],
cwd=PROJECT_ROOT,
stdout=log_fh,
stderr=subprocess.STDOUT,
text=True,
)
return {"started": True, **recording_status()}
def stop_phone_recording() -> dict[str, object]:
global RECORDER
with RUNNER_LOCK:
if RECORDER is None or RECORDER.poll() is not None:
return {"stopped": False, "reason": "recording_not_running", **recording_status()}
RECORDER.terminate()
try:
RECORDER.wait(timeout=5)
except subprocess.TimeoutExpired:
RECORDER.kill()
return {"stopped": True, **recording_status()}
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"""<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>铁路系统发票批量开票</title>
<style>
:root {
color-scheme: light;
--blue: #2563eb;
--blue-dark: #1d4ed8;
--green: #16803c;
--red: #b42318;
--amber: #a15c00;
--ink: #172033;
--muted: #667085;
--border: #d9e2ee;
--soft-border: #edf2f7;
--panel: rgba(255,255,255,0.92);
--page: #f4f7fb;
--shadow: 0 18px 45px rgba(15, 23, 42, 0.08);
}
* { box-sizing: border-box; }
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: radial-gradient(circle at top left, #eaf2ff 0, transparent 340px), var(--page); color: var(--ink); }
header { position: sticky; top: 0; z-index: 20; background: rgba(255,255,255,0.9); border-bottom: 1px solid var(--border); padding: 16px 28px; display: flex; align-items: center; justify-content: space-between; gap: 18px; backdrop-filter: blur(14px); }
h1 { font-size: 22px; margin: 0; letter-spacing: -0.02em; }
h2 { font-size: 16px; margin: 0; letter-spacing: -0.01em; }
.subtitle { margin: 5px 0 0; color: var(--muted); font-size: 13px; }
.statusbar { display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; align-items: center; }
.pill { border: 1px solid var(--border); border-radius: 999px; padding: 6px 11px; background: #fff; color: #475467; font-size: 13px; box-shadow: 0 1px 2px rgba(15,23,42,0.04); }
.pill.ok { background: #eafaf0; color: var(--green); border-color: #bcebd0; }
.pill.warn { background: #fff7df; color: var(--amber); border-color: #f9dfa0; }
.pill.bad { background: #fff0ef; color: var(--red); border-color: #ffc7c1; }
.pill.calibrate-link { text-decoration: none; cursor: pointer; background: #f0f5ff; color: var(--blue); border-color: #c7d7fe; font-weight: 600; transition: background .15s, border-color .15s; }
.pill.calibrate-link:hover { background: #e0ebff; border-color: #a3bffa; }
main { width: min(1480px, calc(100vw - 36px)); margin: 0 auto; padding: 22px 0 42px; display: grid; gap: 18px; }
section { background: var(--panel); border: 1px solid var(--border); border-radius: 18px; padding: 18px; box-shadow: 0 1px 2px rgba(15,23,42,0.04); }
.section-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 14px; margin-bottom: 14px; }
.section-head p { margin: 5px 0 0; color: var(--muted); font-size: 13px; }
.hero-panel { padding: 20px; box-shadow: var(--shadow); }
.control-grid { display: grid; grid-template-columns: minmax(280px, 0.9fr) minmax(420px, 1.4fr) minmax(280px, 0.9fr); gap: 14px; align-items: stretch; }
.control-card { background: #fff; border: 1px solid var(--soft-border); border-radius: 15px; padding: 14px; display: flex; flex-direction: column; gap: 12px; min-width: 0; }
.card-title { display: flex; align-items: center; justify-content: space-between; gap: 10px; color: #344054; font-weight: 700; font-size: 13px; }
.card-title span { color: var(--muted); font-weight: 500; }
.button-row, .run-actions, .nav-actions, .toggle-row { display: flex; flex-wrap: wrap; gap: 9px; align-items: center; }
.device-connect { margin-top: 2px; padding-top: 12px; border-top: 1px solid var(--soft-border); display: grid; gap: 9px; }
.device-connect-title { color: #344054; font-weight: 700; font-size: 13px; }
.device-connect-fields { display: grid; grid-template-columns: minmax(0, 1fr) 92px; gap: 8px; }
.field-grid { display: grid; grid-template-columns: repeat(3, minmax(120px, 1fr)); gap: 10px; }
.field-grid label, .settings-controls label { display: grid; gap: 6px; }
label { font-size: 13px; color: #344054; }
button, input::file-selector-button { border: 1px solid var(--border); background: #fff; border-radius: 10px; padding: 9px 13px; font-size: 14px; cursor: pointer; transition: background 0.15s, border-color 0.15s, transform 0.15s, box-shadow 0.15s; }
button:hover, input::file-selector-button:hover { border-color: #b9c7d8; background: #f8fafc; }
button:active { transform: translateY(1px); }
button.primary { background: var(--blue); color: #fff; border-color: var(--blue); box-shadow: 0 8px 18px rgba(37,99,235,0.2); }
button.primary:hover { background: var(--blue-dark); border-color: var(--blue-dark); }
button.danger { color: #fff; background: var(--red); border-color: var(--red); }
button:disabled { opacity: 0.65; cursor: not-allowed; }
button.toggle-on { background: #eafaf0; color: var(--green); border-color: #bcebd0; font-weight: 600; }
button.toggle-off { background: #fff; color: #98a2b3; border-color: var(--border); }
input[type="number"] { width: 100%; padding: 9px 10px; border: 1px solid var(--border); border-radius: 10px; }
input[type="text"], select { width: 100%; padding: 9px 10px; border: 1px solid var(--border); border-radius: 10px; background: #fff; }
input[type="checkbox"] { accent-color: var(--blue); }
.path-input { min-width: 360px; flex: 1 1 360px; }
.message-bar { margin: 14px 0 0; min-height: 22px; padding: 10px 12px; border: 1px solid var(--soft-border); border-radius: 12px; background: #f8fafc; }
.settings-section { display: grid; gap: 12px; }
.settings-controls { display: grid; grid-template-columns: minmax(220px, 360px) minmax(320px, 1fr) auto; gap: 12px; align-items: end; }
.settings-controls .path-input { min-width: 0; width: 100%; }
.settings-summary { display: grid; gap: 6px; }
.settings-path { display: grid; grid-template-columns: 88px minmax(0, 1fr); gap: 8px; align-items: start; font-size: 13px; color: var(--muted); }
.settings-path strong { color: #344054; font-weight: 700; }
.settings-path code { color: var(--ink); background: #f8fafc; border: 1px solid var(--soft-border); border-radius: 8px; padding: 5px 7px; white-space: normal; overflow-wrap: anywhere; word-break: break-word; }
.stats { display: grid; grid-template-columns: repeat(6, minmax(120px, 1fr)); gap: 12px; padding: 0; background: transparent; border: 0; box-shadow: none; }
.stat { border: 1px solid var(--border); border-radius: 16px; padding: 14px 15px; background: #fff; box-shadow: 0 8px 22px rgba(15,23,42,0.05); }
.stat b { display: block; font-size: 28px; line-height: 1; margin-bottom: 7px; letter-spacing: -0.03em; }
.stat span { color: var(--muted); font-size: 13px; }
table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 13px; }
th, td { border-bottom: 1px solid var(--soft-border); text-align: left; padding: 10px 11px; white-space: nowrap; }
th { background: #f8fafc; position: sticky; top: 0; z-index: 1; color: #475467; font-weight: 700; }
tr:hover td { background: #fbfdff; }
.table-wrap { max-height: 450px; overflow: auto; border: 1px solid var(--border); border-radius: 13px; background: #fff; }
.queue-title { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
.queue-title-main { min-width: 0; }
.queue-actions { display: flex; flex: 0 0 auto; gap: 7px; }
.queue-actions button { font-size: 12px; padding: 6px 10px; white-space: nowrap; }
.queue-table { min-width: 680px; }
.badge { display: inline-flex; align-items: center; padding: 3px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; background: #e5e7eb; color: #4b5563; }
.badge.tone-ok { background: #eafaf0; color: var(--green); }
.badge.tone-bad { background: #fff0ef; color: var(--red); }
.badge.tone-warn { background: #fff7df; color: var(--amber); }
.badge.tone-info { background: #eaf2ff; color: var(--blue); }
.badge.tone-muted { background: #f3f4f6; color: #4b5563; }
.badge.tone-pending { background: #f8fafc; color: #667085; border: 1px dashed #cbd5e1; }
.reason-cell { color: var(--muted); font-size: 12px; max-width: 300px; white-space: normal; }
.dropzone { border: 2px dashed #c7d2e0; border-radius: 14px; padding: 18px; background: linear-gradient(180deg, #fbfdff, #f7faff); display: flex; align-items: center; min-height: 128px; cursor: pointer; transition: all 0.15s; }
.dropzone:hover { border-color: var(--blue); background: #f0f6ff; }
.dropzone.dragover { border-color: var(--blue); background: #e0ecff; border-style: solid; }
.dropzone-text { font-size: 13px; color: #475467; flex: 1; line-height: 1.55; }
.dropzone strong { color: var(--ink); font-size: 15px; }
.dropzone input[type=file] { display: none; }
.paste-zone { border: 2px dashed #c7d2e0; border-radius: 14px; padding: 18px; background: linear-gradient(180deg, #fbfdff, #f7faff); cursor: pointer; transition: all 0.15s; text-align: center; outline: none; min-height: 128px; display: grid; place-items: center; }
.paste-zone:hover, .paste-zone:focus { border-color: var(--blue); background: #f0f6ff; }
.paste-zone.success { border-color: var(--green); background: #eafaf0; border-style: solid; }
.paste-zone.error { border-color: var(--red); background: #fff0ef; border-style: solid; }
.paste-zone-text { font-size: 13px; color: #475467; line-height: 1.55; }
.paste-zone strong { color: var(--ink); font-size: 15px; }
.qr-result { display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: #f0f6ff; border: 1px solid #bbd6fe; border-radius: 12px; flex-wrap: wrap; }
.qr-result code { flex: 1; font-size: 13px; word-break: break-all; color: #1e40af; background: transparent; padding: 0; min-width: 200px; }
pre { margin: 0; background: #101828; color: #e5e7eb; padding: 13px; border-radius: 13px; height: 450px; overflow: auto; font-size: 12px; line-height: 1.55; }
.muted { color: var(--muted); font-size: 13px; }
.muted.ok-text { color: var(--green); font-weight: 600; }
.grid2 { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(360px, 0.8fr); gap: 18px; align-items: start; }
@media (max-width: 1180px) { .control-grid, .grid2 { grid-template-columns: 1fr; } .stats { grid-template-columns: repeat(3, minmax(120px, 1fr)); } pre { height: 320px; } }
@media (max-width: 760px) { main { width: min(100vw - 24px, 1480px); } header { align-items: flex-start; flex-direction: column; padding: 14px 16px; } .statusbar { justify-content: flex-start; } .field-grid, .settings-controls, .stats { grid-template-columns: 1fr; } .section-head { flex-direction: column; } .run-actions button, .nav-actions button, .button-row button { flex: 1 1 160px; } }
</style>
</head>
<body>
<header>
<div>
<h1>铁路系统发票批量开票</h1>
<p class="subtitle">名单导入 → 手机页面识别 → 自动开票 → 进度记录</p>
</div>
<div class="statusbar">
<span class="pill" id="runnerState">读取中</span>
<span class="pill" id="deviceState">手机状态读取中</span>
<span class="pill" id="deviceApp">当前 App 读取中</span>
<a class="pill calibrate-link" href="/calibrate" title="换手机屏幕适配">📱 设备校准</a>
</div>
</header>
<main>
<section class="hero-panel">
<div class="section-head">
<div>
<h2>运行控制台</h2>
<p>只调整本地 UI 布局,所有按钮和接口保持原功能。</p>
</div>
</div>
<div class="control-grid">
<div class="control-card">
<div class="card-title">① 名单 <span>Roster</span></div>
<label class="dropzone" id="dropzone">
<input type="file" id="rosterFile" name="file" accept=".xlsx,.xlsm,.csv">
<span class="dropzone-text"><strong>拖拽名单</strong>到此处,或点击选择文件 <span class="muted">(.xlsx / .xlsm / .csv)</span><br><span class="muted" id="dropzoneHint">未选择文件</span></span>
</label>
<div class="button-row">
<button id="launch12306">启动 12306</button>
<button id="buildQueue">抓当前页队列</button>
</div>
<div class="device-connect">
<div class="device-connect-title">📶 无线连接手机</div>
<div class="device-connect-fields">
<label>手机 IP <input id="deviceHost" type="text" inputmode="decimal" placeholder="例如 192.168.1.11"></label>
<label>端口 <input id="devicePort" type="number" min="1" max="65535" value="5555"></label>
</div>
<div class="button-row"><button id="connectDevice">连接手机</button></div>
<span class="muted" id="deviceConnectHint">手机和电脑需同一 WiFi并开启网络 ADB 调试。</span>
</div>
</div>
<div class="control-card">
<div class="card-title">② 参数与执行 <span>Run</span></div>
<div class="field-grid">
<label>模式 <select id="runMode"><option value="h5" selected>H5开票单</option><option value="native">列表页(视觉)</option></select></label>
<label>最多页数 <input id="maxBatches" type="number" min="1" max="300" value="200"></label>
<label>最多 N 人 <input id="maxActions" type="number" min="0" max="200" value="0" title="0 = 不限"></label>
</div>
<div class="toggle-row">
<label><input id="fast" type="checkbox" checked> 快速模式</label>
<label><input id="dryRun" type="checkbox"> dry-run</label>
<label><input id="scrollTopFirst" type="checkbox"> 开始前回顶</label>
<label><input id="sendEmail" type="checkbox"> 兼容旧流程:发送邮箱</label>
</div>
<div class="run-actions">
<button class="primary" id="startRun">开始运行</button>
<button class="danger" id="stopRun">停止</button>
<button id="scrollTop">回到顶部</button>
<button id="nextPage">上滑下一页</button>
<button id="startScrcpy">📱 屏幕控制</button>
<button id="recordPhone">● 开始录制演示</button>
<button id="finalRetryPass" class="toggle-on" title="到底后回顶做第二轮,重试失败项并记入核查表">🔄 查缺补漏</button>
<button id="resetRound" class="danger" title="归档并清空本轮的进度/队列/二维码/日志和 output 交付目录roster/tasks 保留">🧹 清一轮开票记录</button>
</div>
</div>
<div class="control-card">
<div class="card-title">③ 二维码 <span>QR</span></div>
<div class="paste-zone" id="pasteZone" tabindex="0">
<span class="paste-zone-text"><strong>📋 粘贴二维码截图</strong><br><span class="muted" id="pasteHint">每组换新二维码时,先粘贴截图保存到手机,再点“重新进入开票页”</span></span>
<img id="qrPreview" style="display:none; max-width:180px; max-height:180px; border-radius:6px; margin-top:8px; border:1px solid var(--border);">
</div>
<label>代理人证件后 8 位 <input id="agentIdLast8" type="text" inputmode="numeric" maxlength="8" placeholder="扫码核验需要时填写"></label>
<div class="button-row">
<button id="reopenQr">重新进入开票页</button>
</div>
<div id="qrResult" style="display:none;" class="qr-result">
<code id="qrUrl"></code>
<button id="openOnPhone">📱 在手机打开</button>
<button id="copyQrUrl">📋 复制链接</button>
</div>
</div>
</div>
<p class="muted message-bar" id="message"></p>
</section>
<section class="settings-section">
<div class="section-head">
<div>
<h2>保存设置</h2>
<p>配置成功后的处理方式和本地保存目录。</p>
</div>
</div>
<div class="settings-controls">
<label>开票成功后
<select id="invoiceSuccessAction">
<option value="download">下载 PDF/OFD/XML 到电脑</option>
<option value="email">发送至邮箱</option>
<option value="continue">仅继续开票</option>
</select>
</label>
<label>保存目录 <input id="invoiceOutputDir" class="path-input" type="text" placeholder="output/invoices"></label>
<button id="saveSettings">保存设置</button>
</div>
<div class="settings-summary" id="settingsHint">
<div class="settings-path"><strong>填写说明</strong><span>保存目录可填相对项目路径或绝对路径。</span></div>
</div>
<p class="muted ok-text" id="settingsSavedMessage"></p>
</section>
<section class="stats">
<div class="stat"><b id="total">0</b><span>名单人数</span></div>
<div class="stat"><b id="completed">0</b><span>已开票</span></div>
<div class="stat"><b id="noTicket">0</b><span>无可开票</span></div>
<div class="stat"><b id="failed">0</b><span>失败</span></div>
<div class="stat"><b id="inProgress">0</b><span>处理中</span></div>
<div class="stat"><b id="pending">0</b><span>未操作</span></div>
</section>
<div class="grid2">
<section>
<h2 class="queue-title"><span class="queue-title-main">当前页队列 <span class="muted" id="queueMeta"></span></span><span class="queue-actions"><button id="markCheckedDone">✅ 标记已完成</button><button id="refreshQueue">🔄 刷新当前页</button></span></h2>
<div class="table-wrap"><table class="queue-table"><thead><tr><th>✓</th><th>状态</th><th>原因</th><th>脱敏名</th><th>姓名</th><th>后8位</th></tr></thead><tbody id="queueBody"></tbody></table></div>
</section>
<section>
<h2>运行日志</h2>
<pre id="log"></pre>
</section>
</div>
<section>
<div class="section-head">
<div>
<h2>本二维码核查表 <a class="muted" href="/api/qr-order-csv" target="_blank">下载本表</a></h2>
<p id="qrOrderMeta">按当前二维码列表出现顺序展示,方便跑完后人工核查失败项。</p>
</div>
</div>
<div class="table-wrap"><table><thead><tr><th>#</th><th>状态</th><th>票数</th><th>脱敏名</th><th>姓名</th><th>身份证后8位</th><th>更新时间</th></tr></thead><tbody id="qrOrderBody"></tbody></table></div>
</section>
<section>
<h2>总进度 <a class="muted" href="/api/progress-csv" target="_blank">下载进度表</a></h2>
<div class="table-wrap"><table><thead><tr><th>#</th><th>状态</th><th>票数</th><th>姓名</th><th>脱敏名</th><th>身份证后8位</th><th>更新时间</th></tr></thead><tbody id="rosterBody"></tbody></table></div>
</section>
</main>
<script>
const $ = (id) => document.getElementById(id);
let settingsDirty = false;
function h(value) {
return String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;'}[c]));
}
function badge(text, tone) {
const cls = tone ? `tone-${tone}` : 'tone-pending';
return `<span class="badge ${cls}">${h(text || '未操作')}</span>`;
}
function renderSettingsHint(relativeDir, absoluteDir, saved=false) {
const relativeText = relativeDir || 'output/invoices';
const absoluteText = absoluteDir || relativeText;
const savedText = saved ? '<span class="ok-text">(已生效)</span>' : '';
$('settingsHint').className = 'settings-summary';
$('settingsHint').innerHTML = `
<div class="settings-path"><strong>实际保存到</strong><code>${h(absoluteText)}</code>${savedText}</div>
`;
}
async function api(path, options={}) {
const res = await fetch(path, options);
const data = await res.json();
if (!res.ok) throw new Error(data.error || res.statusText);
return data;
}
async function refreshRecording() {
try {
const data = await api('/api/recording');
const button = $('recordPhone');
if (data.running) {
button.textContent = '■ 停止录制演示';
button.className = 'danger';
button.title = data.session ? `正在录制:${data.session}` : '正在录制';
} else {
button.textContent = '● 开始录制演示';
button.className = '';
button.title = data.session ? `最近录制:${data.session}` : '开始后请直接在手机或屏幕控制窗口操作';
}
} catch (_) {}
}
async function refresh() {
const data = await api('/api/status');
$('total').textContent = data.counts.total;
$('completed').textContent = data.counts.completed;
$('noTicket').textContent = data.counts.no_ticket || 0;
$('failed').textContent = data.counts.failed;
$('inProgress').textContent = data.counts.in_progress;
$('pending').textContent = data.counts.pending;
$('runnerState').textContent = data.runner.status === 'running' ? '运行中' : (data.runner.status === 'failed' ? '异常退出' : '已停止');
$('runnerState').className = data.runner.status === 'running' ? 'pill ok' : (data.runner.status === 'failed' ? 'pill bad' : 'pill');
$('deviceState').textContent = data.device.connected ? '手机已连接' : '手机未连接';
$('deviceState').className = data.device.connected ? 'pill ok' : 'pill bad';
$('deviceApp').textContent = data.device.message || '';
$('deviceApp').className = data.device.in_12306 ? 'pill ok' : (data.device.connected ? 'pill warn' : 'pill bad');
if (data.config) {
if (!settingsDirty && document.activeElement !== $('invoiceOutputDir')) {
$('invoiceSuccessAction').value = data.config.invoice_success_action || 'download';
$('invoiceOutputDir').value = data.config.invoice_output_dir || 'output/invoices';
$('sendEmail').checked = (data.config.invoice_success_action || 'download') === 'email';
}
renderSettingsHint(data.config.invoice_output_dir, data.config.invoice_output_absolute_dir, false);
}
$('log').textContent = data.runner.log_tail || '';
$('queueBody').innerHTML = data.queue.map((r, i) => `<tr><td><input type="checkbox" class="queueCb" data-idx="${i}" data-masked="${h(r.masked_name)}" data-real="${h(r.real_name)}" data-id8="${h(r.id_last8)}"></td><td>${badge(r.display_status, r.display_tone)}</td><td class="reason-cell">${h(r.display_reason)}</td><td>${h(r.masked_name)}</td><td>${h(r.real_name)}</td><td>${h(r.id_last8)}</td></tr>`).join('');
const queueCounts = data.queue.reduce((acc, r) => { acc[r.display_status] = (acc[r.display_status] || 0) + 1; return acc; }, {});
$('queueMeta').textContent = data.queue.length ? `本页 ${data.queue.length} 人 · ` + Object.entries(queueCounts).map(([k,v]) => `${k} ${v}`).join(' / ') : '尚未抓取本页队列';
const qrOrder = data.qr_order || [];
const qrCounts = qrOrder.reduce((acc, r) => { acc[r.display_status] = (acc[r.display_status] || 0) + 1; return acc; }, {});
$('qrOrderMeta').textContent = qrOrder.length ? `本二维码 ${qrOrder.length} 人 · ` + Object.entries(qrCounts).map(([k,v]) => `${k} ${v}`).join(' / ') : '还没有记录本二维码顺序,运行 H5 批处理后自动生成';
const ticketText = r => r.ticket_count ? `${h(r.ticket_count)} 张` : '';
$('qrOrderBody').innerHTML = qrOrder.map(r => `<tr><td>${h(r.seq)}</td><td>${badge(r.display_status, r.display_tone)}</td><td>${ticketText(r)}</td><td>${h(r.masked_name)}</td><td>${h(r.real_name)}</td><td>${h(r.id_last8)}</td><td>${h(r.updated_at)}</td></tr>`).join('');
$('rosterBody').innerHTML = data.roster.map(r => `<tr><td>${h(r.seq)}</td><td>${badge(r.display_status, r.display_tone)}</td><td>${ticketText(r)}</td><td>${h(r.name)}</td><td>${h(r.masked_name)}</td><td>${h(r.id_last8)}</td><td>${h(r.updated_at)}</td></tr>`).join('');
await refreshRecording();
}
function summarize(path, data) {
try {
if (path === '/api/launch-12306') {
if (data && data.device && data.device.in_12306) return '12306 已启动并位于前台';
if (data && data.return_code === 0) return '已发送启动指令';
return '启动失败:' + (data && (data.stderr || data.stdout) || '未知错误');
}
if (path === '/api/build-queue') {
const stdout = (data && data.stdout) || '';
const rowsMatch = stdout.match(/rows=(\d+) pending=(\d+)/);
if (rowsMatch) return `已抓取本页 ${rowsMatch[1]}pending ${rowsMatch[2]} 待处理`;
if (data && data.return_code !== 0) return '抓队列失败:' + ((data.stderr || '').split('\n').pop() || '未知');
return '已抓取队列';
}
if (path === '/api/start') {
if (data && data.started) {
const prelude = (data.prelude || []).join(' | ');
return `已开始运行 (PID ${data.pid})${prelude ? '' + prelude : ''}`;
}
if (data && data.reason === 'runner_already_running') return '已在运行中,无需重复启动';
return '启动失败';
}
if (path === '/api/stop') {
if (data && data.stopped) return data.killed ? '已强制停止' : '已停止';
if (data && data.reason === 'runner_not_running') return '当前没有运行中的批处理';
return '停止失败';
}
if (path === '/api/reset-round') {
if (data && data.reset) {
const parts = [];
if (data.archived_files && data.archived_files.length) parts.push(`归档 ${data.archived_files.length} 个记录文件`);
if (data.output_archived) parts.push('output 交付目录已归档');
const dir = data.archive_dir ? ` → ${data.archive_dir}` : '';
return `本轮已清理${parts.length ? '' + parts.join('') + '' : ''}${dir}`;
}
if (data && data.reason === 'runner_running') return data.message || '运行中不能清理';
return '清理失败';
}
if (path === '/api/next-page') {
if (data && data.return_code === 0) return '已上滑下一页';
return '翻页失败:' + ((data && data.stderr || '').split('\n').pop() || '未知');
}
if (path === '/api/scroll-top') {
const stdout = (data && data.stdout) || '';
const m = stdout.match(/at_top iterations=(\d+)/);
if (m) return `已回到顶部 (滑了 ${m[1]} 次)`;
if (stdout.includes('max_iterations_reached')) return '已尽量回顶,但未稳定 — 请人工检查';
return '回顶部失败:' + ((data && data.stderr || '').split('\n').pop() || '未知');
}
if (path === '/api/upload-roster') {
const stdout = (data && data.stdout) || '';
const rowsMatch = stdout.match(/roster_rows=(\d+)/);
if (rowsMatch) return `名单已上传并导入,共 ${rowsMatch[1]} 条`;
if (data && data.return_code !== 0) return '导入失败:' + ((data.stderr || '').split('\n').pop() || '未知');
return '名单已上传';
}
} catch (e) { /* fall through */ }
return '操作完成';
}
async function postJson(path, body={}) {
const data = await api(path, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) });
$('message').textContent = summarize(path, data);
await refresh();
}
async function uploadRoster(file) {
if (!file) return;
$('dropzoneHint').textContent = `上传中:${file.name}`;
const form = new FormData();
form.append('file', file, file.name);
const res = await fetch('/api/upload-roster', { method: 'POST', body: form });
const data = await res.json();
$('message').textContent = summarize('/api/upload-roster', data);
$('dropzoneHint').textContent = `已选:${file.name}`;
await refresh();
}
const dropzone = $('dropzone');
const rosterInput = $('rosterFile');
rosterInput.addEventListener('change', () => uploadRoster(rosterInput.files[0]));
['dragenter', 'dragover'].forEach(evt => dropzone.addEventListener(evt, (e) => {
e.preventDefault(); e.stopPropagation(); dropzone.classList.add('dragover');
}));
['dragleave', 'drop'].forEach(evt => dropzone.addEventListener(evt, (e) => {
e.preventDefault(); e.stopPropagation(); dropzone.classList.remove('dragover');
}));
dropzone.addEventListener('drop', (e) => {
const files = e.dataTransfer && e.dataTransfer.files;
if (!files || !files.length) return;
const file = files[0];
if (!/\.(xlsx|xlsm|csv)$/i.test(file.name)) {
$('message').textContent = '只支持 .xlsx / .xlsm / .csv';
return;
}
uploadRoster(file);
});
$('launch12306').onclick = async () => {
await postJson('/api/launch-12306');
setTimeout(refresh, 1000);
};
const savedDeviceHost = localStorage.getItem('autotrain_device_host');
if (savedDeviceHost) $('deviceHost').value = savedDeviceHost;
$('connectDevice').onclick = async () => {
const host = $('deviceHost').value.trim();
const port = Number($('devicePort').value || 5555);
if (!host) { $('deviceConnectHint').textContent = '请填写手机当前 WiFi IP'; return; }
$('connectDevice').disabled = true;
$('connectDevice').textContent = '连接中...';
try {
const res = await fetch('/api/connect-device', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({host, port})});
const data = await res.json();
if (!res.ok || !data.connected) throw new Error(data.message || data.stderr || data.stdout || '连接失败');
localStorage.setItem('autotrain_device_host', host);
$('deviceConnectHint').textContent = `已连接:${data.endpoint}`;
$('message').textContent = `手机已连接:${data.endpoint}`;
await refresh();
} catch (err) {
$('deviceConnectHint').textContent = '连接失败:' + (err.message || '请检查 IP、端口和网络 ADB 开关');
} finally {
$('connectDevice').disabled = false;
$('connectDevice').textContent = '连接手机';
}
};
$('reopenQr').onclick = async () => {
if (!qrPreview.src || qrPreview.style.display === 'none') {
$('message').textContent = '请先粘贴本组二维码截图,再重新进入开票页';
return;
}
$('log').textContent = '';
$('message').textContent = '正在保存二维码截图并推送到手机相册...';
try {
const res = await fetch('/api/save-qr-image', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: qrPreview.src }),
});
const data = await res.json();
if (!data.saved) {
$('message').textContent = data.error || '二维码截图保存失败,请重新粘贴';
return;
}
lastDecodedUrl = '';
qrResult.style.display = 'none';
pasteHint.textContent = '二维码截图已保存,正在推送到手机相册...';
} catch (err) {
$('message').textContent = '保存二维码截图失败: ' + (err.message || '网络错误');
return;
}
$('message').textContent = '正在走 订单 → 电子发票 → 扫码开票 → 相册选码...';
const data = await api('/api/reopen-qr', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ agent_id_last8: $('agentIdLast8').value }) });
$('message').textContent = data.reopened ? '已重新进入扫码开票单' : (data.error || '重进失败: 请先在粘贴区解码一个二维码,并确认手机在 12306 可操作页面');
await refresh();
};
$('buildQueue').onclick = () => postJson('/api/build-queue');
$('markCheckedDone').onclick = async () => {
const cbs = document.querySelectorAll('.queueCb:checked');
if (!cbs.length) { $('message').textContent = '请先勾选要标记的人'; return; }
const names = Array.from(cbs).map(cb => ({ masked: cb.dataset.masked, real: cb.dataset.real, id8: cb.dataset.id8 }));
$('message').textContent = `正在标记 ${names.length} 人...`;
try {
const res = await fetch('/api/mark-completed', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ rows: names }),
});
const data = await res.json();
$('message').textContent = `已标记 ${data.marked || 0} 人为人工完成`;
await refresh();
} catch (err) {
$('message').textContent = '标记失败: ' + (err.message || '网络错误');
}
};
$('refreshQueue').onclick = async () => {
$('refreshQueue').textContent = '⏳ 识别中...';
$('refreshQueue').disabled = true;
try {
const data = await api('/api/vision-page-queue', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: '{}' });
if (data.rows) {
$('queueBody').innerHTML = data.rows.map(r => `<tr><td>${badge(r.display_status, r.display_tone)}</td><td class="reason-cell">${h(r.display_reason)}</td><td>${h(r.masked_name)}</td><td>${h(r.real_name)}</td><td>${h(r.id_last8)}</td></tr>`).join('');
$('queueMeta').textContent = data.meta || '';
$('message').textContent = '已刷新: ' + (data.meta || '');
} else {
$('message').textContent = '刷新失败: ' + (data.error || '未知错误');
}
} catch (err) {
$('message').textContent = '刷新失败: ' + (err.message || '网络错误');
}
$('refreshQueue').textContent = '🔄 刷新当前页';
$('refreshQueue').disabled = false;
};
$('invoiceOutputDir').addEventListener('input', () => { settingsDirty = true; });
$('invoiceSuccessAction').onchange = () => { settingsDirty = true; $('sendEmail').checked = $('invoiceSuccessAction').value === 'email'; };
$('sendEmail').onchange = () => { settingsDirty = true; if ($('sendEmail').checked) $('invoiceSuccessAction').value = 'email'; };
$('saveSettings').onclick = async () => {
$('saveSettings').disabled = true;
$('saveSettings').textContent = '保存中...';
try {
const data = await api('/api/settings', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ invoice_success_action: $('invoiceSuccessAction').value, invoice_output_dir: $('invoiceOutputDir').value })
});
if (!data.saved) throw new Error('设置保存失败');
settingsDirty = false;
$('invoiceOutputDir').value = data.invoice_output_dir || $('invoiceOutputDir').value;
await refresh();
renderSettingsHint(data.invoice_output_dir, data.absolute_dir, true);
$('settingsSavedMessage').textContent = `最近保存:${data.invoice_success_action} → ${data.absolute_dir}`;
$('message').textContent = `保存目录已生效:${data.absolute_dir}`;
} catch (err) {
$('settingsHint').className = 'muted';
$('settingsSavedMessage').textContent = '';
$('message').textContent = '设置保存失败: ' + (err.message || '网络错误');
} finally {
$('saveSettings').disabled = false;
$('saveSettings').textContent = '保存设置';
}
};
$('startRun').onclick = () => postJson('/api/start', { max_batches: Number($('maxBatches').value), max_actions: Number($('maxActions').value), send_email: $('sendEmail').checked, fast: $('fast').checked, dry_run: $('dryRun').checked, scroll_top_first: $('scrollTopFirst').checked, mode: $('runMode').value, invoice_success_action: $('invoiceSuccessAction').value, invoice_output_dir: $('invoiceOutputDir').value, final_retry_pass: $('finalRetryPass').classList.contains('toggle-on') });
$('stopRun').onclick = () => postJson('/api/stop');
$('resetRound').onclick = () => {
if (!confirm('确定清理本轮开票记录吗?\n\n将归档并清空\n· 进度/队列/二维码顺序 CSV\n· 上次二维码 URL/图片\n· 运行日志\n· output 交付目录\n\n保留名单 (roster)、任务台账 (tasks)、代理人证件后 8 位。\n\n归档在 archive/round-resets/<时间>/,可随时找回。')) return;
postJson('/api/reset-round');
};
$('nextPage').onclick = () => postJson('/api/next-page');
$('scrollTop').onclick = () => postJson('/api/scroll-top');
$('startScrcpy').onclick = () => postJson('/api/scrcpy');
$('recordPhone').onclick = async () => {
try {
const current = await api('/api/recording');
const data = await api('/api/recording', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({action: current.running ? 'stop' : 'start'})});
$('message').textContent = data.started ? `开始录制:${data.session}。现在请直接操作手机,完成后点击停止录制。` : (data.stopped ? `录制已保存:${data.session}` : (data.message || data.reason || '录制操作未完成'));
await refreshRecording();
} catch (err) {
$('message').textContent = '录制操作失败:' + (err.message || '网络错误');
}
};
$('finalRetryPass').onclick = function() {
const on = this.classList.toggle('toggle-on');
this.classList.toggle('toggle-off', !on);
this.textContent = on ? '🔄 查缺补漏' : '🔄 查缺补漏(关)';
};
// --- 粘贴二维码 ---
const pasteZone = $('pasteZone');
const pasteHint = $('pasteHint');
const qrPreview = $('qrPreview');
const qrResult = $('qrResult');
const qrUrl = $('qrUrl');
let lastDecodedUrl = '';
pasteZone.addEventListener('click', () => pasteZone.focus());
pasteZone.addEventListener('paste', async (e) => {
e.preventDefault();
const items = e.clipboardData && e.clipboardData.items;
if (!items) return;
let imageFile = null;
for (const item of items) {
if (item.type.startsWith('image/')) {
imageFile = item.getAsFile();
break;
}
}
if (!imageFile) {
pasteHint.textContent = '未检测到图片,请确认已截图并复制到剪贴板';
pasteZone.classList.add('error');
setTimeout(() => pasteZone.classList.remove('error'), 2000);
return;
}
// 显示预览
const reader = new FileReader();
reader.onload = async (ev) => {
const base64 = ev.target.result;
lastDecodedUrl = '';
qrUrl.textContent = '';
$('log').textContent = '';
qrPreview.src = base64;
qrPreview.style.display = 'inline-block';
pasteHint.textContent = '保存二维码截图中...';
pasteZone.classList.remove('error', 'success');
qrResult.style.display = 'none';
try {
const res = await fetch('/api/save-qr-image', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: base64 }),
});
const data = await res.json();
if (data.saved) {
lastDecodedUrl = '';
qrResult.style.display = 'none';
pasteHint.textContent = '二维码截图已保存,点“重新进入开票页”会推到手机相册';
pasteZone.classList.add('success');
setTimeout(() => pasteZone.classList.remove('success'), 3000);
} else {
lastDecodedUrl = '';
qrResult.style.display = 'none';
pasteHint.textContent = data.error || '二维码截图保存失败,请重新粘贴';
pasteZone.classList.add('error');
setTimeout(() => pasteZone.classList.remove('error'), 3000);
}
} catch (err) {
qrResult.style.display = 'none';
pasteHint.textContent = '保存请求失败: ' + (err.message || '网络错误');
pasteZone.classList.add('error');
setTimeout(() => pasteZone.classList.remove('error'), 2000);
}
};
reader.readAsDataURL(imageFile);
});
$('openOnPhone').onclick = async () => {
if (!lastDecodedUrl) return;
$('message').textContent = '正在手机打开...';
try {
const res = await fetch('/api/open-on-phone', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: lastDecodedUrl }),
});
const data = await res.json();
if (data.return_code === 0) {
$('message').textContent = '已在手机打开: ' + lastDecodedUrl;
} else {
$('message').textContent = '手机打开失败: ' + (data.stderr || data.stdout || '未知错误');
}
await refresh();
} catch (err) {
$('message').textContent = '请求失败: ' + (err.message || '网络错误');
}
};
$('copyQrUrl').onclick = () => {
if (!lastDecodedUrl) return;
navigator.clipboard.writeText(lastDecodedUrl).then(() => {
$('message').textContent = '链接已复制到剪贴板';
}).catch(() => {
$('message').textContent = '复制失败,请手动复制';
});
};
refresh();
setInterval(refresh, 2000);
</script>
</body>
</html>
"""
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.get_device_status())
elif parsed.path == "/api/calibrate/candidates":
queue = build_vision_page_queue()
rows = [row for row in queue.get("rows", []) if row.get("display_status") == "待处理"]
write_json(self, {"rows": rows, "error": queue.get("error", ""), "meta": queue.get("meta", "")})
elif parsed.path.startswith("/api/calibrate/screenshot"):
from urllib.parse import parse_qs
qs = parse_qs(parsed.query)
sid = qs.get("id", [""])[0]
if sid.endswith("_sample"):
img = calibrate.trusted_sample_path(sid.removesuffix("_sample"))
else:
img = calibrate.CALIBRATION_DIR / f"{sid}.png"
if img and 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/recording":
write_json(self, recording_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/reset-round":
write_json(self, reset_current_round())
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/connect-device":
body = self.read_json()
result = connect_wireless_device(str(body.get("host", "")), body.get("port", 5555))
write_json(self, result, 200 if result["connected"] else 400)
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/scrcpy":
result = subprocess.run(["scrcpy", "--window-title", "手机屏幕控制Ctrl+Q 退出)", "--max-size", "800", "--stay-awake", "--always-on-top"], cwd=PROJECT_ROOT, stdout=subprocess.PIPE, stderr=subprocess.PIPE, 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/recording":
body = self.read_json()
if str(body.get("action", "start")) == "stop":
write_json(self, stop_phone_recording())
else:
write_json(self, start_phone_recording())
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()))
elif action == "auto-run":
write_json(self, start_automatic_calibration(str(body.get("masked_name", ""))))
elif action == "approve-sample":
write_json(self, calibrate.approve_current_as_sample(str(body.get("step_id", ""))))
else:
write_json(self, {"error": "unknown_action"}, 400)
CALIBRATE_HTML = r"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>设备校准 · 铁路发票</title>
<style>
:root{color-scheme:light;--blue:#2563eb;--blue-dark:#1d4ed8;--green:#16803c;--red:#b42318;--amber:#a15c00;--ink:#172033;--muted:#667085;--border:#d9e2ee;--soft-border:#edf2f7;--panel:rgba(255,255,255,.92);--page:#f4f7fb;--shadow:0 18px 45px rgba(15,23,42,.08)}
*{box-sizing:border-box}
body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:radial-gradient(circle at top left,#eaf2ff 0,transparent 340px),var(--page);color:var(--ink)}
header{position:sticky;top:0;z-index:20;background:rgba(255,255,255,.9);border-bottom:1px solid var(--border);padding:16px 28px;display:flex;align-items:center;justify-content:space-between;gap:18px;backdrop-filter:blur(14px)}
h1{font-size:22px;margin:0;letter-spacing:-.02em} h2{font-size:16px;margin:0;letter-spacing:-.01em}
.subtitle{margin:5px 0 0;color:var(--muted);font-size:13px}.header-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.pill{border:1px solid var(--border);border-radius:999px;padding:6px 11px;background:#fff;color:#475467;font-size:13px}.pill.warn{background:#fff7df;color:var(--amber);border-color:#f9dfa0}.pill.ok{background:#eafaf0;color:var(--green);border-color:#bcebd0}
.back-link{display:inline-flex;align-items:center;text-decoration:none;color:var(--blue);font-size:13px;font-weight:600;padding:7px 10px;border:1px solid #c7d7fe;border-radius:999px;background:#f0f5ff}
.calibrate-main{width:min(1480px,calc(100vw - 36px));margin:0 auto;padding:22px 0 42px}
.layout{display:grid;grid-template-columns:230px minmax(0,1fr);gap:18px;align-items:start}.sidebar,.main{min-width:0}
.sidebar-card,.panel{background:var(--panel);border:1px solid var(--border);border-radius:18px;padding:16px;box-shadow:0 1px 2px rgba(15,23,42,.04)}
.sidebar-card{position:sticky;top:90px}.sidebar-label{font-size:12px;font-weight:700;color:#475467;margin:0 0 10px;text-transform:uppercase;letter-spacing:.05em}
.step-list{display:grid;gap:4px}.step-item{padding:10px 9px;border-radius:10px;cursor:pointer;display:flex;align-items:center;gap:8px;transition:background .15s,color .15s}.step-item:hover{background:#f0f6ff}.step-item.active{background:#eaf2ff;color:var(--blue);font-weight:700}
.step-num{width:25px;height:25px;border-radius:50%;display:grid;place-items:center;background:#eef2f6;color:#667085;font-size:12px;flex-shrink:0}.step-item.done .step-num{background:#eafaf0;color:var(--green)}.step-item.fail .step-num{background:#fff0ef;color:var(--red)}.step-name{font-size:13px}
.screen-info{margin-top:12px;padding:11px 12px;border-radius:12px;border:1px solid var(--soft-border);background:#f8fafc;color:var(--muted);font-size:13px}.screen-info span{color:var(--blue);font-weight:700}
.main-inner{display:grid;grid-template-columns:minmax(330px,.9fr) minmax(420px,1.1fr);gap:18px;align-items:start}.sample-column,.action-column{min-width:0}.sample-column .panel,.action-column .panel{margin:0}
.panel h2{margin-bottom:6px}.panel .desc{color:var(--muted);font-size:13px;margin-bottom:12px;line-height:1.55}
.sample-label{display:inline-flex;background:#eaf2ff;color:var(--blue);padding:4px 9px;border-radius:999px;font-size:12px;font-weight:700;margin-bottom:10px}
.screenshot-frame{background:#f8fafc;border-radius:13px;padding:12px;margin:12px 0;border:1px solid var(--soft-border);display:grid;place-items:center;min-height:300px}.screenshot-frame img{max-width:100%;max-height:min(66vh,650px);width:auto;border-radius:8px;object-fit:contain;box-shadow:0 8px 24px rgba(15,23,42,.12)}
.btn{border:1px solid var(--border);background:#fff;border-radius:10px;padding:9px 13px;font-size:14px;cursor:pointer;transition:background .15s,border-color .15s,transform .15s}.btn:hover{background:#f8fafc;border-color:#b9c7d8}.btn:active{transform:translateY(1px)}.btn:disabled{opacity:.55;cursor:not-allowed}.btn-primary{background:var(--blue);color:#fff;border-color:var(--blue);box-shadow:0 8px 18px rgba(37,99,235,.2)}.btn-primary:hover{background:var(--blue-dark);border-color:var(--blue-dark)}.btn-success{background:#eafaf0;color:var(--green);border-color:#bcebd0;font-weight:600}.btn-danger{background:var(--red);color:#fff;border-color:var(--red)}
.btn-row{display:flex;gap:9px;flex-wrap:wrap;margin:12px 0}.result{background:#f8fafc;border:1px solid var(--soft-border);border-radius:12px;padding:13px;margin-top:12px;font-size:13px;line-height:1.65}.result .ok{color:var(--green)}.result .fail{color:var(--red)}.result .label{color:var(--muted)}
.badge{display:inline-flex;padding:2px 8px;border-radius:999px;font-size:12px;margin-left:6px}.badge-ok{background:#eafaf0;color:var(--green)}.badge-fail{background:#fff0ef;color:var(--red)}.badge-skip{background:#eef2f6;color:#667085}.coord-table{width:100%;border-collapse:separate;border-spacing:0;margin-top:8px;font-size:12px;overflow:hidden;border:1px solid var(--soft-border);border-radius:9px}.coord-table th,.coord-table td{border-bottom:1px solid var(--soft-border);padding:7px 9px;text-align:left}.coord-table tr:last-child td{border-bottom:0}.coord-table th{background:#fff;color:#475467}
.actions-bar{position:sticky;bottom:0;margin-top:18px;padding:12px;background:rgba(255,255,255,.92);border:1px solid var(--border);border-radius:15px;box-shadow:var(--shadow);display:flex;gap:9px;flex-wrap:wrap;backdrop-filter:blur(14px)}.empty{color:var(--muted);text-align:center;padding:70px 20px}
@media(max-width:980px){.layout{grid-template-columns:1fr}.sidebar-card{position:static}.main-inner{grid-template-columns:1fr 1fr}}@media(max-width:720px){header{align-items:flex-start;flex-direction:column;padding:14px 16px}.calibrate-main{width:min(100vw - 24px,1480px)}.main-inner{grid-template-columns:1fr}.screenshot-frame{min-height:220px}.actions-bar .btn{flex:1 1 150px}}
</style>
</head>
<body>
<header><div><h1>设备校准</h1><p class="subtitle">新手机先完成一次全流程验证,后续批量开票将自动匹配此设备配置。</p></div><div class="header-actions"><span class="pill warn" id="calibrationStatus">读取设备中</span><a class="back-link" href="/">← 返回主界面</a></div></header>
<main class="calibrate-main">
<div class="layout">
<div class="sidebar">
<div class="sidebar-card"><p class="sidebar-label">校准步骤</p><div class="step-list" id="stepList"></div><div class="screen-info" id="screenInfo">加载中...</div></div>
</div>
<div class="main">
<div class="main-inner">
<div class="sample-column" id="samplePanel">
<div class="empty">选择左侧步骤后显示样图</div>
</div>
<div class="action-column">
<div class="panel" id="stepPanel">
<div class="empty">← 点击左侧步骤开始校准</div>
</div>
</div>
</div>
<div class="actions-bar">
<button class="btn btn-danger" onclick="startAutomaticCalibration()" id="btnAutoRun">⚠️ 自动真实校准</button>
<button class="btn btn-success" onclick="analyzeAll()" id="btnAnalyzeAll">🔍 全部分析</button>
<button class="btn btn-primary" onclick="saveProfile()" id="btnSave">💾 保存设备配置</button>
<button class="btn" onclick="location.href='/'">← 返回主界面</button>
</div>
</div>
</div>
</main>
<script>
let steps=[], currentStep=null, captureStatus={}, analysisResults={};
async function init(){
const res=await fetch('/api/calibrate/steps'); steps=await res.json();
const sr=await fetch('/api/calibrate/screen'); const si=await sr.json();
document.getElementById('screenInfo').innerHTML=si.width
? `屏幕: <span>${si.width}×${si.height}</span>`
: `<span class="fail">未检测到设备</span>`;
const cr=await fetch('/api/calibrate/capture-status'); captureStatus=await cr.json();
const pr=await fetch('/api/calibrate/profile'); const profile=await pr.json();
if(profile.profile&&profile.profile.calibration) analysisResults=profile.profile.calibration;
const status=document.getElementById('calibrationStatus');
const device=profile.device||{};
if(profile.status==='verified'){status.className='pill ok';status.textContent=`已校准 · ${device.model||'当前设备'}`;}
else if(profile.status==='unavailable'){status.className='pill warn';status.textContent='未检测到设备';}
else{status.className='pill warn';status.textContent=`待校准 · ${device.model||'当前设备'}`;}
renderStepList();
}
function renderStepList(){
const el=document.getElementById('stepList');
el.innerHTML=steps.map((s,i)=>{
const cs=captureStatus[s.id]||{};
const ar=analysisResults[s.id]||{};
let cls='step-item';
let badge='';
if(cs.captured&&ar.verified){cls+=' done';badge='';}
else if(cs.captured&&ar.verified===false){cls+=' fail';badge='';}
else if(cs.captured){badge='...';}
if(currentStep===s.id)cls+=' active';
return `<div class="${cls}" onclick="selectStep('${s.id}')">
<div class="step-num">${badge||i+1}</div>
<div class="step-name">${s.name}</div>
</div>`;
}).join('');
}
function selectStep(id){
currentStep=id;
const step=steps.find(s=>s.id===id);
const cs=captureStatus[id]||{};
const ar=analysisResults[id]||{};
renderStepList();
// 左边样图区
let sampleHtml='<div class="panel">';
sampleHtml+='<div class="sample-label">📷 参考样图</div>';
sampleHtml+='<div class="screenshot-frame">';
sampleHtml+='<img src="/api/calibrate/screenshot?id='+id+'_sample&t='+Date.now()+'" ';
sampleHtml+='style="max-height:450px;width:auto;border-radius:6px;object-fit:contain" ';
sampleHtml+='onerror="this.parentElement.innerHTML=\'<div class=\\\'empty\\\' style=\\\'padding:80px 0;\\\'>暂无可信样图<br>请在正确页面截图后,点击「设为样图」</div>\'">';
sampleHtml+='</div></div>';
document.getElementById('samplePanel').innerHTML=sampleHtml;
// 右边操作区
let html='<h2>'+step.name+'</h2>';
if(step.desc)html+='<div class="desc">'+step.desc+'</div>';
const hints={
list_page: '<strong>特征:</strong>显示多个乘客姓名、脱敏身份证、每个条目有「开具」按钮',
verify_page: '<strong>特征:</strong>显示「请输入乘车人xxx证件号码后8位」有输入框和「核验」按钮',
invoice_info: '<strong>特征:</strong>显示发票抬头名称、税号,有「提交」按钮',
invoice_confirm: '<strong>特征:</strong>显示「发票信息确认」,有「确认」按钮',
success_page: '<strong>特征:</strong>显示「发票开具成功」,有发票预览、「下载」和「继续开票」按钮',
no_ticket: '<strong>特征:</strong>显示「您没有可开具电子发票的客票」',
loading: '<strong>特征:</strong>显示「发票开具中」/loading图标',
album_qr: '<strong>特征:</strong>手机相册显示QR码缩略图'
};
if(hints[id])html+='<div class="desc" style="background:#eafaf0;border:1px solid #bcebd0;border-radius:10px;padding:10px 12px;color:#16803c;margin-bottom:12px;">'+hints[id]+'</div>';
html+='<div class="btn-row">';
html+='<button class="btn btn-primary" onclick="capture(\''+id+'\')">📸 截图</button>';
html+='<button class="btn btn-success" onclick="analyzeOne(\''+id+'\')"'+(cs.captured?'':'disabled')+'>🔍 分析本步</button>';
if(cs.captured)html+='<button class="btn" onclick="approveSample(\''+id+'\')">☆ 设为样图</button>';
html+='</div>';
if(cs.captured){
html+='<div style="margin-top:8px"><strong>当前截图:</strong></div>';
html+='<div class="screenshot-frame"><img src="/api/calibrate/screenshot?id='+id+'&t='+Date.now()+'" alt="screenshot"></div>';
}
if(ar.step_id||ar.error){
html+=renderResult(ar,step);
}
document.getElementById('stepPanel').innerHTML=html;
}
function renderResult(ar,step){
let h='<div class="result">';
if(ar.error){h+=`<span class="fail">❌ ${ar.error}</span></div>`;return h;}
if(ar.skipped){h+=`<span class="badge badge-skip">未截图</span></div>`;return h;}
h+=`<div><span class="label">页面类型:</span> ${ar.page_detected||'?'}`
h+=` <span class="badge ${ar.page_ok?'badge-ok':'badge-fail'}">${ar.page_ok?'符合预期':'不符'}</span></div>`;
if(ar.page_summary)h+=`<div class="label">${ar.page_summary}</div>`;
if(ar.screen_size)h+=`<div class="label">截图尺寸: ${ar.screen_size[0]}×${ar.screen_size[1]}</div>`;
if(ar.buttons&&Object.keys(ar.buttons).length){
h+='<table class="coord-table"><tr><th>按钮</th><th>找到</th><th>坐标</th><th>耗时</th></tr>';
for(const[name,b]of Object.entries(ar.buttons)){
h+=`<tr><td>${name}</td><td class="${b.found?'ok':'fail'}">${b.found?'':''}</td>`;
h+=`<td>${b.xy?`(${b.xy[0]},${b.xy[1]})`:'-'}</td><td>${b.elapsed||'-'}s</td></tr>`;
}
h+='</table>';
}
if(ar.qr_thumbnail)h+=`<div><span class="label">QR缩略图:</span> ${ar.qr_thumbnail.found?''+(ar.qr_thumbnail.xy||[]):''}</div>`;
h+=`<div style="margin-top:8px"><span class="label">综合:</span> `;
h+=`<span class="badge ${ar.verified?'badge-ok':'badge-fail'}">${ar.verified?'✓ 通过':'✗ 未通过'}</span></div>`;
h+='</div>';
return h;
}
async function capture(id){
document.getElementById('stepPanel').innerHTML='<div class="empty">📸 截图中...</div>';
const res=await fetch('/api/calibrate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'capture',step_id:id})});
const data=await res.json();
if(data.ok){captureStatus[id]={captured:true};selectStep(id);renderStepList();}
else{alert('截图失败: '+(data.error||'未知错误'));}
}
async function analyzeOne(id){
document.getElementById('stepPanel').innerHTML='<div class="empty">🔍 分析中...</div>';
const res=await fetch('/api/calibrate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'analyze',step_id:id})});
const data=await res.json();
analysisResults[id]=data;selectStep(id);renderStepList();
}
async function approveSample(id){
if(!confirm('确认当前截图就是「'+(steps.find(s=>s.id===id)||{}).name+'」的正确页面?它将替换该步骤的参考样图。'))return;
const res=await fetch('/api/calibrate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'approve-sample',step_id:id})});
const data=await res.json();
if(data.ok){selectStep(id);}
else{alert('设为样图失败:'+(data.error||'未知错误'));}
}
async function analyzeAll(){
document.getElementById('btnAnalyzeAll').disabled=true;
document.getElementById('btnAnalyzeAll').textContent='🔍 分析中...';
const res=await fetch('/api/calibrate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'analyze',step_id:'*'})});
const data=await res.json();
analysisResults=data;renderStepList();
if(currentStep)selectStep(currentStep);
document.getElementById('btnAnalyzeAll').disabled=false;
document.getElementById('btnAnalyzeAll').textContent='🔍 全部分析';
}
async function saveProfile(){
const res=await fetch('/api/calibrate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'save'})});
const data=await res.json();
if(data.screen){alert('✅ 设备配置已保存\n屏幕: '+data.screen.width+'×'+data.screen.height+'\n滑动幅度: '+data.computed.swipe_amplitude+'px\nDuration: '+data.computed.swipe_duration_ms+'ms');}
else{alert('保存失败: '+(data.error||'未知错误'));}
}
async function startAutomaticCalibration(){
const res=await fetch('/api/calibrate/candidates'); const data=await res.json();
if(!data.rows||!data.rows.length){alert(data.error||'当前列表页没有可用于校准的待开票乘客');return;}
const options=data.rows.map((r,i)=>`${i+1}. ${r.masked_name}${r.real_name||'未匹配'}`).join('\n');
const selected=prompt('选择用于真实开票校准的乘客编号:\n'+options);
const index=Number(selected)-1;
if(!Number.isInteger(index)||!data.rows[index])return;
const person=data.rows[index];
if(!confirm(`将为 ${person.masked_name} 执行一次真实开票,并记录全过程截图/XML。确认后会自动点击“确认开票”。`))return;
const start=await fetch('/api/calibrate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'auto-run',masked_name:person.masked_name})});
const result=await start.json();
alert(result.started ? '已开始真实校准,请在主界面查看运行日志。' : ('启动失败:'+(result.reason||result.error||'未知错误')));
}
init();
</script>
</body>
</html>"""
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()