1199 lines
45 KiB
JavaScript
1199 lines
45 KiB
JavaScript
/**
|
||
* 屏幕流监控客户端(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<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可能不可用,直接返回原图
|
||
// 既然后端缓冲区已经设置为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
|
||
|