autotrain/scripts/web_ui.py
xinxin6623 137eaed2af feat: improve web ui automation runner
Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-12 00:42:20 +08:00

1255 lines
63 KiB
Python
Raw 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
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"
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 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 ""
if pstatus == "completed":
return {"display": "已开票", "tone": "ok", "reason": "总进度表已完成"}
if pstatus == "no_ticket":
return {"display": "无可开票", "tone": "muted", "reason": "12306 未查询到可开发票客票"}
if pstatus == "in_progress" or queue_status == "clicked":
return {"display": "处理中", "tone": "info", "reason": "脚本正在处理这行"}
if pstatus == "failed":
return {"display": "失败", "tone": "bad", "reason": "上次运行未完成"}
if pstatus == "error":
return {"display": "运行错误", "tone": "bad", "reason": "脚本异常"}
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["updated_at"] = progress_row.get("updated_at", "") if progress_row else ""
qr_order.append(item)
counts["no_ticket"] = 0
for person in 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
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 "",
}
)
with RUNNER_LOCK:
running = RUNNER is not None and RUNNER.poll() is None
return_code = None if RUNNER is None else RUNNER.poll()
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):
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, "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 = "native", invoice_success_action: str = "", invoice_output_dir: str = "") -> dict[str, object]:
global RUNNER
with RUNNER_LOCK:
if RUNNER is not None and RUNNER.poll() is None:
return {"started": False, "reason": "runner_already_running"}
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")
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 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")
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}
def stop_runner() -> dict[str, object]:
global RUNNER
with RUNNER_LOCK:
if RUNNER is None or RUNNER.poll() is not None:
return {"stopped": False, "reason": "runner_not_running"}
RUNNER.terminate()
for _ in range(10):
if RUNNER.poll() is not None:
return {"stopped": True}
time.sleep(0.2)
RUNNER.kill()
return {"stopped": True, "killed": 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"""<!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; }
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; }
.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; }
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>
</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>
<div class="control-card">
<div class="card-title">② 参数与执行 <span>Run</span></div>
<div class="field-grid">
<label>模式 <select id="runMode"><option value="native">列表页(视觉)</option><option value="h5">H5开票单</option></select></label>
<label>最多页数 <input id="maxBatches" type="number" min="1" max="200" value="20"></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>
</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>身份证后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>身份证后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 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 批处理后自动生成';
$('qrOrderBody').innerHTML = qrOrder.map(r => `<tr><td>${h(r.seq)}</td><td>${badge(r.display_status, r.display_tone)}</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>${badge(r.display_status, r.display_tone)}</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('');
}
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/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);
};
$('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({ qr_url: lastDecodedUrl, 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 });
$('stopRun').onclick = () => postJson('/api/stop');
$('nextPage').onclick = () => postJson('/api/next-page');
$('scrollTop').onclick = () => postJson('/api/scroll-top');
// --- 粘贴二维码 ---
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 == "/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", 20)),
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", "native")),
str(body.get("invoice_success_action", "")),
str(body.get("invoice_output_dir", "")),
))
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()
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 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()