From e36b05159b0aab4cf5317020155cd8654a28228b Mon Sep 17 00:00:00 2001 From: Manus AI Date: Sat, 8 Aug 2026 21:48:34 +0800 Subject: [PATCH] fix: expose device pairing QR and guarded cleanup --- sdk/app/routers/devices.py | 453 +++- sdk/app/services/device_manager.py | 42 +- sdk/app/static/hub.html | 1894 ++++++++++++++--- .../test_qrcode_bind_and_offline_delete.py | 28 + 开发文档/10、项目管理/工作日志.md | 16 +- .../工作手机SDK_存客宝宝塔部署总方案.md | 35 + 6 files changed, 2071 insertions(+), 397 deletions(-) create mode 100644 sdk/tests/test_qrcode_bind_and_offline_delete.py diff --git a/sdk/app/routers/devices.py b/sdk/app/routers/devices.py index 63ad9ee18f..a675506ecc 100644 --- a/sdk/app/routers/devices.py +++ b/sdk/app/routers/devices.py @@ -1,10 +1,13 @@ """ 工作手机SDK v3.0 - 设备管理路由 """ +from __future__ import annotations from fastapi import APIRouter, HTTPException, Depends +from fastapi.responses import JSONResponse from typing import List, Optional -from pydantic import BaseModel +from pydantic import BaseModel, Field +import uuid from services.ws_hub import ws_hub from services.device_manager import device_manager @@ -17,6 +20,7 @@ router = APIRouter() class DeviceResponse(BaseModel): """设备信息响应""" device_id: str + device_id_md5: Optional[str] = None name: Optional[str] = None model: Optional[str] = None status: str = "offline" @@ -54,9 +58,60 @@ class InputRequest(BaseModel): class SwipeRequest(BaseModel): - """滑动请求""" + """滑动请求。duration 已弃用,设备 Agent 仅支持 direction + scale。""" direction: str # up, down, left, right scale: float = 0.8 + duration: Optional[float] = Field( + default=None, + description="弃用字段:当前 Agent 未实现持续时长控制;传入时返回 duration_unsupported。", + ) + + +class DeviceOperationReceipt(BaseModel): + """设备 Agent 统一执行/离线回执。""" + code: int + success: bool + data: dict = Field(default_factory=dict) + error_code: Optional[str] = None + error_message: Optional[str] = None + retryable: bool = False + trace_id: Optional[str] = None + channel_used: str + raw_rpc_receipt: Optional[dict] = None + readback: Optional[dict] = None + + +class DeviceNotFoundReceipt(BaseModel): + """设备详情未找到时的统一结构化回执。""" + code: int = 404 + success: bool = False + data: dict = Field(default_factory=dict) + error_code: str = "device_not_found" + error_message: str + retryable: bool = False + trace_id: str + channel_used: str = "websocket/registry" + raw_rpc_receipt: dict + readback: Optional[dict] = None + + +DEVICE_NOT_FOUND_EXAMPLE = { + "code": 404, + "success": False, + "data": {"device_id": "device-unknown"}, + "error_code": "device_not_found", + "error_message": "设备未在登记信息或 WSS Agent 中找到", + "retryable": False, + "trace_id": "device-detail-trace-id", + "channel_used": "websocket/registry", + "raw_rpc_receipt": { + "operation": "get_device", + "device_id": "device-unknown", + "found": False, + "source": "registry+wss", + }, + "readback": None, +} class HeartbeatConfigRequest(BaseModel): @@ -75,21 +130,109 @@ class StandingOrderRequest(BaseModel): order: str +def _ws_receipt(result: dict, *, device_id: str = "", action: str = "") -> dict: + """统一设备域 WS 原始回执,供存客宝逐条真机验收追踪。""" + raw = dict(result or {}) + data = raw.get("data") or {} + code = int(raw.get("code", 500)) + data = raw.get("data") if isinstance(raw.get("data"), dict) else {} + success = bool(data.get("success")) if "success" in data else code == 200 + return { + "code": code, + "success": success, + "data": data, + "channel_used": "websocket/agent", + "trace_id": raw.get("trace_id") or raw.get("command_id"), + "raw_rpc_receipt": raw, + "readback": data.get("readback") or data.get("db_readback"), + } + + +def _ws_offline_receipt(device_id: str, action: str, reason: str = "device_offline") -> JSONResponse: + """WSS Agent 不在线时返回可审计的结构化 503,不转主机 ADB。""" + trace_id = uuid.uuid4().hex + receipt = { + "operation": action, + "device_id": device_id, + "channel": "websocket/offline", + "reason": reason, + "trace_id": trace_id, + } + return JSONResponse( + status_code=503, + content={ + "code": 503, + "success": False, + "data": {}, + "error_code": "device_offline", + "error_message": "设备 WSS Agent 未在线", + "retryable": True, + "trace_id": trace_id, + "channel_used": "websocket/offline", + "raw_rpc_receipt": receipt, + "readback": None, + }, + ) + + +def _ws_failure_receipt(device_id: str, action: str, result: dict) -> JSONResponse: + """Agent 非成功回执保持原始信封并以 503 暴露。""" + body = _ws_receipt(result, device_id=device_id, action=action) + body.update({ + "code": 503, + "success": False, + "error_code": body.get("data", {}).get("error_code") or "device_command_failed", + "error_message": body.get("data", {}).get("error_message") or result.get("message") or "设备 Agent 执行失败", + "retryable": True, + }) + return JSONResponse(status_code=503, content=body) + + +async def list_ws_managed_devices() -> List[dict]: + """设备/Fleet 仅使用登记数据与 WSS Agent 状态,不扫描主机 ADB。""" + stored: List[dict] = [] + try: + stored = await device_manager.get_all_devices() + except Exception: + pass + online = {item["device_id"]: item for item in ws_hub.get_online_devices()} + merged = {item.get("device_id"): dict(item) for item in stored if item.get("device_id")} + for device_id, info in online.items(): + merged[device_id] = {**merged.get(device_id, {}), **info} + from services.device_id_util import enrich_device_id_fields, sanitize_sensitive_fields + devices = [] + for device_id, item in merged.items(): + device = dict(item) + is_online = device_id in online + device["device_id"] = device_id + device["status"] = "online" if is_online else "offline" + device["connection_type"] = "websocket" if is_online else "offline" + devices.append(enrich_device_id_fields(device)) + return sanitize_sensitive_fields(devices) + + # ========== 设备列表 ========== @router.get("/devices", response_model=dict) async def get_devices(): - """获取所有设备列表(融合 WebSocket + ADB + DB)""" - from services.device_fleet import list_merged_local_devices - - devices = await list_merged_local_devices() + """获取所有登记设备(WSS Agent 状态为唯一在线依据)。""" + devices = await list_ws_managed_devices() return {"code": 200, "data": devices} -@router.get("/devices/{device_id}", response_model=dict) +@router.get( + "/devices/{device_id}", + response_model=dict, + responses={ + 404: { + "model": DeviceNotFoundReceipt, + "description": "设备未在登记信息或 WSS Agent 中找到", + "content": {"application/json": {"example": DEVICE_NOT_FOUND_EXAMPLE}}, + }, + }, +) async def get_device(device_id: str): - """获取设备详情(支持 WebSocket / ADB / DB)""" - from services.adb_device import adb_manager + """获取设备详情(登记信息 + WSS Agent,无主机 ADB 探测)。""" # 检查 WebSocket 在线 online_info = ws_hub.get_device_info(device_id) @@ -101,56 +244,62 @@ async def get_device(device_id: str): except Exception: pass - # 检查 ADB 直连(在线程池中执行以避免阻塞) - adb_info = None - import asyncio - - def _check_adb(): - dev = adb_manager.get_device(device_id) - if dev and dev.is_online(): - info = dev.get_info() - info["connection_type"] = "adb" - return info - return None - try: - adb_info = await asyncio.get_running_loop().run_in_executor(None, _check_adb) - except Exception: - pass + if not online_info and not db_info: + trace_id = uuid.uuid4().hex + receipt = { + "operation": "get_device", + "device_id": device_id, + "found": False, + "source": "registry+wss", + "trace_id": trace_id, + } + return JSONResponse( + status_code=404, + content={ + "code": 404, + "success": False, + "data": {"device_id": device_id}, + "error_code": "device_not_found", + "error_message": "设备未在登记信息或 WSS Agent 中找到", + "retryable": False, + "trace_id": trace_id, + "channel_used": "websocket/registry", + "raw_rpc_receipt": receipt, + "readback": None, + }, + ) - if not online_info and not db_info and not adb_info: - raise HTTPException(status_code=404, detail="设备不存在") - - device = {**(db_info or {}), **(adb_info or {}), **(online_info or {})} + device = {**(db_info or {}), **(online_info or {})} if online_info: device["status"] = "online" - elif adb_info: - device["status"] = "adb" + device["connection_type"] = "websocket" else: device["status"] = "offline" + device["connection_type"] = "offline" device["device_id"] = device_id + from services.device_id_util import enrich_device_id_fields, sanitize_sensitive_fields + enrich_device_id_fields(device) - # 若 model/brand/android_version 为空或 Unknown,从 ADB 补充(设备可能同时连 WS 和 USB) - def _is_empty_or_unknown(v) -> bool: - return not v or (isinstance(v, str) and v.strip().lower() in ("", "unknown", "未知")) - for field in ("model", "brand", "android_version"): - if _is_empty_or_unknown(device.get(field)) and adb_info and adb_info.get(field): - device[field] = adb_info[field] - # 若仍缺失,尝试从 adb_manager 实时拉取(设备可能刚连上 ADB) - if _is_empty_or_unknown(device.get("model")) or _is_empty_or_unknown(device.get("android_version")): - try: - dev = adb_manager.get_device(device_id) - if dev and dev.is_online(): - info = await asyncio.wait_for( - asyncio.get_running_loop().run_in_executor(None, dev.get_info), - timeout=5.0, - ) - for field in ("model", "brand", "android_version"): - if _is_empty_or_unknown(device.get(field)) and info.get(field): - device[field] = info[field] - except (asyncio.TimeoutError, Exception): - pass + return {"code": 200, "data": sanitize_sensitive_fields(device)} - return {"code": 200, "data": device} + +@router.delete("/devices/{device_id}", response_model=dict) +async def delete_offline_device(device_id: str): + """删除失效登记设备;在线 WSS 设备必须先断开,避免误删活跃连接。""" + if ws_hub.is_online(device_id): + raise HTTPException(status_code=409, detail="设备在线,停止连接后再删除") + existing = await device_manager.get_device(device_id) + if not existing: + raise HTTPException(status_code=404, detail="设备不存在或已删除") + result = await device_manager.delete_device(device_id) + if not result.get("deleted"): + raise HTTPException(status_code=503, detail="设备登记删除失败") + return { + "code": 200, + "success": True, + "data": {"device_id": device_id, "deleted": True}, + "message": "失效设备已删除,历史命令记录保留用于审计", + } @router.get("/devices/{device_id}/heartbeat", response_model=dict) @@ -179,32 +328,43 @@ async def set_device_heartbeat(device_id: str, req: HeartbeatConfigRequest): # ========== 设备控制 ========== -@router.post("/devices/{device_id}/screenshot", response_model=dict) +@router.post("/devices/{device_id}/screenshot", response_model=dict, responses={503: {"model": DeviceOperationReceipt}}) async def screenshot(device_id: str): - """获取设备截图""" - + """获取设备截图,仅经 WSS Agent 下发。""" + if not ws_hub.is_online(device_id): - raise HTTPException(status_code=503, detail="设备不在线") - - result = await ws_hub.send_command(device_id, { - "type": "execute", - "data": { - "action": "screenshot" - } - }) - + return _ws_offline_receipt(device_id, "screenshot") + + try: + result = await ws_hub.send_command(device_id, { + "type": "execute", + "data": { + "action": "screenshot" + } + }, timeout=12) + except Exception as exc: + return _ws_offline_receipt(device_id, "screenshot", f"ws_exception:{type(exc).__name__}") + if result.get("code") != 200: - raise HTTPException(status_code=result.get("code", 500), detail=result.get("message")) - - return {"code": 200, "data": result.get("data", {})} + return _ws_failure_receipt(device_id, "screenshot", result) + + data = result.get("data", {}) + # Android Agent 使用 image_base64;旧 Python Agent 使用 base64。 + if not data.get("base64") and data.get("image_base64"): + data["base64"] = data["image_base64"] + if not data.get("base64"): + return _ws_failure_receipt(device_id, "screenshot", {**result, "message": "ws_empty_screenshot"}) + data["collection_channel"] = "agent_internal_screenshot" + result["data"] = data + return _ws_receipt(result, device_id=device_id, action="screenshot") -@router.post("/devices/{device_id}/click", response_model=dict) +@router.post("/devices/{device_id}/click", response_model=dict, responses={503: {"model": DeviceOperationReceipt}}) async def click(device_id: str, req: ClickRequest): """点击坐标""" if not ws_hub.is_online(device_id): - raise HTTPException(status_code=503, detail="设备不在线") + return _ws_offline_receipt(device_id, "click") result = await ws_hub.send_command(device_id, { "type": "execute", @@ -214,15 +374,17 @@ async def click(device_id: str, req: ClickRequest): } }) - return {"code": 200, "data": result.get("data", {})} + if result.get("code") != 200: + return _ws_failure_receipt(device_id, "click", result) + return _ws_receipt(result, device_id=device_id, action="click") -@router.post("/devices/{device_id}/click-text", response_model=dict) +@router.post("/devices/{device_id}/click-text", response_model=dict, responses={503: {"model": DeviceOperationReceipt}}) async def click_text(device_id: str, req: ClickTextRequest): """点击文字""" if not ws_hub.is_online(device_id): - raise HTTPException(status_code=503, detail="设备不在线") + return _ws_offline_receipt(device_id, "click_text") result = await ws_hub.send_command(device_id, { "type": "execute", @@ -232,15 +394,17 @@ async def click_text(device_id: str, req: ClickTextRequest): } }) - return {"code": 200, "data": result.get("data", {})} + if result.get("code") != 200: + return _ws_failure_receipt(device_id, "click_text", result) + return _ws_receipt(result, device_id=device_id, action="click_text") -@router.post("/devices/{device_id}/input", response_model=dict) +@router.post("/devices/{device_id}/input", response_model=dict, responses={503: {"model": DeviceOperationReceipt}}) async def input_text(device_id: str, req: InputRequest): """输入文字""" if not ws_hub.is_online(device_id): - raise HTTPException(status_code=503, detail="设备不在线") + return _ws_offline_receipt(device_id, "input") result = await ws_hub.send_command(device_id, { "type": "execute", @@ -250,15 +414,34 @@ async def input_text(device_id: str, req: InputRequest): } }) - return {"code": 200, "data": result.get("data", {})} + if result.get("code") != 200: + return _ws_failure_receipt(device_id, "input", result) + return _ws_receipt(result, device_id=device_id, action="input") -@router.post("/devices/{device_id}/swipe", response_model=dict) +@router.post("/devices/{device_id}/swipe", response_model=dict, responses={422: {"model": DeviceOperationReceipt}, 503: {"model": DeviceOperationReceipt}}) async def swipe(device_id: str, req: SwipeRequest): - """滑动""" + """滑动;持续时长由 Agent 支持后另行开放。""" + + if req.duration is not None: + return JSONResponse( + status_code=422, + content={ + "code": 422, + "success": False, + "data": {}, + "error_code": "duration_unsupported", + "error_message": "swipe.duration 已弃用;请仅传 direction 与 scale", + "retryable": False, + "trace_id": None, + "channel_used": "contract/validation", + "raw_rpc_receipt": None, + "readback": None, + }, + ) if not ws_hub.is_online(device_id): - raise HTTPException(status_code=503, detail="设备不在线") + return _ws_offline_receipt(device_id, "swipe") result = await ws_hub.send_command(device_id, { "type": "execute", @@ -268,15 +451,17 @@ async def swipe(device_id: str, req: SwipeRequest): } }) - return {"code": 200, "data": result.get("data", {})} + if result.get("code") != 200: + return _ws_failure_receipt(device_id, "swipe", result) + return _ws_receipt(result, device_id=device_id, action="swipe") -@router.get("/devices/{device_id}/ui-tree", response_model=dict) +@router.get("/devices/{device_id}/ui-tree", response_model=dict, responses={503: {"model": DeviceOperationReceipt}}) async def get_ui_tree(device_id: str): - """获取UI树""" + """获取 UI 树;外层执行固定为 WSS Agent。""" if not ws_hub.is_online(device_id): - raise HTTPException(status_code=503, detail="设备不在线") + return _ws_offline_receipt(device_id, "ui_tree") result = await ws_hub.send_command(device_id, { "type": "execute", @@ -285,7 +470,15 @@ async def get_ui_tree(device_id: str): } }) - return {"code": 200, "data": result.get("data", {})} + if result.get("code") != 200: + return _ws_failure_receipt(device_id, "ui_tree", result) + data = result.get("data") if isinstance(result.get("data"), dict) else {} + if data.get("channel") == "accessibility": + data["agent_collection_channel"] = "accessibility" + data["channel"] = "agent_internal_accessibility" + data["collection_scope"] = "agent_internal_device_diagnostic" + result["data"] = data + return _ws_receipt(result, device_id=device_id, action="ui_tree") # ========== 脚本执行 ========== @@ -332,18 +525,17 @@ async def push_ai_task(device_id: str, req: AITaskRequest): if not ws_hub.is_online(device_id): raise HTTPException(status_code=503, detail="设备不在线") - ok = await ws_hub.push_ai_task(device_id, req.instruction, req.priority) - if not ok: + result = await ws_hub.send_command(device_id, { + "type": "ai_task", + "data": {"instruction": req.instruction, "priority": req.priority}, + }, timeout=20) + if int(result.get("code", 500)) >= 400: raise HTTPException(status_code=500, detail="推送失败") - return { - "code": 200, - "data": { - "device_id": device_id, - "instruction": req.instruction, - "priority": req.priority, - "status": "queued", - }, - } + receipt = _ws_receipt(result) + receipt["data"] = {**(receipt.get("data") or {}), "device_id": device_id, + "instruction": req.instruction, "priority": req.priority, + "status": "queued"} + return receipt @router.post("/devices/{device_id}/ai/standing-order", response_model=dict) @@ -352,17 +544,16 @@ async def push_standing_order(device_id: str, req: StandingOrderRequest): if not ws_hub.is_online(device_id): raise HTTPException(status_code=503, detail="设备不在线") - ok = await ws_hub.push_standing_order(device_id, req.order) - if not ok: + result = await ws_hub.send_command(device_id, { + "type": "standing_order", + "data": {"order": req.order}, + }, timeout=20) + if int(result.get("code", 500)) >= 400: raise HTTPException(status_code=500, detail="推送失败") - return { - "code": 200, - "data": { - "device_id": device_id, - "order": req.order, - "status": "pushed", - }, - } + receipt = _ws_receipt(result) + receipt["data"] = {**(receipt.get("data") or {}), "device_id": device_id, + "order": req.order, "status": "pushed"} + return receipt @router.post("/devices/{device_id}/ai/execute", response_model=dict) @@ -376,11 +567,55 @@ async def ai_execute(device_id: str, task: str, timeout: int = 60): "data": {"task": task}, }, timeout=timeout) - return { - "code": result.get("code", 200), - "message": result.get("message", ""), - "data": result.get("data", {}), - } + return _ws_receipt(result) + + +class DeviceAIChatRequest(BaseModel): + """卡若网关决策 + WS 下发(手机无需本地 api_key)""" + instruction: str + timeout: int = 120 + + +@router.post("/devices/{device_id}/ai/chat", response_model=dict) +async def device_ai_chat(device_id: str, req: DeviceAIChatRequest): + """ + 小 AI 核心端点:服务端调卡若 /api/gateway/chat 决策,经 WS 让手机执行。 + 与日常卡若接口一致,不另建 Ollama/local-ai。 + """ + from services.karuo_device_ai import chat_and_execute_on_device + + result = await chat_and_execute_on_device(device_id, req.instruction, req.timeout) + code = int(result.get("code", 200)) + if code >= 400: + raise HTTPException(status_code=code, detail=result.get("message", "执行失败")) + return {"code": 200, "data": result} + + +class HotUpdateRequest(BaseModel): + update_type: str = "config" # config | hub_html | refresh + config: Optional[dict] = None + content: Optional[str] = None + url: Optional[str] = None + + +@router.post("/devices/{device_id}/hot-update", response_model=dict) +async def device_hot_update(device_id: str, req: HotUpdateRequest): + """热更新 Hub / 配置(如 ai_api_key),无需重装 APK""" + if not ws_hub.is_online(device_id): + raise HTTPException(status_code=503, detail="设备不在线") + payload: dict = {"update_type": req.update_type} + if req.config: + payload["config"] = req.config + if req.content: + payload["content"] = req.content + if req.url: + payload["url"] = req.url + result = await ws_hub.send_command( + device_id, + {"type": "ui_update", "data": payload}, + timeout=60, + ) + return {"code": result.get("code", 200), "data": result} # ========== AI 心跳监控仪表盘 ========== diff --git a/sdk/app/services/device_manager.py b/sdk/app/services/device_manager.py index a0f8a8b667..c2e5cf982c 100644 --- a/sdk/app/services/device_manager.py +++ b/sdk/app/services/device_manager.py @@ -3,8 +3,12 @@ """ import hashlib -from motor.motor_asyncio import AsyncIOMotorClient -from typing import Optional, List +from typing import Optional, List, TYPE_CHECKING + +try: + from motor.motor_asyncio import AsyncIOMotorClient +except ImportError: # NAS 无 Mongo 依赖时降级 + AsyncIOMotorClient = None # type: ignore[misc, assignment] import logging from datetime import datetime @@ -22,11 +26,15 @@ class DeviceManager: async def init(self): """初始化数据库连接(MongoDB 不可用时降级为无 DB 模式)""" + if AsyncIOMotorClient is None: + logger.warning("motor 未安装,MongoDB 功能降级为无 DB 模式") + return try: self.client = AsyncIOMotorClient( settings.MONGO_URI, serverSelectionTimeoutMS=3000, # 3秒超时,不阻塞启动 ) + await self.client.admin.command("ping") self.db = self.client[settings.MONGO_DB] # 尝试创建索引 @@ -40,6 +48,8 @@ class DeviceManager: logger.info(f"MongoDB连接成功(无索引): {settings.MONGO_DB}") except Exception as e: logger.warning(f"MongoDB 连接失败(降级为无DB模式,ADB 直连仍可用): {e}") + if self.client: + self.client.close() self.client = None self.db = None @@ -98,6 +108,13 @@ class DeviceManager: cursor = self.db.devices.find({}, {"_id": 0}).skip(skip).limit(limit) return await cursor.to_list(length=limit) + async def delete_device(self, device_id: str) -> dict: + """删除失效设备登记;命令历史保留用于审计。""" + if self.db is None: + return {"deleted": False, "reason": "device_store_unavailable"} + result = await self.db.devices.delete_one({"device_id": device_id}) + return {"deleted": result.deleted_count > 0, "deleted_count": result.deleted_count} + async def update_device_status(self, device_id: str, status: str): """更新设备状态""" if self.db is None: @@ -122,18 +139,35 @@ class DeviceManager: @staticmethod def compute_fingerprint(info: dict) -> str: - """从设备上报信息计算指纹哈希(MD5)""" + """从设备上报信息计算指纹哈希(MD5)。 + + 防封要点(G2 修复 2026-05-31):硬件字段缺失时,绝不能让所有设备 + 都得到空串 MD5(d41d8cd98f00b204e9800998ecf8427e)—— 那会令全部设备 + 互相"指纹碰撞",使碰撞检测失效。无硬件数据时回退到 device_id 作为 + 稳定种子,保证一机一指纹;同时兼容嵌套 display.{width,height}。 + """ keys = sorted([ "brand", "model", "manufacturer", "android_version", "sdk_version", "serial", "imei", "mac", "bluetooth_mac", "screen_width", "screen_height", "density", "cpu_abi", "fingerprint", "android_id", ]) + merged = dict(info or {}) + # 兼容 agent 上报的嵌套 display.{width,height} + disp = merged.get("display") + if isinstance(disp, dict): + merged.setdefault("screen_width", disp.get("width") or disp.get("displayWidth")) + merged.setdefault("screen_height", disp.get("height") or disp.get("displayHeight")) parts = [] for k in keys: - v = info.get(k, "") + v = merged.get(k, "") if v: parts.append(f"{k}={v}") + if not parts: + # 无任何硬件字段:用 device_id 兜底,避免全设备空哈希误撞 + did = merged.get("device_id", "") + if did: + parts.append(f"device_id={did}") raw = "|".join(parts) return hashlib.md5(raw.encode("utf-8")).hexdigest() diff --git a/sdk/app/static/hub.html b/sdk/app/static/hub.html index 6642998947..5f9033e4b4 100644 --- a/sdk/app/static/hub.html +++ b/sdk/app/static/hub.html @@ -3,7 +3,7 @@ -AI数智员工 · 控制台 +工作手机控制台 @@ -319,11 +502,12 @@ radial-gradient(ellipse 40% 40% at 10% 90%,rgba(124,58,237,.06),transparent)}
⚙️
-

