Skip to content
项目
群组
代码片段
帮助
当前项目
正在载入...
登录 / 注册
切换导航面板
S
saas-lab
项目
项目
详情
活动
周期分析
仓库
仓库
文件
提交
分支
标签
贡献者
图表
比较
统计图
议题
0
议题
0
列表
看板
标记
里程碑
合并请求
0
合并请求
0
CI / CD
CI / CD
流水线
作业
日程
统计图
Wiki
Wiki
代码片段
代码片段
成员
成员
折叠边栏
关闭边栏
活动
图像
聊天
创建新问题
作业
提交
问题看板
Open sidebar
EzijingWeb
saas-lab
Commits
e6f35ea5
提交
e6f35ea5
authored
8月 07, 2025
作者:
王鹏飞
浏览文件
操作
浏览文件
下载
电子邮件补丁
差异文件
feat: add live monitor feature to app configuration
上级
229242a6
显示空白字符变更
内嵌
并排
正在显示
6 个修改的文件
包含
238 行增加
和
0 行删除
+238
-0
useAppConfig.ts
src/composables/useAppConfig.ts
+1
-0
useLive.ts
src/composables/useLive.ts
+104
-0
useLiveMonitor.ts
src/composables/useLiveMonitor.ts
+58
-0
useSocket.ts
src/composables/useSocket.ts
+21
-0
Live.vue
src/modules/student/lab/components/Live.vue
+51
-0
Index.vue
src/modules/student/lab/views/Index.vue
+3
-0
没有找到文件。
src/composables/useAppConfig.ts
浏览文件 @
e6f35ea5
...
@@ -38,6 +38,7 @@ const appConfigList = [
...
@@ -38,6 +38,7 @@ const appConfigList = [
],
],
},
},
],
],
liveMonitor
:
true
,
},
},
{
{
system
:
'game'
,
system
:
'game'
,
...
...
src/composables/useLive.ts
0 → 100644
浏览文件 @
e6f35ea5
import
{
useDevicesList
,
useUserMedia
}
from
'@vueuse/core'
export
const
readBlobAsBase64
=
(
blob
:
Blob
):
Promise
<
string
>
=>
{
return
new
Promise
((
resolve
,
reject
)
=>
{
const
reader
=
new
FileReader
()
reader
.
onloadend
=
()
=>
{
try
{
const
base64Data
=
(
reader
.
result
as
string
).
split
(
','
)[
2
]
resolve
(
base64Data
)
}
catch
(
error
)
{
reject
(
error
)
}
}
reader
.
onerror
=
()
=>
{
reject
(
new
Error
(
'Failed to read file'
))
}
reader
.
readAsDataURL
(
blob
)
})
}
interface
UseLiveProps
{
enabledUserMedia
?:
boolean
onStart
?:
()
=>
void
onRecord
?:
(
data
:
Blob
)
=>
void
onStop
?:
(
blob
:
Blob
)
=>
void
}
export
function
useLive
({
enabledUserMedia
=
true
,
onStart
,
onRecord
,
onStop
}:
UseLiveProps
)
{
const
startTime
=
ref
(
0
)
const
endTime
=
ref
(
0
)
const
duration
=
computed
(()
=>
(
endTime
.
value
-
startTime
.
value
)
/
1000
)
const
currentTime
=
ref
(
0
)
// 获取设备列表并设置默认设备
const
{
videoInputs
:
cameras
,
audioInputs
:
microphones
}
=
useDevicesList
({
requestPermissions
:
true
})
const
currentCamera
=
computed
(()
=>
cameras
.
value
[
0
]?.
deviceId
)
const
currentMicrophone
=
computed
(()
=>
microphones
.
value
[
0
]?.
deviceId
)
// 创建媒体流
const
{
stream
}
=
useUserMedia
({
enabled
:
enabledUserMedia
,
constraints
:
{
video
:
{
deviceId
:
currentCamera
as
any
},
audio
:
{
deviceId
:
currentMicrophone
as
any
}
},
})
// 录像设置
const
recordedChunks
=
ref
<
Blob
[]
>
([])
let
mediaRecorder
:
MediaRecorder
|
null
=
null
let
timeUpdateInterval
:
number
|
null
=
null
// 初始化MediaRecorder
const
initializeMediaRecorder
=
()
=>
{
if
(
!
stream
.
value
)
return
mediaRecorder
=
new
MediaRecorder
(
stream
.
value
,
{
mimeType
:
'video/webm'
})
mediaRecorder
.
ondataavailable
=
handleDataAvailable
mediaRecorder
.
onstart
=
handleStart
mediaRecorder
.
onstop
=
handleStop
}
// 数据可用时处理
const
handleDataAvailable
=
(
event
:
BlobEvent
)
=>
{
if
(
event
.
data
.
size
>
0
)
{
recordedChunks
.
value
.
push
(
event
.
data
)
onRecord
&&
onRecord
(
event
.
data
)
}
}
// 录像开始时处理
const
handleStart
=
()
=>
{
startTime
.
value
=
Date
.
now
()
onStart
&&
onStart
()
// Update currentTime every 100ms while recording
timeUpdateInterval
=
setInterval
(()
=>
{
currentTime
.
value
=
Math
.
floor
((
Date
.
now
()
-
startTime
.
value
)
/
1000
)
},
1000
*
5
)
}
// 录像停止时处理
const
handleStop
=
()
=>
{
endTime
.
value
=
Date
.
now
()
const
blob
=
new
Blob
(
recordedChunks
.
value
,
{
type
:
mediaRecorder
?.
mimeType
})
onStop
&&
onStop
(
blob
)
recordedChunks
.
value
=
[]
// Clear the interval when recording stops
if
(
timeUpdateInterval
!==
null
)
{
clearInterval
(
timeUpdateInterval
)
timeUpdateInterval
=
null
}
}
// 开始录制
const
start
=
()
=>
{
if
(
!
mediaRecorder
)
initializeMediaRecorder
()
recordedChunks
.
value
=
[]
mediaRecorder
?.
start
(
100
)
// 每100ms触发一次dataavailable事件
}
// 停止录制
const
stop
=
()
=>
{
if
(
mediaRecorder
)
mediaRecorder
.
stop
()
}
return
{
stream
,
start
,
stop
,
startTime
,
endTime
,
duration
,
currentTime
}
}
src/composables/useLiveMonitor.ts
0 → 100644
浏览文件 @
e6f35ea5
import
{
useUserStore
}
from
'@/stores/user'
import
{
useLive
,
readBlobAsBase64
}
from
'@/composables/useLive'
import
{
useSocket
}
from
'@/composables/useSocket'
import
md5
from
'blueimp-md5'
import
{
ElMessageBox
}
from
'element-plus'
export
function
useLiveMonitor
({
autoStart
=
false
}:
{
autoStart
?:
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
}
}
catch
(
error
)
{
console
.
error
(
'Failed to parse message:'
,
error
)
}
},
})
const
{
stream
,
startTime
,
start
,
stop
}
=
useLive
({
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
)
},
})
watchEffect
(()
=>
{
if
(
autoStart
)
{
ElMessageBox
.
alert
(
'本次考试要求全程开启摄像头,请点击‘确定’允许摄像头访问,以便正常参加考试。'
,
'温馨提示'
,
{
confirmButtonText
:
'确定'
,
beforeClose
:
(
action
,
instance
,
done
)
=>
{
if
(
stream
.
value
)
done
()
},
callback
:
()
=>
{
start
()
},
})
}
})
onUnmounted
(()
=>
{
stop
()
})
return
{
fileUrl
,
fileName
,
start
,
stop
}
}
src/composables/useSocket.ts
0 → 100644
浏览文件 @
e6f35ea5
import
{
useWebSocket
,
type
UseWebSocketOptions
}
from
'@vueuse/core'
import
{
useUserStore
}
from
'@/stores/user'
export
function
useSocket
(
options
:
UseWebSocketOptions
)
{
const
userStore
=
useUserStore
()
const
ssoId
=
userStore
.
user
?.
id
const
defaultOptions
=
{
autoReconnect
:
true
,
onConnected
:
()
=>
{
send
(
JSON
.
stringify
({
type
:
'login'
,
sso_id
:
ssoId
}))
},
heartbeat
:
{
message
:
JSON
.
stringify
({
type
:
'health'
,
sso_id
:
ssoId
}),
interval
:
1000
*
50
,
pongTimeout
:
1000
},
}
const
{
status
,
data
,
send
,
open
,
close
}
=
useWebSocket
(
'wss://saas-lab-api.ezijing.com/wss'
,
{
...
defaultOptions
,
...
options
,
})
return
{
status
,
data
,
send
,
open
,
close
}
}
src/modules/student/lab/components/Live.vue
0 → 100644
浏览文件 @
e6f35ea5
<
script
setup
lang=
"ts"
>
import
{
useUserStore
}
from
'@/stores/user'
import
{
useLive
,
readBlobAsBase64
}
from
'@/composables/useLive'
import
{
useSocket
}
from
'@/composables/useSocket'
import
md5
from
'blueimp-md5'
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
}
}
catch
(
error
)
{
console
.
error
(
'Failed to parse message:'
,
error
)
}
},
})
const
{
startTime
,
start
,
stop
}
=
useLive
({
onRecord
:
async
(
blob
)
=>
{
console
.
log
(
'onRecord'
,
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
)
},
})
onMounted
(()
=>
{
start
()
})
onUnmounted
(()
=>
{
stop
()
})
</
script
>
<
template
>
<div
class=
"live"
></div>
</
template
>
src/modules/student/lab/views/Index.vue
浏览文件 @
e6f35ea5
...
@@ -11,7 +11,10 @@ import { saveAs } from 'file-saver'
...
@@ -11,7 +11,10 @@ import { saveAs } from 'file-saver'
import
html2pdf
from
'html2pdf.js'
import
html2pdf
from
'html2pdf.js'
import
{
useCookies
}
from
'@vueuse/integrations/useCookies'
import
{
useCookies
}
from
'@vueuse/integrations/useCookies'
import
{
useAppConfig
}
from
'@/composables/useAppConfig'
import
{
useAppConfig
}
from
'@/composables/useAppConfig'
import
{
useLiveMonitor
}
from
'@/composables/useLiveMonitor'
const
appConfig
=
useAppConfig
()
const
appConfig
=
useAppConfig
()
useLiveMonitor
({
autoStart
:
appConfig
.
liveMonitor
})
const
Question
=
defineAsyncComponent
(()
=>
import
(
'../components/Question.vue'
))
const
Question
=
defineAsyncComponent
(()
=>
import
(
'../components/Question.vue'
))
const
Info
=
defineAsyncComponent
(()
=>
import
(
'../components/Info.vue'
))
const
Info
=
defineAsyncComponent
(()
=>
import
(
'../components/Info.vue'
))
...
...
编写
预览
Markdown
格式
0%
重试
或
添加新文件
添加附件
取消
您添加了
0
人
到此讨论。请谨慎行事。
请先完成此评论的编辑!
取消
请
注册
或者
登录
后发表评论