feat: 监控、解绑设备、作业模式、锁屏等功能

This commit is contained in:
shawko 2026-05-11 10:28:47 +08:00
parent 3e5c5e4817
commit 436deb44dd
9 changed files with 369 additions and 30 deletions

View File

@ -1,7 +1,6 @@
import str from '@/utils/str' import str from '@/utils/str'
import { http } from './http' import { http } from './http'
import { get } from './request' import { get } from './request'
import { $XXL } from '@/utils'
export type Option = { export type Option = {
value: any value: any
@ -99,6 +98,11 @@ export interface UserData {
smartUserIdentifier: string smartUserIdentifier: string
schoolPersonType: number schoolPersonType: number
lockFlag: number lockFlag: number
screenLock?: 0 | 1
// 作业模式 0: 未开启 1: 已开启
workMode: number
// 监控状态 0: 未开启 1: 已开启
monitorState: number
isTeacher: boolean isTeacher: boolean
// 是否为可以接受任务的学员 // 是否为可以接受任务的学员
isTaskStudent: boolean isTaskStudent: boolean
@ -206,6 +210,14 @@ const globalApi = {
data, data,
}), }),
// 上报课堂监控截图
reportSnapshot: (data: { sessionId: string; url: string; userId: string }) =>
http.request({
url: '/school/classMonitor/reportSnapshot',
method: 'POST',
data,
}),
// 用户视频表-id查询 // 用户视频表-id查询
getUserVideoById: (id: any) => getUserVideoById: (id: any) =>
http.request({ http.request({

View File

@ -0,0 +1,161 @@
import html2canvas from 'html2canvas'
import { storeToRefs } from 'pinia'
import globalApi from '@/api/global'
import { userStore } from '@/stores'
import { $XXL, sys_type, sys_type_study_machine } from '@/utils'
import sass from '@/utils/sass'
import { useTips } from './useTips'
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 normalizeUploadPath = (res: any) => {
return String(
res?.data?.filePath || res?.data?.url || res?.filePath || res?.url || res?.data?.path || '',
)
.replace(/[`]/g, '')
.replace(/http:\/\//gi, 'https://')
.trim()
}
const uploadNativeScreenshot = async () => {
const { token } = storeToRefs(userStore())
const { data: filePath } = $XXL.getScreenshot() || {}
if (!filePath) return ''
const res = await $XXL.uploadFile({
url: `${import.meta.env.VITE_SERVER_URL}/sysFileInfo/tenUploadAll`,
header: {
Authorization: token.value,
'X-Tenant-Id': sass.platform.tenantId,
},
filePath,
fileKey: 'file',
})
return normalizeUploadPath(res)
}
const createWebScreenshotFile = async () => {
const canvas = await html2canvas(document.body, {
useCORS: true,
backgroundColor: null,
x: window.scrollX,
y: window.scrollY,
width: window.innerWidth,
height: window.innerHeight,
windowWidth: window.innerWidth,
windowHeight: window.innerHeight,
scale: window.devicePixelRatio || 1,
})
return await 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 uploadWebScreenshot = async () => {
const file = await createWebScreenshotFile()
if (!file) return ''
const formData = new FormData()
formData.append('file', file)
const { data: res } = await globalApi.uploadFile(formData)
return normalizeUploadPath(res)
}
const uploadMonitorScreenshot = async () => {
if (sys_type === sys_type_study_machine) {
return await uploadNativeScreenshot()
}
return await uploadWebScreenshot()
}
const reportMonitorSnapshot = async (sessionId: string) => {
const { userInfo } = storeToRefs(userStore())
if (isUploadingMonitorSnapshot || monitorSessionId !== sessionId) return
isUploadingMonitorSnapshot = true
try {
const url = await uploadMonitorScreenshot()
if (!url || monitorSessionId !== sessionId) return
await globalApi.reportSnapshot({
sessionId,
url,
userId: userInfo.value.userId,
})
} catch (err) {
console.log(err)
} finally {
isUploadingMonitorSnapshot = false
}
}
export const stopClassMonitor = (showTip = false) => {
const isMonitoring = !!monitorTimer || !!monitorSessionId || monitorState === 1
if (monitorTimer) {
clearInterval(monitorTimer)
monitorTimer = null
}
if (showTip && isMonitoring) {
useTips({
tips: '已结束课堂监控',
showCancel: false,
})
}
monitorSessionId = ''
monitorState = 0
}
const startClassMonitor = (msg: anyObj) => {
const sessionId = msg?.sessionId
if (!sessionId) return
if (monitorTimer && monitorSessionId === sessionId) return
stopClassMonitor()
monitorSessionId = sessionId
monitorState = 1
useTips({
tips: '已进入课堂监控状态',
showCancel: false,
})
reportMonitorSnapshot(sessionId)
monitorTimer = setInterval(() => reportMonitorSnapshot(sessionId), MONITOR_SNAPSHOT_INTERVAL)
}
export const handleClassMonitorMessage = (data: anyObj) => {
const messageType = data?.type || data?.msg?.type
if (messageType !== MONITOR_MSG_TYPE) return false
const msg = data?.msg || data
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 nextState = Number(state)
if (nextState !== 0 && nextState !== 1) return
if (nextState === 0) {
stopClassMonitor(true)
return
}
monitorState = 1
}

View File

@ -1,10 +1,11 @@
import { useWebSocket } from '@vueuse/core' import { useWebSocket } from '@vueuse/core'
import { userStore, globalStore, urgeStore } from '@/stores' import { userStore, globalStore, urgeStore } from '@/stores'
import type { UseWebSocketReturn } from '@vueuse/core' import type { UseWebSocketReturn } from '@vueuse/core'
import { getSN, $XXL, sys_type, sys_type_study_machine, isNotStudyMachine } from '@/utils' 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'
let SOCKET: UseWebSocketReturn<any> let SOCKET: UseWebSocketReturn<any>
@ -17,6 +18,7 @@ enum SOCKET_MSG_TYPE {
inspectorEnd = 'inspectorEnd', inspectorEnd = 'inspectorEnd',
systemMessage = 'systemMessage', systemMessage = 'systemMessage',
messageReceive = 'messageReceive', messageReceive = 'messageReceive',
monitorStart = 'monitorStart',
} }
const inspectorEnd = ref<anyObj>() const inspectorEnd = ref<anyObj>()
@ -30,7 +32,6 @@ export const useSocket = () => {
const SN = getSN() const SN = getSN()
const init = () => { const init = () => {
return
if (import.meta.env.MODE === 'test') return if (import.meta.env.MODE === 'test') return
if (!token.value) return if (!token.value) return
stop() stop()
@ -79,8 +80,28 @@ export const useSocket = () => {
// console.log(ws, event); // console.log(ws, event);
} }
const getLockFlag = (data: anyObj, fallback: 0 | 1): 0 | 1 => {
const locked = data.msg?.locked
if (locked === true || locked === 'true') return 1
if (locked === false || locked === 'false') return 0
const lockFlag = Number(locked)
return lockFlag === 0 || lockFlag === 1 ? lockFlag : fallback
}
const updateLockScreen = (lockFlag: 0 | 1) => {
if (isNotStudyMachine) return
system.value.isLock = lockFlag === 1
useLog(LogType.LockScreenTime, {
simSerialNumber: SN,
lockFlag,
})
}
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
// 确认接收 // 确认接收
sendMsg({ sendMsg({
@ -89,7 +110,7 @@ export const useSocket = () => {
}) })
// 全局消息提醒 // 全局消息提醒
if (data.type === SOCKET_MSG_TYPE.systemMessage) { if (messageType === SOCKET_MSG_TYPE.systemMessage) {
useTips({ useTips({
tips: data.msg, tips: data.msg,
showCancel: false, showCancel: false,
@ -98,29 +119,31 @@ export const useSocket = () => {
} }
// if (isWeb) return; // if (isWeb) return;
switch (data.type) { if (
messageType !== SOCKET_MSG_TYPE.lockScreen &&
messageType !== SOCKET_MSG_TYPE.unLockScreen &&
typeof data.msg?.locked !== 'undefined'
) {
updateLockScreen(getLockFlag(data, 0))
return
}
switch (messageType) {
case SOCKET_MSG_TYPE.lockScreen: case SOCKET_MSG_TYPE.lockScreen:
if (isNotStudyMachine) return
// 执行锁屏 // 执行锁屏
system.value.isLock = true updateLockScreen(getLockFlag(data, 1))
useLog(LogType.LockScreenTime, {
simSerialNumber: SN,
lockFlag: 1,
})
break break
case SOCKET_MSG_TYPE.unLockScreen: case SOCKET_MSG_TYPE.unLockScreen:
if (isNotStudyMachine) return
// 执行解锁 // 执行解锁
system.value.isLock = false updateLockScreen(getLockFlag(data, 0))
useLog(LogType.LockScreenTime, {
simSerialNumber: SN,
lockFlag: 0,
})
break break
case SOCKET_MSG_TYPE.desktopPreview: case SOCKET_MSG_TYPE.desktopPreview:
if (isNotStudyMachine) return if (isNotStudyMachine) return
onDesktopPreview() onDesktopPreview()
break break
case SOCKET_MSG_TYPE.monitorStart:
handleClassMonitorMessage(data)
break
case SOCKET_MSG_TYPE.inspectorStart: case SOCKET_MSG_TYPE.inspectorStart:
// 开始督学 // 开始督学
// console.log('开始督学'); // console.log('开始督学');
@ -197,6 +220,7 @@ export const useSocket = () => {
} }
const stop = () => { const stop = () => {
stopClassMonitor()
SOCKET?.close() SOCKET?.close()
} }

View File

@ -13,6 +13,7 @@ import { ref, shallowRef } from 'vue'
import questionExplainApi from '@/api/subject/questionExplain' import questionExplainApi from '@/api/subject/questionExplain'
import { getCache } from '@/utils' import { getCache } from '@/utils'
import { userStore } from '@/stores' import { userStore } from '@/stores'
import { handleClassMonitorMessage, stopClassMonitor } from './useClassMonitor'
// ======================== 常量配置 ======================== // ======================== 常量配置 ========================
@ -194,6 +195,8 @@ function handleMessage(data: TeacherWsMessage) {
// 心跳响应不做业务处理 // 心跳响应不做业务处理
if (data.type === 'pong') return if (data.type === 'pong') return
handleClassMonitorMessage(data)
// 调用所有已注册的外部消息回调 // 调用所有已注册的外部消息回调
messageHandlers.forEach(handler => { messageHandlers.forEach(handler => {
try { try {
@ -285,6 +288,7 @@ async function init() {
function close() { function close() {
isManualClose = true isManualClose = true
stopHeartbeat() stopHeartbeat()
stopClassMonitor()
clearReconnectTimer() clearReconnectTimer()
stopVisibilityListener() stopVisibilityListener()
reconnectAttempts = 0 reconnectAttempts = 0

View File

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { globalStore, subjectStore } from '@/stores' import { globalStore, subjectStore, userStore } from '@/stores'
import { useSocket, useTeacherExplain } from '@/hooks' import { LogType, useLog, useSocket, useTeacherExplain } from '@/hooks'
import { $XXL, isWebEvn } from '@/utils' import { $XXL, getSN, isStudyMachine, isWebEvn } from '@/utils'
import hud from '@/utils/hud' import hud from '@/utils/hud'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
@ -12,8 +12,12 @@ const logo = sass.getLogo()
const router = useRouter() const router = useRouter()
const { system, init, getDict, initDevTool, destroyDevTool } = globalStore() const { system, init, getDict, initDevTool, destroyDevTool } = globalStore()
const { getUserInfo } = userStore()
const { getSubjectAllList } = subjectStore() const { getSubjectAllList } = subjectStore()
const LOCK_HOME_PAGE_REFRESH_INTERVAL = 10_000
let lockHomePageTimer: ReturnType<typeof setInterval> | null = null
let lockHomePageRefreshing = false
if (!isWebEvn) { if (!isWebEvn) {
// //
@ -82,6 +86,52 @@ const {
} = useTeacherExplain() } = useTeacherExplain()
teacherExplainInit() teacherExplainInit()
function getLockFlag(data: anyObj, fallback: 0 | 1): 0 | 1 {
const locked = data.msg?.locked
if (locked === true || locked === 'true') return 1
if (locked === false || locked === 'false') return 0
const lockFlag = Number(locked)
return lockFlag === 0 || lockFlag === 1 ? lockFlag : fallback
}
function updateLockScreen(lockFlag: 0 | 1) {
if (!isStudyMachine) return
system.isLock = lockFlag === 1
useLog(LogType.LockScreenTime, {
simSerialNumber: getSN(),
lockFlag,
})
}
async function refreshLockHomePage() {
if (lockHomePageRefreshing) return
lockHomePageRefreshing = true
try {
await getUserInfo()
} catch (err) {
console.warn('锁屏状态刷新首页信息失败', err)
} finally {
lockHomePageRefreshing = false
}
}
function startLockHomePageRefresh() {
if (lockHomePageTimer) return
refreshLockHomePage()
lockHomePageTimer = setInterval(refreshLockHomePage, LOCK_HOME_PAGE_REFRESH_INTERVAL)
}
function stopLockHomePageRefresh() {
if (!lockHomePageTimer) return
clearInterval(lockHomePageTimer)
lockHomePageTimer = null
}
// WebSocket // WebSocket
watch( watch(
() => router.currentRoute.value.path, () => router.currentRoute.value.path,
@ -92,9 +142,32 @@ watch(
}, },
) )
watch(
() => system.isLock,
isLock => {
if (isLock) {
startLockHomePageRefresh()
return
}
stopLockHomePageRefresh()
},
{ immediate: true },
)
// WebSocket questionExplainStart explaining true // WebSocket questionExplainStart explaining true
// questionId // questionId
onTeacherMessage(data => { onTeacherMessage(data => {
if (data.type === 'lockScreen') {
updateLockScreen(getLockFlag(data, 1))
return
}
if (data.type === 'unLockScreen') {
updateLockScreen(getLockFlag(data, 0))
return
}
if (data.type === 'questionExplainStart') { if (data.type === 'questionExplainStart') {
let msg = data.msg let msg = data.msg
if (typeof msg === 'string') { if (typeof msg === 'string') {
@ -190,6 +263,10 @@ function onLoaded() {
onMounted(() => { onMounted(() => {
$XXL.closeLoading() $XXL.closeLoading()
}) })
onUnmounted(() => {
stopLockHomePageRefresh()
})
</script> </script>
<template> <template>
<!-- @click="onPageClick" --> <!-- @click="onPageClick" -->

View File

@ -3,7 +3,7 @@ import { userStore, globalStore, taskStore, subjectStore } from '@/stores'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import globalApi from '@/api/global' import globalApi from '@/api/global'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAudio } from '@/hooks' import { useAudio, XMessage } from '@/hooks'
import { import {
getLevelProgress, getLevelProgress,
$XXL, $XXL,
@ -43,6 +43,7 @@ const taskData = taskStore()
const { userInfo, workToken } = storeToRefs(userStore()) const { userInfo, workToken } = storeToRefs(userStore())
const { getUserInfo, isFirstBind } = userStore() const { getUserInfo, isFirstBind } = userStore()
const { system, checkUpdate } = globalStore() const { system, checkUpdate } = globalStore()
const isHomePageLoading = ref(false)
const navMain = computed(() => { const navMain = computed(() => {
// const { purchasedInspector, nextInspectorCourseDate, conductInspectorPlanId } = // const { purchasedInspector, nextInspectorCourseDate, conductInspectorPlanId } =
@ -238,6 +239,8 @@ function getTaskRedPoint() {
const { selectSubject } = subjectStore() const { selectSubject } = subjectStore()
const handleNav = debounce( const handleNav = debounce(
(data: anyObj) => { (data: anyObj) => {
if (isHomePageLoading.value) return
if (data.key === 'homeWork' && userInfo.value.isTeacher) { if (data.key === 'homeWork' && userInfo.value.isTeacher) {
const path = `${import.meta.env.VITE_TEACHER_ADMIN_URL}?token=${workToken.value.replace('Bearer ', '')}` const path = `${import.meta.env.VITE_TEACHER_ADMIN_URL}?token=${workToken.value.replace('Bearer ', '')}`
if (isWebEvn) { if (isWebEvn) {
@ -287,17 +290,36 @@ function handleKnown(data: anyObj) {
// //
function handleToUserPage() { function handleToUserPage() {
if (isHomePageLoading.value) return
router.push({ path: '/system/user' }) router.push({ path: '/system/user' })
} }
// //
function clickPhoto() { function clickPhoto() {
if (isHomePageLoading.value) return
if (isShufaAccount(userInfo.value.account)) { if (isShufaAccount(userInfo.value.account)) {
// //
router.replace(SHUFA_PATH) router.replace(SHUFA_PATH)
} }
} }
async function refreshHomePage() {
isHomePageLoading.value = true
try {
await getUserInfo()
if (Number(userInfo.value.workMode) === 1) {
XMessage.info('当前处于作业模式')
await router.replace('/system/homeWork')
return true
}
return false
} finally {
isHomePageLoading.value = false
}
}
let timer: any let timer: any
onMounted(async () => { onMounted(async () => {
// //
@ -316,7 +338,8 @@ onActivated(async () => {
getPoint() getPoint()
getTaskRedPoint() getTaskRedPoint()
await getUserInfo() const isWorkMode = await refreshHomePage()
if (isWorkMode) return
// //
await nextTick() await nextTick()

View File

@ -195,7 +195,7 @@ const startDoWork = () => {
></Empty> ></Empty>
</div> </div>
<div class="top"> <div class="top">
<BackBar leftText="作业" /> <BackBar leftText="作业" index />
<div class="top-history"> <div class="top-history">
<!-- <mj-button type="success" tilt @click="to(`/system/homeWork/statistics`)" <!-- <mj-button type="success" tilt @click="to(`/system/homeWork/statistics`)"
>统计分析</mj-button >统计分析</mj-button

View File

@ -204,7 +204,7 @@ router.beforeEach(async (to, from, next) => {
// 全局的返回函数 // 全局的返回函数
export function globalBack(home = false) { export function globalBack(home = false) {
if (window.history.state?.position === 1 || !window.history.state.back || home) { if (window.history.state?.position === 1 || !window.history.state.back || home) {
router.push('/') router.replace('/')
} else { } else {
const afterRoute = router.currentRoute.value.fullPath const afterRoute = router.currentRoute.value.fullPath
router.back() router.back()

View File

@ -6,8 +6,9 @@ import loginApi from '@/api/login'
import type { accountLoginType } from '@/api/login' import type { accountLoginType } from '@/api/login'
import type { Option, UserData } from '@/api/global' import type { Option, UserData } from '@/api/global'
import type { School } from '@/api/studentManage' import type { School } from '@/api/studentManage'
import { setCache, clearCache, getSN, getManufacturer, isStudyMachine } from '@/utils' import { setCache, clearCache, getSN, getManufacturer, isStudyMachine, isWebEvn } from '@/utils'
import { useSocket } from '@/hooks' import { LogType, useLog, useSocket } from '@/hooks'
import { syncClassMonitorState } from '@/hooks/useClassMonitor'
import { globalStore } from '@/stores' import { globalStore } from '@/stores'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import obj from '@/utils/obj' import obj from '@/utils/obj'
@ -84,17 +85,42 @@ export const userStore = defineStore('user', () => {
return userInfo.value.schoolClass?.[0]?.classList?.[0] return userInfo.value.schoolClass?.[0]?.classList?.[0]
} }
const updateScreenLock = (screenLock?: unknown) => {
const lockFlag = Number(screenLock)
if (lockFlag !== 0 && lockFlag !== 1) return
const { system } = storeToRefs(globalStore())
const isLock = lockFlag === 1
if (!isStudyMachine && isLock) return
if (system.value.isLock === isLock) return
system.value.isLock = isLock
if (!isStudyMachine) return
useLog(LogType.LockScreenTime, {
simSerialNumber: getSN(),
lockFlag,
})
}
// 更新用户信息 // 更新用户信息
const updateUserInfo = async (info: any) => { const updateUserInfo = async (info: any) => {
if (!info) return if (!info) return
const hasMonitorState = Object.prototype.hasOwnProperty.call(info, 'monitorState')
info = { ...userInfo.value, ...info } info = { ...userInfo.value, ...info }
if (isStudyMachine) { if (typeof info.screenLock !== 'undefined') {
updateScreenLock(info.screenLock)
} else if (isStudyMachine) {
// 锁屏 // 锁屏
const { system } = storeToRefs(globalStore()) const { system } = storeToRefs(globalStore())
system.value.isLock = info.lockFlag === 0 system.value.isLock = info.lockFlag === 0
} }
if (hasMonitorState) {
syncClassMonitorState(info.monitorState)
}
if (typeof info.schoolClass === 'string') { if (typeof info.schoolClass === 'string') {
info.schoolClass = JSON.parse(info.schoolClass) info.schoolClass = JSON.parse(info.schoolClass)
if (info.schoolClass?.length) { if (info.schoolClass?.length) {
@ -158,13 +184,25 @@ export const userStore = defineStore('user', () => {
setCache('token', `Bearer ${tokenValue}`) setCache('token', `Bearer ${tokenValue}`)
} }
const getDeviceSerialNumber = () => {
const { system } = globalStore()
return system.device.SN || getSN()
}
// 账号登录 // 账号登录
const accountLogin = async (data: accountLoginType) => { const accountLogin = async (data: accountLoginType) => {
const res = await loginApi.psdLogin({ const loginData: accountLoginType = {
...data, ...data,
simSerialNumber: getSN(),
model: getManufacturer(), model: getManufacturer(),
}) }
if (isWebEvn) {
delete loginData.simSerialNumber
} else {
loginData.simSerialNumber = getDeviceSerialNumber()
}
const res = await loginApi.psdLogin(loginData)
await loginBefore() await loginBefore()
setToken(res as any) setToken(res as any)
await loginAfter() await loginAfter()