400 lines
12 KiB
TypeScript
400 lines
12 KiB
TypeScript
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,
|
||
})
|
||
}
|