From efe57930aaf581a2d7b90254e16e65cbdcd6df5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=98=BF=E6=A2=A6?= Date: Fri, 15 May 2026 11:25:17 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=9B=B4=E6=92=AD=E4=B8=AD=E8=B0=83?= =?UTF-8?q?=E7=94=A8=E6=9F=A5=E8=AF=A2=E7=9B=B4=E6=92=AD=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.test | 4 +- src/api/subject/teacherScreenShare.ts | 20 +++++ src/hooks/useTeacherExplain.ts | 108 +++++++++++++++++++++++++- src/utils/android.ts | 5 ++ vite.config.ts | 8 +- 5 files changed, 138 insertions(+), 7 deletions(-) create mode 100644 src/api/subject/teacherScreenShare.ts diff --git a/.env.test b/.env.test index f75afa4..2c2a62d 100644 --- a/.env.test +++ b/.env.test @@ -1,5 +1,5 @@ -VITE_WS_URL = ws://43.136.52.196:27001/device -# VITE_WS_URL = ws://ai.xuexiaole.com/ws/device +# VITE_WS_URL = ws://43.136.52.196:27001/device +VITE_WS_URL = ws://ai.lingxixue.com/ws/device VITE_OSS_URL = https://lxx-web-1313840333.cos.ap-chengdu.myqcloud.com VITE_CDN_URL = https://dmgcdn-1313840333.cos.ap-guangzhou.myqcloud.com VITE_SERVER_URL = http://127.0.0.1:9053/api/main diff --git a/src/api/subject/teacherScreenShare.ts b/src/api/subject/teacherScreenShare.ts new file mode 100644 index 0000000..c5e4656 --- /dev/null +++ b/src/api/subject/teacherScreenShare.ts @@ -0,0 +1,20 @@ +import { get } from '../request' + +export type TeacherScreenShareState = { + classId: number + ip: string + lastHeartbeatAt: number + port: number + sharing: boolean + startedAt: number + ttlSeconds: number +} + +const teacherScreenShareApi = { + /** 查询当前班级老师投屏状态(直播中轮询) */ + state: async (classId: string) => { + return await get('/school/teacherScreenShare/state', { classId }) + }, +} + +export default teacherScreenShareApi diff --git a/src/hooks/useTeacherExplain.ts b/src/hooks/useTeacherExplain.ts index 05c6c12..0fbdd9f 100644 --- a/src/hooks/useTeacherExplain.ts +++ b/src/hooks/useTeacherExplain.ts @@ -8,10 +8,12 @@ * 4. 每 30 秒发送一次心跳保活 * 5. 收到的消息统一处理,支持注册自定义消息回调 * 6. 断线自动重连 + * 7. 老师投屏进行中每 30s 查询 /school/teacherScreenShare/state,sharing 为 false 时结束投屏并停止轮询 */ import { ref, shallowRef } from 'vue' import questionExplainApi from '@/api/subject/questionExplain' -import { getCache } from '@/utils' +import teacherScreenShareApi from '@/api/subject/teacherScreenShare' +import { getCache, $XXL } from '@/utils' import { userStore } from '@/stores' import { handleClassMonitorMessage, stopClassMonitor } from './useClassMonitor' @@ -20,6 +22,9 @@ import { handleClassMonitorMessage, stopClassMonitor } from './useClassMonitor' /** 心跳发送间隔(毫秒) */ const HEARTBEAT_INTERVAL = 30_000 +/** 接收老师投屏时,查询服务端投屏状态的间隔(毫秒) */ +const TEACHER_SCREEN_SHARE_STATE_POLL_INTERVAL = 30_000 + /** 断线重连延迟(毫秒) */ const RECONNECT_DELAY = 5_000 @@ -58,6 +63,11 @@ 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() @@ -70,6 +80,100 @@ const lastMessage = shallowRef(null) /** 班级信息(响应式) */ const classInfo = ref(null) +const TEACHER_SCREEN_SHARE_TYPES = new Set(['teacherScreenShareStart', 'teacherScreenShareStop']) + +/** 兼容顶层或嵌套在 `data` 下的投屏消息 */ +function resolveTeacherScreenSharePayload(data: TeacherWsMessage): TeacherWsMessage | null { + if (TEACHER_SCREEN_SHARE_TYPES.has(data.type)) return data + const inner = (data as Record).data + if ( + inner && + typeof inner === 'object' && + TEACHER_SCREEN_SHARE_TYPES.has((inner as TeacherWsMessage).type) + ) { + return inner as TeacherWsMessage + } + return null +} + +function parseMsgObject(msg: unknown): Record | null { + if (msg && typeof msg === 'object') return msg as Record + if (typeof msg === 'string') { + try { + return JSON.parse(msg) as Record + } catch { + return 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 handleTeacherScreenShareMessage(data: TeacherWsMessage) { + const root = resolveTeacherScreenSharePayload(data) + if (!root) return + + if (root.type === 'teacherScreenShareStart') { + const msg = parseMsgObject(root.msg) + if (!msg) return + const host = msg.ip !== undefined && msg.ip !== null ? String(msg.ip) : '' + const port = Number(msg.port) + if (!host || !Number.isFinite(port)) return + const u = userStore().userInfo + $XXL.startTeacherShareScreen({ + host, + port, + name: String(u?.name || u?.nickName || ''), + id: String(u?.userId ?? ''), + }) + const pollClassId = + msg.classId !== undefined && msg.classId !== null && String(msg.classId).trim() !== '' + ? String(msg.classId) + : classInfo.value?.classId !== undefined && classInfo.value?.classId !== null + ? String(classInfo.value.classId) + : '' + if (pollClassId) startTeacherScreenSharePolling(pollClassId) + return + } + + if (root.type === 'teacherScreenShareStop') { + stopTeacherScreenSharePolling() + $XXL.stopTeacherShareScreen() + } +} + // ======================== 内部方法 ======================== /** 获取带 token 的 WebSocket 连接地址 */ @@ -196,6 +300,7 @@ function handleMessage(data: TeacherWsMessage) { if (data.type === 'pong') return handleClassMonitorMessage(data) + handleTeacherScreenShareMessage(data) // 调用所有已注册的外部消息回调 messageHandlers.forEach(handler => { @@ -288,6 +393,7 @@ 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 aed71dd..e34f73c 100644 --- a/src/utils/android.ts +++ b/src/utils/android.ts @@ -104,6 +104,11 @@ export const $XXL = { writeError: (error: string) => packSync('writeError', error), uninstallApp: (pkgName: string) => packSync('uninstallApp', pkgName), openExternalURL: (url: string) => packSync('openExternalURL', url), + /** 老师投屏开始:参数为 JSON 字符串,与原生 `startTeacherShareScreen` 约定一致 */ + startTeacherShareScreen: (config: { host: string; port: number; name: string; id: string }) => + packSync('startTeacherShareScreen', JSON.stringify(config)), + /** 老师投屏结束 */ + stopTeacherShareScreen: () => packSync('stopTeacherShareScreen'), // openApp: (config: anyObj) => pack('openApp', config), } diff --git a/vite.config.ts b/vite.config.ts index 88c66d4..8a1a6a8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -148,16 +148,16 @@ export default defineConfig({ open: true, proxy: { '/api/campus': { - target: 'http://43.136.52.196:9053', + // target: 'http://43.136.52.196:9053', // target: 'http://test.web.xuexiaole.com', - // target: 'https://ai.xuexiaole.com', + target: 'https://ai.lingxixue.com', changeOrigin: true, rewrite: path => path.replace('/api/campus', '/'), }, '/api': { - target: 'http://43.136.52.196:9053', + // target: 'http://43.136.52.196:9053', // target: 'http://test.web.xuexiaole.com', - // target: 'https://ai.xuexiaole.com', + target: 'https://ai.lingxixue.com', changeOrigin: true, }, },