H5 视觉兜底优化 + scrcpy 非阻塞启动 + 自动校准/重连脚本
- 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 前端异常捕获
This commit is contained in:
parent
9eb6778a6f
commit
65d2719afb
31
CODEMAP.md
31
CODEMAP.md
@ -1,27 +1,28 @@
|
|||||||
<!-- 本文件由 /wrap-up 自动生成并覆盖,勿手工编辑;生成器见 wrap-up skill references/codemap.md -->
|
<!-- 本文件由 /wrap-up 自动生成并覆盖,勿手工编辑;生成器见 wrap-up skill references/codemap.md -->
|
||||||
# 铁路系统发票 · 代码图谱索引 (CODEMAP)
|
# 铁路系统发票 · 代码图谱索引 (CODEMAP)
|
||||||
|
|
||||||
> 由 codebase-memory-mcp 于 2026-07-11 生成 · 图谱 566 节点 / 1799 边 · 活文件,每次 /wrap-up 覆盖
|
> 由 codebase-memory-mcp 于 2026-07-17 生成 · 图谱 1088 节点 / 3812 边 · 活文件,每次 /wrap-up 覆盖
|
||||||
|
|
||||||
## 一、给能连 MCP 的 agent(首选)
|
## 一、给能连 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"])`
|
- 架构总览:`get_architecture(project="Users-qoragufimo390gmail.com-Documents-_e99d9ee9a1b9e79bae-works-autotrain", aspects=["all"])`
|
||||||
- 查函数/类:`search_graph(project="Users-qoragufimo390gmail.com-Documents-works-autotrain", name_pattern=".*X.*")`
|
- 查函数/类:`search_graph(project="Users-qoragufimo390gmail.com-Documents-_e99d9ee9a1b9e79bae-works-autotrain", name_pattern=".*X.*")`
|
||||||
- 调用链:`trace_path(project="Users-qoragufimo390gmail.com-Documents-works-autotrain", function_name="X", direction="both")`
|
- 调用链:`trace_path(project="Users-qoragufimo390gmail.com-Documents-_e99d9ee9a1b9e79bae-works-autotrain", function_name="X", direction="both")`
|
||||||
- 死代码:`search_graph(project="Users-qoragufimo390gmail.com-Documents-works-autotrain", max_degree=0, exclude_entry_points=true)`
|
- 死代码:`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-works-autotrain", query="MATCH ...")`
|
- Cypher:`query_graph(project="Users-qoragufimo390gmail.com-Documents-_e99d9ee9a1b9e79bae-works-autotrain", query="MATCH ...")`
|
||||||
|
|
||||||
未连 MCP?CLI 等价:`~/.local/bin/codebase-memory-mcp cli <tool> '<json>'`
|
未连 MCP?CLI 等价:`~/.local/bin/codebase-memory-mcp cli <tool> '<json>'`
|
||||||
|
|
||||||
## 二、结构画像快照(连不上 MCP 时读这段就够)
|
## 二、结构画像快照(连不上 MCP 时读这段就够)
|
||||||
|
|
||||||
- **规模**:566 节点 / 1799 边 / 10 Python+Bash 文件
|
- **规模**:1088 节点 / 3812 边 / 36 Python+Bash 文件
|
||||||
- **语言**:Python 7 · Bash 3
|
- **语言**:Python 32 · Bash 4
|
||||||
- **节点构成**:Section 203 · Function 192 · Variable 92 · File 23 · Module 23 · Method 17 · Class 7 · Folder 6
|
- **节点构成**:Function 350 · Section 296 · Variable 225 · File/Module 64 · Method 53 · Class 20 · Folder 13
|
||||||
- **包/目录**:`scripts/`(204 节点,唯一逻辑包)— 全部逻辑在独立 Python 文件里,彼此通过 CSV/配置文件耦合(IMPORTS 边仅 2 条印证)
|
- **包/目录**:`scripts/`(250 节点,主逻辑)· `nextgen/`(149 节点,重构中的新一代)· `tests/`(12 节点)— 旧 scripts 彼此通过 CSV/配置文件耦合(IMPORTS 边 55 条,含 nextgen 内聚)
|
||||||
- **入口**(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`(名单标状态)
|
- **入口**(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):`run_adb`(23) · `project_path`(21) · `adb_dump_nodes`(17) · `adb_visible_texts`(14) · `match_roster`(13) · `read_progress`(12)
|
- **热点函数**(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)
|
||||||
- **边类型**:CALLS 748 · DEFINES 606 · USAGE 162 · SEMANTICALLY_RELATED 131 · WRITES 81 · SIMILAR_TO 26 · FILE_CHANGES_WITH 3 · IMPORTS 2
|
- **边类型**: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` = entry layer(有入口、只外调);其余 builtins 为 core layer
|
- **架构层**:`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 设备层)
|
||||||
|
|||||||
13
INDEX.md
13
INDEX.md
@ -9,15 +9,7 @@
|
|||||||
> 只保留最新一条,下一轮 wrap-up 直接覆盖。历史接力沉淀到 obwiki。
|
> 只保留最新一条,下一轮 wrap-up 直接覆盖。历史接力沉淀到 obwiki。
|
||||||
|
|
||||||
### 概述
|
### 概述
|
||||||
**02 批次收尾完成(去重+儿童票标注+清一轮按钮),Web UI 改动需重启验证,敬曦翔未开票待确认。**
|
**无未尽事项。** 上一轮(07-11 02 批次收尾)的待办已随本轮 scrcpy 修复 + 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/<ts>/`,保留 roster/tasks/agent_id_last8。另把 `invoice-wrapup` skill 从 `.opencode/skills/` 移到 `skills/invoice-wrapup/`。
|
|
||||||
|
|
||||||
**待办**:
|
|
||||||
1. `./scripts/restart_web_ui.sh` 重启 Web UI,验证「清一轮开票记录」按钮功能
|
|
||||||
2. `敬曦翔` 名单有但无发票(未开票),人工确认是否漏开
|
|
||||||
3. 下一批开票前用「清一轮开票记录」按钮清空本轮数据
|
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
@ -43,6 +35,7 @@
|
|||||||
│ ├── web_ui_watchdog.sh
|
│ ├── web_ui_watchdog.sh
|
||||||
│ ├── vision_fallback.py
|
│ ├── vision_fallback.py
|
||||||
│ └── record_step.py
|
│ └── record_step.py
|
||||||
|
├── nextgen/ # 新一代跨平台、原子工具编排分支工程(自带三件套)
|
||||||
├── templates/
|
├── templates/
|
||||||
│ └── android_flow.example.json
|
│ └── android_flow.example.json
|
||||||
└── work/
|
└── work/
|
||||||
@ -50,7 +43,7 @@
|
|||||||
|
|
||||||
## 子模块导航
|
## 子模块导航
|
||||||
|
|
||||||
(本项目暂无子目录)
|
| [nextgen/](./nextgen/INDEX.md) | 跨平台设备档案、原子工具编排与受控 LLM 任务循环 | 子项目(自带三件套) |
|
||||||
|
|
||||||
## 常用操作
|
## 常用操作
|
||||||
|
|
||||||
|
|||||||
74
scripts/auto_calibrate_from_recording.py
Normal file
74
scripts/auto_calibrate_from_recording.py
Normal file
@ -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("完成!")
|
||||||
@ -1257,6 +1257,9 @@ def wait_for_page_transition(
|
|||||||
|
|
||||||
network_sensitive=True 的步骤通常触发 12306 后端请求,先给短等待,若仍是 loading/other
|
network_sensitive=True 的步骤通常触发 12306 后端请求,先给短等待,若仍是 loading/other
|
||||||
就自动延长,避免固定把每步都 sleep 很久。
|
就自动延长,避免固定把每步都 sleep 很久。
|
||||||
|
|
||||||
|
H5 WebView 页面(如核验页)的文本在 uiautomator dump XML 中不可见,
|
||||||
|
XML 持续返回 other 超过 quick_timeout 后自动启用视觉兜底,无需手动开 --screenshot-wait。
|
||||||
"""
|
"""
|
||||||
quick_timeout = float(getattr(args, "smart_wait_quick", 2.0))
|
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
|
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_vision_at = 0.0
|
||||||
last_kind = "other"
|
last_kind = "other"
|
||||||
phase = "quick"
|
phase = "quick"
|
||||||
|
other_start: float | None = None # XML 首次返回 other 的时间,用于触发自动视觉兜底
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
nodes = adb_dump_nodes()
|
nodes = adb_dump_nodes()
|
||||||
|
page_text = "\n".join(adb_visible_texts(nodes))
|
||||||
kind = xml_page_kind(nodes)
|
kind = xml_page_kind(nodes)
|
||||||
last_kind = kind
|
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:
|
if kind in expected_pages:
|
||||||
print(f"wait_page_hit label={label} via=xml page={kind} phase={phase}")
|
print(f"wait_page_hit label={label} via=xml page={kind} phase={phase}")
|
||||||
return kind
|
return kind
|
||||||
|
|
||||||
now = time.perf_counter()
|
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
|
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:
|
try:
|
||||||
detected = vision_detect_page()
|
detected = vision_detect_page()
|
||||||
v_page = str(detected.get("page") or "other")
|
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:
|
def _xml_or_vision_click(nodes: list[dict], text: str, label: str, *, clickable: bool | None = None, contains: bool = False) -> bool:
|
||||||
"""先 XML 找按钮点击,找不到则视觉 fallback。返回 True=已点击, False=都找不到。"""
|
"""优先用视觉 API 找按钮点击(WebView 优先),找不到才试 XML。返回 True=已点击, False=都找不到。"""
|
||||||
matches = find_android_nodes(nodes, text, clickable=clickable, contains=contains)
|
# 优先视觉(因为 H5 WebView 页面的按钮在 XML 里看不到!)
|
||||||
visible = [n for n in matches if n.get("bounds", "[0,0][0,0]") != "[0,0][0,0]"]
|
print(f"{label}: trying vision first for 「{text}」...")
|
||||||
if visible:
|
|
||||||
click_node(visible[0], label)
|
|
||||||
return True
|
|
||||||
# 视觉 fallback
|
|
||||||
print(f"XML 未找到「{text}」按钮,视觉 fallback...")
|
|
||||||
pt = _vision_find_button(text)
|
pt = _vision_find_button(text)
|
||||||
if pt:
|
if pt:
|
||||||
run_adb(["shell", "input", "tap", str(pt[0]), str(pt[1])])
|
run_adb(["shell", "input", "tap", str(pt[0]), str(pt[1])])
|
||||||
print(f"vision_click {label} at {pt}")
|
print(f"vision_click {label} at {pt}")
|
||||||
return True
|
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
|
return False
|
||||||
|
|
||||||
|
|
||||||
@ -2380,13 +2417,21 @@ def android_h5_page_run(args: argparse.Namespace) -> None:
|
|||||||
print(f"retry_failed_attempt {masked}")
|
print(f"retry_failed_attempt {masked}")
|
||||||
update_progress(masked, real_name, id8, "in_progress")
|
update_progress(masked, real_name, id8, "in_progress")
|
||||||
run_adb(["shell", "input", "tap", str(tap_point[0]), str(tap_point[1])])
|
run_adb(["shell", "input", "tap", str(tap_point[0]), str(tap_point[1])])
|
||||||
wait_for_page_transition(
|
detected_page = wait_for_page_transition(
|
||||||
args,
|
args,
|
||||||
{"verify", "no_ticket", "invoice_info", "invoice_confirm", "success"},
|
{"verify", "no_ticket", "invoice_info", "invoice_confirm", "success"},
|
||||||
label=f"after_list_open:{masked}",
|
label=f"after_list_open:{masked}",
|
||||||
network_sensitive=True,
|
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)
|
result = drive_invoice_subflow(args, masked)
|
||||||
total_tapped += 1
|
total_tapped += 1
|
||||||
|
|
||||||
@ -2677,6 +2722,9 @@ def android_invoice_step(args: argparse.Namespace) -> None:
|
|||||||
nodes = adb_dump_nodes()
|
nodes = adb_dump_nodes()
|
||||||
texts = adb_visible_texts(nodes)
|
texts = adb_visible_texts(nodes)
|
||||||
page_text = "\n".join(texts)
|
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))
|
roster = read_roster(project_path(args.roster))
|
||||||
|
|
||||||
if "是否允许“铁路12306”访问媒体" in page_text and "允许" in page_text:
|
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)
|
wait_for_page_transition(args, {"invoice_info", "invoice_confirm"}, label="after_ticket_open", network_sensitive=True)
|
||||||
return
|
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:
|
if "扫码开票单" in page_text:
|
||||||
passenger = first_matched_visible_passenger(nodes, roster, progress_names({"completed", "in_progress", "no_ticket"}))
|
passenger = first_matched_visible_passenger(nodes, roster, progress_names({"completed", "in_progress", "no_ticket"}))
|
||||||
if not passenger:
|
if not passenger:
|
||||||
|
|||||||
47
scripts/reconnect-phone.sh
Executable file
47
scripts/reconnect-phone.sh
Executable file
@ -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
|
||||||
@ -32,6 +32,7 @@ RUNNER_STOPPED_BY_USER: dict[str, object] | None = None
|
|||||||
RECORDER: subprocess.Popen[str] | None = None
|
RECORDER: subprocess.Popen[str] | None = None
|
||||||
RECORDER_SESSION: Path | None = None
|
RECORDER_SESSION: Path | None = None
|
||||||
RECORDER_LOG = PROJECT_ROOT / "work" / "phone_recording.log"
|
RECORDER_LOG = PROJECT_ROOT / "work" / "phone_recording.log"
|
||||||
|
SCRCPY: subprocess.Popen[str] | None = None
|
||||||
UPLOAD_DIR = PROJECT_ROOT / "data" / "input"
|
UPLOAD_DIR = PROJECT_ROOT / "data" / "input"
|
||||||
|
|
||||||
|
|
||||||
@ -1021,9 +1022,14 @@ INDEX_HTML = r"""<!doctype html>
|
|||||||
const stdout = (data && data.stdout) || '';
|
const stdout = (data && data.stdout) || '';
|
||||||
const m = stdout.match(/at_top iterations=(\d+)/);
|
const m = stdout.match(/at_top iterations=(\d+)/);
|
||||||
if (m) return `已回到顶部 (滑了 ${m[1]} 次)`;
|
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() || '未知');
|
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') {
|
if (path === '/api/upload-roster') {
|
||||||
const stdout = (data && data.stdout) || '';
|
const stdout = (data && data.stdout) || '';
|
||||||
const rowsMatch = stdout.match(/roster_rows=(\d+)/);
|
const rowsMatch = stdout.match(/roster_rows=(\d+)/);
|
||||||
@ -1035,9 +1041,13 @@ INDEX_HTML = r"""<!doctype html>
|
|||||||
return '操作完成';
|
return '操作完成';
|
||||||
}
|
}
|
||||||
async function postJson(path, body={}) {
|
async function postJson(path, body={}) {
|
||||||
|
try {
|
||||||
const data = await api(path, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) });
|
const data = await api(path, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) });
|
||||||
$('message').textContent = summarize(path, data);
|
$('message').textContent = summarize(path, data);
|
||||||
await refresh();
|
await refresh();
|
||||||
|
} catch (err) {
|
||||||
|
$('message').textContent = '操作失败:' + (err.message || '网络错误');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
async function uploadRoster(file) {
|
async function uploadRoster(file) {
|
||||||
if (!file) return;
|
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)
|
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)
|
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":
|
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)
|
with RUNNER_LOCK:
|
||||||
write_json(self, {"return_code": result.returncode, "stdout": result.stdout, "stderr": result.stderr}, 200 if result.returncode == 0 else 500)
|
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":
|
elif parsed.path == "/api/recording":
|
||||||
body = self.read_json()
|
body = self.read_json()
|
||||||
if str(body.get("action", "start")) == "stop":
|
if str(body.get("action", "start")) == "stop":
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user