lingxixue-web/src/hooks/useClassMonitor.ts
2026-05-12 11:15:18 +08:00

400 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<typeof setInterval> | 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<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 () => {
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<void>(resolve => {
requestAnimationFrame(() => {
requestAnimationFrame(() => resolve())
})
})
}
const canvasToPngFile = (canvas: HTMLCanvasElement) =>
new Promise<File | null>(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<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 () => {
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,
})
}