/** * 屏幕流监控客户端(App环境) * 使用 WebSocket + 截图方式实现实时监控 */ import config from './config.js' class ScreenStreamClient { constructor() { this.ws = null // WebSocket 连接 this.userId = null this.isConnected = false this.isStreaming = false this.captureTimer = null this.captureInterval = 1000 // 默认1000ms(1帧/秒),降低频率减少数据量 // 最大100KB,既然后端缓冲区已设置为1MB,可以接受较大的文件 // 注意:如果质量参数不起作用,文件大小可能达到20-30KB,这是可以接受的 this.maxSize = 100 * 1024 // 100KB,既然后端缓冲区已设置为1MB this.reconnectTimer = null this.reconnectAttempts = 0 this.maxReconnectAttempts = 5 this._plusErrorLogged = false // 标记是否已输出plus错误日志 } /** * 启动屏幕流监控 * @param {String|Number} userId 用户ID * @param {Number} interval 截图间隔(毫秒,默认500ms) */ async start(userId, interval = 500) { if (this.isStreaming) { console.log('屏幕流已在运行中') return } this.userId = String(userId) this.captureInterval = interval try { await this.connectWebSocket() this.isStreaming = true console.log('✅ 屏幕流监控已启动(截图方式)') } catch (error) { console.error('❌ 启动屏幕流失败:', error) this.stop() throw error } } /** * 停止屏幕流监控 */ stop() { this.isStreaming = false this.stopScreenCapture() this.disconnectWebSocket() console.log('🛑 屏幕流监控已停止') } /** * 连接 WebSocket */ connectWebSocket() { return new Promise((resolve, reject) => { if (this.ws && this.isConnected) { resolve() return } try { // 安全地获取服务器配置 let serverHost = 'localhost' let serverPort = 8080 if (config && typeof config.getServerConfig === 'function') { const serverConfig = config.getServerConfig() if (serverConfig) { serverHost = serverConfig.serverHost || serverHost serverPort = serverConfig.serverPort || serverPort } } else { // 如果config.getServerConfig不可用,尝试直接从存储读取 try { const storedHost = uni.getStorageSync('server_host') const storedPort = uni.getStorageSync('server_port') if (storedHost) serverHost = storedHost if (storedPort) serverPort = parseInt(storedPort) || serverPort } catch (e) { console.warn('读取服务器配置失败,使用默认值:', e) } } const wsProtocol = 'ws:' const wsUrl = `${wsProtocol}//${serverHost}:${serverPort}/ws/screenStream/${this.userId}` console.log('🔌 连接屏幕流 WebSocket:', wsUrl) // #ifdef APP-PLUS // App环境:使用uni.connectSocket this.ws = uni.connectSocket({ url: wsUrl, success: () => { console.log('✅ WebSocket 连接成功') }, fail: (err) => { console.error('❌ WebSocket 连接失败:', err) reject(err) } }) this.ws.onOpen(() => { console.log('✅ WebSocket 屏幕流连接成功') this.isConnected = true this.reconnectAttempts = 0 // 重置重连次数 // 发送上线消息,确认这是学生端连接 this.sendMessage({ type: 'online', userId: this.userId }) resolve() }) this.ws.onMessage((event) => { try { const message = typeof event.data === 'string' ? JSON.parse(event.data) : event.data this.handleMessage(message) } catch (error) { console.error('解析 WebSocket 消息失败:', error) } }) this.ws.onError((error) => { console.error('WebSocket 屏幕流错误:', error) this.isConnected = false this.reconnect() reject(error) }) this.ws.onClose((event) => { console.log('WebSocket 屏幕流连接已关闭:', event) this.isConnected = false this.ws = null this.stopScreenCapture() this.reconnect() }) // #endif // #ifdef H5 // H5环境:使用原生WebSocket(用于测试) this.ws = new WebSocket(wsUrl) this.ws.onopen = () => { console.log('✅ WebSocket 屏幕流连接成功') this.isConnected = true this.reconnectAttempts = 0 this.sendMessage({ type: 'online', userId: this.userId }) resolve() } this.ws.onmessage = (event) => { try { const message = typeof event.data === 'string' ? JSON.parse(event.data) : event.data this.handleMessage(message) } catch (error) { console.error('解析消息失败:', error) } } this.ws.onerror = (error) => { console.error('WebSocket 错误:', error) this.isConnected = false this.reconnect() reject(error) } this.ws.onclose = () => { console.log('WebSocket 连接已关闭') this.isConnected = false this.ws = null this.stopScreenCapture() this.reconnect() } // #endif } catch (error) { console.error('连接 WebSocket 异常:', error) reject(error) } }) } /** * 断开 WebSocket 连接 */ disconnectWebSocket() { if (this.ws) { try { // #ifdef APP-PLUS this.ws.close({ success: () => { console.log('WebSocket 屏幕流连接已关闭') } }) // #endif // #ifdef H5 if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) { this.ws.close() } // #endif } catch (error) { console.error('断开 WebSocket 失败:', error) } this.ws = null } this.isConnected = false } /** * 处理收到的消息 */ handleMessage(message) { console.log('📨 收到消息:', message.type) switch (message.type) { case 'connected': console.log('✅ WebSocket 连接确认') break case 'start_capture': const interval = message.interval || this.captureInterval console.log('📹 收到开始截图指令,间隔:', interval, 'ms') // 不再自动启动持续截图,改为按需截图 // this.startScreenCapture(interval) console.log('ℹ️ 已切换到按需截图模式,等待管理端请求') // 确保截图功能已准备好 this.prepareCapture() break case 'request_screenshot': console.log('📸 收到截图请求,立即发送一次截图') // 收到请求时,立即发送一次截图 this.captureAndSendOnce() break case 'monitor_connected': console.log('✅ 监控端已连接,准备接收截图请求') // 确保截图功能已准备好 this.prepareCapture() break case 'stop_capture': console.log('🛑 收到停止截图指令') this.stopScreenCapture() break case 'ping': this.sendMessage({ type: 'pong' }) break default: console.log('收到未知消息:', message.type) } } /** * 开始屏幕捕获 */ startScreenCapture(interval) { this.stopScreenCapture() this.captureInterval = interval || this.captureInterval // #ifdef APP-PLUS // 检查是否有可用的截图方式(plus.screen.capture 或 plus.nativeObj.Bitmap) const hasScreenCapture = typeof plus !== 'undefined' && plus.screen && typeof plus.screen.capture === 'function' const hasNativeObj = typeof plus !== 'undefined' && plus.nativeObj && plus.webview if (hasScreenCapture || hasNativeObj) { console.log('📹 启动实时屏幕捕获,间隔:', this.captureInterval, 'ms') if (hasScreenCapture) { console.log('📹 使用 plus.screen.capture 方式') } else if (hasNativeObj) { console.log('📹 使用 plus.nativeObj.Bitmap + webview.draw 方式') } // 延迟第一次截图,确保plus对象已初始化 setTimeout(() => { if (this.isStreaming && this.isConnected) { this.captureAndSend() } }, 2000) // 延迟2秒开始第一次截图,确保WebSocket连接稳定 this.captureTimer = setInterval(() => { if (this.isStreaming && this.isConnected) { this.captureAndSend() } }, this.captureInterval) } else { // 所有截图方式都不可用,只保持连接,不进行截图 console.warn('⚠️ 所有截图方式都不可用,将保持连接但不进行截图') console.warn('⚠️ 学生将显示为在线,但无法传输屏幕画面') console.warn('⚠️ 提示:可能需要使用原生插件或第三方SDK来实现屏幕截图') // 不启动定时器,但保持 WebSocket 连接,让学生显示为在线 } // #endif // #ifndef APP-PLUS console.log('📹 启动实时屏幕捕获,间隔:', this.captureInterval, 'ms') this.captureAndSend() this.captureTimer = setInterval(() => { if (this.isStreaming && this.isConnected) { this.captureAndSend() } }, this.captureInterval) // #endif } /** * 停止屏幕捕获 */ stopScreenCapture() { if (this.captureTimer) { clearInterval(this.captureTimer) this.captureTimer = null console.log('🛑 停止屏幕捕获') } } /** * 准备截图功能(确保plus对象已初始化) */ async prepareCapture() { // #ifdef APP-PLUS // 等待plus对象初始化(如果需要) if (typeof plus === 'undefined') { console.log('⏳ 等待plus对象初始化...') try { await this.waitForPlusReady(5000) // 最多等待5秒 } catch (error) { console.warn('⚠️ plus对象初始化超时,但继续尝试截图') } } // #endif } /** * 捕获并发送一次屏幕截图(按需截图) */ async captureAndSendOnce() { // #ifdef APP-PLUS // 检查是否有可用的截图方式 const hasScreenCapture = typeof plus !== 'undefined' && plus.screen && typeof plus.screen.capture === 'function' const hasNativeObj = typeof plus !== 'undefined' && plus.nativeObj && plus.webview // 如果没有任何截图方式可用,直接返回 if (!hasScreenCapture && !hasNativeObj) { console.warn('⚠️ 截图功能不可用,无法发送截图') console.warn('⚠️ 提示:请检查manifest.json中的CAPTURE_SCREEN权限配置') return } // #endif try { const base64Data = await this.captureScreenshot() if (!base64Data) { return } const size = this.getBase64Size(base64Data) const sizeKB = size / 1024 // 如果超过最大大小,给出警告(但不阻止发送,既然后端缓冲区已足够大) if (size > this.maxSize) { console.warn('⚠️ 截图过大 (', sizeKB.toFixed(2), 'KB),超过建议限制 (', (this.maxSize / 1024).toFixed(2), 'KB)') console.warn('ℹ️ 后端缓冲区已设置为1MB,将继续发送') // 不再跳过发送,既然后端缓冲区已足够大 } // 如果超过50KB,给出警告(但仍然发送) if (sizeKB > 50) { console.warn('⚠️ 截图较大 (', sizeKB.toFixed(2), 'KB),建议优化压缩') } this.sendMessage({ type: 'screen_frame', data: base64Data, timestamp: Date.now() }) console.log('✅ 截图已发送,大小:', sizeKB.toFixed(2), 'KB') } catch (error) { console.error('❌ 截图发送失败:', error) } } /** * 捕获并发送屏幕截图(持续截图模式,已废弃,改用按需截图) */ async captureAndSend() { // #ifdef APP-PLUS // 检查是否有可用的截图方式 const hasScreenCapture = typeof plus !== 'undefined' && plus.screen && typeof plus.screen.capture === 'function' const hasNativeObj = typeof plus !== 'undefined' && plus.nativeObj && plus.webview // 如果没有任何截图方式可用,直接返回 if (!hasScreenCapture && !hasNativeObj) { // 不输出错误,因为已经在 startScreenCapture 中提示过了 return } // #endif try { const base64Data = await this.captureScreenshot() if (!base64Data) { return } const size = this.getBase64Size(base64Data) const sizeKB = size / 1024 // 如果超过最大大小,给出警告(但不阻止发送,既然后端缓冲区已足够大) if (size > this.maxSize) { console.warn('⚠️ 截图过大 (', sizeKB.toFixed(2), 'KB),超过建议限制 (', (this.maxSize / 1024).toFixed(2), 'KB)') console.warn('ℹ️ 后端缓冲区已设置为1MB,将继续发送') // 不再跳过发送,既然后端缓冲区已足够大 } // 如果超过50KB,给出警告(但仍然发送) if (sizeKB > 50) { console.warn('⚠️ 截图较大 (', sizeKB.toFixed(2), 'KB),建议优化压缩') } this.sendMessage({ type: 'screen_frame', data: base64Data, timestamp: Date.now() }) } catch (error) { // 如果是截图功能不可用错误,减少日志输出频率 if (error.message && (error.message.includes('plus 对象') || error.message.includes('plus.screen.capture') || error.message.includes('不支持屏幕截图'))) { // 只在第一次失败时输出详细日志 if (!this._plusErrorLogged) { console.error('❌ 截图失败:', error.message) console.error('❌ 提示:当前设备可能不支持屏幕截图功能') console.error('❌ 学生将显示为在线,但无法传输屏幕画面') this._plusErrorLogged = true } } else { console.error('❌ 截图发送失败:', error) } } } /** * 捕获屏幕截图 * 尝试多种方式:plus.screen.capture -> plus.webview.capture -> Canvas */ captureScreenshot() { return new Promise((resolve, reject) => { // #ifdef APP-PLUS // 等待 plus 对象初始化(最多等待15秒) this.waitForPlusReady(15000).then(() => { try { console.log('📸 开始截图...') // 方案1:尝试使用 plus.screen.capture(全屏截图) if (plus && plus.screen && typeof plus.screen.capture === 'function') { console.log('📸 使用 plus.screen.capture 截图...') plus.screen.capture((bitmap) => { console.log('📸 截图回调,bitmap:', !!bitmap) if (!bitmap) { console.warn('⚠️ plus.screen.capture 返回空,尝试其他方式...') this.tryAlternativeCapture(resolve, reject) return } console.log('📸 开始转换bitmap为Base64...') this.bitmapToBase64(bitmap, (base64) => { if (base64) { console.log('✅ 截图成功(plus.screen.capture),Base64长度:', base64.length) resolve(base64) } else { console.error('❌ 截图转换失败') reject(new Error('截图转换失败')) } }) }, (error) => { console.warn('⚠️ plus.screen.capture 失败,尝试其他方式:', error) this.tryAlternativeCapture(resolve, reject) }) return } // 如果 plus.screen.capture 不可用,尝试其他方式 console.log('📸 plus.screen.capture 不可用,尝试其他截图方式...') this.tryAlternativeCapture(resolve, reject) } catch (error) { console.error('❌ 截图异常:', error) this.tryAlternativeCapture(resolve, reject) } }).catch((error) => { console.error('❌ 等待 plus 对象初始化超时:', error) reject(new Error('plus 对象未初始化')) }) // #endif // #ifndef APP-PLUS reject(new Error('当前环境不支持截图')) // #endif }) } /** * 尝试其他截图方式(备用方案) */ tryAlternativeCapture(resolve, reject) { // #ifdef APP-PLUS try { // 方案2:使用 plus.nativeObj.Bitmap + webview.draw 截图 if (plus && plus.webview && plus.nativeObj) { console.log('📸 尝试使用 plus.nativeObj.Bitmap + webview.draw 截图...') try { // 获取当前webview const currentWebview = plus.webview.currentWebview() if (currentWebview) { // 获取系统信息,确定截图尺寸 const systemInfo = uni.getSystemInfoSync() const originalWidth = systemInfo.windowWidth const originalHeight = systemInfo.windowHeight // 由于质量参数不起作用,我们需要通过降低分辨率来减小文件大小 // 尝试不同的缩放比例,找到合适的大小(目标:5-8KB) // 从很小的尺寸开始(20%),逐步增大直到找到合适的大小 console.log('📸 原始尺寸:', originalWidth, 'x', originalHeight) // 创建Bitmap对象 const bitmapId = 'screenshot-bitmap-' + Date.now() const bitmap = new plus.nativeObj.Bitmap(bitmapId) // 使用webview的draw方法将内容绘制到Bitmap(原始尺寸) console.log('📸 调用 webview.draw 绘制到Bitmap...') currentWebview.draw(bitmap, () => { console.log('📸 webview.draw 成功,开始转换为Base64...') try { // 方法1:尝试直接使用 bitmap.toBase64Data(如果可用) if (typeof bitmap.toBase64Data === 'function') { console.log('📸 尝试使用 bitmap.toBase64Data 直接转换...') try { // 尝试使用JPEG格式(更小)和质量压缩 // 从20%质量开始,逐步提高(如果太小) let quality = 0.2 // 20%质量,确保文件足够小 let base64 = null let sizeKB = 0 // 尝试不同质量,找到合适的大小(目标:5-8KB) // 从很低质量开始(5%),逐步提高,确保文件足够小 for (let q = 0.05; q <= 0.5 && (!base64 || sizeKB > 8); q += 0.05) { try { const testBase64 = bitmap.toBase64Data('jpg', q) const testSizeKB = testBase64.length / 1024 console.log(`📸 尝试JPEG格式(${(q * 100).toFixed(0)}%质量),大小:`, testSizeKB.toFixed(2), 'KB') if (testSizeKB <= 8) { base64 = testBase64 sizeKB = testSizeKB quality = q // 如果找到了合适的大小,尝试稍微提高质量(如果可能) if (q < 0.3) { try { const betterBase64 = bitmap.toBase64Data('jpg', q + 0.1) const betterSizeKB = betterBase64.length / 1024 if (betterSizeKB <= 8) { base64 = betterBase64 sizeKB = betterSizeKB quality = q + 0.1 console.log(`📸 提高质量到 ${((q + 0.1) * 100).toFixed(0)}%,大小:`, betterSizeKB.toFixed(2), 'KB') } } catch (e) { // 忽略提高质量失败 } } break } else if (!base64 || testSizeKB < sizeKB) { base64 = testBase64 sizeKB = testSizeKB quality = q } } catch (e) { console.warn(`⚠️ JPEG格式(${(q * 100).toFixed(0)}%质量)失败:`, e) if (q === 0.05) { // 如果最低质量也失败,尝试PNG(但PNG通常更大) try { base64 = bitmap.toBase64Data('png') sizeKB = base64.length / 1024 console.log('📸 使用PNG格式,大小:', sizeKB.toFixed(2), 'KB') // PNG 通常比 JPEG 大,如果超过限制,尝试更低的 JPEG 质量 if (sizeKB > 8) { console.warn('⚠️ PNG格式也过大,尝试使用最低质量JPEG') try { // 尝试使用 1% 质量(如果支持) const minQualityBase64 = bitmap.toBase64Data('jpg', 0.01) const minQualitySizeKB = minQualityBase64.length / 1024 if (minQualitySizeKB < sizeKB && minQualitySizeKB <= 8) { base64 = minQualityBase64 sizeKB = minQualitySizeKB quality = 0.01 console.log('📸 使用最低质量JPEG(1%),大小:', minQualitySizeKB.toFixed(2), 'KB') } } catch (e3) { console.warn('⚠️ 最低质量JPEG也失败:', e3) } } } catch (e2) { console.error('❌ PNG格式也失败:', e2) throw e2 } } } } if (base64) { console.log('✅ 截图成功(bitmap.toBase64Data),Base64长度:', base64.length, 'bytes (', sizeKB.toFixed(2), 'KB),质量:', (quality * 100).toFixed(0) + '%') // 如果仍然超过8KB,尝试使用Canvas API缩放图片(仅H5环境) // App环境:既然后端缓冲区已设置为1MB,20KB的文件应该可以传输 if (sizeKB > 8) { // #ifdef H5 console.warn('⚠️ 图片仍然过大(', sizeKB.toFixed(2), 'KB),尝试使用Canvas API缩放...') // 使用Promise方式处理异步缩放 this.scaleImageWithCanvas(base64, 0.3).then((scaledBase64) => { if (scaledBase64) { const scaledSizeKB = scaledBase64.length / 1024 console.log('📸 Canvas缩放后大小:', scaledSizeKB.toFixed(2), 'KB') if (scaledSizeKB <= 8) { base64 = scaledBase64 sizeKB = scaledSizeKB console.log('✅ 使用Canvas缩放后的图片,大小:', sizeKB.toFixed(2), 'KB') } else { console.warn('⚠️ Canvas缩放后仍然过大(', scaledSizeKB.toFixed(2), 'KB),使用原图') } } // 继续处理(无论缩放成功与否) try { bitmap.clear() } catch (e) { console.warn('⚠️ 清理Bitmap失败:', e) } resolve(base64) }).catch((canvasError) => { console.warn('⚠️ Canvas缩放失败,使用原图:', canvasError) // 缩放失败,使用原图 try { bitmap.clear() } catch (e) { console.warn('⚠️ 清理Bitmap失败:', e) } resolve(base64) }) return // 提前返回,等待Canvas缩放完成 // #endif // #ifdef APP-PLUS // App环境:既然后端缓冲区已设置为1MB,暂时接受较大的文件 console.warn('⚠️ 图片较大(', sizeKB.toFixed(2), 'KB),超过8KB限制') console.warn('ℹ️ 后端缓冲区已设置为1MB,可以传输此文件') // 继续使用原图 // #endif } try { bitmap.clear() } catch (e) { console.warn('⚠️ 清理Bitmap失败:', e) } resolve(base64) return } } catch (e) { console.warn('⚠️ bitmap.toBase64Data 失败,尝试其他方式:', e) } } // 方法2:尝试使用 bitmap.toDataURL(如果可用) if (typeof bitmap.toDataURL === 'function') { console.log('📸 尝试使用 bitmap.toDataURL 直接转换...') try { const dataUrl = bitmap.toDataURL('image/png') if (dataUrl) { // 提取Base64数据(去掉data:image/png;base64,前缀) const base64 = dataUrl.split(',')[1] || dataUrl console.log('✅ 截图成功(bitmap.toDataURL),Base64长度:', base64.length) try { bitmap.clear() } catch (e) { console.warn('⚠️ 清理Bitmap失败:', e) } resolve(base64) return } } catch (e) { console.warn('⚠️ bitmap.toDataURL 失败,尝试保存文件方式:', e) } } // 方法3:使用保存文件的方式(备用,但已知有问题) console.log('📸 使用保存文件方式转换(注意:此方法可能不工作)...') this.bitmapToBase64(bitmap, (base64) => { if (base64) { console.log('✅ 截图成功(webview.draw + Bitmap),Base64长度:', base64.length) // 清理Bitmap对象 try { bitmap.clear() } catch (e) { console.warn('⚠️ 清理Bitmap失败:', e) } resolve(base64) } else { console.warn('⚠️ Bitmap转换Base64失败,返回空') try { bitmap.clear() } catch (e) { console.warn('⚠️ 清理Bitmap失败:', e) } this.tryNativeScreenshot(resolve, reject) } }, (error) => { console.error('❌ Bitmap转换Base64出错:', error) try { bitmap.clear() } catch (e) { console.warn('⚠️ 清理Bitmap失败:', e) } this.tryNativeScreenshot(resolve, reject) }) } catch (error) { console.error('❌ bitmapToBase64调用异常:', error) try { bitmap.clear() } catch (e) { console.warn('⚠️ 清理Bitmap失败:', e) } this.tryNativeScreenshot(resolve, reject) } }, (error) => { console.warn('⚠️ webview.draw 失败:', error) try { bitmap.clear() } catch (e) { console.warn('⚠️ 清理Bitmap失败:', e) } this.tryNativeScreenshot(resolve, reject) }) return } } catch (error) { console.warn('⚠️ Bitmap方式失败,尝试其他方式:', error) } } // 方案3:尝试其他原生截图方式 console.log('📸 尝试其他原生截图方式...') this.tryNativeScreenshot(resolve, reject) } catch (error) { console.error('❌ 所有截图方式都失败:', error) reject(new Error('所有截图方式都不可用: ' + error.message)) } // #endif // #ifndef APP-PLUS reject(new Error('非App环境,无法使用备用截图方式')) // #endif } /** * 使用Canvas API缩放图片 * @param {String} base64Data Base64图片数据 * @param {Number} scale 缩放比例(0-1) * @returns {Promise} 缩放后的Base64数据 */ async scaleImageWithCanvas(base64Data, scale = 0.3) { return new Promise((resolve, reject) => { try { // #ifdef H5 // H5环境:使用Canvas API const img = new Image() img.onload = () => { try { const canvas = document.createElement('canvas') canvas.width = Math.floor(img.width * scale) canvas.height = Math.floor(img.height * scale) const ctx = canvas.getContext('2d') ctx.drawImage(img, 0, 0, canvas.width, canvas.height) // 转换为JPEG格式,使用较低质量 const scaledBase64 = canvas.toDataURL('image/jpeg', 0.5).split(',')[1] resolve(scaledBase64) } catch (error) { reject(error) } } img.onerror = (error) => { reject(new Error('图片加载失败')) } // 确保base64数据格式正确 const dataUrl = base64Data.startsWith('data:') ? base64Data : `data:image/jpeg;base64,${base64Data}` img.src = dataUrl // #endif // #ifdef APP-PLUS // App环境:Canvas API可能不可用,直接返回原图 // 既然后端缓冲区已经设置为1MB,20KB的文件应该可以传输 console.warn('⚠️ App环境不支持Canvas API,使用原图(后端缓冲区已设置为1MB)') resolve(base64Data) // #endif // #ifndef H5 || APP-PLUS // 其他环境:直接返回原图 resolve(base64Data) // #endif } catch (error) { reject(error) } }) } /** * 尝试其他原生截图方式 */ tryNativeScreenshot(resolve, reject) { // #ifdef APP-PLUS try { // 检查是否有其他可用的截图方法 console.log('📸 检查可用的plus对象方法...') console.log('📸 plus.webview:', !!plus?.webview) console.log('📸 plus.nativeObj:', !!plus?.nativeObj) console.log('📸 plus.navigator:', !!plus?.navigator) // 如果所有方式都不可用,给出明确的错误提示 console.error('❌ 所有截图方式都不可用') console.error('❌ 建议解决方案:') console.error(' 1. 检查manifest.json中的权限配置(CAPTURE_SCREEN)') console.error(' 2. 确认设备系统版本是否支持(Android 5.0+, iOS 12.0+)') console.error(' 3. 使用原生插件实现截图功能') console.error(' 4. 使用第三方SDK(如腾讯云TRTC、ZEGO即构科技)') reject(new Error('当前设备不支持屏幕截图功能。\n建议:\n1) 检查权限配置\n2) 使用原生插件\n3) 使用第三方SDK(腾讯云TRTC、ZEGO)')) } catch (error) { console.error('❌ 原生截图失败:', error) reject(error) } // #endif // #ifndef APP-PLUS reject(new Error('非App环境')) // #endif } /** * 等待 plus 对象初始化 */ waitForPlusReady(maxWaitTime = 15000) { return new Promise((resolve, reject) => { // #ifdef APP-PLUS // 诊断信息 console.log('🔍 开始检查 plus 对象...') console.log('🔍 typeof plus:', typeof plus) console.log('🔍 plus 是否存在:', typeof plus !== 'undefined') // 如果 plus 已经可用,检查 capture 方法 if (typeof plus !== 'undefined') { console.log('🔍 plus 对象存在') console.log('🔍 plus.screen 是否存在:', typeof plus.screen !== 'undefined') if (plus.screen) { console.log('🔍 plus.screen.capture 类型:', typeof plus.screen.capture) if (typeof plus.screen.capture === 'function') { console.log('✅ plus 对象已就绪,截图功能可用') resolve() return } else { console.warn('⚠️ plus.screen.capture 不是函数(API不可用)') console.warn('⚠️ 将保持连接但不进行截图') // 即使 capture 不可用,也resolve,让连接继续 // 这样学生至少能显示为在线 resolve() return } } else { console.warn('⚠️ plus.screen 不存在') } } else { console.warn('⚠️ plus 对象未定义') } // 监听 plusready 事件(如果可用) let plusReadyHandler = null // #ifdef H5 if (typeof document !== 'undefined') { plusReadyHandler = () => { console.log('📢 收到 plusready 事件') if (typeof plus !== 'undefined' && plus.screen && typeof plus.screen.capture === 'function') { document.removeEventListener('plusready', plusReadyHandler) clearTimeout(timeoutId) clearInterval(checkInterval) console.log('✅ plus 对象已就绪(通过事件)') resolve() } } document.addEventListener('plusready', plusReadyHandler) console.log('📢 已监听 plusready 事件') } // #endif // 使用轮询方式检查 const startTime = Date.now() let checkCount = 0 const checkInterval = setInterval(() => { checkCount++ const elapsed = Date.now() - startTime // 每2秒输出一次诊断信息 if (checkCount % 20 === 0) { console.log(`🔍 检查中... (${Math.floor(elapsed/1000)}秒)`) console.log('🔍 typeof plus:', typeof plus) if (typeof plus !== 'undefined') { console.log('🔍 plus.screen:', typeof plus.screen) if (plus.screen) { console.log('🔍 plus.screen.capture:', typeof plus.screen.capture) } } } // 检查 plus 对象和 capture 方法 if (typeof plus !== 'undefined' && plus.screen) { if (typeof plus.screen.capture === 'function') { // #ifdef H5 if (typeof document !== 'undefined' && plusReadyHandler) { document.removeEventListener('plusready', plusReadyHandler) } // #endif clearInterval(checkInterval) clearTimeout(timeoutId) console.log('✅ plus 对象已就绪,截图功能可用(通过轮询)') resolve() } else if (elapsed > 3000) { // 如果3秒后 plus 对象存在但 capture 不可用,也resolve(让学生显示为在线) // #ifdef H5 if (typeof document !== 'undefined' && plusReadyHandler) { document.removeEventListener('plusready', plusReadyHandler) } // #endif clearInterval(checkInterval) clearTimeout(timeoutId) console.warn('⚠️ plus 对象存在,但 plus.screen.capture 不可用(通过轮询)') console.warn('⚠️ 将保持连接但不进行截图') resolve() // 仍然resolve,让连接继续 } } if (elapsed > maxWaitTime) { // #ifdef H5 if (typeof document !== 'undefined' && plusReadyHandler) { document.removeEventListener('plusready', plusReadyHandler) } // #endif clearInterval(checkInterval) clearTimeout(timeoutId) console.error('❌ plus 对象初始化超时') console.error('❌ 最终状态 - typeof plus:', typeof plus) if (typeof plus !== 'undefined') { console.error('❌ plus.screen:', typeof plus.screen) if (plus.screen) { console.error('❌ plus.screen.capture:', typeof plus.screen.capture) // 如果 plus 和 plus.screen 都存在,即使 capture 不可用,也resolve console.warn('⚠️ 将保持连接但不进行截图') resolve() // 仍然resolve,让连接继续 return } } reject(new Error('plus 对象初始化超时')) } }, 100) // 超时处理 const timeoutId = setTimeout(() => { // #ifdef H5 if (typeof document !== 'undefined' && plusReadyHandler) { document.removeEventListener('plusready', plusReadyHandler) } // #endif clearInterval(checkInterval) // 如果 plus 对象存在但 capture 不可用,也resolve if (typeof plus !== 'undefined' && plus.screen) { console.warn('⚠️ plus 对象存在,但 plus.screen.capture 不可用(超时)') console.warn('⚠️ 将保持连接但不进行截图') resolve() // 仍然resolve,让连接继续 } else { console.error('❌ plus 对象初始化超时(定时器)') reject(new Error('plus 对象初始化超时')) } }, maxWaitTime) // #endif // #ifndef APP-PLUS reject(new Error('非 App 环境')) // #endif }) } /** * 将 bitmap 转换为 Base64 */ bitmapToBase64(bitmap, callback, errorCallback) { // #ifdef APP-PLUS try { console.log('📸 开始将Bitmap转换为Base64...') const timestamp = Date.now() const tempPath = `_doc/screenshot_${timestamp}.png` console.log('📸 保存Bitmap到临时文件:', tempPath) // 注意:bitmap.save 的回调参数可能是 (success) 或 (error) // 根据HTML5Plus文档,成功时调用第一个回调,失败时调用第二个回调 // 添加超时处理,防止回调永远不触发 let saveTimeout = setTimeout(() => { console.error('❌ Bitmap保存超时(5秒),可能保存失败或回调未触发') if (errorCallback) { errorCallback(new Error('Bitmap保存超时')) } else { callback(null) } }, 5000) bitmap.save(tempPath, () => { // 保存成功回调 clearTimeout(saveTimeout) console.log('📸 Bitmap保存成功,开始读取文件...') // 添加延迟,确保文件已完全写入 setTimeout(() => { plus.io.resolveLocalFileSystemURL(tempPath, (entry) => { console.log('📸 文件系统解析成功,开始读取文件内容...') entry.file((file) => { console.log('📸 文件对象获取成功,文件大小:', file.size, 'bytes') const reader = new plus.io.FileReader() reader.onloadend = (e) => { console.log('📸 文件读取完成,开始转换为Base64...') try { const result = e.target.result if (!result) { console.error('❌ 文件读取结果为空') if (errorCallback) { errorCallback(new Error('文件读取结果为空')) } else { callback(null) } return } // 提取Base64数据(去掉data:image/png;base64,前缀) const base64 = result.split(',')[1] || result console.log('✅ Base64转换成功,长度:', base64.length) callback(base64) // 清理临时文件 entry.remove(() => { console.log('📸 临时文件已删除') }, (err) => { console.warn('⚠️ 删除临时文件失败:', err) }) } catch (error) { console.error('❌ Base64转换异常:', error) if (errorCallback) { errorCallback(error) } else { callback(null) } } } reader.onerror = (error) => { console.error('❌ 文件读取失败:', error) if (errorCallback) { errorCallback(error) } else { callback(null) } } console.log('📸 开始读取文件为DataURL...') reader.readAsDataURL(file) }, (error) => { console.error('❌ 获取文件对象失败:', error) if (errorCallback) { errorCallback(error) } else { callback(null) } }) }, (error) => { console.error('❌ 解析文件路径失败:', error) if (errorCallback) { errorCallback(error) } else { callback(null) } }) }, 100) // 延迟100ms确保文件已写入 }, (error) => { // 保存失败回调 clearTimeout(saveTimeout) console.error('❌ 保存Bitmap失败:', error) if (errorCallback) { errorCallback(error) } else { callback(null) } }) } catch (error) { console.error('bitmap 转换失败:', error) callback(null) } // #endif // #ifndef APP-PLUS callback(null) // #endif } /** * 计算 Base64 大小(字节) */ getBase64Size(base64) { return Math.ceil(base64.length * 3 / 4) } /** * 发送消息 */ sendMessage(message) { if (!this.ws || !this.isConnected) { console.warn('WebSocket 未连接,无法发送消息') return } try { // #ifdef APP-PLUS this.ws.send({ data: JSON.stringify(message), success: () => { // 消息发送成功 }, fail: (err) => { console.error('发送消息失败:', err) } }) // #endif // #ifdef H5 if (this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify(message)) } else { console.warn('WebSocket 未连接,无法发送消息') } // #endif } catch (error) { console.error('发送消息异常:', error) } } /** * 重连 WebSocket */ reconnect() { if (this.reconnectTimer) { clearTimeout(this.reconnectTimer) } if (this.reconnectAttempts < this.maxReconnectAttempts && this.isStreaming) { this.reconnectAttempts++ const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000) // 指数退避,最大30秒 console.log(`尝试重连 WebSocket (${this.reconnectAttempts}/${this.maxReconnectAttempts}),延迟 ${delay / 1000} 秒...`) this.reconnectTimer = setTimeout(() => { this.connectWebSocket().catch(err => { console.error('重连失败:', err) }) }, delay) } else if (this.reconnectAttempts >= this.maxReconnectAttempts) { console.error('达到最大重连次数,停止重连。') uni.showToast({ title: '实时监控连接失败,请检查网络或联系管理员', icon: 'none', duration: 3000 }) } } } const screenStreamClient = new ScreenStreamClient() export default screenStreamClient