guoyu/fronted_uniapp/utils/screenStream.js
2025-12-03 18:58:36 +08:00

1199 lines
45 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 屏幕流监控客户端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 // 默认1000ms1帧/秒),降低频率减少数据量
// 最大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.captureBase64长度:', 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('📸 使用最低质量JPEG1%),大小:', minQualitySizeKB.toFixed(2), 'KB')
}
} catch (e3) {
console.warn('⚠️ 最低质量JPEG也失败:', e3)
}
}
} catch (e2) {
console.error('❌ PNG格式也失败:', e2)
throw e2
}
}
}
}
if (base64) {
console.log('✅ 截图成功bitmap.toBase64DataBase64长度:', base64.length, 'bytes (', sizeKB.toFixed(2), 'KB),质量:', (quality * 100).toFixed(0) + '%')
// 如果仍然超过8KB尝试使用Canvas API缩放图片仅H5环境
// App环境既然后端缓冲区已设置为1MB20KB的文件应该可以传输
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.toDataURLBase64长度:', 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 + BitmapBase64长度:', 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<String>} 缩放后的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可能不可用直接返回原图
// 既然后端缓冲区已经设置为1MB20KB的文件应该可以传输
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