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