feat:增加直播模块

This commit is contained in:
M了个J 2026-05-12 12:04:26 +08:00
parent 7ac31e52a4
commit d1f364e230
8 changed files with 780 additions and 22 deletions

View File

@ -43,6 +43,7 @@
"lottie-web": "^5.12.2", "lottie-web": "^5.12.2",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"mathjax": "3.0.1", "mathjax": "3.0.1",
"mediasoup-client": "^3.20.0",
"mermaid-it-markdown": "^1.0.8", "mermaid-it-markdown": "^1.0.8",
"nprogress": "^0.2.0", "nprogress": "^0.2.0",
"pinia": "^2.2.0", "pinia": "^2.2.0",

106
pnpm-lock.yaml generated
View File

@ -92,6 +92,9 @@ importers:
mathjax: mathjax:
specifier: 3.0.1 specifier: 3.0.1
version: 3.0.1 version: 3.0.1
mediasoup-client:
specifier: ^3.20.0
version: 3.20.0
mermaid-it-markdown: mermaid-it-markdown:
specifier: ^1.0.8 specifier: ^1.0.8
version: 1.0.8(mermaid@10.9.3)(typescript@5.8.2) version: 1.0.8(mermaid@10.9.3)(typescript@5.8.2)
@ -655,6 +658,14 @@ packages:
'@kurkle/color@0.3.4': '@kurkle/color@0.3.4':
resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==}
'@lukeed/csprng@1.1.0':
resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==}
engines: {node: '>=8'}
'@lukeed/uuid@2.0.1':
resolution: {integrity: sha512-qC72D4+CDdjGqJvkFMMEAtancHUQ7/d/tAiHf64z8MopFDmcrtbcJuerDtFceuAfQJ2pDSfCKCtbqoGBNnwg0w==}
engines: {node: '>=8'}
'@microsoft/fetch-event-source@2.0.1': '@microsoft/fetch-event-source@2.0.1':
resolution: {integrity: sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==} resolution: {integrity: sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==}
@ -909,9 +920,15 @@ packages:
'@types/debug@4.1.12': '@types/debug@4.1.12':
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
'@types/debug@4.1.13':
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
'@types/estree@1.0.7': '@types/estree@1.0.7':
resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==} resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==}
'@types/events@3.0.3':
resolution: {integrity: sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==}
'@types/hammerjs@2.0.46': '@types/hammerjs@2.0.46':
resolution: {integrity: sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==} resolution: {integrity: sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==}
@ -1341,6 +1358,10 @@ packages:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
awaitqueue@3.3.0:
resolution: {integrity: sha512-zLxDhzQbzHmOyvxi7g3OlfR7jLrcmElStPxfLPpJkrFSDm71RSrY/MvsDA8Btlx8X1nOHUzGhQvc6bdUjL2f2w==}
engines: {node: '>=20'}
axios@1.8.4: axios@1.8.4:
resolution: {integrity: sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==} resolution: {integrity: sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==}
@ -1758,6 +1779,15 @@ packages:
supports-color: supports-color:
optional: true optional: true
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
decamelize@1.2.0: decamelize@1.2.0:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -2078,6 +2108,10 @@ packages:
eventemitter3@5.0.1: eventemitter3@5.0.1:
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
events@3.3.0:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'}
execa@8.0.1: execa@8.0.1:
resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==}
engines: {node: '>=16.17'} engines: {node: '>=16.17'}
@ -2088,6 +2122,10 @@ packages:
ext@1.7.0: ext@1.7.0:
resolution: {integrity: sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==} resolution: {integrity: sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==}
fake-mediastreamtrack@2.2.1:
resolution: {integrity: sha512-SITLc7UTDirSdgLGORrlmqjWLJtbtfIz/xO7DwVbJH3f0ds+NQok4ccl/WEzz49NSgiUlXf2wDW0+y5C+TO6EA==}
engines: {node: '>=20'}
fast-deep-equal@3.1.3: fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@ -2256,6 +2294,10 @@ packages:
graphemer@1.4.0: graphemer@1.4.0:
resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==}
h264-profile-level-id@2.3.2:
resolution: {integrity: sha512-hnq1UDlw7WGJV6GCr/g7wnkHYUjdAY2bis9rgn2JqSdQS2WfVvnt1ZE9g8nTguracodf5LLKZOwURsDN49YtBQ==}
engines: {node: '>=20'}
hammerjs@2.0.8: hammerjs@2.0.8:
resolution: {integrity: sha512-tSQXBXS/MWQOn/RKckawJ61vvsDpCom87JgxiYdGwHdOa0ht0vzUWDlfioofFCRU0L+6NGDt6XzbgoJvZkMeRQ==} resolution: {integrity: sha512-tSQXBXS/MWQOn/RKckawJ61vvsDpCom87JgxiYdGwHdOa0ht0vzUWDlfioofFCRU0L+6NGDt6XzbgoJvZkMeRQ==}
engines: {node: '>=0.8.0'} engines: {node: '>=0.8.0'}
@ -2712,6 +2754,10 @@ packages:
mdurl@2.0.0: mdurl@2.0.0:
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
mediasoup-client@3.20.0:
resolution: {integrity: sha512-FL6RlsGwvmXbXkbMoc1eW2EqW7k8Cw/Mhls3QewQXH+mioWVeVNWjzcDPmTZ3Sw+mua/n0IH5C/ntuVsx+KegA==}
engines: {node: '>=22'}
memoize-one@6.0.0: memoize-one@6.0.0:
resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==} resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==}
@ -3220,6 +3266,10 @@ packages:
scule@1.3.0: scule@1.3.0:
resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==}
sdp-transform@3.0.0:
resolution: {integrity: sha512-gfYVRGxjHkGF2NPeUWHw5u6T/KGFtS5/drPms73gaSuMaVHKCY3lpLnGDfswVQO0kddeePoti09AwhYP4zA8dQ==}
hasBin: true
semver@6.3.1: semver@6.3.1:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true hasBin: true
@ -3363,6 +3413,10 @@ packages:
stylis@4.3.6: stylis@4.3.6:
resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==} resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==}
supports-color@10.2.2:
resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
engines: {node: '>=18'}
supports-color@7.2.0: supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -4192,6 +4246,12 @@ snapshots:
'@kurkle/color@0.3.4': {} '@kurkle/color@0.3.4': {}
'@lukeed/csprng@1.1.0': {}
'@lukeed/uuid@2.0.1':
dependencies:
'@lukeed/csprng': 1.1.0
'@microsoft/fetch-event-source@2.0.1': {} '@microsoft/fetch-event-source@2.0.1': {}
'@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1': '@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1':
@ -4364,8 +4424,14 @@ snapshots:
dependencies: dependencies:
'@types/ms': 2.1.0 '@types/ms': 2.1.0
'@types/debug@4.1.13':
dependencies:
'@types/ms': 2.1.0
'@types/estree@1.0.7': {} '@types/estree@1.0.7': {}
'@types/events@3.0.3': {}
'@types/hammerjs@2.0.46': {} '@types/hammerjs@2.0.46': {}
'@types/json-schema@7.0.15': {} '@types/json-schema@7.0.15': {}
@ -4914,6 +4980,12 @@ snapshots:
dependencies: dependencies:
possible-typed-array-names: 1.1.0 possible-typed-array-names: 1.1.0
awaitqueue@3.3.0(supports-color@10.2.2):
dependencies:
debug: 4.4.3(supports-color@10.2.2)
transitivePeerDependencies:
- supports-color
axios@1.8.4: axios@1.8.4:
dependencies: dependencies:
follow-redirects: 1.15.9 follow-redirects: 1.15.9
@ -5360,6 +5432,12 @@ snapshots:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
debug@4.4.3(supports-color@10.2.2):
dependencies:
ms: 2.1.3
optionalDependencies:
supports-color: 10.2.2
decamelize@1.2.0: {} decamelize@1.2.0: {}
decode-named-character-reference@1.1.0: decode-named-character-reference@1.1.0:
@ -5789,6 +5867,8 @@ snapshots:
eventemitter3@5.0.1: {} eventemitter3@5.0.1: {}
events@3.3.0: {}
execa@8.0.1: execa@8.0.1:
dependencies: dependencies:
cross-spawn: 7.0.6 cross-spawn: 7.0.6
@ -5807,6 +5887,10 @@ snapshots:
dependencies: dependencies:
type: 2.7.3 type: 2.7.3
fake-mediastreamtrack@2.2.1:
dependencies:
'@lukeed/uuid': 2.0.1
fast-deep-equal@3.1.3: {} fast-deep-equal@3.1.3: {}
fast-glob@3.3.3: fast-glob@3.3.3:
@ -5993,6 +6077,12 @@ snapshots:
graphemer@1.4.0: {} graphemer@1.4.0: {}
h264-profile-level-id@2.3.2(supports-color@10.2.2):
dependencies:
debug: 4.4.3(supports-color@10.2.2)
transitivePeerDependencies:
- supports-color
hammerjs@2.0.8: {} hammerjs@2.0.8: {}
has-bigints@1.1.0: {} has-bigints@1.1.0: {}
@ -6441,6 +6531,18 @@ snapshots:
mdurl@2.0.0: {} mdurl@2.0.0: {}
mediasoup-client@3.20.0:
dependencies:
'@types/debug': 4.1.13
'@types/events-alias': '@types/events@3.0.3'
awaitqueue: 3.3.0(supports-color@10.2.2)
debug: 4.4.3(supports-color@10.2.2)
events-alias: events@3.3.0
fake-mediastreamtrack: 2.2.1
h264-profile-level-id: 2.3.2(supports-color@10.2.2)
sdp-transform: 3.0.0
supports-color: 10.2.2
memoize-one@6.0.0: {} memoize-one@6.0.0: {}
meow@12.1.1: {} meow@12.1.1: {}
@ -7040,6 +7142,8 @@ snapshots:
scule@1.3.0: {} scule@1.3.0: {}
sdp-transform@3.0.0: {}
semver@6.3.1: {} semver@6.3.1: {}
semver@7.7.2: {} semver@7.7.2: {}
@ -7197,6 +7301,8 @@ snapshots:
stylis@4.3.6: {} stylis@4.3.6: {}
supports-color@10.2.2: {}
supports-color@7.2.0: supports-color@7.2.0:
dependencies: dependencies:
has-flag: 4.0.0 has-flag: 4.0.0

