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
结果沉淀 事件、截图、经验回到工作台形成闭环
-
-
-
-
-
- ${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) => `
+ ${m.icon} 0${index+1}
+ ${m.name}
+ ${m.desc}
+
+ `).join('')}
+
系统会自动选择最快可用通道:数据优先走 Hook,界面操作走 Agent,设备级操作走 ADB。
@@ -653,7 +987,7 @@ function renderOverview(){
设备 状态 连接 当前 APP
${devices.length ? devices.slice(0,8).map(d => {
const isU = d.status==='unauthorized';
- return `
+ return `
${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')}
@@ -661,15 +995,51 @@ function renderOverview(){
}).join('') : ` 暂无设备 · 连接设备 `}
+ ${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=>`
+ 📱 ${esc(d.model||d.nickname||d.device_id)} ${esc(d.device_id)}
+ ${deviceStatusLabel(d)} 查看 →
+ `).join('') || '
当前没有已连接设备,可在右侧扫码绑定
';
+ return `
+
+ 工作手机运营中心 数据总览 设备、连接和服务状态集中查看,核心入口一步到达。
+ 🔄 刷新实时数据
+
+
+ ${[['设备总数',total,'📱','全部登记设备'],['在线设备',online,'●','当前可控制'],['WS / ADB',`${ws} / ${adb}`,'🔗','无线与直连'],['离线设备',offline,'○','等待设备上线']].map(([label,value,icon,desc])=>`${icon} `).join('')}
+
+
+
+
+ 📱 手机设备 选择手机进入控制
+ 🔍 扫描局域网 发现SDK与手机节点
+ 🧠 智能引擎 AI、Hook与运行监控
+ 🔗 对接网关 REST、MCP与存客宝
+
+
+
+
+ ${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 `
`;
+ }
+ 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('')+'
' : ''}
+
+
+
+ ` : '
请选择设备
';
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])=>`
${icon} ${label}`).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?'● 微信正在运行':'○ 微信未运行'}
+
+
+
设备 IMEI
+
检测周期 30秒 1分钟 5分钟 10分钟 30分钟
+
+
微信未运行时自动启动
+
保存到手机 立即启动微信
+
` : '';
+
+ 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) => `
${icon} ${label} `;
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])=>
- `
${icon} ${name} `
- ).join('')}
+
+
+ ${selectedApp.icon}
+ 进入${selectedApp.name}控制 ${appTab==='wechat'?'常用操作、资料查询、账号状态都在这里。':'设备级操作独立管理。'}
+ 进入控制
+
+
+ ${Object.entries(appMeta).filter(([id])=>id!==appTab).map(([id,meta])=>`
+
+ ${meta.icon} ${meta.name}
+ `).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])=>`${icon} ${label} `).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])=>`${icon} ${label} `).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
-
-
+
💬 微信消息操作
+
+
+
+
+ 发送消息
+ 批量发送
+ 添加好友
+
-
-
-
-
- \u53d1\u9001
- \u6279\u91cf\u53d1
- \u52a0\u597d\u53cb
-
- ` : '';
+ ` : '';
const aiSection = `
-
-
\ud83e\udd16 AI \u6307\u4ee4
-
- ${['pattern','ai','ai-only'].map(m=>`
${{pattern:'\u6a21\u5f0f\u5339\u914d',ai:'AI \u6267\u884c','ai-only':'AI \u89e3\u6790'}[m]} `).join('')}
+
🤖 AI 指令
+
+
🔧 AI 接口 ${state.aiConfig?.provider || '龙虾U盘'}
+
+
+
${['pattern','ai','ai-only'].map(m=>`${{pattern:'模式匹配',ai:'AI 执行','ai-only':'AI 解析'}[m]} `).join('')}
+
执行
-
-
-
\u6267\u884c
+ `;
+
+ const compactAppSelector = `
+
+ 📱 应用控制 ${selectedApp.name} · 点击切换
+
+
+ ${Object.entries(appMeta).map(([id,meta])=>`${meta.icon} ${meta.name} `).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}
+
` : ''}
+
+
-
-
-
-
\ud83d\udcf8 \u8bbe\u5907\u622a\u5c4f
-
-
${state.shotAutoRefresh?'\u23f8 \u505c\u6b62\u5237\u65b0':'\u25b6 \u81ea\u52a8\u5237\u65b0'}
-
- 3s
- 5s
- 10s
- 30s
-
-
\ud83d\udcf7 \u7acb\u5373\u622a\u56fe
+
+
+
+
+
+
+
+
+
直接点击、滑动上方手机画面进行操作
+
+ 🏠 主页
+ ↩️ 返回
+ ↻ 重连画面
-
\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?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
-
-
Hook 50-200ms
-
u2 UI 2-15s
-
ADB 1-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=>`⚙️ ${act.n} `).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 `
+ ${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 `
+
+ 📱 ${online?'已连接':'离线'}
+ ${esc(name)}
+ ${esc(device.brand||'Android')} · ${esc(device.device_id)}
+ ● ${tone.label} ${wxRunning?'微信运行':'微信待启动'}
+ 进入控制台 →
+ `;
+ }).join('') : `
${renderConnectGuide()}
`;
const header = `
-
📱 设备管理
-
${devices.length} 台设备 · ${onlineCount} 在线 · 统一管控中心
+
📱 手机设备
+
${devices.length} 台手机 · ${onlineCount} 在线 · 点击手机进入二级控制页面
WS ${wsCount}
ADB ${adbCount}
${unauth.length?` ⚠ ${unauth.length} 待授权 `:''}
🔄 刷新
- 📡 扫描
+ 📷 扫码绑定
+ 📡 扫描 ADB
-
-
🎮 设备控制
-
📡 接口文档 (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 · 点击路径可复制
+
+
+
+
+
+
+
存客宝 ${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 `
-
-
+
+
📲 扫码绑定手机 先扫描服务器,再用手机扫码;二维码只绑定当前服务器
首页唯一入口
@@ -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})
-
🔌 连接监控
-
💓 心跳
+
+
+
+
运行与安全
+
📡 事件 ${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(){
` : ''}
时间 设备 级别 事件 ${hbEvents.length ? hbEvents.slice(0, 30).map(ev => { const lvlColor = ev.level === 'CRITICAL' ? 'var(--red)' : ev.level === 'WARNING' ? 'var(--orange)' : 'var(--sub)'; return `${ev.time ? new Date(ev.time).toLocaleTimeString('zh-CN',{hour12:false}) : '-'} ${esc((ev.device_id || '').substring(0,12))} ${esc(ev.level || '-')} ${esc(ev.message || '-')} `; }).join('') : '暂无事件 — 设备运行正常 '}
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_KEY、DEVICE_PAIRING_TOKEN、CONSOLE_PASSWORD,然后重启 workphone-sdk-baota。
+ 2. 存客宝后端:配置 WORKPHONE_SDK_URL=https://wpsdk.quwanzhi.com、WORKPHONE_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 注册。
+