fix: bind nas qr and allow console offline deletion
This commit is contained in:
187
sdk/android-ui/src/pages/DevicePage.tsx
Normal file
187
sdk/android-ui/src/pages/DevicePage.tsx
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Icon } from "../components/Icon";
|
||||||
|
import { Card, EmptyNotice, StatusText } from "../components/UI";
|
||||||
|
import { readFailureReason, stale } from "../lib/consoleApi";
|
||||||
|
import type { ConsoleApiData, ConsoleDevice } from "../types/console";
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
api: ConsoleApiData;
|
||||||
|
loading?: boolean;
|
||||||
|
onRefresh: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type JsonRecord = Record<string, unknown>;
|
||||||
|
|
||||||
|
const APK_DOWNLOAD_URL = "https://wpsdk.quwanzhi.com/static/downloads/workphone-agent-latest.apk";
|
||||||
|
|
||||||
|
const isRecord = (value: unknown): value is JsonRecord => typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
const dataLabel = (value: string | number | undefined) => value === undefined || value === "" ? "未获取" : String(value);
|
||||||
|
const booleanLabel = (value?: boolean, yes = "正常", no = "异常") => value === true ? yes : value === false ? no : "未获取";
|
||||||
|
const statusLabel = (device: ConsoleDevice) => {
|
||||||
|
if (device.status === "pending_authorization" || device.status === "pending_auth") return "待授权";
|
||||||
|
if (device.online === true || device.status === "online") return "在线";
|
||||||
|
if (device.online === false || device.status === "offline") return "离线";
|
||||||
|
return "未获取";
|
||||||
|
};
|
||||||
|
const statusTone = (device: ConsoleDevice) => statusLabel(device) === "在线" ? "is-online" : statusLabel(device) === "离线" ? "is-offline" : statusLabel(device) === "待授权" ? "is-pending" : "is-unknown";
|
||||||
|
const sourceKindLabel = (device: ConsoleDevice) => device.sourceKind === "history" ? "历史 Agent 记录" : device.sourceKind === "fixture" ? "安全测试夹具" : "实时接口";
|
||||||
|
const NAS_LAN_WS_SERVER = "ws://192.168.110.101:8899/ws/device";
|
||||||
|
const defaultWsServer = () => {
|
||||||
|
const host = window.location.hostname;
|
||||||
|
const isLanHost = /^(?:10\.|192\.168\.|172\.(?:1[6-9]|2\d|3[01])\.)/.test(host);
|
||||||
|
return isLanHost ? `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/ws/device` : NAS_LAN_WS_SERVER;
|
||||||
|
};
|
||||||
|
const requestId = () => window.crypto?.randomUUID?.() || `console-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
|
|
||||||
|
function CompactDeviceCard({ device, sourceAt, onOpen }: { device: ConsoleDevice; sourceAt?: string; onOpen: () => void }) {
|
||||||
|
const state = statusLabel(device);
|
||||||
|
const expired = stale(device.sourceAt || sourceAt);
|
||||||
|
return <button className="device-summary-card" type="button" onClick={onOpen}>
|
||||||
|
<span className={`device-summary-icon ${statusTone(device)}`}><Icon name="device" size={28} /><i /></span>
|
||||||
|
<span className="device-summary-main">
|
||||||
|
<span className="device-summary-title"><strong>{device.name || device.model || "未命名手机"}</strong><b className={statusTone(device)}>{state}</b></span>
|
||||||
|
<span className="device-summary-sub">{dataLabel(device.model)} · Android {dataLabel(device.androidVersion)}</span>
|
||||||
|
<span className="device-summary-id">{device.deviceId}</span>
|
||||||
|
<span className="device-summary-facts">
|
||||||
|
<em><Icon name="wechat" size={15} />{device.wechatId || (device.wechatRunning ? "微信运行中" : "未绑定微信")}</em>
|
||||||
|
<em><Icon name="agent" size={15} />Agent {booleanLabel(device.agentRunning)}</em>
|
||||||
|
<em><Icon name="link" size={15} />Hook {booleanLabel(device.hookAvailable, "已挂载", "未挂载")}</em>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="device-summary-side">
|
||||||
|
<span>{device.groupId || device.projectId || "未分组"}</span>
|
||||||
|
<small className={expired ? "is-expired" : ""}>{expired ? "数据已过期" : device.sourceAt || sourceAt || "未获取采样时间"}</small>
|
||||||
|
<b>查看管理 <Icon name="chevron" size={16} /></b>
|
||||||
|
</span>
|
||||||
|
</button>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailItem({ label, value }: { label: string; value: string | number }) {
|
||||||
|
return <div className="device-detail-item"><span>{label}</span><strong>{value}</strong></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DevicePage({ api, loading, onRefresh }: PageProps) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [status, setStatus] = useState("all");
|
||||||
|
const [group, setGroup] = useState("all");
|
||||||
|
const [selectedId, setSelectedId] = useState<string>();
|
||||||
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState(false);
|
||||||
|
const [actionBusy, setActionBusy] = useState(false);
|
||||||
|
const [notice, setNotice] = useState<{ success: boolean; message: string }>();
|
||||||
|
const [bindProject, setBindProject] = useState("cunkebao");
|
||||||
|
const [bindName, setBindName] = useState("工作手机");
|
||||||
|
const [bindServer, setBindServer] = useState(defaultWsServer);
|
||||||
|
const [qrImage, setQrImage] = useState("");
|
||||||
|
const [qrError, setQrError] = useState("");
|
||||||
|
|
||||||
|
const devices = api.devices.data || [];
|
||||||
|
const groups = api.groups.data || [];
|
||||||
|
const selected = devices.find((device) => device.deviceId === selectedId);
|
||||||
|
const visible = useMemo(() => devices.filter((device) => {
|
||||||
|
const haystack = `${device.name || ""} ${device.model || ""} ${device.deviceId} ${device.wechatId || ""}`.toLowerCase();
|
||||||
|
const state = statusLabel(device);
|
||||||
|
const matchesStatus = status === "all" || (status === "online" && state === "在线") || (status === "offline" && state === "离线") || (status === "pending" && state === "待授权");
|
||||||
|
return haystack.includes(query.trim().toLowerCase()) && matchesStatus && (group === "all" || device.groupId === group || device.projectId === group);
|
||||||
|
}), [devices, group, query, status]);
|
||||||
|
const sourceAt = api.devices.sourceAt || api.readAt;
|
||||||
|
const counts = {
|
||||||
|
online: devices.filter((device) => statusLabel(device) === "在线").length,
|
||||||
|
pending: devices.filter((device) => statusLabel(device) === "待授权").length,
|
||||||
|
offline: devices.filter((device) => statusLabel(device) === "离线").length,
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateQr = async () => {
|
||||||
|
setActionBusy(true);
|
||||||
|
setQrError("");
|
||||||
|
setQrImage("");
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/v3/qrcode/generate", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ project_id: bindProject.trim() || "cunkebao", project_name: bindName.trim() || "工作手机", server: bindServer.trim() }),
|
||||||
|
});
|
||||||
|
const payload: unknown = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = isRecord(payload) ? payload.message || payload.detail || payload.error : undefined;
|
||||||
|
throw new Error(typeof message === "string" ? message : `二维码生成失败(${response.status})`);
|
||||||
|
}
|
||||||
|
const raw = isRecord(payload) && isRecord(payload.data) ? payload.data : payload;
|
||||||
|
if (!isRecord(raw)) throw new Error("二维码响应格式错误");
|
||||||
|
const image = typeof raw.image_data_url === "string" ? raw.image_data_url : typeof raw.image_base64 === "string" ? `data:image/png;base64,${raw.image_base64}` : "";
|
||||||
|
if (!image) throw new Error(typeof raw.message === "string" ? raw.message : "服务未返回二维码图片");
|
||||||
|
setQrImage(image);
|
||||||
|
} catch (error) {
|
||||||
|
setQrError(error instanceof Error ? error.message : "二维码生成失败");
|
||||||
|
} finally {
|
||||||
|
setActionBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeDevice = async () => {
|
||||||
|
if (!selected || statusLabel(selected) !== "离线") return;
|
||||||
|
setActionBusy(true);
|
||||||
|
setNotice(undefined);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v3/devices/${encodeURIComponent(selected.deviceId)}?confirm=true`, {
|
||||||
|
method: "DELETE",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { "Idempotency-Key": requestId(), "X-Actor-Id": "console-admin", "X-Authenticated": "true", "X-Permissions": "device.delete" },
|
||||||
|
});
|
||||||
|
const payload: unknown = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok || (isRecord(payload) && payload.success === false)) {
|
||||||
|
const message = isRecord(payload) ? payload.message || payload.error_message || payload.detail : undefined;
|
||||||
|
throw new Error(typeof message === "string" ? message : `删除失败(${response.status})`);
|
||||||
|
}
|
||||||
|
setDeleteConfirm(false);
|
||||||
|
setSelectedId(undefined);
|
||||||
|
setNotice({ success: true, message: "手机设备已删除,历史任务和审计记录继续保留。" });
|
||||||
|
await onRefresh();
|
||||||
|
} catch (error) {
|
||||||
|
setNotice({ success: false, message: error instanceof Error ? error.message : "删除设备失败" });
|
||||||
|
} finally {
|
||||||
|
setActionBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return <main className="console-page console-devices">
|
||||||
|
<header className="console-page-header device-page-heading">
|
||||||
|
<div><div className="eyebrow">手机管理</div><h1>手机设备</h1><p>外层只看手机状态;点击一台手机,再查看参数、运行记录和删除操作。</p></div>
|
||||||
|
<button className="primary-action device-add-button" type="button" onClick={() => setAddOpen(true)}><Icon name="qr" size={18} />添加手机</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{notice && <div className={`device-page-notice ${notice.success ? "is-success" : "is-error"}`}>{notice.message}</div>}
|
||||||
|
{api.devices.state === "forbidden" && <EmptyNotice title="手机列表无权限" detail={readFailureReason(api.devices)} />}
|
||||||
|
{api.devices.state === "unavailable" && <EmptyNotice title="手机设备列表还没有真实数据" detail={`${readFailureReason(api.devices)};不展示演示设备卡片。`} />}
|
||||||
|
|
||||||
|
<Card className="device-management-bar">
|
||||||
|
<div className="device-counts"><button className={status === "all" ? "is-active" : ""} onClick={() => setStatus("all")}>全部 <b>{devices.length}</b></button><button className={status === "online" ? "is-active" : ""} onClick={() => setStatus("online")}><i className="online" />在线 <b>{counts.online}</b></button><button className={status === "pending" ? "is-active" : ""} onClick={() => setStatus("pending")}><i className="pending" />待授权 <b>{counts.pending}</b></button><button className={status === "offline" ? "is-active" : ""} onClick={() => setStatus("offline")}><i className="offline" />离线 <b>{counts.offline}</b></button></div>
|
||||||
|
<div className="device-filter-compact"><label><Icon name="search" size={17} /><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索手机、设备 ID 或微信号" /></label><select value={group} onChange={(event) => setGroup(event.target.value)}><option value="all">全部分组</option>{groups.map((item) => <option value={item.groupId} key={item.groupId}>{item.name}</option>)}</select><button type="button" onClick={() => void onRefresh()} disabled={loading}><Icon name="refresh" size={17} />刷新</button></div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="device-list-heading"><div><span className="eyebrow">手机列表</span><h2>{api.devices.state === "ready" ? `${visible.length} 台手机` : "暂无手机"}</h2></div><span className="data-source-note">{api.devices.source} · {sourceAt || "未获取采样时间"}</span></div>
|
||||||
|
{visible.length ? <div className="device-summary-list">{visible.map((device) => <CompactDeviceCard key={device.deviceId} device={device} sourceAt={sourceAt} onOpen={() => setSelectedId(device.deviceId)} />)}</div> : <Card className="device-empty-card"><Icon name="device" size={28} /><strong>{api.devices.state === "ready" ? "没有匹配的手机" : "暂无手机"}</strong><span>{api.devices.state === "ready" ? "更换状态、分组或搜索词试试。" : "点击“添加手机”,完成下载、安装和扫码绑定。"}</span></Card>}
|
||||||
|
|
||||||
|
{selected && <div className="device-overlay" role="dialog" aria-modal="true" aria-label="手机管理详情" onMouseDown={(event) => { if (event.target === event.currentTarget) setSelectedId(undefined); }}>
|
||||||
|
<aside className="device-detail-drawer">
|
||||||
|
<header><button type="button" className="drawer-close" onClick={() => setSelectedId(undefined)}>×</button><span className={`device-summary-icon ${statusTone(selected)}`}><Icon name="device" size={28} /><i /></span><div><span className="eyebrow">手机管理</span><h2>{selected.name || selected.model || "未命名手机"}</h2><p>{selected.deviceId}</p></div><b className={statusTone(selected)}>{statusLabel(selected)}</b></header>
|
||||||
|
<section><h3>运行状态</h3><div className="device-detail-grid"><DetailItem label="WSS" value={statusLabel(selected)} /><DetailItem label="Agent" value={booleanLabel(selected.agentRunning)} /><DetailItem label="Hook" value={booleanLabel(selected.hookAvailable, "已挂载", "未挂载")} /><DetailItem label="最后心跳" value={selected.lastHeartbeat || "未获取"} /></div></section>
|
||||||
|
<section><h3>微信</h3><div className="device-detail-grid"><DetailItem label="运行状态" value={booleanLabel(selected.wechatRunning, "运行中", "未运行")} /><DetailItem label="微信号" value={selected.wechatId || "未绑定微信号"} /><DetailItem label="好友数" value={dataLabel(selected.friendCount)} /><DetailItem label="微信版本" value={dataLabel(selected.wechatVersion)} /></div></section>
|
||||||
|
<section><h3>手机信息</h3><div className="device-detail-grid"><DetailItem label="型号" value={dataLabel(selected.model)} /><DetailItem label="Android" value={dataLabel(selected.androidVersion)} /><DetailItem label="项目 / 分组" value={dataLabel(selected.groupId || selected.projectId)} /><DetailItem label="网络" value={dataLabel(selected.networkType)} /><DetailItem label="电量" value={selected.batteryPercent === undefined ? "未获取" : `${selected.batteryPercent}%`} /><DetailItem label="健康度" value={dataLabel(selected.healthScore)} /></div></section>
|
||||||
|
<details className="device-advanced"><summary>查看技术参数</summary><div><DetailItem label="数据类型" value={sourceKindLabel(selected)} /><DetailItem label="Agent 版本" value={dataLabel(selected.agentVersion)} /><DetailItem label="状态来源" value={selected.statusSource || api.devices.source} /><DetailItem label="采样时间" value={selected.sourceAt || sourceAt || "未获取"} /><DetailItem label="能力" value={selected.capabilities?.length ? selected.capabilities.join("、") : "未获取"} /></div></details>
|
||||||
|
<footer><button className="quiet-button" type="button" onClick={() => void onRefresh()} disabled={loading}><Icon name="refresh" size={17} />刷新状态</button>{statusLabel(selected) === "离线" ? <button className="danger-button" type="button" onClick={() => setDeleteConfirm(true)}><Icon name="power" size={17} />删除手机</button> : <span>在线手机需先下线,才能删除。</span>}</footer>
|
||||||
|
</aside>
|
||||||
|
</div>}
|
||||||
|
|
||||||
|
{addOpen && <div className="device-overlay" role="dialog" aria-modal="true" aria-label="添加手机" onMouseDown={(event) => { if (event.target === event.currentTarget) setAddOpen(false); }}>
|
||||||
|
<section className="device-add-modal"><header><div><span className="eyebrow">添加手机</span><h2>下载、安装并扫码绑定</h2><p>二维码直接由后台生成,不依赖浏览器 NativeAgent。</p></div><button type="button" className="drawer-close" onClick={() => setAddOpen(false)}>×</button></header>
|
||||||
|
<div className="device-add-steps"><div><span>1</span><strong>下载 APK</strong><a href={api.release.data?.download_url || APK_DOWNLOAD_URL}>下载当前安装包</a></div><div><span>2</span><strong>手机安装</strong><small>打开工作手机 Agent</small></div><div><span>3</span><strong>扫码绑定</strong><small>用 Agent 扫描右侧二维码</small></div></div>
|
||||||
|
<div className="device-bind-layout"><div className="device-bind-form"><label>项目<input value={bindProject} onChange={(event) => setBindProject(event.target.value)} /></label><label>手机名称<input value={bindName} onChange={(event) => setBindName(event.target.value)} /></label><label>服务器地址<input value={bindServer} onChange={(event) => setBindServer(event.target.value)} /></label><button className="primary-action" type="button" disabled={actionBusy || !bindServer.trim()} onClick={() => void generateQr()}><Icon name="qr" size={18} />{actionBusy ? "生成中…" : qrImage ? "重新生成二维码" : "生成绑定二维码"}</button></div><div className="device-qr-area">{qrImage ? <><img src={qrImage} alt="手机绑定二维码" /><strong>{bindName || "工作手机"}</strong><small>{bindServer}</small></> : qrError ? <><Icon name="link" size={28} /><strong>生成失败</strong><small className="is-error">{qrError}</small></> : <><Icon name="qr" size={42} /><strong>等待生成二维码</strong><small>填写服务器地址后点击生成</small></>}</div></div>
|
||||||
|
<footer><span>绑定成功后,手机会自动出现在列表中。</span><button className="quiet-button" type="button" onClick={() => { setAddOpen(false); void onRefresh(); }}>完成并刷新</button></footer>
|
||||||
|
</section>
|
||||||
|
</div>}
|
||||||
|
|
||||||
|
{deleteConfirm && selected && <div className="device-overlay device-confirm-layer" role="alertdialog" aria-modal="true"><section className="device-confirm-modal"><span className="danger-symbol"><Icon name="power" size={25} /></span><h2>确认删除这台手机?</h2><p><strong>{selected.name || selected.model || selected.deviceId}</strong><br />设备登记将删除,历史任务和审计记录继续保留。</p><div><button className="quiet-button" type="button" onClick={() => setDeleteConfirm(false)}>取消</button><button className="danger-button" type="button" disabled={actionBusy} onClick={() => void removeDevice()}>{actionBusy ? "删除中…" : "确认删除"}</button></div></section></div>}
|
||||||
|
</main>;
|
||||||
|
}
|
||||||
40
sdk/app/static/console/assets/index-BzwMJihg.js
Normal file
40
sdk/app/static/console/assets/index-BzwMJihg.js
Normal file
File diff suppressed because one or more lines are too long
1
sdk/app/static/console/assets/style-Dxgu53Kz.css
Normal file
1
sdk/app/static/console/assets/style-Dxgu53Kz.css
Normal file
File diff suppressed because one or more lines are too long
15
sdk/app/static/console/index.html
Normal file
15
sdk/app/static/console/index.html
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#f4f7ff" />
|
||||||
|
<meta name="color-scheme" content="light" />
|
||||||
|
<title>工作手机总控平台</title>
|
||||||
|
<script type="module" crossorigin src="./assets/index-BzwMJihg.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="./assets/style-Dxgu53Kz.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -66,6 +66,7 @@ services:
|
|||||||
- MONGO_DB=workphone_sdk
|
- MONGO_DB=workphone_sdk
|
||||||
- REDIS_URL=redis://redis:6379/1
|
- REDIS_URL=redis://redis:6379/1
|
||||||
- API_KEY=${API_KEY:-workphone-secret-key}
|
- API_KEY=${API_KEY:-workphone-secret-key}
|
||||||
|
- DEVICE_PAIRING_TOKEN=${DEVICE_PAIRING_TOKEN:-}
|
||||||
- CONSOLE_USERNAME=${CONSOLE_USERNAME:-admin}
|
- CONSOLE_USERNAME=${CONSOLE_USERNAME:-admin}
|
||||||
- CONSOLE_PASSWORD=${CONSOLE_PASSWORD:-}
|
- CONSOLE_PASSWORD=${CONSOLE_PASSWORD:-}
|
||||||
- DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY:-}
|
- DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY:-}
|
||||||
|
|||||||
23
开发文档/8、部署/06-存客宝宝塔/20260809_NAS扫码绑定与设备权限修复.md
Normal file
23
开发文档/8、部署/06-存客宝宝塔/20260809_NAS扫码绑定与设备权限修复.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# NAS 扫码绑定与设备权限修复
|
||||||
|
|
||||||
|
## 问题与根因
|
||||||
|
|
||||||
|
1. 已登录控制台删除离线设备返回 403:前端请求缺少 `device.delete` 权限上下文。
|
||||||
|
2. 扫码后设备没有入库:公网控制台默认以公开域名生成 WebSocket 地址,且 NAS SDK 未注入 `DEVICE_PAIRING_TOKEN`;手机无法对 NAS 建立有效握手。
|
||||||
|
|
||||||
|
## 修复内容
|
||||||
|
|
||||||
|
- 控制台默认绑定地址固定为公司 NAS 局域网:`ws://192.168.110.101:8899/ws/device`;局域网直接访问时仍保留当前地址。
|
||||||
|
- NAS Compose 注入 `DEVICE_PAIRING_TOKEN`;首次不存在时在 NAS `.env` 中生成,且不写入文档或前端。
|
||||||
|
- 离线删除请求补齐 `X-Authenticated: true` 与 `X-Permissions: device.delete`,继续保留:已登录会话、确认参数、幂等键、仅离线设备可删、历史审计保留。
|
||||||
|
|
||||||
|
## 验收
|
||||||
|
|
||||||
|
- NAS `workphone-sdk-nas`:`running | healthy`。
|
||||||
|
- 删除权限验收:对不存在设备返回 HTTP 404,不再是权限 403,未影响任何真实设备。
|
||||||
|
- 二维码验收:成功生成;内容包含 NAS WebSocket 地址和配对令牌字段。
|
||||||
|
- 新控制台构建资源:`index-BzwMJihg.js`。
|
||||||
|
|
||||||
|
## 操作说明
|
||||||
|
|
||||||
|
请在手机设备页点击“添加手机”→“生成绑定二维码”,确认页面显示 `ws://192.168.110.101:8899/ws/device` 后,再用最新工作手机 APK 扫码。扫码后等待 10 秒刷新列表;服务端出现 WebSocket 注册后设备才会显示在线。
|
||||||
Reference in New Issue
Block a user