AI数智员工 · 控制台

+

工作手机控制台

多手机统一管控 · 设备管理 · 智能引擎 · 对接网关 · 知识库

+ 检测中 WS 0 ADB 0 @@ -375,8 +559,8 @@ const API = (function() { return location.origin !== 'null' ? location.origin : ''; })(); const NAVS = [ - {id:'overview', icon:'🏠', name:'工作台', desc:'KPI 面板 · 快速操作 · 发现'}, - {id:'devices', icon:'📱', name:'设备管理', desc:'控制 · 接口 · 系统架构'}, + {id:'overview', icon:'📊', name:'数据总览', desc:'数据 · 快捷入口 · 扫码'}, + {id:'devices', icon:'📱', name:'手机设备', desc:'设备列表 · 状态 · 进入控制'}, {id:'engine', icon:'🧠', name:'智能引擎', desc:'AI Brain · Hook · 心跳'}, {id:'gateway', icon:'🔗', name:'对接网关', desc:'REST · MCP · OpenAI'}, {id:'docs', icon:'📖', name:'知识库', desc:'文档 · 架构图 · 手册'}, @@ -394,6 +578,7 @@ const state = { docFilter: '', devices: [], currentDevice: null, + deviceView: 'list', deviceTab: 'control', appFocus: 'wechat', protocol: null, @@ -420,7 +605,16 @@ const state = { brainSkillFilter: 'all', infraTab: 'connection', docsTab: 'manual', + bindingServer: '', selectedAgentDoc: null, + integrationManifest: null, + integrationManifestLoading: false, + integrationManifestError: '', + realtime: {devices: [], recent_events: []}, + realtimeError: '', + antibanStatus: null, + aiConfig: null, + aiConfigLoading: false, }; /* ═══ Utilities ═══ */ @@ -433,25 +627,121 @@ function log(message, type='info'){ const ts = new Date().toLocaleTimeString('zh-CN', {hour12:false}); state.logs.push({message, type, ts}); if (state.logs.length > 80) state.logs.shift(); - const box = document.querySelector('.log-box'); + const box = document.getElementById('device-action-logs') || document.querySelector('.log-box:not(#wx-result)'); if (box) { box.innerHTML = state.logs.map(l=>`
[${l.ts}] ${esc(l.message)}
`).join(''); box.scrollTop=box.scrollHeight; } } /* ═══ API ═══ */ +let consoleAuthPromise = null; +let consoleAuthenticated = false; + +function updateConsoleAuthChip(ok){ + const chip = document.getElementById('chip-auth'); + if (!chip) return; + consoleAuthenticated = ok; + chip.textContent = ok ? '🔐 已登录 · 退出' : '🔓 登录'; + chip.style.color = ok ? 'var(--green)' : 'var(--red)'; +} + +async function ensureConsoleAuthenticated(){ + if (consoleAuthPromise) return consoleAuthPromise; + consoleAuthPromise = (async () => { + const res = await fetch(`${API}/api/v3/console/session`, {credentials:'same-origin'}); + if (res.ok) { + updateConsoleAuthChip(true); + return true; + } + updateConsoleAuthChip(false); + return false; + })().finally(() => { consoleAuthPromise = null; }); + return consoleAuthPromise; +} + +async function loginConsole(){ + const username = document.getElementById('console-username')?.value?.trim() || ''; + const password = document.getElementById('console-password')?.value || ''; + const error = document.getElementById('console-login-error'); + if (!username || !password) { if (error) error.textContent = '请输入账号和密码'; return; } + const res = await fetch(`${API}/api/v3/console/login`, { + method:'POST', credentials:'same-origin', headers:{'Content-Type':'application/json'}, + body:JSON.stringify({username,password}), + }); + if (!res.ok) { if (error) error.textContent = '账号或密码错误'; return; } + updateConsoleAuthChip(true); + await bootstrap(); + startPolling(); +} + +async function logoutConsole(){ + await fetch(`${API}/api/v3/console/logout`, {method:'POST', credentials:'same-origin'}); + clearInterval(_pollTimer); _pollTimer = null; + updateConsoleAuthChip(false); + await bootstrap(); +} + +async function toggleConsoleAuth(){ + if (consoleAuthenticated) await logoutConsole(); + else { + await bootstrap(); + document.getElementById('console-username')?.focus(); + } +} + +async function authenticatedFetch(url, options={}){ + if (!consoleAuthenticated && !await ensureConsoleAuthenticated()) { + throw new Error('控制台未认证'); + } + const headers = new Headers(options.headers || {}); + const res = await fetch(url, {...options, headers, credentials:'same-origin'}); + if (res.status === 401) { + updateConsoleAuthChip(false); + } + return res; +} + +async function apiErrorFromResponse(res){ + const text = await res.text().catch(() => ''); + let message = `请求失败: ${res.status}`; + try { + const obj = JSON.parse(text); + if (obj && typeof obj === 'object') { + message = obj.message || obj.detail || obj.error || message; + } + } catch (_e) {} + const err = new Error(message); + err.status = res.status; + err.body = text; + return err; +} + async function apiGet(url){ - const res = await fetch(`${API}${url}`); - if(!res.ok) throw new Error(`请求失败: ${res.status}`); + const res = await authenticatedFetch(`${API}${url}`); + if(!res.ok) throw await apiErrorFromResponse(res); return res.json(); } async function apiPost(url, body){ - const res = await fetch(`${API}${url}`, { + const res = await authenticatedFetch(`${API}${url}`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body || {}) }); - if(!res.ok) throw new Error(`请求失败: ${res.status}`); + if(!res.ok) throw await apiErrorFromResponse(res); + return res.json(); +} + +async function apiDelete(url){ + const res = await authenticatedFetch(`${API}${url}`, {method:'DELETE'}); + if(!res.ok) throw await apiErrorFromResponse(res); + return res.json(); +} + +async function apiUpload(url, file){ + const form = new FormData(); + form.append('file', file); + const res = await authenticatedFetch(`${API}${url}`, {method:'POST', body:form}); + if(!res.ok) throw await apiErrorFromResponse(res); return res.json(); } @@ -480,18 +770,18 @@ function renderStats(){ const totalDevices = ov.device_total ?? 0; const wsCount = ov.ws_online ?? 0; const adbCount = ov.adb_count ?? 0; - const controllable = wsCount + adbCount; + const controllable = ov.device_online ?? (wsCount + adbCount); document.getElementById('stats').innerHTML = ` ${statCard('📱 设备总数', `${totalDevices}`, `${controllable} 可控 · ${unauth ? unauth + ' 待授权' : '全部就绪'}`)} ${statCard('🔌 WS 连接', wsCount, wsCount > 0 ? '设备在线' : '无连接')} - ${statCard('📡 ADB 设备', `${adbCount} / ${adbAll}`, unauth ? `⚠️ ${unauth} 待授权` : 'USB/WiFi 直连')} + ${statCard('📷 扫码设备', wsCount, wsCount > 0 ? '无线控制已连接' : '等待手机扫码连接')} ${statCard('🔗 API 端点', '150+', '3 种对接方式')} ${statCard('📄 知识文档', (ov.docs || state.docs || []).length || 0, '站内阅读')} ${statCard('⚡ 服务状态', ov.sdk_online ? '健康' : '离线', `v${ov.sdk_version || '-'}`)} `; document.getElementById('chip-health').innerHTML = `${ov.sdk_online ? '在线' : '离线'}`; document.getElementById('chip-ws').textContent = `WS ${wsCount}`; - document.getElementById('chip-adb').textContent = `ADB ${adbCount}${unauth ? ` ⚠${unauth}` : ''}`; + document.getElementById('chip-adb').textContent = `扫码 ${wsCount}`; document.getElementById('chip-version').textContent = `v${ov.sdk_version || '-'}`; updateConnBar(); } @@ -507,7 +797,8 @@ function statCard(title, value, desc){ } function navigate(id){ - if (id === 'wechat') { state.current = 'devices'; state.deviceTab = 'control'; } + if (id === 'wechat') { state.current = 'devices'; state.deviceView = 'list'; } + else if (id === 'devices') { state.current = 'devices'; state.deviceView = 'list'; } else if (id === 'infra') { state.current = 'engine'; state.engineTab = 'connection'; } else { state.current = id; } const url = new URL(location.href); @@ -546,6 +837,49 @@ function deviceStatusLabel(device){ return `${esc(st)}`; } +function deviceControlCards(modes){ + const list = Array.isArray(modes) ? modes : []; + const find = (...ids) => list.find(mode => ids.includes(mode.id)); + const hook = find('hook'); + const agent = find('agent','u2'); + const adb = find('adb'); + return [ + { + id:'data', icon:'🪝', name:'数据能力', + desc:'微信资料、好友、群聊、标签', + source:hook, status:hook?.available?'已连接':'等待 Hook', + detail:hook?.available ? (hook.detail || 'Hook / Frida 已就绪') : '需要 Root + Frida Server' + }, + { + id:'ui', icon:'🖱️', name:'交互操作', + desc:'打开应用、点击、输入、朋友圈', + source:agent, status:agent?.available?'已连接':'等待 Agent', + detail:agent?.available ? (agent.detail || 'Agent WebSocket 已就绪') : '需要手机端工作 App' + }, + { + id:'device', icon:'🔗', name:'高级连接', + desc:'Agent、无障碍、ADB、注入引擎', + source:agent?.available ? agent : adb, status:agent?.available?'已连接':(adb?.available?'ADB兜底':'待配置'), + detail:agent?.available ? '无线主控与系统服务统一管理' : (adb?.detail || '等待设备服务上线') + } + ]; +} + +function openControlCapability(type){ + if(type === 'data'){ + state.current = 'engine'; + state.engineTab = 'hook'; + } else if(type === 'ui'){ + state.current = 'devices'; + state.deviceView = 'detail'; + } else { + state.current = 'devices'; + state.deviceView = 'detail'; + } + renderSidebar(); + renderCurrentView(); +} + /* ═══ 1. Overview Panel ═══ */ function renderOverview(){ @@ -555,7 +889,7 @@ function renderOverview(){ const totalDevices = ov.device_total || 0; const wsCount = ov.ws_online || 0; const adbCount = ov.adb_count || 0; - const controllable = wsCount + adbCount; + const controllable = ov.device_online ?? (wsCount + adbCount); const unauth = ov.unauthorized_count || 0; const discoveredCount = state.discoveredServers?.length || 0; @@ -623,28 +957,28 @@ function renderOverview(){
` : ''} - -
-
-

控制链路

从业务请求到设备执行的四步闭环

+ +
+
+
+
DEVICE CONTROL
+
设备能力中心
+
按使用目的选择,不需要理解底层技术。
+
+
+ AI 自动编排 + +
-
-
STEP 1

业务请求

存客宝 / 管理端 / API 入口统一进入 SDK

-
STEP 2

智能决策

Hook → Agent → ADB → AI 多通道自动择优

-
STEP 3

设备执行

Frida / u2 / scrcpy 分别承接深度控制和自动化

-
STEP 4

结果沉淀

事件、截图、经验回到工作台形成闭环

-
-
-
-

控制模式

5 种控制模式按优先级自动选择

-
-
-
- ${modes.map(m => { - const icons = {hook:'🪝',agent:'🔌',adb:'📱',ai:'🤖',scrcpy:'🖥️'}; - return `
${icons[m.id]||'⚡'}
${m.name}
${m.desc}
P${m.priority||'?'}
`; - }).join('')} +
+ ${deviceControlCards(modes).map((m,index) => ``).join('')}
+
系统会自动选择最快可用通道:数据优先走 Hook,界面操作走 Agent,设备级操作走 ADB。

设备快照

已连设备实时状态

@@ -653,7 +987,7 @@ function renderOverview(){
${devices.length ? devices.slice(0,8).map(d => { const isU = d.status==='unauthorized'; - return ` + return ` @@ -661,15 +995,51 @@ function renderOverview(){ }).join('') : ``}
设备状态连接当前 APP
${isU?'🔒 ':''}${esc(d.model||d.device_id||'-')} ${deviceStatusLabel(d)}
${isU?'⚠ 需授权':((d.connection_modes||[]).filter(m=>m.available).slice(0,3).map(m=>`${esc(m.id)}`).join('')||'ADB')}
暂无设备 · 连接设备
+ ${renderBindSection()} `; } +function renderOverviewClean(){ + const ov = state.overview || {}; + const devices = state.devices || ov.devices || []; + const total = ov.device_total ?? devices.length; + const online = ov.device_online ?? devices.filter(d=>['online','adb'].includes(d.status||d.adb_status)).length; + const ws = ov.ws_online ?? devices.filter(d=>d.status==='online').length; + const adb = ov.adb_count ?? devices.filter(deviceHasAdb).length; + const offline = Math.max(0, total-online); + const deviceRows = devices.slice(0,5).map(d=>``).join('') || '
当前没有已连接设备,可在右侧扫码绑定
'; + return ` +
+
工作手机运营中心

数据总览

设备、连接和服务状态集中查看,核心入口一步到达。

+ +
+
+ ${[['设备总数',total,'📱','全部登记设备'],['在线设备',online,'●','当前可控制'],['WS / ADB',`${ws} / ${adb}`,'🔗','无线与直连'],['离线设备',offline,'○','等待设备上线']].map(([label,value,icon,desc])=>`
${icon}
${label}${value}

${desc}

`).join('')} +
+
+
快捷入口

常用操作

+
+ + + + +
+
+
+
设备快照

最近设备

${deviceRows}
+ ${renderBindSection()} +
`; +} + /* ═══ 1.5 WeChat Control Panel ═══ */ const WX_MODULES = { message:{title:'消息管理',count:6,ch:'Hook优先(Frida RPC ~200ms) | u2降级(~5s)',actions:[ {n:'发送消息',a:'send_message',f:[{k:'to_id',l:'联系人'},{k:'content',l:'内容',t:'textarea'},{k:'msg_type',l:'类型',v:'text'}]}, - {n:'获取消息',a:'get_messages',f:[{k:'conversation_id',l:'会话'},{k:'limit',l:'数量',v:'20'}]}, + {n:'获取消息',a:'get_messages',f:[{k:'conversation_id',l:'会话'},{k:'limit',l:'数量',v:'500'}]}, {n:'批量发送',a:'batch_send_message',f:[{k:'to_ids',l:'接收者(逗号分隔)'},{k:'content',l:'内容',t:'textarea'}]}, {n:'转发消息',a:'forward_message',f:[{k:'to_id',l:'转发给'}]}, {n:'撤回消息',a:'recall_message',f:[]}, @@ -680,7 +1050,7 @@ const WX_MODULES = { {n:'通过请求',a:'accept_friend',f:[{k:'user_id',l:'用户(可选)'}]}, {n:'设置备注',a:'set_remark',f:[{k:'user_id',l:'好友'},{k:'remark',l:'备注'}]}, {n:'删除好友',a:'delete_friend',f:[{k:'user_id',l:'好友'}]}, - {n:'获取联系人',a:'get_contacts',f:[{k:'limit',l:'数量',v:'100'}]}, + {n:'获取联系人',a:'get_contacts',f:[{k:'limit',l:'数量',v:'500'}]}, {n:'搜索联系人',a:'search_contact',f:[{k:'keyword',l:'关键词'}]}, {n:'好友详情',a:'get_friend_info',f:[{k:'user_id',l:'好友'}]}, {n:'批量添加',a:'batch_add_friend',f:[{k:'user_ids',l:'ID列表(逗号)'},{k:'message',l:'验证消息'}]}, @@ -756,7 +1126,7 @@ const WX_MODULES = { {n:'关注公众号',a:'follow_official_account',f:[{k:'account_name',l:'公众号名'}]}, {n:'语音通话',a:'voice_call',f:[{k:'user_id',l:'联系人'}]}, {n:'视频通话',a:'video_call',f:[{k:'user_id',l:'联系人'}]}, - {n:'群发消息',a:'mass_send',f:[{k:'content',l:'内容',t:'textarea'}]}, + {n:'群发消息',a:'mass_send',f:[{k:'user_ids',l:'接收者(逗号)'},{k:'content',l:'内容',t:'textarea'}]}, {n:'搜一搜',a:'wechat_search',f:[{k:'keyword',l:'关键词'}]}, {n:'看一看',a:'top_stories',f:[]}, {n:'获取步数',a:'get_steps',f:[]}, @@ -785,6 +1155,41 @@ function wxStatusTone(device){ return {label:'离线', color:'var(--red)'}; } +function wxMapValue(device, ...keys){ + const profile = device?.device_profile || device?.profile || {}; + for (const source of [device || {}, profile || {}]) { + for (const key of keys) { + if (source[key] !== undefined && source[key] !== null && source[key] !== '') return source[key]; + } + } + return ''; +} + +function wxDisplayTime(value){ + if (!value) return '待现场核对'; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString('zh-CN', {hour12:false}); +} + +function wxDeviceMap(device){ + return { + wxid: wxMapValue(device, 'wxid', 'wx_id', 'wechat_wxid'), + wechatId: wxMapValue(device, 'wechat_id', 'wechatId', 'account_wechat_id'), + friendCount: wxMapValue(device, 'friend_count', 'friends_count', 'contact_count', 'totalFriend', 'total_friend'), + lastActive: wxMapValue(device, 'last_active', 'last_seen', 'last_activity_at', 'last_heartbeat', 'updated_at'), + }; +} + +function deviceHasAdb(device){ + return !!device && ( + device.status==='adb' || + device.adb_status==='device' || + device.connection_type==='adb' || + device.best_mode?.id==='adb' || + (device.connection_modes||[]).some(mode=>mode.id==='adb' && mode.available) + ); +} + function wxHealthScore(device){ if (!device) return 0; let score = 35; @@ -807,19 +1212,71 @@ function wxReadInput(id){ return el ? String(el.value || '').trim() : ''; } +function wxCompactResult(data){ + const payload = data?.data && typeof data.data === 'object' ? data.data : data; + const summary = {}; + ['count','returned_count','total_count','raw_total_count','has_more','offset','channel','action_resolved'].forEach(key => { + if (payload?.[key] !== undefined) summary[key] = payload[key]; + }); + ['contacts','messages','groups','labels','tags','moments'].forEach(key => { + if (Array.isArray(payload?.[key])) summary[`${key}_returned_count`] = payload[key].length; + }); + const display = Object.keys(summary).length ? {summary, response:data} : data; + return JSON.stringify(display, (_key, value) => { + if (Array.isArray(value) && value.length > 20) { + return {returned_count:value.length, preview:value.slice(0, 10)}; + } + return value; + }, 2); +} -function renderUnifiedControl(){ +function wxResultHtml(data){ + const payload = data?.data && typeof data.data === 'object' ? data.data : data || {}; + const source = data?.channel_used || payload.channel || 'sdk_control'; + const profileKeys = [ + ['wxid','微信 ID'],['nickname','昵称'],['remark','备注'],['wechat_version','微信版本'], + ['wechat_running','运行状态'],['installed','已安装'] + ]; + const profileItems = profileKeys.filter(([key]) => payload[key] !== undefined && payload[key] !== null && payload[key] !== '') + .map(([key,label]) => `
${label}${esc(payload[key] === true ? '正常' : payload[key] === false ? '否' : String(payload[key]))}
`).join(''); + if(profileItems){ + return `
资料已获取成功
${profileItems}
`; + } + const listKey = ['contacts','groups','labels','tags','moments'].find(key => Array.isArray(payload[key])); + if(listKey){ + const list = payload[listKey] || []; + if(!list.length) return `
${listKey==='contacts'?'联系人':listKey==='groups'?'群聊':'数据'}查询已完成
当前没有返回可展示的${listKey==='contacts'?'联系人':listKey==='groups'?'群聊':'记录'}。
`; + return `
${listKey==='contacts'?'联系人':listKey==='groups'?'群聊':'查询结果'}(${list.length})成功
${list.slice(0,20).map(item=>{const name=item.nickname||item.remark||item.name||item.user_id||item.wxid||'未命名';const sub=item.wxid||item.user_id||item.username||'';return `
${esc(String(name))}${esc(String(sub))}
`}).join('')}
`; + } + if(payload.ui_tree){ + const tree = String(payload.ui_tree).trim(); + return `
微信界面已读取成功
${tree && tree!=='null'?`
${esc(tree.slice(0,2000))}
`:'
已连接,当前页面没有可读的微信节点。
'}
`; + } + const simple = Object.entries(payload).filter(([key,value])=>!['raw','response','data'].includes(key) && ['string','number','boolean'].includes(typeof value)).slice(0,8); + if(simple.length) return `
操作已完成成功
${simple.map(([key,value])=>`
${esc(key)}${esc(String(value))}
`).join('')}
`; + return `
操作已完成成功
`; +} + + +function renderUnifiedControl(showFleet=false){ const allDevices = state.devices || []; + if (!state.aiConfig && !state.aiConfigLoading) setTimeout(loadAIConfig, 0); const did = state.currentDevice || (allDevices[0]?.device_id); - if (did && !state.currentDevice) state.currentDevice = did; + if (did && !state.currentDevice) { + state.currentDevice = did; + setTimeout(()=>loadDeviceDetail(did), 0); + } const current = allDevices.find(d=>d.device_id===did) || allDevices[0]; + const currentMap = wxDeviceMap(current); + const currentRealtime = (state.realtime?.devices || []).find(d => d.device_id === did) || {}; + const lastRealtimeEvent = (state.realtime?.recent_events || []).find(e => e.device_id === did || e.payload?.device_id === did); const keyword = (state.wxKeyword || '').toLowerCase(); const filter = state.wxStatusFilter || 'all'; const filtered = allDevices.filter(d => { const src = [d.device_id,d.model,d.brand,d.nickname,d.profile_nickname].filter(Boolean).join(' ').toLowerCase(); if (keyword && !src.includes(keyword)) return false; if (filter==='online' && d.status!=='online') return false; - if (filter==='adb' && d.status!=='adb' && d.adb_status!=='device') return false; + if (filter==='adb' && !deviceHasAdb(d)) return false; if (filter==='offline' && !['offline','unauthorized'].includes(d.status||d.adb_status||'offline')) return false; return true; }); @@ -840,8 +1297,10 @@ function renderUnifiedControl(){ const st = wxStatusTone(device); const active = device.device_id === did; const name = device.profile_nickname || device.nickname || device.model || device.device_id; - const wxId = device.wechat_id || device.wx_id || ''; + const map = wxDeviceMap(device); + const wxId = map.wxid || map.wechatId; const wxRunningNow = device.wechat_running || device.current_app === 'com.tencent.mm'; + const wxDisplay = wxId || device.wechat_version || (wxRunningNow ? '运行中' : '-'); const avatar = name ? name[0] : '\ud83d\udcf1'; return `
${esc(avatar)}
@@ -851,9 +1310,11 @@ function renderUnifiedControl(){ ${st.label}
${esc(device.device_id)}
+
IMEI: ${esc(device.imei || '待登记')}
- 微信: ${esc(wxId || (wxRunningNow ? '运行中' : '-'))} - ${device.totalFriend ? '好友: '+device.totalFriend+'' : ''} + 微信: ${esc(wxDisplay)} + 微信ID: ${esc(wxId || '待现场核对')} + 好友: ${esc(map.friendCount === '' ? '待现场核对' : String(map.friendCount))}
`; @@ -864,177 +1325,318 @@ function renderUnifiedControl(){ ).join(''); const deviceInfoCard = current ? ` -
-
\ud83d\udcf1
-
-
${esc(current.profile_nickname||current.nickname||current.model||current.device_id)}
-
${esc([current.brand,current.model].filter(Boolean).join(' ')||'')} · ${esc(current.device_id)}
+
+
📱
+
+
${esc(current.profile_nickname||current.nickname||current.model||current.device_id)}
+
${esc([current.brand,current.model].filter(Boolean).join(' ')||'Android')} · ${esc(current.device_id)}
-
- ${tone.label} -
健康 ${hp}%
+
+ ${tone.label} +
健康 ${hp}%
-
-
Android${esc(current.android_version||current.sdk_version||'-')}
-
当前APP${esc(current.current_app||(current.wechat_running?'微信':'-'))}
-
电量${current.battery_level>=0?current.battery_level+'%'+(current.battery_charging?' ⚡':''):'-'}
-
内存${current.memory_usage_pct>0?current.memory_usage_pct.toFixed(1)+'%':'-'}
+
+
系统${esc(current.android_version||current.sdk_version||'-')}
+
当前应用${esc(current.current_app||(current.wechat_running?'微信':'-'))}
+
电量${current.battery_level>=0?current.battery_level+'%'+(current.battery_charging?' ⚡':''):'-'}
+
连接${currentRealtime.online||current.status==='online'?'在线':'可控'}
- ${(current.connection_modes||[]).length ? '
'+(current.connection_modes||[]).map(m=>''+esc(m.name||m.id)+'').join('')+'
' : ''} - ` : '
请连接设备或点击左侧选择
'; +
+ 连接、账号与设备详情 +
+ ${(current.connection_modes||[]).length ? '
'+(current.connection_modes||[]).map(m=>''+esc(m.name||m.id)+'').join('')+'
' : ''} +
+
微信 wxid${esc(currentMap.wxid || '待核对')}
+
好友数${esc(currentMap.friendCount === '' ? '待核对' : String(currentMap.friendCount))}
+
最后活跃${esc(wxDisplayTime(currentMap.lastActive))}
+
IMEI${esc(current.imei || '待登记')}
+
内存${current.memory_usage_pct>0?current.memory_usage_pct.toFixed(1)+'%':'-'}
+
最近事件${esc(lastRealtimeEvent?.event_type || '暂无')}
+
+
+
+ ` : '
请选择设备
'; const deviceActions = ` -
-
\u26a1 设备操作
-
- ${[ - ['\ud83d\udcf8','截屏',"doAction('screenshot')"], - ['\ud83c\udfe0','主页',"doAction('home')"], - ['\u2b05\ufe0f','返回',"doAction('back')"], - ['\ud83c\udf33','UI树',"doAction('ui_tree')"], - ['\u2139\ufe0f','信息',"doAction('device_info')"], - ].map(([icon,label,action])=>``).join('')} +
+
📱 手机控制
常用操作放在这里,点击即可执行
${current?.wechat_running?'微信运行中':'设备在线'}
+
+ + + +
`; + const serviceSummary = Array.isArray(current?.service_summary) ? current.service_summary : []; + const serviceSummaryHtml = serviceSummary.length ? serviceSummary.map(service => `${esc(service.name)} · ${service.running?'运行中':'待配置'}`).join('') : '服务状态读取中'; + const deviceManagementCard = ` +
+ 🔗 设备操作与服务${esc(current?.status==='online'?'设备在线':'可控设备')} · 点击展开 +
+
常用设备操作
安装、重启、服务检测统一放在这里。
+
+ + + + +
+ +
${serviceSummaryHtml}
+
+
`; + + const runtimeConfigCard = current ? ` +
+
+
⚙ APK 运行参数
通过 SDK 热更新,手机无需重装
+ ${current.wechat_running?'● 微信正在运行':'○ 微信未运行'} +
+
+
+
+
+ +
+
` : ''; + + const appMeta = { + wechat:{icon:'\ud83d\udcac',name:'微信',desc:'基础操作 · 数据 · 账号'}, + soul:{icon:'\ud83d\udc9c',name:'Soul',desc:'打开 · 截图 · 设备操作'}, + xhs:{icon:'\ud83d\udcd5',name:'小红书',desc:'打开 · 截图 · 设备操作'}, + douyin:{icon:'\ud83c\udfb5',name:'抖音',desc:'打开 · 截图 · 设备操作'}, + xianyu:{icon:'\ud83d\udc1f',name:'闲鱼',desc:'打开 · 截图 · 设备操作'}, + }; + const selectedApp = appMeta[appTab] || appMeta.wechat; + const controlButton = (icon,label,action) => ``; const appSelector = `
-
-
\ud83d\udcf2 应用控制
+
+
📱 应用控制
先进入一个应用,功能按需展开。
+ ${hasDevice ? '已绑定设备' : '请选择可控设备'}
-
- ${[['wechat','\ud83d\udcac','微信'],['soul','\ud83d\udc9c','Soul'],['xhs','\ud83d\udcd5','小红书'],['douyin','\ud83c\udfb5','抖音'],['xianyu','\ud83d\udc1f','闲鱼']].map(([id,icon,name])=> - `` - ).join('')} +
+
+ ${selectedApp.icon} + 进入${selectedApp.name}控制${appTab==='wechat'?'常用操作、资料查询、账号状态都在这里。':'设备级操作独立管理。'} + +
+
+ ${Object.entries(appMeta).filter(([id])=>id!==appTab).map(([id,meta])=>` + `).join('')} +
-
+
+
+
${selectedApp.icon} ${selectedApp.name}
${appTab==='wechat'?'常用功能优先,更多功能点开查看。':'设备操作单独管理。'}
+ ${appTab==='wechat'?(current?.wechat_running?'微信运行中':'微信待启动'):'设备操作'} +
${appTab==='wechat' ? ` -
- ${[ - ['\ud83d\udcf2','开微信',"doAction('open_wechat')"], - ['\ud83d\udc64','资料',"wxQuickRun('get_profile')"], - ['\ud83d\udc65','联系人',"wxQuickRun('get_contacts',{limit:100})"], - ['\ud83c\udff7\ufe0f','标签',"wxQuickRun('get_tags')"], - ['\u7fa4','群列表',"wxQuickRun('get_groups',{limit:100})"], - ['\ud83d\udd14','未读',"wxQuickRun('get_unread_messages')"], - ['\ud83d\udd0d','搜索',"wxQuickRun('wechat_search',{keyword:prompt('\u641c\u7d22\u5173\u952e\u8bcd')||''})"], - ['\ud83d\udcf8','截屏',"doAction('screenshot')"], - ].map(([icon,label,action])=>``).join('')} -
+
⚡ 基础操作
+ ${controlButton('📱','打开微信',"doAction('open_wechat')")} + ${controlButton('📸','截屏',"doAction('screenshot')")} + ${controlButton('🏠','回到主页',"doAction('home')")} + ${controlButton('↩️','返回上级',"doAction('back')")} +
+
👤 微信数据
+ ${controlButton('👤','个人资料',"wxQuickRun('get_profile')")} + ${controlButton('👥','联系人',"wxQuickRun('get_contacts',{limit:500})")} + ${controlButton('🏷️','标签',"wxQuickRun('get_tags')")} + ${controlButton('群','群列表',"wxQuickRun('get_groups',{limit:500})")} + ${controlButton('🔔','未读消息',"wxQuickRun('get_unread_messages')")} + ${controlButton('🔎','搜索微信',"wxQuickRun('wechat_search',{keyword:prompt('搜索关键词')||''})")} +
+
✨ 微信常用功能
+ ${controlButton('📸','发朋友圈',"wxCatalogRun('moments','post_moments')")} + ${controlButton('🧧','发红包',"wxCatalogRun('payment','send_red_packet')")} + ${controlButton('🧧','收红包',"wxCatalogRun('payment','receive_red_packet')")} + ${controlButton('🔎','查询好友',"wxCatalogRun('friend','get_friend_info')")} + ${controlButton('🏷️','按标签查人',"wxCatalogRun('tag','get_users_by_tag')")} +
+
🧰 微信功能目录
按模块查看当前已登记动作,带参数的功能点击后填写参数。
${renderWxActionCatalog()}
+
🧪 测试与诊断
+ ${controlButton('🧩','Hook 状态',"wxQuickRun('get_hook_status')")} + ${controlButton('🏷️','微信版本',"wxQuickRun('get_wechat_version')")} + ${controlButton('🔐','账号状态',"wxQuickRun('check_account_status')")} + ${controlButton('🖼️','读取 UI 树',"doAction('ui_tree')")} +
测试结果统一显示在右侧“执行结果”和“操作日志”,不与日常功能混排。
` : ` -
- ${[ - [appTab==='soul'?'\ud83d\udc9c':appTab==='xhs'?'\ud83d\udcd5':appTab==='douyin'?'\ud83c\udfb5':'\ud83d\udc1f','打开',"doAction('open_"+appTab+"')"], - ['\ud83d\udcf8','截屏',"doAction('screenshot')"], - ['\ud83c\udfe0','主页',"doAction('home')"], - ['\u2b05\ufe0f','返回',"doAction('back')"], - ].map(([icon,label,action])=>``).join('')} -
-
- \ud83d\udea7 ${{soul:'Soul',xhs:'\u5c0f\u7ea2\u4e66',douyin:'\u6296\u97f3',xianyu:'\u95f2\u9c7c'}[appTab]||appTab} \u81ea\u52a8\u5316\u6b63\u5728\u5f00\u53d1\u4e2d\uff0c\u5f53\u524d\u53ef\u901a\u8fc7\u8bbe\u5907\u64cd\u4f5c\u624b\u52a8\u63a7\u5236 -
+
⚡ 基础操作
+ ${controlButton(selectedApp.icon,'打开'+selectedApp.name,`doAction('open_${appTab}')`)} + ${controlButton('📸','截屏',"doAction('screenshot')")} + ${controlButton('🏠','回到主页',"doAction('home')")} + ${controlButton('↩️','返回上级',"doAction('back')")} +
+
${selectedApp.name} 的业务动作会继续放在本应用面板内,避免和微信、设备、测试功能混在一起。
`}
`; const msgSection = appTab==='wechat' ? ` -
-
\ud83d\udcac \u5feb\u6377\u6d88\u606f
-
- +
💬 微信消息操作 +
+
+
+
+ + + +
-
- -
-
- - - -
-
` : ''; + ` : ''; const aiSection = ` -
-
\ud83e\udd16 AI \u6307\u4ee4
-
- ${['pattern','ai','ai-only'].map(m=>``).join('')} +
🤖 AI 指令 +
+
🔧 AI 接口 ${state.aiConfig?.provider || '龙虾U盘'} +
+
+ + +
+
+
Agent、AI指令和设备自动化统一使用这里的接口。
+
+
+
${['pattern','ai','ai-only'].map(m=>``).join('')}
+
-
- - +
`; + + const compactAppSelector = ` +
+ 📱 应用控制${selectedApp.name} · 点击切换 +
+
+ ${Object.entries(appMeta).map(([id,meta])=>``).join('')} +
+ ${appTab==='wechat' ? ` +
+ ${controlButton('👤','个人资料',"wxQuickRun('get_profile')")} + ${controlButton('👥','联系人',"wxQuickRun('get_contacts',{limit:500})")} + ${controlButton('🔎','搜索好友',"wxCatalogRun('friend','search_contact')")} + ${controlButton('📸','发朋友圈',"wxCatalogRun('moments','post_moments')")} +
+
更多微信功能
+
+ ${controlButton('🏷️','标签',"wxQuickRun('get_tags')")} + ${controlButton('群','群列表',"wxQuickRun('get_groups',{limit:500})")} + ${controlButton('🧧','发红包',"wxCatalogRun('payment','send_red_packet')")} + ${controlButton('🔐','账号状态',"wxQuickRun('check_account_status')")} +
+ ${msgSection}${aiSection} +
+ ` : ` +
+ ${controlButton(selectedApp.icon,'打开'+selectedApp.name,`doAction('open_${appTab}')`)} + ${controlButton('↻','重连画面',"restartLiveScreen()")} + ${controlButton('🏠','主页',"doAction('home')")} + ${controlButton('↩️','返回',"doAction('back')")} +
+ `}
-
`; + `; + + if (!showFleet && current) { + window.setTimeout(() => { + if (state.current === 'devices' && state.deviceView === 'detail' && state.currentDevice === current.device_id) { + startRemoteScreen(current.device_id); + } + }, 60); + } return ` - ${unauth.length ? ` -
- \u26a0\ufe0f -
${unauth.length} \u53f0\u8bbe\u5907\u5f85 USB \u6388\u6743
\u5728\u624b\u673a\u5c4f\u5e55\u70b9\u51fb\u300c\u5141\u8bb8 USB \u8c03\u8bd5\u300d\u2192 \u52fe\u9009\u300c\u59cb\u7ec8\u5141\u8bb8\u300d\u2192\u300c\u786e\u5b9a\u300d
-
` : ''} -
-
-
- \u8bbe\u5907 - ${filtered.length} / ${allDevices.length} + ${showFleet ? `
+
+
我的工作手机点击手机后,下方所有操作切换到该设备
+
+ + ${filterBtns}
- -
${filterBtns}
-
${deviceCards}
-
-
${deviceInfoCard}${deviceActions}
-
${appSelector}${msgSection}${aiSection}
+
${deviceCards}
+
` : ''} +
+
+
${deviceInfoCard}
+ ${deviceManagementCard} +
⚙️ 通用设置保活、检测周期、IMEI
${runtimeConfigCard || '
请选择设备
'}
+ ${compactAppSelector}
-
-
-
-
\ud83d\udcf8 \u8bbe\u5907\u622a\u5c4f
-
- - - +
+
+
+
📱 实时设备控制
+ 正在连接 +
+
+
+ + 兼容设备画面 +
+
直接点击、滑动上方手机画面进行操作
+
+ + +
-
\u70b9\u51fb\u201c\u622a\u5c4f\u201d\u6216\u5f00\u542f\u81ea\u52a8\u5237\u65b0
-
-
-
\u6267\u884c\u7edf\u8ba1
-
-
${_wxStats.total}
\u603b\u64cd\u4f5c
-
${_wxStats.total?Math.round(_wxStats.success/_wxStats.total*100)+'%':'-'}
\u6210\u529f\u7387
+
📊 执行记录 统计、日志、通道
+
+
${_wxStats.total}
总操作
+
${_wxStats.total?Math.round(_wxStats.success/_wxStats.total*100)+'%':'-'}
成功率
-
-
-
\u6267\u884c\u7ed3\u679c
-
\u7b49\u5f85\u64cd\u4f5c...
-
-
-
\u64cd\u4f5c\u65e5\u5fd7
-
${executionLogHtml || '
\u6682\u65e0\u8bb0\u5f55
'}
-
-
-
\u901a\u9053\u6027\u80fd
-
-
Hook50-200ms
-
u2 UI2-15s
-
ADB1-10s
-
-
+
操作日志
${logs || executionLogHtml || '
暂无记录
'}
+
通道:Hook 50-200ms · u2 UI 2-15s · ADB 1-10s
+
- ${renderBindSection()} `; } +async function loadAIConfig(){ + if (state.aiConfigLoading) return; + state.aiConfigLoading = true; + try { + const res = await apiGet('/api/v3/ai/config'); + state.aiConfig = res.data || {}; + } catch (error) { + log(`AI接口配置读取失败:${error.message}`, 'error'); + } finally { + state.aiConfigLoading = false; + if (state.current === 'devices') renderCurrentView(false); + } +} + +async function saveAIConfig(){ + const baseUrl = wxReadInput('ai-config-base'); + const model = wxReadInput('ai-config-model'); + const apiKey = wxReadInput('ai-config-key'); + const tip = document.getElementById('ai-config-tip'); + if (!baseUrl || !model) { + if (tip) tip.textContent = '请填写接口地址和模型名称'; + return; + } + try { + const payload = {provider:'longxia', base_url:baseUrl, model}; + if (apiKey) payload.api_key = apiKey; + const res = await apiPost('/api/v3/ai/config', payload); + state.aiConfig = res.data || {...state.aiConfig, base_url:baseUrl, model}; + if (tip) tip.textContent = '已保存,Agent下次执行立即使用'; + log('统一 AI 接口配置已保存', 'info'); + } catch (error) { + if (tip) tip.textContent = `保存失败:${error.message}`; + log(`AI接口保存失败:${error.message}`, 'error'); + } +} + const WX_ACTION_ROUTES = { send_message:{m:'POST',p:'/message/send'},get_messages:{m:'POST',p:'/message/list'}, + get_unread_messages:{m:'POST',p:'/message/list'},get_recent_messages:{m:'POST',p:'/message/list'}, batch_send_message:{m:'POST',p:'/message/batch-send'},forward_message:{m:'POST',p:'/message/forward'}, recall_message:{m:'POST',p:'/message/recall'},send_card:{m:'POST',p:'/message/send-card'}, add_friend:{m:'POST',p:'/friend/add'},accept_friend:{m:'POST',p:'/friend/accept'}, @@ -1061,32 +1663,32 @@ const WX_ACTION_ROUTES = { unblock_appeal:{m:'POST',p:'/account/unblock'},unblock_with_sms:{m:'POST',p:'/account/unblock'}, change_password:{m:'POST',p:'/account/change-password'},check_restrictions:{m:'GET',p:'/account/status'}, appeal_restriction:{m:'POST',p:'/account/unblock'}, - send_red_packet:{m:'POST',p:'/message/send'},transfer:{m:'POST',p:'/message/send'}, - show_payment_code:{m:'GET',p:'/profile/get'},receive_payment:{m:'GET',p:'/profile/get'}, - view_wallet:{m:'GET',p:'/profile/get'},view_transactions:{m:'GET',p:'/profile/get'}, - receive_red_packet:{m:'POST',p:'/message/send'}, + send_red_packet:{m:'POST',p:'/payment/red-packet'},transfer:{m:'POST',p:'/payment/transfer'}, + show_payment_code:{m:'GET',p:'/payment/code'},view_wallet:{m:'GET',p:'/payment/wallet'}, + view_transactions:{m:'GET',p:'/payment/transactions'},receive_red_packet:{m:'POST',p:'/hook/execute',hook:true}, + receive_payment:{m:'POST',p:'/hook/execute',hook:true}, set_chat_top:{m:'POST',p:'/chat/set-top'},set_mute_chat:{m:'POST',p:'/chat/set-mute'}, clear_chat_history:{m:'POST',p:'/chat/clear-history'}, add_to_favorites:{m:'POST',p:'/favorites/add'},get_favorites:{m:'GET',p:'/favorites/list'}, - open_mini_program:{m:'POST',p:'/message/send'},follow_official_account:{m:'POST',p:'/friend/add'}, - open_video_channel:{m:'GET',p:'/profile/get'},like_video:{m:'POST',p:'/moments/like'}, - comment_video:{m:'POST',p:'/moments/comment'},follow_video_creator:{m:'POST',p:'/friend/add'}, - share_video:{m:'POST',p:'/message/forward'},get_video_list:{m:'GET',p:'/profile/get'}, - scan_qr_code:{m:'POST',p:'/message/send'},scan_add_friend:{m:'POST',p:'/friend/add'}, - show_my_qr:{m:'GET',p:'/profile/get'},extract_qr_from_image:{m:'POST',p:'/message/send'}, - voice_call:{m:'POST',p:'/message/send'},video_call:{m:'POST',p:'/message/send'}, - mass_send:{m:'POST',p:'/message/batch-send'},wechat_search:{m:'GET',p:'/search/wechat'}, - top_stories:{m:'GET',p:'/profile/get'},get_steps:{m:'GET',p:'/profile/get'}, - like_steps:{m:'POST',p:'/moments/like'},send_location:{m:'POST',p:'/message/send'}, - share_real_time_location:{m:'POST',p:'/message/send'},send_emoji:{m:'POST',p:'/message/send'}, - get_sticker_list:{m:'GET',p:'/profile/get'},send_voice_message:{m:'POST',p:'/message/send'}, - send_file_from_chat:{m:'POST',p:'/message/send'},toggle_do_not_disturb:{m:'POST',p:'/chat/set-mute'}, - clear_cache:{m:'GET',p:'/profile/get'},logout:{m:'POST',p:'/account/unblock'}, - switch_account:{m:'POST',p:'/account/unblock'}, + open_mini_program:{m:'POST',p:'/miniprogram/open'},follow_official_account:{m:'POST',p:'/hook/execute',hook:true}, + open_video_channel:{m:'GET',p:'/video-channel/list'},get_video_list:{m:'GET',p:'/video-channel/list'}, + like_video:{m:'POST',p:'/hook/execute',hook:true},comment_video:{m:'POST',p:'/hook/execute',hook:true}, + follow_video_creator:{m:'POST',p:'/hook/execute',hook:true},share_video:{m:'POST',p:'/hook/execute',hook:true}, + scan_qr_code:{m:'POST',p:'/hook/execute',hook:true},scan_add_friend:{m:'POST',p:'/hook/execute',hook:true}, + show_my_qr:{m:'GET',p:'/scan/my-qr'},extract_qr_from_image:{m:'POST',p:'/hook/execute',hook:true}, + voice_call:{m:'POST',p:'/hook/execute',hook:true},video_call:{m:'POST',p:'/hook/execute',hook:true}, + mass_send:{m:'POST',p:'/mass-send'},wechat_search:{m:'GET',p:'/search/wechat'}, + top_stories:{m:'GET',p:'/discover/top-stories'},get_steps:{m:'GET',p:'/wechat-sport/steps'}, + like_steps:{m:'POST',p:'/hook/execute',hook:true},send_location:{m:'POST',p:'/location/send'}, + share_real_time_location:{m:'POST',p:'/hook/execute',hook:true},send_emoji:{m:'POST',p:'/emoji/send'}, + get_sticker_list:{m:'GET',p:'/emoji/stickers'},send_voice_message:{m:'POST',p:'/message/voice'}, + send_file_from_chat:{m:'POST',p:'/file/send'},toggle_do_not_disturb:{m:'POST',p:'/settings/do-not-disturb'}, + clear_cache:{m:'POST',p:'/settings/clear-cache'},logout:{m:'POST',p:'/hook/execute',hook:true}, + switch_account:{m:'POST',p:'/hook/execute',hook:true}, }; async function wxCall(action, params, actMeta){ - const route = WX_ACTION_ROUTES[action] || {m:'POST',p:'/message/send'}; + const route = WX_ACTION_ROUTES[action] || {m:'POST',p:'/hook/execute',hook:true}; const act = actMeta || Object.values(WX_MODULES).flatMap(item => item.actions).find(item => item.a===action) || {n: action}; const finalParams = {platform:'wechat',device_id: state.currentDevice || '', ...(params || {})}; if(finalParams.to_ids && typeof finalParams.to_ids === 'string') finalParams.to_ids = finalParams.to_ids.split(',').map(s=>s.trim()).filter(Boolean); @@ -1102,7 +1704,19 @@ async function wxCall(action, params, actMeta){ const start = Date.now(); try{ let data; - if(route.m==='GET'){ + if(route.hook){ + const hookParams = {...finalParams}; + delete hookParams.device_id; + delete hookParams.platform; + delete hookParams.action; + data = await apiPost(`/api/v3${route.p}`, { + device_id: finalParams.device_id, + platform: finalParams.platform, + action, + params: hookParams, + hook_only: false + }); + } else if(route.m==='GET'){ const qs=Object.entries(finalParams).filter(([k,v])=>v!==undefined && v!==null && v!=='').map(([k,v])=>`${k}=${encodeURIComponent(Array.isArray(v) ? v.join(',') : v)}`).join('&'); data = await apiGet(`/api/v3${route.p}?${qs}`); } else { @@ -1110,28 +1724,31 @@ async function wxCall(action, params, actMeta){ } const ms = Date.now() - start; const ch = data.channel_used || data.data?.channel || 'sdk_control'; - const ok = (data.code===200||data.code===undefined) && data.data?.success!==false; + const nestedCode = Number(data.data?.code); + const ok = (data.code===200||data.code===undefined) && data.success!==false && data.data?.success!==false && !(nestedCode>=400); _wxStats.total++; if(ok)_wxStats.success++; _wxStats.totalTime+=ms; _wxLogs.push({ts:new Date().toLocaleTimeString(),action:act.n,ok,ch:String(ch),ms}); chainEls.forEach(id=>{const e=document.getElementById(id);if(e)e.textContent=(ok?'🟢 ':'🔴 ')+e.textContent.substring(2)}); - const res=document.getElementById('wx-result');if(res)res.textContent=`// ${ch} | ${ms}ms\n`+JSON.stringify(data,null,2); + const res=document.getElementById('wx-result');if(res)res.innerHTML=ok?wxResultHtml(data):`
${esc(wxResponseMessage(data))}
`; + const resultChannel=document.getElementById('wx-result-channel');if(resultChannel)resultChannel.textContent=`${ch} · ${ms}ms`; const total=document.getElementById('wx-total');if(total)total.textContent=_wxStats.total; const rate=document.getElementById('wx-rate');if(rate)rate.textContent=Math.round(_wxStats.success/_wxStats.total*100)+'%'; const avg=document.getElementById('wx-avg');if(avg)avg.textContent=Math.round(_wxStats.totalTime/_wxStats.total)+'ms'; const chEl=document.getElementById('wx-ch');if(chEl)chEl.textContent=ch; - const logBox=document.getElementById('wx-logs'); + const logBox=document.getElementById('device-action-logs'); if(logBox){ const entry=`
${new Date().toLocaleTimeString()} ${esc(act.n)} ${ok?'✓':'✗'} ${ch} ${ms}ms
`; logBox.innerHTML=entry+logBox.innerHTML; } - log(`微信 ${act.n}: ${ok?'成功':'失败'} (${ch}, ${ms}ms)`, ok?'info':'error'); + log(`微信 ${act.n}: ${ok?'成功':'失败'} (${ch}, ${ms}ms)${ok?'':' — '+wxResponseMessage(data)}`, ok?'info':'error'); }catch(e){ const ms=Date.now()-start; _wxStats.total++; _wxStats.totalTime+=ms; _wxLogs.push({ts:new Date().toLocaleTimeString(),action:act.n,ok:false,ch:'error',ms}); chainEls.forEach(id=>{const e2=document.getElementById(id);if(e2)e2.textContent='🔴 '+e2.textContent.substring(2)}); - const res=document.getElementById('wx-result');if(res)res.textContent='// 错误\n'+e.message; + const res=document.getElementById('wx-result');if(res)res.innerHTML=`
${esc(e.message || '请求失败')}
`; + const resultChannel=document.getElementById('wx-result-channel');if(resultChannel)resultChannel.textContent='请求失败'; log(`微信 ${act.n}: 失败 — ${e.message}`,'error'); } } @@ -1164,41 +1781,207 @@ async function wxQuickRun(action, fieldMap){ return wxCall(action, params); } +function wxCatalogRun(moduleId, action){ + const module = WX_MODULES[moduleId]; + const act = module?.actions.find(item=>item.a===action); + if(!act) return; + const params = {}; + for(const field of (act.f || [])){ + const value = prompt(`${act.n} · ${field.l}`, field.v || ''); + if(value === null) return; + params[field.k] = value; + } + return wxCall(action, params, act); +} + +function renderWxActionCatalog(){ + return Object.entries(WX_MODULES).map(([moduleId,module])=>` +
+ 📂 ${module.title}${module.actions.length} 项 +
+
+ ${module.actions.map(act=>``).join('')} +
+
+
`).join(''); +} + +function wxResponseMessage(data){ + const payload = data?.data; + return data?.message || data?.detail || payload?.message || payload?.error || payload?.detail || '请求失败'; +} + +function wxUnavailableGuide(data){ + const detail = wxResponseMessage(data); + const is503 = Number(data?.code)===503 || Number(data?.status)===503 || /503/.test(String(detail)); + if(!is503) return `// 错误\n${detail}`; + const wsOnline = state.devices?.find(d=>d.device_id===state.currentDevice)?.status === 'online'; + return `// 微信通道暂不可用\n${detail}\n\n处理建议:${wsOnline?'检查手机端 Frida/Hook 状态':'启动手机端 Agent,保持 WS 连接后再执行微信数据操作'}\n当前设备:${state.currentDevice || '-'}\n说明:ADB 连接只负责设备级操作,好友、资料、朋友圈等数据需要 WS + 微信通道。`; +} + /* ═══ 2. Devices Panel (设备控制 / 接口文档 / 系统说明) ═══ */ function renderDevicePanel(){ - const tab = state.deviceTab || 'control'; const devices = state.devices || []; const wsCount = devices.filter(d=>d.status==='online').length; - const adbCount = devices.filter(d=>d.status==='adb'||d.adb_status==='device').length; - const onlineCount = wsCount + adbCount; + const adbCount = devices.filter(deviceHasAdb).length; + const onlineCount = devices.filter(d=> + d.status==='online' || + d.status==='adb' || + d.adb_status==='device' || + d.controllable===true + ).length; const unauth = devices.filter(d=>d.status==='unauthorized'||d.adb_status==='unauthorized'); + if (state.deviceView === 'detail' && state.currentDevice) { + const current = devices.find(d => d.device_id === state.currentDevice); + const title = current?.profile_nickname || current?.nickname || current?.model || state.currentDevice; + return ` +
+
+
+ +

📱 ${esc(title)}

设备控制详情 · 所有操作仅作用于当前手机

+
+
+ + ${current?.status === 'offline' ? `` : ''} +
+
+
${renderUnifiedControl(false)}`; + } + const cards = devices.length ? devices.map(device => { + const tone = wxStatusTone(device); + const name = device.profile_nickname || device.nickname || device.model || device.device_id; + const wxRunning = device.wechat_running || device.current_app === 'com.tencent.mm'; + const online = device.status === 'online' || device.status === 'adb' || device.adb_status === 'device' || device.controllable === true; + return ``; + }).join('') : `
${renderConnectGuide()}
`; const header = `
-

📱 设备管理

-

${devices.length} 台设备 · ${onlineCount} 在线 · 统一管控中心

+

📱 手机设备

+

${devices.length} 台手机 · ${onlineCount} 在线 · 点击手机进入二级控制页面

WS ${wsCount} ADB ${adbCount} ${unauth.length?`⚠ ${unauth.length} 待授权`:''} - + +
-
-
🎮 设备控制
-
📡 接口文档 (150+)
-
📊 系统说明
+
`; + return header + `
${cards}
`; +} + +function openDeviceDetail(deviceId){ + state.current = 'devices'; + state.deviceView = 'detail'; + state.currentDevice = deviceId; + renderSidebar(); + renderCurrentView(); + window.setTimeout(() => { + if (state.current === 'devices' && state.deviceView === 'detail' && state.currentDevice === deviceId) { + loadDeviceDetail(deviceId); + } + }, 3000); + log(`进入手机控制:${deviceId}`, 'info'); +} + +function loadIntegrationManifest(){ + if (state.integrationManifest || state.integrationManifestLoading) return; + state.integrationManifestLoading = true; + state.integrationManifestError = ''; + apiGet('/api/v3/integration/manifest') + .then(res => { + state.integrationManifest = res.data || res; + state.integrationManifestLoading = false; + renderCurrentView(false); + }) + .catch(err => { + state.integrationManifestLoading = false; + state.integrationManifestError = err.message || String(err); + renderCurrentView(false); + }); +} + +function renderApiDocsDynamicTab(){ + loadIntegrationManifest(); + const manifest = state.integrationManifest; + if (!manifest) { + return ` +
+
+

📡 全部 API 接口文档

正在从 /api/v3/integration/manifest 实时加载接口清单...

+ +
+ ${state.integrationManifestError ? `
加载失败:${esc(state.integrationManifestError)}
` : `
接口中心加载中,稍等 1 秒自动刷新。
`} +
`; + } + + const modules = manifest.modules || []; + const totalEndpoints = manifest.total_endpoints || modules.reduce((sum, mod) => sum + ((mod.endpoints || []).length), 0); + const consumers = manifest.consumers || {}; + const expandedCat = state._apiDocExpanded || (modules[0] && (modules[0].module || modules[0].id)) || ''; + const copyPath = p => String(p || '').replace(/'/g, "\\'"); + const methodTone = m => m === 'GET' ? '37,99,235' : m === 'POST' ? '22,163,74' : m === 'DELETE' ? '220,38,38' : '245,158,11'; + const mkEndpoint = ep => { + const m = ep.method || 'GET'; + const p = ep.path || ''; + const summary = ep.summary || ep.name || '接口'; + return `
+ ${esc(m)} + ${esc(p)} + ${esc(summary)} +
`; + }; + + return ` +
+
+
+

📡 全部 API 接口文档

+

${totalEndpoints} 个实时端点 · ${modules.length} 个模块 · 来源 /api/v3/integration/manifest · 点击路径可复制

+
+
+ 📄 Swagger + ReDoc + +
+
+
+

总端点

${totalEndpoints}

+

模块

${modules.length}

+

存客宝

${esc(consumers.cunkebao || '可筛选')}

+

对接原则

统一走 /api/v3/integration/* 发现能力,业务调用再进具体接口。

+
+
+ ${modules.map(mod => { + const id = mod.module || mod.id || mod.label || 'module'; + const isOpen = expandedCat === id; + const endpoints = mod.endpoints || []; + const consumerText = (mod.consumers || []).join(' / '); + return `
+
+ 📦 +
${esc(mod.label || id)}${endpoints.length} 个端点 · ${esc(consumerText || 'common')}
+ ${esc(id)} + +
+ ${isOpen ? '
'+endpoints.map(mkEndpoint).join('')+'
' : ''} +
`; + }).join('')}
`; - let content = ''; - if (tab === 'control') content = renderUnifiedControl(); - else if (tab === 'api-docs') content = renderApiDocsTab(); - else if (tab === 'system') content = renderSystemFlowTab(); - return header + content; } function renderApiDocsTab(){ @@ -1428,18 +2211,28 @@ function renderSystemFlowTab(){ } function renderBindSection(){ - const defaultServer = `ws://${location.hostname}:${location.port || 8899}/ws/device`; + const wsProtocol = location.protocol === 'https:' ? 'wss' : 'ws'; + const privateIp = (state.serverInfo?.ips || []).find(ip => + /^10\./.test(ip) || /^192\.168\./.test(ip) || /^172\.(1[6-9]|2\d|3[01])\./.test(ip) + ); + const bindHost = privateIp || location.hostname; + const wsPort = state.serverInfo?.port + ? `:${state.serverInfo.port}` + : (location.port ? `:${location.port}` : (location.protocol === 'https:' ? '' : ':8899')); + const defaultServer = state.bindingServer || `${wsProtocol}://${bindHost}${wsPort}/ws/device`; return ` -
-

📲 设备绑定

扫码绑定或批量部署新设备

+
+

📲 扫码绑定手机

先扫描服务器,再用手机扫码;二维码只绑定当前服务器

首页唯一入口

绑定配置

- - + + + +
授权关系:网页用管理员会话,存客宝用 API Key,手机用配对令牌。三者分开配置。
@@ -1456,23 +2249,11 @@ function renderConnectGuide(){
📡
暂无设备连接
-
- 方式 1:USB 连接
- ① 手机开启「开发者选项」→ 打开「USB 调试」
- ② USB 线连接电脑 → 手机弹窗点「允许」
- ③ 点击上方「📡 扫描 ADB」 -
-
- 方式 2:WiFi ADB 连接
- ① 先 USB 连接一次 → 执行 adb tcpip 5555
- ② 拔掉 USB → adb connect 手机IP:5555
- ③ 之后手机只需同一 WiFi 即可控制 -
- 方式 3:Agent WebSocket
- ① 在手机 Termux 中安装 Python Agent
- ② 启动 Agent → 自动通过 WiFi 连接本服务器
- ③ 无需 USB,支持远程控制 + 扫码无线连接
+ ① 打开手机端工作手机 APK
+ ② 扫描中台生成的设备绑定二维码
+ ③ Agent WebSocket 自动连接并接受远程控制
@@ -1497,13 +2278,20 @@ function renderEnginePanel(){

🧠 智能引擎

AI Brain 自主决策 · Hook 模块控制 · 事件流监控

-
-
🧠 AI Brain
-
🪝 Hook
-
📊 Hook 数据
-
📡 Events (${events.length})
-
🔌 连接监控
-
💓 心跳
+
+
AI 中枢
+
🧠 AI Brain
+
+
HOOK 能力
+
🪝 模块
+
📊 数据
+
+
运行与安全
+
📡 事件 ${events.length}
+
🔌 连接
+
💓 心跳
+
🛡️ 防封
+
`; @@ -1540,9 +2328,9 @@ function renderEnginePanel(){

执行通道

4 层通道自动降级 · Hook → u2 → ADB → AI Agent

- ${card('🔗 Frida Hook P1','直接操作 APP 内部函数
50-200ms · 隐蔽性高
需 Root + Frida')} + ${card('🧩 微信注入能力 P1','微信深度数据与接口能力
服务状态在设备高级连接中统一查看
需 Root + 注入引擎')} ${card('🖱️ u2 自动化 P2','UI 模拟操作
2-15s · 通用性强
无需 Root')} - ${card('⌨️ ADB Shell P3','ADB 命令控制
1-10s · 基础操作
截图/点击/输入')} + ${card('⌨️ 设备底座 P3','ADB 作为安装与运维兜底
截图、点击、输入与系统操作
无线主控时保持可选')} ${card('🧠 AI Agent P4','LLM 意图解析
自然语言 → 步骤执行
依赖通道 1/2/3')}
@@ -1594,7 +2382,7 @@ function renderEnginePanel(){ {cat:'微信 · 消息', items:[ {label:'发消息', s:'wechat', a:'send_message', p:'{"to_id":"联系人名","content":"你好,在吗?"}'}, {label:'群发消息', s:'wechat', a:'batch_send_message', p:'{"user_ids":["张三","李四"],"content":"通知:明天下午3点开会"}'}, - {label:'获取消息', s:'wechat', a:'get_messages', p:'{"limit":20}'}, + {label:'获取消息', s:'wechat', a:'get_messages', p:'{"limit":3000}'}, {label:'转发消息', s:'wechat', a:'forward_message', p:'{"to_id":"李四","content":"转发内容"}'}, {label:'发名片', s:'wechat', a:'send_card', p:'{"to_id":"张三","card_wxid":"wxid_xxx"}'}, {label:'发表情', s:'wechat', a:'send_emoji', p:'{"user_id":"张三","emoji_name":"微笑"}'}, @@ -1606,7 +2394,7 @@ function renderEnginePanel(){ {label:'添加好友', s:'wechat', a:'add_friend', p:'{"user_id":"微信号","message":"你好,我是XXX"}'}, {label:'通过好友', s:'wechat', a:'accept_friend', p:'{}'}, {label:'批量加好友', s:'wechat', a:'batch_add_friend', p:'{"user_ids":["wxid1","wxid2"],"message":"你好"}'}, - {label:'获取联系人', s:'wechat', a:'get_contacts', p:'{"limit":100}'}, + {label:'获取联系人', s:'wechat', a:'get_contacts', p:'{"limit":10000}'}, {label:'搜索联系人', s:'wechat', a:'search_contact', p:'{"keyword":"张三"}'}, {label:'好友详情', s:'wechat', a:'get_friend_info', p:'{"user_id":"wxid_xxx"}'}, {label:'设置备注', s:'wechat', a:'set_remark', p:'{"user_id":"wxid_xxx","remark":"客户-张三"}'}, @@ -1617,7 +2405,7 @@ function renderEnginePanel(){ {label:'邀请入群', s:'wechat', a:'invite_to_group', p:'{"group_id":"群名","member_ids":["王五"]}'}, {label:'群发消息', s:'wechat', a:'send_group_message', p:'{"group_id":"群名","content":"大家好"}'}, {label:'设置群公告', s:'wechat', a:'set_group_notice', p:'{"group_id":"群名","notice":"本周五聚餐"}'}, - {label:'获取群列表', s:'wechat', a:'get_groups', p:'{"limit":100}'}, + {label:'获取群列表', s:'wechat', a:'get_groups', p:'{"limit":500}'}, {label:'群成员', s:'wechat', a:'get_group_members', p:'{"group_id":"群名"}'}, {label:'群欢迎语', s:'wechat', a:'set_group_welcome', p:'{"group_id":"群名","welcome_text":"欢迎加入!"}'}, {label:'踢人', s:'wechat', a:'remove_from_group', p:'{"group_id":"群名","member_ids":["xxx"]}'}, @@ -1845,6 +2633,10 @@ function renderEnginePanel(){ const labels = hd.labels?.labels || []; const messages = hd.messages?.messages || []; const deviceInfo = hd.device_info?.device || {}; + const contactTotal = hd.contacts?.total_count || hd.contacts?._diag?.filtered_total || hd.contacts?.count || contacts.length || 0; + const contactLoaded = contacts.length || 0; + const messageTotal = hd.messages?.total_count || hd.messages?.count || messages.length || 0; + const messageLoaded = messages.length || 0; content = `
@@ -1866,10 +2658,10 @@ function renderEnginePanel(){
数据统计
-
${contacts.length || hd.contacts?.count || 0}
联系人
+
${contactTotal}
联系人${contactLoaded && contactLoaded !== contactTotal ? ` · 已载 ${contactLoaded}` : ''}
${groups.length || hd.groups?.count || 0}
群聊
${labels.length || hd.labels?.count || 0}
标签
-
${messages.length || 0}
近期消息
+
${messageTotal}
近期消息${messageLoaded && messageLoaded !== messageTotal ? ` · 已载 ${messageLoaded}` : ''}
@@ -1886,7 +2678,7 @@ function renderEnginePanel(){
-

👥 联系人 (${contacts.length})

+

👥 联系人 (${contactTotal}${contactLoaded && contactLoaded !== contactTotal ? ` / 已载 ${contactLoaded}` : ''})

@@ -2014,6 +2806,85 @@ function renderEnginePanel(){ }).join('')}
` : ''}

健康事件

${hbEvents.length ? hbEvents.slice(0, 30).map(ev => { const lvlColor = ev.level === 'CRITICAL' ? 'var(--red)' : ev.level === 'WARNING' ? 'var(--orange)' : 'var(--sub)'; return ``; }).join('') : ''}
时间设备级别事件
${ev.time ? new Date(ev.time).toLocaleTimeString('zh-CN',{hour12:false}) : '-'}${esc((ev.device_id || '').substring(0,12))}${esc(ev.level || '-')}${esc(ev.message || '-')}
暂无事件 — 设备运行正常

AI 任务队列

向设备推送 AI 任务,通过心跳周期自动下发

推送任务

队列说明

1. 任务通过 API 推入设备队列
2. 每次心跳周期(10s)检查队列
3. 自动批量下发(每次最多 3 个)
4. 设备 Agent 收到后立即执行
5. 执行结果通过 WS 事件回传
`; + } else if (tab === 'antiban') { + const ab = state.antibanStatus || {}; + const hours = ab.operation_hours || {}; + const limits = (ab.platform_limits || {}).wechat || {}; + const device = ab.device || {}; + const red = device.redlight || {}; + const sentinel = device.sentinel || {}; + const warnings = device.guard_warnings || []; + const limitRows = Object.entries(limits).map(([action, cfg]) => ` + + ${esc(action)} + ${(cfg.interval || []).join(' - ')}s + ${cfg.daily_max ?? '-'} + ${cfg.new_daily_max ?? '-'} + `).join(''); + const redTone = red.level === 'red' ? 'var(--red)' : red.level === 'yellow' ? 'var(--orange)' : 'var(--green)'; + const rt = device.runtime || {}; + const st = rt.silent_throttle || {}; + const lp = rt.long_pause || {}; + const hr = rt.last_highrisk; + const stTone = st.active ? 'var(--red)' : 'var(--green)'; + const lpTone = lp.active ? 'var(--orange)' : 'var(--green)'; + const fmtMin = s => s >= 60 ? `${Math.round(s/60)}min` : `${s}s`; + content = ` +
+
+

🛡️ 防封风控

账号暖机 · 限流节律 · Root/Frida 红灯 · AB-02 静默限流熔断

+
+
+
+ ${statCard('操作时段', `${(hours.range || [7,23]).join(':00-')}:00`, hours.in_window ? '当前允许操作' : '当前应暂停')} + ${statCard('红灯等级', red.level || 'unknown', red.reason || '等待设备上报')} + ${statCard('RiskSentinel', sentinel.level_name || sentinel.level || 'unknown', `失败 ${sentinel.consecutive_failures ?? '-'}`)} + ${statCard('长暂停', ab.long_pause?.pause_seconds || '600-1800', `每 ${ab.long_pause?.every_actions || '8-12'} 动作`)} +
+
+
+

AB-02/03 实时熔断(本设备)

rate_limiter 运行态 · /antiban/status?device_id 真源

+
+ ${statCard('AB-02 静默限流熔断', st.active ? `熔断中 ${fmtMin(st.until_in_s||0)}` : '正常', `累计触发 ${st.hits ?? 0} 次`)} + ${statCard('AB-03 长暂停', lp.active ? `暂停中 ${fmtMin(lp.until_in_s||0)}` : '正常', `动作节拍 ${lp.action_seq ?? 0}${lp.next_pause_at ? '/'+lp.next_pause_at : ''}`)} + ${statCard('最近高危动作', hr ? hr.action : '无', hr ? `${hr.ago_s}s 前` : '窗口内无高危')} +
+
+
+

微信动作限流

服务端 _anti_ban_guard 真源,所有写操作必须经过该守卫

+
+ + + ${limitRows || ''} +
动作随机间隔成熟号/日新号/日
暂无限流配置
+
+
+
+
+
+

设备红灯 / Guard

+

设备:${esc(device.device_id || state.currentDevice || '-')} · 在线:${device.online ? '✅' : '❌'}

+
+ ${(warnings || []).length ? warnings.map(w => `${esc(w)}`).join('') : '暂无警告'} +
+
+
+

高危动作互斥

+

以下动作不得同窗口并发,触发风控后需静置 24-48h。

+
+ ${(ab.high_risk_exclusive || []).map(x => `${esc(x)}`).join('')} +
+
+
+
+
+

下一步防封 WBS

共享基线:开发文档/2、架构/05-规范/防封共享策略与执行基线.md

+
+ ${card('AB-02 静默限流 ✅','success 但无 message_id / svr_id → 设备级熔断,写类冷却 30-60min 逐次退避')} + ${card('AB-03 长暂停 ✅','每 8-12 个动作插入 10-30 分钟长暂停 + 高危动作互斥')} + ${card('AB-04 量产红灯','prod 下 Root/Frida/ADB 暴露标红,dev 标黄')} +
+
`; } return header + content; @@ -2465,11 +3336,11 @@ async function fetchHookData(){ state.hookDataLoading = true; renderCurrentView(); try { - const r = await fetch(`/api/v3/hook/data/${did}?modules=profile,contacts,groups,labels,messages,device_info`); + const r = await authenticatedFetch(`/api/v3/hook/data/${did}?modules=profile,contacts,groups,labels,messages,device_info&contact_limit=200&message_limit=50&contact_offset=0&message_offset=0`); const j = await r.json(); if (j.success && j.data) { state.hookData = j.data; - log(`Hook 数据已加载: ${Object.keys(j.data).length} 个模块`, 'info'); + log(`Hook 数据预览已加载: ${Object.keys(j.data).length} 个模块(完整数据走分页接口)`, 'info'); } else { state.hookData = {}; log('Hook 数据加载失败: ' + (j.error || '未知错误'), 'error'); @@ -2525,8 +3396,8 @@ function fillBrainScript(script, action){ if (ae) ae.value = action; const paramHints = { 'send_message': '{"to_id": "联系人名", "content": "消息内容"}', - 'get_messages': '{"limit": 20}', - 'get_contacts': '{"limit": 100}', + 'get_messages': '{"limit": 3000, "offset": 0}', + 'get_contacts': '{"limit": 10000, "offset": 0}', 'add_friend': '{"user_id": "微信号", "message": "你好"}', 'accept_friend': '{}', 'create_group': '{"group_name": "群名", "member_ids": ["id1","id2"]}', @@ -2661,36 +3532,115 @@ async function pushHeartbeatTask(){ /* ═══ Bind QR ═══ */ +async function openBindQRCode(){ + state.current = 'overview'; + state.deviceView = 'list'; + renderSidebar(); + renderCurrentView(); + document.getElementById('bind-panel')?.scrollIntoView({behavior:'smooth', block:'center'}); + await generateBindQR(); +} + +async function deleteOfflineDevice(deviceId){ + const device = (state.devices || []).find(item => item.device_id === deviceId); + if (!device || device.status !== 'offline') { + log('仅离线的失效设备可删除', 'error'); + return; + } + if (!confirm(`确认删除失效设备 ${device.model || deviceId}?历史命令记录会保留。`)) return; + try { + const res = await apiDelete(`/api/v3/devices/${encodeURIComponent(deviceId)}`); + log(res.message || '失效设备已删除', 'info'); + state.currentDevice = null; + state.deviceView = 'list'; + await refreshDevices(); + renderCurrentView(); + } catch (e) { + log(`删除失败:${e.message || e}`, 'error'); + } +} + async function generateBindQR(){ const project = document.getElementById('bind-project')?.value?.trim() || 'cunkebao'; - const name = document.getElementById('bind-name')?.value?.trim() || 'AI数智员工'; + const name = document.getElementById('bind-name')?.value?.trim() || '工作手机'; const server = document.getElementById('bind-server')?.value?.trim(); const area = document.getElementById('bind-qr-area'); if (!server) { area.innerHTML = '
请填写服务器地址
'; return; } area.innerHTML = '
生成中...
'; try { - const res = await apiPost('/api/v3/qrcode/generate', {project_id: project, project_name: name, server}); - if (res.success && res.image_data_url) { + const payload = {project_id: project, project_name: name, server}; + const shouldRetry = (e) => { + const msg = String(e?.message || '').toLowerCase(); + const status = Number(e?.status || 0); + return status >= 500 || msg.includes('重连') || msg.includes('暂时') || msg.includes('超时'); + }; + let res; + try { + res = await apiPost('/api/v3/qrcode/generate', payload); + } catch (firstError) { + if (shouldRetry(firstError)) { + area.innerHTML = '
服务波动,重试中...
'; + await new Promise(resolve => setTimeout(resolve, 800)); + res = await apiPost('/api/v3/qrcode/generate', payload); + } else { + throw firstError; + } + } + + const qr = res?.data || res || {}; + const image = qr.image_data_url || (qr.image_base64 ? `data:image/png;base64,${qr.image_base64}` : ''); + if (qr.success !== false && image) { area.innerHTML = ` - 绑定二维码 + 绑定二维码
${esc(name)}
${esc(project)}
${esc(server)}
`; - } else { - area.innerHTML = `
${esc(res.message || '生成失败')}
`; + return; } + + const msg = qr.message || '二维码生成失败,请重试'; + area.innerHTML = `
${esc(msg)}
`; } catch(e) { - area.innerHTML = `
请求失败: ${esc(e.message)}
`; + let tip = '服务连接异常,请稍后重试'; + if (e && e.status === 401) tip = '控制台授权已失效,请重新登录;若存客宝调用返回401,请核对宝塔 .env 的 API_KEY'; + else if (e && /qrcode|二维码/.test(e.message || '')) tip = `服务异常:${e.message}`; + else if (e && e.message && e.message.includes('服务正在重连')) tip = '服务正在重连,请稍后再试(建议先在控制台执行服务重启)'; + else if (e && e.status && e.status >= 500) tip = '服务重启中,请稍后点击重新生成'; + area.innerHTML = `
${esc(tip)}
`; } } /* ═══ Device Actions ═══ */ +async function saveDeviceRuntimeConfig(){ + const did = state.currentDevice; + if (!did) return log('请先选择设备', 'error'); + const imei = document.getElementById('runtime-imei')?.value?.trim() || ''; + if (imei && !/^\d{14,17}$/.test(imei)) return log('IMEI 必须是14~17位数字', 'error'); + const enabled = document.getElementById('runtime-wx-enabled')?.checked !== false; + const interval = Number(document.getElementById('runtime-wx-interval')?.value || 300); + try { + await apiPost(`/api/v3/devices/${did}/hot-update`, { + update_type:'config', + config:{ + device_imei:imei, + wechat_keepalive_enabled:enabled, + wechat_keepalive_interval_sec:interval + } + }); + log(`APK参数已更新:IMEI ${imei?'已登记':'使用系统读取'} · 微信保活 ${enabled?'开启':'关闭'} · ${interval}秒`, 'info'); + setTimeout(()=>loadDeviceDetail(did), 1500); + } catch(error) { + log(`APK参数更新失败:${error.message}`, 'error'); + } +} + async function doAction(action){ if (!state.currentDevice) return log('请先选择设备', 'error'); log(`执行操作:${action}`, 'info'); + setControlFeedback('指令已发送', true); try{ if (action === 'screenshot'){ const res = await apiPost(`/api/v3/adb/devices/${state.currentDevice}/screenshot`); @@ -2721,17 +3671,299 @@ async function doAction(action){ open_soul: {url:`/api/v3/adb/devices/${state.currentDevice}/app/start`, body:{package:'cn.soulapp.android'}}, home: {url:`/api/v3/adb/devices/${state.currentDevice}/key`, body:{key:'home'}}, back: {url:`/api/v3/adb/devices/${state.currentDevice}/key`, body:{key:'back'}}, + recent: {url:`/api/v3/adb/devices/${state.currentDevice}/key`, body:{key:'recent'}}, }; + if (['home','back','recent'].includes(action) && remoteSend({type:'key',key:action})) { + log(`scrcpy 远程按键:${action}`,'info'); + return; + } const config = actionMap[action]; if (!config) return log(`未知操作:${action}`, 'error'); const res = await apiPost(config.url, config.body); log(`操作完成:${action} ${JSON.stringify(res.data || {}).slice(0, 80)}`, 'info'); - setTimeout(() => doAction('screenshot'), 900); }catch(error){ log(`操作失败:${error.message}`, 'error'); + }finally{ + window.setTimeout(() => setControlFeedback(_remoteFirstFrame?(_remoteTransport==='agent-jpeg'?'扫码设备实时':'scrcpy 实时'):'实时在线', false), 120); } } +async function deviceAdminAction(action){ + if (!state.currentDevice) return log('请先选择设备', 'error'); + if (action === 'reboot' && !window.confirm('确认重启当前手机?设备会短暂离线后自动回连。')) return; + try { + const res = await apiPost(`/api/v3/adb/devices/${state.currentDevice}/reboot`, {}); + log(res.code === 200 ? '重启指令已发送,等待手机回连' : `重启失败:${res.message || '请求失败'}`, res.code === 200 ? 'info' : 'error'); + setTimeout(() => refreshDevices(true), 3500); + } catch (error) { log(`重启失败:${error.message}`, 'error'); } +} + +function chooseDeviceApk(kind='app'){ + state.pendingInstallKind = kind; + document.getElementById('device-apk-file')?.click(); +} + +async function installSelectedApk(file){ + const input = document.getElementById('device-apk-file'); + if (!file || !state.currentDevice) return; + if (!file.name.toLowerCase().endsWith('.apk')) { log('请选择 APK 文件', 'error'); return; } + try { + log(`正在安装${state.pendingInstallKind === 'wechat' ? '微信' : '软件'}:${file.name}`, 'info'); + const res = await apiUpload(`/api/v3/adb/devices/${state.currentDevice}/app/install-ws`, file); + const ok = res.code === 200 && res.data?.success !== false; + log(ok ? `安装完成:${file.name}` : `安装失败:${res.message || res.data?.output || '设备返回失败'}`, ok ? 'info' : 'error'); + if (input) input.value = ''; + if (ok) setTimeout(() => loadDeviceDetail(state.currentDevice), 1200); + } catch (error) { log(`安装失败:${error.message}`, 'error'); if (input) input.value = ''; } +} + +async function loadDeviceServices(deviceId=state.currentDevice){ + if (!deviceId) return; + try { + await apiPost(`/api/v3/adb/devices/${deviceId}/services/ensure`, {}).catch(() => null); + const res = await apiGet(`/api/v3/adb/devices/${deviceId}/services`); + const services = res.data?.services || res.services || []; + state.devices = (state.devices || []).map(device => device.device_id === deviceId ? {...device, service_summary: services} : device); + const active = (state.devices || []).find(device => device.device_id === deviceId); + if (active) patchDeviceDetailSummary(active); + log('设备服务状态已刷新', 'info'); + } catch (error) { log(`服务状态读取失败:${error.message}`, 'error'); } +} + +let _phonePointerStart = null; +let _remoteSocket = null; +let _remoteDecoder = null; +let _remoteConfigPacket = null; +let _remoteVideoSize = {width:272,height:600}; +let _remoteFirstFrame = false; +let _remoteMoveAt = 0; +let _remoteSerial = ''; +let _remoteGeneration = 0; +let _remoteTransport = ''; + +function remoteSocketUrl(deviceId){ + const source = API || location.origin; + const base = source.replace(/^http:/,'ws:').replace(/^https:/,'wss:'); + return `${base}/api/v3/adb/devices/${encodeURIComponent(deviceId)}/screen/remote.ws`; +} +function remoteSend(message){ + if (!_remoteSocket || _remoteSocket.readyState !== WebSocket.OPEN) return false; + _remoteSocket.send(JSON.stringify(message)); + return true; +} +function remotePoint(event){ + const screen = document.getElementById('live-screen'); + if (!screen || screen.style.display === 'none') return null; + const rect = screen.getBoundingClientRect(); + if (!rect.width || !rect.height) return null; + return { + x:Math.round(Math.min(1,Math.max(0,(event.clientX-rect.left)/rect.width))*_remoteVideoSize.width), + y:Math.round(Math.min(1,Math.max(0,(event.clientY-rect.top)/rect.height))*_remoteVideoSize.height) + }; +} +function phonePointerStart(event){ + if (event.pointerType === 'mouse' && event.button !== 0) return; + event.currentTarget?.setPointerCapture?.(event.pointerId); + const point = remotePoint(event); + const remote = !!point && remoteSend({type:'touch',action:'down',...point,pointer_id:-1}); + _phonePointerStart = {x:event.clientX,y:event.clientY,time:Date.now(),remote,lastPoint:point}; + setControlFeedback(remote?'远程触控':'触控中', true); +} +function phonePointerMove(event){ + if (!_phonePointerStart?.remote || Date.now()-_remoteMoveAt<16) return; + const point = remotePoint(event); + if (!point) return; + _remoteMoveAt = Date.now(); + _phonePointerStart.lastPoint = point; + remoteSend({type:'touch',action:'move',...point,pointer_id:-1}); +} +function phonePointerCancel(event){ + if (_phonePointerStart?.remote && _phonePointerStart.lastPoint) { + remoteSend({type:'touch',action:'up',..._phonePointerStart.lastPoint,pointer_id:-1}); + } + _phonePointerStart = null; + setControlFeedback(_remoteFirstFrame?(_remoteTransport==='agent-jpeg'?'扫码设备实时':'scrcpy 实时'):'实时在线', false); +} +async function phonePointerEnd(event){ + if (!_phonePointerStart || !state.currentDevice) return; + const start = _phonePointerStart; _phonePointerStart = null; + const dx = event.clientX-start.x, dy = event.clientY-start.y; + const distance = Math.hypot(dx,dy); + try { + if (start.remote) { + const point = remotePoint(event) || start.lastPoint; + if (point) remoteSend({type:'touch',action:'up',...point,pointer_id:-1}); + log(distance>14?'scrcpy 远程滑动已发送':'scrcpy 远程点击已发送','info'); + return; + } + if (distance > 24) { + const direction = Math.abs(dx)>Math.abs(dy) ? (dx>0?'right':'left') : (dy>0?'down':'up'); + await apiPost(`/api/v3/adb/devices/${state.currentDevice}/swipe`, {direction,scale:Math.min(.8,Math.max(.25,distance/420))}); + log(`手机画面滑动:${direction}`,'info'); + } else if (distance < 14) { + const screen = document.getElementById('live-screen'); + if (!screen) return; + const rect = screen.getBoundingClientRect(); + const x = Math.round(((event.clientX-rect.left)/rect.width)*(currentDeviceDisplay().width||1080)); + const y = Math.round(((event.clientY-rect.top)/rect.height)*(currentDeviceDisplay().height||2400)); + await apiPost(`/api/v3/adb/devices/${state.currentDevice}/click`, {x,y}); + log(`手机画面点击:${x}, ${y}`,'info'); + } + } catch(error) { + log(`手机画面操作失败:${error.message}`,'error'); + } finally { + window.setTimeout(()=>setControlFeedback(_remoteFirstFrame?(_remoteTransport==='agent-jpeg'?'扫码设备实时':'scrcpy 实时'):'实时在线',false),80); + } +} +function currentDeviceDisplay(){ + const device = (state.devices || []).find(item => item.device_id === state.currentDevice) || {}; + return device.display || {width:1080,height:2400}; +} + +function liveScreenUrl(){ + if (!state.currentDevice) return ''; + return `${API}/api/v3/adb/devices/${encodeURIComponent(state.currentDevice)}/screen/live.mjpeg?fps=3&quality=65&max_width=600&t=${Date.now()}`; +} +function liveScreenReady(){ + const status = document.getElementById('live-screen-status'); + if (status) { status.className = 'live-device-status'; status.textContent = _remoteTransport==='agent-jpeg' ? '扫码设备实时' : 'scrcpy 实时'; } +} +function setControlFeedback(text, active=false){ + const status = document.getElementById('live-screen-status'); + const frame = document.getElementById('shot-box'); + if (status) { + status.className = active ? 'live-device-status waiting' : 'live-device-status'; + status.textContent = text; + } + frame?.classList.toggle('control-feedback', active); +} +function startRemoteScreen(deviceId=state.currentDevice){ + if (!deviceId || !document.getElementById('live-screen')) return; + const generation = ++_remoteGeneration; + if (_remoteSocket) { try{_remoteSocket.close()}catch(_){} } + if (_remoteDecoder) { try{_remoteDecoder.close()}catch(_){} } + _remoteSerial = deviceId; + _remoteTransport = ''; + _remoteFirstFrame = false; + _remoteConfigPacket = null; + const canvas = document.getElementById('live-screen'); + const fallback = document.getElementById('live-screen-fallback'); + if (canvas) canvas.style.display = 'block'; + if (fallback) fallback.style.display = 'none'; + setControlFeedback('远程连接中',true); + if (!('VideoDecoder' in window) || !('EncodedVideoChunk' in window)) { + startFallbackScreen('浏览器解码兼容模式'); + return; + } + const context = canvas.getContext('2d',{alpha:false,desynchronized:true}); + _remoteDecoder = new VideoDecoder({ + output(frame){ + if (canvas.width!==frame.displayWidth || canvas.height!==frame.displayHeight) { + canvas.width=frame.displayWidth; canvas.height=frame.displayHeight; + } + context.drawImage(frame,0,0,canvas.width,canvas.height); + frame.close(); + if (!_remoteFirstFrame) { + _remoteFirstFrame=true; + liveScreenReady(); + } + }, + error(error){ + log(`scrcpy 解码异常:${error.message}`,'error'); + startFallbackScreen('视频解码兼容模式'); + } + }); + const socket = new WebSocket(remoteSocketUrl(deviceId)); + _remoteSocket = socket; + socket.binaryType = 'arraybuffer'; + socket.onmessage = event => { + if (generation !== _remoteGeneration) return; + if (typeof event.data === 'string') { + const message = JSON.parse(event.data); + if (message.type==='init') { + _remoteTransport=message.transport || 'scrcpy-h264'; + _remoteVideoSize={width:Number(message.width)||272,height:Number(message.height)||600}; + canvas.width=_remoteVideoSize.width; canvas.height=_remoteVideoSize.height; + if (message.transport==='agent-jpeg') { + _remoteDecoder.close(); _remoteDecoder=null; + setControlFeedback('扫码设备连接中',true); + } else { + _remoteDecoder.configure({codec:message.codec||'avc1.64000A',optimizeForLatency:true,hardwareAcceleration:'prefer-hardware'}); + setControlFeedback('视频接收中',true); + } + } else if (message.type==='config' && _remoteDecoder?.state!=='closed') { + try{_remoteDecoder.configure({codec:message.codec||'avc1.64000A',optimizeForLatency:true,hardwareAcceleration:'prefer-hardware'})}catch(_){} + } else if (message.type==='error') { + log(`scrcpy 连接失败:${message.message}`,'error'); + } + return; + } + const bytes = new Uint8Array(event.data); + if (bytes.byteLength<10) return; + const view = new DataView(bytes.buffer,bytes.byteOffset,bytes.byteLength); + const kind = view.getUint8(0); + const timestamp = Number(view.getBigUint64(1,false)); + const payload = bytes.slice(9); + if (kind===3) { + createImageBitmap(new Blob([payload],{type:'image/jpeg'})).then(frame=>{ + if (generation!==_remoteGeneration) { frame.close(); return; } + if (canvas.width!==frame.width || canvas.height!==frame.height) { canvas.width=frame.width; canvas.height=frame.height; } + context.drawImage(frame,0,0,canvas.width,canvas.height); frame.close(); + if (!_remoteFirstFrame) { _remoteFirstFrame=true; liveScreenReady(); } + }).catch(()=>{ if (!_remoteFirstFrame) startFallbackScreen('远程画面重试'); }); + return; + } + if (!_remoteDecoder || _remoteDecoder.state!=='configured') return; + if (kind===0) { _remoteConfigPacket=payload; return; } + let data=payload; + if (kind===1 && _remoteConfigPacket) { + data=new Uint8Array(_remoteConfigPacket.byteLength+payload.byteLength); + data.set(_remoteConfigPacket); data.set(payload,_remoteConfigPacket.byteLength); + } + try { + _remoteDecoder.decode(new EncodedVideoChunk({type:kind===1?'key':'delta',timestamp,duration:33333,data})); + } catch(error) { + if (!_remoteFirstFrame) startFallbackScreen('视频解码兼容模式'); + } + }; + socket.onerror = () => { + if (generation !== _remoteGeneration) return; + if (!_remoteFirstFrame) startFallbackScreen('远程视频兼容模式'); + }; + socket.onclose = () => { + if (generation !== _remoteGeneration) return; + if (!_remoteFirstFrame && _remoteSerial===deviceId) startFallbackScreen('远程视频兼容模式'); + else if (_remoteSerial===deviceId) setControlFeedback('远程已断开',false); + }; +} +function startFallbackScreen(reason='兼容模式'){ + ++_remoteGeneration; + if (_remoteSocket) { try{_remoteSocket.close()}catch(_){} } + const canvas=document.getElementById('live-screen'); + const image=document.getElementById('live-screen-fallback'); + if (canvas) canvas.style.display='none'; + if (image) { image.style.display='block'; image.src=liveScreenUrl(); } + const status=document.getElementById('live-screen-status'); + if (status) { status.className='live-device-status waiting'; status.textContent=reason; } +} +function fallbackScreenReady(){ + const status=document.getElementById('live-screen-status'); + if (status) { status.className='live-device-status waiting'; status.textContent='兼容画面'; } +} +function liveScreenFailed(image){ + const status = document.getElementById('live-screen-status'); + if (status) { status.className = 'live-device-status error'; status.textContent = '连接重试'; } + window.setTimeout(() => { + if (image?.isConnected && state.currentDevice) image.src = liveScreenUrl(); + }, 1600); +} +function restartLiveScreen(){ + const status = document.getElementById('live-screen-status'); + if (status) { status.className = 'live-device-status waiting'; status.textContent = '正在重连'; } + startRemoteScreen(state.currentDevice); + log('scrcpy 远程视频正在重新连接','info'); +} + function setMode(mode){ state.cmdMode = mode; if (state.current === 'devices') renderCurrentView(); @@ -2772,19 +4004,26 @@ async function sendCmd(){ function selectDevice(deviceId){ state.currentDevice = deviceId; renderCurrentView(); - loadDeviceDetail(deviceId); - refreshHook(); + window.setTimeout(() => { + if (state.current === 'devices' && state.currentDevice === deviceId) loadDeviceDetail(deviceId); + }, 3000); log(`已切换设备:${deviceId}`, 'info'); } async function loadDeviceDetail(deviceId){ try{ - const [detailRes, modesRes] = await Promise.all([ + const preserveLiveScreen = state.current === 'devices' + && state.deviceView === 'detail' + && state.currentDevice === deviceId + && !!document.getElementById('live-screen'); + const [detailRes, modesRes, servicesRes] = await Promise.all([ apiGet(`/api/v3/devices/${deviceId}`), - apiGet(`/api/v3/connection/modes/${deviceId}`).catch(() => null) + apiGet(`/api/v3/connection/modes/${deviceId}`).catch(() => null), + apiGet(`/api/v3/adb/devices/${deviceId}/services`).catch(() => null) ]); let detail = detailRes.data || {}; const modes = modesRes?.data || {}; + const services = servicesRes?.data || servicesRes || {}; const needAdb = !detail.model || String(detail.model).includes('Unknown') || !detail.brand; if (needAdb) { try { @@ -2795,14 +4034,38 @@ async function loadDeviceDetail(deviceId){ if (adbData.android_version && adbData.android_version !== 'Unknown') detail = {...detail, android_version: adbData.android_version}; } catch(_) {} } - const merged = {...detail, connection_modes: modes.modes || [], best_mode: modes.best_mode || null}; + const merged = { + ...detail, + device_id: deviceId, + connection_modes: modes.modes || [], + best_mode: modes.best_mode || null, + service_summary: services.services || detail.service_summary || [] + }; state.devices = (state.devices || []).map(item => item.device_id === deviceId ? {...item, ...merged} : item); - if (state.current === 'devices') renderCurrentView(); + if (preserveLiveScreen) patchDeviceDetailSummary(merged); + else if (state.current === 'devices') renderCurrentView(); }catch(error){ log(`设备详情加载失败:${error.message}`, 'error'); } } +function patchDeviceDetailSummary(device){ + const setText = (id, value) => { + const element = document.getElementById(id); + if (element && value !== undefined && value !== null && value !== '') element.textContent = String(value); + }; + setText('device-summary-name', device.profile_nickname || device.nickname || device.model || device.device_id); + setText('device-summary-id', `${[device.brand,device.model].filter(Boolean).join(' ') || 'Android'} · ${device.device_id}`); + setText('device-metric-android', device.android_version || device.sdk_version || '-'); + setText('device-metric-app', device.current_app || (device.wechat_running ? '微信' : '-')); + setText('device-metric-battery', device.battery_level >= 0 ? `${device.battery_level}%${device.battery_charging ? ' ⚡' : ''}` : '-'); + const services = Array.isArray(device.service_summary) ? device.service_summary : []; + const serviceRow = document.querySelector('.service-summary-row'); + if (serviceRow && services.length) { + serviceRow.innerHTML = services.map(service => `${esc(service.name)} · ${service.running?'运行中':'待配置'}`).join(''); + } +} + async function adbRescan(){ log('正在扫描 ADB 设备...', 'info'); try { @@ -2819,14 +4082,37 @@ async function adbRescan(){ async function refreshDevices(silent){ await Promise.all([refreshOverview(silent), refreshConnection(silent)]); + await refreshRealtimeStatus(silent); if (!silent && state.currentDevice) await loadDeviceDetail(state.currentDevice); } async function refreshAll(){ await Promise.all([refreshOverview(), refreshConnection(), refreshDocs()]); + await refreshRealtimeStatus(); log('全部数据已刷新', 'info'); } +async function refreshRealtimeStatus(silent=true){ + try { + const res = await apiGet('/api/v3/integration/realtime/status?consumer=cunkebao&event_limit=20'); + const data = res.data || res || {}; + state.realtime = { + devices: Array.isArray(data.devices) ? data.devices : [], + recent_events: Array.isArray(data.recent_events) ? data.recent_events : [], + }; + state.realtimeError = ''; + const liveById = new Map(state.realtime.devices.map(item => [item.device_id, item])); + state.devices = (state.devices || []).map(device => { + const live = liveById.get(device.device_id); + return live ? {...device, ...live, status: live.status || device.status} : device; + }); + if (!silent && state.current === 'devices') renderCurrentView(false); + } catch (error) { + state.realtimeError = error.message || String(error); + if (!silent) log(`实时状态加载失败:${state.realtimeError}`, 'error'); + } +} + async function refreshConnectionModes(){ try { const res = await apiGet('/api/v3/connection/modes'); @@ -2854,7 +4140,9 @@ async function refreshOverview(silent){ const res = await apiGet('/api/v3/workbench/overview'); state.overview = res.data; state.devices = res.data.devices || []; - if (!state.currentDevice && state.devices.length) state.currentDevice = state.devices[0].device_id; + if (state.devices.length && !state.devices.some(device => device.device_id === state.currentDevice)) { + state.currentDevice = state.devices[0].device_id; + } renderStats(); if (!silent) renderCurrentView(); }catch(error){ @@ -2928,13 +4216,26 @@ async function refreshHeartbeat(){ } } +async function refreshAntiban(){ + try { + const qs = state.currentDevice ? `?device_id=${encodeURIComponent(state.currentDevice)}` : ''; + const res = await apiGet(`/api/v3/antiban/status${qs}`); + state.antibanStatus = res.data || {}; + if (state.current === 'engine' && state.engineTab === 'antiban') renderCurrentView(false); + } catch(e) { + log('防封状态加载失败: ' + e.message, 'error'); + } +} + /* ═══ View Switching ═══ */ let _lastView = ''; function renderCurrentView(animate){ const view = document.getElementById('view'); + const stats = document.getElementById('stats'); + if (stats) stats.style.display = 'none'; const renderers = { - overview: renderOverview, + overview: renderOverviewClean, devices: renderDevicePanel, engine: renderEnginePanel, gateway: renderGatewayPanel, @@ -2967,7 +4268,7 @@ function closeLightbox(){ document.addEventListener('click', function(e){ const img = e.target; if (img.tagName !== 'IMG') return; - if (img.closest('.asset-card') || img.closest('.doc-body') || img.closest('.shot')) { + if (img.id !== 'live-screen' && (img.closest('.asset-card') || img.closest('.doc-body') || img.closest('.shot'))) { e.stopPropagation(); openLightbox(img.src, img.alt || ''); } @@ -2987,7 +4288,7 @@ function toggleShotAutoRefresh() { if (state.shotAutoRefresh) { doAction('screenshot'); _shotTimer = setInterval(() => { - if (state.currentDevice && state.current === 'devices' && state.deviceTab === 'control') { + if (state.currentDevice && state.current === 'devices' && state.deviceView === 'detail') { doAction('screenshot'); } }, _shotInterval * 1000); @@ -3009,7 +4310,7 @@ function setShotInterval(sec) { if (state.shotAutoRefresh) { if (_shotTimer) clearInterval(_shotTimer); _shotTimer = setInterval(() => { - if (state.currentDevice && state.current === 'devices' && state.deviceTab === 'control') { + if (state.currentDevice && state.current === 'devices' && state.deviceView === 'detail') { doAction('screenshot'); } }, _shotInterval * 1000); @@ -3031,8 +4332,10 @@ async function scanLanDevices() { try { const res = await apiPost('/api/v3/discovery/scan-lan?timeout=3'); state.discoveredServers = res.data?.servers || []; + const preferred = state.discoveredServers.find(server => server.is_self) || state.discoveredServers[0]; + if (preferred?.ws_url) state.bindingServer = preferred.ws_url.replace(/\/ws\/device\/?$/, '/ws/device'); state.discoveryStatus = 'done'; - log(`局域网扫描完成:发现 ${state.discoveredServers.length} 个服务`, 'info'); + log(`局域网扫描完成:发现 ${state.discoveredServers.length} 个服务,绑定基准已切换到 ${state.bindingServer || '当前服务器'}`, 'info'); } catch(e) { state.discoveryStatus = 'error'; log(`局域网扫描失败:${e.message}`, 'error'); @@ -3076,16 +4379,40 @@ function updateConnBar() { async function bootstrap(){ renderSidebar(); + if (!await ensureConsoleAuthenticated()) { + document.getElementById('view').innerHTML = `
+
🔐
+

登录工作手机控制台

+

管理员账号用于网页控制台;API Key 仅放在存客宝后端或宝塔环境变量中。

+
+ + +
+ +
+
+ 授权配置位置
+ 1. 宝塔:/www/wwwroot/workphone-sdk/.env 配置 API_KEYDEVICE_PAIRING_TOKENCONSOLE_PASSWORD,然后重启 workphone-sdk-baota
+ 2. 存客宝后端:配置 WORKPHONE_SDK_URL=https://wpsdk.quwanzhi.comWORKPHONE_SDK_KEY=同一API_KEY
+ 3. 手机扫码:登录后进入“手机设备”,点击“生成绑定二维码”;二维码只包含工作手机服务地址与配对令牌,不包含 API Key。 +
+
`; + updateConsoleAuthChip(false); + return false; + } await Promise.all([ refreshOverview(), refreshDocs(), refreshConnection(), refreshHook(), refreshHeartbeat(), + refreshAntiban(), refreshGateway(), loadBrainDashboard(), loadServerInfo(), ]); + await refreshRealtimeStatus(); + if (state.currentDevice) await loadDeviceDetail(state.currentDevice); renderCurrentView(); updateConnBar(); setTimeout(() => { @@ -3095,22 +4422,23 @@ async function bootstrap(){ scanLanDevices(); } }, 2000); + return true; } -bootstrap(); +bootstrap().then(ok => { if (ok) startPolling(); }); let _pollTimer = null; function startPolling(){ if (_pollTimer) clearInterval(_pollTimer); _pollTimer = setInterval(async () => { await refreshOverview(true); + await refreshRealtimeStatus(true); updateConnBar(); }, 30000); } -startPolling(); document.addEventListener('visibilitychange', () => { if (document.hidden) { clearInterval(_pollTimer); _pollTimer = null; } - else { startPolling(); refreshOverview(true).then(updateConnBar); } + else if (consoleAuthenticated) { startPolling(); refreshOverview(true).then(() => refreshRealtimeStatus(true)).then(updateConnBar); } }); diff --git a/sdk/tests/test_qrcode_bind_and_offline_delete.py b/sdk/tests/test_qrcode_bind_and_offline_delete.py new file mode 100644 index 0000000000..18584675e8 --- /dev/null +++ b/sdk/tests/test_qrcode_bind_and_offline_delete.py @@ -0,0 +1,28 @@ +from pathlib import Path +import sys + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "app")) + +from routers import devices +from routers.qrcode import _build_qr_content, _render_qr_png_bytes + + +def test_pairing_qr_encodes_server_and_token(): + content = _build_qr_content("cunkebao", "工作手机", "wss://wpsdk.quwanzhi.com/ws/device", pairing_token="pair-token") + assert "wpsdk.quwanzhi.com" in content + assert "pair-token" in content + assert _render_qr_png_bytes(content).startswith(b"\x89PNG") + + +def test_device_routes_expose_guarded_delete(): + routes = {(route.path, tuple(sorted(route.methods or []))) for route in devices.router.routes} + assert ("/devices/{device_id}", ("DELETE",)) in routes + + +def test_device_page_exposes_bind_and_offline_delete_actions(): + hub = (ROOT / "app" / "static" / "hub.html").read_text() + assert "openBindQRCode()" in hub + assert "扫码绑定" in hub + assert "deleteOfflineDevice" in hub + assert "扫描 ADB" in hub diff --git a/开发文档/10、项目管理/工作日志.md b/开发文档/10、项目管理/工作日志.md index 42f083b9f4..44507fe423 100644 --- a/开发文档/10、项目管理/工作日志.md +++ b/开发文档/10、项目管理/工作日志.md @@ -3785,4 +3785,18 @@ v3.1: Agent 内置 AI Brain → 心跳驱动自主决策 → Frida优先/u2兜 **进度**: 接口层 100% · 真机 E2E ~90%(等 ADB) ---- \ No newline at end of file +--- +### 2026-08-08 | ChatGPT 项目对话协同方案 + +**完成项**: +1. 按公开官方文档核验 ChatGPT Projects 的项目记忆、共享与同项目对话聚合能力。 +2. 核验 Codex 的代码任务、测试证据、本地/云端协同与 Git 环境能力。 +3. 新增《ChatGPT_Codex_卡若AI协同方案_20260808》与 CP1~CP4 需求台账。 +4. 定义边界:项目对话仅做资料与索引,设备动作仍经现有 API → Agent → 回读 → 审计闭环。 + +**进度**:方案制作 100%;P0 建立 ChatGPT 项目待执行;代码改造 0%。 +### 2026-08-08 21:47|扫码绑定入口与离线设备删除 +- 已把设备页 `扫描` 拆分为 `扫码绑定` 与 `扫描 ADB`;扫码绑定会自动生成当前服务器二维码。 +- 已新增离线设备删除接口和页面按钮,在线设备有 409 保护,保留命令审计。 +- 验收:本地定向测试 3/3 通过;宝塔容器 healthy、二维码接口 HTTP 200 且返回 PNG、页面标识回读通过、删除不存在设备返回 404。当前 WSS 在线设备 0 台,等待手机扫码注册。 + diff --git a/开发文档/8、部署/06-存客宝宝塔/工作手机SDK_存客宝宝塔部署总方案.md b/开发文档/8、部署/06-存客宝宝塔/工作手机SDK_存客宝宝塔部署总方案.md index d2a1004b35..2db3aa8af5 100644 --- a/开发文档/8、部署/06-存客宝宝塔/工作手机SDK_存客宝宝塔部署总方案.md +++ b/开发文档/8、部署/06-存客宝宝塔/工作手机SDK_存客宝宝塔部署总方案.md @@ -119,6 +119,15 @@ curl -X POST http://127.0.0.1:8899/api/v3/health 2>/dev/null || true - 如果控制台无法触摸:确认 `docker compose logs` 有 `websocket connected` 并回传心跳 - 如果外网解析到错误 IP:先在本地 `dig +short wpsdk.quwanzhi.com` 与宝塔机器 `ping` 比对 +## 九、2026-08-08 线上核验记录 + +- `https://wpsdk.quwanzhi.com/health` 返回 HTTP 200,服务版本为 `3.0.0`,当前在线设备数为 0。 +- 未登录访问 `/hub` 返回 HTTP 401 `API Key 无效`,说明生产环境已开启鉴权,扫码页面需要先用管理员账号登录。 +- `/api/v3/qrcode/generate` 未携带线上匹配密钥时返回 HTTP 401,二维码路由本身已发布,空白区域是未完成登录或生成前的初始状态。 +- 本机 `sdk/.env` 中存在 API Key,但与公网服务当前使用的 API Key 不一致;需要在宝塔 `/www/wwwroot/workphone-sdk/.env` 对齐后重启容器。 +- 对齐后验收顺序:打开 `/hub?tab=devices` → 管理员登录 → 点击“扫描服务器并作为绑定基准” → 点击“生成绑定二维码” → 手机端扫码 → 回到设备列表观察 WS 在线数。 +- 本轮只做了公网只读核验和本地页面、文档更新,未执行远程上传、容器重启或真实设备写操作。 + ## 九、上线后交付物 - 已执行命令列表 @@ -160,3 +169,29 @@ curl -X POST http://127.0.0.1:8899/api/v3/health 2>/dev/null || true - 服务器本地 `curl http://127.0.0.1:8899/health` 返回同样健康状态。 - 访问说明: - 控制台页面可能返回 401(鉴权网关)是正常行为;需登录后使用 `/hub`。 + + +### 2026-08-08|扫码版本差异复核 + +- 复核结论:本地 `sdk/app/static/hub.html` 已包含“扫码绑定手机”面板和 `POST /api/v3/qrcode/generate` 调用;用户反馈线上页面仍为旧版,按“线上未同步”处理。 +- 根因:此前完成了本地页面和 v1.0 交付包更新,但没有在本轮执行宝塔远程同步,因此本地与线上版本存在差异。 +- 正式发布命令:`TARGET=cunkebao REMOTE_DIR=/www/wwwroot/workphone-sdk bash sdk/scripts/deploy_baota_wpsdk.sh`。 +- 发布后必须验证:登录 `https://wpsdk.quwanzhi.com/hub?tab=devices`,确认“扫码绑定手机”面板、扫描服务器按钮、生成绑定二维码按钮均出现,再执行手机扫码。 +- 回滚方式:使用发布前保存的 `app/` 目录和 Docker 镜像重新执行 `docker compose -f docker-compose.baota.yml up -d --build`,再重载 Nginx。 +- 本轮已完成本地发布准备和源码路由校验;远程同步和容器重启需在宝塔主机执行。 + +## 2026-08-08 21:47 扫码绑定与失效设备清理修复 + +- **现象**:设备页顶部“扫描”实际执行 ADB 扫描,未直接展示绑定二维码,造成扫码入口混淆。 +- **处理**:设备页新增 `📷 扫码绑定`,点击后切回绑定区并自动调用 `POST /api/v3/qrcode/generate` 渲染二维码;原按钮明确改名为 `📡 扫描 ADB`。 +- **绑定内容**:二维码仅包含当前服务 WSS 地址和配对令牌,控制台使用管理员会话,外部服务使用 API Key。 +- **清理能力**:新增 `DELETE /api/v3/devices/{device_id}`;仅允许删除离线登记设备,在线设备返回 `409`,历史命令记录保留审计。 +- **验收**:本地 `pytest tests/test_qrcode_bind_and_offline_delete.py -q` 通过;上线后回读二维码生成、页面标识、离线删除保护及健康状态。 +### 2026-08-08 21:48 上线验收结果 + +- `workphone-sdk-baota` 已重建并显示 `healthy`。 +- 服务器内网 `POST /api/v3/qrcode/generate`:HTTP 200,`success=true`,返回 PNG Data URL,二维码中含 `wss://wpsdk.quwanzhi.com/ws/device`。 +- 页面源码回读确认 `openBindQRCode`、`扫码绑定`、`扫描 ADB`、`删除失效设备` 均已上线。 +- `DELETE /api/v3/devices/__verification_missing_device__` 返回 HTTP 404,确认删除接口受设备存在性校验保护;在线设备另有 HTTP 409 保护。 +- 公网健康检查:`https://wpsdk.quwanzhi.com/health` 返回 healthy;当前 `devices_online=0`,等待手机扫描二维码并完成 WSS 注册。 +