View File

@ -46,8 +46,6 @@ const { system, checkUpdate } = globalStore()
const isHomePageLoading = ref(false) const isHomePageLoading = ref(false)
const navMain = computed(() => { const navMain = computed(() => {
// const { purchasedInspector, nextInspectorCourseDate, conductInspectorPlanId } =
// inspectorInfo.value ?? {}
return [ return [
{ {
id: '2', id: '2',
@ -55,23 +53,6 @@ const navMain = computed(() => {
key: 'subject', key: 'subject',
path: '/subject', path: '/subject',
}, },
// {
// id: '3',
// url: homeUrge,
// key: 'urge',
// path: '',
// show: !isHide.value,
// // id: '3',
// // url: homeUrge,
// // key: 'urge',
// // path: conductInspectorPlanId
// // ? '/urge/learning'
// // : purchasedInspector
// // ? '/urge/timetable'
// // : '/urge/service',
// // desc: nextInspectorCourseDate && dayjs(nextInspectorCourseDate).format('MM-DD HH:mm'),
// // showTip: true,
// },
{ {
id: '5', id: '5',
url: homeTask, url: homeTask,
@ -95,6 +76,9 @@ const navMain = computed(() => {
] ]
}) })
const navMainVisible = computed(() => navMain.value.filter(item => !(item.show === false)))
const navMainIsFourGrid = computed(() => navMainVisible.value.length === 4)
const navLeft = computed(() => { const navLeft = computed(() => {
const hideAppSchools = ['勤智', '德艺', '领航', '黄古'] const hideAppSchools = ['勤智', '德艺', '领航', '黄古']
// //
@ -467,9 +451,9 @@ onUnmounted(() => {
@click="clickPhoto" @click="clickPhoto"
></div> ></div>
</div> --> </div> -->
<div class="right"> <div class="right" :class="{ 'right--four': navMainIsFourGrid }">
<template v-for="item in navMain" :key="item.id"> <template v-for="item in navMainVisible" :key="item.id">
<div v-if="!(item.show === false)" @click="handleNav(item)"> <div @click="handleNav(item)">
<img v-if="item" :src="item.url" /> <img v-if="item" :src="item.url" />
<Icon v-if="item.showTip" class="warning" name="warning" size="32" /> <Icon v-if="item.showTip" class="warning" name="warning" size="32" />
<!-- <div v-if="item.desc" class="desc">{{ item.desc }}</div> --> <!-- <div v-if="item.desc" class="desc">{{ item.desc }}</div> -->
@ -712,6 +696,28 @@ onUnmounted(() => {
font-family: $font-cn; font-family: $font-cn;
} }
} }
&--four {
$width: 30vh;
$rowGap: 5vh;
$columnGap: 20vh;
margin-top: -9vh;
display: grid;
grid-template-columns: repeat(2, $width);
grid-auto-rows: $width;
row-gap: $rowGap;
column-gap: $columnGap;
div {
width: $width;
height: $width;
img {
width: $width;
height: $width;
}
}
}
} }
} }
} }

View File

@ -0,0 +1,36 @@
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
const props = defineProps<{ stream: MediaStream | null }>()
const el = ref<HTMLVideoElement | null>(null)
watch(
() => props.stream,
async s => {
await nextTick()
const v = el.value
if (!v) return
v.srcObject = s
try {
await v.play()
} catch {
/* autoplay policy / transient */
}
},
{ immediate: true, flush: 'post' },
)
</script>
<template>
<!-- muted: remote video autoplay is often blocked without muted -->
<video ref="el" class="vid" autoplay playsinline muted />
</template>
<style scoped>
.vid {
width: 100%;
max-height: 70vh;
background: #000;
border-radius: 8px;
}
</style>

322
src/page/live/index.vue Normal file
View File

@ -0,0 +1,322 @@
<script setup lang="ts">
import StreamVideo from './StreamVideo.vue'
import { RoomWsClient } from './room-ws-client'
import type { Transport } from 'mediasoup-client/lib/types'
const roomId = ref('classroom')
const displayName = ref('学员')
const wsHost = ref('127.0.0.1')
const wsPort = ref(30443)
const status = ref('未连接')
const teacherStream = ref<MediaStream | null>(null)
const localPreview = ref<MediaStream | null>(null)
let client: RoomWsClient | null = null
let unsub: (() => void) | null = null
let sendTransport: Transport | null = null
let recvTransport: Transport | null = null
let teacherPeerId: string | null = null
const consumed = new Set<string>()
const pendingTeacherProducerIds: string[] = []
function wsUrl() {
return `ws://${wsHost.value}:${wsPort.value}`
}
function resetAfterRoomGone() {
teacherStream.value = null
recvTransport = null
sendTransport = null
teacherPeerId = null
consumed.clear()
pendingTeacherProducerIds.length = 0
localPreview.value?.getTracks().forEach(t => t.stop())
localPreview.value = null
unsub?.()
unsub = null
client?.close()
client = null
}
onMounted(() => {
try {
const q = new URLSearchParams(window.location.search)
const hp = q.get('port')
const hh = q.get('host')
if (hh?.trim()) wsHost.value = hh.trim()
if (hp) {
const n = Number(hp)
if (Number.isFinite(n) && n > 0) wsPort.value = n
}
} catch {
/* ignore */
}
})
async function consumeTeacher(recv: Transport, producerId: string) {
if (consumed.has(producerId)) return
if (!client) return
try {
const consumer = await client.consume(recv, producerId)
consumed.add(producerId)
teacherStream.value = new MediaStream([consumer.track])
consumer.on('producerclose', () => {
consumed.delete(producerId)
teacherStream.value = null
})
} catch (e) {
status.value = `拉取教师画面失败:${e instanceof Error ? e.message : String(e)}`
}
}
async function joinClass() {
teacherStream.value = null
localPreview.value?.getTracks().forEach(t => t.stop())
localPreview.value = null
consumed.clear()
pendingTeacherProducerIds.length = 0
unsub?.()
client?.close()
client = null
sendTransport = null
recvTransport = null
teacherPeerId = null
status.value = '连接信令…'
client = new RoomWsClient(wsUrl())
client.onDisconnect(() => {
status.value = '与教室断开,请重新点击「加入课堂」'
resetAfterRoomGone()
})
try {
await client.connect()
unsub = client.subscribe(msg => {
if (msg.type === 'newProducer' && msg.data) {
const d = msg.data as {
peerId: string
producerId: string
appData?: { role?: string }
}
if (d.appData?.role === 'teacher') {
teacherPeerId = d.peerId
if (recvTransport) void consumeTeacher(recvTransport, d.producerId)
else if (!pendingTeacherProducerIds.includes(d.producerId)) {
pendingTeacherProducerIds.push(d.producerId)
}
}
}
if (msg.type === 'peerLeft' && msg.data) {
const d = msg.data as { peerId: string; role?: string }
const teacherLeft =
d.role === 'teacher' || (teacherPeerId !== null && d.peerId === teacherPeerId)
if (teacherLeft) {
status.value = '未连接(教师端已断开,请重新点击「加入课堂」)'
resetAfterRoomGone()
}
}
if (msg.type === 'peerJoined' && msg.data) {
const d = msg.data as {
peerId: string
role?: string
self?: boolean
}
if (!d.self && d.role === 'teacher') {
teacherPeerId = d.peerId
}
}
if (msg.type === 'producerClosed' && msg.data) {
const d = msg.data as { producerId: string }
consumed.delete(d.producerId)
const i = pendingTeacherProducerIds.indexOf(d.producerId)
if (i !== -1) pendingTeacherProducerIds.splice(i, 1)
teacherStream.value = null
status.value = '教师已停止共享屏幕;恢复后请重新加入或等待教师再次推流'
}
})
const joined = await client.join(roomId.value, 'student', displayName.value)
await client.loadDevice(joined.rtpCapabilities)
recvTransport = await client.createRecvTransport()
for (const pid of pendingTeacherProducerIds.splice(0)) {
await consumeTeacher(recvTransport, pid)
}
for (const ep of joined.existingProducers) {
if (ep.appData?.role === 'teacher') {
teacherPeerId = ep.peerId
await consumeTeacher(recvTransport, ep.producerId)
}
}
status.value =
'已加入课堂。请点击「共享我的屏幕」将画面推送给教师(手机浏览器上必须点一次该按钮)。'
} catch (e) {
unsub?.()
unsub = null
client?.close()
client = null
recvTransport = null
sendTransport = null
status.value = `加入失败:${e instanceof Error ? e.message : String(e)}`
}
}
async function pushScreenToTeacher(): Promise<boolean> {
if (!client) {
status.value = '请先加入课堂'
return false
}
if (sendTransport) {
status.value = '已在向教师推送屏幕'
return true
}
if (!window.isSecureContext) {
status.value =
'当前页面不是安全上下文(例如用 http://局域网IP 打开)。屏幕共享会被浏览器禁用,请改用 HTTPS 部署,或使用 https://localhost / 本机调试。'
return false
}
const gdm = navigator.mediaDevices?.getDisplayMedia
if (typeof gdm !== 'function') {
status.value =
'当前浏览器不支持屏幕共享(无 getDisplayMedia。请换用 Chrome 等浏览器,或确认系统版本。'
return false
}
status.value = '正在采集屏幕以推送给教师…'
let stream: MediaStream
try {
stream = await gdm.call(navigator.mediaDevices, {
video: true,
audio: false,
})
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
status.value = `未开始共享(${msg})。请直接点击「共享我的屏幕」(不要依赖加入课堂后自动共享);若通过 http 访问局域网地址,需改为 HTTPS。`
return false
}
localPreview.value = stream
stream.getVideoTracks()[0].addEventListener('ended', () => {
sendTransport?.close()
sendTransport = null
localPreview.value?.getTracks().forEach(t => t.stop())
localPreview.value = null
status.value = '本机屏幕共享已结束;仍与课堂保持连接,可再次点击「共享我的屏幕」推送'
})
sendTransport = await client.createSendTransport()
await sendTransport.produce({
track: stream.getVideoTracks()[0],
appData: { source: 'screen' },
})
status.value = '已连接,正在向教师推送本机屏幕'
return true
}
async function shareMyScreen() {
await pushScreenToTeacher()
}
async function leave() {
resetAfterRoomGone()
status.value = '已离开'
}
onUnmounted(() => {
void leave()
})
</script>
<template>
<div class="live-student px-w">
<div class="app">
<header class="bar">
<h1>学员端</h1>
<div class="row">
<label>房间 <input v-model="roomId" /></label>
<label>显示名 <input v-model="displayName" /></label>
<label>WS 主机 <input v-model="wsHost" placeholder="教师机 IP" /></label>
<label>
端口
<input
v-model.number="wsPort"
type="number"
title="须与教师端界面上的端口一致(教师端可能为随机端口)"
/>
</label>
<button type="button" class="primary" @click="joinClass">加入课堂</button>
<button type="button" @click="shareMyScreen">共享我的屏幕</button>
<button type="button" @click="leave">离开</button>
</div>
<p class="status">{{ status }}</p>
</header>
<section class="main">
<h2>教师屏幕</h2>
<StreamVideo v-if="teacherStream" :stream="teacherStream" />
<p v-else class="muted">等待教师推流</p>
</section>
<section class="pip">
<h2>我的屏幕预览</h2>
<StreamVideo v-if="localPreview" :stream="localPreview" />
<p v-else class="muted">未共享</p>
</section>
</div>
</div>
</template>
<style scoped>
.live-student {
min-height: 100vh;
}
.app {
font-family: system-ui, sans-serif;
padding: 12px;
color: #e8e8ef;
background: #0f1419;
min-height: 100vh;
}
.bar h1 {
margin: 0 0 8px;
font-size: 1.25rem;
}
.row {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.row input {
width: 130px;
}
button {
padding: 6px 12px;
border-radius: 6px;
border: 1px solid #444;
background: #252b36;
color: inherit;
cursor: pointer;
}
button.primary {
background: #2f9e6d;
border-color: #2f9e6d;
}
.status {
margin: 8px 0 0;
opacity: 0.85;
font-size: 0.9rem;
}
.main,
.pip {
margin-top: 16px;
}
.main h2,
.pip h2 {
font-size: 1rem;
margin-bottom: 8px;
}
.muted {
opacity: 0.6;
}
.pip .vid {
max-height: 200px;
}
</style>

View File

@ -0,0 +1,276 @@
import * as mediasoupClient from 'mediasoup-client'
import type { Device, Transport, Consumer } from 'mediasoup-client/lib/types'
import type * as T from 'mediasoup-client/lib/types'
export type Role = 'teacher' | 'student'
type Handler = (msg: { type: string; data?: unknown }) => void
export class RoomWsClient {
private ws!: WebSocket
private pending = new Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void }>()
private seq = 0
private handlers: Handler[] = []
private device: Device | null = null
private onWsClosed: (() => void) | null = null
private voluntaryClose = false
private signalingEverOpened = false
constructor(private readonly url: string) {}
onDisconnect(handler: (() => void) | null) {
this.onWsClosed = handler
}
connect(timeoutMs = 20000): Promise<void> {
return new Promise((resolve, reject) => {
let settled = false
const finish = (fn: () => void) => {
if (settled) return
settled = true
clearTimeout(timer)
fn()
}
this.signalingEverOpened = false
this.ws = new WebSocket(this.url)
const timer = setTimeout(() => {
finish(() => {
this.voluntaryClose = true
try {
this.ws.close()
} catch {
/* ignore */
}
reject(
new Error(
`信令连接超时(${timeoutMs / 1000}s请确认教师端已启动 SFU、端口正确跨设备时「WS 主机」须填教师机局域网 IP勿使用 127.0.0.1`,
),
)
})
}, timeoutMs)
this.ws.addEventListener('open', () => {
this.signalingEverOpened = true
finish(() => resolve())
})
this.ws.addEventListener('error', () => {
finish(() =>
reject(
new Error(
'无法连接信令请核对「端口」与教师端状态栏里「SFU 已监听」的端口一致跨设备时「WS 主机」填教师机局域网 IP勿用 127.0.0.1);并确认教师已启动 SFU、防火墙放行该端口。',
),
),
)
})
this.ws.addEventListener('message', ev => this.handleRaw(ev.data))
this.ws.addEventListener('close', () => this.handleSocketClosed())
})
}
private handleSocketClosed() {
for (const [, p] of this.pending) {
p.reject(new Error('WebSocket closed'))
}
this.pending.clear()
const notify = !this.voluntaryClose
const hadOpened = this.signalingEverOpened
this.voluntaryClose = false
this.signalingEverOpened = false
if (notify && hadOpened) {
try {
this.onWsClosed?.()
} finally {
this.onWsClosed = null
}
} else {
this.onWsClosed = null
}
}
close() {
this.voluntaryClose = true
try {
this.ws?.close()
} catch {
/* ignore */
}
}
subscribe(handler: Handler) {
this.handlers.push(handler)
return () => {
this.handlers = this.handlers.filter(h => h !== handler)
}
}
private emit(msg: { type: string; data?: unknown }) {
for (const h of this.handlers) h(msg)
}
private handleRaw(raw: unknown) {
let msg: { type: string; requestId?: string; data?: unknown; error?: string }
try {
msg = JSON.parse(String(raw))
} catch {
return
}
if (msg.type === 'response') {
const p = this.pending.get(msg.requestId || '')
if (p) {
this.pending.delete(msg.requestId || '')
if (msg.error) p.reject(new Error(msg.error))
else p.resolve(msg.data)
}
return
}
this.emit(msg as { type: string; data?: unknown })
}
request<T>(type: string, data: Record<string, unknown> = {}): Promise<T> {
if (this.ws.readyState !== WebSocket.OPEN) {
return Promise.reject(new Error('WebSocket is not open'))
}
const requestId = `r${++this.seq}`
return new Promise((resolve, reject) => {
this.pending.set(requestId, { resolve: resolve as (v: unknown) => void, reject })
try {
this.ws.send(JSON.stringify({ type, requestId, data }))
} catch (e) {
this.pending.delete(requestId)
reject(e instanceof Error ? e : new Error(String(e)))
}
})
}
async join(roomId: string, role: Role, displayName: string) {
return (await this.request('join', { roomId, role, displayName })) as {
peerId: string
roomId: string
role: Role
rtpCapabilities: T.RtpCapabilities
existingProducers: {
peerId: string
producerId: string
kind: T.MediaKind
appData: Record<string, unknown>
}[]
}
}
async loadDevice(rtpCapabilities: T.RtpCapabilities) {
this.device = new mediasoupClient.Device()
await this.device.load({ routerRtpCapabilities: rtpCapabilities })
return this.device
}
getDevice(): Device {
if (!this.device) throw new Error('Device not loaded')
return this.device
}
async createWebRtcTransport(direction: 'send' | 'recv') {
return (await this.request('createWebRtcTransport', { direction })) as {
id: string
iceParameters: T.IceParameters
iceCandidates: T.IceCandidate[]
dtlsParameters: T.DtlsParameters
}
}
async connectWebRtcTransport(transportId: string, dtlsParameters: T.DtlsParameters) {
await this.request('connectWebRtcTransport', { transportId, dtlsParameters })
}
async createSendTransport(): Promise<Transport> {
const device = this.getDevice()
const opt = await this.createWebRtcTransport('send')
const transport = device.createSendTransport(opt)
transport.on(
'connect',
(
{ dtlsParameters }: { dtlsParameters: T.DtlsParameters },
cb: () => void,
err: (e: Error) => void,
) => {
this.connectWebRtcTransport(transport.id, dtlsParameters)
.then(() => cb())
.catch(err)
},
)
transport.on(
'produce',
async (
{
kind,
rtpParameters,
appData,
}: {
kind: T.MediaKind
rtpParameters: T.RtpParameters
appData?: Record<string, unknown>
},
cb: (args: { id: string }) => void,
err: (e: Error) => void,
) => {
try {
const { id } = (await this.request('produce', {
transportId: transport.id,
kind,
rtpParameters,
appData: appData || {},
})) as { id: string }
cb({ id })
} catch (e) {
err(e instanceof Error ? e : new Error(String(e)))
}
},
)
return transport
}
async createRecvTransport(): Promise<Transport> {
const device = this.getDevice()
const opt = await this.createWebRtcTransport('recv')
const transport = device.createRecvTransport(opt)
transport.on(
'connect',
(
{ dtlsParameters }: { dtlsParameters: T.DtlsParameters },
cb: () => void,
err: (e: Error) => void,
) => {
this.connectWebRtcTransport(transport.id, dtlsParameters)
.then(() => cb())
.catch(err)
},
)
return transport
}
async consume(recvTransport: Transport, producerId: string): Promise<Consumer> {
const device = this.getDevice()
const { id, kind, rtpParameters, type } = (await this.request('consume', {
transportId: recvTransport.id,
producerId,
rtpCapabilities: device.rtpCapabilities,
})) as {
id: string
kind: T.MediaKind
rtpParameters: T.RtpParameters
type: T.ConsumerType
}
const consumer = await recvTransport.consume({ id, producerId, kind, rtpParameters, type })
await this.request('resumeConsumer', { consumerId: consumer.id })
return consumer
}
async pauseConsumer(consumerId: string) {
await this.request('pauseConsumer', { consumerId })
}
async resumeConsumer(consumerId: string) {
await this.request('resumeConsumer', { consumerId })
}
}

10
src/page/live/routes.ts Normal file
View File

@ -0,0 +1,10 @@
import type { RouteRecordRaw } from 'vue-router'
const routes: RouteRecordRaw = {
name: 'live',
path: '/live',
component: () => import('./index.vue'),
meta: { title: '直播课堂(学员)', isPublic: true },
}
export default routes

View File

@ -16,6 +16,7 @@ const lead_black_list = [
const path_white_list = [ const path_white_list = [
'/', '/',
'/login', '/login',
'/live',
'/subject', // 学科 '/subject', // 学科
'/subject/learningArtifact', // 学习神器 '/subject/learningArtifact', // 学习神器
'/system/setting', // 设置 '/system/setting', // 设置