Skip to content
项目
群组
代码片段
帮助
当前项目
正在载入...
登录 / 注册
切换导航面板
S
saas-lab
项目
项目
详情
活动
周期分析
仓库
仓库
文件
提交
分支
标签
贡献者
图表
比较
统计图
议题
0
议题
0
列表
看板
标记
里程碑
合并请求
0
合并请求
0
CI / CD
CI / CD
流水线
作业
日程
统计图
Wiki
Wiki
代码片段
代码片段
成员
成员
折叠边栏
关闭边栏
活动
图像
聊天
创建新问题
作业
提交
问题看板
Open sidebar
EzijingWeb
saas-lab
Commits
3b1e4ca6
提交
3b1e4ca6
authored
7月 13, 2026
作者:
王鹏飞
浏览文件
操作
浏览文件
下载
电子邮件补丁
差异文件
feat: 新增直播监控功能,支持OSS流式上传与摄像头切换
上级
2c4d1c85
隐藏空白字符变更
内嵌
并排
正在显示
6 个修改的文件
包含
193 行增加
和
45 行删除
+193
-45
Index.vue
src/components/layout/Index.vue
+14
-5
useAppConfig.ts
src/composables/useAppConfig.ts
+1
-0
useLive.ts
src/composables/useLive.ts
+46
-10
useLiveMonitor.ts
src/composables/useLiveMonitor.ts
+76
-28
Index.vue
src/modules/student/lab/views/Index.vue
+0
-2
oss.ts
src/utils/oss.ts
+56
-0
没有找到文件。
src/components/layout/Index.vue
浏览文件 @
3b1e4ca6
...
...
@@ -8,16 +8,27 @@ import AppAside from './Aside.vue'
import
AppMain
from
'./Main.vue'
import
AppFooter
from
'./Footer.vue'
import
{
useMediaQuery
}
from
'@vueuse/core'
import
{
useLiveMonitor
}
from
'@/composables/useLiveMonitor'
import
{
useAppConfig
}
from
'@/composables/useAppConfig'
import
{
useUserStore
}
from
'@/stores/user'
const
appConfig
=
useAppConfig
()
const
userStore
=
useUserStore
()
if
(
appConfig
.
liveMonitor
&&
userStore
.
role
?.
id
===
1
)
{
useLiveMonitor
({
autoStart
:
true
,
isOSSUpload
:
true
})
}
const
props
=
withDefaults
(
defineProps
<
{
sidebar
?:
boolean
;
footer
?:
boolean
;
header
?:
boolean
}
>
(),
{
header
:
true
,
sidebar
:
false
,
footer
:
false
footer
:
false
,
})
const
isMobile
=
useMediaQuery
(
'(max-width: 767px)'
)
const
sidebarVisible
=
ref
(
false
)
const
closeSidebar
=
()
=>
{
sidebarVisible
.
value
=
false
}
const
closeSidebar
=
()
=>
{
sidebarVisible
.
value
=
false
}
const
showOverlaySidebar
=
computed
(()
=>
isMobile
.
value
&&
sidebarVisible
.
value
&&
props
.
sidebar
)
</
script
>
...
...
@@ -26,9 +37,7 @@ const showOverlaySidebar = computed(() => isMobile.value && sidebarVisible.value
<AppHeader
v-if=
"header"
></AppHeader>
<div
class=
"app-layout-container"
>
<div
v-if=
"showOverlaySidebar"
class=
"sidebar-overlay"
@
click=
"closeSidebar"
></div>
<AppAside
v-if=
"sidebar"
:class=
"
{ 'app-aside--overlay': isMobile, 'app-aside--visible': sidebarVisible }">
<AppAside
v-if=
"sidebar"
:class=
"
{ 'app-aside--overlay': isMobile, 'app-aside--visible': sidebarVisible }">
</AppAside>
<AppMain
@
click=
"closeSidebar"
></AppMain>
</div>
...
...
src/composables/useAppConfig.ts
浏览文件 @
3b1e4ca6
...
...
@@ -165,6 +165,7 @@ const appConfigList = [
// loginURL: urls.swsjfxsLogin,
studentMenus
:
[],
homeUrl
:
'/student/lab/competition'
,
liveMonitor
:
true
,
},
]
...
...
src/composables/useLive.ts
浏览文件 @
3b1e4ca6
...
...
@@ -20,12 +20,13 @@ export const readBlobAsBase64 = (blob: Blob): Promise<string> => {
interface
UseLiveProps
{
enabledUserMedia
?:
boolean
keepChunks
?:
boolean
onStart
?:
()
=>
void
onRecord
?:
(
data
:
Blob
)
=>
void
onStop
?:
(
blob
:
Blob
)
=>
void
}
export
function
useLive
({
enabledUserMedia
=
true
,
onStart
,
onRecord
,
onStop
}:
UseLiveProps
)
{
export
function
useLive
({
enabledUserMedia
=
true
,
keepChunks
=
true
,
onStart
,
onRecord
,
onStop
}:
UseLiveProps
)
{
const
startTime
=
ref
(
0
)
const
endTime
=
ref
(
0
)
const
duration
=
computed
(()
=>
(
endTime
.
value
-
startTime
.
value
)
/
1000
)
...
...
@@ -36,10 +37,25 @@ export function useLive({ enabledUserMedia = true, onStart, onRecord, onStop }:
const
currentCamera
=
computed
(()
=>
cameras
.
value
[
0
]?.
deviceId
)
const
currentMicrophone
=
computed
(()
=>
microphones
.
value
[
0
]?.
deviceId
)
// 移动端摄像头切换
const
facingMode
=
ref
<
'user'
|
'environment'
>
(
'user'
)
const
isFrontCamera
=
computed
(()
=>
facingMode
.
value
===
'user'
)
// 检测移动端
const
isMobile
=
/Android|iPhone|iPad|iPod/i
.
test
(
navigator
.
userAgent
)
||
window
.
innerWidth
<
768
// 响应式媒体约束:移动端使用 facingMode,桌面端使用 deviceId
const
constraints
=
computed
(()
=>
{
if
(
isMobile
)
{
return
{
video
:
{
facingMode
:
{
ideal
:
facingMode
.
value
}
},
audio
:
true
}
}
return
{
video
:
{
deviceId
:
currentCamera
.
value
as
any
},
audio
:
{
deviceId
:
currentMicrophone
.
value
as
any
}
}
})
// 创建媒体流
const
{
stream
,
restart
}
=
useUserMedia
({
enabled
:
enabledUserMedia
,
constraints
:
{
video
:
{
deviceId
:
currentCamera
as
any
},
audio
:
{
deviceId
:
currentMicrophone
as
any
}
}
,
constraints
:
constraints
as
any
,
})
// 录像设置
...
...
@@ -50,7 +66,8 @@ export function useLive({ enabledUserMedia = true, onStart, onRecord, onStop }:
// 初始化MediaRecorder
const
initializeMediaRecorder
=
()
=>
{
if
(
!
stream
.
value
)
return
mediaRecorder
=
new
MediaRecorder
(
stream
.
value
,
{
mimeType
:
'video/webm'
})
const
mimeType
=
MediaRecorder
.
isTypeSupported
(
'video/mp4'
)
?
'video/mp4'
:
'video/webm'
mediaRecorder
=
new
MediaRecorder
(
stream
.
value
,
{
mimeType
})
mediaRecorder
.
ondataavailable
=
handleDataAvailable
mediaRecorder
.
onstart
=
handleStart
mediaRecorder
.
onstop
=
handleStop
...
...
@@ -59,7 +76,7 @@ export function useLive({ enabledUserMedia = true, onStart, onRecord, onStop }:
// 数据可用时处理
const
handleDataAvailable
=
(
event
:
BlobEvent
)
=>
{
if
(
event
.
data
.
size
>
0
)
{
recordedChunks
.
value
.
push
(
event
.
data
)
if
(
keepChunks
)
recordedChunks
.
value
.
push
(
event
.
data
)
onRecord
&&
onRecord
(
event
.
data
)
}
}
...
...
@@ -68,7 +85,8 @@ export function useLive({ enabledUserMedia = true, onStart, onRecord, onStop }:
const
handleStart
=
()
=>
{
startTime
.
value
=
Date
.
now
()
onStart
&&
onStart
()
// Update currentTime every 100ms while recording
// Update currentTime every 5s while recording
timeUpdateInterval
=
setInterval
(()
=>
{
currentTime
.
value
=
Math
.
floor
((
Date
.
now
()
-
startTime
.
value
)
/
1000
)
},
1000
*
5
)
...
...
@@ -92,7 +110,7 @@ export function useLive({ enabledUserMedia = true, onStart, onRecord, onStop }:
const
start
=
()
=>
{
if
(
!
mediaRecorder
)
initializeMediaRecorder
()
recordedChunks
.
value
=
[]
mediaRecorder
?.
start
(
100
)
// 每100ms触发一次dataavailable事件
mediaRecorder
?.
start
(
3000
)
}
// 停止录制
...
...
@@ -103,13 +121,31 @@ export function useLive({ enabledUserMedia = true, onStart, onRecord, onStop }:
// 重新开始
const
handleRestart
=
async
()
=>
{
// 停止录制
stop
()
// 重新获取流
await
restart
()
// 开始录制
start
()
}
return
{
stream
,
start
,
stop
,
restart
:
handleRestart
,
startTime
,
endTime
,
duration
,
currentTime
}
// 切换摄像头(移动端前置/后置)
const
switchCamera
=
()
=>
{
if
(
mediaRecorder
?.
state
===
'recording'
)
return
facingMode
.
value
=
facingMode
.
value
===
'user'
?
'environment'
:
'user'
// 流自动更新后重新初始化 MediaRecorder
nextTick
(()
=>
initializeMediaRecorder
())
}
return
{
stream
,
start
,
stop
,
restart
:
handleRestart
,
startTime
,
endTime
,
duration
,
currentTime
,
switchCamera
,
facingMode
,
isFrontCamera
,
isMobile
,
}
}
src/composables/useLiveMonitor.ts
浏览文件 @
3b1e4ca6
import
{
useUserStore
}
from
'@/stores/user'
import
{
useLive
,
readBlobAsBase64
}
from
'@/composables/useLive'
import
{
useSocket
}
from
'@/composables/useSocket'
import
{
appendUpload
}
from
'@/utils/oss'
import
md5
from
'blueimp-md5'
import
{
ElMessageBox
}
from
'element-plus'
import
{
usePermission
,
useIntervalFn
}
from
'@vueuse/core'
export
function
useLiveMonitor
({
autoStart
=
false
}:
{
autoStart
?:
boolean
}
=
{})
{
export
function
useLiveMonitor
({
autoStart
=
false
,
isOSSUpload
=
true
,
}:
{
autoStart
?:
boolean
isOSSUpload
?:
boolean
}
=
{})
{
const
userStore
=
useUserStore
()
const
ssoId
=
userStore
.
user
?.
id
const
fileUrl
=
ref
(
''
)
const
fileName
=
computed
(()
=>
md5
(
`
${
ssoId
}${
startTime
.
value
}
`
))
// WebSocket 设置
const
{
send
}
=
useSocket
({
onMessage
:
(
ws
,
event
)
=>
{
try
{
const
data
=
JSON
.
parse
(
event
.
data
)
if
(
data
?.
type
===
'video_rtc'
)
{
fileUrl
.
value
=
data
.
data
.
uri
const
dateStr
=
new
Date
().
toISOString
().
slice
(
0
,
10
).
replace
(
/-/g
,
''
)
let
ossResult
:
any
=
null
let
send
:
(
data
:
string
)
=>
void
=
()
=>
{}
if
(
!
isOSSUpload
)
{
const
socket
=
useSocket
({
onMessage
:
(
ws
,
event
)
=>
{
try
{
const
data
=
JSON
.
parse
(
event
.
data
)
if
(
data
?.
type
===
'video_rtc'
)
{
fileUrl
.
value
=
data
.
data
.
uri
}
}
catch
(
error
)
{
console
.
error
(
'Failed to parse message:'
,
error
)
}
}
catch
(
error
)
{
console
.
error
(
'Failed to parse message:'
,
error
)
}
},
})
},
})
send
=
socket
.
send
}
const
{
startTime
,
start
,
stop
,
restart
}
=
useLive
({
const
{
startTime
,
start
,
stop
,
restart
,
stream
}
=
useLive
({
enabledUserMedia
:
autoStart
,
keepChunks
:
false
,
onRecord
:
async
(
blob
)
=>
{
const
base64Data
=
await
readBlobAsBase64
(
blob
)
const
jsonData
=
JSON
.
stringify
({
type
:
'send'
,
sso_id
:
ssoId
,
data
:
{
type
:
'video_rtc'
,
channel
:
'rtc'
,
data
:
{
video
:
base64Data
,
file_name
:
fileName
.
value
}
},
})
send
(
jsonData
)
if
(
isOSSUpload
)
{
try
{
ossResult
=
await
appendUpload
(
blob
,
{
name
:
`date_
${
dateStr
}
/user_
${
ssoId
}
/
${
fileName
.
value
}
.mp4`
,
position
:
ossResult
?.
nextAppendPosition
,
})
fileUrl
.
value
=
ossResult
.
url
}
catch
(
error
)
{
console
.
error
(
'OSS append upload failed:'
,
error
)
}
}
else
{
const
base64Data
=
await
readBlobAsBase64
(
blob
)
send
(
JSON
.
stringify
({
type
:
'send'
,
sso_id
:
ssoId
,
data
:
{
type
:
'video_rtc'
,
channel
:
'rtc'
,
data
:
{
video
:
base64Data
,
file_name
:
fileName
.
value
}
},
}),
)
}
},
})
...
...
@@ -44,13 +72,8 @@ export function useLiveMonitor({ autoStart = false }: { autoStart?: boolean } =
const
showMessageBox
=
()
=>
{
if
(
hasMessageBox
.
value
)
return
hasMessageBox
.
value
=
true
ElMessageBox
.
alert
(
'本次考试要求全程开启摄像头,请点击
‘确定’
允许摄像头访问,以便正常参加考试。'
,
'温馨提示'
,
{
ElMessageBox
.
alert
(
'本次考试要求全程开启摄像头,请点击
"确定"
允许摄像头访问,以便正常参加考试。'
,
'温馨提示'
,
{
confirmButtonText
:
'确定'
,
// beforeClose: (action, instance, done) => {
// console.log('stream', stream.value)
// console.log('cameraPermission', cameraPermission.value)
// if (stream.value && cameraPermission.value === 'granted') done()
// },
callback
:
()
=>
{
hasMessageBox
.
value
=
false
restart
()
...
...
@@ -62,11 +85,36 @@ export function useLiveMonitor({ autoStart = false }: { autoStart?: boolean } =
if
(
cameraPermission
.
value
===
'denied'
)
showMessageBox
()
},
1000
*
10
)
// iframe 直播协作:直播开始时暂停监控释放摄像头,直播结束后恢复
const
monitored
=
ref
(
false
)
const
monitorPause
=
()
=>
{
if
(
!
monitored
.
value
)
return
stop
()
stream
.
value
?.
getTracks
().
forEach
((
t
)
=>
t
.
stop
())
monitored
.
value
=
false
}
const
monitorResume
=
async
()
=>
{
if
(
monitored
.
value
)
return
await
restart
()
start
()
monitored
.
value
=
true
}
const
handleIframeMessage
=
(
event
:
MessageEvent
)
=>
{
if
(
event
.
data
?.
action
===
'monitor-pause'
)
monitorPause
()
if
(
event
.
data
?.
action
===
'monitor-resume'
)
monitorResume
()
}
onMounted
(()
=>
{
if
(
autoStart
)
showMessageBox
()
window
.
addEventListener
(
'message'
,
handleIframeMessage
)
if
(
autoStart
)
{
monitored
.
value
=
true
showMessageBox
()
}
})
onUnmounted
(()
=>
{
window
.
removeEventListener
(
'message'
,
handleIframeMessage
)
stop
()
})
...
...
src/modules/student/lab/views/Index.vue
浏览文件 @
3b1e4ca6
...
...
@@ -13,10 +13,8 @@ import html2pdf from 'html2pdf.js'
import
{
useCookies
}
from
'@vueuse/integrations/useCookies'
import
{
useAppConfig
}
from
'@/composables/useAppConfig'
import
{
urls
}
from
'@/utils/env'
// import { useLiveMonitor } from '@/composables/useLiveMonitor'
const
appConfig
=
useAppConfig
()
// useLiveMonitor({ autoStart: !!appConfig.liveMonitor })
const
Question
=
defineAsyncComponent
(()
=>
import
(
'../components/Question.vue'
))
const
Info
=
defineAsyncComponent
(()
=>
import
(
'../components/Info.vue'
))
...
...
src/utils/oss.ts
0 → 100644
浏览文件 @
3b1e4ca6
import
OSS
from
'ali-oss'
import
md5
from
'blueimp-md5'
import
{
getToken
}
from
'@/api/base'
let
store
:
OSS
|
null
=
null
async
function
getTokenInfo
()
{
const
response
:
any
=
await
getToken
()
return
{
accessKeyId
:
response
.
Credentials
.
AccessKeyId
,
accessKeySecret
:
response
.
Credentials
.
AccessKeySecret
,
stsToken
:
response
.
Credentials
.
SecurityToken
,
}
}
async
function
createStore
()
{
if
(
store
)
return
store
const
response
=
await
getTokenInfo
()
return
(
store
=
new
OSS
({
bucket
:
'webapp-pub'
,
region
:
'oss-cn-beijing'
,
accessKeyId
:
response
.
accessKeyId
,
accessKeySecret
:
response
.
accessKeySecret
,
stsToken
:
response
.
stsToken
,
refreshSTSToken
:
()
=>
getTokenInfo
(),
cname
:
true
,
endpoint
:
'https://webapp-pub.ezijing.com'
,
}))
}
async
function
parseParams
(
file
:
Blob
|
File
,
options
:
any
=
{})
{
const
{
name
,
prefix
=
'upload/saas-lab/monitor/'
,
...
rest
}
=
options
let
objectName
:
string
if
(
name
)
{
objectName
=
`
${
prefix
}${
name
}
`
}
else
{
const
ext
=
file
instanceof
File
?
file
.
name
.
split
(
'.'
).
pop
()
||
'mp4'
:
'mp4'
objectName
=
`
${
prefix
}${
md5
(
new
Date
().
getTime
()
+
Math
.
random
().
toString
(
36
).
slice
(
-
8
))}
.
${
ext
}
`
}
return
{
name
:
objectName
,
file
,
options
:
rest
}
}
/** 追加上传 — 录制时流式上传视频 chunk */
export
async
function
appendUpload
(
file
:
Blob
|
File
,
options
?:
any
)
{
const
store
=
await
createStore
()
const
params
=
await
parseParams
(
file
,
options
)
return
store
.
append
(
params
.
name
,
params
.
file
,
params
.
options
)
}
/** 分片上传 — 完整文件一次性上传 */
export
async
function
multipartUpload
(
file
:
Blob
|
File
,
options
?:
any
)
{
const
store
=
await
createStore
()
const
params
=
await
parseParams
(
file
,
options
)
const
res
:
any
=
await
store
.
multipartUpload
(
params
.
name
,
params
.
file
,
params
.
options
)
return
{
url
:
`https://webapp-pub.ezijing.com/
${
res
.
name
}
`
,
...
res
}
}
编写
预览
Markdown
格式
0%
重试
或
添加新文件
添加附件
取消
您添加了
0
人
到此讨论。请谨慎行事。
请先完成此评论的编辑!
取消
请
注册
或者
登录
后发表评论