1026 lines
50 KiB
Python
1026 lines
50 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 re
|
||
import sys
|
||
import shutil
|
||
import subprocess
|
||
import threading
|
||
import time
|
||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||
from pathlib import Path
|
||
from urllib.parse import urlparse
|
||
|
||
import invoice_tool
|
||
|
||
PROJECT_ROOT = invoice_tool.PROJECT_ROOT
|
||
PYTHON = sys.executable
|
||
|
||
RUNNER: subprocess.Popen[str] | None = None
|
||
RUNNER_LOCK = threading.Lock()
|
||
RUNNER_LOG = PROJECT_ROOT / "work" / "web_runner.log"
|
||
UPLOAD_DIR = PROJECT_ROOT / "data" / "input"
|
||
|
||
|
||
def csv_rows(path: Path) -> list[dict[str, str]]:
|
||
if not path.exists():
|
||
return []
|
||
with path.open("r", encoding="utf-8-sig", newline="") as fh:
|
||
return list(csv.DictReader(fh))
|
||
|
||
|
||
def write_json(handler: BaseHTTPRequestHandler, data: object, status: int = 200) -> None:
|
||
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
||
handler.send_response(status)
|
||
handler.send_header("Content-Type", "application/json; charset=utf-8")
|
||
handler.send_header("Content-Length", str(len(body)))
|
||
handler.end_headers()
|
||
handler.wfile.write(body)
|
||
|
||
|
||
def device_status() -> dict[str, object]:
|
||
state = invoice_tool.run_adb(["get-state"], check=False)
|
||
connected = state.returncode == 0 and state.stdout.strip() == "device"
|
||
status: dict[str, object] = {
|
||
"connected": connected,
|
||
"state": state.stdout.strip() or "unknown",
|
||
"current_package": "",
|
||
"current_activity": "",
|
||
"in_12306": False,
|
||
"message": "手机已连接" if connected else (state.stderr.strip() or state.stdout.strip() or "未检测到手机"),
|
||
}
|
||
if not connected:
|
||
return status
|
||
|
||
window = invoice_tool.run_adb(["shell", "dumpsys", "window"], check=False)
|
||
focus_lines = [line.strip() for line in window.stdout.splitlines() if "mCurrentFocus=" in line or "mFocusedApp=" in line]
|
||
focus_text = " ".join(focus_lines)
|
||
match = re.search(r"u0\s+([^/\s]+)/([^\s}]+)", focus_text)
|
||
if match:
|
||
status["current_package"] = match.group(1)
|
||
status["current_activity"] = match.group(2)
|
||
status["in_12306"] = match.group(1) == "com.MobileTicket"
|
||
status["message"] = "当前在 12306" if status["in_12306"] else f"当前在 {match.group(1)}"
|
||
else:
|
||
status["message"] = "手机已连接,未识别当前前台 App"
|
||
return status
|
||
|
||
|
||
PROGRESS_LABELS = {
|
||
"completed": {"text": "已开票", "tone": "ok", "reason": ""},
|
||
"no_ticket": {"text": "无可开票", "tone": "muted", "reason": "12306 未查询到可开发票客票"},
|
||
"in_progress": {"text": "处理中", "tone": "info", "reason": ""},
|
||
"failed": {"text": "失败", "tone": "bad", "reason": "上次运行未完成"},
|
||
"error": {"text": "运行错误", "tone": "bad", "reason": "脚本异常"},
|
||
"pending": {"text": "未操作", "tone": "pending", "reason": ""},
|
||
}
|
||
|
||
MATCH_REASON = {
|
||
"no_match": "名单里没有这个脱敏名",
|
||
"ambiguous": "同名多人,无法唯一匹配",
|
||
"no_button": "未识别到「开具」按钮",
|
||
}
|
||
|
||
|
||
def label_for_queue_row(row: dict[str, str], progress_by_key: dict, progress_by_masked: dict) -> dict[str, str]:
|
||
masked = row.get("masked_name", "")
|
||
real = row.get("real_name", "")
|
||
id8 = row.get("id_last8", "")
|
||
queue_status = row.get("queue_status", "")
|
||
match_status = row.get("match_status", "")
|
||
|
||
progress_row: dict[str, str] | None = None
|
||
if real and id8:
|
||
progress_row = progress_by_key.get((real, id8))
|
||
if progress_row is None and masked:
|
||
progress_row = progress_by_masked.get(masked)
|
||
pstatus = progress_row.get("status", "") if progress_row else ""
|
||
|
||
if pstatus == "completed":
|
||
return {"display": "已开票", "tone": "ok", "reason": "总进度表已完成"}
|
||
if pstatus == "no_ticket":
|
||
return {"display": "无可开票", "tone": "muted", "reason": "12306 未查询到可开发票客票"}
|
||
if pstatus == "in_progress" or queue_status == "clicked":
|
||
return {"display": "处理中", "tone": "info", "reason": "脚本正在处理这行"}
|
||
if pstatus == "failed":
|
||
return {"display": "失败", "tone": "bad", "reason": "上次运行未完成"}
|
||
if pstatus == "error":
|
||
return {"display": "运行错误", "tone": "bad", "reason": "脚本异常"}
|
||
|
||
if match_status in MATCH_REASON:
|
||
return {"display": "识别问题", "tone": "warn", "reason": MATCH_REASON[match_status]}
|
||
if match_status == "runtime_error":
|
||
return {"display": "运行错误", "tone": "bad", "reason": "上次处理异常"}
|
||
if match_status == "skip_progress":
|
||
return {"display": "已处理", "tone": "muted", "reason": "进度表已记录,跳过"}
|
||
if match_status == "resume_in_progress":
|
||
return {"display": "继续中", "tone": "info", "reason": "上次未完成,本次继续"}
|
||
|
||
if queue_status == "pending":
|
||
return {"display": "待处理", "tone": "pending", "reason": ""}
|
||
if queue_status == "skipped":
|
||
return {"display": "跳过", "tone": "muted", "reason": match_status or ""}
|
||
return {"display": queue_status or "未知", "tone": "muted", "reason": match_status or ""}
|
||
|
||
|
||
def status_payload() -> dict[str, object]:
|
||
roster = csv_rows(invoice_tool.ROSTER_CSV)
|
||
progress = csv_rows(invoice_tool.PROGRESS_CSV)
|
||
queue_raw = csv_rows(invoice_tool.PAGE_QUEUE_CSV)
|
||
by_person_key = {
|
||
(row.get("real_name", ""), row.get("id_last8", "")): row
|
||
for row in progress
|
||
if row.get("real_name") or row.get("id_last8")
|
||
}
|
||
by_real_name = {row.get("real_name", ""): row for row in progress if row.get("real_name")}
|
||
by_masked = {row.get("masked_name", ""): row for row in progress if row.get("masked_name")}
|
||
roster_status = []
|
||
counts = {"total": len(roster), "completed": 0, "failed": 0, "in_progress": 0, "pending": 0}
|
||
|
||
queue = []
|
||
for row in queue_raw:
|
||
label = label_for_queue_row(row, by_person_key, by_masked)
|
||
item = dict(row)
|
||
item["display_status"] = label["display"]
|
||
item["display_tone"] = label["tone"]
|
||
item["display_reason"] = label["reason"]
|
||
queue.append(item)
|
||
|
||
counts["no_ticket"] = 0
|
||
for person in roster:
|
||
person_key = (person.get("姓名", ""), person.get("身份证后8位", ""))
|
||
progress_row = by_person_key.get(person_key) or by_real_name.get(person.get("姓名", ""))
|
||
status = progress_row.get("status", "pending") if progress_row else "pending"
|
||
label = PROGRESS_LABELS.get(status, {"text": status or "未操作", "tone": "pending", "reason": ""})
|
||
if status == "completed":
|
||
counts["completed"] += 1
|
||
elif status == "no_ticket":
|
||
counts["no_ticket"] += 1
|
||
elif status in {"failed", "error"}:
|
||
counts["failed"] += 1
|
||
elif status == "in_progress":
|
||
counts["in_progress"] += 1
|
||
else:
|
||
counts["pending"] += 1
|
||
roster_status.append(
|
||
{
|
||
"name": person.get("姓名", ""),
|
||
"id_last8": person.get("身份证后8位", ""),
|
||
"status": status,
|
||
"display_status": label["text"],
|
||
"display_tone": label["tone"],
|
||
"masked_name": progress_row.get("masked_name", "") if progress_row else "",
|
||
"updated_at": progress_row.get("updated_at", "") if progress_row else "",
|
||
}
|
||
)
|
||
|
||
with RUNNER_LOCK:
|
||
running = RUNNER is not None and RUNNER.poll() is None
|
||
return_code = None if RUNNER is None else RUNNER.poll()
|
||
|
||
log_tail = ""
|
||
if RUNNER_LOG.exists():
|
||
log_tail = "\n".join(RUNNER_LOG.read_text(encoding="utf-8", errors="ignore").splitlines()[-80:])
|
||
|
||
runner_status = "running" if running else "stopped"
|
||
if (not running) and return_code not in (None, 0):
|
||
runner_status = "failed"
|
||
|
||
config = invoice_tool.load_config(invoice_tool.CONFIG_LOCAL_PATH)
|
||
return {
|
||
"counts": counts,
|
||
"roster": roster_status,
|
||
"queue": queue,
|
||
"progress": progress,
|
||
"runner": {"running": running, "status": runner_status, "return_code": return_code, "log_tail": log_tail},
|
||
"device": device_status(),
|
||
"config": {
|
||
"company_title": config.company_title,
|
||
"tax_id": config.tax_id,
|
||
"receiver_email": config.receiver_email,
|
||
"invoice_output_dir": config.invoice_output_dir,
|
||
"invoice_success_action": config.invoice_success_action,
|
||
"invoice_output_absolute_dir": str(invoice_tool.project_path(config.invoice_output_dir)),
|
||
},
|
||
"files": {
|
||
"roster": str(invoice_tool.ROSTER_CSV),
|
||
"progress": str(invoice_tool.PROGRESS_CSV),
|
||
"queue": str(invoice_tool.PAGE_QUEUE_CSV),
|
||
},
|
||
}
|
||
|
||
|
||
def save_invoice_settings(invoice_success_action: str, invoice_output_dir: str) -> dict[str, object]:
|
||
allowed_actions = {"download", "email", "continue"}
|
||
action = invoice_success_action if invoice_success_action in allowed_actions else "download"
|
||
output_dir = invoice_output_dir.strip() or "output/invoices"
|
||
config_path = invoice_tool.CONFIG_LOCAL_PATH
|
||
data: dict[str, object] = {}
|
||
if config_path.exists():
|
||
data = json.loads(config_path.read_text(encoding="utf-8"))
|
||
data["invoice_success_action"] = action
|
||
data["invoice_output_dir"] = output_dir
|
||
config_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||
project_dir = invoice_tool.project_path(output_dir)
|
||
project_dir.mkdir(parents=True, exist_ok=True)
|
||
return {"saved": True, "invoice_success_action": action, "invoice_output_dir": output_dir, "absolute_dir": str(project_dir)}
|
||
|
||
|
||
def start_runner(max_batches: int, max_actions: int, send_email: bool, fast: bool, dry_run: bool = False, scroll_top_first: bool = True, mode: str = "native", invoice_success_action: str = "", invoice_output_dir: str = "") -> dict[str, object]:
|
||
global RUNNER
|
||
with RUNNER_LOCK:
|
||
if RUNNER is not None and RUNNER.poll() is None:
|
||
return {"started": False, "reason": "runner_already_running"}
|
||
RUNNER_LOG.parent.mkdir(parents=True, exist_ok=True)
|
||
RUNNER_LOG.write_text("", encoding="utf-8")
|
||
log_fh = RUNNER_LOG.open("a", encoding="utf-8")
|
||
|
||
prelude: list[str] = []
|
||
if scroll_top_first:
|
||
top_result = subprocess.run(
|
||
[PYTHON, "scripts/invoice_tool.py", "android-page-top"],
|
||
cwd=PROJECT_ROOT, capture_output=True, text=True, check=False,
|
||
)
|
||
log_fh.write(f"[prelude] android-page-top rc={top_result.returncode}\n{top_result.stdout}{top_result.stderr}\n")
|
||
log_fh.flush()
|
||
prelude.append(f"scroll_top: {top_result.stdout.strip().splitlines()[-1] if top_result.stdout.strip() else 'no_output'}")
|
||
|
||
if mode == "h5":
|
||
sub_cmd = "android-h5-page-run"
|
||
else:
|
||
sub_cmd = "android-vision-page-run"
|
||
|
||
cmd = [PYTHON, "-u", "scripts/invoice_tool.py", sub_cmd, "--max-batches", str(max_batches)]
|
||
if max_actions > 0:
|
||
cmd += ["--max-actions", str(max_actions)]
|
||
if send_email:
|
||
cmd.append("--send-email")
|
||
if invoice_success_action:
|
||
cmd += ["--invoice-success-action", invoice_success_action]
|
||
if invoice_output_dir:
|
||
cmd += ["--invoice-output-dir", invoice_output_dir]
|
||
if fast:
|
||
cmd.append("--fast")
|
||
if dry_run:
|
||
cmd.append("--dry-run")
|
||
RUNNER = subprocess.Popen(cmd, cwd=PROJECT_ROOT, stdout=log_fh, stderr=subprocess.STDOUT, text=True)
|
||
return {"started": True, "pid": RUNNER.pid, "cmd": cmd, "prelude": prelude, "mode": mode}
|
||
|
||
|
||
def stop_runner() -> dict[str, object]:
|
||
global RUNNER
|
||
with RUNNER_LOCK:
|
||
if RUNNER is None or RUNNER.poll() is not None:
|
||
return {"stopped": False, "reason": "runner_not_running"}
|
||
RUNNER.terminate()
|
||
for _ in range(10):
|
||
if RUNNER.poll() is not None:
|
||
return {"stopped": True}
|
||
time.sleep(0.2)
|
||
RUNNER.kill()
|
||
return {"stopped": True, "killed": True}
|
||
|
||
|
||
def build_vision_page_queue() -> dict[str, object]:
|
||
"""用视觉 API 截屏识别当前页乘客,匹配名单+进度,返回队列数据。"""
|
||
try:
|
||
parsed = invoice_tool.vision_list_rows()
|
||
except Exception as exc:
|
||
return {"error": str(exc), "rows": [], "meta": f"视觉识别失败: {exc}"}
|
||
|
||
if not parsed.get("page_ok"):
|
||
return {"error": "not_on_list_page", "rows": [], "meta": "当前不在扫码开票单列表页"}
|
||
|
||
rows = parsed.get("rows") or []
|
||
roster = invoice_tool.read_roster()
|
||
skip_names = invoice_tool.progress_names({"completed", "no_ticket", "failed", "in_progress"})
|
||
queue = []
|
||
for row in rows:
|
||
masked = row.get("masked_name", "")
|
||
id_first = row.get("id_first", "") or ""
|
||
id_last = row.get("id_last", "") or ""
|
||
|
||
real = ""
|
||
id8 = ""
|
||
if masked in skip_names:
|
||
status, tone, reason = "已处理", "muted", "进度表已完成或跳过"
|
||
else:
|
||
matches = invoice_tool.match_roster(masked, roster, id_first, id_last)
|
||
if not matches and (id_first or id_last):
|
||
matches = invoice_tool.match_roster(masked, roster)
|
||
if len(matches) == 1:
|
||
status, tone, reason = "待处理", "pending", f"匹配: {matches[0].get('姓名', '')}"
|
||
real = matches[0].get("姓名", "")
|
||
id8 = matches[0].get("身份证后8位", "")
|
||
elif len(matches) > 1:
|
||
status, tone, reason = "识别问题", "warn", f"同名 {len(matches)} 人"
|
||
else:
|
||
status, tone, reason = "识别问题", "warn", "不在名单中"
|
||
|
||
queue.append({
|
||
"masked_name": masked,
|
||
"real_name": real,
|
||
"id_last8": id8,
|
||
"id_first": id_first,
|
||
"id_last": id_last,
|
||
"display_status": status,
|
||
"display_tone": tone,
|
||
"display_reason": reason,
|
||
})
|
||
|
||
# 同步写入 page_queue CSV,防止定时 refresh() 覆盖
|
||
now = invoice_tool.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
csv_rows = []
|
||
for q in queue:
|
||
csv_rows.append({
|
||
"page_id": f"vision_{now}",
|
||
"masked_name": q["masked_name"],
|
||
"id_first": q.get("id_first", ""),
|
||
"id_last": q.get("id_last", ""),
|
||
"real_name": q.get("real_name", ""),
|
||
"id_last8": q.get("id_last8", ""),
|
||
"button_bounds": "",
|
||
"match_status": "matched" if q["display_tone"] == "pending" else "skip_progress",
|
||
"queue_status": "pending" if q["display_tone"] == "pending" else "skipped",
|
||
"attempts": "0",
|
||
"updated_at": now,
|
||
})
|
||
invoice_tool.write_page_queue(csv_rows)
|
||
|
||
return {
|
||
"rows": queue,
|
||
"meta": f"本页 {len(rows)} 人 · scroll={parsed.get('scroll_position', '?')} · pending={sum(1 for q in queue if q['display_tone'] == 'pending')}",
|
||
"scroll_position": parsed.get("scroll_position"),
|
||
}
|
||
|
||
|
||
def launch_railway_app() -> dict[str, object]:
|
||
result = invoice_tool.run_adb(
|
||
["shell", "monkey", "-p", "com.MobileTicket", "-c", "android.intent.category.LAUNCHER", "1"],
|
||
check=False,
|
||
)
|
||
if result.returncode == 0:
|
||
time.sleep(1)
|
||
return {
|
||
"return_code": result.returncode,
|
||
"stdout": result.stdout.strip(),
|
||
"stderr": result.stderr.strip(),
|
||
"package": "com.MobileTicket",
|
||
"device": device_status(),
|
||
}
|
||
|
||
|
||
INDEX_HTML = r"""<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>铁路系统发票批量开票</title>
|
||
<style>
|
||
:root { color-scheme: light; --blue: #2563eb; --green: #16803c; --red: #b42318; --amber: #a15c00; --border: #d7dee8; }
|
||
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f5f7fa; color: #1f2937; }
|
||
header { background: #fff; border-bottom: 1px solid var(--border); padding: 18px 24px; display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||
h1 { font-size: 20px; margin: 0; letter-spacing: 0; }
|
||
.statusbar { display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; align-items: center; }
|
||
.pill { border: 1px solid var(--border); border-radius: 999px; padding: 4px 10px; background: #fff; color: #4b5563; font-size: 13px; }
|
||
.pill.ok { background: #dcfce7; color: var(--green); border-color: #bbf7d0; }
|
||
.pill.warn { background: #fef3c7; color: var(--amber); border-color: #fde68a; }
|
||
.pill.bad { background: #fee2e2; color: var(--red); border-color: #fecaca; }
|
||
main { padding: 20px 24px 40px; display: grid; gap: 16px; }
|
||
section { background: #fff; border: 1px solid var(--border); border-radius: 8px; padding: 16px; }
|
||
h2 { font-size: 16px; margin: 0 0 12px; }
|
||
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 12px; }
|
||
.stat { border: 1px solid var(--border); border-radius: 8px; padding: 12px; background: #fbfcfe; }
|
||
.stat b { display: block; font-size: 24px; margin-bottom: 4px; }
|
||
.controls { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
|
||
button, input::file-selector-button { border: 1px solid var(--border); background: #fff; border-radius: 6px; padding: 8px 12px; font-size: 14px; cursor: pointer; }
|
||
button.primary { background: var(--blue); color: #fff; border-color: var(--blue); }
|
||
button.danger { color: #fff; background: var(--red); border-color: var(--red); }
|
||
input[type="number"] { width: 72px; padding: 8px; border: 1px solid var(--border); border-radius: 6px; }
|
||
input[type="text"], select { padding: 8px; border: 1px solid var(--border); border-radius: 6px; background: #fff; }
|
||
.path-input { min-width: 360px; flex: 1 1 360px; }
|
||
label { font-size: 14px; color: #374151; }
|
||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||
th, td { border-bottom: 1px solid #edf0f5; text-align: left; padding: 8px; white-space: nowrap; }
|
||
th { background: #f8fafc; position: sticky; top: 0; }
|
||
.table-wrap { max-height: 430px; overflow: auto; border: 1px solid var(--border); border-radius: 8px; }
|
||
.queue-title { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
||
.queue-title-main { min-width: 0; }
|
||
.queue-actions { display: flex; flex: 0 0 auto; gap: 6px; }
|
||
.queue-actions button { font-size: 12px; padding: 4px 10px; white-space: nowrap; }
|
||
.queue-table { min-width: 620px; }
|
||
.badge { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 12px; background: #e5e7eb; color: #4b5563; }
|
||
.badge.tone-ok { background: #dcfce7; color: var(--green); }
|
||
.badge.tone-bad { background: #fee2e2; color: var(--red); }
|
||
.badge.tone-warn { background: #fef3c7; color: var(--amber); }
|
||
.badge.tone-info { background: #dbeafe; color: var(--blue); }
|
||
.badge.tone-muted { background: #f3f4f6; color: #4b5563; }
|
||
.badge.tone-pending { background: #f3f4f6; color: #6b7280; border: 1px dashed #d1d5db; }
|
||
.reason-cell { color: #6b7280; font-size: 12px; max-width: 280px; white-space: normal; }
|
||
.dropzone { border: 2px dashed #c7d2e0; border-radius: 8px; padding: 14px 16px; background: #fbfcfe; display: flex; align-items: center; gap: 12px; cursor: pointer; transition: all 0.15s; flex: 1 1 320px; min-width: 280px; }
|
||
.dropzone:hover { border-color: var(--blue); background: #f0f6ff; }
|
||
.dropzone.dragover { border-color: var(--blue); background: #e0ecff; border-style: solid; }
|
||
.dropzone-text { font-size: 13px; color: #4b5563; flex: 1; }
|
||
.dropzone strong { color: #1f2937; }
|
||
.dropzone input[type=file] { display: none; }
|
||
.paste-zone { border: 2px dashed #c7d2e0; border-radius: 8px; padding: 14px 16px; background: #fbfcfe; cursor: pointer; transition: all 0.15s; text-align: center; outline: none; margin-top: 10px; }
|
||
.paste-zone:hover, .paste-zone:focus { border-color: var(--blue); background: #f0f6ff; }
|
||
.paste-zone.success { border-color: var(--green); background: #dcfce7; border-style: solid; }
|
||
.paste-zone.error { border-color: var(--red); background: #fee2e2; border-style: solid; }
|
||
.paste-zone-text { font-size: 13px; color: #4b5563; }
|
||
.paste-zone strong { color: #1f2937; }
|
||
.qr-result { display: flex; align-items: center; gap: 10px; margin-top: 10px; padding: 10px 14px; background: #f0f6ff; border: 1px solid #bbd6fe; border-radius: 8px; flex-wrap: wrap; }
|
||
.qr-result code { flex: 1; font-size: 13px; word-break: break-all; color: #1e40af; background: transparent; padding: 0; min-width: 200px; }
|
||
pre { margin: 0; background: #111827; color: #e5e7eb; padding: 12px; border-radius: 8px; max-height: 260px; overflow: auto; font-size: 12px; }
|
||
.muted { color: #6b7280; font-size: 13px; }
|
||
.grid2 { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; }
|
||
@media (max-width: 900px) { .stats, .grid2 { grid-template-columns: 1fr; } header { align-items: flex-start; flex-direction: column; } }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<h1>铁路系统发票批量开票</h1>
|
||
<div class="statusbar">
|
||
<span class="pill" id="runnerState">读取中</span>
|
||
<span class="pill" id="deviceState">手机状态读取中</span>
|
||
<span class="pill" id="deviceApp">当前 App 读取中</span>
|
||
</div>
|
||
</header>
|
||
<main>
|
||
<section>
|
||
<h2>名单上传与运行</h2>
|
||
<div class="controls">
|
||
<label class="dropzone" id="dropzone">
|
||
<input type="file" id="rosterFile" name="file" accept=".xlsx,.xlsm,.csv">
|
||
<span class="dropzone-text"><strong>拖拽名单</strong>到此处,或点击选择文件 <span class="muted">(.xlsx / .xlsm / .csv)</span><br><span class="muted" id="dropzoneHint">未选择文件</span></span>
|
||
</label>
|
||
<button id="launch12306">启动 12306</button>
|
||
<button id="reopenQr">重新进入开票页</button>
|
||
<button id="buildQueue">抓当前页队列</button>
|
||
<label>最多页数 <input id="maxBatches" type="number" min="1" max="200" value="20"></label>
|
||
<label>最多 N 人 <input id="maxActions" type="number" min="0" max="200" value="0" title="0 = 不限"></label>
|
||
<label><input id="sendEmail" type="checkbox"> 兼容旧流程:发送邮箱</label>
|
||
<label><input id="fast" type="checkbox" checked> 快速模式</label>
|
||
<label><input id="dryRun" type="checkbox"> dry-run</label>
|
||
<label><input id="scrollTopFirst" type="checkbox"> 开始前回顶</label>
|
||
<label>模式 <select id="runMode"><option value="native">列表页(视觉)</option><option value="h5">H5开票单</option></select></label>
|
||
<button class="primary" id="startRun">开始运行</button>
|
||
<button class="danger" id="stopRun">停止</button>
|
||
<button id="scrollTop">回到顶部</button>
|
||
<button id="nextPage">上滑下一页</button>
|
||
</div>
|
||
<div class="paste-zone" id="pasteZone" tabindex="0">
|
||
<span class="paste-zone-text"><strong>📋 粘贴二维码截图</strong> — 在此处 Ctrl+V / Cmd+V 粘贴<br><span class="muted" id="pasteHint">支持截图中的二维码自动识别,解码后可在手机打开</span></span>
|
||
<img id="qrPreview" style="display:none; max-width:180px; max-height:180px; border-radius:6px; margin-top:8px; border:1px solid var(--border);">
|
||
</div>
|
||
<div id="qrResult" style="display:none;" class="qr-result">
|
||
<code id="qrUrl"></code>
|
||
<button id="openOnPhone">📱 在手机打开</button>
|
||
<button id="copyQrUrl">📋 复制链接</button>
|
||
</div>
|
||
<p class="muted" id="message"></p>
|
||
</section>
|
||
|
||
<section>
|
||
<h2>保存设置</h2>
|
||
<div class="controls">
|
||
<label>开票成功后
|
||
<select id="invoiceSuccessAction">
|
||
<option value="download">下载 PDF/OFD/XML 到电脑</option>
|
||
<option value="email">发送至邮箱</option>
|
||
<option value="continue">仅继续开票</option>
|
||
</select>
|
||
</label>
|
||
<label>保存目录 <input id="invoiceOutputDir" class="path-input" type="text" placeholder="output/invoices"></label>
|
||
<button id="saveSettings">保存设置</button>
|
||
</div>
|
||
<p class="muted" id="settingsHint">保存目录可填相对项目路径或绝对路径。</p>
|
||
</section>
|
||
|
||
<section class="stats">
|
||
<div class="stat"><b id="total">0</b><span>名单人数</span></div>
|
||
<div class="stat"><b id="completed">0</b><span>已开票</span></div>
|
||
<div class="stat"><b id="noTicket">0</b><span>无可开票</span></div>
|
||
<div class="stat"><b id="failed">0</b><span>失败</span></div>
|
||
<div class="stat"><b id="inProgress">0</b><span>处理中</span></div>
|
||
<div class="stat"><b id="pending">0</b><span>未操作</span></div>
|
||
</section>
|
||
|
||
<div class="grid2">
|
||
<section>
|
||
<h2 class="queue-title"><span class="queue-title-main">当前页队列 <span class="muted" id="queueMeta"></span></span><span class="queue-actions"><button id="markCheckedDone">✅ 标记已完成</button><button id="refreshQueue">🔄 刷新当前页</button></span></h2>
|
||
<div class="table-wrap"><table class="queue-table"><thead><tr><th>✓</th><th>状态</th><th>原因</th><th>脱敏名</th><th>姓名</th><th>后8位</th></tr></thead><tbody id="queueBody"></tbody></table></div>
|
||
</section>
|
||
<section>
|
||
<h2>运行日志</h2>
|
||
<pre id="log"></pre>
|
||
</section>
|
||
</div>
|
||
|
||
<section>
|
||
<h2>总进度 <a class="muted" href="/api/progress-csv" target="_blank">下载进度表</a></h2>
|
||
<div class="table-wrap"><table><thead><tr><th>状态</th><th>姓名</th><th>脱敏名</th><th>身份证后8位</th><th>更新时间</th></tr></thead><tbody id="rosterBody"></tbody></table></div>
|
||
</section>
|
||
</main>
|
||
<script>
|
||
const $ = (id) => document.getElementById(id);
|
||
let settingsDirty = false;
|
||
function h(value) {
|
||
return String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&', '<':'<', '>':'>', '"':'"', "'":'''}[c]));
|
||
}
|
||
function badge(text, tone) {
|
||
const cls = tone ? `tone-${tone}` : 'tone-pending';
|
||
return `<span class="badge ${cls}">${h(text || '未操作')}</span>`;
|
||
}
|
||
async function api(path, options={}) {
|
||
const res = await fetch(path, options);
|
||
const data = await res.json();
|
||
if (!res.ok) throw new Error(data.error || res.statusText);
|
||
return data;
|
||
}
|
||
async function refresh() {
|
||
const data = await api('/api/status');
|
||
$('total').textContent = data.counts.total;
|
||
$('completed').textContent = data.counts.completed;
|
||
$('noTicket').textContent = data.counts.no_ticket || 0;
|
||
$('failed').textContent = data.counts.failed;
|
||
$('inProgress').textContent = data.counts.in_progress;
|
||
$('pending').textContent = data.counts.pending;
|
||
$('runnerState').textContent = data.runner.status === 'running' ? '运行中' : (data.runner.status === 'failed' ? '异常退出' : '已停止');
|
||
$('runnerState').className = data.runner.status === 'running' ? 'pill ok' : (data.runner.status === 'failed' ? 'pill bad' : 'pill');
|
||
$('deviceState').textContent = data.device.connected ? '手机已连接' : '手机未连接';
|
||
$('deviceState').className = data.device.connected ? 'pill ok' : 'pill bad';
|
||
$('deviceApp').textContent = data.device.message || '';
|
||
$('deviceApp').className = data.device.in_12306 ? 'pill ok' : (data.device.connected ? 'pill warn' : 'pill bad');
|
||
if (data.config) {
|
||
if (!settingsDirty && document.activeElement !== $('invoiceOutputDir')) {
|
||
$('invoiceSuccessAction').value = data.config.invoice_success_action || 'download';
|
||
$('invoiceOutputDir').value = data.config.invoice_output_dir || 'output/invoices';
|
||
$('sendEmail').checked = (data.config.invoice_success_action || 'download') === 'email';
|
||
}
|
||
$('settingsHint').textContent = `当前保存到:${data.config.invoice_output_absolute_dir || data.config.invoice_output_dir || 'output/invoices'}`;
|
||
}
|
||
$('log').textContent = data.runner.log_tail || '';
|
||
$('queueBody').innerHTML = data.queue.map((r, i) => `<tr><td><input type="checkbox" class="queueCb" data-idx="${i}" data-masked="${h(r.masked_name)}" data-real="${h(r.real_name)}" data-id8="${h(r.id_last8)}"></td><td>${badge(r.display_status, r.display_tone)}</td><td class="reason-cell">${h(r.display_reason)}</td><td>${h(r.masked_name)}</td><td>${h(r.real_name)}</td><td>${h(r.id_last8)}</td></tr>`).join('');
|
||
const queueCounts = data.queue.reduce((acc, r) => { acc[r.display_status] = (acc[r.display_status] || 0) + 1; return acc; }, {});
|
||
$('queueMeta').textContent = data.queue.length ? `本页 ${data.queue.length} 人 · ` + Object.entries(queueCounts).map(([k,v]) => `${k} ${v}`).join(' / ') : '尚未抓取本页队列';
|
||
$('rosterBody').innerHTML = data.roster.map(r => `<tr><td>${badge(r.display_status, r.display_tone)}</td><td>${h(r.name)}</td><td>${h(r.masked_name)}</td><td>${h(r.id_last8)}</td><td>${h(r.updated_at)}</td></tr>`).join('');
|
||
}
|
||
function summarize(path, data) {
|
||
try {
|
||
if (path === '/api/launch-12306') {
|
||
if (data && data.device && data.device.in_12306) return '12306 已启动并位于前台';
|
||
if (data && data.return_code === 0) return '已发送启动指令';
|
||
return '启动失败:' + (data && (data.stderr || data.stdout) || '未知错误');
|
||
}
|
||
if (path === '/api/build-queue') {
|
||
const stdout = (data && data.stdout) || '';
|
||
const rowsMatch = stdout.match(/rows=(\d+) pending=(\d+)/);
|
||
if (rowsMatch) return `已抓取本页 ${rowsMatch[1]} 人,pending ${rowsMatch[2]} 待处理`;
|
||
if (data && data.return_code !== 0) return '抓队列失败:' + ((data.stderr || '').split('\n').pop() || '未知');
|
||
return '已抓取队列';
|
||
}
|
||
if (path === '/api/start') {
|
||
if (data && data.started) {
|
||
const prelude = (data.prelude || []).join(' | ');
|
||
return `已开始运行 (PID ${data.pid})${prelude ? ' — ' + prelude : ''}`;
|
||
}
|
||
if (data && data.reason === 'runner_already_running') return '已在运行中,无需重复启动';
|
||
return '启动失败';
|
||
}
|
||
if (path === '/api/stop') {
|
||
if (data && data.stopped) return data.killed ? '已强制停止' : '已停止';
|
||
if (data && data.reason === 'runner_not_running') return '当前没有运行中的批处理';
|
||
return '停止失败';
|
||
}
|
||
if (path === '/api/next-page') {
|
||
if (data && data.return_code === 0) return '已上滑下一页';
|
||
return '翻页失败:' + ((data && data.stderr || '').split('\n').pop() || '未知');
|
||
}
|
||
if (path === '/api/scroll-top') {
|
||
const stdout = (data && data.stdout) || '';
|
||
const m = stdout.match(/at_top iterations=(\d+)/);
|
||
if (m) return `已回到顶部 (滑了 ${m[1]} 次)`;
|
||
if (stdout.includes('max_iterations_reached')) return '已尽量回顶,但未稳定 — 请人工检查';
|
||
return '回顶部失败:' + ((data && data.stderr || '').split('\n').pop() || '未知');
|
||
}
|
||
if (path === '/api/upload-roster') {
|
||
const stdout = (data && data.stdout) || '';
|
||
const rowsMatch = stdout.match(/roster_rows=(\d+)/);
|
||
if (rowsMatch) return `名单已上传并导入,共 ${rowsMatch[1]} 条`;
|
||
if (data && data.return_code !== 0) return '导入失败:' + ((data.stderr || '').split('\n').pop() || '未知');
|
||
return '名单已上传';
|
||
}
|
||
} catch (e) { /* fall through */ }
|
||
return '操作完成';
|
||
}
|
||
async function postJson(path, body={}) {
|
||
const data = await api(path, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) });
|
||
$('message').textContent = summarize(path, data);
|
||
await refresh();
|
||
}
|
||
async function uploadRoster(file) {
|
||
if (!file) return;
|
||
$('dropzoneHint').textContent = `上传中:${file.name}`;
|
||
const form = new FormData();
|
||
form.append('file', file, file.name);
|
||
const res = await fetch('/api/upload-roster', { method: 'POST', body: form });
|
||
const data = await res.json();
|
||
$('message').textContent = summarize('/api/upload-roster', data);
|
||
$('dropzoneHint').textContent = `已选:${file.name}`;
|
||
await refresh();
|
||
}
|
||
const dropzone = $('dropzone');
|
||
const rosterInput = $('rosterFile');
|
||
rosterInput.addEventListener('change', () => uploadRoster(rosterInput.files[0]));
|
||
['dragenter', 'dragover'].forEach(evt => dropzone.addEventListener(evt, (e) => {
|
||
e.preventDefault(); e.stopPropagation(); dropzone.classList.add('dragover');
|
||
}));
|
||
['dragleave', 'drop'].forEach(evt => dropzone.addEventListener(evt, (e) => {
|
||
e.preventDefault(); e.stopPropagation(); dropzone.classList.remove('dragover');
|
||
}));
|
||
dropzone.addEventListener('drop', (e) => {
|
||
const files = e.dataTransfer && e.dataTransfer.files;
|
||
if (!files || !files.length) return;
|
||
const file = files[0];
|
||
if (!/\.(xlsx|xlsm|csv)$/i.test(file.name)) {
|
||
$('message').textContent = '只支持 .xlsx / .xlsm / .csv';
|
||
return;
|
||
}
|
||
uploadRoster(file);
|
||
});
|
||
$('launch12306').onclick = async () => {
|
||
await postJson('/api/launch-12306');
|
||
setTimeout(refresh, 1000);
|
||
};
|
||
$('reopenQr').onclick = async () => {
|
||
$('message').textContent = '正在手机重新进入开票页...';
|
||
const data = await api('/api/reopen-qr', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: '{}' });
|
||
$('message').textContent = data.reopened ? '已重新进入' : '重进失败: 请先在粘贴区解码一个二维码';
|
||
await refresh();
|
||
};
|
||
$('buildQueue').onclick = () => postJson('/api/build-queue');
|
||
$('markCheckedDone').onclick = async () => {
|
||
const cbs = document.querySelectorAll('.queueCb:checked');
|
||
if (!cbs.length) { $('message').textContent = '请先勾选要标记的人'; return; }
|
||
const names = Array.from(cbs).map(cb => ({ masked: cb.dataset.masked, real: cb.dataset.real, id8: cb.dataset.id8 }));
|
||
$('message').textContent = `正在标记 ${names.length} 人...`;
|
||
try {
|
||
const res = await fetch('/api/mark-completed', {
|
||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({ rows: names }),
|
||
});
|
||
const data = await res.json();
|
||
$('message').textContent = `已标记 ${data.marked || 0} 人为人工完成`;
|
||
await refresh();
|
||
} catch (err) {
|
||
$('message').textContent = '标记失败: ' + (err.message || '网络错误');
|
||
}
|
||
};
|
||
$('refreshQueue').onclick = async () => {
|
||
$('refreshQueue').textContent = '⏳ 识别中...';
|
||
$('refreshQueue').disabled = true;
|
||
try {
|
||
const data = await api('/api/vision-page-queue', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: '{}' });
|
||
if (data.rows) {
|
||
$('queueBody').innerHTML = data.rows.map(r => `<tr><td>${badge(r.display_status, r.display_tone)}</td><td class="reason-cell">${h(r.display_reason)}</td><td>${h(r.masked_name)}</td><td>${h(r.real_name)}</td><td>${h(r.id_last8)}</td></tr>`).join('');
|
||
$('queueMeta').textContent = data.meta || '';
|
||
$('message').textContent = '已刷新: ' + (data.meta || '');
|
||
} else {
|
||
$('message').textContent = '刷新失败: ' + (data.error || '未知错误');
|
||
}
|
||
} catch (err) {
|
||
$('message').textContent = '刷新失败: ' + (err.message || '网络错误');
|
||
}
|
||
$('refreshQueue').textContent = '🔄 刷新当前页';
|
||
$('refreshQueue').disabled = false;
|
||
};
|
||
$('invoiceOutputDir').addEventListener('input', () => { settingsDirty = true; });
|
||
$('invoiceSuccessAction').onchange = () => { settingsDirty = true; $('sendEmail').checked = $('invoiceSuccessAction').value === 'email'; };
|
||
$('sendEmail').onchange = () => { settingsDirty = true; if ($('sendEmail').checked) $('invoiceSuccessAction').value = 'email'; };
|
||
$('saveSettings').onclick = async () => {
|
||
const data = await api('/api/settings', {
|
||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({ invoice_success_action: $('invoiceSuccessAction').value, invoice_output_dir: $('invoiceOutputDir').value })
|
||
});
|
||
$('message').textContent = data.saved ? `设置已保存:${data.absolute_dir}` : '设置保存失败';
|
||
settingsDirty = false;
|
||
await refresh();
|
||
};
|
||
$('startRun').onclick = () => postJson('/api/start', { max_batches: Number($('maxBatches').value), max_actions: Number($('maxActions').value), send_email: $('sendEmail').checked, fast: $('fast').checked, dry_run: $('dryRun').checked, scroll_top_first: $('scrollTopFirst').checked, mode: $('runMode').value, invoice_success_action: $('invoiceSuccessAction').value, invoice_output_dir: $('invoiceOutputDir').value });
|
||
$('stopRun').onclick = () => postJson('/api/stop');
|
||
$('nextPage').onclick = () => postJson('/api/next-page');
|
||
$('scrollTop').onclick = () => postJson('/api/scroll-top');
|
||
|
||
// --- 粘贴二维码 ---
|
||
const pasteZone = $('pasteZone');
|
||
const pasteHint = $('pasteHint');
|
||
const qrPreview = $('qrPreview');
|
||
const qrResult = $('qrResult');
|
||
const qrUrl = $('qrUrl');
|
||
let lastDecodedUrl = '';
|
||
|
||
pasteZone.addEventListener('click', () => pasteZone.focus());
|
||
|
||
pasteZone.addEventListener('paste', async (e) => {
|
||
e.preventDefault();
|
||
const items = e.clipboardData && e.clipboardData.items;
|
||
if (!items) return;
|
||
|
||
let imageFile = null;
|
||
for (const item of items) {
|
||
if (item.type.startsWith('image/')) {
|
||
imageFile = item.getAsFile();
|
||
break;
|
||
}
|
||
}
|
||
if (!imageFile) {
|
||
pasteHint.textContent = '未检测到图片,请确认已截图并复制到剪贴板';
|
||
pasteZone.classList.add('error');
|
||
setTimeout(() => pasteZone.classList.remove('error'), 2000);
|
||
return;
|
||
}
|
||
|
||
// 显示预览
|
||
const reader = new FileReader();
|
||
reader.onload = async (ev) => {
|
||
const base64 = ev.target.result;
|
||
qrPreview.src = base64;
|
||
qrPreview.style.display = 'inline-block';
|
||
pasteHint.textContent = '解码中...';
|
||
pasteZone.classList.remove('error', 'success');
|
||
qrResult.style.display = 'none';
|
||
|
||
try {
|
||
const res = await fetch('/api/decode-qr', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ image: base64 }),
|
||
});
|
||
const data = await res.json();
|
||
if (data.results && data.results.length > 0) {
|
||
lastDecodedUrl = data.results[0];
|
||
qrUrl.textContent = lastDecodedUrl;
|
||
qrResult.style.display = 'flex';
|
||
pasteHint.textContent = `已识别 ${data.results.length} 个二维码`;
|
||
pasteZone.classList.add('success');
|
||
setTimeout(() => pasteZone.classList.remove('success'), 3000);
|
||
} else {
|
||
qrResult.style.display = 'none';
|
||
pasteHint.textContent = data.error || '未识别到二维码';
|
||
pasteZone.classList.add('error');
|
||
setTimeout(() => pasteZone.classList.remove('error'), 2000);
|
||
}
|
||
} catch (err) {
|
||
qrResult.style.display = 'none';
|
||
pasteHint.textContent = '解码请求失败: ' + (err.message || '网络错误');
|
||
pasteZone.classList.add('error');
|
||
setTimeout(() => pasteZone.classList.remove('error'), 2000);
|
||
}
|
||
};
|
||
reader.readAsDataURL(imageFile);
|
||
});
|
||
|
||
$('openOnPhone').onclick = async () => {
|
||
if (!lastDecodedUrl) return;
|
||
$('message').textContent = '正在手机打开...';
|
||
try {
|
||
const res = await fetch('/api/open-on-phone', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ url: lastDecodedUrl }),
|
||
});
|
||
const data = await res.json();
|
||
if (data.return_code === 0) {
|
||
$('message').textContent = '已在手机打开: ' + lastDecodedUrl;
|
||
} else {
|
||
$('message').textContent = '手机打开失败: ' + (data.stderr || data.stdout || '未知错误');
|
||
}
|
||
await refresh();
|
||
} catch (err) {
|
||
$('message').textContent = '请求失败: ' + (err.message || '网络错误');
|
||
}
|
||
};
|
||
|
||
$('copyQrUrl').onclick = () => {
|
||
if (!lastDecodedUrl) return;
|
||
navigator.clipboard.writeText(lastDecodedUrl).then(() => {
|
||
$('message').textContent = '链接已复制到剪贴板';
|
||
}).catch(() => {
|
||
$('message').textContent = '复制失败,请手动复制';
|
||
});
|
||
};
|
||
|
||
refresh();
|
||
setInterval(refresh, 2000);
|
||
</script>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
|
||
class Handler(BaseHTTPRequestHandler):
|
||
def log_message(self, fmt: str, *args: object) -> None:
|
||
return
|
||
|
||
def do_HEAD(self) -> None:
|
||
parsed = urlparse(self.path)
|
||
if parsed.path == "/":
|
||
body = INDEX_HTML.encode("utf-8")
|
||
self.send_response(200)
|
||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||
self.send_header("Content-Length", str(len(body)))
|
||
self.end_headers()
|
||
elif parsed.path == "/api/progress-csv" and invoice_tool.PROGRESS_CSV.exists():
|
||
self.send_response(200)
|
||
self.send_header("Content-Type", "text/csv; charset=utf-8")
|
||
self.send_header("Content-Disposition", f'attachment; filename="{invoice_tool.PROGRESS_CSV.name}"')
|
||
self.send_header("Content-Length", str(invoice_tool.PROGRESS_CSV.stat().st_size))
|
||
self.end_headers()
|
||
elif parsed.path == "/api/status":
|
||
self.send_response(200)
|
||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||
self.end_headers()
|
||
elif parsed.path in {"/api/decode-qr", "/api/open-on-phone", "/api/reopen-qr", "/api/vision-page-queue", "/api/mark-completed"}:
|
||
self.send_response(200)
|
||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||
self.end_headers()
|
||
else:
|
||
self.send_response(404)
|
||
self.end_headers()
|
||
|
||
def do_GET(self) -> None:
|
||
parsed = urlparse(self.path)
|
||
if parsed.path == "/":
|
||
body = INDEX_HTML.encode("utf-8")
|
||
self.send_response(200)
|
||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||
self.send_header("Content-Length", str(len(body)))
|
||
self.end_headers()
|
||
self.wfile.write(body)
|
||
elif parsed.path == "/api/status":
|
||
write_json(self, status_payload())
|
||
elif parsed.path == "/api/device-status":
|
||
write_json(self, device_status())
|
||
elif parsed.path == "/api/progress-csv":
|
||
self.send_file(invoice_tool.PROGRESS_CSV, "text/csv; charset=utf-8")
|
||
else:
|
||
write_json(self, {"error": "not_found"}, 404)
|
||
|
||
def do_POST(self) -> None:
|
||
parsed = urlparse(self.path)
|
||
try:
|
||
if parsed.path == "/api/upload-roster":
|
||
self.handle_upload_roster()
|
||
elif parsed.path == "/api/start":
|
||
body = self.read_json()
|
||
write_json(self, start_runner(
|
||
int(body.get("max_batches", 20)),
|
||
int(body.get("max_actions", 0)),
|
||
bool(body.get("send_email", True)),
|
||
bool(body.get("fast", True)),
|
||
bool(body.get("dry_run", False)),
|
||
bool(body.get("scroll_top_first", True)),
|
||
str(body.get("mode", "native")),
|
||
str(body.get("invoice_success_action", "")),
|
||
str(body.get("invoice_output_dir", "")),
|
||
))
|
||
elif parsed.path == "/api/stop":
|
||
write_json(self, stop_runner())
|
||
elif parsed.path == "/api/settings":
|
||
body = self.read_json()
|
||
write_json(self, save_invoice_settings(
|
||
str(body.get("invoice_success_action", "download")),
|
||
str(body.get("invoice_output_dir", "output/invoices")),
|
||
))
|
||
elif parsed.path == "/api/launch-12306":
|
||
result = launch_railway_app()
|
||
write_json(self, result, 200 if result["return_code"] == 0 else 500)
|
||
elif parsed.path == "/api/build-queue":
|
||
result = subprocess.run([PYTHON, "scripts/invoice_tool.py", "android-build-page-queue"], cwd=PROJECT_ROOT, capture_output=True, text=True, check=False)
|
||
write_json(self, {"return_code": result.returncode, "stdout": result.stdout, "stderr": result.stderr}, 200 if result.returncode == 0 else 500)
|
||
elif parsed.path == "/api/next-page":
|
||
result = subprocess.run([PYTHON, "scripts/invoice_tool.py", "android-page-next", "--rebuild"], cwd=PROJECT_ROOT, capture_output=True, text=True, check=False)
|
||
write_json(self, {"return_code": result.returncode, "stdout": result.stdout, "stderr": result.stderr}, 200 if result.returncode == 0 else 500)
|
||
elif parsed.path == "/api/scroll-top":
|
||
result = subprocess.run([PYTHON, "scripts/invoice_tool.py", "android-page-top"], cwd=PROJECT_ROOT, capture_output=True, text=True, check=False)
|
||
write_json(self, {"return_code": result.returncode, "stdout": result.stdout, "stderr": result.stderr}, 200 if result.returncode == 0 else 500)
|
||
elif parsed.path == "/api/decode-qr":
|
||
self.handle_decode_qr()
|
||
elif parsed.path == "/api/open-on-phone":
|
||
self.handle_open_on_phone()
|
||
elif parsed.path == "/api/reopen-qr":
|
||
result = invoice_tool.reopen_qr_url()
|
||
write_json(self, {"reopened": result, "device": device_status()})
|
||
elif parsed.path == "/api/vision-page-queue":
|
||
write_json(self, build_vision_page_queue())
|
||
elif parsed.path == "/api/mark-completed":
|
||
self.handle_mark_completed()
|
||
else:
|
||
write_json(self, {"error": "not_found"}, 404)
|
||
except Exception as exc:
|
||
write_json(self, {"error": str(exc)}, 500)
|
||
|
||
def read_json(self) -> dict[str, object]:
|
||
length = int(self.headers.get("Content-Length", "0"))
|
||
if not length:
|
||
return {}
|
||
return json.loads(self.rfile.read(length).decode("utf-8"))
|
||
|
||
def send_file(self, path: Path, content_type: str) -> None:
|
||
if not path.exists():
|
||
write_json(self, {"error": "file_not_found", "file": str(path)}, 404)
|
||
return
|
||
body = path.read_bytes()
|
||
self.send_response(200)
|
||
self.send_header("Content-Type", content_type)
|
||
self.send_header("Content-Disposition", f'attachment; filename="{path.name}"')
|
||
self.send_header("Content-Length", str(len(body)))
|
||
self.end_headers()
|
||
self.wfile.write(body)
|
||
|
||
def handle_upload_roster(self) -> None:
|
||
form = cgi.FieldStorage(fp=self.rfile, headers=self.headers, environ={"REQUEST_METHOD": "POST", "CONTENT_TYPE": self.headers.get("Content-Type", "")})
|
||
item = form["file"]
|
||
filename = Path(item.filename or "roster.xlsx").name
|
||
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||
dest = UPLOAD_DIR / filename
|
||
with dest.open("wb") as fh:
|
||
shutil.copyfileobj(item.file, fh)
|
||
result = subprocess.run([PYTHON, "scripts/invoice_tool.py", "import-roster", "--input", str(dest)], cwd=PROJECT_ROOT, capture_output=True, text=True, check=False)
|
||
write_json(self, {"file": str(dest), "return_code": result.returncode, "stdout": result.stdout, "stderr": result.stderr}, 200 if result.returncode == 0 else 500)
|
||
|
||
def handle_decode_qr(self) -> None:
|
||
body = self.read_json()
|
||
image_b64 = str(body.get("image", "")).strip()
|
||
if not image_b64:
|
||
write_json(self, {"results": [], "error": "未收到图片数据"}, 400)
|
||
return
|
||
|
||
# 去掉 data URL 前缀 (data:image/png;base64,...)
|
||
if "," in image_b64 and image_b64.startswith("data:"):
|
||
image_b64 = image_b64.split(",", 1)[1]
|
||
|
||
try:
|
||
image_bytes = base64.b64decode(image_b64)
|
||
except Exception:
|
||
write_json(self, {"results": [], "error": "图片数据解码失败,请重试"}, 400)
|
||
return
|
||
|
||
result = invoice_tool.decode_qr_image(image_bytes)
|
||
# 保存解码结果,供脚本自动重进使用
|
||
if result.get("results"):
|
||
invoice_tool.save_qr_url(result["results"][0])
|
||
result["qr_pushed"] = invoice_tool.generate_and_push_qr()
|
||
result["saved"] = True
|
||
write_json(self, dict(result))
|
||
|
||
def handle_mark_completed(self) -> None:
|
||
body = self.read_json()
|
||
rows = body.get("rows") or []
|
||
marked = 0
|
||
for item in rows:
|
||
masked = item.get("masked", "")
|
||
real = item.get("real", "")
|
||
id8 = item.get("id8", "")
|
||
if masked or real:
|
||
invoice_tool.update_progress(masked, real, id8, "completed")
|
||
marked += 1
|
||
write_json(self, {"marked": marked})
|
||
|
||
def handle_open_on_phone(self) -> None:
|
||
body = self.read_json()
|
||
url = str(body.get("url", "")).strip()
|
||
if not url:
|
||
write_json(self, {"return_code": -1, "stdout": "", "stderr": "未提供 URL", "device": device_status()}, 400)
|
||
return
|
||
|
||
adb_result = invoice_tool.run_adb(
|
||
["shell", "am", "start", "-a", "android.intent.action.VIEW", "-d", url],
|
||
check=False,
|
||
)
|
||
time.sleep(0.5)
|
||
write_json(self, {
|
||
"return_code": adb_result.returncode,
|
||
"stdout": adb_result.stdout.strip(),
|
||
"stderr": adb_result.stderr.strip(),
|
||
"device": device_status(),
|
||
}, 200 if adb_result.returncode == 0 else 500)
|
||
|
||
|
||
def main() -> None:
|
||
invoice_tool.ensure_dirs()
|
||
server = ThreadingHTTPServer(("127.0.0.1", 8765), Handler)
|
||
print("UI: http://127.0.0.1:8765")
|
||
server.serve_forever()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|