autotrain/scripts/web_ui.py
2026-06-11 12:18:53 +08:00

1026 lines
50 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 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 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 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)
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)
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,
"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),
},
}
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; --green: #16803c; --red: #b42318; --amber: #a15c00; --border: #d7dee8; }
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f5f7fa; color: #1f2937; }
header { background: #fff; border-bottom: 1px solid var(--border); padding: 18px 24px; display: flex; align-items: center; justify-content: space-between; gap: 16px; }
h1 { font-size: 20px; margin: 0; letter-spacing: 0; }
.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: 4px 10px; background: #fff; color: #4b5563; font-size: 13px; }
.pill.ok { background: #dcfce7; color: var(--green); border-color: #bbf7d0; }
.pill.warn { background: #fef3c7; color: var(--amber); border-color: #fde68a; }
.pill.bad { background: #fee2e2; color: var(--red); border-color: #fecaca; }
main { padding: 20px 24px 40px; display: grid; gap: 16px; }
section { background: #fff; border: 1px solid var(--border); border-radius: 8px; padding: 16px; }
h2 { font-size: 16px; margin: 0 0 12px; }
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 12px; }
.stat { border: 1px solid var(--border); border-radius: 8px; padding: 12px; background: #fbfcfe; }
.stat b { display: block; font-size: 24px; margin-bottom: 4px; }
.controls { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
button, input::file-selector-button { border: 1px solid var(--border); background: #fff; border-radius: 6px; padding: 8px 12px; font-size: 14px; cursor: pointer; }
button.primary { background: var(--blue); color: #fff; border-color: var(--blue); }
button.danger { color: #fff; background: var(--red); border-color: var(--red); }
input[type="number"] { width: 72px; padding: 8px; border: 1px solid var(--border); border-radius: 6px; }
input[type="text"], select { padding: 8px; border: 1px solid var(--border); border-radius: 6px; background: #fff; }
.path-input { min-width: 360px; flex: 1 1 360px; }
label { font-size: 14px; color: #374151; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { border-bottom: 1px solid #edf0f5; text-align: left; padding: 8px; white-space: nowrap; }
th { background: #f8fafc; position: sticky; top: 0; }
.table-wrap { max-height: 430px; overflow: auto; border: 1px solid var(--border); border-radius: 8px; }
.queue-title { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.queue-title-main { min-width: 0; }
.queue-actions { display: flex; flex: 0 0 auto; gap: 6px; }
.queue-actions button { font-size: 12px; padding: 4px 10px; white-space: nowrap; }
.queue-table { min-width: 620px; }
.badge { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 12px; background: #e5e7eb; color: #4b5563; }
.badge.tone-ok { background: #dcfce7; color: var(--green); }
.badge.tone-bad { background: #fee2e2; color: var(--red); }
.badge.tone-warn { background: #fef3c7; color: var(--amber); }
.badge.tone-info { background: #dbeafe; color: var(--blue); }
.badge.tone-muted { background: #f3f4f6; color: #4b5563; }
.badge.tone-pending { background: #f3f4f6; color: #6b7280; border: 1px dashed #d1d5db; }
.reason-cell { color: #6b7280; font-size: 12px; max-width: 280px; white-space: normal; }
.dropzone { border: 2px dashed #c7d2e0; border-radius: 8px; padding: 14px 16px; background: #fbfcfe; display: flex; align-items: center; gap: 12px; cursor: pointer; transition: all 0.15s; flex: 1 1 320px; min-width: 280px; }
.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: #4b5563; flex: 1; }
.dropzone strong { color: #1f2937; }
.dropzone input[type=file] { display: none; }
.paste-zone { border: 2px dashed #c7d2e0; border-radius: 8px; padding: 14px 16px; background: #fbfcfe; cursor: pointer; transition: all 0.15s; text-align: center; outline: none; margin-top: 10px; }
.paste-zone:hover, .paste-zone:focus { border-color: var(--blue); background: #f0f6ff; }
.paste-zone.success { border-color: var(--green); background: #dcfce7; border-style: solid; }
.paste-zone.error { border-color: var(--red); background: #fee2e2; border-style: solid; }
.paste-zone-text { font-size: 13px; color: #4b5563; }
.paste-zone strong { color: #1f2937; }
.qr-result { display: flex; align-items: center; gap: 10px; margin-top: 10px; padding: 10px 14px; background: #f0f6ff; border: 1px solid #bbd6fe; border-radius: 8px; 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: #111827; color: #e5e7eb; padding: 12px; border-radius: 8px; max-height: 260px; overflow: auto; font-size: 12px; }
.muted { color: #6b7280; font-size: 13px; }
.grid2 { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; }
@media (max-width: 900px) { .stats, .grid2 { grid-template-columns: 1fr; } header { align-items: flex-start; flex-direction: column; } }
</style>
</head>
<body>
<header>
<h1>铁路系统发票批量开票</h1>
<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>
<h2>名单上传与运行</h2>
<div class="controls">
<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>
<button id="launch12306">启动 12306</button>
<button id="reopenQr">重新进入开票页</button>
<button id="buildQueue">抓当前页队列</button>
<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>
<label><input id="sendEmail" type="checkbox"> 兼容旧流程:发送邮箱</label>
<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>模式 <select id="runMode"><option value="native">列表页(视觉)</option><option value="h5">H5开票单</option></select></label>
<button class="primary" id="startRun">开始运行</button>
<button class="danger" id="stopRun">停止</button>
<button id="scrollTop">回到顶部</button>
<button id="nextPage">上滑下一页</button>
</div>
<div class="paste-zone" id="pasteZone" tabindex="0">
<span class="paste-zone-text"><strong>📋 粘贴二维码截图</strong> — 在此处 Ctrl+V / Cmd+V 粘贴<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>
<div id="qrResult" style="display:none;" class="qr-result">
<code id="qrUrl"></code>
<button id="openOnPhone">📱 在手机打开</button>
<button id="copyQrUrl">📋 复制链接</button>
</div>
<p class="muted" id="message"></p>
</section>
<section>
<h2>保存设置</h2>
<div class="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>
<p class="muted" id="settingsHint">保存目录可填相对项目路径或绝对路径。</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>
<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>`;
}
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';
}
$('settingsHint').textContent = `当前保存到:${data.config.invoice_output_absolute_dir || data.config.invoice_output_dir || 'output/invoices'}`;
}
$('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(' / ') : '尚未抓取本页队列';
$('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 () => {
$('message').textContent = '正在手机重新进入开票页...';
const data = await api('/api/reopen-qr', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: '{}' });
$('message').textContent = data.reopened ? '已重新进入' : '重进失败: 请先在粘贴区解码一个二维码';
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 () => {
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 })
});
$('message').textContent = data.saved ? `设置已保存:${data.absolute_dir}` : '设置保存失败';
settingsDirty = false;
await refresh();
};
$('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;
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/decode-qr', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: base64 }),
});
const data = await res.json();
if (data.results && data.results.length > 0) {
lastDecodedUrl = data.results[0];
qrUrl.textContent = lastDecodedUrl;
qrResult.style.display = 'flex';
pasteHint.textContent = `已识别 ${data.results.length} 个二维码`;
pasteZone.classList.add('success');
setTimeout(() => pasteZone.classList.remove('success'), 3000);
} else {
qrResult.style.display = 'none';
pasteHint.textContent = data.error || '未识别到二维码';
pasteZone.classList.add('error');
setTimeout(() => pasteZone.classList.remove('error'), 2000);
}
} 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("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/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/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")
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/open-on-phone":
self.handle_open_on_phone()
elif parsed.path == "/api/reopen-qr":
result = invoice_tool.reopen_qr_url()
write_json(self, {"reopened": result, "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
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
write_json(self, dict(result))
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:
invoice_tool.ensure_dirs()
server = ThreadingHTTPServer(("127.0.0.1", 8765), Handler)
print("UI: http://127.0.0.1:8765")
server.serve_forever()
if __name__ == "__main__":
main()