guoyu/fronted_uniapp/utils/screenStream.js

1199 lines
45 KiB
JavaScript
Raw Permalink Normal View History

2025-12-03 18:58:36 +08:00
/**
* 屏幕流监控客户端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