fix: 修复课堂监控无法截屏问题

This commit is contained in:
shawko 2026-05-11 18:12:35 +08:00
parent 9c91889597
commit a6fde98a35
5 changed files with 285 additions and 50 deletions

BIN
.pnpm-store/v11/index.db Normal file

Binary file not shown.

View File

@ -2,7 +2,7 @@ import html2canvas from 'html2canvas'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import globalApi from '@/api/global' import globalApi from '@/api/global'
import { userStore } from '@/stores' import { userStore } from '@/stores'
import { $XXL, sys_type, sys_type_study_machine } from '@/utils' import { $XXL, getNativeApi } from '@/utils'
import sass from '@/utils/sass' import sass from '@/utils/sass'
import { useTips } from './useTips' import { useTips } from './useTips'
@ -13,20 +13,78 @@ let monitorState: 0 | 1 = 0
const MONITOR_SNAPSHOT_INTERVAL = 10000 const MONITOR_SNAPSHOT_INTERVAL = 10000
const MONITOR_MSG_TYPE = 'monitorStart' const MONITOR_MSG_TYPE = 'monitorStart'
const MONITOR_STOP_MSG_TYPE = 'monitorStop'
const MONITOR_LOG_PREFIX = '[class-monitor]'
const monitorLog = (...args: unknown[]) => console.log(MONITOR_LOG_PREFIX, ...args)
const monitorWarn = (...args: unknown[]) => console.warn(MONITOR_LOG_PREFIX, ...args)
const monitorError = (...args: unknown[]) => console.error(MONITOR_LOG_PREFIX, ...args)
const getServerOrigin = () => {
try {
return new URL(import.meta.env.VITE_SERVER_URL).origin
} catch {
return ''
}
}
const isLocalFilePath = (path: string) => {
return /^(file:\/\/|\/storage\/|\/sdcard\/|content:\/\/)/i.test(path)
}
const normalizeUploadPath = (res: any) => { const normalizeUploadPath = (res: any) => {
return String( const rawPath = String(
res?.data?.filePath || res?.data?.url || res?.filePath || res?.url || res?.data?.path || '', res?.data?.url ||
res?.url ||
res?.data?.filePath ||
res?.filePath ||
res?.data?.path ||
res?.path ||
'',
) )
.replace(/[`]/g, '') .replace(/[`]/g, '')
.replace(/http:\/\//gi, 'https://') .replace(/http:\/\//gi, 'https://')
.trim() .trim()
if (!rawPath) return ''
if (isLocalFilePath(rawPath)) {
monitorWarn('upload result is local path, ignore it', rawPath)
return ''
}
if (/^https?:\/\//i.test(rawPath)) return rawPath
const origin = getServerOrigin()
if (origin && rawPath.startsWith('/')) return `${origin}${rawPath}`
return rawPath
}
const parseNativeScreenshotPath = (raw: unknown): string => {
if (typeof raw === 'string' && raw.trim()) return raw.trim()
if (!raw || typeof raw !== 'object') return ''
const r = raw as Record<string, unknown>
const directPath = r.filePath ?? r.path ?? r.localPath ?? r.url
if (typeof directPath === 'string' && directPath.trim()) return directPath.trim()
const d = r.data
if (typeof d === 'string' && d.trim()) return d.trim()
if (d && typeof d === 'object') {
const o = d as Record<string, unknown>
const p = o.filePath ?? o.path ?? o.localPath
if (typeof p === 'string' && p.trim()) return p.trim()
}
return ''
} }
const uploadNativeScreenshot = async () => { const uploadNativeScreenshot = async () => {
try {
const { token } = storeToRefs(userStore()) const { token } = storeToRefs(userStore())
const { data: filePath } = $XXL.getScreenshot() || {} const raw = $XXL.getScreenshot() || {}
if (!filePath) return '' monitorLog('native getScreenshot result', raw)
const filePath = parseNativeScreenshotPath(raw)
if (!filePath) {
monitorWarn('native screenshot path empty')
return ''
}
monitorLog('native screenshot path', filePath)
const res = await $XXL.uploadFile({ const res = await $XXL.uploadFile({
url: `${import.meta.env.VITE_SERVER_URL}/sysFileInfo/tenUploadAll`, url: `${import.meta.env.VITE_SERVER_URL}/sysFileInfo/tenUploadAll`,
@ -37,70 +95,220 @@ const uploadNativeScreenshot = async () => {
filePath, filePath,
fileKey: 'file', fileKey: 'file',
}) })
return normalizeUploadPath(res) const url = normalizeUploadPath(res)
monitorLog('native upload result', { raw: res, url })
return url
} catch (err) {
monitorWarn('native screenshot upload failed', err)
return ''
}
} }
const createWebScreenshotFile = async () => { /** App WebView 无原生截图时仅能用 DOM 截图;尽量贴合「当前视口」并降低高 DPR 白屏概率 */
const canvas = await html2canvas(document.body, { const getMonitorCaptureElement = (): HTMLElement => {
useCORS: true, const app = document.getElementById('app')
backgroundColor: null, if (app && (app.offsetWidth > 0 || app.offsetHeight > 0)) return app
x: window.scrollX, const scrollRoot = document.scrollingElement
y: window.scrollY, if (scrollRoot instanceof HTMLElement) return scrollRoot
width: window.innerWidth, return document.documentElement
height: window.innerHeight, }
windowWidth: window.innerWidth,
windowHeight: window.innerHeight,
scale: window.devicePixelRatio || 1,
})
return await new Promise<File | null>(resolve => { const prepareMonitorDomForCapture = async () => {
try {
await document.fonts?.ready
} catch {
//
}
await new Promise<void>(resolve => {
requestAnimationFrame(() => {
requestAnimationFrame(() => resolve())
})
})
}
const canvasToPngFile = (canvas: HTMLCanvasElement) =>
new Promise<File | null>(resolve => {
canvas.toBlob(blob => { canvas.toBlob(blob => {
if (!blob) return resolve(null) if (!blob) return resolve(null)
resolve(new File([blob], `monitor-${Date.now()}.png`, { type: 'image/png' })) resolve(new File([blob], `monitor-${Date.now()}.png`, { type: 'image/png' }))
}, 'image/png') }, 'image/png')
}) })
const createWebScreenshotFile = async (): Promise<File | null> => {
await prepareMonitorDomForCapture()
const vw = window.innerWidth
const vh = window.innerHeight
const scale = Math.min(window.devicePixelRatio || 1, 2)
monitorLog('web screenshot start', {
href: window.location.href,
viewport: `${vw}x${vh}`,
dpr: window.devicePixelRatio,
scale,
scrollX: window.scrollX,
scrollY: window.scrollY,
})
const base = {
useCORS: true,
allowTaint: false,
backgroundColor: '#ffffff' as const,
scale,
imageTimeout: 15000,
logging: import.meta.env.DEV,
}
const win = {
scrollX: -window.scrollX,
scrollY: -window.scrollY,
windowWidth: Math.max(document.documentElement.clientWidth, vw),
windowHeight: Math.max(document.documentElement.clientHeight, vh),
}
const attempts: Array<() => Promise<HTMLCanvasElement>> = [
// 视口截图常用写法(对 WebView 比单纯裁 x/y 更稳)
() =>
html2canvas(document.body, {
...base,
foreignObjectRendering: false,
...win,
}),
() =>
html2canvas(document.body, {
...base,
foreignObjectRendering: true,
...win,
}),
() => {
const el = getMonitorCaptureElement()
const sx = window.scrollX
const sy = window.scrollY
return html2canvas(el, {
...base,
foreignObjectRendering: false,
x: sx,
y: sy,
width: vw,
height: vh,
windowWidth: vw,
windowHeight: vh,
})
},
]
for (const [index, run] of attempts.entries()) {
try {
monitorLog('web screenshot attempt start', index + 1)
const canvas = await run()
monitorLog('web screenshot canvas', {
attempt: index + 1,
width: canvas.width,
height: canvas.height,
})
if (!canvas.width || !canvas.height) {
monitorWarn('web screenshot canvas empty', index + 1)
continue
}
const file = await canvasToPngFile(canvas)
if (file) {
monitorLog('web screenshot file ready', {
attempt: index + 1,
name: file.name,
size: file.size,
type: file.type,
})
return file
}
monitorWarn('web screenshot toBlob returned empty', index + 1)
} catch (err) {
monitorWarn('web screenshot attempt failed', index + 1, err)
}
}
monitorWarn('web screenshot all attempts failed')
return null
} }
const uploadWebScreenshot = async () => { const uploadWebScreenshot = async () => {
monitorLog('web upload start')
const file = await createWebScreenshotFile() const file = await createWebScreenshotFile()
if (!file) return '' if (!file) {
monitorWarn('web upload skipped because screenshot file is empty')
return ''
}
const formData = new FormData() const formData = new FormData()
formData.append('file', file) formData.append('file', file)
const { data: res } = await globalApi.uploadFile(formData) const { data: res } = await globalApi.uploadFile(formData)
return normalizeUploadPath(res) const url = normalizeUploadPath(res)
monitorLog('web upload result', { raw: res, url })
return url
} }
const uploadMonitorScreenshot = async () => { const uploadMonitorScreenshot = async () => {
if (sys_type === sys_type_study_machine) { // $xxl学习机与 $xxl_universal通用 App都可能提供 getScreenshot不能只用 sys_type 区分
return await uploadNativeScreenshot() const nativeApi = getNativeApi()
monitorLog('choose screenshot mode', {
hasNativeApi: !!nativeApi,
hasGetScreenshot: typeof nativeApi?.getScreenshot === 'function',
hasUploadFile: typeof nativeApi?.uploadFile === 'function',
})
if (nativeApi && typeof nativeApi.getScreenshot === 'function') {
const url = await uploadNativeScreenshot()
if (url) return url
monitorWarn('native screenshot empty, fallback to web screenshot')
} }
return await uploadWebScreenshot() return await uploadWebScreenshot()
} }
const reportMonitorSnapshot = async (sessionId: string) => { const reportMonitorSnapshot = async (sessionId: string) => {
const { userInfo } = storeToRefs(userStore()) const { userInfo } = storeToRefs(userStore())
if (isUploadingMonitorSnapshot || monitorSessionId !== sessionId) return if (isUploadingMonitorSnapshot || monitorSessionId !== sessionId) {
monitorLog('snapshot skipped', {
isUploadingMonitorSnapshot,
monitorSessionId,
sessionId,
})
return
}
isUploadingMonitorSnapshot = true isUploadingMonitorSnapshot = true
monitorLog('snapshot report start', { sessionId, userId: userInfo.value.userId })
try { try {
const url = await uploadMonitorScreenshot() const url = await uploadMonitorScreenshot()
if (!url || monitorSessionId !== sessionId) return if (!url || monitorSessionId !== sessionId) {
monitorWarn('snapshot report skipped after screenshot', {
url,
monitorSessionId,
sessionId,
})
return
}
monitorLog('snapshot final image url', url)
await globalApi.reportSnapshot({ await globalApi.reportSnapshot({
sessionId, sessionId,
url, url,
userId: userInfo.value.userId, userId: userInfo.value.userId,
}) })
monitorLog('snapshot report success', { sessionId, url })
} catch (err) { } catch (err) {
console.log(err) monitorError('snapshot report failed', err)
} finally { } finally {
isUploadingMonitorSnapshot = false isUploadingMonitorSnapshot = false
monitorLog('snapshot report end', { sessionId })
} }
} }
/** 与老师讲解 WS 等场景兼容:外层 type 可能是 push/classMessage监控类型在 msg.type */
export const resolveMonitorWsType = (data: anyObj): string | undefined => {
const nested = data?.msg?.type
if (nested === MONITOR_STOP_MSG_TYPE || nested === MONITOR_MSG_TYPE) return nested
const top = data?.type
if (top === MONITOR_STOP_MSG_TYPE || top === MONITOR_MSG_TYPE) return top
return undefined
}
export const stopClassMonitor = (showTip = false) => { export const stopClassMonitor = (showTip = false) => {
const isMonitoring = !!monitorTimer || !!monitorSessionId || monitorState === 1 const isMonitoring = !!monitorTimer || !!monitorSessionId || monitorState === 1
monitorLog('stop monitor', { showTip, isMonitoring, monitorSessionId, monitorState })
if (monitorTimer) { if (monitorTimer) {
clearInterval(monitorTimer) clearInterval(monitorTimer)
monitorTimer = null monitorTimer = null
@ -117,8 +325,15 @@ export const stopClassMonitor = (showTip = false) => {
const startClassMonitor = (msg: anyObj) => { const startClassMonitor = (msg: anyObj) => {
const sessionId = msg?.sessionId const sessionId = msg?.sessionId
if (!sessionId) return monitorLog('start monitor message', msg)
if (monitorTimer && monitorSessionId === sessionId) return if (!sessionId) {
monitorWarn('start monitor ignored because sessionId is empty')
return
}
if (monitorTimer && monitorSessionId === sessionId) {
monitorLog('start monitor ignored because session is already running', sessionId)
return
}
stopClassMonitor() stopClassMonitor()
monitorSessionId = sessionId monitorSessionId = sessionId
@ -132,10 +347,18 @@ const startClassMonitor = (msg: anyObj) => {
} }
export const handleClassMonitorMessage = (data: anyObj) => { export const handleClassMonitorMessage = (data: anyObj) => {
const messageType = data?.type || data?.msg?.type const monitorType = resolveMonitorWsType(data)
if (messageType !== MONITOR_MSG_TYPE) return false monitorLog('handle monitor ws message', { monitorType, data })
if (monitorType === MONITOR_STOP_MSG_TYPE) {
stopClassMonitor(true)
return true
}
if (monitorType !== MONITOR_MSG_TYPE) return false
const msg = data?.msg || data const msg: anyObj = {
...(data && typeof data === 'object' ? data : {}),
...(data?.msg && typeof data.msg === 'object' && !Array.isArray(data.msg) ? data.msg : {}),
}
const monitoring = msg?.monitoring const monitoring = msg?.monitoring
if (monitoring === true || monitoring === 'true') { if (monitoring === true || monitoring === 'true') {
startClassMonitor(msg) startClassMonitor(msg)

View File

@ -5,7 +5,11 @@ import { getSN, $XXL, isNotStudyMachine } from '@/utils'
import { useLog, LogType, useTips } from '@/hooks' import { useLog, LogType, useTips } from '@/hooks'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { handleClassMonitorMessage, stopClassMonitor } from './useClassMonitor' import {
handleClassMonitorMessage,
resolveMonitorWsType,
stopClassMonitor,
} from './useClassMonitor'
let SOCKET: UseWebSocketReturn<any> let SOCKET: UseWebSocketReturn<any>
@ -19,6 +23,7 @@ enum SOCKET_MSG_TYPE {
systemMessage = 'systemMessage', systemMessage = 'systemMessage',
messageReceive = 'messageReceive', messageReceive = 'messageReceive',
monitorStart = 'monitorStart', monitorStart = 'monitorStart',
monitorStop = 'monitorStop',
} }
const inspectorEnd = ref<anyObj>() const inspectorEnd = ref<anyObj>()
@ -101,7 +106,9 @@ export const useSocket = () => {
const onMessage = (ws: WebSocket, event: MessageEvent) => { const onMessage = (ws: WebSocket, event: MessageEvent) => {
const data = JSON.parse(event.data) const data = JSON.parse(event.data)
const messageType = data.type || data.msg?.type const monitorEnvelope = resolveMonitorWsType(data)
const messageType = monitorEnvelope ?? data.type ?? data.msg?.type
const isPayloadMonitor = Boolean(monitorEnvelope)
// 确认接收 // 确认接收
sendMsg({ sendMsg({
@ -120,6 +127,7 @@ export const useSocket = () => {
// if (isWeb) return; // if (isWeb) return;
if ( if (
!isPayloadMonitor &&
messageType !== SOCKET_MSG_TYPE.lockScreen && messageType !== SOCKET_MSG_TYPE.lockScreen &&
messageType !== SOCKET_MSG_TYPE.unLockScreen && messageType !== SOCKET_MSG_TYPE.unLockScreen &&
typeof data.msg?.locked !== 'undefined' typeof data.msg?.locked !== 'undefined'
@ -142,6 +150,7 @@ export const useSocket = () => {
onDesktopPreview() onDesktopPreview()
break break
case SOCKET_MSG_TYPE.monitorStart: case SOCKET_MSG_TYPE.monitorStart:
case SOCKET_MSG_TYPE.monitorStop:
handleClassMonitorMessage(data) handleClassMonitorMessage(data)
break break
case SOCKET_MSG_TYPE.inspectorStart: case SOCKET_MSG_TYPE.inspectorStart:

View File

@ -7,8 +7,9 @@ export const sys_type_study_machine = 2
// 普通移动端 // 普通移动端
export const sys_type_native_universal = 3 export const sys_type_native_universal = 3
// 原生api的调用对象 // 原生api的调用对象。App WebView 里桥可能晚于业务脚本注入,调用时需要重新读取。
export const native_api = window.$xxl || window.$xxl_universal export const getNativeApi = () => window.$xxl || window.$xxl_universal
export const native_api = getNativeApi()
// 系统类型 // 系统类型
export const sys_type = window.$xxl export const sys_type = window.$xxl
@ -102,7 +103,8 @@ export const $XXL = {
const pack = (method: string, config: anyObj = {}, cb?: anyFuc): any => { const pack = (method: string, config: anyObj = {}, cb?: anyFuc): any => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (isWebEvn) { const api = getNativeApi()
if (!api) {
reject(new Error('暂不支持')) reject(new Error('暂不支持'))
return return
} }
@ -128,14 +130,15 @@ const pack = (method: string, config: anyObj = {}, cb?: anyFuc): any => {
} }
} }
native_api[method]?.(JSON.stringify({ ...config, cb: `xxl_cb.${cbName}` })) api[method]?.(JSON.stringify({ ...config, cb: `xxl_cb.${cbName}` }))
}) })
} }
const packSync = (method: string, ...args: any[]) => { const packSync = (method: string, ...args: any[]) => {
try { try {
if (!native_api) return const api = getNativeApi()
const res = native_api[method]?.(...args) if (!api) return
const res = api[method]?.(...args)
return res && JSON.parse(res) return res && JSON.parse(res)
} catch (err) { } catch (err) {
console.log(err) console.log(err)

File diff suppressed because one or more lines are too long