autotrain/scripts/web_ui.py
xinxin6623 7a01ed4db9 feat: add invoice batch wrap-up (name extraction + roster annotation) and web ui automation improvements
- add scripts/annotate_roster_invoices.py: match passenger names to invoice PDFs, write status + relative encoded hyperlinks into roster xlsx
- add scripts/add_invoice_passenger_name.py, scripts/calibrate.py
- add .opencode/skills/invoice-wrapup skill
- improve invoice_tool.py / web_ui.py / vision_fallback.py automation
- add device/screen/windows adaptation docs
- ignore out/ (delivered invoice PDFs contain sensitive data)
2026-07-10 00:11:45 +08:00

1542 lines
78 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
import calibrate
PROJECT_ROOT = invoice_tool.PROJECT_ROOT
PYTHON = sys.executable
RUNNER: subprocess.Popen[str] | None = None
RUNNER_LOCK = threading.Lock()
RUNNER_LOG = PROJECT_ROOT / "work" / "web_runner.log"
RUNNER_STOPPED_BY_USER: dict[str, object] | None = None
UPLOAD_DIR = PROJECT_ROOT / "data" / "input"
def clear_runner_log(reason: str = "") -> None:
RUNNER_LOG.parent.mkdir(parents=True, exist_ok=True)
header = f"[log cleared] {reason}\n" if reason else ""
RUNNER_LOG.write_text(header, encoding="utf-8")
def csv_rows(path: Path) -> list[dict[str, str]]:
if not path.exists():
return []
with path.open("r", encoding="utf-8-sig", newline="") as fh:
return list(csv.DictReader(fh))
def backup_runtime_tables(label: str = "start") -> dict[str, object]:
"""启动批处理前备份运行期三张表。"""
stamp = time.strftime("%Y%m%d-%H%M%S")
backup_dir = PROJECT_ROOT / "archive" / "runtime-backups" / f"{stamp}-{label}"
copied: list[str] = []
for path in [invoice_tool.PROGRESS_CSV, invoice_tool.QR_ORDER_CSV, invoice_tool.PAGE_QUEUE_CSV]:
if not path.exists():
continue
backup_dir.mkdir(parents=True, exist_ok=True)
dest = backup_dir / path.name
shutil.copy2(path, dest)
copied.append(str(dest))
return {"dir": str(backup_dir), "files": copied}
def write_json(handler: BaseHTTPRequestHandler, data: object, status: int = 200) -> None:
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
handler.send_response(status)
handler.send_header("Content-Type", "application/json; charset=utf-8")
handler.send_header("Content-Length", str(len(body)))
handler.end_headers()
handler.wfile.write(body)
def device_status() -> dict[str, object]:
state = invoice_tool.run_adb(["get-state"], check=False)
connected = state.returncode == 0 and state.stdout.strip() == "device"
status: dict[str, object] = {
"connected": connected,
"state": state.stdout.strip() or "unknown",
"current_package": "",
"current_activity": "",
"in_12306": False,
"message": "手机已连接" if connected else (state.stderr.strip() or state.stdout.strip() or "未检测到手机"),
}
if not connected:
return status
window = invoice_tool.run_adb(["shell", "dumpsys", "window"], check=False)
focus_lines = [line.strip() for line in window.stdout.splitlines() if "mCurrentFocus=" in line or "mFocusedApp=" in line]
focus_text = " ".join(focus_lines)
match = re.search(r"u0\s+([^/\s]+)/([^\s}]+)", focus_text)
if match:
status["current_package"] = match.group(1)
status["current_activity"] = match.group(2)
status["in_12306"] = match.group(1) == "com.MobileTicket"
status["message"] = "当前在 12306" if status["in_12306"] else f"当前在 {match.group(1)}"
else:
status["message"] = "手机已连接,未识别当前前台 App"
return status
PROGRESS_LABELS = {
"completed": {"text": "已开票", "tone": "ok", "reason": ""},
"no_ticket": {"text": "无可开票", "tone": "muted", "reason": "12306 未查询到可开发票客票"},
"in_progress": {"text": "处理中", "tone": "info", "reason": ""},
"failed": {"text": "失败", "tone": "bad", "reason": "上次运行未完成"},
"error": {"text": "运行错误", "tone": "bad", "reason": "脚本异常"},
"pending": {"text": "未操作", "tone": "pending", "reason": ""},
}
MATCH_REASON = {
"no_match": "名单里没有这个脱敏名",
"ambiguous": "同名多人,无法唯一匹配",
"no_button": "未识别到「开具」按钮",
}
def label_for_queue_row(row: dict[str, str], progress_by_key: dict, progress_by_masked: dict) -> dict[str, str]:
masked = row.get("masked_name", "")
real = row.get("real_name", "")
id8 = row.get("id_last8", "")
queue_status = row.get("queue_status", "")
match_status = row.get("match_status", "")
progress_row: dict[str, str] | None = None
if real and id8:
progress_row = progress_by_key.get((real, id8))
if progress_row is None and masked:
progress_row = progress_by_masked.get(masked)
pstatus = progress_row.get("status", "") if progress_row else ""
progress_reason = progress_row.get("reason", "") if progress_row else ""
if pstatus == "completed":
return {"display": "已开票", "tone": "ok", "reason": progress_reason or "总进度表已完成"}
if pstatus == "no_ticket":
return {"display": "无可开票", "tone": "muted", "reason": progress_reason or "12306 未查询到可开发票客票"}
if pstatus == "in_progress" or queue_status == "clicked":
return {"display": "处理中", "tone": "info", "reason": progress_reason or "脚本正在处理这行"}
if pstatus == "failed":
return {"display": "失败", "tone": "bad", "reason": progress_reason or "上次运行未完成"}
if pstatus == "error":
return {"display": "运行错误", "tone": "bad", "reason": progress_reason or "脚本异常"}
if match_status in MATCH_REASON:
return {"display": "识别问题", "tone": "warn", "reason": MATCH_REASON[match_status]}
if match_status == "runtime_error":
return {"display": "运行错误", "tone": "bad", "reason": "上次处理异常"}
if match_status == "skip_progress":
return {"display": "已处理", "tone": "muted", "reason": "进度表已记录,跳过"}
if match_status == "resume_in_progress":
return {"display": "继续中", "tone": "info", "reason": "上次未完成,本次继续"}
if queue_status == "pending":
return {"display": "待处理", "tone": "pending", "reason": ""}
if queue_status == "skipped":
return {"display": "跳过", "tone": "muted", "reason": match_status or ""}
return {"display": queue_status or "未知", "tone": "muted", "reason": match_status or ""}
def label_for_progress_status(status: str) -> dict[str, str]:
return PROGRESS_LABELS.get(status, {"text": status or "未操作", "tone": "pending", "reason": ""})
def status_payload() -> dict[str, object]:
roster = csv_rows(invoice_tool.ROSTER_CSV)
progress = csv_rows(invoice_tool.PROGRESS_CSV)
queue_raw = csv_rows(invoice_tool.PAGE_QUEUE_CSV)
qr_order_raw = csv_rows(invoice_tool.QR_ORDER_CSV)
by_person_key = {
(row.get("real_name", ""), row.get("id_last8", "")): row
for row in progress
if row.get("real_name") or row.get("id_last8")
}
by_real_name = {row.get("real_name", ""): row for row in progress if row.get("real_name")}
by_masked = {row.get("masked_name", ""): row for row in progress if row.get("masked_name")}
roster_status = []
counts = {"total": len(roster), "completed": 0, "failed": 0, "in_progress": 0, "pending": 0}
queue = []
for row in queue_raw:
label = label_for_queue_row(row, by_person_key, by_masked)
item = dict(row)
item["display_status"] = label["display"]
item["display_tone"] = label["tone"]
item["display_reason"] = label["reason"]
queue.append(item)
qr_order = []
for row in qr_order_raw:
real = row.get("real_name", "")
id8 = row.get("id_last8", "")
masked = row.get("masked_name", "")
progress_row = by_person_key.get((real, id8)) if (real or id8) else None
if progress_row is None and masked:
progress_row = by_masked.get(masked)
status = progress_row.get("status", "pending") if progress_row else "pending"
if progress_row is None and not (real or id8):
label = {"text": "名单未匹配", "tone": "warn", "reason": ""}
status = "no_match"
else:
label = label_for_progress_status(status)
item = dict(row)
item["status"] = status
item["display_status"] = label["text"]
item["display_tone"] = label["tone"]
item["display_reason"] = progress_row.get("reason", label.get("reason", "")) if progress_row else label.get("reason", "")
item["updated_at"] = progress_row.get("updated_at", "") if progress_row else ""
qr_order.append(item)
counts["no_ticket"] = 0
for idx, person in enumerate(roster):
person_key = (person.get("姓名", ""), person.get("身份证后8位", ""))
progress_row = by_person_key.get(person_key) or by_real_name.get(person.get("姓名", ""))
status = progress_row.get("status", "pending") if progress_row else "pending"
label = PROGRESS_LABELS.get(status, {"text": status or "未操作", "tone": "pending", "reason": ""})
if status == "completed":
counts["completed"] += 1
elif status == "no_ticket":
counts["no_ticket"] += 1
elif status in {"failed", "error"}:
counts["failed"] += 1
elif status == "in_progress":
counts["in_progress"] += 1
else:
counts["pending"] += 1
# progress 行号 = 开票处理顺序;未在 progress 的用大数排最后
progress_idx = next((i for i, r in enumerate(progress) if r is progress_row), len(progress))
roster_status.append(
{
"name": person.get("姓名", ""),
"id_last8": person.get("身份证后8位", ""),
"status": status,
"display_status": label["text"],
"display_tone": label["tone"],
"masked_name": progress_row.get("masked_name", "") if progress_row else "",
"updated_at": progress_row.get("updated_at", "") if progress_row else "",
"reason": progress_row.get("reason", "") if progress_row else label.get("reason", ""),
"_progress_idx": progress_idx,
"_roster_idx": idx,
}
)
# 完全按开票处理顺序progress 行号)排列,未处理的按名单顺序放最后
roster_status.sort(key=lambda r: (r.get("_progress_idx", 0), r.get("_roster_idx", 0)))
for r in roster_status:
r.pop("_progress_idx", None)
r.pop("_roster_idx", None)
with RUNNER_LOCK:
running = RUNNER is not None and RUNNER.poll() is None
return_code = None if RUNNER is None else RUNNER.poll()
stopped_by_user = bool(
RUNNER_STOPPED_BY_USER
and RUNNER is not None
and RUNNER_STOPPED_BY_USER.get("pid") == RUNNER.pid
)
log_tail = ""
if RUNNER_LOG.exists():
log_tail = "\n".join(RUNNER_LOG.read_text(encoding="utf-8", errors="ignore").splitlines()[-80:])
runner_status = "running" if running else "stopped"
if (not running) and return_code not in (None, 0) and not stopped_by_user:
runner_status = "failed"
config = invoice_tool.load_config(invoice_tool.CONFIG_LOCAL_PATH)
return {
"counts": counts,
"roster": roster_status,
"queue": queue,
"qr_order": qr_order,
"progress": progress,
"runner": {"running": running, "status": runner_status, "return_code": return_code, "stopped_by_user": stopped_by_user, "log_tail": log_tail},
"device": device_status(),
"config": {
"company_title": config.company_title,
"tax_id": config.tax_id,
"receiver_email": config.receiver_email,
"invoice_output_dir": config.invoice_output_dir,
"invoice_success_action": config.invoice_success_action,
"invoice_output_absolute_dir": str(invoice_tool.project_path(config.invoice_output_dir)),
},
"files": {
"roster": str(invoice_tool.ROSTER_CSV),
"progress": str(invoice_tool.PROGRESS_CSV),
"queue": str(invoice_tool.PAGE_QUEUE_CSV),
"qr_order": str(invoice_tool.QR_ORDER_CSV),
},
}
def save_invoice_settings(invoice_success_action: str, invoice_output_dir: str) -> dict[str, object]:
allowed_actions = {"download", "email", "continue"}
action = invoice_success_action if invoice_success_action in allowed_actions else "download"
output_dir = invoice_output_dir.strip() or "output/invoices"
config_path = invoice_tool.CONFIG_LOCAL_PATH
data: dict[str, object] = {}
if config_path.exists():
data = json.loads(config_path.read_text(encoding="utf-8"))
data["invoice_success_action"] = action
data["invoice_output_dir"] = output_dir
config_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
project_dir = invoice_tool.project_path(output_dir)
project_dir.mkdir(parents=True, exist_ok=True)
return {"saved": True, "invoice_success_action": action, "invoice_output_dir": output_dir, "absolute_dir": str(project_dir)}
def start_runner(max_batches: int, max_actions: int, send_email: bool, fast: bool, dry_run: bool = False, scroll_top_first: bool = True, mode: str = "h5", invoice_success_action: str = "", invoice_output_dir: str = "", final_retry_pass: bool = True) -> dict[str, object]:
global RUNNER, RUNNER_STOPPED_BY_USER
with RUNNER_LOCK:
if RUNNER is not None and RUNNER.poll() is None:
return {"started": False, "reason": "runner_already_running"}
backup = backup_runtime_tables("start")
RUNNER_STOPPED_BY_USER = None
RUNNER_LOG.parent.mkdir(parents=True, exist_ok=True)
RUNNER_LOG.write_text("", encoding="utf-8")
log_fh = RUNNER_LOG.open("a", encoding="utf-8")
if backup["files"]:
log_fh.write(f"[prelude] runtime_backup dir={backup['dir']} files={len(backup['files'])}\n")
log_fh.flush()
prelude: list[str] = []
if scroll_top_first:
top_result = subprocess.run(
[PYTHON, "scripts/invoice_tool.py", "android-page-top"],
cwd=PROJECT_ROOT, capture_output=True, text=True, check=False,
)
log_fh.write(f"[prelude] android-page-top rc={top_result.returncode}\n{top_result.stdout}{top_result.stderr}\n")
log_fh.flush()
prelude.append(f"scroll_top: {top_result.stdout.strip().splitlines()[-1] if top_result.stdout.strip() else 'no_output'}")
if mode == "h5":
sub_cmd = "android-h5-page-run"
else:
sub_cmd = "android-vision-page-run"
cmd = [PYTHON, "-u", "scripts/invoice_tool.py", sub_cmd, "--max-batches", str(max_batches)]
if mode == "h5":
cmd.append("--no-resume-after-last-progress")
if max_actions > 0:
cmd += ["--max-actions", str(max_actions)]
if send_email:
cmd.append("--send-email")
if invoice_success_action:
cmd += ["--invoice-success-action", invoice_success_action]
if invoice_output_dir:
cmd += ["--invoice-output-dir", invoice_output_dir]
if fast:
cmd.append("--fast")
if dry_run:
cmd.append("--dry-run")
if not final_retry_pass:
cmd.append("--no-final-retry-pass")
RUNNER = subprocess.Popen(cmd, cwd=PROJECT_ROOT, stdout=log_fh, stderr=subprocess.STDOUT, text=True)
return {"started": True, "pid": RUNNER.pid, "cmd": cmd, "prelude": prelude, "mode": mode, "backup": backup}
def stop_runner() -> dict[str, object]:
global RUNNER, RUNNER_STOPPED_BY_USER
with RUNNER_LOCK:
if RUNNER is None or RUNNER.poll() is not None:
return {"stopped": False, "reason": "runner_not_running"}
RUNNER_STOPPED_BY_USER = {"pid": RUNNER.pid, "at": time.strftime("%Y-%m-%d %H:%M:%S")}
RUNNER.terminate()
for _ in range(10):
if RUNNER.poll() is not None:
return {"stopped": True, "stopped_by_user": True}
time.sleep(0.2)
RUNNER.kill()
return {"stopped": True, "killed": True, "stopped_by_user": True}
def build_vision_page_queue() -> dict[str, object]:
"""用视觉 API 截屏识别当前页乘客,匹配名单+进度,返回队列数据。"""
try:
parsed = invoice_tool.vision_list_rows()
except Exception as exc:
return {"error": str(exc), "rows": [], "meta": f"视觉识别失败: {exc}"}
if not parsed.get("page_ok"):
return {"error": "not_on_list_page", "rows": [], "meta": "当前不在扫码开票单列表页"}
rows = parsed.get("rows") or []
roster = invoice_tool.read_roster()
skip_names = invoice_tool.progress_names({"completed", "no_ticket", "failed", "in_progress"})
queue = []
for row in rows:
masked = row.get("masked_name", "")
id_first = row.get("id_first", "") or ""
id_last = row.get("id_last", "") or ""
real = ""
id8 = ""
if masked in skip_names:
status, tone, reason = "已处理", "muted", "进度表已完成或跳过"
else:
matches = invoice_tool.match_roster(masked, roster, id_first, id_last)
if not matches and (id_first or id_last):
matches = invoice_tool.match_roster(masked, roster)
if len(matches) == 1:
status, tone, reason = "待处理", "pending", f"匹配: {matches[0].get('姓名', '')}"
real = matches[0].get("姓名", "")
id8 = matches[0].get("身份证后8位", "")
elif len(matches) > 1:
status, tone, reason = "识别问题", "warn", f"同名 {len(matches)}"
else:
status, tone, reason = "识别问题", "warn", "不在名单中"
queue.append({
"masked_name": masked,
"real_name": real,
"id_last8": id8,
"id_first": id_first,
"id_last": id_last,
"display_status": status,
"display_tone": tone,
"display_reason": reason,
})
# 同步写入 page_queue CSV,防止定时 refresh() 覆盖
now = invoice_tool.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
csv_rows = []
for q in queue:
csv_rows.append({
"page_id": f"vision_{now}",
"masked_name": q["masked_name"],
"id_first": q.get("id_first", ""),
"id_last": q.get("id_last", ""),
"real_name": q.get("real_name", ""),
"id_last8": q.get("id_last8", ""),
"button_bounds": "",
"match_status": "matched" if q["display_tone"] == "pending" else "skip_progress",
"queue_status": "pending" if q["display_tone"] == "pending" else "skipped",
"attempts": "0",
"updated_at": now,
})
invoice_tool.write_page_queue(csv_rows)
return {
"rows": queue,
"meta": f"本页 {len(rows)} 人 · scroll={parsed.get('scroll_position', '?')} · pending={sum(1 for q in queue if q['display_tone'] == 'pending')}",
"scroll_position": parsed.get("scroll_position"),
}
def launch_railway_app() -> dict[str, object]:
result = invoice_tool.run_adb(
["shell", "monkey", "-p", "com.MobileTicket", "-c", "android.intent.category.LAUNCHER", "1"],
check=False,
)
if result.returncode == 0:
time.sleep(1)
return {
"return_code": result.returncode,
"stdout": result.stdout.strip(),
"stderr": result.stderr.strip(),
"package": "com.MobileTicket",
"device": device_status(),
}
INDEX_HTML = r"""<!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; }
.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>
<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="finalRetryPass" class="toggle-on" title="到底后回顶做第二轮,重试失败项并记入核查表">🔄 查缺补漏</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({ 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');
$('nextPage').onclick = () => postJson('/api/next-page');
$('scrollTop').onclick = () => postJson('/api/scroll-top');
$('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.load_device_profile())
elif parsed.path.startswith("/api/calibrate/screenshot"):
from urllib.parse import parse_qs
qs = parse_qs(parsed.query)
sid = qs.get("id", [""])[0]
img = calibrate.CALIBRATION_DIR / f"{sid}.png"
if img.exists():
data = img.read_bytes()
self.send_response(200)
self.send_header("Content-Type", "image/png")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
else:
write_json(self, {"error": "not_found"}, 404)
elif parsed.path == "/api/status":
write_json(self, status_payload())
elif parsed.path == "/api/device-status":
write_json(self, device_status())
elif parsed.path == "/api/progress-csv":
self.send_file(invoice_tool.PROGRESS_CSV, "text/csv; charset=utf-8")
elif parsed.path == "/api/qr-order-csv":
self.send_file(invoice_tool.QR_ORDER_CSV, "text/csv; charset=utf-8")
else:
write_json(self, {"error": "not_found"}, 404)
def do_POST(self) -> None:
parsed = urlparse(self.path)
try:
if parsed.path == "/api/upload-roster":
self.handle_upload_roster()
elif parsed.path == "/api/start":
body = self.read_json()
write_json(self, start_runner(
int(body.get("max_batches", 200)),
int(body.get("max_actions", 0)),
bool(body.get("send_email", True)),
bool(body.get("fast", True)),
bool(body.get("dry_run", False)),
bool(body.get("scroll_top_first", True)),
str(body.get("mode", "h5")),
str(body.get("invoice_success_action", "")),
str(body.get("invoice_output_dir", "")),
bool(body.get("final_retry_pass", True)),
))
elif parsed.path == "/api/stop":
write_json(self, stop_runner())
elif parsed.path == "/api/settings":
body = self.read_json()
write_json(self, save_invoice_settings(
str(body.get("invoice_success_action", "download")),
str(body.get("invoice_output_dir", "output/invoices")),
))
elif parsed.path == "/api/launch-12306":
result = launch_railway_app()
write_json(self, result, 200 if result["return_code"] == 0 else 500)
elif parsed.path == "/api/build-queue":
result = subprocess.run([PYTHON, "scripts/invoice_tool.py", "android-build-page-queue"], cwd=PROJECT_ROOT, capture_output=True, text=True, check=False)
write_json(self, {"return_code": result.returncode, "stdout": result.stdout, "stderr": result.stderr}, 200 if result.returncode == 0 else 500)
elif parsed.path == "/api/next-page":
result = subprocess.run([PYTHON, "scripts/invoice_tool.py", "android-page-next", "--rebuild"], cwd=PROJECT_ROOT, capture_output=True, text=True, check=False)
write_json(self, {"return_code": result.returncode, "stdout": result.stdout, "stderr": result.stderr}, 200 if result.returncode == 0 else 500)
elif parsed.path == "/api/scroll-top":
result = subprocess.run([PYTHON, "scripts/invoice_tool.py", "android-page-top"], cwd=PROJECT_ROOT, capture_output=True, text=True, check=False)
write_json(self, {"return_code": result.returncode, "stdout": result.stdout, "stderr": result.stderr}, 200 if result.returncode == 0 else 500)
elif parsed.path == "/api/decode-qr":
self.handle_decode_qr()
elif parsed.path == "/api/save-qr-image":
self.handle_save_qr_image()
elif parsed.path == "/api/open-on-phone":
self.handle_open_on_phone()
elif parsed.path == "/api/reopen-qr":
clear_runner_log("reopen qr page")
body = self.read_json()
qr_url = str(body.get("qr_url", "")).strip()
if qr_url:
invoice_tool.save_qr_url(qr_url)
if body.get("agent_id_last8"):
saved = invoice_tool.save_agent_id_last8(str(body.get("agent_id_last8", "")))
print(f"[web-ui] saved agent_id_last8={saved}", flush=True)
result = invoice_tool.reopen_qr_url()
write_json(self, {
"reopened": result,
"error": "自动扫码失败:请确认已粘贴二维码截图,手机在 12306 首页或电子发票相关页面" if not result else "",
"device": device_status(),
})
elif parsed.path == "/api/vision-page-queue":
write_json(self, build_vision_page_queue())
elif parsed.path == "/api/mark-completed":
self.handle_mark_completed()
elif parsed.path == "/api/calibrate":
self.handle_calibrate()
else:
write_json(self, {"error": "not_found"}, 404)
except Exception as exc:
write_json(self, {"error": str(exc)}, 500)
def read_json(self) -> dict[str, object]:
length = int(self.headers.get("Content-Length", "0"))
if not length:
return {}
return json.loads(self.rfile.read(length).decode("utf-8"))
def send_file(self, path: Path, content_type: str) -> None:
if not path.exists():
write_json(self, {"error": "file_not_found", "file": str(path)}, 404)
return
body = path.read_bytes()
self.send_response(200)
self.send_header("Content-Type", content_type)
self.send_header("Content-Disposition", f'attachment; filename="{path.name}"')
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def handle_upload_roster(self) -> None:
form = cgi.FieldStorage(fp=self.rfile, headers=self.headers, environ={"REQUEST_METHOD": "POST", "CONTENT_TYPE": self.headers.get("Content-Type", "")})
item = form["file"]
filename = Path(item.filename or "roster.xlsx").name
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
dest = UPLOAD_DIR / filename
with dest.open("wb") as fh:
shutil.copyfileobj(item.file, fh)
result = subprocess.run([PYTHON, "scripts/invoice_tool.py", "import-roster", "--input", str(dest)], cwd=PROJECT_ROOT, capture_output=True, text=True, check=False)
write_json(self, {"file": str(dest), "return_code": result.returncode, "stdout": result.stdout, "stderr": result.stderr}, 200 if result.returncode == 0 else 500)
def handle_decode_qr(self) -> None:
body = self.read_json()
image_b64 = str(body.get("image", "")).strip()
if not image_b64:
write_json(self, {"results": [], "error": "未收到图片数据"}, 400)
return
# 去掉 data URL 前缀 (data:image/png;base64,...)
if "," in image_b64 and image_b64.startswith("data:"):
image_b64 = image_b64.split(",", 1)[1]
try:
image_bytes = base64.b64decode(image_b64)
except Exception:
write_json(self, {"results": [], "error": "图片数据解码失败,请重试"}, 400)
return
invoice_tool.save_qr_image_bytes(image_bytes)
result = invoice_tool.decode_qr_image(image_bytes)
# 保存解码结果,供脚本自动重进使用
if result.get("results"):
invoice_tool.save_qr_url(result["results"][0])
result["qr_pushed"] = invoice_tool.generate_and_push_qr()
result["saved"] = True
else:
result["image_saved"] = True
write_json(self, dict(result))
def handle_save_qr_image(self) -> None:
body = self.read_json()
image_b64 = str(body.get("image", "")).strip()
if not image_b64:
write_json(self, {"saved": False, "error": "未收到图片数据"}, 400)
return
if "," in image_b64 and image_b64.startswith("data:"):
image_b64 = image_b64.split(",", 1)[1]
try:
image_bytes = base64.b64decode(image_b64)
except Exception:
write_json(self, {"saved": False, "error": "图片数据解码失败,请重试"}, 400)
return
path = invoice_tool.save_qr_image_bytes(image_bytes)
clear_runner_log("new qr image pasted")
write_json(self, {"saved": True, "image_saved": True, "path": str(path), "size": path.stat().st_size})
def handle_mark_completed(self) -> None:
body = self.read_json()
rows = body.get("rows") or []
marked = 0
for item in rows:
masked = item.get("masked", "")
real = item.get("real", "")
id8 = item.get("id8", "")
if masked or real:
invoice_tool.update_progress(masked, real, id8, "completed")
marked += 1
write_json(self, {"marked": marked})
def handle_open_on_phone(self) -> None:
body = self.read_json()
url = str(body.get("url", "")).strip()
if not url:
write_json(self, {"return_code": -1, "stdout": "", "stderr": "未提供 URL", "device": device_status()}, 400)
return
adb_result = invoice_tool.run_adb(
["shell", "am", "start", "-a", "android.intent.action.VIEW", "-d", url],
check=False,
)
time.sleep(0.5)
write_json(self, {
"return_code": adb_result.returncode,
"stdout": adb_result.stdout.strip(),
"stderr": adb_result.stderr.strip(),
"device": device_status(),
}, 200 if adb_result.returncode == 0 else 500)
def handle_calibrate(self) -> None:
body = self.read_json()
action = str(body.get("action", ""))
if action == "capture":
step_id = str(body.get("step_id", ""))
write_json(self, calibrate.capture_screenshot(step_id))
elif action == "analyze":
step_id = str(body.get("step_id", ""))
if step_id == "*":
write_json(self, calibrate.analyze_all())
else:
write_json(self, calibrate.analyze_step(step_id))
elif action == "save":
write_json(self, calibrate.save_device_profile(calibrate.analyze_all()))
else:
write_json(self, {"error": "unknown_action"}, 400)
CALIBRATE_HTML = r"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>设备校准 · 铁路发票</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,system-ui,sans-serif;background:#1a1a2e;color:#e0e0e0;padding:20px}
h1{font-size:1.4rem;margin-bottom:16px;color:#00d4ff}
.layout{display:flex;gap:20px;max-width:1400px;margin:0 auto}
.sidebar{width:280px;flex-shrink:0}
.main{flex:1;min-width:0}
.step-list{background:#16213e;border-radius:10px;padding:12px}
.step-item{padding:10px 12px;border-radius:8px;cursor:pointer;margin-bottom:4px;display:flex;align-items:center;gap:8px;transition:background .15s}
.step-item:hover{background:#0f3460}
.step-item.active{background:#0f3460}
.step-num{background:#333;width:24px;height:24px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:.8rem;flex-shrink:0}
.step-item.done .step-num{background:#2ecc71}
.step-item.fail .step-num{background:#e74c3c}
.step-name{font-size:.9rem}
.panel{background:#16213e;border-radius:10px;padding:24px;margin-bottom:16px}
.panel h2{font-size:1.1rem;margin-bottom:8px;color:#00d4ff}
.panel .desc{color:#888;font-size:.9rem;margin-bottom:16px;line-height:1.5}
.btn{padding:8px 20px;border:none;border-radius:6px;cursor:pointer;font-size:.9rem;font-weight:600;transition:opacity .15s}
.btn-primary{background:#00d4ff;color:#1a1a2e}
.btn-success{background:#2ecc71;color:#fff}
.btn:disabled{opacity:.4;cursor:not-allowed}
.btn-row{display:flex;gap:10px;margin:16px 0}
.screenshot{max-width:100%;border-radius:8px;margin:12px 0;border:1px solid #333}
.result{background:#0d1b2a;border-radius:8px;padding:14px;margin-top:12px;font-size:.85rem;line-height:1.6}
.result .ok{color:#2ecc71}
.result .fail{color:#e74c3c}
.result .label{color:#888}
.badge{display:inline-block;padding:2px 8px;border-radius:4px;font-size:.75rem;margin-left:6px}
.badge-ok{background:#1a4a2e;color:#2ecc71}
.badge-fail{background:#4a1a1a;color:#e74c3c}
.badge-skip{background:#333;color:#888}
.coord-table{width:100%;border-collapse:collapse;margin-top:8px;font-size:.85rem}
.coord-table th,.coord-table td{border:1px solid #333;padding:6px 10px;text-align:left}
.coord-table th{background:#0f3460;color:#00d4ff}
.actions-bar{position:sticky;bottom:0;background:#1a1a2e;padding:12px 0;border-top:1px solid #333}
.empty{color:#555;text-align:center;padding:40px}
.screen-info{background:#0d1b2a;border-radius:8px;padding:12px;margin-bottom:16px;font-size:.85rem}
.screen-info span{color:#00d4ff;font-weight:600}
</style>
</head>
<body>
<h1>📱 设备校准 · 屏幕适配</h1>
<div class="layout">
<div class="sidebar">
<div class="step-list" id="stepList"></div>
<div class="screen-info" id="screenInfo">加载中...</div>
</div>
<div class="main">
<div class="panel" id="stepPanel">
<div class="empty">← 点击左侧步骤开始校准</div>
</div>
<div class="actions-bar">
<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>
<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.calibration) analysisResults=profile.calibration;
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 html=`<h2>${step.id==='screen'?'':step.name}</h2>`;
if(step.desc)html+=`<div class="desc">${step.desc}</div>`;
html+=`<div class="btn-row">
<button class="btn btn-primary" onclick="capture('${id}')">📸 截图</button>
<button class="btn btn-success" onclick="analyzeOne('${id}')" ${cs.captured?'':'disabled'}>🔍 分析本步</button>
</div>`;
if(cs.captured){
html+=`<img class="screenshot" src="/api/calibrate/screenshot?id=${id}&t=${Date.now()}" alt="screenshot">`;
}
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 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||'未知错误'));}
}
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()