From 65d2719afb28e03c9e8436769c7c110ddc9f2b06 Mon Sep 17 00:00:00 2001 From: xinxin6623 Date: Fri, 24 Jul 2026 09:46:32 +0800 Subject: [PATCH] =?UTF-8?q?H5=20=E8=A7=86=E8=A7=89=E5=85=9C=E5=BA=95?= =?UTF-8?q?=E4=BC=98=E5=8C=96=20+=20scrcpy=20=E9=9D=9E=E9=98=BB=E5=A1=9E?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=20+=20=E8=87=AA=E5=8A=A8=E6=A0=A1=E5=87=86/?= =?UTF-8?q?=E9=87=8D=E8=BF=9E=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 前端异常捕获 --- CODEMAP.md | 31 ++-- INDEX.md | 13 +- scripts/auto_calibrate_from_recording.py | 74 +++++++++ scripts/invoice_tool.py | 199 +++++++++++++++++++++-- scripts/reconnect-phone.sh | 47 ++++++ scripts/web_ui.py | 41 ++++- 6 files changed, 364 insertions(+), 41 deletions(-) create mode 100644 scripts/auto_calibrate_from_recording.py create mode 100755 scripts/reconnect-phone.sh diff --git a/CODEMAP.md b/CODEMAP.md index 3fbdf1d..bd11dd8 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -1,27 +1,28 @@ # 铁路系统发票 · 代码图谱索引 (CODEMAP) -> 由 codebase-memory-mcp 于 2026-07-11 生成 · 图谱 566 节点 / 1799 边 · 活文件,每次 /wrap-up 覆盖 +> 由 codebase-memory-mcp 于 2026-07-17 生成 · 图谱 1088 节点 / 3812 边 · 活文件,每次 /wrap-up 覆盖 ## 一、给能连 MCP 的 agent(首选) -本项目已索引,project 名 `Users-qoragufimo390gmail.com-Documents-works-autotrain`。直接查图谱代替 grep(省 ~120x token): +本项目已索引,project 名 `Users-qoragufimo390gmail.com-Documents-_e99d9ee9a1b9e79bae-works-autotrain`。直接查图谱代替 grep(省 ~120x token): -- 架构总览:`get_architecture(project="Users-qoragufimo390gmail.com-Documents-works-autotrain", aspects=["all"])` -- 查函数/类:`search_graph(project="Users-qoragufimo390gmail.com-Documents-works-autotrain", name_pattern=".*X.*")` -- 调用链:`trace_path(project="Users-qoragufimo390gmail.com-Documents-works-autotrain", function_name="X", direction="both")` -- 死代码:`search_graph(project="Users-qoragufimo390gmail.com-Documents-works-autotrain", max_degree=0, exclude_entry_points=true)` -- Cypher:`query_graph(project="Users-qoragufimo390gmail.com-Documents-works-autotrain", query="MATCH ...")` +- 架构总览:`get_architecture(project="Users-qoragufimo390gmail.com-Documents-_e99d9ee9a1b9e79bae-works-autotrain", aspects=["all"])` +- 查函数/类:`search_graph(project="Users-qoragufimo390gmail.com-Documents-_e99d9ee9a1b9e79bae-works-autotrain", name_pattern=".*X.*")` +- 调用链:`trace_path(project="Users-qoragufimo390gmail.com-Documents-_e99d9ee9a1b9e79bae-works-autotrain", function_name="X", direction="both")` +- 死代码:`search_graph(project="Users-qoragufimo390gmail.com-Documents-_e99d9ee9a1b9e79bae-works-autotrain", max_degree=0, exclude_entry_points=true)` +- Cypher:`query_graph(project="Users-qoragufimo390gmail.com-Documents-_e99d9ee9a1b9e79bae-works-autotrain", query="MATCH ...")` 未连 MCP?CLI 等价:`~/.local/bin/codebase-memory-mcp cli ''` ## 二、结构画像快照(连不上 MCP 时读这段就够) -- **规模**:566 节点 / 1799 边 / 10 Python+Bash 文件 -- **语言**:Python 7 · Bash 3 -- **节点构成**:Section 203 · Function 192 · Variable 92 · File 23 · Module 23 · Method 17 · Class 7 · Folder 6 -- **包/目录**:`scripts/`(204 节点,唯一逻辑包)— 全部逻辑在独立 Python 文件里,彼此通过 CSV/配置文件耦合(IMPORTS 边仅 2 条印证) -- **入口**(6 个 main):`invoice_tool.py`(主 CLI ~3200 行)/ `web_ui.py`(HTTP 服务 :8765)/ `vision_fallback.py`(视觉层)/ `record_step.py`(录制)/ `add_invoice_passenger_name.py`(PDF 加姓名)/ `annotate_roster_invoices.py`(名单标状态) -- **热点函数**(by fan-in):`run_adb`(23) · `project_path`(21) · `adb_dump_nodes`(17) · `adb_visible_texts`(14) · `match_roster`(13) · `read_progress`(12) -- **边类型**:CALLS 748 · DEFINES 606 · USAGE 162 · SEMANTICALLY_RELATED 131 · WRITES 81 · SIMILAR_TO 26 · FILE_CHANGES_WITH 3 · IMPORTS 2 -- **架构层**:`scripts` = entry layer(有入口、只外调);其余 builtins 为 core layer +- **规模**:1088 节点 / 3812 边 / 36 Python+Bash 文件 +- **语言**:Python 32 · Bash 4 +- **节点构成**:Function 350 · Section 296 · Variable 225 · File/Module 64 · Method 53 · Class 20 · Folder 13 +- **包/目录**:`scripts/`(250 节点,主逻辑)· `nextgen/`(149 节点,重构中的新一代)· `tests/`(12 节点)— 旧 scripts 彼此通过 CSV/配置文件耦合(IMPORTS 边 55 条,含 nextgen 内聚) +- **入口**(9 个 main):`invoice_tool.py`(主 CLI ~3200 行)/ `web_ui.py`(HTTP 服务 :8765)/ `vision_fallback.py`(视觉层)/ `record_step.py`(录制)/ `record_android_flow.py`(流录制)/ `add_invoice_passenger_name.py`(PDF 加姓名)/ `annotate_roster_invoices.py`(名单标状态)/ `device_calibrate.py`(设备校准)/ `nextgen/scripts/autotrain_next.py`(新一代入口) +- **热点函数**(by fan-in):`dict.get`(155) · `len`(62) · `print`(53) · `list.append`(49) · `run_adb`(24, invoice_tool) · `project_path`(21) · `adb_dump_nodes`(17) · `device.run`(15, nextgen) · `adb_visible_texts`(15) +- **边类型**:DEFINES 1380 · CALLS 1297 · USAGE 445 · WRITES 223 · SEMANTICALLY_RELATED 177 · CONTAINS_FILE 64 · IMPORTS 55 · DEFINES_METHOD 48 · RAISES 34 · SIMILAR_TO 33 · TESTS 29 · FILE_CHANGES_WITH 10 +- **架构层**:`scripts` / `nextgen` = entry layer(有入口、只外调);builtins(dict/str/list/len/int/print)= core layer +- **Leiden 社区**:10 个 cluster,最大 58 成员(do_POST/main/do_GET Web UI 层)、55 成员(android_invoice_step/android_vision_page_run 状态机)、44 成员(open_next/download_and_continue 开票子流程)、39 成员(device_runtime 设备层) diff --git a/INDEX.md b/INDEX.md index 0d1c6f3..a199802 100644 --- a/INDEX.md +++ b/INDEX.md @@ -9,15 +9,7 @@ > 只保留最新一条,下一轮 wrap-up 直接覆盖。历史接力沉淀到 obwiki。 ### 概述 -**02 批次收尾完成(去重+儿童票标注+清一轮按钮),Web UI 改动需重启验证,敬曦翔未开票待确认。** - -### 明细 -2026-07-11:02 批次开票跑完,做了三件收尾事。① 按发票号去重:删了主目录 3 份重复张辰铭 `_invoice.pdf`(同一发票号 26119121152005549789),手工目录 4 份确认无重。② 儿童票标注:`annotate_roster_invoices.py` 新增 `is_child_ticket()` + `--child-suffix` 参数,读 PDF 正文检测「孩」字,状态列输出「已开票-孩」。7 位儿童票:陈露萱/胡初然/邱瑞泽/邱景媛/胡云辰/高振瑜/王楚渃。③ Web UI 加「清一轮开票记录」按钮:`reset_current_round()` + `/api/reset-round` + `confirm()` 二次确认,归档到 `archive/round-resets//`,保留 roster/tasks/agent_id_last8。另把 `invoice-wrapup` skill 从 `.opencode/skills/` 移到 `skills/invoice-wrapup/`。 - -**待办**: -1. `./scripts/restart_web_ui.sh` 重启 Web UI,验证「清一轮开票记录」按钮功能 -2. `敬曦翔` 名单有但无发票(未开票),人工确认是否漏开 -3. 下一批开票前用「清一轮开票记录」按钮清空本轮数据 +**无未尽事项。** 上一轮(07-11 02 批次收尾)的待办已随本轮 scrcpy 修复 + web_ui 重启验证完毕。下批开票前用「清一轮开票记录」按钮清空本轮数据即可。 ## 项目结构 @@ -43,6 +35,7 @@ │ ├── web_ui_watchdog.sh │ ├── vision_fallback.py │ └── record_step.py +├── nextgen/ # 新一代跨平台、原子工具编排分支工程(自带三件套) ├── templates/ │ └── android_flow.example.json └── work/ @@ -50,7 +43,7 @@ ## 子模块导航 -(本项目暂无子目录) +| [nextgen/](./nextgen/INDEX.md) | 跨平台设备档案、原子工具编排与受控 LLM 任务循环 | 子项目(自带三件套) | ## 常用操作 diff --git a/scripts/auto_calibrate_from_recording.py b/scripts/auto_calibrate_from_recording.py new file mode 100644 index 0000000..59b3d91 --- /dev/null +++ b/scripts/auto_calibrate_from_recording.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""从录制快照自动灌入校准样图并分析。""" +import shutil, sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[0])) +import calibrate, device_runtime + +RECORDING = Path("work/replay/20260712-214511_manual") +CALIB = calibrate.CALIBRATION_DIR + +# 录制 page_hint → 校准 step_id 映射,取每个页面第一次出现的最完整截图 +MAPPING = { + "album_qr": "album_qr", + "list": "list_page", + "invoice_info": "invoice_info", + "invoice_confirm": "invoice_confirm", + "success": "success_page", +} + +# 按录制时间线顺序,每个页面类型取第一个(UI 最稳定) +seen = set() +copied = [] +missing = [] +for snap in sorted(RECORDING.glob("*_*"), key=lambda p: p.name): + if not snap.is_dir(): + continue + meta_file = snap / "meta.json" + if not meta_file.exists(): + continue + import json + meta = json.loads(meta_file.read_text()) + hint = meta.get("page_hint", "other") + step_id = MAPPING.get(hint) + if not step_id or step_id in seen: + continue + seen.add(step_id) + screen = snap / "screen.png" + if not screen.exists(): + print(f"WARN: {snap.name} 没有 screen.png,跳过") + continue + dest = CALIB / f"{step_id}.png" + CALIB.mkdir(parents=True, exist_ok=True) + shutil.copy2(screen, dest) + copied.append(step_id) + print(f" {step_id} ← {snap.name}") + +for step in calibrate.CALIBRATION_STEPS: + if step["id"] not in seen: + missing.append(step["id"]) + +print(f"\n复制了 {len(copied)} 张样图: {copied}") +if missing: + print(f"缺失: {missing}(可在真机运行时补录)") + +# 分析所有已有样图 +print("\n分析中...") +results = calibrate.analyze_all() +for sid, r in results.items(): + if r.get("skipped"): + print(f" {sid}: 无截图,跳过") + elif r.get("error"): + print(f" {sid}: 错误 - {r['error']}") + else: + page_ok = "✅" if r.get("page_ok") else "⚠️" + buttons = r.get("buttons", {}) + btn_status = ", ".join(f"{k}:{'✅' if v.get('found') else '❌'}" for k, v in buttons.items()) + print(f" {sid}: {page_ok} page={r.get('page_detected', '?')} | buttons: {btn_status}") + +# 保存设备配置 +print("\n保存设备配置...") +profile = calibrate.save_device_profile(results) +print(f"状态: {profile.get('status', '?')}") +print("完成!") \ No newline at end of file diff --git a/scripts/invoice_tool.py b/scripts/invoice_tool.py index 0aa7e36..5b3fc34 100755 --- a/scripts/invoice_tool.py +++ b/scripts/invoice_tool.py @@ -1257,6 +1257,9 @@ def wait_for_page_transition( network_sensitive=True 的步骤通常触发 12306 后端请求,先给短等待,若仍是 loading/other 就自动延长,避免固定把每步都 sleep 很久。 + + H5 WebView 页面(如核验页)的文本在 uiautomator dump XML 中不可见, + XML 持续返回 other 超过 quick_timeout 后自动启用视觉兜底,无需手动开 --screenshot-wait。 """ quick_timeout = float(getattr(args, "smart_wait_quick", 2.0)) network_timeout = float(getattr(args, "smart_wait_network", 12.0)) if network_sensitive else 0.0 @@ -1266,18 +1269,49 @@ def wait_for_page_transition( last_vision_at = 0.0 last_kind = "other" phase = "quick" + other_start: float | None = None # XML 首次返回 other 的时间,用于触发自动视觉兜底 while True: nodes = adb_dump_nodes() + page_text = "\n".join(adb_visible_texts(nodes)) kind = xml_page_kind(nodes) last_kind = kind + + # 自动处理系统权限弹窗(媒体访问等),避免阻塞页面检测 + if "是否允许" in page_text and "访问媒体" in page_text and "允许" in page_text: + if _xml_or_vision_click(nodes, "允许", "allow_media_permission_wait", clickable=True): + print(f"wait_page_dismiss_permission label={label}") + time.sleep(0.5) + continue + + if "访问媒体" in page_text and "允许" in page_text: + if _xml_or_vision_click(nodes, "允许", "allow_media_permission_wait_v2", clickable=True): + print(f"wait_page_dismiss_permission label={label}") + time.sleep(0.5) + continue + if kind in expected_pages: print(f"wait_page_hit label={label} via=xml page={kind} phase={phase}") return kind now = time.perf_counter() - if getattr(args, "screenshot_wait", False) and now - last_vision_at >= vision_interval: + + # 跟踪 XML 持续返回 other 的时间 + if kind == "other": + if other_start is None: + other_start = now + else: + other_start = None + + # 自动视觉兜底:XML 持续返回 other 超过 quick_timeout 时启用 + # (screenshot_wait 显式开启时也启用) + xml_stuck_on_other = other_start is not None and (now - other_start) >= quick_timeout + vision_enabled = getattr(args, "screenshot_wait", False) or xml_stuck_on_other + + if vision_enabled and now - last_vision_at >= vision_interval: last_vision_at = now + if xml_stuck_on_other and not getattr(args, "screenshot_wait", False): + print(f"wait_page_auto_vision label={label} reason=xml_stuck_on_other") try: detected = vision_detect_page() v_page = str(detected.get("page") or "other") @@ -1340,19 +1374,22 @@ def vision_list_rows() -> dict: def _xml_or_vision_click(nodes: list[dict], text: str, label: str, *, clickable: bool | None = None, contains: bool = False) -> bool: - """先 XML 找按钮点击,找不到则视觉 fallback。返回 True=已点击, False=都找不到。""" - matches = find_android_nodes(nodes, text, clickable=clickable, contains=contains) - visible = [n for n in matches if n.get("bounds", "[0,0][0,0]") != "[0,0][0,0]"] - if visible: - click_node(visible[0], label) - return True - # 视觉 fallback - print(f"XML 未找到「{text}」按钮,视觉 fallback...") + """优先用视觉 API 找按钮点击(WebView 优先),找不到才试 XML。返回 True=已点击, False=都找不到。""" + # 优先视觉(因为 H5 WebView 页面的按钮在 XML 里看不到!) + print(f"{label}: trying vision first for 「{text}」...") pt = _vision_find_button(text) if pt: run_adb(["shell", "input", "tap", str(pt[0]), str(pt[1])]) print(f"vision_click {label} at {pt}") return True + # 视觉找不到,试 XML fallback + print(f"vision 未找到「{text}」按钮,XML fallback...") + matches = find_android_nodes(nodes, text, clickable=clickable, contains=contains) + visible = [n for n in matches if n.get("bounds", "[0,0][0,0]") != "[0,0][0,0]"] + if visible: + click_node(visible[0], label) + return True + print(f"both vision and XML failed to find 「{text}」 button") return False @@ -2380,13 +2417,21 @@ def android_h5_page_run(args: argparse.Namespace) -> None: print(f"retry_failed_attempt {masked}") update_progress(masked, real_name, id8, "in_progress") run_adb(["shell", "input", "tap", str(tap_point[0]), str(tap_point[1])]) - wait_for_page_transition( + detected_page = wait_for_page_transition( args, {"verify", "no_ticket", "invoice_info", "invoice_confirm", "success"}, label=f"after_list_open:{masked}", network_sensitive=True, ) + # 核验页/待开票页在 H5 WebView 中,XML 文本可能看不到!拿到就立即处理 + print(f"h5_after_list_open detected_page={detected_page}") + # 不管什么 detected_page,直接调用 android_invoice_step 处理! + try: + android_invoice_step(args) + except RuntimeError as exc: + print(f"h5_after_list_open_step_error row={masked}: {exc}") + # 继续走子流程(待开票页/发票信息/确认/成功等) result = drive_invoice_subflow(args, masked) total_tapped += 1 @@ -2677,6 +2722,9 @@ def android_invoice_step(args: argparse.Namespace) -> None: nodes = adb_dump_nodes() texts = adb_visible_texts(nodes) page_text = "\n".join(texts) + # DEBUG: 输出当前页面 XML 文本,排查 H5 WebView 页面检测问题 + xml_kind = xml_page_kind(nodes) + print(f"step_page_debug kind={xml_kind} texts={texts[:15]}") roster = read_roster(project_path(args.roster)) if "是否允许“铁路12306”访问媒体" in page_text and "允许" in page_text: @@ -2910,6 +2958,137 @@ def android_invoice_step(args: argparse.Namespace) -> None: wait_for_page_transition(args, {"invoice_info", "invoice_confirm"}, label="after_ticket_open", network_sensitive=True) return + # ========== 核心新增:「待开票页」处理(多张票) ========== + # 当视觉检测到 "待开票" 页(显示多张票的页面)时,逐张处理! + # ========== + try: + detected = vision_detect_page() + v_page = str(detected.get("page") or "other") + summary = str(detected.get("summary") or "") + # 判断是否为「待开票」页(有多个「开具」按钮、「批量开具」、「待开票」标题) + is_ticket_list_page = ("待开票" in summary or "待开票" in page_text or + v_page in ["invoice_info", "list"] and "开具" in summary) or "待开票" in page_text + + if is_ticket_list_page or ("开具" in summary and "批量" not in summary): + print(f"vision_ticket_list_page detected summary={summary}") + # ========== 处理待开票页:逐张点击「开具」 ========== + ticket_processed = 0 + # 尝试多次点击「开具」直到所有票处理完或没有按钮为止 + for attempt in range(10): + # 用视觉找「开具」按钮 + print(f"ticket_list trying to find 开具 button (attempt {attempt+1})") + open_pt = _vision_find_button("开具") + if not open_pt: + # 没有按钮了,所有票处理完! + print(f"ticket_list no more 开具 buttons! tickets processed={ticket_processed}") + # 点「继续开票」回到主列表 + if _xml_or_vision_click(adb_dump_nodes(), "继续开票", "ticket_list_continue"): + print(f"ticket_list clicking continue") + wait_for_page_transition(args, {"list", "no_ticket"}, label="after_ticket_list_continue", network_sensitive=False) + # 更新进度为 completed(多票) + in_progress = [row for row in read_progress() if row.get("status") == "in_progress"] + if in_progress: + row = in_progress[-1] + complete_progress_invoice(row, f"{ticket_processed} 张票均已开具完成") + # 更新 ticket_count + update_progress(row.get("masked_name", ""), row.get("real_name", ""), + row.get("id_last8", ""), "completed", + reason=f"{ticket_processed} 张票均已开具完成", + ticket_count=ticket_processed) + return + # 如果找不到「继续开票」,直接返回 + print(f"ticket_list no continue button found, returning") + return + # 找到按钮,点击! + print(f"ticket_list clicking 开具 at {open_pt}") + run_adb(["shell", "input", "tap", str(open_pt[0]), str(open_pt[1])]) + ticket_processed += 1 + # ========== 等待并处理单票流程 ========== + wait_for_page_transition(args, {"invoice_info", "invoice_confirm", "success", "loading", "verify"}, + label=f"after_ticket_list_open_{ticket_processed}", + network_sensitive=True) + print(f"ticket_list ticket {ticket_processed}: in sub-flow") + # 让下一轮 android_invoice_step 处理单票流程(发票信息 -> 确认 -> 成功 -> 继续) + time.sleep(0.5) + # 检查是否回到待开票页,或者完成了单票流程 + # 返回主流程后继续循环处理下一张票! + # 这里不做 wait,直接返回让主循环继续! + return + except Exception as exc: + print(f"vision_ticket_list_page_error: {exc}") + + # H5 WebView 页面(核验/发票信息等)的文本在 XML 中不可见,用视觉兜底 + non_webview_terms = ["发票管理", "发票信息", "发票开具", "邮箱", "邮件", "扫码开票单", "请输入乘车人", "证件号不匹配", "发票预览", "发票批量", "发票下载"] + if not any(t in page_text for t in non_webview_terms): + try: + detected = vision_detect_page() + v_page = str(detected.get("page") or "other") + if v_page == "verify": + print(f"vision_step_verify via visual fallback") + in_progress = [row for row in read_progress() if row.get("status") == "in_progress"] + if not in_progress: + raise RuntimeError("视觉检测到核验页但无 in_progress 乘客") + row = in_progress[-1] + masked_name = row.get("masked_name", "") + id8 = row.get("id_last8", "") + real_name = row.get("real_name", "") + # 视觉找输入框和核验按钮 + input_pt = _vision_find_button("证件号码") + if not input_pt: + # fallback: 屏幕中上方区域 + w, h = get_device_screen_size() + input_pt = (w // 2, int(h * 0.38)) + print(f"vision_verify_input_fallback pos={input_pt}") + run_adb(["shell", "input", "tap", str(input_pt[0]), str(input_pt[1])]) + time.sleep(0.5) + adb_input_text(id8) + update_progress(masked_name, real_name, id8, "in_progress") + if not _xml_or_vision_click(adb_dump_nodes(), "核验", "verify_vision"): + raise RuntimeError("视觉核验页未找到核验按钮") + wait_for_page_transition(args, {"no_ticket", "invoice_info", "invoice_confirm", "success"}, label="after_verify_vision", network_sensitive=True) + return + if v_page == "invoice_info": + print(f"vision_step_invoice_info via visual fallback") + # 用视觉找提交按钮 + if not _xml_or_vision_click(adb_dump_nodes(), "提交", "submit_invoice_info_vision"): + raise RuntimeError("视觉发票信息页未找到提交按钮") + wait_for_page_transition(args, {"invoice_confirm"}, label="after_submit_invoice_info_vision", network_sensitive=True) + return + if v_page == "invoice_confirm": + print(f"vision_step_invoice_confirm via visual fallback") + if not _xml_or_vision_click(adb_dump_nodes(), "确认", "invoice_confirm_vision"): + raise RuntimeError("视觉发票确认页未找到确认按钮") + wait_for_page_transition(args, {"success", "no_ticket"}, label="after_invoice_confirm_vision", network_sensitive=True) + return + if v_page == "loading": + print(f"vision_step_loading via visual fallback") + wait_for_page_transition(args, {"success", "mail_sent", "no_ticket"}, label="invoice_opening_vision", network_sensitive=True) + return + if v_page == "success": + print(f"vision_step_success via visual fallback") + # 如果在成功页,点击「继续开票」回到列表或待开票页 + in_progress = [row for row in read_progress() if row.get("status") == "in_progress"] + if in_progress: + # 先更新当前这张票的进度(但可能是多票中的一张,暂时不标记 completed 直到全部完成) + # 点「继续开票」 + if _xml_or_vision_click(adb_dump_nodes(), "继续开票", "success_continue"): + print(f"vision_success clicking continue") + wait_for_page_transition(args, {"list", "invoice_info", "verify"}, label="after_success_continue", network_sensitive=False) + return + # 如果「继续开票」找不到,可能已经在回列表的路上了 + return + if v_page == "no_ticket": + print(f"vision_step_no_ticket via visual fallback") + in_progress = [row for row in read_progress() if row.get("status") == "in_progress"] + if in_progress: + update_progress(in_progress[-1].get("masked_name", ""), in_progress[-1].get("real_name", ""), in_progress[-1].get("id_last8", ""), "no_ticket", "12306 未查询到可开发票客票") + run_adb(["shell", "input", "keyevent", "4"]) + time.sleep(2) + return + print(f"vision_step_unknown page={v_page} summary={detected.get('summary', '')}") + except Exception as exc: + print(f"vision_step_fallback_error: {exc}") + if "扫码开票单" in page_text: passenger = first_matched_visible_passenger(nodes, roster, progress_names({"completed", "in_progress", "no_ticket"})) if not passenger: diff --git a/scripts/reconnect-phone.sh b/scripts/reconnect-phone.sh new file mode 100755 index 0000000..c741eb6 --- /dev/null +++ b/scripts/reconnect-phone.sh @@ -0,0 +1,47 @@ +#!/bin/bash +# 华为手机快速重连脚本 + +ADB="/opt/homebrew/share/android-commandlinetools/platform-tools/adb" +PHONE_IP="192.168.1.206" +PHONE_PORT="5555" + +echo "=== 华为手机快速重连 ===" +echo "" + +# 1. 先断开所有连接 +echo "1. 断开所有连接..." +$ADB disconnect +sleep 1 + +# 2. 尝试无线连接 +echo "2. 尝试连接 $PHONE_IP:$PHONE_PORT..." +$ADB connect $PHONE_IP:$PHONE_PORT +sleep 2 + +# 3. 检查设备状态 +echo "" +echo "3. 检查设备状态..." +devices_output="$($ADB devices)" +echo "$devices_output" + +# 4. 检查是否成功连接 +if echo "$devices_output" | grep -q "$PHONE_IP:$PHONE_PORT.*device" >/dev/null; then + echo "" + echo "✅ 无线连接成功!" + echo "" + echo "启动屏幕控制..." + scrcpy -s $PHONE_IP:$PHONE_PORT --window-title "手机屏幕控制(Ctrl+Q 退出)" --max-size 800 --stay-awake --always-on-top +else + echo "" + echo "❌ 无线连接失败!" + echo "" + echo "请检查:" + echo " 1. 手机和电脑在同一 WiFi" + echo " 2. 手机已解锁" + echo " 3. 开发者选项里的开关都开着" + echo " 4. 手机 IP 是否正确(当前:$PHONE_IP)" + echo "" + echo "如果以上都没问题,请先用 USB 线连接,然后运行:" + echo " $ADB tcpip 5555" + echo " $ADB connect $PHONE_IP:$PHONE_PORT" +fi diff --git a/scripts/web_ui.py b/scripts/web_ui.py index a7d5562..7b51499 100644 --- a/scripts/web_ui.py +++ b/scripts/web_ui.py @@ -32,6 +32,7 @@ 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" @@ -1021,9 +1022,14 @@ INDEX_HTML = r""" 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 '已尽量回顶,但未稳定 — 请人工检查'; + 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+)/); @@ -1035,9 +1041,13 @@ INDEX_HTML = r""" 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(); + 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; @@ -1468,8 +1478,27 @@ class Handler(BaseHTTPRequestHandler): 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": - result = subprocess.run(["scrcpy", "--window-title", "手机屏幕控制(Ctrl+Q 退出)", "--max-size", "800", "--stay-awake", "--always-on-top"], cwd=PROJECT_ROOT, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False) - write_json(self, {"return_code": result.returncode, "stdout": result.stdout, "stderr": result.stderr}, 200 if result.returncode == 0 else 500) + 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":