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) { ...@@ -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 } * @param {Object} data - { bizType, bizId }
*/ */
...@@ -71,21 +108,48 @@ export function startRecording(data) { ...@@ -71,21 +108,48 @@ export function startRecording(data) {
} }
/** /**
* 停止录制并上传视频 * 上传单个分片
* @param {string} taskId - 录制任务ID * @param {Object} params
* @param {File} file - 视频文件 * @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() const formData = new FormData()
formData.append('taskId', taskId) formData.append('chunk', chunk) // 只放文件
formData.append('file', file)
return request({ return request({
url: '/communication/api/recordingTask/stop', url: '/oss/api/chunkedUploadContext/uploadChunk',
method: 'post', method: 'post',
params: {
taskId: taskId,
chunkIndex: chunkIndex,
projectBizId: '',
source: 'recording'
},
data: formData, data: formData,
headers: { headers: {
'Content-Type': 'multipart/form-data' '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 @@ ...@@ -7,85 +7,91 @@
<span class="role-badge" :class="role === 'customer' ? 'customer' : 'consultant'"> <span class="role-badge" :class="role === 'customer' ? 'customer' : 'consultant'">
{{ role === 'customer' ? '👤 客户' : '👤 顾问' }} {{ role === 'customer' ? '👤 客户' : '👤 顾问' }}
</span> </span>
<span v-if="presenter !== 'none'" class="presenter-badge">
📺 {{ presenter === role ? '您正在投屏' : '对方投屏中' }}
</span>
<span v-if="remoteControlActive" class="remote-badge">
🖱️ {{ remoteControlDirection === 'controlling' ? '正在控制对方' : '正在被控制' }}
</span>
</div> </div>
<div class="header-center"> <div class="header-center">
<span class="title">{{ currentArticle?.title || '加载中...' }}</span> <span class="title">{{ currentArticle?.title || '加载中...' }}</span>
<span class="page-info">{{ currentPage + 1 }} / {{ articles.length }}</span> <span class="page-info">{{ currentPage + 1 }} / {{ articles.length }}</span>
</div> </div>
<div class="header-right"> <div class="header-right">
<el-badge <el-badge :value="recordingStatus === 'recording' ? '录制中' : recordingStatus === 'stopped' ? '已录制' : '未录制'"
:value="recordingStatus === 'recording' ? '录制中' : recordingStatus === 'stopped' ? '已录制' : '未录制'" :type="recordingStatus === 'recording' ? 'danger' : recordingStatus === 'stopped' ? 'success' : 'info'">
:type="recordingStatus === 'recording' ? 'danger' : recordingStatus === 'stopped' ? 'success' : 'info'"
>
<el-button link type="primary" icon="VideoCamera" @click="toggleRecording" /> <el-button link type="primary" icon="VideoCamera" @click="toggleRecording" />
</el-badge> </el-badge>
<el-badge :value="wsConnected ? '在线' : '离线'" :type="wsConnected ? 'success' : 'danger'"> <el-badge :value="wsConnected ? '在线' : '离线'" :type="wsConnected ? 'success' : 'danger'">
<el-button link type="primary" icon="Connection" /> <el-button link type="primary" icon="Connection" />
</el-badge> </el-badge>
<el-button type="primary" size="small" @click="testRemoteClick">
🖱️ 测试点击
</el-button>
</div> </div>
</div> </div>
<!-- 文章内容区域 --> <!-- 内容区域 -->
<div class="reader-body" ref="readerBodyRef"> <div class="reader-body" ref="readerBodyRef">
<!-- 顾问且正在接收屏幕流时显示视频 --> <div v-if="isReceivingScreen" class="video-container">
<div v-if="role === 'consultant' && isReceivingScreen" class="video-container">
<video ref="remoteVideoRef" autoplay playsinline></video> <video ref="remoteVideoRef" autoplay playsinline></video>
<!-- 视频上方的控制覆盖层 --> <!-- 控制条仅对投屏者显示 -->
<div class="controls-overlay"> <div class="controls-overlay" v-if="presenter === role">
<div class="nav-controls"> <div class="nav-controls">
<button class="nav-btn" :class="{ disabled: !canOperate }" @click="prevPage" :disabled="!canOperate"> <button class="nav-btn" :class="{ disabled: !canOperate }" @click="prevPage" :disabled="!canOperate">◀</button>
<span></span>
</button>
<span class="page-text">{{ currentPage + 1 }} / {{ articles.length }}</span> <span class="page-text">{{ currentPage + 1 }} / {{ articles.length }}</span>
<button class="nav-btn" :class="{ disabled: !canOperate }" @click="nextPage" :disabled="!canOperate"> <button class="nav-btn" :class="{ disabled: !canOperate }" @click="nextPage" :disabled="!canOperate">▶</button>
<span></span>
</button>
</div> </div>
<div class="zoom-controls-overlay"> <div class="zoom-controls-overlay">
<button class="zoom-btn" @click="zoomOut" :disabled="!canOperate"></button> <button class="zoom-btn" @click="zoomOut" :disabled="!canOperate"></button>
<span>{{ Math.round(zoomLevel * 100) }}%</span> <span>{{ Math.round(zoomLevel * 100) }}%</span>
<button class="zoom-btn" @click="zoomIn" :disabled="!canOperate">+</button> <button class="zoom-btn" @click="zoomIn" :disabled="!canOperate">+</button>
</div> </div>
<div class="status-tip" v-if="!canOperate"> <div class="status-tip" v-if="!canOperate">🔒 {{ role === 'customer' ? '顾问' : '客户' }}正在操作</div>
<span>🔒 顾问正在操作</span> </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> </div>
<!-- 否则显示文章内容 -->
<div v-else class="article-wrapper" :style="{ transform: `scale(${zoomLevel})`, transformOrigin: 'top left' }"> <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 class="article-content" ref="articleContentRef" v-html="currentArticle?.content || ''"></div>
</div> </div>
</div> </div>
<!-- 底部翻页控制(客户可见,顾问在视频模式下隐藏) --> <div class="reader-footer" v-if="!isReceivingScreen">
<div class="reader-footer" v-if="!(role === 'consultant' && isReceivingScreen)">
<el-button type="primary" plain circle icon="ArrowLeft" @click="prevPage" :disabled="!canOperate" /> <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-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" /> <el-button type="primary" plain circle icon="ArrowRight" @click="nextPage" :disabled="!canOperate" />
</div> </div>
<!-- 缩放控制(客户可见,顾问在视频模式下隐藏) --> <div class="zoom-controls" v-if="!isReceivingScreen">
<div class="zoom-controls" v-if="!(role === 'consultant' && isReceivingScreen)">
<el-button type="primary" plain circle size="small" @click="zoomOut" :disabled="!canOperate || zoomLevel <= 0.5"></el-button> <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> <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> <el-button type="primary" plain circle size="small" @click="zoomIn" :disabled="!canOperate || zoomLevel >= 2.0">+</el-button>
</div> </div>
<!-- ★★★ 右下角悬浮按钮组 ★★★ --> <!-- 悬浮按钮组 -->
<div class="fab-group"> <div class="fab-group">
<!-- 客户按钮组 --> <!-- 客户按钮组 -->
<template v-if="role === 'customer'"> <template v-if="role === 'customer'">
<!-- 开始共享 / 结束共享 --> <!-- 发起/结束协同 -->
<el-badge :value="sharingStatus === '讲解中' ? '讲解中' : '未开始'" :type="sharingStatus === '讲解中' ? 'success' : 'info'"> <el-badge :value="sharingStatus === '讲解中' ? '已创建' : '未开始'" :type="sharingStatus === '讲解中' ? 'success' : 'info'">
<el-button <el-button
v-if="sharingStatus !== '讲解中'" v-if="sharingStatus !== '讲解中'"
class="fab-btn fab-share" class="fab-btn fab-share"
type="primary" type="primary"
circle circle
size="large" size="large"
@click="startSharing" @click="startCoSession"
> >
<span class="fab-btn-text">开始<br>共享</span> <span class="fab-btn-text">发起<br>协同</span>
</el-button> </el-button>
<el-button <el-button
v-else v-else
...@@ -93,20 +99,32 @@ ...@@ -93,20 +99,32 @@
type="danger" type="danger"
circle circle
size="large" size="large"
@click="handleEndSharing" @click="handleEndCoSession"
> >
<span class="fab-btn-text">结束<br>共享</span> <span class="fab-btn-text">结束<br>协同</span>
</el-button> </el-button>
</el-badge> </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 <el-button
class="fab-btn fab-desensitize" class="fab-btn"
:class="presenter === 'customer' ? 'fab-end' : 'fab-share'"
type="primary" type="primary"
circle circle
size="large" 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> <span class="fab-btn-text">脱敏</span>
</el-button> </el-button>
</el-badge> </el-badge>
...@@ -114,8 +132,8 @@ ...@@ -114,8 +132,8 @@
<!-- 顾问按钮组 --> <!-- 顾问按钮组 -->
<template v-if="role === 'consultant'"> <template v-if="role === 'consultant'">
<!-- 加入 -->
<el-badge :value="sharingStatus === '讲解中' ? '已加入' : '未加入'" :type="sharingStatus === '讲解中' ? 'success' : 'info'"> <el-badge :value="sharingStatus === '讲解中' ? '已加入' : '未加入'" :type="sharingStatus === '讲解中' ? 'success' : 'info'">
<!-- 未加入时显示“加入” -->
<el-button <el-button
v-if="sharingStatus !== '讲解中'" v-if="sharingStatus !== '讲解中'"
class="fab-btn fab-join" class="fab-btn fab-join"
...@@ -126,86 +144,96 @@ ...@@ -126,86 +144,96 @@
> >
<span class="fab-btn-text">加入</span> <span class="fab-btn-text">加入</span>
</el-button> </el-button>
<!-- 已加入时显示“授权客户”或“收回授权” --> <!-- 投屏/结束投屏 -->
<el-button <el-button
v-else v-else
class="fab-btn" class="fab-btn"
:class="isController ? 'fab-authorize' : 'fab-revoke'" :class="presenter === 'consultant' ? 'fab-end' : 'fab-share'"
type="primary" type="primary"
circle circle
size="large" 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-button>
</el-badge> </el-badge>
<!-- 顾问投屏 badge 提示(复用上方按钮自带的 badge,此处不需要额外加) -->
</template> </template>
</div> </div>
<!-- 共享信息弹窗 --> <!-- 弹窗:共享信息 -->
<el-dialog title="大屏讲解" v-model="shareDialogVisible" width="400px" append-to-body> <el-dialog title="大屏讲解" v-model="shareDialogVisible" width="400px" append-to-body>
<div style="text-align: center; padding: 16px 0;"> <div style="text-align:center;padding:16px 0;">
<el-alert title="请将以下信息分享给顾问" type="success" :closable="false" style="margin-bottom: 16px;" /> <el-alert title="请将以下信息分享给顾问" type="success" :closable="false" style="margin-bottom:16px;" />
<div style="margin-bottom: 16px;"> <div style="margin-bottom:16px;">
<div style="font-size: 14px; color: #666;">共享码</div> <div style="font-size:14px;color:#666;">共享码</div>
<div style="font-size: 28px; font-weight: bold; letter-spacing: 6px; color: #1890ff;"> <div style="font-size:28px;font-weight:bold;letter-spacing:6px;color:#1890ff;">{{ shareInfo.roomPwd || '------' }}</div>
{{ shareInfo.roomPwd || '------' }} <el-button size="small" type="primary" plain @click="copyCode" style="margin-top:8px;">复制</el-button>
</div>
<el-button size="small" type="primary" plain @click="copyCode" style="margin-top: 8px;">复制</el-button>
</div> </div>
<div> <div>
<div style="font-size: 14px; color: #666;">房间号</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:16px;font-weight:500;color:#333;">{{ shareInfo.roomId || '------' }}</div>
</div> </div>
</div> </div>
<template #footer>
<el-button type="primary" @click="shareDialogVisible = false; connectAsCustomer()">开始协同</el-button>
</template>
</el-dialog> </el-dialog>
<!-- 顾问加入对话框 --> <!-- 弹窗:顾问加入 -->
<el-dialog title="加入协同" v-model="joinDialogVisible" width="400px" append-to-body> <el-dialog title="加入协同" v-model="joinDialogVisible" width="400px" append-to-body>
<el-form :model="joinForm" label-width="80px"> <el-form :model="joinForm" label-width="80px">
<el-form-item label="共享码"> <el-form-item label="共享码"><el-input v-model="joinForm.roomPwd" placeholder="请输入6位共享码" maxlength="6" /></el-form-item>
<el-input v-model="joinForm.roomPwd" placeholder="请输入6位共享码" maxlength="6" /> <el-form-item label="房间号"><el-input v-model="joinForm.roomId" placeholder="请输入房间号" /></el-form-item>
</el-form-item>
<el-form-item label="房间号">
<el-input v-model="joinForm.roomId" placeholder="请输入房间号" />
</el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button type="primary" @click="submitJoin">加入</el-button> <el-button type="primary" @click="submitJoin">加入</el-button>
<el-button @click="joinDialogVisible = false">取消</el-button> <el-button @click="joinDialogVisible = false">取消</el-button>
</template> </template>
</el-dialog> </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> </div>
</template> </template>
<script setup name="ReportReader"> <script setup>
import { ref, reactive, computed, onMounted, onBeforeUnmount, getCurrentInstance, nextTick } from 'vue' import { ref, reactive, computed, onMounted, onBeforeUnmount, nextTick, getCurrentInstance } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { import {
createCoSession, createCoSession,
endCoSession, endCoSession as apiEndCoSession,
joinCoSession, joinCoSession,
startRecording, startRecording,
stopRecording, stopRecording,
queryRecording, queryRecording,
getDesensitizationRules getDesensitizationRules,
uploadChunk
} from '@/api/system/report' } from '@/api/system/report'
import useUserStore from '@/store/modules/user' import useUserStore from '@/store/modules/user'
import { getToken } from '@/utils/auth' import { getToken } from '@/utils/auth'
console.log('【协同报告阅读器】组件开始加载') console.log('【共享报告阅读器】组件加载')
const { proxy } = getCurrentInstance() const { proxy } = getCurrentInstance()
const userStore = useUserStore() const userStore = useUserStore()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const role = ref(route.query.role || 'customer') const role = ref(route.query.role || 'customer')
console.log('【角色】当前身份:', role.value) const roleMap = { customer: 'customer', consultant: 'consultant' }
// ========== 文章数据 ========== // ========== 文章数据 ==========
const articles = reactive([ const articles = reactive([
...@@ -299,101 +327,208 @@ const isController = ref(false) ...@@ -299,101 +327,208 @@ const isController = ref(false)
const shareInfo = reactive({ roomId: '', roomPwd: '', sessionBizId: '', ownerId: '' }) const shareInfo = reactive({ roomId: '', roomPwd: '', sessionBizId: '', ownerId: '' })
const joinForm = reactive({ roomId: '', roomPwd: '' }) 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 ws = null
let wsReconnectTimer = null let wsReconnectTimer = null
let shouldReconnect = true let shouldReconnect = true
// ========== 录制相关状态 ========== // ========== 录制相关 ==========
const recordingStatus = ref('idle') const recordingStatus = ref('idle')
const recordingTaskId = ref(null) const recordingTaskId = ref(null)
const mediaRecorder = ref(null) const mediaRecorder = ref(null)
const recordedChunks = ref([]) const uploadedChunkCount = ref(0)
const recordingStream = ref(null)
const recordingStartTime = ref(null) const recordingStartTime = ref(null)
const recordingDuration = ref(0) const recordingDuration = ref(0)
let durationTimer = null let durationTimer = null
const recordedFileUrl = ref('') const recordedFileUrl = ref('')
const uploading = ref(false) const isStopping = ref(false)
let uploadPromises = []
// ========== WebRTC 相关 ========== // 项目业务ID(为空时后端自动使用默认OSS)
let pc = null const projectBizId = ref('')
const remoteVideoRef = ref(null)
const isReceivingScreen = ref(false)
// ★ 共享流(全局)
let sharedStream = null
// ========== 计算属性 ========== // ========== 计算属性 ==========
const currentArticle = computed(() => articles[currentPage.value] || articles[0]) const currentArticle = computed(() => articles[currentPage.value] || articles[0])
const canOperate = computed(() => wsConnected.value && isController.value) const canOperate = computed(() => wsConnected.value && isController.value)
// ========== 页面导航 ========== // ========== 页面导航 ==========
function goBack() { function goBack() { router.push('/system/report') }
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 }))
} }
// ========== 翻页、滚动、缩放 ========== // ========== WebSocket 连接 ==========
function prevPage() { function connectWebSocket(roomId, userId, userType, sendControl = false, onOpenCallback = null) {
if (canOperate.value && currentPage.value > 0) { if (ws) { try { ws.close() } catch (e) {}; ws = null }
changePage(currentPage.value - 1) 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)
}
} }
} ws.onmessage = (event) => {
function nextPage() { try { handleWebSocketMessage(JSON.parse(event.data)) } catch (e) { console.error('解析消息失败:', e) }
if (canOperate.value && currentPage.value < articles.length - 1) {
changePage(currentPage.value + 1)
} }
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 function connectAsCustomer() {
changePage(val) if (!shareInfo.roomId) return
nextTick(() => { shouldReconnect = true
sendSyncAction('turn_the_page', 0) 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) async function submitJoin() {
sendSyncAction('scaling', zoomLevel.value) 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) async function startCoSession() {
sendSyncAction('scaling', zoomLevel.value) if (sharingStatus.value === '讲解中') {
ElMessage.warning('已创建协同会话,请等待顾问加入')
return
} }
await createSessionOnly()
} }
function changePage(newPage) { async function handleEndCoSession() {
if (newPage < 0 || newPage >= articles.length) return if (recordingStatus.value === 'recording') {
currentPage.value = newPage await stopRecordingAndUpload()
if (desensitizationEnabled.value) { }
applyDesensitization() await stopSharing()
} else { if (role.value === 'customer') {
restoreOriginalContent() 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
} }
} }
// ========== 控制权切换 ========== async function toggleScreenShare() {
function toggleControl() { if (presenter.value === role.value) {
if (role.value !== 'consultant') { await stopSharing()
ElMessage.warning('只有顾问可以切换控制权')
return return
} }
if (!wsConnected.value) { if (presenter.value === 'none') {
ElMessage.warning('未连接协同') await startLocalSharing()
return 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 stopSharing() {
async function startSharing() { 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 userId = userStore.userBizId || 'admin'
const token = getToken() || 'demo-token' const token = getToken() || 'demo-token'
const ownerType = role.value === 'customer' ? 'customer' : 'consultant'
try { try {
const res = await createCoSession({ const res = await createCoSession({
scope: 'single', scope: 'single',
...@@ -414,7 +549,7 @@ async function startSharing() { ...@@ -414,7 +549,7 @@ async function startSharing() {
} }
}), }),
ownerId: userId, ownerId: userId,
ownerType: ownerType, ownerType: 'customer',
userId: userId, userId: userId,
token: token token: token
}) })
...@@ -426,9 +561,11 @@ async function startSharing() { ...@@ -426,9 +561,11 @@ async function startSharing() {
shareInfo.ownerId = userId shareInfo.ownerId = userId
sharingStatus.value = '讲解中' sharingStatus.value = '讲解中'
shareDialogVisible.value = true shareDialogVisible.value = true
ElMessage.success('大屏讲解已开启,请分享共享码') ElMessage.success('协同会话已创建,请分享共享码给顾问')
shouldReconnect = true
connectWebSocket(shareInfo.roomId, userId, 'owner')
} else { } else {
ElMessage.error(res.msg || '开启失败') ElMessage.error(res.msg || '创建失败')
} }
} catch (e) { } catch (e) {
shareInfo.roomId = 'room_' + Math.random().toString(36).substr(2, 8) shareInfo.roomId = 'room_' + Math.random().toString(36).substr(2, 8)
...@@ -437,297 +574,428 @@ async function startSharing() { ...@@ -437,297 +574,428 @@ async function startSharing() {
shareInfo.ownerId = userId shareInfo.ownerId = userId
sharingStatus.value = '讲解中' sharingStatus.value = '讲解中'
shareDialogVisible.value = true shareDialogVisible.value = true
ElMessage.success('(模拟)大屏讲解已开启') ElMessage.success('(模拟)协同会话已创建')
shouldReconnect = true
connectWebSocket(shareInfo.roomId, userId, 'owner')
} }
} }
// ========== 客户连接 WebSocket ========== async function startLocalSharing() {
function connectAsCustomer() { if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
if (!shareInfo.roomId) return ElMessage.error('浏览器不支持屏幕共享')
shouldReconnect = true
connectWebSocket(shareInfo.roomId, userStore.userBizId || 'CUST-001', 'owner')
}
// ========== 顾问加入 ==========
async function submitJoin() {
if (!joinForm.roomPwd || !joinForm.roomId) {
ElMessage.warning('请填写完整信息')
return return
} }
try { 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 } if (pc) { pc.close(); pc = null }
// ★ 获取屏幕流(只弹一次)
const stream = await navigator.mediaDevices.getDisplayMedia({ const stream = await navigator.mediaDevices.getDisplayMedia({
video: { video: { frameRate: { ideal: 30 }, cursor: 'always' },
displaySurface: 'browser',
frameRate: { ideal: 30 },
cursor: 'always'
},
audio: true audio: true
}) })
// 保存全局流供录制复用
sharedStream = stream sharedStream = stream
// ★ 立即启动录制(复用同一个流)
if (shareInfo.sessionBizId) { if (shareInfo.sessionBizId) {
startRecordingWithStream(stream) await startRecordingWithStream(stream)
} else {
console.warn('【录制】sessionBizId 未就绪,稍后重试')
} }
// 创建 PeerConnection
pc = new RTCPeerConnection({ 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)) stream.getTracks().forEach(track => pc.addTrack(track, stream))
pc.onicecandidate = (event) => { pc.onicecandidate = (event) => {
if (event.candidate) { if (event.candidate) {
ws.send(JSON.stringify({ ws.send(JSON.stringify({ action: 'webrtc_ice_candidate', roomId: shareInfo.roomId, candidate: event.candidate }))
action: 'webrtc_ice_candidate',
roomId,
candidate: event.candidate
}))
} }
} }
pc.onconnectionstatechange = () => { pc.onconnectionstatechange = () => {
console.log('【WebRTC】连接状态:', pc.connectionState) if (pc.connectionState === 'connected') ElMessage.success('屏幕共享已连接')
if (pc.connectionState === 'connected') {
ElMessage.success('屏幕共享已连接')
} else if (pc.connectionState === 'disconnected' || pc.connectionState === 'failed') {
ElMessage.warning('屏幕共享连接中断')
}
} }
setupDataChannelListener()
const offer = await pc.createOffer() const offer = await pc.createOffer()
await pc.setLocalDescription(offer) await pc.setLocalDescription(offer)
ws.send(JSON.stringify({ ws.send(JSON.stringify({ action: 'webrtc_offer', roomId: shareInfo.roomId, sdp: offer }))
action: 'webrtc_offer', presenter.value = role.value === 'customer' ? 'customer' : 'consultant'
roomId, ws.send(JSON.stringify({ action: 'presenter_change', target: presenter.value, from: roleMap[role] }))
sdp: offer
}))
console.log('【WebRTC】屏幕共享已启动')
} catch (error) { } catch (error) {
console.error('【WebRTC】启动屏幕共享失败:', error) console.error('启动投屏失败:', error)
ElMessage.error('启动屏幕共享失败: ' + error.message) ElMessage.error('启动投屏失败: ' + error.message)
if (pc) { pc.close(); pc = null }
} }
} }
// ========== 录制功能(复用共享流) ========== function stopLocalSharing() {
async function startRecordingWithStream(stream) { if (pc) { pc.close(); pc = null }
try { if (sharedStream) {
if (recordingStatus.value === 'recording') return sharedStream.getTracks().forEach(track => track.stop())
if (!shareInfo.sessionBizId) { sharedStream = null
ElMessage.warning('未获取到协同会话ID,无法开始录制') }
return isReceivingScreen.value = false
} if (remoteVideoRef.value) {
const startRes = await startRecording({ bizType: 'co_session', bizId: shareInfo.sessionBizId }) remoteVideoRef.value.srcObject = null
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'] function handlePresenterChange(msg) {
let mimeType = mimeTypes.find(type => MediaRecorder.isTypeSupported(type)) || '' if (msg.from === roleMap[role]) {
const recorder = new MediaRecorder(stream, { mimeType, videoBitsPerSecond: 2500000 }) presenter.value = msg.from
recordedChunks.value = [] return
recorder.ondataavailable = (event) => { }
if (event.data && event.data.size > 0) recordedChunks.value.push(event.data) if (msg.target) {
} presenter.value = msg.target
recorder.onstop = () => console.log('【录制】MediaRecorder 停止,共', recordedChunks.value.length, '个数据块') return
recorder.onerror = (event) => { console.error('【录制】MediaRecorder 错误:', event); ElMessage.error('录制发生错误') } }
if (msg.target === roleMap[role]) {
// 监听流结束(用户点击停止共享时,自动停止录制) inviteMessage.value = `${msg.from === 'customer' ? '客户' : '顾问'} 请求投屏,是否接受?`
if (stream) { pendingInvite = msg
const videoTrack = stream.getVideoTracks()[0] inviteDialogVisible.value = true
if (videoTrack) { } else {
videoTrack.addEventListener('ended', () => { ws.send(JSON.stringify({ action: 'presenter_response', accept: false, target: msg.target }))
if (recordingStatus.value === 'recording') { }
ElMessage.warning('屏幕共享被中断,录制将停止') }
stopRecordingAndUpload()
}
})
}
}
mediaRecorder.value = recorder function respondInvite(accept) {
recorder.start(1000) inviteDialogVisible.value = false
recordingStatus.value = 'recording' ws.send(JSON.stringify({ action: 'presenter_response', accept, target: pendingInvite.target }))
recordingStartTime.value = Date.now() if (accept) {
recordingDuration.value = 0 stopLocalSharing()
if (durationTimer) clearInterval(durationTimer) startLocalSharing()
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 handlePresenterResponse(msg) {
async function stopRecordingAndUpload() { if (msg.accept) {
if (recordingStatus.value !== 'recording') { stopLocalSharing()
ElMessage.warning('当前没有正在进行的录制') presenter.value = msg.target
ElMessage.success('投屏已切换')
} else {
ElMessage.info('对方拒绝了投屏切换')
}
}
// ========== 远程控制 ==========
function requestRemoteControl() {
if (!wsConnected.value) {
ElMessage.warning('未连接协同')
return return
} }
if (!mediaRecorder.value) { if (remoteControlActive.value) {
ElMessage.warning('录制器不存在') ElMessage.warning('远程控制已激活')
return return
} }
uploading.value = true if (presenter.value === 'none') {
try { ElMessage.warning('当前无人投屏,无法请求远程控制')
mediaRecorder.value.stop() return
if (durationTimer) { clearInterval(durationTimer); durationTimer = null } }
await new Promise(resolve => setTimeout(resolve, 500)) ws.send(JSON.stringify({
if (recordedChunks.value.length === 0) { action: 'request_remote_control',
ElMessage.warning('没有录制到任何数据') target: presenter.value,
recordingStatus.value = 'idle' from: role.value === 'customer' ? 'customer' : 'consultant'
uploading.value = false }))
return 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' }) remoteControlActive.value = false
console.log('【录制】文件大小:', (blob.size / 1024 / 1024).toFixed(2), 'MB') remoteControlDirection.value = null
const fileExtension = blob.type.includes('mp4') ? 'mp4' : 'webm' ws.send(JSON.stringify({ action: 'remote_control_stop' }))
const fileName = `recording_${recordingTaskId.value}_${Date.now()}.${fileExtension}` } else {
const file = new File([blob], fileName, { type: blob.type }) ws.send(JSON.stringify({ action: 'remote_control_stop' }))
const stopRes = await stopRecording(recordingTaskId.value, file) remoteControlActive.value = false
if (stopRes.code === 200) { remoteControlDirection.value = null
const data = stopRes.data }
recordedFileUrl.value = data.fileUrl || '' }
recordingStatus.value = 'stopped'
ElMessage.success('录制文件上传成功!') function setupDataChannelListener() {
console.log('【录制】上传成功,fileUrl:', recordedFileUrl.value) if (!pc) return
try { pc.ondatachannel = null
const queryRes = await queryRecording(recordingTaskId.value) pc.ondatachannel = (event) => {
if (queryRes.code === 200) console.log('【录制】查询结果:', queryRes.data) const channel = event.channel
} catch (e) { console.warn('查询录制信息失败:', e) } console.log('【被控端】DataChannel 连接建立,标签:', channel.label)
} else { channel.onmessage = (e) => {
ElMessage.error(stopRes.msg || '上传失败') const data = JSON.parse(e.data)
recordingStatus.value = 'idle' console.log('【被控端】收到远程控制事件:', data.type)
simulateEvent(data)
} }
} catch (error) { channel.onopen = () => {
console.error('【录制】停止并上传失败:', error) remoteControlActive.value = true
ElMessage.error('停止录制失败: ' + (error.message || '未知错误')) remoteControlDirection.value = 'being_controlled'
recordingStatus.value = 'idle' console.log('【被控端】DataChannel 已打开')
} finally { }
uploading.value = false channel.onclose = () => {
mediaRecorder.value = null remoteControlActive.value = false
recordedChunks.value = [] remoteControlDirection.value = null
recordingTaskId.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) { async function handleWebRTCOffer(roomId, sdp) {
try { 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 } if (pc) { pc.close(); pc = null }
pc = new RTCPeerConnection({ 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) => { pc.onicecandidate = (event) => {
if (event.candidate) { if (event.candidate) {
ws.send(JSON.stringify({ ws.send(JSON.stringify({ action: 'webrtc_ice_candidate', roomId, candidate: event.candidate }))
action: 'webrtc_ice_candidate',
roomId,
candidate: event.candidate
}))
} }
} }
pc.ontrack = (event) => { pc.ontrack = (event) => {
if (remoteVideoRef.value) { const stream = event.streams[0]
remoteVideoRef.value.srcObject = event.streams[0] console.log('收到远程流,轨道数:', stream.getTracks().length)
remoteVideoRef.value.play() const setVideo = () => {
isReceivingScreen.value = true if (remoteVideoRef.value) {
console.log('【WebRTC】开始接收屏幕流') remoteVideoRef.value.srcObject = stream
remoteVideoRef.value.play().catch(e => console.warn('播放失败', e))
isReceivingScreen.value = true
return true
}
return false
} }
} if (!setVideo()) {
pc.onconnectionstatechange = () => { isReceivingScreen.value = true
console.log('【WebRTC】连接状态:', pc.connectionState) nextTick(() => {
if (pc.connectionState === 'connected') { if (!setVideo()) setTimeout(setVideo, 100)
ElMessage.success('已接收屏幕共享') })
} else if (pc.connectionState === 'disconnected' || pc.connectionState === 'failed') {
ElMessage.warning('屏幕共享连接中断')
isReceivingScreen.value = false
} }
} }
setupDataChannelListener()
await pc.setRemoteDescription(new RTCSessionDescription(sdp)) await pc.setRemoteDescription(new RTCSessionDescription(sdp))
const answer = await pc.createAnswer() const answer = await pc.createAnswer()
await pc.setLocalDescription(answer) await pc.setLocalDescription(answer)
ws.send(JSON.stringify({ ws.send(JSON.stringify({ action: 'webrtc_answer', roomId, sdp: answer }))
action: 'webrtc_answer',
roomId,
sdp: answer
}))
console.log('【WebRTC】回答已发送')
} catch (error) { } catch (error) {
console.error('【WebRTC】处理 Offer 失败:', error) console.error('处理 Offer 失败:', error)
ElMessage.error('接收屏幕共享失败: ' + error.message) ElMessage.error('接收屏幕共享失败: ' + error.message)
} }
} }
...@@ -736,9 +1004,13 @@ async function handleWebRTCAnswer(sdp) { ...@@ -736,9 +1004,13 @@ async function handleWebRTCAnswer(sdp) {
if (!pc) return if (!pc) return
try { try {
await pc.setRemoteDescription(new RTCSessionDescription(sdp)) 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) { } catch (error) {
console.error('【WebRTC】设置 Answer 失败:', error) console.error('设置 Answer 失败:', error)
} }
} }
...@@ -746,8 +1018,9 @@ async function handleWebRTCIceCandidate(candidate) { ...@@ -746,8 +1018,9 @@ async function handleWebRTCIceCandidate(candidate) {
if (!pc) return if (!pc) return
try { try {
await pc.addIceCandidate(new RTCIceCandidate(candidate)) await pc.addIceCandidate(new RTCIceCandidate(candidate))
console.log('ICE candidate 添加成功')
} catch (error) { } catch (error) {
console.error('【WebRTC】添加 ICE 候选失败:', error) console.error('ICE candidate 添加失败:', error)
} }
} }
...@@ -757,12 +1030,22 @@ function closeWebRTC() { ...@@ -757,12 +1030,22 @@ function closeWebRTC() {
if (remoteVideoRef.value) { if (remoteVideoRef.value) {
remoteVideoRef.value.srcObject = null 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 消息 ========== // ========== 处理 WebSocket 消息 ==========
async function handleWebSocketMessage(msg) { async function handleWebSocketMessage(msg) {
console.log('【WS消息】', msg) console.log('【WS消息】', msg)
const currentRoomId = shareInfo.roomId || joinForm.roomId || ''
if (msg.action === 'control_transfer') { if (msg.action === 'control_transfer') {
const currentUserType = role.value === 'customer' ? 'owner' : 'participant' const currentUserType = role.value === 'customer' ? 'owner' : 'participant'
const currentUserId = userStore.userBizId || 'admin' const currentUserId = userStore.userBizId || 'admin'
...@@ -812,10 +1095,9 @@ async function handleWebSocketMessage(msg) { ...@@ -812,10 +1095,9 @@ async function handleWebSocketMessage(msg) {
return return
} }
if (msg.action === 'end_sharing') { if (msg.action === 'end_sharing') {
console.log('【end_sharing】收到结束共享消息,当前录制状态:', recordingStatus.value) console.log('收到结束共享消息,当前录制状态:', recordingStatus.value)
ElMessage.warning('共享已结束') ElMessage.warning('共享已结束')
if (recordingStatus.value === 'recording') { if (recordingStatus.value === 'recording') {
console.log('【end_sharing】正在录制,开始停止并上传...')
await stopRecordingAndUpload() await stopRecordingAndUpload()
} }
shouldReconnect = false shouldReconnect = false
...@@ -826,19 +1108,86 @@ async function handleWebSocketMessage(msg) { ...@@ -826,19 +1108,86 @@ async function handleWebSocketMessage(msg) {
closeWebRTC() closeWebRTC()
return return
} }
// WebRTC 信令 if (msg.action === 'presenter_change') {
if (msg.action === 'webrtc_offer') { handlePresenterChange(msg)
if (role.value === 'consultant') { return
await handleWebRTCOffer(msg.roomId, msg.sdp) }
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 return
} }
if (msg.action === 'webrtc_answer') { if (msg.action === 'remote_control_stop') {
if (role.value === 'customer') { ElMessage.info('远程控制已中断')
await handleWebRTCAnswer(msg.sdp) 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 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') { if (msg.action === 'webrtc_ice_candidate') {
await handleWebRTCIceCandidate(msg.candidate) await handleWebRTCIceCandidate(msg.candidate)
return return
...@@ -893,39 +1242,7 @@ function sendSyncAction(action, value) { ...@@ -893,39 +1242,7 @@ function sendSyncAction(action, value) {
ws.send(JSON.stringify(sendMsg)) 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() { function copyCode() {
if (shareInfo.roomPwd) { if (shareInfo.roomPwd) {
navigator.clipboard?.writeText(shareInfo.roomPwd).then(() => ElMessage.success('已复制')) navigator.clipboard?.writeText(shareInfo.roomPwd).then(() => ElMessage.success('已复制'))
...@@ -941,25 +1258,197 @@ function copyCode() { ...@@ -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() { function toggleRecording() {
if (recordingStatus.value === 'recording') { if (recordingStatus.value === 'recording') {
stopRecordingAndUpload() stopRecordingAndUpload()
} else if (recordingStatus.value === 'idle') { } else if (recordingStatus.value === 'idle') {
ElMessage.info('录制将在共享屏幕时自动开始') ElMessage.info('录制将在投屏时自动开始')
} else if (recordingStatus.value === 'stopped') { } else if (recordingStatus.value === 'stopped') {
if (recordedFileUrl.value) window.open(recordedFileUrl.value, '_blank') 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() { async function applyDesensitization() {
if (!desensitizationEnabled.value) { if (!desensitizationEnabled.value) {
restoreOriginalContent() restoreOriginalContent()
...@@ -1049,6 +1538,11 @@ async function toggleDesensitization() { ...@@ -1049,6 +1538,11 @@ async function toggleDesensitization() {
} }
} }
function testRemoteClick() {
console.log('✅✅✅ 远程控制测试按钮被点击!')
ElMessage.success('远程控制点击成功!')
}
// ========== 生命周期 ========== // ========== 生命周期 ==========
onMounted(() => { onMounted(() => {
articles.forEach((article, index) => { articles.forEach((article, index) => {
...@@ -1084,11 +1578,8 @@ onBeforeUnmount(() => { ...@@ -1084,11 +1578,8 @@ onBeforeUnmount(() => {
sharedStream.getTracks().forEach(track => track.stop()) sharedStream.getTracks().forEach(track => track.stop())
sharedStream = null sharedStream = null
} }
if (recordingStream.value) {
recordingStream.value.getTracks().forEach(track => track.stop())
recordingStream.value = null
}
originalContentMap.clear() originalContentMap.clear()
uploadPromises = []
}) })
</script> </script>
...@@ -1121,6 +1612,12 @@ onBeforeUnmount(() => { ...@@ -1121,6 +1612,12 @@ onBeforeUnmount(() => {
} }
.role-badge.customer { background: #e6f7ff; color: #1890ff; } .role-badge.customer { background: #e6f7ff; color: #1890ff; }
.role-badge.consultant { background: #f6ffed; color: #52c41a; } .role-badge.consultant { background: #f6ffed; color: #52c41a; }
.presenter-badge, .remote-badge {
font-size: 12px;
background: #f0f2f5;
padding: 2px 8px;
border-radius: 12px;
}
.header-center { .header-center {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
...@@ -1196,11 +1693,10 @@ onBeforeUnmount(() => { ...@@ -1196,11 +1693,10 @@ onBeforeUnmount(() => {
} }
.zoom-level { font-size: 12px; font-weight: 500; color: #333; } .zoom-level { font-size: 12px; font-weight: 500; color: #333; }
/* 悬浮按钮组 */
.fab-group { .fab-group {
position: fixed; position: fixed;
bottom: 30px; bottom: 30px;
right: 30px; right: 70px; /* 原为30px,向左移动 */
z-index: 30; z-index: 30;
display: flex; display: flex;
flex-direction: column-reverse; flex-direction: column-reverse;
...@@ -1221,6 +1717,17 @@ onBeforeUnmount(() => { ...@@ -1221,6 +1717,17 @@ onBeforeUnmount(() => {
padding: 0; padding: 0;
} }
.fab-btn:hover { transform: scale(1.05); } .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 { .fab-btn-text {
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
...@@ -1237,12 +1744,7 @@ onBeforeUnmount(() => { ...@@ -1237,12 +1744,7 @@ onBeforeUnmount(() => {
.fab-desensitize:hover { background: #73d13d; } .fab-desensitize:hover { background: #73d13d; }
.fab-join { background: #1890ff; } .fab-join { background: #1890ff; }
.fab-join:hover { background: #40a9ff; } .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 { .video-container {
position: relative; position: relative;
width: 100%; width: 100%;
...@@ -1257,8 +1759,6 @@ onBeforeUnmount(() => { ...@@ -1257,8 +1759,6 @@ onBeforeUnmount(() => {
height: 100%; height: 100%;
object-fit: contain; object-fit: contain;
} }
/* 视频覆盖层 */
.controls-overlay { .controls-overlay {
position: absolute; position: absolute;
bottom: 30px; bottom: 30px;
...@@ -1275,6 +1775,14 @@ onBeforeUnmount(() => { ...@@ -1275,6 +1775,14 @@ onBeforeUnmount(() => {
z-index: 10; z-index: 10;
color: white; color: white;
} }
.video-actions {
position: absolute;
top: 20px;
right: 20px;
z-index: 11;
display: flex;
gap: 8px;
}
.nav-controls { .nav-controls {
display: flex; display: flex;
align-items: center; align-items: center;
...@@ -1323,7 +1831,6 @@ onBeforeUnmount(() => { ...@@ -1323,7 +1831,6 @@ onBeforeUnmount(() => {
padding-left: 16px; padding-left: 16px;
border-left: 1px solid rgba(255,255,255,0.1); border-left: 1px solid rgba(255,255,255,0.1);
} }
.customer-info { .customer-info {
background: #f6ffed; background: #f6ffed;
border: 1px solid #b7eb8f; 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