Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
Y
yd-middle-front
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
1
Merge Requests
1
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
xingmin
yd-middle-front
Commits
0961b634
Commit
0961b634
authored
Aug 07, 2026
by
zhangxingmin
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
push
parent
d23ea9b2
Show whitespace changes
Inline
Side-by-side
Showing
2 changed files
with
992 additions
and
421 deletions
+992
-421
src/api/system/report.js
+72
-8
src/views/system/report/index.vue
+920
-413
No files found.
src/api/system/report.js
View file @
0961b634
import
request
from
'@/utils/request'
// 从您的 request 工具中获取 baseURL(假设 request 是 axios 实例)
import
request
from
'@/utils/request'
// 您的 axios 实例
import
{
getToken
}
from
'@/utils/auth'
// 您的 token 获取方法
const
baseURL
=
request
.
defaults
.
baseURL
||
process
.
env
.
VUE_APP_BASE_API
||
''
/**
* 查询报告列表(模拟接口)
...
...
@@ -59,6 +63,39 @@ export function joinCoSession(data) {
// ==================== 录制相关接口 ====================
/**
// * 开始录制
// * @param {Object} data - { bizType, bizId }
// */
// export function startRecording(data) {
// return request({
// url: '/communication/api/recordingTask/start',
// method: 'post',
// data: data
// })
// }
//
// /**
// * 停止录制并上传视频
// * @param {string} taskId - 录制任务ID
// * @param {File} file - 视频文件
// */
// export function stopRecording(taskId, file) {
// const formData = new FormData()
// formData.append('taskId', taskId)
// formData.append('file', file)
// return request({
// url: '/communication/api/recordingTask/stop',
// method: 'post',
// data: formData,
// headers: {
// 'Content-Type': 'multipart/form-data'
// }
// })
// }
// ==================== 录制相关接口 ====================
/**
* 开始录制
* @param {Object} data - { bizType, bizId }
*/
...
...
@@ -71,21 +108,48 @@ export function startRecording(data) {
}
/**
* 停止录制并上传视频
* @param {string} taskId - 录制任务ID
* @param {File} file - 视频文件
* 上传单个分片
* @param {Object} params
* @param {string} params.taskId - 录制任务ID
* @param {number} params.chunkIndex - 分片索引(从0开始)
* @param {File} params.chunk - 分片文件(Blob/File)
* @param {string} params.projectBizId - 项目业务ID(用于获取OSS配置)
* @param {string} params.source - 来源标识(固定为 'recording')
*/
export
function
stopRecording
(
taskId
,
file
)
{
export
function
uploadChunk
({
taskId
,
chunkIndex
,
chunk
,
projectBizId
,
source
}
)
{
const
formData
=
new
FormData
()
formData
.
append
(
'
taskId'
,
taskId
)
formData
.
append
(
'file'
,
file
)
formData
.
append
(
'
chunk'
,
chunk
)
// 只放文件
return
request
({
url
:
'/
communication/api/recordingTask/stop
'
,
url
:
'/
oss/api/chunkedUploadContext/uploadChunk
'
,
method
:
'post'
,
params
:
{
taskId
:
taskId
,
chunkIndex
:
chunkIndex
,
projectBizId
:
''
,
source
:
'recording'
},
data
:
formData
,
headers
:
{
'Content-Type'
:
'multipart/form-data'
},
transformRequest
:
[
function
(
data
)
{
return
data
}
]
})
}
/**
* 停止录制(触发合并)
* @param {string} taskId - 录制任务ID
*/
export
function
stopRecording
(
taskId
)
{
return
request
({
url
:
'/communication/api/recordingTask/stop'
,
method
:
'post'
,
params
:
{
taskId
}
// 使用 query 参数
})
}
...
...
src/views/system/report/index.vue
View file @
0961b634
...
...
@@ -7,85 +7,91 @@
<span
class=
"role-badge"
:class=
"role === 'customer' ? 'customer' : 'consultant'"
>
{{
role
===
'customer'
?
'👤 客户'
:
'👤 顾问'
}}
</span>
<span
v-if=
"presenter !== 'none'"
class=
"presenter-badge"
>
📺
{{
presenter
===
role
?
'您正在投屏'
:
'对方投屏中'
}}
</span>
<span
v-if=
"remoteControlActive"
class=
"remote-badge"
>
🖱️
{{
remoteControlDirection
===
'controlling'
?
'正在控制对方'
:
'正在被控制'
}}
</span>
</div>
<div
class=
"header-center"
>
<span
class=
"title"
>
{{
currentArticle
?.
title
||
'加载中...'
}}
</span>
<span
class=
"page-info"
>
第
{{
currentPage
+
1
}}
/
{{
articles
.
length
}}
篇
</span>
</div>
<div
class=
"header-right"
>
<el-badge
:value=
"recordingStatus === 'recording' ? '录制中' : recordingStatus === 'stopped' ? '已录制' : '未录制'"
:type=
"recordingStatus === 'recording' ? 'danger' : recordingStatus === 'stopped' ? 'success' : 'info'"
>
<el-badge
:value=
"recordingStatus === 'recording' ? '录制中' : recordingStatus === 'stopped' ? '已录制' : '未录制'"
:type=
"recordingStatus === 'recording' ? 'danger' : recordingStatus === 'stopped' ? 'success' : 'info'"
>
<el-button
link
type=
"primary"
icon=
"VideoCamera"
@
click=
"toggleRecording"
/>
</el-badge>
<el-badge
:value=
"wsConnected ? '在线' : '离线'"
:type=
"wsConnected ? 'success' : 'danger'"
>
<el-button
link
type=
"primary"
icon=
"Connection"
/>
</el-badge>
<el-button
type=
"primary"
size=
"small"
@
click=
"testRemoteClick"
>
🖱️ 测试点击
</el-button>
</div>
</div>
<!--
文章
内容区域 -->
<!-- 内容区域 -->
<div
class=
"reader-body"
ref=
"readerBodyRef"
>
<!-- 顾问且正在接收屏幕流时显示视频 -->
<div
v-if=
"role === 'consultant' && isReceivingScreen"
class=
"video-container"
>
<div
v-if=
"isReceivingScreen"
class=
"video-container"
>
<video
ref=
"remoteVideoRef"
autoplay
playsinline
></video>
<!--
视频上方的控制覆盖层
-->
<div
class=
"controls-overlay"
>
<!--
控制条仅对投屏者显示
-->
<div
class=
"controls-overlay"
v-if=
"presenter === role"
>
<div
class=
"nav-controls"
>
<button
class=
"nav-btn"
:class=
"
{ disabled: !canOperate }" @click="prevPage" :disabled="!canOperate">
<span>
◀
</span>
</button>
<button
class=
"nav-btn"
:class=
"
{ disabled: !canOperate }" @click="prevPage" :disabled="!canOperate">◀
</button>
<span
class=
"page-text"
>
{{
currentPage
+
1
}}
/
{{
articles
.
length
}}
</span>
<button
class=
"nav-btn"
:class=
"
{ disabled: !canOperate }" @click="nextPage" :disabled="!canOperate">
<span>
▶
</span>
</button>
<button
class=
"nav-btn"
:class=
"
{ disabled: !canOperate }" @click="nextPage" :disabled="!canOperate">▶
</button>
</div>
<div
class=
"zoom-controls-overlay"
>
<button
class=
"zoom-btn"
@
click=
"zoomOut"
:disabled=
"!canOperate"
>
−
</button>
<span>
{{
Math
.
round
(
zoomLevel
*
100
)
}}
%
</span>
<button
class=
"zoom-btn"
@
click=
"zoomIn"
:disabled=
"!canOperate"
>
+
</button>
</div>
<div
class=
"status-tip"
v-if=
"!canOperate"
>
<span>
🔒 顾问正在操作
</span>
<div
class=
"status-tip"
v-if=
"!canOperate"
>
🔒
{{
role
===
'customer'
?
'顾问'
:
'客户'
}}
正在操作
</div>
</div>
<!-- 远程控制按钮 -->
<div
class=
"video-actions"
>
<el-button
v-if=
"!remoteControlActive && presenter !== roleMap[role]"
size=
"small"
type=
"warning"
@
click=
"requestRemoteControl"
>
请求远程控制
</el-button>
<el-button
v-if=
"remoteControlActive && remoteControlDirection === 'being_controlled'"
size=
"small"
type=
"danger"
@
click=
"stopRemoteControl"
>
中断控制
</el-button>
</div>
</div>
<!-- 否则显示文章内容 -->
<div
v-else
class=
"article-wrapper"
:style=
"
{ transform: `scale(${zoomLevel})`, transformOrigin: 'top left' }">
<div
class=
"article-content"
ref=
"articleContentRef"
v-html=
"currentArticle?.content || ''"
></div>
</div>
</div>
<!-- 底部翻页控制(客户可见,顾问在视频模式下隐藏) -->
<div
class=
"reader-footer"
v-if=
"!(role === 'consultant' && isReceivingScreen)"
>
<div
class=
"reader-footer"
v-if=
"!isReceivingScreen"
>
<el-button
type=
"primary"
plain
circle
icon=
"ArrowLeft"
@
click=
"prevPage"
:disabled=
"!canOperate"
/>
<el-slider
v-model=
"currentPage"
:min=
"0"
:max=
"articles.length - 1"
:show-tooltip=
"false"
style=
"flex:1; margin:0 16px;"
@
change=
"onSliderChange"
:disabled=
"!canOperate"
/>
<el-button
type=
"primary"
plain
circle
icon=
"ArrowRight"
@
click=
"nextPage"
:disabled=
"!canOperate"
/>
</div>
<!-- 缩放控制(客户可见,顾问在视频模式下隐藏) -->
<div
class=
"zoom-controls"
v-if=
"!(role === 'consultant' && isReceivingScreen)"
>
<div
class=
"zoom-controls"
v-if=
"!isReceivingScreen"
>
<el-button
type=
"primary"
plain
circle
size=
"small"
@
click=
"zoomOut"
:disabled=
"!canOperate || zoomLevel
<
=
0
.
5
"
>
−
</el-button>
<span
class=
"zoom-level"
>
{{
Math
.
round
(
zoomLevel
*
100
)
}}
%
</span>
<el-button
type=
"primary"
plain
circle
size=
"small"
@
click=
"zoomIn"
:disabled=
"!canOperate || zoomLevel >= 2.0"
>
+
</el-button>
</div>
<!--
★★★ 右下角悬浮按钮组 ★★★
-->
<!--
悬浮按钮组
-->
<div
class=
"fab-group"
>
<!-- 客户按钮组 -->
<template
v-if=
"role === 'customer'"
>
<!--
开始共享 / 结束共享
-->
<el-badge
:value=
"sharingStatus === '讲解中' ? '
讲解中
' : '未开始'"
:type=
"sharingStatus === '讲解中' ? 'success' : 'info'"
>
<!--
发起/结束协同
-->
<el-badge
:value=
"sharingStatus === '讲解中' ? '
已创建
' : '未开始'"
:type=
"sharingStatus === '讲解中' ? 'success' : 'info'"
>
<el-button
v-if=
"sharingStatus !== '讲解中'"
class=
"fab-btn fab-share"
type=
"primary"
circle
size=
"large"
@
click=
"start
Sharing
"
@
click=
"start
CoSession
"
>
<span
class=
"fab-btn-text"
>
开始
<br>
共享
</span>
<span
class=
"fab-btn-text"
>
发起
<br>
协同
</span>
</el-button>
<el-button
v-else
...
...
@@ -93,20 +99,32 @@
type=
"danger"
circle
size=
"large"
@
click=
"handleEnd
Sharing
"
@
click=
"handleEnd
CoSession
"
>
<span
class=
"fab-btn-text"
>
结束
<br>
共享
</span>
<span
class=
"fab-btn-text"
>
结束
<br>
协同
</span>
</el-button>
</el-badge>
<!-- 脱敏开关 -->
<el-badge
:value=
"desensitizationEnabled ? '已脱敏' : '未脱敏'"
:type=
"desensitizationEnabled ? 'success' : 'info'"
>
<!-- 投屏 -->
<el-badge
v-if=
"sharingStatus === '讲解中'"
:value=
"presenter === 'customer' ? '您正在投屏' : presenter !== 'none' ? '对方投屏中' : '未投屏'"
:type=
"presenter === 'customer' ? 'danger' : presenter !== 'none' ? 'warning' : 'info'"
>
<el-button
class=
"fab-btn fab-desensitize"
class=
"fab-btn"
:class=
"presenter === 'customer' ? 'fab-end' : 'fab-share'"
type=
"primary"
circle
size=
"large"
@
click=
"toggleDesensitization"
@
click=
"toggleScreenShare"
:disabled=
"presenter !== 'none' && presenter !== 'customer'"
>
<span
class=
"fab-btn-text"
>
{{
presenter
===
'customer'
?
'结束
\
n投屏'
:
'投屏'
}}
</span>
</el-button>
</el-badge>
<!-- 脱敏 -->
<el-badge
:value=
"desensitizationEnabled ? '已脱敏' : '未脱敏'"
:type=
"desensitizationEnabled ? 'success' : 'info'"
>
<el-button
class=
"fab-btn fab-desensitize"
type=
"primary"
circle
size=
"large"
@
click=
"toggleDesensitization"
>
<span
class=
"fab-btn-text"
>
脱敏
</span>
</el-button>
</el-badge>
...
...
@@ -114,8 +132,8 @@
<!-- 顾问按钮组 -->
<
template
v-if=
"role === 'consultant'"
>
<!-- 加入 -->
<el-badge
:value=
"sharingStatus === '讲解中' ? '已加入' : '未加入'"
:type=
"sharingStatus === '讲解中' ? 'success' : 'info'"
>
<!-- 未加入时显示“加入” -->
<el-button
v-if=
"sharingStatus !== '讲解中'"
class=
"fab-btn fab-join"
...
...
@@ -126,86 +144,96 @@
>
<span
class=
"fab-btn-text"
>
加入
</span>
</el-button>
<!--
已加入时显示“授权客户”或“收回授权”
-->
<!--
投屏/结束投屏
-->
<el-button
v-else
class=
"fab-btn"
:class=
"
isController ? 'fab-authorize' : 'fab-revok
e'"
:class=
"
presenter === 'consultant' ? 'fab-end' : 'fab-shar
e'"
type=
"primary"
circle
size=
"large"
@
click=
"toggleControl"
@
click=
"toggleScreenShare"
:disabled=
"presenter !== 'none' && presenter !== 'consultant'"
>
<span
class=
"fab-btn-text"
>
{{
isController
?
'授权
\
n客户'
:
'收回
\
n授权
'
}}
</span>
<span
class=
"fab-btn-text"
>
{{
presenter
===
'consultant'
?
'结束
\
n投屏'
:
'投屏
'
}}
</span>
</el-button>
</el-badge>
<!-- 顾问投屏 badge 提示(复用上方按钮自带的 badge,此处不需要额外加) -->
</
template
>
</div>
<!--
共享信息弹窗
-->
<!--
弹窗:共享信息
-->
<el-dialog
title=
"大屏讲解"
v-model=
"shareDialogVisible"
width=
"400px"
append-to-body
>
<div
style=
"text-align: center; padding: 16px 0;"
>
<el-alert
title=
"请将以下信息分享给顾问"
type=
"success"
:closable=
"false"
style=
"margin-bottom: 16px;"
/>
<div
style=
"margin-bottom: 16px;"
>
<div
style=
"font-size: 14px; color: #666;"
>
共享码
</div>
<div
style=
"font-size: 28px; font-weight: bold; letter-spacing: 6px; color: #1890ff;"
>
{{ shareInfo.roomPwd || '------' }}
</div>
<el-button
size=
"small"
type=
"primary"
plain
@
click=
"copyCode"
style=
"margin-top: 8px;"
>
复制
</el-button>
<div
style=
"text-align:center;padding:16px 0;"
>
<el-alert
title=
"请将以下信息分享给顾问"
type=
"success"
:closable=
"false"
style=
"margin-bottom:16px;"
/>
<div
style=
"margin-bottom:16px;"
>
<div
style=
"font-size:14px;color:#666;"
>
共享码
</div>
<div
style=
"font-size:28px;font-weight:bold;letter-spacing:6px;color:#1890ff;"
>
{{ shareInfo.roomPwd || '------' }}
</div>
<el-button
size=
"small"
type=
"primary"
plain
@
click=
"copyCode"
style=
"margin-top:8px;"
>
复制
</el-button>
</div>
<div>
<div
style=
"font-size:
14px; color:
#666;"
>
房间号
</div>
<div
style=
"font-size:
16px; font-weight: 500; color:
#333;"
>
{{ shareInfo.roomId || '------' }}
</div>
<div
style=
"font-size:
14px;color:
#666;"
>
房间号
</div>
<div
style=
"font-size:
16px;font-weight:500;color:
#333;"
>
{{ shareInfo.roomId || '------' }}
</div>
</div>
</div>
<
template
#
footer
>
<el-button
type=
"primary"
@
click=
"shareDialogVisible = false; connectAsCustomer()"
>
开始协同
</el-button>
</
template
>
</el-dialog>
<!--
顾问加入对话框
-->
<!--
弹窗:顾问加入
-->
<el-dialog
title=
"加入协同"
v-model=
"joinDialogVisible"
width=
"400px"
append-to-body
>
<el-form
:model=
"joinForm"
label-width=
"80px"
>
<el-form-item
label=
"共享码"
>
<el-input
v-model=
"joinForm.roomPwd"
placeholder=
"请输入6位共享码"
maxlength=
"6"
/>
</el-form-item>
<el-form-item
label=
"房间号"
>
<el-input
v-model=
"joinForm.roomId"
placeholder=
"请输入房间号"
/>
</el-form-item>
<el-form-item
label=
"共享码"
><el-input
v-model=
"joinForm.roomPwd"
placeholder=
"请输入6位共享码"
maxlength=
"6"
/></el-form-item>
<el-form-item
label=
"房间号"
><el-input
v-model=
"joinForm.roomId"
placeholder=
"请输入房间号"
/></el-form-item>
</el-form>
<
template
#
footer
>
<el-button
type=
"primary"
@
click=
"submitJoin"
>
加入
</el-button>
<el-button
@
click=
"joinDialogVisible = false"
>
取消
</el-button>
</
template
>
</el-dialog>
<!-- 投屏邀请弹窗 -->
<el-dialog
title=
"投屏邀请"
v-model=
"inviteDialogVisible"
width=
"400px"
append-to-body
>
<p>
{{ inviteMessage }}
</p>
<
template
#
footer
>
<el-button
@
click=
"respondInvite(false)"
>
拒绝
</el-button>
<el-button
type=
"primary"
@
click=
"respondInvite(true)"
>
接受
</el-button>
</
template
>
</el-dialog>
<!-- 远程控制请求弹窗 -->
<el-dialog
title=
"远程控制请求"
v-model=
"remoteControlInviteVisible"
width=
"400px"
append-to-body
>
<p>
{{ remoteControlInviteMessage }}
</p>
<
template
#
footer
>
<el-button
@
click=
"respondRemoteControl(false)"
>
拒绝
</el-button>
<el-button
type=
"primary"
@
click=
"respondRemoteControl(true)"
>
接受
</el-button>
</
template
>
</el-dialog>
</div>
</template>
<
script
setup
name=
"ReportReader"
>
import
{
ref
,
reactive
,
computed
,
onMounted
,
onBeforeUnmount
,
getCurrentInstance
,
nextTick
}
from
'vue'
<
script
setup
>
import
{
ref
,
reactive
,
computed
,
onMounted
,
onBeforeUnmount
,
nextTick
,
getCurrentInstance
}
from
'vue'
import
{
useRoute
,
useRouter
}
from
'vue-router'
import
{
ElMessage
}
from
'element-plus'
import
{
createCoSession
,
endCoSession
,
endCoSession
as
apiEndCoSession
,
joinCoSession
,
startRecording
,
stopRecording
,
queryRecording
,
getDesensitizationRules
getDesensitizationRules
,
uploadChunk
}
from
'@/api/system/report'
import
useUserStore
from
'@/store/modules/user'
import
{
getToken
}
from
'@/utils/auth'
console
.
log
(
'【协同报告阅读器】组件开始加载'
)
console
.
log
(
'【共享报告阅读器】组件加载'
)
const
{
proxy
}
=
getCurrentInstance
()
const
userStore
=
useUserStore
()
const
route
=
useRoute
()
const
router
=
useRouter
()
const
role
=
ref
(
route
.
query
.
role
||
'customer'
)
cons
ole
.
log
(
'【角色】当前身份:'
,
role
.
value
)
cons
t
roleMap
=
{
customer
:
'customer'
,
consultant
:
'consultant'
}
// ========== 文章数据 ==========
const
articles
=
reactive
([
...
...
@@ -299,101 +327,208 @@ const isController = ref(false)
const
shareInfo
=
reactive
({
roomId
:
''
,
roomPwd
:
''
,
sessionBizId
:
''
,
ownerId
:
''
})
const
joinForm
=
reactive
({
roomId
:
''
,
roomPwd
:
''
})
// ========== 投屏状态 ==========
const
presenter
=
ref
(
'none'
)
const
isReceivingScreen
=
ref
(
false
)
const
remoteVideoRef
=
ref
(
null
)
// ========== 远程控制 ==========
const
remoteControlActive
=
ref
(
false
)
const
remoteControlDirection
=
ref
(
null
)
let
dataChannel
=
null
let
isDataChannelOpen
=
false
const
remoteControlInviteVisible
=
ref
(
false
)
const
remoteControlInviteMessage
=
ref
(
''
)
let
pendingRemoteControl
=
null
const
inviteDialogVisible
=
ref
(
false
)
const
inviteMessage
=
ref
(
''
)
let
pendingInvite
=
null
// ========== WebRTC ==========
let
pc
=
null
let
sharedStream
=
null
// ========== WebSocket ==========
let
ws
=
null
let
wsReconnectTimer
=
null
let
shouldReconnect
=
true
// ========== 录制相关
状态
==========
// ========== 录制相关 ==========
const
recordingStatus
=
ref
(
'idle'
)
const
recordingTaskId
=
ref
(
null
)
const
mediaRecorder
=
ref
(
null
)
const
recordedChunks
=
ref
([])
const
recordingStream
=
ref
(
null
)
const
uploadedChunkCount
=
ref
(
0
)
const
recordingStartTime
=
ref
(
null
)
const
recordingDuration
=
ref
(
0
)
let
durationTimer
=
null
const
recordedFileUrl
=
ref
(
''
)
const
uploading
=
ref
(
false
)
const
isStopping
=
ref
(
false
)
let
uploadPromises
=
[]
// ========== WebRTC 相关 ==========
let
pc
=
null
const
remoteVideoRef
=
ref
(
null
)
const
isReceivingScreen
=
ref
(
false
)
// ★ 共享流(全局)
let
sharedStream
=
null
// 项目业务ID(为空时后端自动使用默认OSS)
const
projectBizId
=
ref
(
''
)
// ========== 计算属性 ==========
const
currentArticle
=
computed
(()
=>
articles
[
currentPage
.
value
]
||
articles
[
0
])
const
canOperate
=
computed
(()
=>
wsConnected
.
value
&&
isController
.
value
)
// ========== 页面导航 ==========
function
goBack
()
{
router
.
push
(
'/system/report'
)
function
goBack
()
{
router
.
push
(
'/system/report'
)
}
function
prevPage
()
{
if
(
canOperate
.
value
&&
currentPage
.
value
>
0
)
changePage
(
currentPage
.
value
-
1
)
}
function
nextPage
()
{
if
(
canOperate
.
value
&&
currentPage
.
value
<
articles
.
length
-
1
)
changePage
(
currentPage
.
value
+
1
)
}
function
onSliderChange
(
val
)
{
if
(
!
canOperate
.
value
)
return
;
changePage
(
val
);
nextTick
(()
=>
{
sendSyncAction
(
'turn_the_page'
,
0
)
})
}
function
zoomIn
()
{
if
(
canOperate
.
value
&&
zoomLevel
.
value
<
2.0
)
{
zoomLevel
.
value
=
Math
.
min
(
2.0
,
zoomLevel
.
value
+
0.1
);
sendSyncAction
(
'scaling'
,
zoomLevel
.
value
)
}
}
function
zoomOut
()
{
if
(
canOperate
.
value
&&
zoomLevel
.
value
>
0.5
)
{
zoomLevel
.
value
=
Math
.
max
(
0.5
,
zoomLevel
.
value
-
0.1
);
sendSyncAction
(
'scaling'
,
zoomLevel
.
value
)
}
}
function
changePage
(
newPage
)
{
if
(
newPage
<
0
||
newPage
>=
articles
.
length
)
return
;
currentPage
.
value
=
newPage
;
if
(
desensitizationEnabled
.
value
)
applyDesensitization
();
else
restoreOriginalContent
()
}
// ========== 控制权 ==========
function
toggleControl
()
{
if
(
role
.
value
!==
'consultant'
)
{
ElMessage
.
warning
(
'只有顾问可以切换控制权'
);
return
}
if
(
!
wsConnected
.
value
)
{
ElMessage
.
warning
(
'未连接协同'
);
return
}
const
targetHolderType
=
isController
.
value
?
'owner'
:
'participant'
isController
.
value
=
!
isController
.
value
ws
.
send
(
JSON
.
stringify
({
action
:
'control_transfer'
,
holderType
:
targetHolderType
}))
}
// ========== 翻页、滚动、缩放 ==========
function
prevPage
()
{
if
(
canOperate
.
value
&&
currentPage
.
value
>
0
)
{
changePage
(
currentPage
.
value
-
1
)
// ========== WebSocket 连接 ==========
function
connectWebSocket
(
roomId
,
userId
,
userType
,
sendControl
=
false
,
onOpenCallback
=
null
)
{
if
(
ws
)
{
try
{
ws
.
close
()
}
catch
(
e
)
{};
ws
=
null
}
if
(
wsReconnectTimer
)
clearTimeout
(
wsReconnectTimer
)
const
wsUrl
=
`ws://139.224.145.34:9482/communication/api/ws/
${
roomId
}
?userId=
${
userId
}
&userType=
${
userType
}
`
console
.
log
(
'【WebSocket】连接地址:'
,
wsUrl
)
ws
=
new
WebSocket
(
wsUrl
)
ws
.
onopen
=
()
=>
{
wsConnected
.
value
=
true
ElMessage
.
success
(
'WebSocket 已连接'
)
if
(
onOpenCallback
)
{
onOpenCallback
()
}
}
function
nextPage
()
{
if
(
canOperate
.
value
&&
currentPage
.
value
<
articles
.
length
-
1
)
{
changePage
(
currentPage
.
value
+
1
)
if
(
sendControl
&&
role
.
value
===
'consultant'
)
{
ws
.
send
(
JSON
.
stringify
({
action
:
'control_transfer'
,
holderType
:
'participant'
}))
setTimeout
(()
=>
{
if
(
ws
&&
ws
.
readyState
===
WebSocket
.
OPEN
)
{
ws
.
send
(
JSON
.
stringify
({
action
:
'request_offer'
,
roomId
}))
}
},
500
)
}
}
ws
.
onmessage
=
(
event
)
=>
{
try
{
handleWebSocketMessage
(
JSON
.
parse
(
event
.
data
))
}
catch
(
e
)
{
console
.
error
(
'解析消息失败:'
,
e
)
}
}
ws
.
onclose
=
()
=>
{
wsConnected
.
value
=
false
closeWebRTC
()
if
(
shouldReconnect
)
{
ElMessage
.
warning
(
'WebSocket 断开,尝试重连...'
)
if
(
wsReconnectTimer
)
clearTimeout
(
wsReconnectTimer
)
wsReconnectTimer
=
setTimeout
(()
=>
connectWebSocket
(
roomId
,
userId
,
userType
),
3000
)
}
else
{
console
.
log
(
'【WebSocket】主动关闭,不再重连'
)
shouldReconnect
=
true
}
}
ws
.
onerror
=
(
err
)
=>
{
console
.
error
(
'WebSocket 错误:'
,
err
);
wsConnected
.
value
=
false
}
}
function
onSliderChange
(
val
)
{
if
(
!
canOperate
.
value
)
return
changePage
(
val
)
nextTick
(()
=>
{
sendSyncAction
(
'turn_the_page'
,
0
)
})
function
connectAsCustomer
()
{
if
(
!
shareInfo
.
roomId
)
return
shouldReconnect
=
true
connectWebSocket
(
shareInfo
.
roomId
,
userStore
.
userBizId
||
'CUST-001'
,
'owner'
)
}
function
zoomIn
()
{
if
(
canOperate
.
value
&&
zoomLevel
.
value
<
2.0
)
{
zoomLevel
.
value
=
Math
.
min
(
2.0
,
zoomLevel
.
value
+
0.1
)
sendSyncAction
(
'scaling'
,
zoomLevel
.
value
)
// ========== 顾问加入 ==========
async
function
submitJoin
()
{
if
(
!
joinForm
.
roomPwd
||
!
joinForm
.
roomId
)
{
ElMessage
.
warning
(
'请填写完整信息'
);
return
}
try
{
const
res
=
await
joinCoSession
({
roomId
:
joinForm
.
roomId
,
roomPwd
:
joinForm
.
roomPwd
,
participantId
:
userStore
.
userBizId
||
'CONS-001'
,
participantType
:
'consultant'
})
if
(
res
.
code
===
200
)
{
if
(
res
.
data
?.
sessionBizId
)
shareInfo
.
sessionBizId
=
res
.
data
.
sessionBizId
if
(
res
.
data
?.
ownerId
)
shareInfo
.
ownerId
=
res
.
data
.
ownerId
sharingStatus
.
value
=
'讲解中'
ElMessage
.
success
(
'加入成功'
)
joinDialogVisible
.
value
=
false
shouldReconnect
=
true
const
onOpen
=
()
=>
{
if
(
ws
&&
ws
.
readyState
===
WebSocket
.
OPEN
)
{
ws
.
send
(
JSON
.
stringify
({
action
:
'request_offer'
,
roomId
:
joinForm
.
roomId
}))
}
}
connectWebSocket
(
joinForm
.
roomId
,
userStore
.
userBizId
||
'CONS-001'
,
'participant'
,
false
,
onOpen
)
}
else
{
ElMessage
.
error
(
res
.
msg
||
'加入失败'
)
}
}
catch
(
error
)
{
console
.
error
(
'【join】接口异常:'
,
error
)
ElMessage
.
error
(
'加入失败,请稍后重试'
)
}
}
function
zoomOut
()
{
if
(
canOperate
.
value
&&
zoomLevel
.
value
>
0.5
)
{
zoomLevel
.
value
=
Math
.
max
(
0.5
,
zoomLevel
.
value
-
0.1
)
sendSyncAction
(
'scaling'
,
zoomLevel
.
value
)
// ========== 投屏核心逻辑 ==========
async
function
startCoSession
()
{
if
(
sharingStatus
.
value
===
'讲解中'
)
{
ElMessage
.
warning
(
'已创建协同会话,请等待顾问加入'
)
return
}
await
createSessionOnly
()
}
function
changePage
(
newPage
)
{
if
(
newPage
<
0
||
newPage
>=
articles
.
length
)
return
currentPage
.
value
=
newPage
if
(
desensitizationEnabled
.
value
)
{
applyDesensitization
()
}
else
{
restoreOriginalContent
()
async
function
handleEndCoSession
()
{
if
(
recordingStatus
.
value
===
'recording'
)
{
await
stopRecordingAndUpload
()
}
await
stopSharing
()
if
(
role
.
value
===
'customer'
)
{
if
(
ws
&&
ws
.
readyState
===
WebSocket
.
OPEN
)
{
ws
.
send
(
JSON
.
stringify
({
action
:
'end_sharing'
}))
}
await
apiEndCoSession
({
roomId
:
shareInfo
.
roomId
})
ElMessage
.
success
(
'共享已结束'
)
}
shouldReconnect
=
false
if
(
wsReconnectTimer
)
clearTimeout
(
wsReconnectTimer
)
if
(
ws
)
{
ws
.
close
();
ws
=
null
}
wsConnected
.
value
=
false
sharingStatus
.
value
=
'已结束'
closeWebRTC
()
if
(
sharedStream
)
{
sharedStream
.
getTracks
().
forEach
(
track
=>
track
.
stop
())
sharedStream
=
null
}
}
// ========== 控制权切换 ==========
function
toggleControl
()
{
if
(
role
.
value
!==
'consultant'
)
{
ElMessage
.
warning
(
'只有顾问可以切换控制权'
)
async
function
toggleScreenShare
()
{
if
(
presenter
.
value
===
role
.
value
)
{
await
stopSharing
()
return
}
if
(
!
wsConnected
.
value
)
{
ElMessage
.
warning
(
'未连接协同'
)
if
(
presenter
.
value
===
'none'
)
{
await
startLocalSharing
(
)
return
}
const
targetHolderType
=
isController
.
value
?
'owner'
:
'participant'
isController
.
value
=
!
isController
.
value
const
msg
=
{
action
:
'control_transfer'
,
holderType
:
targetHolderType
}
ws
.
send
(
JSON
.
stringify
(
msg
))
console
.
log
(
'【控制权切换】发送:'
,
msg
)
}
// ========== 客户发起共享 ==========
async
function
startSharing
()
{
async
function
stopSharing
()
{
if
(
presenter
.
value
===
'none'
)
return
if
(
recordingStatus
.
value
===
'recording'
)
{
await
stopRecordingAndUpload
()
}
stopLocalSharing
()
if
(
ws
&&
ws
.
readyState
===
WebSocket
.
OPEN
)
{
ws
.
send
(
JSON
.
stringify
({
action
:
'presenter_revoke'
}))
}
presenter
.
value
=
'none'
ElMessage
.
success
(
'已停止投屏'
)
}
async
function
createSessionOnly
()
{
const
userId
=
userStore
.
userBizId
||
'admin'
const
token
=
getToken
()
||
'demo-token'
const
ownerType
=
role
.
value
===
'customer'
?
'customer'
:
'consultant'
try
{
const
res
=
await
createCoSession
({
scope
:
'single'
,
...
...
@@ -414,7 +549,7 @@ async function startSharing() {
}
}),
ownerId
:
userId
,
ownerType
:
ownerType
,
ownerType
:
'customer'
,
userId
:
userId
,
token
:
token
})
...
...
@@ -426,9 +561,11 @@ async function startSharing() {
shareInfo
.
ownerId
=
userId
sharingStatus
.
value
=
'讲解中'
shareDialogVisible
.
value
=
true
ElMessage
.
success
(
'大屏讲解已开启,请分享共享码'
)
ElMessage
.
success
(
'协同会话已创建,请分享共享码给顾问'
)
shouldReconnect
=
true
connectWebSocket
(
shareInfo
.
roomId
,
userId
,
'owner'
)
}
else
{
ElMessage
.
error
(
res
.
msg
||
'
开启
失败'
)
ElMessage
.
error
(
res
.
msg
||
'
创建
失败'
)
}
}
catch
(
e
)
{
shareInfo
.
roomId
=
'room_'
+
Math
.
random
().
toString
(
36
).
substr
(
2
,
8
)
...
...
@@ -437,297 +574,428 @@ async function startSharing() {
shareInfo
.
ownerId
=
userId
sharingStatus
.
value
=
'讲解中'
shareDialogVisible
.
value
=
true
ElMessage
.
success
(
'(模拟)大屏讲解已开启'
)
}
}
// ========== 客户连接 WebSocket ==========
function
connectAsCustomer
()
{
if
(
!
shareInfo
.
roomId
)
return
ElMessage
.
success
(
'(模拟)协同会话已创建'
)
shouldReconnect
=
true
connectWebSocket
(
shareInfo
.
roomId
,
userStore
.
userBizId
||
'CUST-001'
,
'owner'
)
}
// ========== 顾问加入 ==========
async
function
submitJoin
()
{
if
(
!
joinForm
.
roomPwd
||
!
joinForm
.
roomId
)
{
ElMessage
.
warning
(
'请填写完整信息'
)
return
}
try
{
const
res
=
await
joinCoSession
({
roomId
:
joinForm
.
roomId
,
roomPwd
:
joinForm
.
roomPwd
,
participantId
:
userStore
.
userBizId
||
'CONS-001'
,
participantType
:
'consultant'
})
if
(
res
.
code
===
200
)
{
if
(
res
.
data
?.
sessionBizId
)
shareInfo
.
sessionBizId
=
res
.
data
.
sessionBizId
if
(
res
.
data
?.
ownerId
)
shareInfo
.
ownerId
=
res
.
data
.
ownerId
sharingStatus
.
value
=
'讲解中'
ElMessage
.
success
(
'加入成功'
)
joinDialogVisible
.
value
=
false
shouldReconnect
=
true
connectWebSocket
(
joinForm
.
roomId
,
userStore
.
userBizId
||
'CONS-001'
,
'participant'
,
true
)
}
else
{
ElMessage
.
error
(
res
.
msg
||
'加入失败'
)
}
}
catch
(
error
)
{
console
.
error
(
'【join】接口异常:'
,
error
)
ElMessage
.
error
(
'加入失败,请稍后重试'
)
connectWebSocket
(
shareInfo
.
roomId
,
userId
,
'owner'
)
}
}
// ========== WebSocket 连接 ==========
function
connectWebSocket
(
roomId
,
userId
,
userType
,
sendControl
=
false
)
{
if
(
ws
)
{
try
{
ws
.
close
()
}
catch
(
e
)
{};
ws
=
null
}
if
(
wsReconnectTimer
)
clearTimeout
(
wsReconnectTimer
)
const
wsUrl
=
`ws://139.224.145.34:9482/communication/api/ws/
${
roomId
}
?userId=
${
userId
}
&userType=
${
userType
}
`
console
.
log
(
'【WebSocket】连接地址:'
,
wsUrl
)
ws
=
new
WebSocket
(
wsUrl
)
ws
.
onopen
=
()
=>
{
wsConnected
.
value
=
true
ElMessage
.
success
(
'WebSocket 已连接'
)
if
(
role
.
value
===
'customer'
)
{
startWebRTCScreenShare
(
roomId
)
}
if
(
sendControl
&&
role
.
value
===
'consultant'
)
{
ws
.
send
(
JSON
.
stringify
({
action
:
'control_transfer'
,
holderType
:
'participant'
}))
console
.
log
(
'【顾问】发送 control_transfer 同步消息'
)
}
}
ws
.
onmessage
=
(
event
)
=>
{
try
{
handleWebSocketMessage
(
JSON
.
parse
(
event
.
data
))
}
catch
(
e
)
{
console
.
error
(
'解析消息失败:'
,
e
)
}
}
ws
.
onclose
=
()
=>
{
wsConnected
.
value
=
false
closeWebRTC
()
if
(
shouldReconnect
)
{
ElMessage
.
warning
(
'WebSocket 断开,尝试重连...'
)
if
(
wsReconnectTimer
)
clearTimeout
(
wsReconnectTimer
)
wsReconnectTimer
=
setTimeout
(()
=>
connectWebSocket
(
roomId
,
userId
,
userType
),
3000
)
}
else
{
console
.
log
(
'【WebSocket】主动关闭,不再重连'
)
shouldReconnect
=
true
}
async
function
startLocalSharing
()
{
if
(
!
navigator
.
mediaDevices
||
!
navigator
.
mediaDevices
.
getDisplayMedia
)
{
ElMessage
.
error
(
'浏览器不支持屏幕共享'
)
return
}
ws
.
onerror
=
(
err
)
=>
{
console
.
error
(
'WebSocket 错误:'
,
err
);
wsConnected
.
value
=
false
}
}
// ========== WebRTC 屏幕共享(客户)并复用流给录制 ==========
async
function
startWebRTCScreenShare
(
roomId
)
{
try
{
if
(
pc
)
{
pc
.
close
();
pc
=
null
}
// ★ 获取屏幕流(只弹一次)
const
stream
=
await
navigator
.
mediaDevices
.
getDisplayMedia
({
video
:
{
displaySurface
:
'browser'
,
frameRate
:
{
ideal
:
30
},
cursor
:
'always'
},
video
:
{
frameRate
:
{
ideal
:
30
},
cursor
:
'always'
},
audio
:
true
})
// 保存全局流供录制复用
sharedStream
=
stream
// ★ 立即启动录制(复用同一个流)
if
(
shareInfo
.
sessionBizId
)
{
startRecordingWithStream
(
stream
)
}
else
{
console
.
warn
(
'【录制】sessionBizId 未就绪,稍后重试'
)
await
startRecordingWithStream
(
stream
)
}
// 创建 PeerConnection
pc
=
new
RTCPeerConnection
({
iceServers
:
[{
urls
:
'stun:stun.l.google.com:19302'
}]
iceServers
:
[
{
urls
:
'stun:stun.l.google.com:19302'
},
{
urls
:
'stun:stun1.l.google.com:19302'
}
]
})
stream
.
getTracks
().
forEach
(
track
=>
pc
.
addTrack
(
track
,
stream
))
pc
.
onicecandidate
=
(
event
)
=>
{
if
(
event
.
candidate
)
{
ws
.
send
(
JSON
.
stringify
({
action
:
'webrtc_ice_candidate'
,
roomId
,
candidate
:
event
.
candidate
}))
ws
.
send
(
JSON
.
stringify
({
action
:
'webrtc_ice_candidate'
,
roomId
:
shareInfo
.
roomId
,
candidate
:
event
.
candidate
}))
}
}
pc
.
onconnectionstatechange
=
()
=>
{
console
.
log
(
'【WebRTC】连接状态:'
,
pc
.
connectionState
)
if
(
pc
.
connectionState
===
'connected'
)
{
ElMessage
.
success
(
'屏幕共享已连接'
)
}
else
if
(
pc
.
connectionState
===
'disconnected'
||
pc
.
connectionState
===
'failed'
)
{
ElMessage
.
warning
(
'屏幕共享连接中断'
)
}
if
(
pc
.
connectionState
===
'connected'
)
ElMessage
.
success
(
'屏幕共享已连接'
)
}
setupDataChannelListener
()
const
offer
=
await
pc
.
createOffer
()
await
pc
.
setLocalDescription
(
offer
)
ws
.
send
(
JSON
.
stringify
({
action
:
'webrtc_offer'
,
roomId
,
sdp
:
offer
}))
console
.
log
(
'【WebRTC】屏幕共享已启动'
)
ws
.
send
(
JSON
.
stringify
({
action
:
'webrtc_offer'
,
roomId
:
shareInfo
.
roomId
,
sdp
:
offer
}))
presenter
.
value
=
role
.
value
===
'customer'
?
'customer'
:
'consultant'
ws
.
send
(
JSON
.
stringify
({
action
:
'presenter_change'
,
target
:
presenter
.
value
,
from
:
roleMap
[
role
]
}))
}
catch
(
error
)
{
console
.
error
(
'【WebRTC】启动屏幕共享失败:'
,
error
)
ElMessage
.
error
(
'启动屏幕共享失败: '
+
error
.
message
)
console
.
error
(
'启动投屏失败:'
,
error
)
ElMessage
.
error
(
'启动投屏失败: '
+
error
.
message
)
}
}
function
stopLocalSharing
()
{
if
(
pc
)
{
pc
.
close
();
pc
=
null
}
if
(
sharedStream
)
{
sharedStream
.
getTracks
().
forEach
(
track
=>
track
.
stop
())
sharedStream
=
null
}
isReceivingScreen
.
value
=
false
if
(
remoteVideoRef
.
value
)
{
remoteVideoRef
.
value
.
srcObject
=
null
}
}
// ========== 录制功能(复用共享流) ==========
async
function
startRecordingWithStream
(
stream
)
{
try
{
if
(
recordingStatus
.
value
===
'recording'
)
return
if
(
!
shareInfo
.
sessionBizId
)
{
ElMessage
.
warning
(
'未获取到协同会话ID,无法开始录制'
)
// ========== 投屏切换处理 ==========
function
handlePresenterChange
(
msg
)
{
if
(
msg
.
from
===
roleMap
[
role
])
{
presenter
.
value
=
msg
.
from
return
}
const
startRes
=
await
startRecording
({
bizType
:
'co_session'
,
bizId
:
shareInfo
.
sessionBizId
})
if
(
startRes
.
code
!==
200
)
{
ElMessage
.
error
(
startRes
.
msg
||
'创建录制任务失败'
)
if
(
msg
.
target
)
{
presenter
.
value
=
msg
.
target
return
}
recordingTaskId
.
value
=
startRes
.
data
console
.
log
(
'【录制】任务创建成功,taskId:'
,
recordingTaskId
.
value
)
if
(
msg
.
target
===
roleMap
[
role
])
{
inviteMessage
.
value
=
`
${
msg
.
from
===
'customer'
?
'客户'
:
'顾问'
}
请求投屏,是否接受?`
pendingInvite
=
msg
inviteDialogVisible
.
value
=
true
}
else
{
ws
.
send
(
JSON
.
stringify
({
action
:
'presenter_response'
,
accept
:
false
,
target
:
msg
.
target
}))
}
}
// 直接使用传入的流
const
mimeTypes
=
[
'video/webm;codecs=vp9'
,
'video/webm;codecs=vp8'
,
'video/webm'
,
'video/mp4'
]
let
mimeType
=
mimeTypes
.
find
(
type
=>
MediaRecorder
.
isTypeSupported
(
type
))
||
''
const
recorder
=
new
MediaRecorder
(
stream
,
{
mimeType
,
videoBitsPerSecond
:
2500000
})
recordedChunks
.
value
=
[]
recorder
.
ondataavailable
=
(
event
)
=>
{
if
(
event
.
data
&&
event
.
data
.
size
>
0
)
recordedChunks
.
value
.
push
(
event
.
data
)
function
respondInvite
(
accept
)
{
inviteDialogVisible
.
value
=
false
ws
.
send
(
JSON
.
stringify
({
action
:
'presenter_response'
,
accept
,
target
:
pendingInvite
.
target
}))
if
(
accept
)
{
stopLocalSharing
()
startLocalSharing
()
}
recorder
.
onstop
=
()
=>
console
.
log
(
'【录制】MediaRecorder 停止,共'
,
recordedChunks
.
value
.
length
,
'个数据块'
)
recorder
.
onerror
=
(
event
)
=>
{
console
.
error
(
'【录制】MediaRecorder 错误:'
,
event
);
ElMessage
.
error
(
'录制发生错误'
)
}
}
// 监听流结束(用户点击停止共享时,自动停止录制)
if
(
stream
)
{
const
videoTrack
=
stream
.
getVideoTracks
()[
0
]
if
(
videoTrack
)
{
videoTrack
.
addEventListener
(
'ended'
,
()
=>
{
if
(
recordingStatus
.
value
===
'recording'
)
{
ElMessage
.
warning
(
'屏幕共享被中断,录制将停止'
)
stopRecordingAndUpload
()
function
handlePresenterResponse
(
msg
)
{
if
(
msg
.
accept
)
{
stopLocalSharing
()
presenter
.
value
=
msg
.
target
ElMessage
.
success
(
'投屏已切换'
)
}
else
{
ElMessage
.
info
(
'对方拒绝了投屏切换'
)
}
})
}
// ========== 远程控制 ==========
function
requestRemoteControl
()
{
if
(
!
wsConnected
.
value
)
{
ElMessage
.
warning
(
'未连接协同'
)
return
}
if
(
remoteControlActive
.
value
)
{
ElMessage
.
warning
(
'远程控制已激活'
)
return
}
if
(
presenter
.
value
===
'none'
)
{
ElMessage
.
warning
(
'当前无人投屏,无法请求远程控制'
)
return
}
ws
.
send
(
JSON
.
stringify
({
action
:
'request_remote_control'
,
target
:
presenter
.
value
,
from
:
role
.
value
===
'customer'
?
'customer'
:
'consultant'
}))
ElMessage
.
info
(
'已发送远程控制请求,等待对方接受...'
)
}
mediaRecorder
.
value
=
recorder
recorder
.
start
(
1000
)
recordingStatus
.
value
=
'recording'
recordingStartTime
.
value
=
Date
.
now
()
recordingDuration
.
value
=
0
if
(
durationTimer
)
clearInterval
(
durationTimer
)
durationTimer
=
setInterval
(()
=>
{
recordingDuration
.
value
=
Math
.
floor
((
Date
.
now
()
-
recordingStartTime
.
value
)
/
1000
)
},
1000
)
ElMessage
.
success
(
'开始录制屏幕'
)
}
catch
(
error
)
{
console
.
error
(
'【录制】启动失败:'
,
error
)
ElMessage
.
error
(
'录制启动失败: '
+
error
.
message
)
recordingStatus
.
value
=
'idle'
recordingTaskId
.
value
=
null
function
respondRemoteControl
(
accept
)
{
remoteControlInviteVisible
.
value
=
false
ws
.
send
(
JSON
.
stringify
({
action
:
'remote_control_response'
,
accept
,
from
:
role
.
value
===
'customer'
?
'customer'
:
'consultant'
}))
if
(
accept
)
{
remoteControlActive
.
value
=
true
remoteControlDirection
.
value
=
'being_controlled'
setupDataChannelListener
()
}
}
function
stopRemoteControl
()
{
if
(
remoteControlDirection
.
value
===
'controlling'
)
{
if
(
dataChannel
)
{
dataChannel
.
close
()
dataChannel
=
null
}
remoteControlActive
.
value
=
false
remoteControlDirection
.
value
=
null
ws
.
send
(
JSON
.
stringify
({
action
:
'remote_control_stop'
}))
}
else
{
ws
.
send
(
JSON
.
stringify
({
action
:
'remote_control_stop'
}))
remoteControlActive
.
value
=
false
remoteControlDirection
.
value
=
null
}
}
function
setupDataChannelListener
()
{
if
(
!
pc
)
return
pc
.
ondatachannel
=
null
pc
.
ondatachannel
=
(
event
)
=>
{
const
channel
=
event
.
channel
console
.
log
(
'【被控端】DataChannel 连接建立,标签:'
,
channel
.
label
)
channel
.
onmessage
=
(
e
)
=>
{
const
data
=
JSON
.
parse
(
e
.
data
)
console
.
log
(
'【被控端】收到远程控制事件:'
,
data
.
type
)
simulateEvent
(
data
)
}
channel
.
onopen
=
()
=>
{
remoteControlActive
.
value
=
true
remoteControlDirection
.
value
=
'being_controlled'
console
.
log
(
'【被控端】DataChannel 已打开'
)
}
channel
.
onclose
=
()
=>
{
remoteControlActive
.
value
=
false
remoteControlDirection
.
value
=
null
console
.
log
(
'【被控端】DataChannel 已关闭'
)
}
}
}
function
startControlling
(
channel
)
{
dataChannel
=
channel
console
.
log
(
'【远程控制】DataChannel 已创建,等待打开...'
)
dataChannel
.
onopen
=
()
=>
{
isDataChannelOpen
=
true
remoteControlActive
.
value
=
true
remoteControlDirection
.
value
=
'controlling'
ElMessage
.
success
(
'远程控制已建立'
)
console
.
log
(
'【远程控制】DataChannel 已打开,开始监听事件'
)
bindControlEvents
()
}
dataChannel
.
onclose
=
()
=>
{
isDataChannelOpen
=
false
remoteControlActive
.
value
=
false
remoteControlDirection
.
value
=
null
ElMessage
.
info
(
'远程控制已断开'
)
unbindControlEvents
()
}
if
(
dataChannel
.
readyState
===
'open'
)
{
dataChannel
.
onopen
()
}
}
// ========== 停止录制并上传 ==========
async
function
stopRecordingAndUpload
()
{
if
(
recordingStatus
.
value
!==
'recording'
)
{
ElMessage
.
warning
(
'当前没有正在进行的录制'
)
return
}
if
(
!
mediaRecorder
.
value
)
{
ElMessage
.
warning
(
'录制器不存在'
)
return
let
controlEventListeners
=
null
function
bindControlEvents
()
{
if
(
controlEventListeners
)
return
const
sendEvent
=
(
type
,
e
)
=>
{
if
(
!
isDataChannelOpen
||
!
dataChannel
)
return
const
rect
=
document
.
documentElement
.
getBoundingClientRect
()
const
percentX
=
e
.
clientX
/
rect
.
width
const
percentY
=
e
.
clientY
/
rect
.
height
const
data
=
{
type
,
percentX
,
percentY
,
button
:
e
.
button
||
0
,
key
:
e
.
key
||
''
,
code
:
e
.
code
||
''
,
altKey
:
e
.
altKey
||
false
,
ctrlKey
:
e
.
ctrlKey
||
false
,
shiftKey
:
e
.
shiftKey
||
false
,
metaKey
:
e
.
metaKey
||
false
,
deltaX
:
e
.
deltaX
||
0
,
deltaY
:
e
.
deltaY
||
0
}
uploading
.
value
=
true
try
{
mediaRecorder
.
value
.
stop
()
if
(
durationTimer
)
{
clearInterval
(
durationTimer
);
durationTimer
=
null
}
await
new
Promise
(
resolve
=>
setTimeout
(
resolve
,
500
))
if
(
recordedChunks
.
value
.
length
===
0
)
{
ElMessage
.
warning
(
'没有录制到任何数据'
)
recordingStatus
.
value
=
'idle'
uploading
.
value
=
false
return
dataChannel
.
send
(
JSON
.
stringify
(
data
))
console
.
log
(
'【控制端】发送事件:'
,
data
.
type
,
`(
${(
percentX
*
100
).
toFixed
(
2
)}
%,
${(
percentY
*
100
).
toFixed
(
2
)}
%)`
,
'目标:'
,
e
.
target
)
}
catch
(
err
)
{
console
.
error
(
'【控制端】发送失败:'
,
err
)
}
const
blob
=
new
Blob
(
recordedChunks
.
value
,
{
type
:
mediaRecorder
.
value
.
mimeType
||
'video/webm'
})
console
.
log
(
'【录制】文件大小:'
,
(
blob
.
size
/
1024
/
1024
).
toFixed
(
2
),
'MB'
)
const
fileExtension
=
blob
.
type
.
includes
(
'mp4'
)
?
'mp4'
:
'webm'
const
fileName
=
`recording_
${
recordingTaskId
.
value
}
_
${
Date
.
now
()}
.
${
fileExtension
}
`
const
file
=
new
File
([
blob
],
fileName
,
{
type
:
blob
.
type
})
const
stopRes
=
await
stopRecording
(
recordingTaskId
.
value
,
file
)
if
(
stopRes
.
code
===
200
)
{
const
data
=
stopRes
.
data
recordedFileUrl
.
value
=
data
.
fileUrl
||
''
recordingStatus
.
value
=
'stopped'
ElMessage
.
success
(
'录制文件上传成功!'
)
console
.
log
(
'【录制】上传成功,fileUrl:'
,
recordedFileUrl
.
value
)
try
{
const
queryRes
=
await
queryRecording
(
recordingTaskId
.
value
)
if
(
queryRes
.
code
===
200
)
console
.
log
(
'【录制】查询结果:'
,
queryRes
.
data
)
}
catch
(
e
)
{
console
.
warn
(
'查询录制信息失败:'
,
e
)
}
}
else
{
ElMessage
.
error
(
stopRes
.
msg
||
'上传失败'
)
recordingStatus
.
value
=
'idle'
}
}
catch
(
error
)
{
console
.
error
(
'【录制】停止并上传失败:'
,
error
)
ElMessage
.
error
(
'停止录制失败: '
+
(
error
.
message
||
'未知错误'
))
recordingStatus
.
value
=
'idle'
}
finally
{
uploading
.
value
=
false
mediaRecorder
.
value
=
null
recordedChunks
.
value
=
[]
recordingTaskId
.
value
=
null
const
clickHandler
=
(
e
)
=>
{
console
.
log
(
'【控制端】捕获 click 事件'
);
sendEvent
(
'click'
,
e
)
}
const
mousedownHandler
=
(
e
)
=>
{
console
.
log
(
'【控制端】捕获 mousedown 事件'
);
sendEvent
(
'mousedown'
,
e
)
}
const
mouseupHandler
=
(
e
)
=>
{
console
.
log
(
'【控制端】捕获 mouseup 事件'
);
sendEvent
(
'mouseup'
,
e
)
}
const
mouseMoveHandler
=
(
e
)
=>
sendEvent
(
'mousemove'
,
e
)
const
wheelHandler
=
(
e
)
=>
sendEvent
(
'wheel'
,
e
)
const
keydownHandler
=
(
e
)
=>
{
if
(
e
.
target
.
tagName
===
'INPUT'
||
e
.
target
.
tagName
===
'TEXTAREA'
)
return
sendEvent
(
'keydown'
,
e
)
}
const
keyupHandler
=
(
e
)
=>
{
if
(
e
.
target
.
tagName
===
'INPUT'
||
e
.
target
.
tagName
===
'TEXTAREA'
)
return
sendEvent
(
'keyup'
,
e
)
}
document
.
addEventListener
(
'click'
,
clickHandler
,
true
)
document
.
addEventListener
(
'mousedown'
,
mousedownHandler
,
true
)
document
.
addEventListener
(
'mouseup'
,
mouseupHandler
,
true
)
document
.
addEventListener
(
'mousemove'
,
mouseMoveHandler
)
document
.
addEventListener
(
'wheel'
,
wheelHandler
)
document
.
addEventListener
(
'keydown'
,
keydownHandler
)
document
.
addEventListener
(
'keyup'
,
keyupHandler
)
controlEventListeners
=
{
clickHandler
,
mousedownHandler
,
mouseupHandler
,
mouseMoveHandler
,
wheelHandler
,
keydownHandler
,
keyupHandler
}
console
.
log
(
'【控制端】事件监听已绑定(含捕获阶段)'
)
}
// ========== 顾问接收屏幕(自动响应 Offer) ==========
function
unbindControlEvents
()
{
if
(
!
controlEventListeners
)
return
const
{
clickHandler
,
mousedownHandler
,
mouseupHandler
,
mouseMoveHandler
,
wheelHandler
,
keydownHandler
,
keyupHandler
}
=
controlEventListeners
document
.
removeEventListener
(
'click'
,
clickHandler
,
true
)
document
.
removeEventListener
(
'mousedown'
,
mousedownHandler
,
true
)
document
.
removeEventListener
(
'mouseup'
,
mouseupHandler
,
true
)
document
.
removeEventListener
(
'mousemove'
,
mouseMoveHandler
)
document
.
removeEventListener
(
'wheel'
,
wheelHandler
)
document
.
removeEventListener
(
'keydown'
,
keydownHandler
)
document
.
removeEventListener
(
'keyup'
,
keyupHandler
)
controlEventListeners
=
null
}
function
simulateEvent
(
data
)
{
console
.
log
(
'【被控端】收到模拟请求:'
,
data
.
type
,
data
)
const
rect
=
document
.
documentElement
.
getBoundingClientRect
()
const
x
=
data
.
percentX
*
rect
.
width
const
y
=
data
.
percentY
*
rect
.
height
let
target
=
document
.
elementFromPoint
(
x
,
y
)
if
(
!
target
)
{
console
.
warn
(
'【被控端】坐标处无元素,使用 body'
)
target
=
document
.
body
}
console
.
log
(
'【被控端】模拟事件:'
,
data
.
type
,
'目标:'
,
target
.
tagName
,
`(
${
x
.
toFixed
(
0
)}
,
${
y
.
toFixed
(
0
)}
)`
,
'元素:'
,
target
)
if
(
data
.
type
===
'click'
)
{
const
downEvent
=
new
MouseEvent
(
'mousedown'
,
{
clientX
:
x
,
clientY
:
y
,
button
:
data
.
button
||
0
,
buttons
:
1
,
which
:
1
,
bubbles
:
true
,
cancelable
:
true
,
view
:
window
})
target
.
dispatchEvent
(
downEvent
)
const
upEvent
=
new
MouseEvent
(
'mouseup'
,
{
clientX
:
x
,
clientY
:
y
,
button
:
data
.
button
||
0
,
buttons
:
0
,
which
:
1
,
bubbles
:
true
,
cancelable
:
true
,
view
:
window
})
target
.
dispatchEvent
(
upEvent
)
const
clickEvent
=
new
MouseEvent
(
'click'
,
{
clientX
:
x
,
clientY
:
y
,
button
:
data
.
button
||
0
,
buttons
:
0
,
which
:
1
,
bubbles
:
true
,
cancelable
:
true
,
view
:
window
})
target
.
dispatchEvent
(
clickEvent
)
}
else
if
(
data
.
type
.
startsWith
(
'mouse'
))
{
const
event
=
new
MouseEvent
(
data
.
type
,
{
clientX
:
x
,
clientY
:
y
,
button
:
data
.
button
||
0
,
buttons
:
data
.
type
===
'mousedown'
?
1
:
0
,
which
:
1
,
bubbles
:
true
,
cancelable
:
true
,
view
:
window
})
target
.
dispatchEvent
(
event
)
}
else
if
(
data
.
type
===
'wheel'
)
{
const
event
=
new
WheelEvent
(
'wheel'
,
{
clientX
:
x
,
clientY
:
y
,
deltaX
:
data
.
deltaX
||
0
,
deltaY
:
data
.
deltaY
||
0
,
bubbles
:
true
,
cancelable
:
true
,
view
:
window
})
target
.
dispatchEvent
(
event
)
}
else
if
(
data
.
type
.
startsWith
(
'key'
))
{
const
event
=
new
KeyboardEvent
(
data
.
type
,
{
key
:
data
.
key
,
code
:
data
.
code
,
altKey
:
data
.
altKey
,
ctrlKey
:
data
.
ctrlKey
,
shiftKey
:
data
.
shiftKey
,
metaKey
:
data
.
metaKey
,
bubbles
:
true
,
cancelable
:
true
,
view
:
window
})
target
.
dispatchEvent
(
event
)
}
else
{
console
.
warn
(
'【被控端】未知事件类型:'
,
data
.
type
)
}
}
// ========== WebRTC 信令 ==========
async
function
handleWebRTCOffer
(
roomId
,
sdp
)
{
try
{
if
(
pc
&&
pc
.
signalingState
!==
'closed'
)
{
console
.
log
(
'复用现有 PeerConnection 进行重新协商'
)
setupDataChannelListener
()
await
pc
.
setRemoteDescription
(
new
RTCSessionDescription
(
sdp
))
const
answer
=
await
pc
.
createAnswer
()
await
pc
.
setLocalDescription
(
answer
)
ws
.
send
(
JSON
.
stringify
({
action
:
'webrtc_answer'
,
roomId
,
sdp
:
answer
}))
return
}
if
(
pc
)
{
pc
.
close
();
pc
=
null
}
pc
=
new
RTCPeerConnection
({
iceServers
:
[{
urls
:
'stun:stun.l.google.com:19302'
}]
iceServers
:
[
{
urls
:
'stun:stun.l.google.com:19302'
},
{
urls
:
'stun:stun1.l.google.com:19302'
}
]
})
pc
.
onicecandidate
=
(
event
)
=>
{
if
(
event
.
candidate
)
{
ws
.
send
(
JSON
.
stringify
({
action
:
'webrtc_ice_candidate'
,
roomId
,
candidate
:
event
.
candidate
}))
ws
.
send
(
JSON
.
stringify
({
action
:
'webrtc_ice_candidate'
,
roomId
,
candidate
:
event
.
candidate
}))
}
}
pc
.
ontrack
=
(
event
)
=>
{
const
stream
=
event
.
streams
[
0
]
console
.
log
(
'收到远程流,轨道数:'
,
stream
.
getTracks
().
length
)
const
setVideo
=
()
=>
{
if
(
remoteVideoRef
.
value
)
{
remoteVideoRef
.
value
.
srcObject
=
event
.
streams
[
0
]
remoteVideoRef
.
value
.
play
(
)
remoteVideoRef
.
value
.
srcObject
=
stream
remoteVideoRef
.
value
.
play
().
catch
(
e
=>
console
.
warn
(
'播放失败'
,
e
)
)
isReceivingScreen
.
value
=
true
console
.
log
(
'【WebRTC】开始接收屏幕流'
)
return
true
}
return
false
}
pc
.
onconnectionstatechange
=
()
=>
{
console
.
log
(
'【WebRTC】连接状态:'
,
pc
.
connectionState
)
if
(
pc
.
connectionState
===
'connected'
)
{
ElMessage
.
success
(
'已接收屏幕共享'
)
}
else
if
(
pc
.
connectionState
===
'disconnected'
||
pc
.
connectionState
===
'failed'
)
{
ElMessage
.
warning
(
'屏幕共享连接中断'
)
isReceivingScreen
.
value
=
false
if
(
!
setVideo
())
{
isReceivingScreen
.
value
=
true
nextTick
(()
=>
{
if
(
!
setVideo
())
setTimeout
(
setVideo
,
100
)
})
}
}
setupDataChannelListener
()
await
pc
.
setRemoteDescription
(
new
RTCSessionDescription
(
sdp
))
const
answer
=
await
pc
.
createAnswer
()
await
pc
.
setLocalDescription
(
answer
)
ws
.
send
(
JSON
.
stringify
({
action
:
'webrtc_answer'
,
roomId
,
sdp
:
answer
}))
console
.
log
(
'【WebRTC】回答已发送'
)
ws
.
send
(
JSON
.
stringify
({
action
:
'webrtc_answer'
,
roomId
,
sdp
:
answer
}))
}
catch
(
error
)
{
console
.
error
(
'
【WebRTC】
处理 Offer 失败:'
,
error
)
console
.
error
(
'处理 Offer 失败:'
,
error
)
ElMessage
.
error
(
'接收屏幕共享失败: '
+
error
.
message
)
}
}
...
...
@@ -736,9 +1004,13 @@ async function handleWebRTCAnswer(sdp) {
if
(
!
pc
)
return
try
{
await
pc
.
setRemoteDescription
(
new
RTCSessionDescription
(
sdp
))
console
.
log
(
'【WebRTC】远程描述已设置'
)
console
.
log
(
'远程描述设置成功'
)
if
(
remoteControlActive
&&
remoteControlDirection
.
value
===
'controlling'
&&
!
dataChannel
)
{
dataChannel
=
pc
.
createDataChannel
(
'remoteControl'
)
startControlling
(
dataChannel
)
}
}
catch
(
error
)
{
console
.
error
(
'
【WebRTC】
设置 Answer 失败:'
,
error
)
console
.
error
(
'设置 Answer 失败:'
,
error
)
}
}
...
...
@@ -746,8 +1018,9 @@ async function handleWebRTCIceCandidate(candidate) {
if
(
!
pc
)
return
try
{
await
pc
.
addIceCandidate
(
new
RTCIceCandidate
(
candidate
))
console
.
log
(
'ICE candidate 添加成功'
)
}
catch
(
error
)
{
console
.
error
(
'
【WebRTC】添加 ICE 候选
失败:'
,
error
)
console
.
error
(
'
ICE candidate 添加
失败:'
,
error
)
}
}
...
...
@@ -757,12 +1030,22 @@ function closeWebRTC() {
if
(
remoteVideoRef
.
value
)
{
remoteVideoRef
.
value
.
srcObject
=
null
}
console
.
log
(
'【WebRTC】连接已关闭'
)
if
(
dataChannel
)
{
unbindControlEvents
()
dataChannel
.
close
()
dataChannel
=
null
}
isDataChannelOpen
=
false
remoteControlActive
.
value
=
false
remoteControlDirection
.
value
=
null
console
.
log
(
'WebRTC 已关闭'
)
}
// ========== 处理 WebSocket 消息 ==========
async
function
handleWebSocketMessage
(
msg
)
{
console
.
log
(
'【WS消息】'
,
msg
)
const
currentRoomId
=
shareInfo
.
roomId
||
joinForm
.
roomId
||
''
if
(
msg
.
action
===
'control_transfer'
)
{
const
currentUserType
=
role
.
value
===
'customer'
?
'owner'
:
'participant'
const
currentUserId
=
userStore
.
userBizId
||
'admin'
...
...
@@ -812,10 +1095,9 @@ async function handleWebSocketMessage(msg) {
return
}
if
(
msg
.
action
===
'end_sharing'
)
{
console
.
log
(
'
【end_sharing】
收到结束共享消息,当前录制状态:'
,
recordingStatus
.
value
)
console
.
log
(
'收到结束共享消息,当前录制状态:'
,
recordingStatus
.
value
)
ElMessage
.
warning
(
'共享已结束'
)
if
(
recordingStatus
.
value
===
'recording'
)
{
console
.
log
(
'【end_sharing】正在录制,开始停止并上传...'
)
await
stopRecordingAndUpload
()
}
shouldReconnect
=
false
...
...
@@ -826,17 +1108,84 @@ async function handleWebSocketMessage(msg) {
closeWebRTC
()
return
}
// WebRTC 信令
if
(
msg
.
action
===
'presenter_change'
)
{
handlePresenterChange
(
msg
)
return
}
if
(
msg
.
action
===
'presenter_response'
)
{
handlePresenterResponse
(
msg
)
return
}
if
(
msg
.
action
===
'presenter_invite'
)
{
inviteMessage
.
value
=
`
${
msg
.
from
===
'customer'
?
'客户'
:
'顾问'
}
邀请您投屏,是否接受?`
pendingInvite
=
msg
inviteDialogVisible
.
value
=
true
return
}
if
(
msg
.
action
===
'presenter_revoke'
)
{
ElMessage
.
warning
(
'投屏权已被收回'
)
stopLocalSharing
()
presenter
.
value
=
'none'
return
}
if
(
msg
.
action
===
'request_remote_control'
)
{
remoteControlInviteMessage
.
value
=
`
${
msg
.
from
===
'customer'
?
'客户'
:
'顾问'
}
请求远程控制您的屏幕,是否接受?`
pendingRemoteControl
=
msg
remoteControlInviteVisible
.
value
=
true
return
}
if
(
msg
.
action
===
'remote_control_response'
)
{
if
(
msg
.
accept
)
{
ElMessage
.
success
(
'对方已接受远程控制'
)
remoteControlActive
.
value
=
true
remoteControlDirection
.
value
=
'controlling'
if
(
pc
&&
!
dataChannel
)
{
dataChannel
=
pc
.
createDataChannel
(
'remoteControl'
)
startControlling
(
dataChannel
)
try
{
const
offer
=
await
pc
.
createOffer
()
await
pc
.
setLocalDescription
(
offer
)
ws
.
send
(
JSON
.
stringify
({
action
:
'webrtc_offer'
,
roomId
:
currentRoomId
,
sdp
:
offer
}))
console
.
log
(
'【远程控制】重新协商 Offer 已发送'
)
}
catch
(
e
)
{
console
.
error
(
'重新协商失败'
,
e
)
ElMessage
.
error
(
'远程控制建立失败: '
+
e
.
message
)
}
}
else
{
ElMessage
.
warning
(
'无法建立远程控制,请检查连接状态'
)
}
}
else
{
ElMessage
.
info
(
'对方拒绝了远程控制'
)
}
return
}
if
(
msg
.
action
===
'remote_control_stop'
)
{
ElMessage
.
info
(
'远程控制已中断'
)
if
(
dataChannel
)
{
unbindControlEvents
()
dataChannel
.
close
()
dataChannel
=
null
}
remoteControlActive
.
value
=
false
remoteControlDirection
.
value
=
null
isDataChannelOpen
=
false
return
}
if
(
msg
.
action
===
'request_offer'
)
{
if
(
role
.
value
===
'customer'
&&
sharedStream
)
{
const
offer
=
await
pc
.
createOffer
()
await
pc
.
setLocalDescription
(
offer
)
ws
.
send
(
JSON
.
stringify
({
action
:
'webrtc_offer'
,
roomId
:
currentRoomId
,
sdp
:
offer
}))
ws
.
send
(
JSON
.
stringify
({
action
:
'presenter_change'
,
target
:
'customer'
,
from
:
'customer'
}))
}
return
}
if
(
msg
.
action
===
'webrtc_offer'
)
{
if
(
role
.
value
===
'consultant'
)
{
await
handleWebRTCOffer
(
msg
.
roomId
,
msg
.
sdp
)
}
return
}
if
(
msg
.
action
===
'webrtc_answer'
)
{
if
(
role
.
value
===
'customer'
)
{
await
handleWebRTCAnswer
(
msg
.
sdp
)
}
return
}
if
(
msg
.
action
===
'webrtc_ice_candidate'
)
{
...
...
@@ -893,39 +1242,7 @@ function sendSyncAction(action, value) {
ws
.
send
(
JSON
.
stringify
(
sendMsg
))
}
// ========== 客户结束共享 ==========
async
function
handleEndSharing
()
{
if
(
role
.
value
!==
'customer'
)
{
ElMessage
.
warning
(
'只有客户可以结束共享'
)
return
}
try
{
await
proxy
.
$modal
.
confirm
(
'确认结束共享?'
)
if
(
ws
&&
ws
.
readyState
===
WebSocket
.
OPEN
)
{
ws
.
send
(
JSON
.
stringify
({
action
:
'end_sharing'
}))
console
.
log
(
'【客户】已发送 end_sharing 消息'
)
}
await
endCoSession
({
roomId
:
shareInfo
.
roomId
})
ElMessage
.
success
(
'共享已结束'
)
shouldReconnect
=
false
if
(
wsReconnectTimer
)
clearTimeout
(
wsReconnectTimer
)
if
(
ws
)
{
ws
.
close
();
ws
=
null
}
wsConnected
.
value
=
false
sharingStatus
.
value
=
'已结束'
closeWebRTC
()
if
(
sharedStream
)
{
sharedStream
.
getTracks
().
forEach
(
track
=>
track
.
stop
())
sharedStream
=
null
}
}
catch
(
error
)
{
if
(
error
!==
'cancel'
)
{
console
.
error
(
'结束共享失败:'
,
error
)
ElMessage
.
error
(
'结束共享失败,请重试'
)
}
}
}
// ========== 工具 ==========
// ========== 复制共享码 ==========
function
copyCode
()
{
if
(
shareInfo
.
roomPwd
)
{
navigator
.
clipboard
?.
writeText
(
shareInfo
.
roomPwd
).
then
(()
=>
ElMessage
.
success
(
'已复制'
))
...
...
@@ -941,25 +1258,197 @@ function copyCode() {
}
}
// ========== 录制 UI 切换 ==========
// ========== 录制相关 ==========
async
function
startRecordingWithStream
(
stream
)
{
try
{
if
(
recordingStatus
.
value
===
'recording'
)
return
if
(
!
shareInfo
.
sessionBizId
)
{
ElMessage
.
warning
(
'未获取到协同会话ID,无法开始录制'
)
return
}
const
startRes
=
await
startRecording
({
bizType
:
'co_session'
,
bizId
:
shareInfo
.
sessionBizId
})
if
(
startRes
.
code
!==
200
)
{
ElMessage
.
error
(
startRes
.
msg
||
'创建录制任务失败'
)
return
}
recordingTaskId
.
value
=
startRes
.
data
console
.
log
(
'【录制】任务创建成功,taskId:'
,
recordingTaskId
.
value
)
const
mimeTypes
=
[
'video/webm;codecs=vp9'
,
'video/webm;codecs=vp8'
,
'video/webm'
,
'video/mp4'
]
let
mimeType
=
mimeTypes
.
find
(
type
=>
MediaRecorder
.
isTypeSupported
(
type
))
||
''
const
recorder
=
new
MediaRecorder
(
stream
,
{
mimeType
,
videoBitsPerSecond
:
10000000
})
uploadedChunkCount
.
value
=
0
isStopping
.
value
=
false
uploadPromises
=
[]
recorder
.
ondataavailable
=
async
(
event
)
=>
{
if
(
event
.
data
&&
event
.
data
.
size
>
0
)
{
const
index
=
uploadedChunkCount
.
value
uploadedChunkCount
.
value
++
const
file
=
new
File
([
event
.
data
],
`chunk_
${
Date
.
now
()}
.webm`
,
{
type
:
event
.
data
.
type
||
'video/webm'
})
console
.
log
(
`【录制】分片数据大小:
${
event
.
data
.
size
}
, 索引:
${
index
}
`
)
const
promise
=
uploadChunk
({
taskId
:
recordingTaskId
.
value
,
chunkIndex
:
index
,
chunk
:
file
,
projectBizId
:
projectBizId
.
value
,
source
:
'recording'
})
uploadPromises
.
push
(
promise
)
try
{
await
promise
console
.
log
(
`【录制】分片
${
index
}
实时上传成功`
)
}
catch
(
error
)
{
console
.
error
(
`【录制】分片
${
index
}
上传失败:`
,
error
)
}
}
}
recorder
.
onstop
=
()
=>
{
console
.
log
(
'【录制】MediaRecorder 停止,共产生'
,
uploadedChunkCount
.
value
,
'个分片'
)
}
recorder
.
onerror
=
(
event
)
=>
{
console
.
error
(
'【录制】MediaRecorder 错误:'
,
event
)
ElMessage
.
error
(
'录制发生错误'
)
}
const
videoTrack
=
stream
.
getVideoTracks
()[
0
]
if
(
videoTrack
)
{
videoTrack
.
addEventListener
(
'ended'
,
()
=>
{
if
(
recordingStatus
.
value
===
'recording'
)
{
ElMessage
.
warning
(
'屏幕共享被中断,录制将停止'
)
stopRecordingAndUpload
()
}
})
}
mediaRecorder
.
value
=
recorder
recorder
.
start
(
20000
)
recordingStatus
.
value
=
'recording'
recordingStartTime
.
value
=
Date
.
now
()
recordingDuration
.
value
=
0
if
(
durationTimer
)
clearInterval
(
durationTimer
)
durationTimer
=
setInterval
(()
=>
{
recordingDuration
.
value
=
Math
.
floor
((
Date
.
now
()
-
recordingStartTime
.
value
)
/
1000
)
},
1000
)
ElMessage
.
success
(
'开始录制屏幕'
)
}
catch
(
error
)
{
console
.
error
(
'【录制】启动失败:'
,
error
)
ElMessage
.
error
(
'录制启动失败: '
+
error
.
message
)
recordingStatus
.
value
=
'idle'
recordingTaskId
.
value
=
null
}
}
async
function
stopRecordingAndUpload
()
{
if
(
isStopping
.
value
)
return
if
(
recordingStatus
.
value
!==
'recording'
)
{
ElMessage
.
warning
(
'当前没有正在进行的录制'
)
return
}
if
(
!
mediaRecorder
.
value
)
{
ElMessage
.
warning
(
'录制器不存在'
)
return
}
isStopping
.
value
=
true
const
stopPromise
=
new
Promise
((
resolve
)
=>
{
const
originalOnStop
=
mediaRecorder
.
value
.
onstop
mediaRecorder
.
value
.
onstop
=
(
event
)
=>
{
if
(
originalOnStop
)
{
originalOnStop
(
event
)
}
resolve
()
}
})
mediaRecorder
.
value
.
stop
()
if
(
durationTimer
)
{
clearInterval
(
durationTimer
)
durationTimer
=
null
}
await
stopPromise
if
(
uploadPromises
.
length
===
0
)
{
ElMessage
.
warning
(
'没有录制到任何数据'
)
recordingStatus
.
value
=
'idle'
isStopping
.
value
=
false
uploadPromises
=
[]
return
}
try
{
await
Promise
.
all
(
uploadPromises
)
console
.
log
(
'【录制】所有分片上传完成'
)
}
catch
(
error
)
{
console
.
error
(
'【录制】分片上传失败:'
,
error
)
}
try
{
await
callStopRecording
()
ElMessage
.
success
(
'录制文件合并成功!'
)
}
catch
(
error
)
{
console
.
error
(
'【录制】合并失败:'
,
error
)
ElMessage
.
error
(
'合并失败: '
+
error
.
message
)
}
finally
{
uploadPromises
=
[]
isStopping
.
value
=
false
}
}
async
function
callStopRecording
()
{
try
{
const
stopRes
=
await
stopRecording
(
recordingTaskId
.
value
)
if
(
stopRes
.
code
===
200
)
{
const
data
=
stopRes
.
data
recordedFileUrl
.
value
=
data
.
fileUrl
||
''
recordingStatus
.
value
=
'stopped'
console
.
log
(
'【录制】文件地址:'
,
recordedFileUrl
.
value
)
try
{
const
queryRes
=
await
queryRecording
(
recordingTaskId
.
value
)
if
(
queryRes
.
code
===
200
)
console
.
log
(
'【录制】查询结果:'
,
queryRes
.
data
)
}
catch
(
e
)
{
/* ignore */
}
}
else
{
throw
new
Error
(
stopRes
.
msg
||
'停止录制失败'
)
}
}
catch
(
error
)
{
console
.
error
(
'【录制】停止录制失败:'
,
error
)
throw
error
}
finally
{
mediaRecorder
.
value
=
null
recordingTaskId
.
value
=
null
}
}
function
toggleRecording
()
{
if
(
recordingStatus
.
value
===
'recording'
)
{
stopRecordingAndUpload
()
}
else
if
(
recordingStatus
.
value
===
'idle'
)
{
ElMessage
.
info
(
'录制将在
共享屏幕
时自动开始'
)
ElMessage
.
info
(
'录制将在
投屏
时自动开始'
)
}
else
if
(
recordingStatus
.
value
===
'stopped'
)
{
if
(
recordedFileUrl
.
value
)
window
.
open
(
recordedFileUrl
.
value
,
'_blank'
)
}
}
function
formatDuration
(
seconds
)
{
if
(
!
seconds
||
seconds
<
0
)
return
'00:00'
const
mins
=
Math
.
floor
(
seconds
/
60
)
const
secs
=
seconds
%
60
return
`
${
String
(
mins
).
padStart
(
2
,
'0'
)}
:
${
String
(
secs
).
padStart
(
2
,
'0'
)}
`
}
// ==================== 脱敏核心逻辑 ====================
// ========== 脱敏相关 ==========
async
function
applyDesensitization
()
{
if
(
!
desensitizationEnabled
.
value
)
{
restoreOriginalContent
()
...
...
@@ -1049,6 +1538,11 @@ async function toggleDesensitization() {
}
}
function
testRemoteClick
()
{
console
.
log
(
'✅✅✅ 远程控制测试按钮被点击!'
)
ElMessage
.
success
(
'远程控制点击成功!'
)
}
// ========== 生命周期 ==========
onMounted
(()
=>
{
articles
.
forEach
((
article
,
index
)
=>
{
...
...
@@ -1084,11 +1578,8 @@ onBeforeUnmount(() => {
sharedStream
.
getTracks
().
forEach
(
track
=>
track
.
stop
())
sharedStream
=
null
}
if
(
recordingStream
.
value
)
{
recordingStream
.
value
.
getTracks
().
forEach
(
track
=>
track
.
stop
())
recordingStream
.
value
=
null
}
originalContentMap
.
clear
()
uploadPromises
=
[]
})
</
script
>
...
...
@@ -1121,6 +1612,12 @@ onBeforeUnmount(() => {
}
.role-badge.customer
{
background
:
#e6f7ff
;
color
:
#1890ff
;
}
.role-badge.consultant
{
background
:
#f6ffed
;
color
:
#52c41a
;
}
.presenter-badge
,
.remote-badge
{
font-size
:
12px
;
background
:
#f0f2f5
;
padding
:
2px
8px
;
border-radius
:
12px
;
}
.header-center
{
display
:
flex
;
flex-direction
:
column
;
...
...
@@ -1196,11 +1693,10 @@ onBeforeUnmount(() => {
}
.zoom-level
{
font-size
:
12px
;
font-weight
:
500
;
color
:
#333
;
}
/* 悬浮按钮组 */
.fab-group
{
position
:
fixed
;
bottom
:
30px
;
right
:
30px
;
right
:
70px
;
/* 原为30px,向左移动 */
z-index
:
30
;
display
:
flex
;
flex-direction
:
column-reverse
;
...
...
@@ -1221,6 +1717,17 @@ onBeforeUnmount(() => {
padding
:
0
;
}
.fab-btn
:hover
{
transform
:
scale
(
1.05
);
}
.fab-btn
:disabled
{
background
:
#d9d9d9
!important
;
color
:
#999
!important
;
cursor
:
not-allowed
;
box-shadow
:
none
;
opacity
:
0.7
;
}
.fab-btn
:disabled:hover
{
transform
:
none
;
background
:
#d9d9d9
!important
;
}
.fab-btn-text
{
font-size
:
12px
;
font-weight
:
600
;
...
...
@@ -1237,12 +1744,7 @@ onBeforeUnmount(() => {
.fab-desensitize
:hover
{
background
:
#73d13d
;
}
.fab-join
{
background
:
#1890ff
;
}
.fab-join
:hover
{
background
:
#40a9ff
;
}
.fab-authorize
{
background
:
#faad14
;
}
.fab-authorize
:hover
{
background
:
#ffd666
;
}
.fab-revoke
{
background
:
#ff4d4f
;
}
.fab-revoke
:hover
{
background
:
#ff7875
;
}
/* 视频容器 */
.video-container
{
position
:
relative
;
width
:
100%
;
...
...
@@ -1257,8 +1759,6 @@ onBeforeUnmount(() => {
height
:
100%
;
object-fit
:
contain
;
}
/* 视频覆盖层 */
.controls-overlay
{
position
:
absolute
;
bottom
:
30px
;
...
...
@@ -1275,6 +1775,14 @@ onBeforeUnmount(() => {
z-index
:
10
;
color
:
white
;
}
.video-actions
{
position
:
absolute
;
top
:
20px
;
right
:
20px
;
z-index
:
11
;
display
:
flex
;
gap
:
8px
;
}
.nav-controls
{
display
:
flex
;
align-items
:
center
;
...
...
@@ -1323,7 +1831,6 @@ onBeforeUnmount(() => {
padding-left
:
16px
;
border-left
:
1px
solid
rgba
(
255
,
255
,
255
,
0.1
);
}
.customer-info
{
background
:
#f6ffed
;
border
:
1px
solid
#b7eb8f
;
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment