Commit 0961b634 by zhangxingmin

push

parent d23ea9b2
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 参数
})
}
......
......@@ -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>
<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="startSharing"
@click="startCoSession"
>
<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="handleEndSharing"
@click="handleEndCoSession"
>
<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-revoke'"
:class="presenter === 'consultant' ? 'fab-end' : 'fab-share'"
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')
console.log('【角色】当前身份:', role.value)
const 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()
}
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)
}
}
}
function nextPage() {
if (canOperate.value && currentPage.value < articles.length - 1) {
changePage(currentPage.value + 1)
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('(模拟)大屏讲解已开启')
ElMessage.success('(模拟)协同会话已创建')
shouldReconnect = true
connectWebSocket(shareInfo.roomId, userId, 'owner')
}
}
// ========== 客户连接 WebSocket ==========
function connectAsCustomer() {
if (!shareInfo.roomId) return
shouldReconnect = true
connectWebSocket(shareInfo.roomId, userStore.userBizId || 'CUST-001', 'owner')
}
// ========== 顾问加入 ==========
async function submitJoin() {
if (!joinForm.roomPwd || !joinForm.roomId) {
ElMessage.warning('请填写完整信息')
async function startLocalSharing() {
if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
ElMessage.error('浏览器不支持屏幕共享')
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('加入失败,请稍后重试')
}
}
// ========== 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
}
}
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)
if (pc) { pc.close(); pc = null }
console.error('启动投屏失败:', error)
ElMessage.error('启动投屏失败: ' + error.message)
}
}
// ========== 录制功能(复用共享流) ==========
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)
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
}
}
// 直接使用传入的流
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)
}
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 handlePresenterChange(msg) {
if (msg.from === roleMap[role]) {
presenter.value = msg.from
return
}
if (msg.target) {
presenter.value = msg.target
return
}
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 }))
}
}
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 respondInvite(accept) {
inviteDialogVisible.value = false
ws.send(JSON.stringify({ action: 'presenter_response', accept, target: pendingInvite.target }))
if (accept) {
stopLocalSharing()
startLocalSharing()
}
}
// ========== 停止录制并上传 ==========
async function stopRecordingAndUpload() {
if (recordingStatus.value !== 'recording') {
ElMessage.warning('当前没有正在进行的录制')
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 (!mediaRecorder.value) {
ElMessage.warning('录制器不存在')
if (remoteControlActive.value) {
ElMessage.warning('远程控制已激活')
return
}
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
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('已发送远程控制请求,等待对方接受...')
}
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
}
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'
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)
}
} catch (error) {
console.error('【录制】停止并上传失败:', error)
ElMessage.error('停止录制失败: ' + (error.message || '未知错误'))
recordingStatus.value = 'idle'
} finally {
uploading.value = false
mediaRecorder.value = null
recordedChunks.value = []
recordingTaskId.value = null
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()
}
}
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
}
try {
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 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('【控制端】事件监听已绑定(含捕获阶段)')
}
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
}
// ========== 顾问接收屏幕(自动响应 Offer) ==========
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) => {
if (remoteVideoRef.value) {
remoteVideoRef.value.srcObject = event.streams[0]
remoteVideoRef.value.play()
isReceivingScreen.value = true
console.log('【WebRTC】开始接收屏幕流')
const stream = event.streams[0]
console.log('收到远程流,轨道数:', stream.getTracks().length)
const setVideo = () => {
if (remoteVideoRef.value) {
remoteVideoRef.value.srcObject = stream
remoteVideoRef.value.play().catch(e => console.warn('播放失败', e))
isReceivingScreen.value = true
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,19 +1108,86 @@ async function handleWebSocketMessage(msg) {
closeWebRTC()
return
}
// WebRTC 信令
if (msg.action === 'webrtc_offer') {
if (role.value === 'consultant') {
await handleWebRTCOffer(msg.roomId, msg.sdp)
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 === 'webrtc_answer') {
if (role.value === 'customer') {
await handleWebRTCAnswer(msg.sdp)
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') {
await handleWebRTCOffer(msg.roomId, msg.sdp)
return
}
if (msg.action === 'webrtc_answer') {
await handleWebRTCAnswer(msg.sdp)
return
}
if (msg.action === 'webrtc_ice_candidate') {
await handleWebRTCIceCandidate(msg.candidate)
return
......@@ -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;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment