From 49023a433dfedc63989f9436a25c480803c9f71e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=98=BF=E6=A2=A6?= Date: Fri, 15 May 2026 14:53:52 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=8E=BB=E6=8E=89=E7=9B=B4=E6=92=AD30?= =?UTF-8?q?=E7=A7=92=E8=BD=AE=E8=AF=A2=EF=BC=8C=E8=AF=BE=E5=A0=82=E7=9B=91?= =?UTF-8?q?=E6=8E=A7=E8=B0=83=E7=94=A8=E5=8E=9F=E7=94=9F=E7=9B=91=E6=8E=A7?= =?UTF-8?q?=E6=96=B9=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/subject/teacherScreenShare.ts | 2 +- src/hooks/useClassMonitor.ts | 365 ++------------------------ src/hooks/useTeacherExplain.ts | 48 +--- src/utils/android.ts | 4 + 4 files changed, 23 insertions(+), 396 deletions(-) diff --git a/src/api/subject/teacherScreenShare.ts b/src/api/subject/teacherScreenShare.ts index c5e4656..e4784dc 100644 --- a/src/api/subject/teacherScreenShare.ts +++ b/src/api/subject/teacherScreenShare.ts @@ -11,7 +11,7 @@ export type TeacherScreenShareState = { } const teacherScreenShareApi = { - /** 查询当前班级老师投屏状态(直播中轮询) */ + /** 查询当前班级老师投屏状态 */ state: async (classId: string) => { return await get('/school/teacherScreenShare/state', { classId }) }, diff --git a/src/hooks/useClassMonitor.ts b/src/hooks/useClassMonitor.ts index 6b4bd59..d512630 100644 --- a/src/hooks/useClassMonitor.ts +++ b/src/hooks/useClassMonitor.ts @@ -1,28 +1,16 @@ -import html2canvas from 'html2canvas' -import { storeToRefs } from 'pinia' -import globalApi from '@/api/global' -import { userStore } from '@/stores' -import { $XXL, getNativeApi } from '@/utils' +import { $XXL } 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 let closeMonitorTip: (() => void) | null = null -const MONITOR_SNAPSHOT_INTERVAL = 10000 const MONITOR_MSG_TYPE = 'monitorStart' const MONITOR_STOP_MSG_TYPE = 'monitorStop' const MONITOR_LOG_PREFIX = '[class-monitor]' -const MONITOR_SNAPSHOT_TARGET_SIZE = 100 * 1024 -const MONITOR_SNAPSHOT_MIN_WIDTH = 480 -const MONITOR_SNAPSHOT_MIN_HEIGHT = 270 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 showMonitorTip = (tips: string) => { closeMonitorTip?.() @@ -32,336 +20,14 @@ const showMonitorTip = (tips: string) => { }) } -const getServerOrigin = () => { - try { - return new URL(import.meta.env.VITE_SERVER_URL).origin - } catch { - return '' - } +const startStudentShareScreen = () => { + monitorLog('call native startStudentShareScreen') + $XXL.startStudentShareScreen() } -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 canvasToBlob = (canvas: HTMLCanvasElement, type: string, quality?: number) => - new Promise(resolve => { - canvas.toBlob( - blob => { - resolve(blob) - }, - type, - quality, - ) - }) - -const resizeCanvas = (source: HTMLCanvasElement, ratio: number) => { - const canvas = document.createElement('canvas') - canvas.width = Math.max(1, Math.round(source.width * ratio)) - canvas.height = Math.max(1, Math.round(source.height * ratio)) - const ctx = canvas.getContext('2d') - if (!ctx) return source - ctx.drawImage(source, 0, 0, canvas.width, canvas.height) - return canvas -} - -const canvasToMonitorFile = async (canvas: HTMLCanvasElement) => { - const qualities = [0.72, 0.6, 0.5, 0.42, 0.35, 0.3, 0.25] - let current = canvas - let bestBlob: Blob | null = null - let bestInfo = { - width: canvas.width, - height: canvas.height, - quality: qualities[0], - } - - for (let resizeCount = 0; resizeCount < 8; resizeCount++) { - for (const quality of qualities) { - const blob = await canvasToBlob(current, 'image/jpeg', quality) - if (!blob) return null - if (!bestBlob || blob.size < bestBlob.size) { - bestBlob = blob - bestInfo = { - width: current.width, - height: current.height, - quality, - } - } - if (blob.size <= MONITOR_SNAPSHOT_TARGET_SIZE) { - monitorLog('web screenshot compressed', { - width: current.width, - height: current.height, - quality, - size: blob.size, - }) - return new File([blob], `monitor-${Date.now()}.jpg`, { type: 'image/jpeg' }) - } - } - - if ( - current.width <= MONITOR_SNAPSHOT_MIN_WIDTH || - current.height <= MONITOR_SNAPSHOT_MIN_HEIGHT - ) { - break - } - current = resizeCanvas(current, 0.85) - } - - if (!bestBlob) return null - monitorWarn('web screenshot compressed but still over target', { - ...bestInfo, - size: bestBlob.size, - target: MONITOR_SNAPSHOT_TARGET_SIZE, - }) - return new File([bestBlob], `monitor-${Date.now()}.jpg`, { type: 'image/jpeg' }) -} - -const createWebScreenshotFile = async (): Promise => { - await prepareMonitorDomForCapture() - - const vw = window.innerWidth - const vh = window.innerHeight - const scale = Math.min(window.devicePixelRatio || 1, 1) - 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 canvasToMonitorFile(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 }) - } +const stopStudentShareScreen = () => { + monitorLog('call native stopStudentShareScreen') + $XXL.stopStudentShareScreen() } /** 与老师讲解 WS 等场景兼容:外层 type 可能是 push/classMessage,监控类型在 msg.type */ @@ -374,11 +40,10 @@ export const resolveMonitorWsType = (data: anyObj): string | undefined => { } export const stopClassMonitor = (showTip = false) => { - const isMonitoring = !!monitorTimer || !!monitorSessionId || monitorState === 1 + const isMonitoring = !!monitorSessionId || monitorState === 1 monitorLog('stop monitor', { showTip, isMonitoring, monitorSessionId, monitorState }) - if (monitorTimer) { - clearInterval(monitorTimer) - monitorTimer = null + if (monitorState === 1) { + stopStudentShareScreen() } if (showTip && isMonitoring) { showMonitorTip('已结束课堂监控') @@ -394,7 +59,7 @@ const startClassMonitor = (msg: anyObj) => { monitorWarn('start monitor ignored because sessionId is empty') return } - if (monitorTimer && monitorSessionId === sessionId) { + if (monitorState === 1 && monitorSessionId === sessionId) { monitorLog('start monitor ignored because session is already running', sessionId) return } @@ -403,8 +68,7 @@ const startClassMonitor = (msg: anyObj) => { monitorSessionId = sessionId monitorState = 1 showMonitorTip('已进入课堂监控状态') - reportMonitorSnapshot(sessionId) - monitorTimer = setInterval(() => reportMonitorSnapshot(sessionId), MONITOR_SNAPSHOT_INTERVAL) + startStudentShareScreen() } const getMonitorSessionId = (data: anyObj) => { @@ -458,7 +122,10 @@ export const syncClassMonitorState = (state: unknown) => { const sessionId = getMonitorSessionId(stateInfo) if (!sessionId) { monitorWarn('sync monitor state ignored because sessionId is empty', stateInfo) - monitorState = 1 + if (monitorState !== 1) { + monitorState = 1 + startStudentShareScreen() + } return } diff --git a/src/hooks/useTeacherExplain.ts b/src/hooks/useTeacherExplain.ts index c0c5400..0b29dec 100644 --- a/src/hooks/useTeacherExplain.ts +++ b/src/hooks/useTeacherExplain.ts @@ -8,7 +8,7 @@ * 4. 每 30 秒发送一次心跳保活 * 5. 收到的消息统一处理,支持注册自定义消息回调 * 6. 断线自动重连 - * 7. 老师投屏进行中每 30s 查询 /school/teacherScreenShare/state,sharing 为 false 时结束投屏并停止轮询 + * 7. 老师投屏 start/stop 由 WebSocket 消息驱动 */ import { ref, shallowRef } from 'vue' import questionExplainApi from '@/api/subject/questionExplain' @@ -22,9 +22,6 @@ import { handleClassMonitorMessage, stopClassMonitor } from './useClassMonitor' /** 心跳发送间隔(毫秒) */ const HEARTBEAT_INTERVAL = 30_000 -/** 接收老师投屏时,查询服务端投屏状态的间隔(毫秒) */ -const TEACHER_SCREEN_SHARE_STATE_POLL_INTERVAL = 30_000 - /** 断线重连延迟(毫秒) */ const RECONNECT_DELAY = 5_000 @@ -63,11 +60,6 @@ let reconnectAttempts = 0 let isManualClose = false let visibilityHandler: (() => void) | null = null -/** 投屏状态轮询定时器(仅在收到 teacherScreenShareStart 后存在) */ -let screenSharePollTimer: ReturnType | null = null -/** 当前轮询对应的班级 id,用于丢弃过期异步结果 */ -let screenSharePollClassId: string | null = null - /** 外部注册的消息处理回调集合 */ const messageHandlers: Set = new Set() @@ -108,38 +100,6 @@ function parseMsgObject(msg: unknown): Record | null { return null } -function stopTeacherScreenSharePolling() { - if (screenSharePollTimer) { - clearInterval(screenSharePollTimer) - screenSharePollTimer = null - } - screenSharePollClassId = null -} - -async function fetchTeacherScreenShareStateOnce(classId: string) { - try { - const res = await teacherScreenShareApi.state(classId) - if (screenSharePollClassId !== classId) return - if (res?.sharing === false) { - $XXL.stopTeacherShareScreen() - stopTeacherScreenSharePolling() - } - } catch (err) { - console.warn('[TeacherExplain] 投屏状态查询失败:', err) - } -} - -/** 仅在直播中轮询;直播结束或未直播不启动 */ -function startTeacherScreenSharePolling(classId: string) { - const cid = String(classId).trim() - if (!cid) return - stopTeacherScreenSharePolling() - screenSharePollClassId = cid - screenSharePollTimer = setInterval(() => { - fetchTeacherScreenShareStateOnce(cid).catch(() => {}) - }, TEACHER_SCREEN_SHARE_STATE_POLL_INTERVAL) -} - function resolveTeacherScreenSharePollClassId(msg: Record): string { if (msg.classId !== undefined && msg.classId !== null && String(msg.classId).trim() !== '') { return String(msg.classId) @@ -154,7 +114,7 @@ function resolveTeacherScreenSharePollClassId(msg: Record): str return '' } -/** 启动老师投屏直播(原生 + 状态轮询) */ +/** 启动老师投屏直播(仅原生) */ function applyTeacherScreenShareStart(msg: Record): boolean { const host = msg.ip !== undefined && msg.ip !== null ? String(msg.ip) : '' const port = Number(msg.port) @@ -167,13 +127,10 @@ function applyTeacherScreenShareStart(msg: Record): boolean { name: String(u?.name || u?.nickName || ''), id: String(u?.userId ?? ''), }) - const pollClassId = resolveTeacherScreenSharePollClassId(msg) - if (pollClassId) startTeacherScreenSharePolling(pollClassId) return true } function stopTeacherScreenShareLive() { - stopTeacherScreenSharePolling() $XXL.stopTeacherShareScreen() } @@ -447,7 +404,6 @@ async function init() { function close() { isManualClose = true stopHeartbeat() - stopTeacherScreenSharePolling() stopClassMonitor() clearReconnectTimer() stopVisibilityListener() diff --git a/src/utils/android.ts b/src/utils/android.ts index e34f73c..be63c14 100644 --- a/src/utils/android.ts +++ b/src/utils/android.ts @@ -109,6 +109,10 @@ export const $XXL = { packSync('startTeacherShareScreen', JSON.stringify(config)), /** 老师投屏结束 */ stopTeacherShareScreen: () => packSync('stopTeacherShareScreen'), + /** 学生课堂监控投屏开始 */ + startStudentShareScreen: () => packSync('startStudentShareScreen'), + /** 学生课堂监控投屏结束 */ + stopStudentShareScreen: () => packSync('stopStudentShareScreen'), // openApp: (config: anyObj) => pack('openApp', config), }