import html2canvas from 'html2canvas' import { storeToRefs } from 'pinia' import globalApi from '@/api/global' import { userStore } from '@/stores' import { $XXL, getNativeApi } from '@/utils' import { useTips } from './useTips' import { uploadFileToCos, uploadNativeFilePathToCos } from '@/utils/cosUpload' let monitorTimer: ReturnType | null = null let monitorSessionId = '' let isUploadingMonitorSnapshot = false let monitorState: 0 | 1 = 0 const MONITOR_SNAPSHOT_INTERVAL = 10000 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 rawPath = String( res?.data?.url || res?.url || res?.data?.filePath || res?.filePath || res?.data?.path || res?.path || '', ) .replace(/[`]/g, '') .replace(/http:\/\//gi, 'https://') .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 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 const p = o.filePath ?? o.path ?? o.localPath if (typeof p === 'string' && p.trim()) return p.trim() } return '' } const uploadNativeScreenshot = async () => { try { const raw = $XXL.getScreenshot() || {} 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 uploadNativeFilePathToCos(filePath, config => $XXL.uploadFile(config), { fileType: 1, }) const url = normalizeUploadPath(res) monitorLog('native upload result', { raw: res, url }) return url } catch (err) { monitorWarn('native screenshot upload failed', err) return '' } } /** App WebView 无原生截图时仅能用 DOM 截图;尽量贴合「当前视口」并降低高 DPR 白屏概率 */ const getMonitorCaptureElement = (): HTMLElement => { const app = document.getElementById('app') if (app && (app.offsetWidth > 0 || app.offsetHeight > 0)) return app const scrollRoot = document.scrollingElement if (scrollRoot instanceof HTMLElement) return scrollRoot return document.documentElement } const prepareMonitorDomForCapture = async () => { try { await document.fonts?.ready } catch { // } await new Promise(resolve => { requestAnimationFrame(() => { requestAnimationFrame(() => resolve()) }) }) } const canvasToPngFile = (canvas: HTMLCanvasElement) => new Promise(resolve => { canvas.toBlob(blob => { if (!blob) return resolve(null) resolve(new File([blob], `monitor-${Date.now()}.png`, { type: 'image/png' })) }, 'image/png') }) const createWebScreenshotFile = async (): Promise => { 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> = [ // 视口截图常用写法(对 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 () => { monitorLog('web upload start') const file = await createWebScreenshotFile() if (!file) { monitorWarn('web upload skipped because screenshot file is empty') return '' } const res = await uploadFileToCos(file, { fileType: 1 }) const url = normalizeUploadPath(res) monitorLog('web upload result', { raw: res, url }) return url } const uploadMonitorScreenshot = async () => { // $xxl(学习机)与 $xxl_universal(通用 App)都可能提供 getScreenshot,不能只用 sys_type 区分 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() } const reportMonitorSnapshot = async (sessionId: string) => { const { userInfo } = storeToRefs(userStore()) if (isUploadingMonitorSnapshot || monitorSessionId !== sessionId) { monitorLog('snapshot skipped', { isUploadingMonitorSnapshot, monitorSessionId, sessionId, }) return } isUploadingMonitorSnapshot = true monitorLog('snapshot report start', { sessionId, userId: userInfo.value.userId }) try { const url = await uploadMonitorScreenshot() if (!url || monitorSessionId !== sessionId) { monitorWarn('snapshot report skipped after screenshot', { url, monitorSessionId, sessionId, }) return } monitorLog('snapshot final image url', url) await globalApi.reportSnapshot({ sessionId, url, userId: userInfo.value.userId, }) monitorLog('snapshot report success', { sessionId, url }) } catch (err) { monitorError('snapshot report failed', err) } finally { 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) => { const isMonitoring = !!monitorTimer || !!monitorSessionId || monitorState === 1 monitorLog('stop monitor', { showTip, isMonitoring, monitorSessionId, monitorState }) if (monitorTimer) { clearInterval(monitorTimer) monitorTimer = null } if (showTip && isMonitoring) { useTips({ tips: '已结束课堂监控', showCancel: false, }) } monitorSessionId = '' monitorState = 0 } const startClassMonitor = (msg: anyObj) => { const sessionId = msg?.sessionId monitorLog('start monitor message', msg) 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() monitorSessionId = sessionId monitorState = 1 useTips({ tips: '已进入课堂监控状态', showCancel: false, }) reportMonitorSnapshot(sessionId) monitorTimer = setInterval(() => reportMonitorSnapshot(sessionId), MONITOR_SNAPSHOT_INTERVAL) } const getMonitorSessionId = (data: anyObj) => { const sessionId = data?.sessionId || data?.monitorSessionId || data?.classMonitorSessionId || data?.monitorInfo?.sessionId || data?.classMonitor?.sessionId return typeof sessionId === 'string' || typeof sessionId === 'number' ? String(sessionId).trim() : '' } export const handleClassMonitorMessage = (data: anyObj) => { const monitorType = resolveMonitorWsType(data) 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: anyObj = { ...(data && typeof data === 'object' ? data : {}), ...(data?.msg && typeof data.msg === 'object' && !Array.isArray(data.msg) ? data.msg : {}), } const monitoring = msg?.monitoring if (monitoring === true || monitoring === 'true') { startClassMonitor(msg) return true } if (monitoring === false || monitoring === 'false') { stopClassMonitor(true) return true } return false } export const syncClassMonitorState = (state: unknown) => { const stateInfo = state && typeof state === 'object' ? (state as anyObj) : {} const nextState = Number(stateInfo.monitorState ?? state) if (nextState !== 0 && nextState !== 1) return if (nextState === 0) { stopClassMonitor(true) return } const sessionId = getMonitorSessionId(stateInfo) if (!sessionId) { monitorWarn('sync monitor state ignored because sessionId is empty', stateInfo) monitorState = 1 return } startClassMonitor({ ...stateInfo, sessionId, }) }