feat:修改存在的问题

This commit is contained in:
leilei
2025-11-03 09:03:32 +08:00
parent 528170fe2f
commit df359d01cc
37 changed files with 7182 additions and 913 deletions

BIN
dist.zip Normal file

Binary file not shown.

3235
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,8 @@
"vite-plugin-compression": "^0.5.1", "vite-plugin-compression": "^0.5.1",
"vite-plugin-svg-icons": "^2.0.1", "vite-plugin-svg-icons": "^2.0.1",
"vue": "^3.5.12", "vue": "^3.5.12",
"vue-pdf": "^4.3.0",
"vue-pdf-embed": "^2.1.3",
"vue-router": "^4.4.5" "vue-router": "^4.4.5"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -72,3 +72,52 @@ export function getRoomInfoApi(room_uid) {
}) })
} }
//获取文件列表
export function getFileListApi(room_uid) {
return request({
url: `/api/v1/conversion/${room_uid}/files`,
method: 'get',
// params:{
// service: room_uid
// }
})
}
//上传文件获取token
export function getUploadTokenApi(data) {
return request({
url: `/api/file/token`,
method: 'post',
data: data,
})
}
//上传文件
export function uploadFileApi(token,data) {
return request({
url: `/api/file/${token}/upload`,
method: 'post',
data: data,
headers: {
'Content-Type': 'multipart/form-data',
},
})
}
//提交一个文档转换任务,将指定的 Office 文档转换为 PDF 格式
export function convertFileApi(data,roomId) {
return request({
url: `/api/v1/conversion/${ roomId }/convert`,
method: 'post',
data: data,
})
}
//获取文档转换任务状态结构
export function getConvertStatusApi(taskId,roomId) {
return request({
url: `/api/v1/conversion/${ roomId }/status/${taskId}`,
method: 'get',
})
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 861 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 744 B

View File

@@ -194,6 +194,7 @@ onMounted(() => {
transition: background 0.3s ease, transform 0.2s ease; transition: background 0.3s ease, transform 0.2s ease;
&:hover { &:hover {
// background: linear-gradient(135deg, #000, #000);
background: linear-gradient(135deg, #00a5a1, #008f8b); background: linear-gradient(135deg, #00a5a1, #008f8b);
transform: translateY(-2px); transform: translateY(-2px);
} }

View File

@@ -2,6 +2,17 @@ import { createRouter, createWebHistory, createWebHashHistory } from 'vue-router
import Layout from "@/layout/index.vue"; import Layout from "@/layout/index.vue";
export const constantRoutes = [ export const constantRoutes = [
{
path: '/redirect',
component: Layout,
hidden: true,
children: [
{
path: '/redirect/:path(.*)',
component: () => import('@/views/redirect/index.vue')
}
]
},
{ {
path: '/', path: '/',
redirect: '/login', // 这里做重定向 redirect: '/login', // 这里做重定向

View File

@@ -21,13 +21,13 @@ export const useAppStore = defineStore(
this.sidebar.opened = !this.sidebar.opened this.sidebar.opened = !this.sidebar.opened
this.sidebar.withoutAnimation = withoutAnimation this.sidebar.withoutAnimation = withoutAnimation
if (this.sidebar.opened) { if (this.sidebar.opened) {
Cookies.set('sidebarStatus', 1) // Cookies.set('sidebarStatus', 1)
} else { } else {
Cookies.set('sidebarStatus', 0) // Cookies.set('sidebarStatus', 0)
} }
}, },
closeSideBar({ withoutAnimation }) { closeSideBar({ withoutAnimation }) {
Cookies.set('sidebarStatus', 0) // Cookies.set('sidebarStatus', 0)
this.sidebar.opened = false this.sidebar.opened = false
this.sidebar.withoutAnimation = withoutAnimation this.sidebar.withoutAnimation = withoutAnimation
}, },

2
src/utils/bus.js Normal file
View File

@@ -0,0 +1,2 @@
import mitt from 'mitt'
export const emitter = mitt()

View File

@@ -16,7 +16,7 @@ const service = axios.create({
// axios中请求配置有baseURL选项表示请求URL公共部分 // axios中请求配置有baseURL选项表示请求URL公共部分
baseURL: import.meta.env.VITE_APP_BASE_API, baseURL: import.meta.env.VITE_APP_BASE_API,
// 超时 // 超时
timeout: 10000, // timeout: 10000,
}); });
// request拦截器 // request拦截器
@@ -134,11 +134,15 @@ service.interceptors.response.use(
case 201: case 201:
return Promise.resolve(responseData); return Promise.resolve(responseData);
case 401: case 401:
// return Promise.resolve(responseData);
const currentPath = router.currentRoute.value.name;
if(currentPath == 'ConferencingRoom'){
return Promise.resolve(responseData); return Promise.resolve(responseData);
// return handleUnauthorized().then(() => { }else{
// return Promise.reject({ code: 401, message: '未授权' }); return handleUnauthorized().then(() => {
// }); return Promise.reject({ code: 401, message: '未授权' });
});
}
case 500: case 500:
const serverErrorMsg = responseData.meta?.message || '服务器内部错误'; const serverErrorMsg = responseData.meta?.message || '服务器内部错误';
ElMessage({ message: serverErrorMsg, type: 'error' }); ElMessage({ message: serverErrorMsg, type: 'error' });
@@ -173,30 +177,53 @@ service.interceptors.response.use(
); );
// 单独处理401未授权 // 单独处理401未授权
// function handleUnauthorized() {
// return ElMessageBox.confirm(
// '认证信息已失效,您可以继续留在该页面,或者重新登录',
// '系统提示',
// {
// confirmButtonText: '重新登录',
// cancelButtonText: '取消',
// type: 'warning',
// }
// )
// .then(() => {
// removeToken()
// if (router.currentRoute.path !== '/login') {
// router.push({
// path: '/login',
// query: { redirect: router.currentRoute.fullPath }
// });
// } else {
// // 如果在登录页,强制刷新以清除残留状态
// window.location.reload();
// }
// })
// .catch(() => {
// return Promise.reject('用户取消操作');
// });
// }
function handleUnauthorized() { function handleUnauthorized() {
return ElMessageBox.confirm( removeToken();
'认证信息已失效,您可以继续留在该页面,或者重新登录',
'系统提示', // 使用 nextTick 确保路由状态已更新
{ import('vue').then(({ nextTick }) => {
confirmButtonText: '重新登录', nextTick(() => {
cancelButtonText: '取消', const currentPath = router.currentRoute.value.fullPath;
type: 'warning', console.log('当前路由:', currentPath);
}
) if (router.currentRoute.value.path !== '/login') {
.then(() => {
removeToken()
if (router.currentRoute.path !== '/login') {
router.push({ router.push({
path: '/login', path: '/login',
query: { redirect: router.currentRoute.fullPath } query: {
redirect: currentPath !== '/login' ? currentPath : undefined
}
}); });
} else { } else {
// 如果在登录页,强制刷新以清除残留状态
window.location.reload(); window.location.reload();
} }
}) });
.catch(() => {
return Promise.reject('用户取消操作');
}); });
} }

View File

@@ -3,7 +3,7 @@ import { getWhiteboardShapes, getWhiteboardHistory } from "@/views/custom/api";
import { useMeterStore } from '@/stores/modules/meter'; import { useMeterStore } from '@/stores/modules/meter';
import { encode, decode } from '@msgpack/msgpack' import { encode, decode } from '@msgpack/msgpack'
import { ElMessage } from 'element-plus'; import { ElMessage } from 'element-plus';
import { emitter } from "@/utils/bus.js";
const meterStore = useMeterStore(); const meterStore = useMeterStore();
meterStore.initUdid(); meterStore.initUdid();
@@ -39,6 +39,8 @@ export const WhiteboardSync = {
const res = await getWhiteboardHistory({ after_timestamp: 0 }, roomUid); const res = await getWhiteboardHistory({ after_timestamp: 0 }, roomUid);
if (res.meta.code === 200 && res.data.shapes.length > 0) { if (res.meta.code === 200 && res.data.shapes.length > 0) {
canvasInstance.addShape(res.data.shapes); canvasInstance.addShape(res.data.shapes);
}else if(res.meta.code === 401){
emitter.emit('whiteboardFailed',true);
} }
// 订阅当前房间 // 订阅当前房间

View File

@@ -86,3 +86,17 @@ export function drawSmoothCurve(ctx, path) {
// 连接最后两个点 // 连接最后两个点
ctx.lineTo(path[path.length - 1].x, path[path.length - 1].y); ctx.lineTo(path[path.length - 1].x, path[path.length - 1].y);
} }
//计算文件的SHA1值
export async function calculateFileSHA1(file) {
const arrayBuffer = await file.arrayBuffer();
const hashBuffer = await crypto.subtle.digest('SHA-1', arrayBuffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
return hashHex;
}
// 用于比较两个对象是否相等
export function simpleDeepEqual(obj1, obj2) {
return JSON.stringify(obj1) === JSON.stringify(obj2);
}

View File

@@ -156,10 +156,7 @@ import {
try { try {
if (!id) { if (!id) {
let res = await getDirectories({ level: 1 }); let res = await getDirectories({ level: 1 });
if (res.meta.code == 401) { if (res.meta.code == 200) {
emit('showLogin', true);
return;
} else {
resolve(res.data); resolve(res.data);
} }
} else { } else {

View File

@@ -0,0 +1,846 @@
<template>
<div>
<el-dialog
v-model="dialogFormVisible"
:title="title"
width="80%"
:before-close="handleClose"
class="file-preview-dialog"
>
<div class="preview-container">
<!-- 加载状态 -->
<div v-if="loading" class="loading-container">
<el-icon class="is-loading" size="48">
<Loading />
</el-icon>
<p>{{ loadingText }}</p>
<!-- <p v-if="convertTaskId" class="task-id">任务ID: {{ convertTaskId }}</p> -->
</div>
<!-- 转换状态 -->
<div v-else-if="converting" class="loading-container">
<el-icon class="is-loading" size="48">
<Loading />
</el-icon>
<p>{{ conversionMessage }}</p>
<!-- <p class="task-id">任务ID: {{ convertTaskId }}</p> -->
</div>
<!-- 下载状态 -->
<div v-else-if="downloading" class="loading-container">
<el-icon class="is-loading" size="48">
<Loading />
</el-icon>
<p>正在下载文件请稍候...</p>
<p class="download-progress" v-if="downloadProgress > 0">
下载进度: {{ downloadProgress }}%
</p>
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="error-container">
<el-icon size="48" color="#F56C6C">
<CircleClose />
</el-icon>
<p>文件预览失败</p>
<p class="error-message">{{ errorMessage }}</p>
<el-button type="primary" @click="retryPreview">重试</el-button>
</div>
<!-- 文件预览内容 -->
<div v-else class="file-content">
<!-- 图片预览 -->
<div v-if="isImage" class="image-preview">
<img :src="previewUrl" :alt="fileName" @load="handleImageLoad" />
</div>
<!-- PDF预览 - 使用 vue-pdf-embed -->
<div v-else-if="isPdf" class="pdf-preview">
<div class="pdf-controls" v-if="pageCount > 0">
<el-button-group>
<el-button :disabled="currentPage <= 1" @click="previousPage">
<el-icon><ArrowLeft /></el-icon>
上一页
</el-button>
<el-button>
{{ currentPage }} / {{ pageCount }}
</el-button>
<el-button :disabled="currentPage >= pageCount" @click="nextPage">
下一页
<el-icon><ArrowRight /></el-icon>
</el-button>
</el-button-group>
</div>
<div class="pdf-viewer-container">
<VuePdfEmbed
:source="pdfSource"
:page="currentPage"
:scale="scale"
@loaded="handlePdfLoaded"
@rendered="handlePdfRendered"
@error="handlePdfError"
class="pdf-viewer"
/>
</div>
</div>
<!-- 视频预览 -->
<div v-else-if="isVideo" class="video-preview">
<video controls :src="previewUrl" class="video-player">
您的浏览器不支持视频播放
</video>
</div>
<!-- 音频预览 -->
<div v-else-if="isAudio" class="audio-preview">
<audio controls :src="previewUrl" class="audio-player">
您的浏览器不支持音频播放
</audio>
</div>
<!-- 文本预览 -->
<div v-else-if="isText" class="text-preview">
<pre>{{ textContent }}</pre>
</div>
<!-- Office文档预览转换后 -->
<div v-else-if="isConvertedOffice" class="office-preview">
<!-- 转换后的Office文件也是PDF使用相同的PDF预览器 -->
<div class="pdf-controls" v-if="pageCount > 0">
<el-button-group>
<el-button :disabled="currentPage <= 1" @click="previousPage">
<el-icon><ArrowLeft /></el-icon>
上一页
</el-button>
<el-button>
{{ currentPage }} / {{ pageCount }}
</el-button>
<el-button :disabled="currentPage >= pageCount" @click="nextPage">
下一页
<el-icon><ArrowRight /></el-icon>
</el-button>
</el-button-group>
</div>
<div class="pdf-viewer-container">
<VuePdfEmbed
:source="pdfSource"
:page="currentPage"
:scale="scale"
@loaded="handlePdfLoaded"
@rendered="handlePdfRendered"
@error="handlePdfError"
class="pdf-viewer"
/>
</div>
</div>
<!-- 不支持预览的文件类型 -->
<div v-else class="unsupported-preview">
<el-icon size="64" color="#909399">
<Document />
</el-icon>
<p>不支持在线预览此文件类型</p>
<p class="file-name">{{ fileName }}</p>
</div>
</div>
</div>
<!-- 底部操作栏 -->
<template #footer>
<div class="dialog-footer">
<el-button type="primary" @click="close">
关闭
</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, getCurrentInstance, onUnmounted, nextTick, onMounted } from 'vue'
import { convertFileApi, getConvertStatusApi } from '@/api/conferencingRoom'
import { ElMessage ,ElMessageBox} from 'element-plus'
import {
Loading,
CircleClose,
Document,
ArrowLeft,
ArrowRight,
} from '@element-plus/icons-vue'
import VuePdfEmbed from 'vue-pdf-embed'
import { mqttClient } from "@/utils/mqtt.js";
import { emitter } from "@/utils/bus.js";
// 定义props
const props = defineProps({
fileType: {
type: Array,
default: () => ["pdf", "png", "jpg", "jpeg", "gif", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "mp4", "mp3"],
},
roomId: {
type: String,
default: '',
},
})
// 定义emits
const emit = defineEmits(['fetch-data'])
const { proxy } = getCurrentInstance()
const enumType = ["doc", "docx", "xls", "xlsx", "ppt", "pptx"]
// 响应式数据
const dialogFormVisible = ref(false)
const title = ref('')
const loading = ref(false)
const converting = ref(false)
const downloading = ref(false)
const downloadProgress = ref(0)
const loadingText = ref('正在加载...')
const conversionMessage = ref('正在转换文件...')
const error = ref(false)
const errorMessage = ref('')
const previewUrl = ref('')
const fileName = ref('')
const textContent = ref('')
const currentFileData = ref(null)
const convertTaskId = ref('')
// PDF相关状态
const pdfSource = ref('')
const currentPage = ref(1)
const pageCount = ref(0)
const scale = ref(1.0)
const pdfDocument = ref(null)
// MQTT相关
const isMqttConnected = ref(false)
// 计算属性
const isImage = computed(() => {
const ext = fileName.value.split('.').pop().toLowerCase()
return ['png', 'jpg', 'jpeg', 'gif'].includes(ext)
})
const isPdf = computed(() => {
const ext = fileName.value.split('.').pop().toLowerCase()
return ext === 'pdf'
})
const isVideo = computed(() => {
const ext = fileName.value.split('.').pop().toLowerCase()
return ['mp4'].includes(ext)
})
const isAudio = computed(() => {
const ext = fileName.value.split('.').pop().toLowerCase()
return ['mp3'].includes(ext)
})
const isText = computed(() => {
const ext = fileName.value.split('.').pop().toLowerCase()
return ext === 'txt'
})
const isOffice = computed(() => {
const ext = fileName.value.split('.').pop().toLowerCase()
return enumType.includes(ext)
})
const isConvertedOffice = computed(() => {
return previewUrl.value && previewUrl.value.endsWith('.pdf') && isOffice.value
})
// 组件挂载时初始化MQTT连接
onMounted(async () => {
})
emitter.on('subscribeToFileConversionStatusTopic',subscribeToFileConversionStatusTopic)
emitter.on('fileUploadStatus',fileUploadStatus)
emitter.on('subscribeToFilePreviewTopic',subscribeToFilePreviewTopic)
function subscribeToFileConversionStatusTopic(data){
try {
const userId = JSON.parse(sessionStorage.getItem('userData'))?.uid
if (!userId) {
console.error('用户ID不存在')
return
}
const topic = `xsynergy/room/${data.roomId}/file/${userId}/conversion_status`
mqttClient.subscribe(topic, handlePdfMessage)
} catch (error) {
console.error('订阅pdf转换事件失败:', error)
}
}
function subscribeToFilePreviewTopic(data){
try {
const topic = `xsynergy/room/${data.roomId}/file/preview`
mqttClient.subscribe(topic, handleFileUploadMessage)
} catch (error) {
console.error('订阅文件上传事件失败:', error)
}
}
function fileUploadStatus(data){
if(!dialogFormVisible.value){
}
}
// 初始化MQTT连接 pdf转换成功
async function initMqttConnection() {
try {
if (isMqttConnected.value) return
const clientId = `PdfConversion_${Date.now()}`
await mqttClient.connect(clientId)
isMqttConnected.value = true
// 订阅主题
subscribeToPdfConversionTopic()
} catch (error) {
console.error('MQTT连接失败:', error)
ElMessage.error('文件转换服务连接失败')
}
}
function subscribeToPdfConversionTopic() {
try {
const userId = JSON.parse(sessionStorage.getItem('userData'))?.uid
if (!userId) {
console.error('用户ID不存在')
return
}
const topic = `xsynergy/room/${props.roomId}/file/${userId}/conversion_status`
mqttClient.subscribe(topic, handlePdfMessage)
} catch (error) {
console.error('订阅pdf转换事件失败:', error)
}
}
function handleFileUploadMessage(payload, topic){
try {
const messageStr = payload.toString()
const data = JSON.parse(messageStr)
emitter.emit('fileUploadStatus')
if(dialogFormVisible.value){
// 显示确认对话框
ElMessageBox.confirm(
`用户${data.user_uid}上传了${data.file_name}文件,是否立即预览?`,
'文件更新提示',
{
confirmButtonText: '预览',
cancelButtonText: '取消',
type: 'warning',
closeOnClickModal: false,
closeOnPressEscape: false,
showClose: false
}
).then(() => {
// 用户点击"预览"
resetPreviewState()
getPreviewFileUrl(data)
ElMessage({
message: '已切换到新文件预览',
type: 'success'
})
}).catch(() => {
// 用户点击"取消"
ElMessage({
message: '已取消预览新文件,继续查看当前文件',
type: 'info'
})
})
// resetPreviewState()
// getPreviewFileUrl(data)
} else {
ElMessage({
message: `用户${data.user_uid}上传了${data.file_name}文件`,
type: 'info',
})
showEdit(data)
}
} catch (error) {
console.error('处理转换状态消息失败:', error)
handleError('处理转换状态失败')
}
}
// 新增重置预览状态的方法
function resetPreviewState() {
loading.value = true
converting.value = false
downloading.value = false
downloadProgress.value = 0
error.value = false
previewUrl.value = ''
textContent.value = ''
resetPdfState()
// 强制重新渲染
nextTick(() => {
// 确保DOM更新
})
}
function handlePdfMessage(payload, topic){
try {
const messageStr = payload.toString()
const data = JSON.parse(messageStr)
switch (data.status) {
case 'converting':
break
case 'completed':
getConvertedFile(data.task_id)
break
case 'failed':
break
default:
console.warn('未知的转换状态:', data.status)
}
} catch (error) {
console.error('处理转换状态消息失败:', error)
handleError('处理转换状态失败')
}
}
// 获取转换后的文件
const getConvertedFile = async (taskId) => {
try {
if (!taskId) {
throw new Error('任务ID不存在')
}
const fileRes = await getConvertStatusApi(taskId,props.roomId)
} catch (err) {
console.error('获取转换文件失败:', err)
handleError('获取转换文件失败', err)
}
}
// 修改 getPreviewFileUrl 方法,确保状态正确更新
async function getPreviewFileUrl(file){
fileName.value = file.file_name || file.source_url.split('/').pop()
const fileExt = fileName.value.split('.').pop().toLowerCase()
try {
// 根据文件类型处理转换后的文件
if (fileExt === 'pdf' || enumType.includes(fileExt)) {
// PDF和Office文档使用PDF预览器
await loadPdfFile(file.preview_url || file.file_url)
} else if (fileExt === 'txt') {
// 文本文件
await loadTextFile(file.preview_url || file.file_url)
} else if (['png', 'jpg', 'jpeg', 'gif'].includes(fileExt)) {
// 图片文件直接预览
previewUrl.value = file.preview_url || file.file_url
// 确保图片重新加载
nextTick(() => {
loading.value = false
})
} else if (fileExt === 'mp4') {
// 视频文件
previewUrl.value = file.preview_url || file.file_url
nextTick(() => {
loading.value = false
})
} else if (fileExt === 'mp3') {
// 音频文件
previewUrl.value = file.preview_url || file.file_url
nextTick(() => {
loading.value = false
})
} else {
// 其他文件类型
previewUrl.value = file.preview_url || file.file_url
nextTick(() => {
loading.value = false
})
}
} catch (error) {
console.error('加载预览文件失败:', error)
handleError('加载预览文件失败', error)
}
}
// 显示弹框
const showEdit = async (data) => {
// 重置状态
resetPreviewState()
title.value = '文件预览'
dialogFormVisible.value = true
currentFileData.value = data
// fileName.value = data.file_name || data.source_url.split('/').pop()
await getPreviewFileUrl(data)
}
// 加载PDF文件
const loadPdfFile = async (fileUrl) => {
try {
loading.value = false
downloading.value = true
downloadProgress.value = 0
const response = await fetch(fileUrl)
if (!response.ok) {
throw new Error('文件下载失败')
}
const contentLength = response.headers.get('content-length')
const total = parseInt(contentLength, 10)
let loaded = 0
const reader = response.body.getReader()
const chunks = []
while (true) {
const { done, value } = await reader.read()
if (done) break
chunks.push(value)
loaded += value.length
if (total) {
downloadProgress.value = Math.round((loaded / total) * 100)
}
}
const blob = new Blob(chunks)
const blobUrl = URL.createObjectURL(blob)
// 先清理之前的URL
if (pdfSource.value) {
URL.revokeObjectURL(pdfSource.value)
}
previewUrl.value = fileUrl
pdfSource.value = blobUrl
// 重置PDF状态
currentPage.value = 1
pageCount.value = 0
downloading.value = false
downloadProgress.value = 0
} catch (err) {
downloading.value = false
downloadProgress.value = 0
handleError('PDF文件下载失败', err)
}
}
// 加载文本文件
const loadTextFile = async (fileUrl) => {
try {
const response = await fetch(fileUrl)
if (!response.ok) throw new Error('无法加载文本文件')
const text = await response.text()
textContent.value = text
loading.value = false
} catch (err) {
handleError('文本文件加载失败', err)
}
}
// 上传文件获取office获取pdf文件
async function getFilePdf(fileUrl) {
try {
loadingText.value = '正在转换文件...'
const res = await convertFileApi({ file_url: fileUrl },props.roomId)
if (res.meta.code !== 200) {
throw new Error(res.meta.msg || '文件转换失败')
}
convertTaskId.value = res.data.task_id
// 等待MQTT消息不进行轮询
loading.value = false
converting.value = true
conversionMessage.value = '已提交转换任务,等待处理...'
} catch (err) {
handleError('文件转换失败', err)
}
}
// PDF相关方法
const handlePdfLoaded = (data) => {
pageCount.value = data.numPages
pdfDocument.value = data
loading.value = false
converting.value = false
}
const handlePdfRendered = () => {
// console.log('PDF页面渲染完成')
}
const handlePdfError = (error) => {
console.error('PDF加载错误:', error)
handleError('PDF文件加载失败', error)
}
const previousPage = () => {
if (currentPage.value > 1) {
currentPage.value--
}
}
const nextPage = () => {
if (currentPage.value < pageCount.value) {
currentPage.value++
}
}
const resetPdfState = () => {
pdfSource.value = ''
currentPage.value = 1
pageCount.value = 0
scale.value = 1.0
pdfDocument.value = null
}
// 处理图片加载完成
const handleImageLoad = () => {
loading.value = false
}
// 处理错误
const handleError = (message, error) => {
error.value = true
errorMessage.value = message
loading.value = false
converting.value = false
downloading.value = false
downloadProgress.value = 0
ElMessage.error(message)
}
// 重试预览
const retryPreview = () => {
if (currentFileData.value) {
showEdit(currentFileData.value)
}
}
// 关闭按钮点击事件
const close = () => {
dialogFormVisible.value = false
resetState()
}
// 处理对话框关闭
const handleClose = (done) => {
close()
}
// 重置状态
const resetState = () => {
loading.value = false
converting.value = false
downloading.value = false
downloadProgress.value = 0
error.value = false
previewUrl.value = ''
textContent.value = ''
currentFileData.value = null
convertTaskId.value = ''
resetPdfState()
}
// 组件卸载时清理
onUnmounted(() => {
// 可以在这里取消特定主题的订阅,但不断开连接
// const userId = JSON.parse(sessionStorage.getItem('userData'))?.uid
// if (userId) {
// const topic = `xsynergy/room/${props.roomId}/file/${userId}/conversion_status`
// mqttClient.unsubscribe(topic, handlePdfMessage)
// }
})
// 暴露方法给父组件
defineExpose({
showEdit,
close,
})
</script>
<style lang="scss" scoped>
.file-preview-dialog {
.preview-container {
min-height: 400px;
display: flex;
align-items: center;
justify-content: center;
.loading-container, .error-container {
text-align: center;
padding: 40px 0;
p {
margin-top: 16px;
color: #606266;
}
.error-message {
font-size: 14px;
color: #F56C6C;
margin-top: 8px;
}
.download-progress {
font-size: 14px;
color: #409EFF;
margin-top: 8px;
}
}
.file-content {
width: 100%;
height: 100%;
.image-preview {
text-align: center;
img {
max-width: 100%;
max-height: 70vh;
object-fit: contain;
}
}
.pdf-preview, .office-preview {
.pdf-controls {
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 16px;
padding: 8px;
background: #f5f5f5;
border-radius: 4px;
flex-wrap: wrap;
gap: 8px;
}
.pdf-viewer-container {
height: 70vh;
overflow: auto;
border: 1px solid #e0e0e0;
border-radius: 4px;
background: #f9f9f9;
.pdf-viewer {
width: 100%;
min-height: 100%;
display: flex;
justify-content: center;
:deep(.vue-pdf-embed) {
text-align: center;
}
:deep(canvas) {
max-width: 100%;
height: auto;
}
}
}
}
.video-preview {
text-align: center;
.video-player {
max-width: 100%;
max-height: 70vh;
}
}
.audio-preview {
text-align: center;
padding: 40px 0;
.audio-player {
width: 80%;
}
}
.text-preview {
height: 70vh;
overflow: auto;
background: #f5f5f5;
padding: 16px;
border-radius: 4px;
pre {
white-space: pre-wrap;
word-wrap: break-word;
margin: 0;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 14px;
line-height: 1.5;
}
}
.unsupported-preview {
text-align: center;
padding: 40px 0;
p {
margin-top: 16px;
color: #606266;
}
.file-name {
font-size: 14px;
color: #909399;
margin-top: 8px;
}
}
}
}
.dialog-footer {
display: flex;
justify-content: space-between;
}
}
@media (max-width: 768px) {
.file-preview-dialog {
width: 95% !important;
.preview-container {
min-height: 300px;
.file-content {
.pdf-preview, .office-preview {
.pdf-viewer-container {
height: 50vh;
}
}
.text-preview {
height: 50vh;
}
}
}
.pdf-controls {
flex-direction: column;
gap: 8px;
.el-button-group {
width: 100%;
display: flex;
.el-button {
flex: 1;
font-size: 12px;
padding: 8px 4px;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,269 @@
<template>
<div>
<el-drawer v-model="drawerVisible" direction="rtl" title="文件列表" size="40%">
<template #header>
<h4>文件列表</h4>
</template>
<div class="drawer-content">
<!-- 上传按钮 -->
<div class="upload-section">
<el-button type="primary" size="small" @click="handleUpload">
<el-icon><Upload /></el-icon>
上传文件
</el-button>
</div>
<!-- 文件列表 -->
<div class="file-list" v-loading="loading">
<!-- 文件列表内容 -->
<div v-for="item in fileList" :key="item.id || item.fileKey" class="file-item">
<div class="file-info">
<div class="file-icon">
<img :src="getFileIcon(item.file_name)" alt="文件图标" class="file-icon-img">
</div>
<div class="file-details">
<div class="file-name" :title="item.file_name">{{ item.file_name }}</div>
<!-- <div class="file-meta">
<span class="file-size">{{ formatFileSize(item.fileSize) }}</span>
</div> -->
</div>
</div>
<div class="file-actions">
<el-button type="primary" size="small" :disabled="!item.preview_url" @click="handlePreview(item)">预览</el-button>
<el-button type="success" size="small" @click="handleDownload(item)">下载</el-button>
</div>
</div>
<!-- 空状态 -->
<div v-if="fileList.length === 0" class="empty-state">
<el-empty description="暂无文件" />
</div>
</div>
</div>
</el-drawer>
<!-- 文件上传 -->
<UpLoadFile
ref="uploadRef"
:fileType='["pdf", "png", "jpg", "jpeg","gif","doc","docx","xls","xlsx","ppt","pptx","txt","mp4","mp3"]'
:roomId="roomId"
@upload-success="handleUploadSuccess"
/>
<!-- 文件预览 -->
<BrowseFile ref="browseFileRef" :roomId="roomId"/>
</div>
</template>
<script setup>
import {
getFileListApi,
} from '@/api/conferencingRoom.js'
import { ElMessage } from 'element-plus';
import { ref } from "vue";
import { Upload } from '@element-plus/icons-vue'
import UpLoadFile from './upLoadFile.vue'
import BrowseFile from './browseFile.vue'
import fileLogo from '@/assets/images/file-logo.png';
import { emitter } from "@/utils/bus.js";
// 定义 emit
const emit = defineEmits([""]);
// 接收 props
const props = defineProps({
roomId: {
type: String,
default: '',
},
});
emitter.on('fileUploadStatus',async ()=>{
if(drawerVisible.value){
console.log('更新啦')
await getFileList()
}
})
const drawerVisible = ref(false);
const fileList = ref([]);
const loading = ref(false);
const uploadRef = ref(null);
//文件预览
const browseFileRef = ref(null);
// 根据文件扩展名获取文件图标
function getFileIcon(fileName) {
return fileLogo;
}
// 格式化文件大小
function formatFileSize(size) {
if (!size) return '未知大小';
if (size < 1024) {
return size + ' B';
} else if (size < 1024 * 1024) {
return (size / 1024).toFixed(2) + ' KB';
} else {
return (size / (1024 * 1024)).toFixed(2) + ' MB';
}
}
// 文件下载功能
function handleDownload(file) {
try {
const iframe = document.createElement('iframe');
iframe.style.display = 'none';
iframe.src = file.source_url;
document.body.appendChild(iframe);
setTimeout(() => {
document.body.removeChild(iframe);
}, 5000); // 5秒后清理iframe
ElMessage.success('开始下载文件');
} catch (error) {
console.error('iframe下载失败:', error);
ElMessage.error('下载失败,请检查浏览器设置');
}
}
// 文件预览功能
function handlePreview(file) {
if (!file.preview_url) {
ElMessage.error('文件链接无效');
return;
}
browseFileRef.value.showEdit(file)
}
// 上传文件
function handleUpload() {
uploadRef.value.showEdit()
// 这里可以添加文件上传逻辑
}
// 显示抽屉
async function show() {
drawerVisible.value = true;
await getFileList()
}
//获取文件列表
async function getFileList(){
loading.value = true;
try {
// xsy
const res = await getFileListApi(props.roomId);
if (res.meta.code !== 200) {
ElMessage.error(res.meta.msg);
return;
}
fileList.value = res.data.files || [];
} catch (error) {
console.error('获取文件列表失败:', error);
ElMessage.error('获取文件列表失败');
} finally {
loading.value = false;
}
}
//文件上传成功
async function handleUploadSuccess(){
if(drawerVisible.value){
await getFileList()
}
}
// 暴露方法给父组件
defineExpose({
show,
});
</script>
<style lang="scss" scoped>
.drawer-content {
padding: 0 10px;
height: 100%;
display: flex;
flex-direction: column;
}
.upload-section {
padding: 15px 0;
border-bottom: 1px solid #e8e8e8;
margin-bottom: 15px;
}
.file-list {
flex: 1;
overflow-y: auto;
}
.file-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
&:last-child {
border-bottom: none;
}
}
.file-info {
display: flex;
align-items: center;
flex: 1;
min-width: 0; /* 防止内容溢出 */
}
.file-icon {
margin-right: 12px;
flex-shrink: 0; /* 防止图标被压缩 */
}
.file-icon-img {
width: 40px;
height: 40px;
object-fit: contain;
}
.file-details {
flex: 1;
min-width: 0; /* 防止文本溢出 */
overflow: hidden; /* 隐藏溢出内容 */
}
.file-name {
font-weight: 500;
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-meta {
font-size: 12px;
color: #909399;
}
.file-actions {
display: flex;
gap: 8px;
flex-shrink: 0; /* 防止按钮被压缩 */
margin-left: 10px; /* 添加左边距,与文件信息保持距离 */
}
.empty-state {
display: flex;
justify-content: center;
align-items: center;
height: 200px;
}
.loading-state {
padding: 10px 0;
}
</style>

View File

@@ -1,6 +0,0 @@
<template>
<div class="file-upload-container">
<input type="file" ref="fileInput" @change="handleFileChange" />
<button @click="uploadFile">上传文件</button>
</div>
</template>

View File

@@ -0,0 +1,726 @@
<template>
<div>
<el-dialog
v-model="dialogFormVisible"
:title="title"
width="80%"
:before-close="handleClose"
class="file-preview-dialog"
>
<div class="preview-container">
<!-- 加载状态 -->
<div v-if="loading" class="loading-container">
<el-icon class="is-loading" size="48">
<Loading />
</el-icon>
<p>{{ loadingText }}</p>
</div>
<!-- 下载状态 -->
<div v-else-if="downloading" class="loading-container">
<el-icon class="is-loading" size="48">
<Loading />
</el-icon>
<p>正在下载文件请稍候...</p>
<p class="download-progress" v-if="downloadProgress > 0">
下载进度: {{ downloadProgress }}%
</p>
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="error-container">
<el-icon size="48" color="#F56C6C">
<CircleClose />
</el-icon>
<p>文件预览失败</p>
<p class="error-message">{{ errorMessage }}</p>
<el-button type="primary" @click="retryPreview">重试</el-button>
</div>
<!-- 文件预览内容 -->
<div v-else class="file-content">
<!-- 图片预览 -->
<div v-if="isImage" class="image-preview">
<img :src="previewUrl" :alt="fileName" @load="handleImageLoad" />
</div>
<!-- PDF预览 - 使用 vue-pdf-embed -->
<div v-else-if="isPdf" class="pdf-preview">
<div class="pdf-controls" v-if="pageCount > 0">
<el-button-group>
<el-button :disabled="currentPage <= 1" @click="previousPage">
<el-icon><ArrowLeft /></el-icon>
上一页
</el-button>
<el-button>
{{ currentPage }} / {{ pageCount }}
</el-button>
<el-button :disabled="currentPage >= pageCount" @click="nextPage">
下一页
<el-icon><ArrowRight /></el-icon>
</el-button>
</el-button-group>
</div>
<div class="pdf-viewer-container">
<VuePdfEmbed
:source="pdfSource"
:page="currentPage"
:scale="scale"
@loaded="handlePdfLoaded"
@rendered="handlePdfRendered"
@error="handlePdfError"
class="pdf-viewer"
/>
</div>
</div>
<!-- 视频预览 -->
<div v-else-if="isVideo" class="video-preview">
<video controls :src="previewUrl" class="video-player">
您的浏览器不支持视频播放
</video>
</div>
<!-- 音频预览 -->
<div v-else-if="isAudio" class="audio-preview">
<audio controls :src="previewUrl" class="audio-player">
您的浏览器不支持音频播放
</audio>
</div>
<!-- 文本预览 -->
<div v-else-if="isText" class="text-preview">
<pre>{{ textContent }}</pre>
</div>
<!-- Office文档预览转换后 -->
<div v-else-if="isConvertedOffice" class="office-preview">
<!-- 转换后的Office文件也是PDF使用相同的PDF预览器 -->
<div class="pdf-controls" v-if="pageCount > 0">
<el-button-group>
<el-button :disabled="currentPage <= 1" @click="previousPage">
<el-icon><ArrowLeft /></el-icon>
上一页
</el-button>
<el-button>
{{ currentPage }} / {{ pageCount }}
</el-button>
<el-button :disabled="currentPage >= pageCount" @click="nextPage">
下一页
<el-icon><ArrowRight /></el-icon>
</el-button>
</el-button-group>
</div>
<div class="pdf-viewer-container">
<VuePdfEmbed
:source="pdfSource"
:page="currentPage"
:scale="scale"
@loaded="handlePdfLoaded"
@rendered="handlePdfRendered"
@error="handlePdfError"
class="pdf-viewer"
/>
</div>
</div>
<!-- 不支持预览的文件类型 -->
<div v-else class="unsupported-preview">
<el-icon size="64" color="#909399">
<Document />
</el-icon>
<p>不支持在线预览此文件类型</p>
<p class="file-name">{{ fileName }}</p>
<!-- <el-button type="primary" @click="downloadFile">下载文件</el-button> -->
</div>
</div>
</div>
<!-- 底部操作栏 -->
<template #footer>
<div class="dialog-footer">
<!-- <el-button @click="downloadFile" :disabled="loading">
<el-icon><Download /></el-icon>
下载
</el-button> -->
<el-button type="primary" @click="close">
关闭
</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, getCurrentInstance, onUnmounted, nextTick } from 'vue'
import { convertFileApi, getConvertStatusApi } from '@/api/conferencingRoom'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Loading,
CircleClose,
Document,
Download,
ArrowLeft,
ArrowRight,
Plus,
Minus,
Refresh
} from '@element-plus/icons-vue'
import VuePdfEmbed from 'vue-pdf-embed'
// 定义props
const props = defineProps({
fileType: {
type: Array,
default: () => ["pdf", "png", "jpg", "jpeg", "gif", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "mp4", "mp3"],
},
roomId: {
type: String,
default: '',
},
})
// 定义emits
const emit = defineEmits(['fetch-data'])
const { proxy } = getCurrentInstance()
const enumType = ["doc", "docx", "xls", "xlsx", "ppt", "pptx"]
// 响应式数据
const dialogFormVisible = ref(false)
const title = ref('')
const loading = ref(false)
const downloading = ref(false) // 新增:下载状态
const downloadProgress = ref(0) // 新增:下载进度
const loadingText = ref('正在加载...')
const error = ref(false)
const errorMessage = ref('')
const previewUrl = ref('')
const fileName = ref('')
const textContent = ref('')
const currentFileData = ref(null)
const convertTaskId = ref('')
const statusCheckInterval = ref(null)
// PDF相关状态
const pdfSource = ref('')
const currentPage = ref(1)
const pageCount = ref(0)
const scale = ref(1.0)
const pdfDocument = ref(null)
// 计算属性
const isImage = computed(() => {
const ext = fileName.value.split('.').pop().toLowerCase()
return ['png', 'jpg', 'jpeg', 'gif'].includes(ext)
})
const isPdf = computed(() => {
const ext = fileName.value.split('.').pop().toLowerCase()
return ext === 'pdf'
})
const isVideo = computed(() => {
const ext = fileName.value.split('.').pop().toLowerCase()
return ['mp4'].includes(ext)
})
const isAudio = computed(() => {
const ext = fileName.value.split('.').pop().toLowerCase()
return ['mp3'].includes(ext)
})
const isText = computed(() => {
const ext = fileName.value.split('.').pop().toLowerCase()
return ext === 'txt'
})
const isOffice = computed(() => {
const ext = fileName.value.split('.').pop().toLowerCase()
return enumType.includes(ext)
})
const isConvertedOffice = computed(() => {
return previewUrl.value && previewUrl.value.endsWith('.pdf') && isOffice.value
})
// 显示弹框
const showEdit = async (data) => {
// 重置状态
loading.value = true
downloading.value = false
downloadProgress.value = 0
title.value = '文件预览'
dialogFormVisible.value = true
currentFileData.value = data
fileName.value = data.fileName || data.fileUrl.split('/').pop()
error.value = false
previewUrl.value = ''
textContent.value = ''
resetPdfState()
const fileExt = fileName.value.split('.').pop().toLowerCase()
try {
if (enumType.includes(fileExt)) {
// Office文档需要转换
await getFilePdf(data.fileUrl)
} else if (fileExt === 'txt') {
// 文本文件需要特殊处理
await loadTextFile(data.fileUrl)
} else if (fileExt === 'pdf') {
// PDF文件使用vue-pdf-embed预览
await loadPdfFile(data.fileUrl)
} else {
// 其他文件直接预览
previewUrl.value = data.fileUrl
loading.value = false
}
} catch (err) {
handleError('文件加载失败', err)
}
}
// 加载PDF文件
const loadPdfFile = async (fileUrl) => {
try {
// 先显示下载状态
loading.value = false
downloading.value = true
downloadProgress.value = 0
// 使用fetch下载文件并跟踪进度
const response = await fetch(fileUrl)
if (!response.ok) {
throw new Error('文件下载失败')
}
const contentLength = response.headers.get('content-length')
const total = parseInt(contentLength, 10)
let loaded = 0
// 创建读取器
const reader = response.body.getReader()
const chunks = []
while (true) {
const { done, value } = await reader.read()
if (done) break
chunks.push(value)
loaded += value.length
// 更新下载进度
if (total) {
downloadProgress.value = Math.round((loaded / total) * 100)
}
}
// 创建Blob URL
const blob = new Blob(chunks)
const blobUrl = URL.createObjectURL(blob)
// 设置PDF源
previewUrl.value = fileUrl
pdfSource.value = blobUrl
// 重置状态
downloading.value = false
downloadProgress.value = 0
// 使用nextTick确保DOM更新
nextTick(() => {
// PDF组件会自动开始加载loading状态会在handlePdfLoaded中设置为false
})
} catch (err) {
downloading.value = false
downloadProgress.value = 0
handleError('PDF文件下载失败', err)
}
}
// 加载文本文件
const loadTextFile = async (fileUrl) => {
try {
const response = await fetch(fileUrl)
if (!response.ok) throw new Error('无法加载文本文件')
const text = await response.text()
textContent.value = text
loading.value = false
} catch (err) {
handleError('文本文件加载失败', err)
}
}
// 上传文件获取office获取pdf文件
async function getFilePdf(fileUrl) {
try {
loadingText.value = '正在转换文件...'
const res = await convertFileApi({ file_url: fileUrl })
if (res.meta.code !== 200) {
throw new Error(res.meta.msg || '文件转换失败')
}
convertTaskId.value = res.data.task_id
loadingText.value = '正在等待转换完成...'
// 开始轮询转换状态
startStatusPolling()
} catch (err) {
handleError('文件转换失败', err)
}
}
// 开始轮询转换状态
const startStatusPolling = () => {
// 清除之前的轮询
if (statusCheckInterval.value) {
clearInterval(statusCheckInterval.value)
}
// 设置新的轮询
statusCheckInterval.value = setInterval(async () => {
try {
const fileRes = await getConvertStatusApi(convertTaskId.value)
if (fileRes.meta.code === 200) {
if (fileRes.data.status === 'completed') {
// 转换完成
clearInterval(statusCheckInterval.value)
previewUrl.value = fileRes.data.output_file
pdfSource.value = fileRes.data.output_file
loading.value = false
} else if (fileRes.data.status === 'failed') {
// 转换失败
clearInterval(statusCheckInterval.value)
throw new Error('文件转换失败')
}
// 其他状态继续等待
} else {
throw new Error(fileRes.meta.msg || '获取转换状态失败')
}
} catch (err) {
clearInterval(statusCheckInterval.value)
handleError('获取转换状态失败', err)
}
}, 2000) // 每2秒检查一次
}
// PDF相关方法
const handlePdfLoaded = (data) => {
pageCount.value = data.numPages
pdfDocument.value = data
loading.value = false
}
const handlePdfRendered = () => {
console.log('PDF页面渲染完成')
}
const handlePdfError = (error) => {
console.error('PDF加载错误:', error)
handleError('PDF文件加载失败', error)
}
const previousPage = () => {
if (currentPage.value > 1) {
currentPage.value--
}
}
const nextPage = () => {
if (currentPage.value < pageCount.value) {
currentPage.value++
}
}
const zoomIn = () => {
if (scale.value < 2) {
scale.value = Math.round((scale.value + 0.1) * 10) / 10
}
}
const zoomOut = () => {
if (scale.value > 0.5) {
scale.value = Math.round((scale.value - 0.1) * 10) / 10
}
}
const resetZoom = () => {
scale.value = 1.0
}
const resetPdfState = () => {
pdfSource.value = ''
currentPage.value = 1
pageCount.value = 0
scale.value = 1.0
pdfDocument.value = null
}
// 处理图片加载完成
const handleImageLoad = () => {
loading.value = false
}
// 处理错误
const handleError = (message, error) => {
console.error('File preview error:', error)
error.value = true
errorMessage.value = message
loading.value = false
downloading.value = false
downloadProgress.value = 0
ElMessage.error(message)
}
// 重试预览
const retryPreview = () => {
if (currentFileData.value) {
showEdit(currentFileData.value)
}
}
// 下载文件
const downloadFile = () => {
if (currentFileData.value && currentFileData.value.fileUrl) {
const link = document.createElement('a')
link.href = currentFileData.value.fileUrl
link.download = fileName.value
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
} else {
ElMessage.warning('无法下载文件')
}
}
// 关闭按钮点击事件
const close = () => {
dialogFormVisible.value = false
resetState()
}
// 处理对话框关闭
const handleClose = (done) => {
close()
}
// 重置状态
const resetState = () => {
// 清除轮询
if (statusCheckInterval.value) {
clearInterval(statusCheckInterval.value)
statusCheckInterval.value = null
}
// 重置其他状态
loading.value = false
downloading.value = false
downloadProgress.value = 0
error.value = false
previewUrl.value = ''
textContent.value = ''
currentFileData.value = null
convertTaskId.value = ''
resetPdfState()
}
// 组件卸载时清理
onUnmounted(() => {
if (statusCheckInterval.value) {
clearInterval(statusCheckInterval.value)
}
})
// 暴露方法给父组件
defineExpose({
showEdit,
close,
})
</script>
<style lang="scss" scoped>
.file-preview-dialog {
.preview-container {
min-height: 400px;
display: flex;
align-items: center;
justify-content: center;
.loading-container, .error-container {
text-align: center;
padding: 40px 0;
p {
margin-top: 16px;
color: #606266;
}
.error-message {
font-size: 14px;
color: #F56C6C;
margin-top: 8px;
}
.download-progress {
font-size: 14px;
color: #409EFF;
margin-top: 8px;
}
}
.file-content {
width: 100%;
height: 100%;
.image-preview {
text-align: center;
img {
max-width: 100%;
max-height: 70vh;
object-fit: contain;
}
}
.pdf-preview, .office-preview {
.pdf-controls {
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 16px;
padding: 8px;
background: #f5f5f5;
border-radius: 4px;
flex-wrap: wrap;
gap: 8px;
}
.pdf-viewer-container {
height: 70vh;
overflow: auto;
border: 1px solid #e0e0e0;
border-radius: 4px;
background: #f9f9f9;
.pdf-viewer {
width: 100%;
min-height: 100%;
display: flex;
justify-content: center;
:deep(.vue-pdf-embed) {
text-align: center;
}
:deep(canvas) {
max-width: 100%;
height: auto;
}
}
}
}
.video-preview {
text-align: center;
.video-player {
max-width: 100%;
max-height: 70vh;
}
}
.audio-preview {
text-align: center;
padding: 40px 0;
.audio-player {
width: 80%;
}
}
.text-preview {
height: 70vh;
overflow: auto;
background: #f5f5f5;
padding: 16px;
border-radius: 4px;
pre {
white-space: pre-wrap;
word-wrap: break-word;
margin: 0;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 14px;
line-height: 1.5;
}
}
.unsupported-preview {
text-align: center;
padding: 40px 0;
p {
margin-top: 16px;
color: #606266;
}
.file-name {
font-size: 14px;
color: #909399;
margin-top: 8px;
}
}
}
}
.dialog-footer {
display: flex;
justify-content: space-between;
}
}
@media (max-width: 768px) {
.file-preview-dialog {
width: 95% !important;
.preview-container {
min-height: 300px;
.file-content {
.pdf-preview, .office-preview {
.pdf-viewer-container {
height: 50vh;
}
}
.text-preview {
height: 50vh;
}
}
}
.pdf-controls {
flex-direction: column;
gap: 8px;
.el-button-group {
width: 100%;
display: flex;
.el-button {
flex: 1;
font-size: 12px;
padding: 8px 4px;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,291 @@
<template>
<div>
<el-dialog
v-model="dialogFormVisible"
:title="title"
width="403px"
@close="close"
>
<el-upload
ref="uploadRef"
:accept="acceptString"
:show-file-list="false"
:limit="999"
style="width: 100%; text-align: center"
:before-upload="handleBeforeUpload"
>
<template #trigger>
<el-button
type="primary"
class="el-button-custom-css blue-css"
:loading="uploadLoading"
:disabled="uploadLoading"
>
{{ uploadLoading ? '上传中...' : '上传文件' }}
</el-button>
</template>
</el-upload>
<div style="margin-top: 20px">
请上传格式为:
<b style="color: #f56c6c">
{{ acceptString }}
</b>
的文件
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, getCurrentInstance } from 'vue'
import axios from 'axios'
import { getUploadTokenApi, uploadFileApi,convertFileApi } from '@/api/conferencingRoom'
import { calculateFileSHA1 } from '@/views/conferencingRoom/business/index.js'
import { ElMessage, ElMessageBox } from 'element-plus'
import { mqttClient } from "@/utils/mqtt.js";
import { emitter } from "@/utils/bus.js";
// 定义props
const props = defineProps({
fileType: {
type: Array,
default: () => ["pdf", "png", "jpg", "jpeg", "gif", "doc", "docx", "xls", "xlsx", "ppt", "pptx"],
},
roomId: {
type: String,
default: '',
},
})
// 定义emits
const emit = defineEmits(['upload-success'])
const { proxy } = getCurrentInstance()
// 响应式数据
const dialogFormVisible = ref(false)
const title = ref('')
const fileList = ref([])
const fileIds = ref([])
const uploadRef = ref(null)
const showList = ref(true)
const saveLoading = ref(false)
const uploadToken = ref('')
const fileUrl = ref('')
const currentUploadFile = ref(null) // 存储当前要上传的文件
const uploadLoading = ref(false) // 上传loading状态
const roomId = ref()
const uploaderInfo = ref('')
// 计算属性将文件类型数组转换为accept字符串
const acceptString = computed(() => {
return props.fileType.map(type => `.${type}`).join(', ')
})
emitter.on('subscribeToFileUploadTopic',subscribeToFileUploadTopic)
function subscribeToFileUploadTopic(data){
try {
// 订阅文件上传状态主题
roomId.value = data.roomId
const topic = `xsynergy/room/${data.roomId}/file/upload`
mqttClient.subscribe(topic, handleFileUploadMessage)
} catch (error) {
console.error('订阅文件上传事件失败:', error)
}
}
//订阅文件上传状态主题
function handleFileUploadMessage(payload, topic){
try {
const messageStr = payload.toString()
const data = JSON.parse(messageStr)
emitter.emit('fileUploadStatus',data)
emit('upload-success')
} catch (error) {
console.error('文件长传状态消息失败:', error)
}
}
// 上传前校检格式和大小
const handleBeforeUpload = async (file) => {
// 如果正在上传中,阻止新文件上传
if (uploadLoading.value) {
ElMessage.warning('文件正在上传中,请稍候...')
return false
}
// 获取文件扩展名
const fileExtension = file.name.toLowerCase().slice(((file.name.lastIndexOf(".") - 1) >>> 0) + 2)
// 校验文件格式
if (!props.fileType.includes(fileExtension)) {
ElMessage.error(`文件格式不支持,请上传 ${acceptString.value} 格式的文件`)
return false
}
// 校检文件大小
const isLt = file.size / 1024 / 1024 < 50
if (!isLt) {
ElMessage.error(`上传文件大小不能超过 50 MB!`)
return false
}
// 开始上传设置loading状态
uploadLoading.value = true
try {
// 保存当前文件引用
currentUploadFile.value = file
// 计算文件SHA1
const sha1 = await calculateFileSHA1(file)
// 获取上传token
const res = await getUploadTokenApi({
service: props.roomId,
hash: sha1,
ext: fileExtension,
})
if(res.meta.code != 200){
ElMessage.error(res.meta.msg)
uploadLoading.value = false // 出错时取消loading
return false
}
if(res.data.exists){
// 文件已存在直接获取文件URL
fileUrl.value = res.data.fileUrl
ElMessage.info('文件已存在,无需重复上传')
// dialogFormVisible.value = false
uploadLoading.value = false
} else {
// 文件不存在获取token并执行上传
uploadToken.value = res.data.token
// 执行上传操作
await handleHttpRequest(file)
}
} catch (error) {
console.error('上传过程出错:', error)
ElMessage.error('上传过程出错,请重试')
uploadLoading.value = false // 出错时取消loading
return false
}
return false // 阻止默认上传行为,因为我们使用自定义上传
}
// 自定义上传
const handleHttpRequest = async (file) => {
if (!uploadToken.value) {
ElMessage.error('上传凭证不存在')
uploadLoading.value = false // 取消loading
return false
}
let params = new FormData()
params.append('file', file)
try {
const res = await uploadFileApi(uploadToken.value, params)
if(res.meta.code != 200){
ElMessage.error(res.meta.msg)
uploadLoading.value = false // 出错时取消loading
return false
}
fileUrl.value = res.data.fileUrl
getFileTaskId(res.data.fileUrl)
ElMessage.success('文件上传成功')
// publishFileUploadData(file);
// 上传成功取消loading
uploadLoading.value = false
dialogFormVisible.value = false
return true
} catch (error) {
console.error('上传文件失败:', error)
ElMessage.error('上传文件失败')
uploadLoading.value = false // 出错时取消loading
return false
}
}
async function getFileTaskId(fileUrl) {
try {
const res = await convertFileApi({ file_url: fileUrl },props.roomId)
if (res.meta.code !== 200) {
throw new Error(res.meta.msg || '文件转换失败')
}
} catch (err) {
ElMessage.error('文件转换失败')
}
}
function publishFileUploadData(fileData) {
try {
const message = {
uploaderName: uploaderInfo.value?.name || '',
uploaderUid: uploaderInfo.value?.uid || '',
fileName: fileData.name,
fileUrl: fileUrl.value,
roomId: roomId.value,
timestamp: Date.now(),
};
mqttClient.publish(`xSynergy/File/Upload/${roomId.value}`, message);
} catch (error) {
console.error('发布激光笔数据失败:', error);
}
}
// 上传前校检格式和大小
const beforeUploadser = (file) => {
return false
}
// 显示弹框
const showEdit = () => {
title.value = '上传文件'
dialogFormVisible.value = true
uploaderInfo.value = JSON.parse(sessionStorage.getItem('userData'))
}
// 关闭按钮点击事件
const close = () => {
fileList.value = []
currentUploadFile.value = null
uploadToken.value = ''
fileUrl.value = ''
uploadLoading.value = false // 关闭时重置loading状态
dialogFormVisible.value = false
}
// 保存按钮点击事件
const save = () => {
close()
}
// 暴露方法给父组件
defineExpose({
showEdit,
close,
save
})
</script>
<style lang="scss">
.avatar-uploader {
.el-upload {
width: 345px;
height: 180px;
}
.el-upload-list__item-thumbnail {
height: 180px;
}
}
@media (max-width: 765px) {
.el-dialog {
width: 80% !important;
}
}
</style>

View File

@@ -1,4 +1,9 @@
<template> <template>
<div v-if="showLogin">
<!-- 登录界面 -->
<Login @loginSuccess="handleLoginSuccess" />
</div>
<div v-else>
<!-- 音频元素 --> <!-- 音频元素 -->
<audio ref="localAudio" autoplay muted class="audio-element"></audio> <audio ref="localAudio" autoplay muted class="audio-element"></audio>
<div id="audio"></div> <div id="audio"></div>
@@ -72,11 +77,24 @@
<div class="video-grid" :class="{ 'grid-layout': !hasActiveScreenShare && participantCount > 1 }"> <div class="video-grid" :class="{ 'grid-layout': !hasActiveScreenShare && participantCount > 1 }">
<!-- 本地视频 --> <!-- 本地视频 -->
<div class="participant-card local-participant"> <div class="participant-card local-participant">
<div class="participant-header"> <!-- <div class="participant-header">
<h3>我的视频 ({{ hostUid }})</h3> <h3>我的视频 ({{ hostUid }})</h3>
<div class="status-indicator"> <div class="status-indicator">
<span class="status-dot" :class="{ 'speaking': isLocalSpeaking }"></span> <span class="status-dot" :class="{ 'speaking': isLocalSpeaking }"></span>
<span>{{ microphoneEnabled ? '音频在线' : '音频离线' }}</span> <span>{{ microphoneEnabled ? '音频在线' : '音频离线' }}</span>
<el-icon><Microphone /></el-icon>
<el-icon><Mute /></el-icon>
</div>
</div> -->
<div class="participant-header">
<h3>我的视频 ({{ hostUid }})</h3>
<div class="status-indicator">
<!-- <span class="status-dot" :class="{ 'speaking': isLocalSpeaking }"></span> -->
<el-icon :class="{ 'audio-on': microphoneEnabled, 'audio-off': !microphoneEnabled }">
<Microphone v-if="microphoneEnabled" />
<Mute v-else />
</el-icon>
<!-- <span>{{ microphoneEnabled ? '音频在线' : '音频离线' }}</span> -->
</div> </div>
</div> </div>
<div class="video-wrapper"> <div class="video-wrapper">
@@ -97,12 +115,23 @@
<!-- 远程视频--> <!-- 远程视频-->
<div class="participant-card" v-for="participant in remoteParticipantsArray" :key="participant.identity"> <div class="participant-card" v-for="participant in remoteParticipantsArray" :key="participant.identity">
<div class="participant-header"> <!-- <div class="participant-header">
<h3>{{ participant.identity }}</h3> <h3>{{ participant.identity }}</h3>
<div class="status-indicator"> <div class="status-indicator">
<span class="status-dot" :class="{ 'speaking': participant.isSpeaking }"></span> <span class="status-dot" :class="{ 'speaking': participant.isSpeaking }"></span>
<span>{{ participant.audioEnabled ? '音频在线' : '音频离线' }}</span> <span>{{ participant.audioEnabled ? '音频在线' : '音频离线' }}</span>
</div> </div>
</div> -->
<div class="participant-header">
<h3>{{ participant.identity }}</h3>
<div class="status-indicator">
<!-- <span class="status-dot" :class="{ 'speaking': participant.isSpeaking }"></span> -->
<el-icon :class="{ 'audio-on': participant.audioEnabled, 'audio-off': !participant.audioEnabled }">
<Microphone v-if="participant.audioEnabled" />
<Mute v-else />
</el-icon>
<!-- <span>{{ participant.audioEnabled ? '音频在线' : '音频离线' }}</span> -->
</div>
</div> </div>
<div class="video-wrapper"> <div class="video-wrapper">
<div class="video-tracks"> <div class="video-tracks">
@@ -121,14 +150,20 @@
<span>暂无视频流</span> <span>暂无视频流</span>
</div> </div>
</div> </div>
<div class="video-overlay"> <!-- video-overlay -->
<div class="video-tracks">
<span class="participant-name">{{ participant.identity }}</span> <span class="participant-name">{{ participant.identity }}</span>
<span v-if="participant.hasScreenTrack" class="screen-sharing-indicator">
<div v-if="participant.hasScreenTrack" class="video-placeholder">
<i class="el-icon-user"></i>
<span>暂无视频流</span>
</div>
<!-- <span v-if="participant.hasScreenTrack" class="screen-sharing-indicator">
<i class="el-icon-monitor"></i> 正在共享 <i class="el-icon-monitor"></i> 正在共享
</span> </span>
<span class="audio-indicator" :class="{ 'muted': !participant.audioEnabled }"> <span class="audio-indicator" :class="{ 'muted': !participant.audioEnabled }">
<i :class="participant.audioEnabled ? 'el-icon-microphone' : 'el-icon-turn-off-microphone'"></i> <i :class="participant.audioEnabled ? 'el-icon-microphone' : 'el-icon-turn-off-microphone'"></i>
</span> </span> -->
</div> </div>
</div> </div>
<!-- 远程参与者音频元素 --> <!-- 远程参与者音频元素 -->
@@ -225,12 +260,12 @@
{{ isLaserPointerActive ? '关闭激光笔' : '激光笔' }} {{ isLaserPointerActive ? '关闭激光笔' : '激光笔' }}
</el-button> </el-button>
<el-button @click="fileUploadHandle" type="info" class="control-btn" size="large"> <el-button @click="fileUploadHandle" type="info" class="control-btn" size="large">
文件上传 文件
</el-button> </el-button>
<el-button @click="inviterJoinRoom" type="info" class="control-btn" size="large"> <el-button @click="inviterJoinRoom" type="info" class="control-btn" size="large">
邀请人员 邀请人员
</el-button> </el-button>
<el-button @click="leaveRoom" type="info" class="control-btn" size="large"> <el-button @click="leaveRoomHandle" type="info" class="control-btn" size="large">
离开会议 离开会议
</el-button> </el-button>
</div> </div>
@@ -238,6 +273,9 @@
</div> </div>
<!-- 邀请人员组件 --> <!-- 邀请人员组件 -->
<InviterJoinRoom ref="inviterJoinRoomRef" @confirmSelection="handleConfirmSelection" /> <InviterJoinRoom ref="inviterJoinRoomRef" @confirmSelection="handleConfirmSelection" />
<!-- 上传文件 -->
<FileList ref="fileListRef" :roomId="roomId"/>
</div>
</div> </div>
</template> </template>
@@ -247,13 +285,18 @@ import { ElMessage ,ElMessageBox} from 'element-plus';
import { ArrowDown, Refresh, Check } from '@element-plus/icons-vue'; import { ArrowDown, Refresh, Check } from '@element-plus/icons-vue';
import { getRoomToken, getRoomList ,getInvite,getTokenApi,exitRoomApi,getRoomInfoApi} from "@/api/conferencingRoom.js" import { getRoomToken, getRoomList ,getInvite,getTokenApi,exitRoomApi,getRoomInfoApi} from "@/api/conferencingRoom.js"
import { Room, RoomEvent, ParticipantEvent, Track } from "livekit-client"; import { Room, RoomEvent, ParticipantEvent, Track } from "livekit-client";
import { errorHandling ,handleDataReceived ,handleReconnected,getDeviceName,handleVideoLoaded,handleConnectionStateChanged,drawSmoothCurve} from './business/index.js' import { errorHandling ,handleDataReceived ,handleReconnected,getDeviceName,handleVideoLoaded,handleConnectionStateChanged,drawSmoothCurve,simpleDeepEqual} from './business/index.js'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useRoomStore } from '@/stores/modules/room.js' import { useRoomStore } from '@/stores/modules/room.js'
import { useUserStore } from '@/stores/modules/user.js' import { useUserStore } from '@/stores/modules/user.js'
import tabulaRase from '@/views/custom/tabulaRase/index.vue' import tabulaRase from '@/views/custom/tabulaRase/index.vue'
import { mqttClient } from "@/utils/mqtt.js"; import { mqttClient } from "@/utils/mqtt.js";
import { emitter } from "@/utils/bus.js";
import Login from "@/components/Login/index.vue";
import InviterJoinRoom from "@/views/conferencingRoom/components/InviterJoinRoom/index.vue" import InviterJoinRoom from "@/views/conferencingRoom/components/InviterJoinRoom/index.vue"
import FileList from "@/views/conferencingRoom/components/fileUpload/fileList.vue"
const userStore = useUserStore() const userStore = useUserStore()
const roomStore = useRoomStore() const roomStore = useRoomStore()
const route = useRoute(); const route = useRoute();
@@ -300,7 +343,13 @@ const laserPointerCanvas = ref(null);
const laserPointerContext = ref(null); const laserPointerContext = ref(null);
const laserPointerElements = ref([]); const laserPointerElements = ref([]);
const laserPointerTimeout = ref(null); const laserPointerTimeout = ref(null);
//文件上传
const fileListRef = ref()
//mqtt相关
const isMqttFileuploadSucc = ref(false)
const isMqttFilePreview = ref(false)
const isMqttFileConversionStatus = ref(false)
const showLogin = ref(false); // 是否显示登录页面
// 鼠标状态跟踪 // 鼠标状态跟踪
const mouseState = reactive({ const mouseState = reactive({
isDrawing: false, isDrawing: false,
@@ -354,7 +403,6 @@ const screenShareStatusText = computed(() => {
} }
return '暂无屏幕共享'; return '暂无屏幕共享';
}); });
// 创建 Room 实例 // 创建 Room 实例
const room = new Room({ const room = new Room({
adaptiveStream: true, adaptiveStream: true,
@@ -386,6 +434,61 @@ const room = new Room({
} }
}); });
emitter.on('whiteboardFailed',whiteboardFailedHandle);
/** 登录成功回调 */
function handleLoginSuccess() {
showLogin.value = false;
}
function whiteboardFailedHandle(e){
showLogin.value = e;
}
//初始化MQTT连接 文件上传成功
async function initMqttFileUploadSucc(){
try {
if (isMqttFileuploadSucc.value) return
const clientId = `fileUpload_${Date.now()}`
await mqttClient.connect(clientId)
isMqttFileuploadSucc.value = true
// 订阅主题
emitter.emit('subscribeToFileUploadTopic',{roomId:roomId.value})
} catch (error) {
console.error('MQTT连接失败:', error)
ElMessage.error('文件上传服务连接失败')
}
}
//文件预览MQTT链接
async function initMqttFilePreview(){
try {
if (isMqttFilePreview.value) return
const clientId = `filePreview_${Date.now()}`
await mqttClient.connect(clientId)
isMqttFilePreview.value = true
// 订阅主题
emitter.emit('subscribeToFilePreviewTopic',{roomId:roomId.value})
} catch (error) {
console.error('MQTT连接失败:', error)
ElMessage.error('文件上传服务连接失败')
}
}
async function initMqttFileConversionStatus(){
try {
if (isMqttFileConversionStatus.value) return
const clientId = `fileConversionStatus_${Date.now()}`
await mqttClient.connect(clientId)
isMqttFileConversionStatus.value = true
// 订阅主题
emitter.emit('subscribeToFileConversionStatusTopic',{roomId:roomId.value})
} catch (error) {
console.error('MQTT连接失败:', error)
ElMessage.error('文件上传服务连接失败')
}
}
// 激光笔功能 // 激光笔功能
function toggleLaserPointer() { function toggleLaserPointer() {
if (!hasActiveScreenShare.value || isWhiteboardActive.value) { if (!hasActiveScreenShare.value || isWhiteboardActive.value) {
@@ -405,7 +508,6 @@ function toggleLaserPointer() {
} }
function handleScreenShareLoaded() { function handleScreenShareLoaded() {
console.log('屏幕共享视频加载完成');
// 视频加载完成后初始化激光笔 Canvas // 视频加载完成后初始化激光笔 Canvas
initLaserPointerCanvas(); initLaserPointerCanvas();
} }
@@ -413,7 +515,8 @@ function handleScreenShareLoaded() {
// 初始化激光笔 Canvas // 初始化激光笔 Canvas
function initLaserPointerCanvas() { function initLaserPointerCanvas() {
if (!laserPointerCanvas.value) return; if (!laserPointerCanvas.value) return;
const videoWrapper = document.querySelector('.screen-share-wrapper'); // screen-share-element screen-share-wrapper
const videoWrapper = document.querySelector('.screen-share-element');
if (!videoWrapper) return; if (!videoWrapper) return;
const rect = videoWrapper.getBoundingClientRect(); const rect = videoWrapper.getBoundingClientRect();
// 设置 Canvas 尺寸 // 设置 Canvas 尺寸
@@ -484,7 +587,7 @@ function handleCanvasDoubleClick(e) {
}; };
laserPointerElements.value.push(circleElement); laserPointerElements.value.push(circleElement);
drawCircle(pixelCoords); drawCircle(pixelCoords);
// 发送到其他用户(如果需要) // 发送到其他用户
publishLaserPointerData(circleElement); publishLaserPointerData(circleElement);
// 2秒后清除 // 2秒后清除
scheduleCleanup(); scheduleCleanup();
@@ -494,35 +597,20 @@ function handleCanvasMouseDown(e) {
if (!isLaserPointerActive.value) return; if (!isLaserPointerActive.value) return;
const pixelCoords = getPixelCoordinates(e); const pixelCoords = getPixelCoordinates(e);
const percentageCoords = getMouseCoordinates(e);
mouseState.isDrawing = true; mouseState.isDrawing = true;
mouseState.startX = pixelCoords.x; mouseState.startX = pixelCoords.x;
mouseState.startY = pixelCoords.y; mouseState.startY = pixelCoords.y;
mouseState.lastX = pixelCoords.x; mouseState.lastX = pixelCoords.x;
mouseState.lastY = pixelCoords.y; mouseState.lastY = pixelCoords.y;
// 初始化当前路径,添加第一个点
mouseState.currentPath = [{
x: percentageCoords.x,
y: percentageCoords.y
}];
} }
function handleCanvasMouseMove(e) { function handleCanvasMouseMove(e) {
if (!isLaserPointerActive.value || !mouseState.isDrawing) return; if (!isLaserPointerActive.value || !mouseState.isDrawing) return;
const pixelCoords = getPixelCoordinates(e); const pixelCoords = getPixelCoordinates(e);
const percentageCoords = getMouseCoordinates(e);
// 添加当前点到路径 // 绘制临时线条
mouseState.currentPath.push({ // drawLine(mouseState.lastX, mouseState.lastY, pixelCoords.x, pixelCoords.y);
x: percentageCoords.x,
y: percentageCoords.y
});
// 实时绘制临时线条
drawLine(mouseState.lastX, mouseState.lastY, pixelCoords.x, pixelCoords.y);
mouseState.lastX = pixelCoords.x; mouseState.lastX = pixelCoords.x;
mouseState.lastY = pixelCoords.y; mouseState.lastY = pixelCoords.y;
@@ -531,72 +619,58 @@ function handleCanvasMouseMove(e) {
function handleCanvasMouseUp(e) { function handleCanvasMouseUp(e) {
if (!isLaserPointerActive.value || !mouseState.isDrawing) return; if (!isLaserPointerActive.value || !mouseState.isDrawing) return;
const percentageCoords = getMouseCoordinates(e); const endCoords = getMouseCoordinates(e);
const startCoords = getMouseCoordinates({
// 添加最后一个点到路径 clientX: mouseState.startX + laserPointerCanvas.value.getBoundingClientRect().left,
if (mouseState.currentPath.length > 0) { clientY: mouseState.startY + laserPointerCanvas.value.getBoundingClientRect().top
const lastPoint = mouseState.currentPath[mouseState.currentPath.length - 1];
// 如果最后一个点不是当前点,则添加当前点
if (lastPoint.x !== percentageCoords.x || lastPoint.y !== percentageCoords.y) {
mouseState.currentPath.push({
x: percentageCoords.x,
y: percentageCoords.y
}); });
}
}
// 创建铅笔元素 // 创建线条元素
const pencilElement = { const lineElement = {
type: 'pencil', type: 'line',
data: { data: {
color: laserPointerConfig.color, color: laserPointerConfig.color,
path: [...mouseState.currentPath], // 复制路径数组 start: startCoords,
end: endCoords,
thickness: laserPointerConfig.thickness, thickness: laserPointerConfig.thickness,
timestamp: Date.now() timestamp: Date.now()
} }
}; };
const startPixel = getPixelFromPercentage(lineElement.data.start);
// 只有当路径有足够多的点时才保存和发送 const endPixel = getPixelFromPercentage(lineElement.data.end);
if (pencilElement.data.path.length >= 2) { drawLine(startPixel.x, startPixel.y, endPixel.x, endPixel.y);
laserPointerElements.value.push(pencilElement); laserPointerElements.value.push(lineElement);
if(!simpleDeepEqual(lineElement.data.start,lineElement.data.end)){
// 重新绘制所有元素(包括新的铅笔路径)
redrawLaserElements();
// 发送到其他用户 // 发送到其他用户
publishLaserPointerData(pencilElement); publishLaserPointerData(lineElement);
}
mouseState.isDrawing = false;
// 安排清理 // 2秒后清除
scheduleCleanup(); scheduleCleanup();
} }
// 重置绘图状态
mouseState.isDrawing = false;
mouseState.currentPath = [];
}
function handleCanvasMouseLeave() { function handleCanvasMouseLeave() {
if (mouseState.isDrawing) {
// 如果正在绘制,完成当前路径
handleCanvasMouseUp(new MouseEvent('mouseup'));
}
mouseState.isDrawing = false; mouseState.isDrawing = false;
} }
// 绘制函数 // 绘制函数
function drawCircle(coords) { function drawCircle(coords) {
if (!laserPointerContext.value) return; if (!laserPointerContext.value) return;
const ctx = laserPointerContext.value; const ctx = laserPointerContext.value;
ctx.beginPath(); ctx.beginPath();
ctx.arc(coords.x, coords.y, 8, 0, Math.PI * 2); ctx.arc(coords.x, coords.y, 2, 0, Math.PI * 2);
ctx.fillStyle = laserPointerConfig.color; ctx.fillStyle = laserPointerConfig.color;
ctx.fill(); ctx.fill();
ctx.strokeStyle = '#ffffff'; // ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2; // ctx.lineWidth = 2;
ctx.stroke(); ctx.stroke();
} }
function drawLine(startX, startY, endX, endY) { function drawLine(startX, startY, endX, endY) {
if (!laserPointerContext.value) return; if (!laserPointerContext.value) return;
const ctx = laserPointerContext.value; const ctx = laserPointerContext.value;
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(startX, startY); ctx.moveTo(startX, startY);
@@ -641,9 +715,12 @@ function redrawLaserElements() {
laserPointerElements.value.forEach(element => { laserPointerElements.value.forEach(element => {
if (now - element.data.timestamp < laserPointerConfig.duration) { if (now - element.data.timestamp < laserPointerConfig.duration) {
if (element.type === 'circle' && element.data.pixelCoords) { if (element.type === 'circle' && element.data.pixelCoords) {
drawCircle(element.data.pixelCoords); const start = getPixelFromPercentage(element.data.start)
} else if (element.type === 'pencil') { drawCircle(start);
drawPencilPath(element.data); } else if (element.type === 'line') {
const startPixel = getPixelFromPercentage(element.data.start);
const endPixel = getPixelFromPercentage(element.data.end);
drawLine(startPixel.x, startPixel.y, endPixel.x, endPixel.y);
} }
} }
}); });
@@ -732,7 +809,6 @@ function handleLaserPointerMessage(payload, topic) {
// 忽略自己发送的消息 // 忽略自己发送的消息
if (data.sender === hostUid.value) return; if (data.sender === hostUid.value) return;
switch (data.type) { switch (data.type) {
case LASER_POINTER_MESSAGE_TYPES.DRAW: case LASER_POINTER_MESSAGE_TYPES.DRAW:
// 添加远程用户的激光笔绘制 // 添加远程用户的激光笔绘制
@@ -752,7 +828,7 @@ function handleLaserPointerMessage(payload, topic) {
//文件上传 //文件上传
async function fileUploadHandle(){ async function fileUploadHandle(){
fileListRef.value.show()
} }
//摄像头打开下拉框触发 //摄像头打开下拉框触发
@@ -1006,9 +1082,7 @@ function handleRemoteWhiteboardOpen(data) {
// 处理远程关闭白板 // 处理远程关闭白板
function handleRemoteWhiteboardClose(data) { function handleRemoteWhiteboardClose(data) {
console.log('关闭白板')
ElMessage.info(`${data.senderName || data.sender} 关闭了白板`); ElMessage.info(`${data.senderName || data.sender} 关闭了白板`);
console.log('data',data)
if(data.roomType == '1'){ if(data.roomType == '1'){
isWhiteboardActive.value = false; isWhiteboardActive.value = false;
} }
@@ -1032,7 +1106,6 @@ async function inviterJoinRoom(){
// 确认选择房间 // 确认选择房间
async function handleConfirmSelection(userInfo){ async function handleConfirmSelection(userInfo){
console.log(userInfo,'加入房间人员信息')
if(userInfo.length < 0){ if(userInfo.length < 0){
ElMessage.error('请选择加入房间的人员') ElMessage.error('请选择加入房间的人员')
return return
@@ -1053,7 +1126,6 @@ function publishWhiteboardMessage(type, payload = {}) {
payload payload
}; };
mqttClient.publish(`xSynergy/shareWhiteboard/${room.name}`, message); mqttClient.publish(`xSynergy/shareWhiteboard/${room.name}`, message);
console.log('白板消息发布成功:', type);
return true; return true;
} catch (error) { } catch (error) {
console.error('发布白板消息失败:', error); console.error('发布白板消息失败:', error);
@@ -1067,7 +1139,7 @@ async function toggleWhiteboard() {
ElMessage.error('请先关闭屏幕共享'); ElMessage.error('请先关闭屏幕共享');
return; return;
} }
roomId.value = room.name // roomId.value = room.name
if (isWhiteboardActive.value) { if (isWhiteboardActive.value) {
// 如果白板已经激活,点击则退出白板 // 如果白板已经激活,点击则退出白板
await exitWhiteboard(); await exitWhiteboard();
@@ -1106,13 +1178,13 @@ async function startWhiteboard() {
async function exitWhiteboard() { async function exitWhiteboard() {
try { try {
// 确认退出 // 确认退出
await ElMessageBox.confirm('确定要退出白板吗?', '提示', // await ElMessageBox.confirm('确定要退出白板吗?', '提示',
{ // {
confirmButtonText: '确定', // confirmButtonText: '确定',
cancelButtonText: '取消', // cancelButtonText: '取消',
type: 'warning' // type: 'warning'
} // }
); // );
// 发布关闭白板消息 // 发布关闭白板消息
publishWhiteboardMessage(WHITEBOARD_MESSAGE_TYPES.CLOSE, { publishWhiteboardMessage(WHITEBOARD_MESSAGE_TYPES.CLOSE, {
action: 'close', action: 'close',
@@ -1191,7 +1263,7 @@ function setupRoomListeners() {
.on(RoomEvent.ParticipantConnected, handleParticipantConnected) .on(RoomEvent.ParticipantConnected, handleParticipantConnected)
.on(RoomEvent.ParticipantDisconnected, handleParticipantDisconnected) .on(RoomEvent.ParticipantDisconnected, handleParticipantDisconnected)
.on(RoomEvent.LocalTrackPublished, handleLocalTrackPublished) .on(RoomEvent.LocalTrackPublished, handleLocalTrackPublished)
.on(RoomEvent.LocalTrackUnpublished, handleLocalTrackUnpublished)// .on(RoomEvent.LocalTrackUnpublished, handleLocalTrackUnpublished)
.on(RoomEvent.TrackMuted, handleTrackMuted) .on(RoomEvent.TrackMuted, handleTrackMuted)
.on(RoomEvent.TrackUnmuted, handleTrackUnmuted) .on(RoomEvent.TrackUnmuted, handleTrackUnmuted)
.on(RoomEvent.ActiveSpeakersChanged, handleActiveSpeakersChanged) .on(RoomEvent.ActiveSpeakersChanged, handleActiveSpeakersChanged)
@@ -1201,9 +1273,12 @@ function setupRoomListeners() {
// 事件处理函数 // 事件处理函数
async function handleConnected() { async function handleConnected() {
roomId.value = room.name
await initMqtt(); await initMqtt();
await initToLaserPointerMqtt() await initToLaserPointerMqtt()
roomId.value = room.name await initMqttFileUploadSucc()
await initMqttFileConversionStatus()
await initMqttFilePreview()
status.value = false; status.value = false;
ElMessage.success('已成功连接到房间'); ElMessage.success('已成功连接到房间');
// 初始化现有远程参与者 // 初始化现有远程参与者
@@ -1259,6 +1334,7 @@ function handleTrackUnsubscribed(track, publication, participant) {
removeParticipantTrack(participant, publication.source); removeParticipantTrack(participant, publication.source);
// 清理视频元素 // 清理视频元素
detachTrackFromParticipantVideo(participant.identity, publication.source); detachTrackFromParticipantVideo(participant.identity, publication.source);
// 如果是屏幕共享,更新屏幕共享状态 // 如果是屏幕共享,更新屏幕共享状态
if (publication.source === Track.Source.ScreenShare) { if (publication.source === Track.Source.ScreenShare) {
updateScreenShareState(participant, null); updateScreenShareState(participant, null);
@@ -1268,13 +1344,18 @@ function handleTrackUnsubscribed(track, publication, participant) {
isGlobalScreenSharing.value = false; isGlobalScreenSharing.value = false;
} }
} }
// 重要:立即更新参与者轨道状态
updateParticipantTracks(participant);
} else if (track.kind === Track.Kind.Audio) { } else if (track.kind === Track.Kind.Audio) {
// 处理音频轨道取消订阅 // 处理音频轨道取消订阅
removeParticipantAudioTrack(participant); removeParticipantAudioTrack(participant);
detachTrackFromParticipantAudio(participant.identity); detachTrackFromParticipantAudio(participant.identity);
} // 立即更新参与者轨道状态
updateParticipantTracks(participant); updateParticipantTracks(participant);
} }
}
function handleParticipantConnected(participant) { function handleParticipantConnected(participant) {
addRemoteParticipant(participant); addRemoteParticipant(participant);
@@ -1364,19 +1445,30 @@ function handleTrackUnmuted(publication, participant) {
function setupParticipantListeners(participant) { function setupParticipantListeners(participant) {
participant participant
.on(ParticipantEvent.TrackSubscribed, (track, publication) => { .on(ParticipantEvent.TrackSubscribed, (track, publication) => {
// console.log(`轨道订阅: ${publication.source} - ${participant.identity}`);
handleTrackSubscribed(track, publication, participant); handleTrackSubscribed(track, publication, participant);
}) })
.on(ParticipantEvent.TrackUnsubscribed, (track, publication) => { .on(ParticipantEvent.TrackUnsubscribed, (track, publication) => {
// console.log(`轨道取消订阅: ${publication.source} - ${participant.identity}`);
handleTrackUnsubscribed(track, publication, participant); handleTrackUnsubscribed(track, publication, participant);
}) })
.on(ParticipantEvent.TrackMuted, (publication) => { .on(ParticipantEvent.TrackMuted, (publication) => {
// console.log(`轨道静音: ${publication.source} - ${participant.identity}`);
handleTrackMuted(publication, participant); handleTrackMuted(publication, participant);
}) })
.on(ParticipantEvent.TrackUnmuted, (publication) => { .on(ParticipantEvent.TrackUnmuted, (publication) => {
// console.log(`轨道取消静音: ${publication.source} - ${participant.identity}`);
handleTrackUnmuted(publication, participant); handleTrackUnmuted(publication, participant);
}) })
.on(ParticipantEvent.IsSpeakingChanged, (speaking) => { .on(ParticipantEvent.IsSpeakingChanged, (speaking) => {
updateParticipantSpeaking(participant, speaking); updateParticipantSpeaking(participant, speaking);
})
// 添加轨道发布状态变化监听
.on(ParticipantEvent.TrackPublished, (publication) => {
updateParticipantTracks(participant);
})
.on(ParticipantEvent.TrackUnpublished, (publication) => {
updateParticipantTracks(participant);
}); });
} }
@@ -1434,7 +1526,7 @@ function addRemoteParticipant(participant) {
isSpeaking: false isSpeaking: false
}; };
remoteParticipants.value.set(participant.identity, participantData); remoteParticipants.value.set(participant.identity, participantData);
console.log("添加远程参与者:", participant.identity); // console.log("添加远程参与者:", participant.identity);
} }
function removeRemoteParticipant(participant) { function removeRemoteParticipant(participant) {
@@ -1564,13 +1656,15 @@ function detachTrackFromParticipantAudio(identity) {
function updateParticipantTracks(participant) { function updateParticipantTracks(participant) {
const data = remoteParticipants.value.get(participant.identity); const data = remoteParticipants.value.get(participant.identity);
if (!data) return; if (!data) return;
// 检查视频轨道状态
// 重置轨道状态
let hasCamera = false; let hasCamera = false;
let hasScreen = false; let hasScreen = false;
let hasAudio = false; let hasAudio = false;
// 检查已发布的轨道
// 检查已发布的轨道 - 只统计已订阅且未静音的轨道
participant.videoTrackPublications.forEach(publication => { participant.videoTrackPublications.forEach(publication => {
if (publication.isSubscribed && publication.track) { if (publication.isSubscribed && publication.track && !publication.isMuted) {
if (publication.source === Track.Source.Camera) { if (publication.source === Track.Source.Camera) {
hasCamera = true; hasCamera = true;
// 确保轨道信息更新 // 确保轨道信息更新
@@ -1586,9 +1680,9 @@ function updateParticipantTracks(participant) {
} }
}); });
// 检查音频轨道 // 检查音频轨道 - 只统计已订阅且未静音的轨道
participant.audioTrackPublications.forEach(publication => { participant.audioTrackPublications.forEach(publication => {
if (publication.isSubscribed && publication.track) { if (publication.isSubscribed && publication.track && !publication.isMuted) {
hasAudio = true; hasAudio = true;
if (!data.audioTrack) { if (!data.audioTrack) {
data.audioTrack = publication.track; data.audioTrack = publication.track;
@@ -1596,10 +1690,27 @@ function updateParticipantTracks(participant) {
} }
}); });
// 更新数据
data.hasCameraTrack = hasCamera; data.hasCameraTrack = hasCamera;
data.hasScreenTrack = hasScreen; data.hasScreenTrack = hasScreen;
data.audioEnabled = hasAudio; data.audioEnabled = hasAudio;
data.videoEnabled = participant.isCameraEnabled; data.videoEnabled = participant.isCameraEnabled;
// 如果没有摄像头轨道,清空相关数据
if (!hasCamera) {
data.cameraTrack = null;
}
// 如果没有屏幕共享轨道,清空相关数据
if (!hasScreen) {
data.screenTrack = null;
}
// 如果没有音频轨道,清空相关数据
if (!hasAudio) {
data.audioTrack = null;
}
remoteParticipants.value.set(participant.identity, { ...data }); remoteParticipants.value.set(participant.identity, { ...data });
} }
@@ -1664,6 +1775,8 @@ async function toggleCamera() {
// 清空选中的视屏设备ID // 清空选中的视屏设备ID
selectedCameraId.value = ''; selectedCameraId.value = '';
ElMessage.info('摄像头已关闭'); ElMessage.info('摄像头已关闭');
// 强制更新一次本地参与者状态
updateParticipantTracks(room.localParticipant);
} else { } else {
// 确保视频元素存在 // 确保视频元素存在
if (!localVideo.value) { if (!localVideo.value) {
@@ -1823,7 +1936,7 @@ async function toggleScreenShare() {
} }
function handleScreenShareEnded() { function handleScreenShareEnded() {
console.log('用户通过浏览器控件停止了屏幕共享'); // console.log('用户通过浏览器控件停止了屏幕共享');
isScreenSharing.value = false; isScreenSharing.value = false;
ElMessage.info('屏幕共享已停止'); ElMessage.info('屏幕共享已停止');
// 移除事件监听器 // 移除事件监听器
@@ -1886,9 +1999,6 @@ async function leaveRoom() {
// 重置所有状态 // 重置所有状态
resetRoomState(); resetRoomState();
ElMessage.success('已离开会议'); ElMessage.success('已离开会议');
router.push({
path: '/coordinate',
})
} catch (error) { } catch (error) {
console.error('离开会议失败:', error); console.error('离开会议失败:', error);
@@ -1896,6 +2006,13 @@ async function leaveRoom() {
} }
} }
async function leaveRoomHandle(){
await leaveRoom()
router.push({
path: '/coordinate',
})
}
// 重置房间状态函数 // 重置房间状态函数
function resetRoomState() { function resetRoomState() {
// 重置本地状态 // 重置本地状态
@@ -1967,7 +2084,7 @@ watch([() => hasActiveScreenShare.value, () => isScreenSharing.value], ([newHasA
if (!newHasActiveScreenShare && !newIsScreenSharing) { if (!newHasActiveScreenShare && !newIsScreenSharing) {
// 检查激光笔是否处于开启状态 // 检查激光笔是否处于开启状态
if (isLaserPointerActive.value) { if (isLaserPointerActive.value) {
console.log('屏幕共享已结束,自动关闭激光笔'); // console.log('屏幕共享已结束,自动关闭激光笔');
// 自动关闭激光笔 // 自动关闭激光笔
closeLaserPointer(); closeLaserPointer();
} }
@@ -2027,6 +2144,18 @@ onMounted(async () => {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.audio-on {
color: #67c23a; /* 绿色表示在线 */
}
.audio-off {
color: #f56c6c; /* 红色表示离线 */
}
.status-indicator .el-icon {
margin-right: 4px;
font-size: 18px;
}
/* 激光笔 Canvas 样式 */ /* 激光笔 Canvas 样式 */
.laser-pointer-canvas { .laser-pointer-canvas {
position: absolute; position: absolute;
@@ -2233,7 +2362,7 @@ body {
} }
/* 会议界面样式 */ /* 会议界面样式 */
.meeting-container { .meeting-container {
width: calc(100vw - 40px) ; width: calc(100vw - 42px) ;
margin: 0 auto; margin: 0 auto;
padding: 20px; padding: 20px;
} }

View File

@@ -45,6 +45,10 @@
<div class="video-overlay"> <div class="video-overlay">
<span class="participant-name">{{ screenSharingUser }}</span> <span class="participant-name">{{ screenSharingUser }}</span>
</div> </div>
<!-- 添加canvas容器 -->
<div class="canvas-container" ref="canvasContainerRef">
<canvas ref="screenShareCanvasRef" class="screen-share-canvas"></canvas>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -132,12 +136,67 @@
<!-- 固定在底部的控制按钮 --> <!-- 固定在底部的控制按钮 -->
<div class="fixed-controls"> <div class="fixed-controls">
<div class="controls-container"> <div class="controls-container">
<el-button @click="toggleCamera" :type="cameraEnabled ? 'danger' : 'info'" class="control-btn" size="large">
<div class="microphone-control-group">
<el-button @click="toggleCamera" :type="cameraEnabled ? 'danger' : 'info'" class="control-btn microphone-btn" size="large">
{{ cameraEnabled ? '关闭摄像头' : '开启摄像头' }} {{ cameraEnabled ? '关闭摄像头' : '开启摄像头' }}
</el-button> </el-button>
<el-button @click="toggleMicrophone" :type="microphoneEnabled ? 'danger' : 'info'" class="control-btn" size="large"> <!-- 摄像头选择下拉菜单 -->
<el-dropdown trigger="click" @command="handleCameraCommand" @visible-change="handleCameraVisibleChange" class="control-dropdown microphone-dropdown">
<el-button :type="cameraEnabled ? 'danger' : 'info'" class="control-btn dropdown-btn" size="large">
<el-icon class="el-icon--right"><arrow-down /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="device in cameraDevices"
:key="device.deviceId"
:command="device.deviceId"
:class="{ 'selected-device': selectedCameraId === device.deviceId }"
>
<i class="el-icon-video-camera"></i>
{{ device.label || `摄像头 ${cameraDevices.indexOf(device) + 1}` }}
<el-icon v-if="selectedCameraId === device.deviceId" class="check-icon"><check /></el-icon>
</el-dropdown-item>
<el-dropdown-item divided command="refresh">
<el-icon><refresh /></el-icon>
刷新设备列表
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<div class="microphone-control-group">
<el-button @click="toggleMicrophone" :type="microphoneEnabled ? 'danger' : 'info'" class="control-btn microphone-btn" size="large">
{{ microphoneEnabled ? '关闭麦克风' : '开启麦克风' }} {{ microphoneEnabled ? '关闭麦克风' : '开启麦克风' }}
</el-button> </el-button>
<!-- 麦克风选择下拉菜单 -->
<el-dropdown trigger="click" @command="handleMicrophoneCommand" @visible-change="handleMicrophoneVisibleChange" class="control-dropdown microphone-dropdown">
<el-button :type="microphoneEnabled ? 'danger' : 'info'" class="control-btn dropdown-btn" size="large">
<el-icon class="el-icon--right"><arrow-down /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="device in microphoneDevices"
:key="device.deviceId"
:command="device.deviceId"
:class="{ 'selected-device': selectedMicrophoneId === device.deviceId }"
>
<i class="el-icon-microphone"></i>
{{ device.label || `麦克风 ${microphoneDevices.indexOf(device) + 1}` }}
<el-icon v-if="selectedMicrophoneId === device.deviceId" class="check-icon"><check /></el-icon>
</el-dropdown-item>
<el-dropdown-item divided command="refresh">
<el-icon><refresh /></el-icon>
刷新设备列表
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<el-button <el-button
@click="toggleScreenShare" @click="toggleScreenShare"
:type="isScreenSharing ? 'danger' : (isGlobalScreenSharing ? 'primary' : 'info')" :type="isScreenSharing ? 'danger' : (isGlobalScreenSharing ? 'primary' : 'info')"
@@ -145,7 +204,6 @@
class="control-btn" class="control-btn"
size="large" size="large"
> >
<!-- :type="isScreenSharing ? 'danger' : (isGlobalScreenSharing ? 'info' : 'primary')" -->
<span v-if="isScreenSharing">停止共享</span> <span v-if="isScreenSharing">停止共享</span>
<span v-else-if="isGlobalScreenSharing">他人共享中</span> <span v-else-if="isGlobalScreenSharing">他人共享中</span>
<span v-else>共享屏幕</span> <span v-else>共享屏幕</span>
@@ -153,6 +211,12 @@
<el-button @click="toggleWhiteboard" :type="isWhiteboardActive ? 'danger' : 'info'" class="control-btn" size="large"> <el-button @click="toggleWhiteboard" :type="isWhiteboardActive ? 'danger' : 'info'" class="control-btn" size="large">
{{ isWhiteboardActive ? '退出白板' : '共享白板' }} {{ isWhiteboardActive ? '退出白板' : '共享白板' }}
</el-button> </el-button>
<el-button @click="laserPointer" type="info" class="control-btn" size="large">
激光笔
</el-button>
<el-button @click="fileUploadHandle" type="info" class="control-btn" size="large">
文件上传
</el-button>
<el-button @click="inviterJoinRoom" type="info" class="control-btn" size="large"> <el-button @click="inviterJoinRoom" type="info" class="control-btn" size="large">
邀请人员 邀请人员
</el-button> </el-button>
@@ -162,14 +226,15 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- 邀请人员组件 --> <!-- 邀请人员组件 -->
<InviterJoinRoom ref="inviterJoinRoomRef" @confirmSelection="handleConfirmSelection" /> <InviterJoinRoom ref="inviterJoinRoomRef" @confirmSelection="handleConfirmSelection" />
</div>
</template> </template>
<script setup> <script setup>
import { reactive, ref, onMounted, onUnmounted, nextTick, computed, watch } from "vue"; import { reactive, ref, onMounted, onUnmounted, nextTick, computed, watch } from "vue";
import { ElMessage ,ElMessageBox} from 'element-plus'; import { ElMessage ,ElMessageBox} from 'element-plus';
import { ArrowDown, Refresh, Check } from '@element-plus/icons-vue';
import { getRoomToken, getRoomList ,getInvite,getTokenApi,exitRoomApi,getRoomInfoApi} from "@/api/conferencingRoom.js" import { getRoomToken, getRoomList ,getInvite,getTokenApi,exitRoomApi,getRoomInfoApi} from "@/api/conferencingRoom.js"
import { Room, RoomEvent, ParticipantEvent, Track } from "livekit-client"; import { Room, RoomEvent, ParticipantEvent, Track } from "livekit-client";
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
@@ -178,20 +243,16 @@ import { useUserStore } from '@/stores/modules/user.js'
import tabulaRase from '@/views/custom/tabulaRase/index.vue' import tabulaRase from '@/views/custom/tabulaRase/index.vue'
import { mqttClient } from "@/utils/mqtt.js"; import { mqttClient } from "@/utils/mqtt.js";
import InviterJoinRoom from "@/views/conferencingRoom/components/InviterJoinRoom/index.vue" import InviterJoinRoom from "@/views/conferencingRoom/components/InviterJoinRoom/index.vue"
const userStore = useUserStore() const userStore = useUserStore()
const roomStore = useRoomStore() const roomStore = useRoomStore()
const route = useRoute(); const route = useRoute();
const router = useRouter() const router = useRouter()
// LiveKit 服务器配置 // LiveKit 服务器配置
const wsURL = "wss://meeting.cnsdt.com:443"; const wsURL = "wss://meeting.cnsdt.com:443";
const hostUid = ref('') const hostUid = ref('')
const status = ref(true); const status = ref(true);
const roomName = ref('') const roomName = ref('')
const roomId = ref('') const roomId = ref('')
// 音视频相关引用和数据 // 音视频相关引用和数据
const localVideo = ref(null); const localVideo = ref(null);
const localAudio = ref(null); const localAudio = ref(null);
@@ -199,30 +260,38 @@ const cameraEnabled = ref(false);
const microphoneEnabled = ref(false); const microphoneEnabled = ref(false);
const isScreenSharing = ref(false); const isScreenSharing = ref(false);
const isLocalSpeaking = ref(false); const isLocalSpeaking = ref(false);
// 设备列表
const cameraDevices = ref([]);
const microphoneDevices = ref([]);
const selectedCameraId = ref('');
const selectedMicrophoneId = ref('');
// 远程参与者管理 // 远程参与者管理
const remoteParticipants = ref(new Map()); const remoteParticipants = ref(new Map());
const videoElementsMap = ref(new Map()); const videoElementsMap = ref(new Map());
const audioElementsMap = ref(new Map()); const audioElementsMap = ref(new Map());
// 屏幕共享相关 // 屏幕共享相关
const screenSharingUser = ref(''); const screenSharingUser = ref('');
const activeScreenShareTrack = ref(null); const activeScreenShareTrack = ref(null);
const screenShareVideo = ref(null); const screenShareVideo = ref(null);//屏幕共享视频元素
const globalScreenSharingUser = ref(''); // 当前正在共享屏幕的用户 const globalScreenSharingUser = ref(''); // 当前正在共享屏幕的用户
const isGlobalScreenSharing = ref(false); // 是否有用户正在共享屏幕 const isGlobalScreenSharing = ref(false); // 是否有用户正在共享屏幕
//共享白板 //共享白板
const isWhiteboardActive = ref(false); const isWhiteboardActive = ref(false);
const whiteboardRef = ref(null); const whiteboardRef = ref(null);
const isGlobalWhiteboardSharing = ref(false); // 是否有用户正在共享白板 const isGlobalWhiteboardSharing = ref(false); // 是否有用户正在共享白板
//当前房间信息 //当前房间信息
const roomInfo = ref('') const roomInfo = ref('')
//邀请参会人员 //邀请参会人员
const inviterJoinRoomRef = ref() const inviterJoinRoomRef = ref()
// 添加canvas相关引用
const screenShareCanvasRef = ref(null);
const canvasContainerRef = ref(null);
const canvasContext = ref(null);
const isCanvasActive = ref(false);
const canvas = ref(null)
const ctx = ref(null)
const isDrawing = ref(false)
const startCoords = ref(null)
// 白板消息类型 // 白板消息类型
const WHITEBOARD_MESSAGE_TYPES = { const WHITEBOARD_MESSAGE_TYPES = {
@@ -267,7 +336,7 @@ const room = new Room({
autoGainControl: true, autoGainControl: true,
}, },
videoCaptureDefaults: { videoCaptureDefaults: {
resolution: { width: 1280, height: 720 } resolution: { width: 1280, height: 720 },
}, },
publishDefaults: { publishDefaults: {
screenShareEncoding: { screenShareEncoding: {
@@ -280,13 +349,220 @@ const room = new Room({
}, },
// 音频发布配置 // 音频发布配置
audioEncoding: { audioEncoding: {
maxBitrate: 64000, // 64kbps for audio maxBitrate: 64000,
}, },
// dtx: true, // 不连续传输,节省带宽 // dtx: true, // 不连续传输,节省带宽
red: true, // 冗余编码,提高抗丢包能力 red: true, // 冗余编码,提高抗丢包能力
} }
}); });
//文件上传
async function fileUploadHandle(){
}
//摄像头打开下拉框触发
async function handleCameraVisibleChange(e){
try {
if(e){
// 请求摄像头权限
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
// 获取设备列表
const devices = await navigator.mediaDevices.enumerateDevices();
// 过滤摄像头设备
cameraDevices.value = devices.filter(device => device.kind === 'videoinput');
// 重要:立即停止临时媒体流,避免占用摄像头
stream.getTracks().forEach(track => {
track.stop();
console.log('临时摄像头轨道已停止');
});
}
} catch (error) {
console.error('摄像头访问失败:', error);
// 使用更友好的方式提示用户
errorHandling(error,'摄像头');
// 清空设备列表
cameraDevices.value = [];
}
}
// 处理摄像头设备选择
async function handleCameraCommand(deviceId) {
if (deviceId === 'refresh') {
await handleCameraVisibleChange(true);
ElMessage.success('设备列表已刷新');
return;
}
selectedCameraId.value = deviceId;
// 如果摄像头已经开启,重新开启以应用新设备
if (cameraEnabled.value) {
await switchCameraDevice(deviceId);
} else {
// 如果摄像头未开启,直接开启选中的设备
await enableCameraWithDevice(deviceId);
}
ElMessage.success(`已切换到摄像头: ${getDeviceName(cameraDevices.value, deviceId)}`);
}
//麦克风打开下拉框触发
async function handleMicrophoneVisibleChange(e){
try {
if(e){
// 请求麦克风权限
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
// 获取设备列表
const devices = await navigator.mediaDevices.enumerateDevices();
// 过滤麦克风设备
microphoneDevices.value = devices.filter(device => device.kind === 'audioinput');
// 停止所有轨道来关闭临时的麦克风访问
stream.getTracks().forEach(track => track.stop());
}
} catch (error) {
console.error('麦克风访问失败:', error);
// 使用更友好的方式提示用户
errorHandling(error,'麦克风');
// 清空设备列表
microphoneDevices.value = [];
}
}
// 处理麦克风设备选择
async function handleMicrophoneCommand(deviceId) {
if (deviceId === 'refresh') {
await handleMicrophoneVisibleChange();
ElMessage.success('设备列表已刷新');
return;
}
selectedMicrophoneId.value = deviceId;
// 如果麦克风已经开启,重新开启以应用新设备
if (microphoneEnabled.value) {
await switchMicrophoneDevice(deviceId);
} else {
// 如果麦克风未开启,直接开启选中的设备
await enableMicrophoneWithDevice(deviceId);
}
ElMessage.success(`已切换到麦克风: ${getDeviceName(microphoneDevices.value, deviceId)}`);
}
// 使用指定设备开启摄像头
async function enableCameraWithDevice(deviceId) {
try {
// 更新设备配置
room.options.videoCaptureDefaults.deviceId = deviceId;
// 开启摄像头
await room.localParticipant.setCameraEnabled(true);
cameraEnabled.value = true;
// 手动获取并附加视频轨道
setTimeout(() => {
attachLocalCameraTrack();
}, 200);
return true;
} catch (error) {
ElMessage.error(`使用指定设备开启摄像头失败`);
try {
if (cameraEnabled.value) {
await room.localParticipant.setCameraEnabled(true);
}
} catch (e) {
cameraEnabled.value = false;
selectedCameraId.value = '';
}
throw error;
}
}
// 使用指定设备开启麦克风
async function enableMicrophoneWithDevice(deviceId) {
try {
// 更新设备配置
room.options.audioCaptureDefaults.deviceId = deviceId;
// 开启麦克风
await room.localParticipant.setMicrophoneEnabled(true);
microphoneEnabled.value = true;
return true;
} catch (error) {
console.error('使用指定设备开启麦克风失败:', error);
microphoneEnabled.value = false;
throw error;
}
}
// 切换摄像头设备
async function switchCameraDevice(deviceId) {
try {
// 先关闭当前摄像头
await room.localParticipant.setCameraEnabled(false);
// 更新设备配置
room.options.videoCaptureDefaults.deviceId = deviceId;
// 重新开启摄像头
await room.localParticipant.setCameraEnabled(true);
// 手动获取并附加视频轨道
setTimeout(() => {
attachLocalCameraTrack();
}, 200);
return true;
} catch (error) {
console.error('切换摄像头设备失败:', error);
// 如果切换失败,尝试重新开启之前的设备
try {
await room.localParticipant.setCameraEnabled(true);
} catch (e) {
cameraEnabled.value = false;
}
throw error;
}
}
// 切换麦克风设备
async function switchMicrophoneDevice(deviceId) {
try {
// 先关闭当前麦克风
await room.localParticipant.setMicrophoneEnabled(false);
// 更新设备配置
room.options.audioCaptureDefaults.deviceId = deviceId;
// 重新开启麦克风
await room.localParticipant.setMicrophoneEnabled(true);
return true;
} catch (error) {
console.error('切换麦克风设备失败:', error);
// 如果切换失败,尝试重新开启之前的设备
try {
await room.localParticipant.setMicrophoneEnabled(true);
} catch (e) {
microphoneEnabled.value = false;
}
throw error;
}
}
// 获取设备名称
function getDeviceName(devices, deviceId) {
const device = devices.find(d => d.deviceId === deviceId);
return device ? (device.label || '未知设备') : '未知设备';
}
// 初始化MQTT连接 // 初始化MQTT连接
async function initMqtt() { async function initMqtt() {
try { try {
@@ -493,6 +769,7 @@ async function exitWhiteboard() {
} }
} }
} }
// 设置视频元素引用 // 设置视频元素引用
function setParticipantVideoRef(el, identity, type) { function setParticipantVideoRef(el, identity, type) {
if (!el) return; if (!el) return;
@@ -516,6 +793,7 @@ function setParticipantVideoRef(el, identity, type) {
} }
} }
} }
// 设置音频元素引用 // 设置音频元素引用
function setParticipantAudioRef(el, identity) { function setParticipantAudioRef(el, identity) {
if (!el) return; if (!el) return;
@@ -528,16 +806,17 @@ function setParticipantAudioRef(el, identity) {
attachTrackToAudio(el, participantData.audioTrack); attachTrackToAudio(el, participantData.audioTrack);
} }
} }
// 设置屏幕共享视频引用 // 设置屏幕共享视频引用
function setScreenShareVideoRef(el) { function setScreenShareVideoRef(el) {
if (!el) return; if (!el) return;
screenShareVideo.value = el; screenShareVideo.value = el;
// 如果已经有屏幕共享轨道,立即附加 // 如果已经有屏幕共享轨道,立即附加
if (activeScreenShareTrack.value) { if (activeScreenShareTrack.value) {
attachTrackToVideo(el, activeScreenShareTrack.value); attachTrackToVideo(el, activeScreenShareTrack.value);
} }
} }
// 设置事件监听器 // 设置事件监听器
function setupRoomListeners() { function setupRoomListeners() {
room.removeAllListeners(); room.removeAllListeners();
@@ -557,11 +836,13 @@ function setupRoomListeners() {
.on(RoomEvent.DataReceived, handleDataReceived) .on(RoomEvent.DataReceived, handleDataReceived)
.on(RoomEvent.ConnectionStateChanged, handleConnectionStateChanged); .on(RoomEvent.ConnectionStateChanged, handleConnectionStateChanged);
} }
// 事件处理函数 // 事件处理函数
async function handleConnected() { async function handleConnected() {
await initMqtt(); await initMqtt();
roomId.value = room.name roomId.value = room.name
status.value = false; status.value = false;
ElMessage.success('已成功连接到房间'); ElMessage.success('已成功连接到房间');
// 初始化现有远程参与者 // 初始化现有远程参与者
room.remoteParticipants.forEach(participant => { room.remoteParticipants.forEach(participant => {
@@ -570,14 +851,6 @@ async function handleConnected() {
// 立即检查并更新参与者的轨道状态 // 立即检查并更新参与者的轨道状态
updateParticipantTracks(participant); updateParticipantTracks(participant);
}); });
// 自动开启麦克风
// try {
// await enableMicrophone();
// ElMessage.success('麦克风已自动开启');
// } catch (error) {
// console.warn('自动开启麦克风失败:', error);
// }
} }
function handleDisconnected(reason) { function handleDisconnected(reason) {
@@ -598,6 +871,7 @@ function handleDisconnected(reason) {
function handleReconnected() { function handleReconnected() {
ElMessage.success('已重新连接到房间'); ElMessage.success('已重新连接到房间');
} }
// 处理轨道订阅事件 // 处理轨道订阅事件
function handleTrackSubscribed(track, publication, participant) { function handleTrackSubscribed(track, publication, participant) {
@@ -733,10 +1007,12 @@ function handleLocalTrackUnpublished(publication) {
function handleTrackMuted(publication, participant) { function handleTrackMuted(publication, participant) {
updateParticipantTracks(participant); updateParticipantTracks(participant);
} }
// 处理轨道取消静音事件 // 处理轨道取消静音事件
function handleTrackUnmuted(publication, participant) { function handleTrackUnmuted(publication, participant) {
updateParticipantTracks(participant); updateParticipantTracks(participant);
} }
// 设置参与者事件监听器 // 设置参与者事件监听器
function setupParticipantListeners(participant) { function setupParticipantListeners(participant) {
participant participant
@@ -756,6 +1032,7 @@ function setupParticipantListeners(participant) {
updateParticipantSpeaking(participant, speaking); updateParticipantSpeaking(participant, speaking);
}); });
} }
// 处理活动说话者变化事件 // 处理活动说话者变化事件
function handleActiveSpeakersChanged(speakers) { function handleActiveSpeakersChanged(speakers) {
// 更新本地说话状态 // 更新本地说话状态
@@ -771,6 +1048,7 @@ function handleActiveSpeakersChanged(speakers) {
} }
}); });
} }
// 处理数据接收事件 // 处理数据接收事件
function handleDataReceived(payload, participant, kind) { function handleDataReceived(payload, participant, kind) {
try { try {
@@ -785,6 +1063,7 @@ function handleDataReceived(payload, participant, kind) {
function handleConnectionStateChanged(state) { function handleConnectionStateChanged(state) {
console.log('连接状态改变:', state); console.log('连接状态改变:', state);
} }
// 更新屏幕共享状态 // 更新屏幕共享状态
function updateScreenShareState(participant, track) { function updateScreenShareState(participant, track) {
if (track) { if (track) {
@@ -807,6 +1086,7 @@ function updateScreenShareState(participant, track) {
} }
} }
} }
// 参与者管理函数 // 参与者管理函数
function addRemoteParticipant(participant) { function addRemoteParticipant(participant) {
if (!participant || participant.identity === room.localParticipant?.identity) { if (!participant || participant.identity === room.localParticipant?.identity) {
@@ -839,6 +1119,7 @@ function removeRemoteParticipant(participant) {
audioElementsMap.value.delete(identity); audioElementsMap.value.delete(identity);
} }
} }
// 更新参与者轨道信息 // 更新参与者轨道信息
function updateParticipantTrack(participant, source, track) { function updateParticipantTrack(participant, source, track) {
const data = remoteParticipants.value.get(participant.identity); const data = remoteParticipants.value.get(participant.identity);
@@ -865,6 +1146,7 @@ function removeParticipantTrack(participant, source) {
} }
remoteParticipants.value.set(participant.identity, { ...data }); remoteParticipants.value.set(participant.identity, { ...data });
} }
// 更新参与者音频轨道信息 // 更新参与者音频轨道信息
function updateParticipantAudioTrack(participant, track) { function updateParticipantAudioTrack(participant, track) {
const data = remoteParticipants.value.get(participant.identity); const data = remoteParticipants.value.get(participant.identity);
@@ -881,6 +1163,7 @@ function removeParticipantAudioTrack(participant) {
data.audioEnabled = false; data.audioEnabled = false;
remoteParticipants.value.set(participant.identity, { ...data }); remoteParticipants.value.set(participant.identity, { ...data });
} }
// 附加轨道到视频元素 // 附加轨道到视频元素
function attachTrackToVideo(videoElement, track) { function attachTrackToVideo(videoElement, track) {
if (!videoElement || !track) return; if (!videoElement || !track) return;
@@ -893,6 +1176,7 @@ function attachTrackToVideo(videoElement, track) {
console.error('附加轨道到视频元素失败:', error); console.error('附加轨道到视频元素失败:', error);
} }
} }
// 附加轨道到音频元素 // 附加轨道到音频元素
function attachTrackToAudio(audioElement, track) { function attachTrackToAudio(audioElement, track) {
if (!audioElement || !track) return; if (!audioElement || !track) return;
@@ -908,6 +1192,7 @@ function attachTrackToAudio(audioElement, track) {
console.error('附加轨道到音频元素失败:', error); console.error('附加轨道到音频元素失败:', error);
} }
} }
// 附加轨道到参与者的视频元素 // 附加轨道到参与者的视频元素
function attachTrackToParticipantVideo(identity, source, track) { function attachTrackToParticipantVideo(identity, source, track) {
const videoElements = videoElementsMap.value.get(identity); const videoElements = videoElementsMap.value.get(identity);
@@ -918,6 +1203,7 @@ function attachTrackToParticipantVideo(identity, source, track) {
attachTrackToVideo(videoElement, track); attachTrackToVideo(videoElement, track);
} }
} }
// 附加轨道到参与者的音频元素 // 附加轨道到参与者的音频元素
function attachTrackToParticipantAudio(identity, track) { function attachTrackToParticipantAudio(identity, track) {
const audioElement = audioElementsMap.value.get(identity); const audioElement = audioElementsMap.value.get(identity);
@@ -925,6 +1211,7 @@ function attachTrackToParticipantAudio(identity, track) {
attachTrackToAudio(audioElement, track); attachTrackToAudio(audioElement, track);
} }
} }
// 从参与者的视频元素分离轨道 // 从参与者的视频元素分离轨道
function detachTrackFromParticipantVideo(identity, source) { function detachTrackFromParticipantVideo(identity, source) {
const videoElements = videoElementsMap.value.get(identity); const videoElements = videoElementsMap.value.get(identity);
@@ -935,6 +1222,7 @@ function detachTrackFromParticipantVideo(identity, source) {
videoElement.srcObject = null; videoElement.srcObject = null;
} }
} }
// 从参与者的音频元素分离轨道 // 从参与者的音频元素分离轨道
function detachTrackFromParticipantAudio(identity) { function detachTrackFromParticipantAudio(identity) {
const audioElement = audioElementsMap.value.get(identity); const audioElement = audioElementsMap.value.get(identity);
@@ -1001,6 +1289,7 @@ function handleVideoLoaded(identity, type) {
function handleScreenShareLoaded() { function handleScreenShareLoaded() {
console.log('屏幕共享视频加载完成'); console.log('屏幕共享视频加载完成');
} }
// 视频轨道处理函数 // 视频轨道处理函数
function attachLocalVideoTrack(track) { function attachLocalVideoTrack(track) {
if (localVideo.value && track) { if (localVideo.value && track) {
@@ -1014,6 +1303,7 @@ function attachLocalVideoTrack(track) {
} }
} }
} }
// 媒体控制函数 // 媒体控制函数
async function enableCamera() { async function enableCamera() {
try { try {
@@ -1050,6 +1340,8 @@ async function toggleCamera() {
localVideo.value.srcObject.getTracks().forEach(track => track.stop()); localVideo.value.srcObject.getTracks().forEach(track => track.stop());
localVideo.value.srcObject = null; localVideo.value.srcObject = null;
} }
// 清空选中的视屏设备ID
selectedCameraId.value = '';
ElMessage.info('摄像头已关闭'); ElMessage.info('摄像头已关闭');
} else { } else {
// 确保视频元素存在 // 确保视频元素存在
@@ -1057,14 +1349,41 @@ async function toggleCamera() {
console.warn('本地视频元素未找到等待DOM更新'); console.warn('本地视频元素未找到等待DOM更新');
await nextTick(); await nextTick();
} }
// 开启摄像头 // 开启摄像头
await room.localParticipant.setCameraEnabled(true); // 确保有视屏输入设备权限和设备列表
cameraEnabled.value = true; if (cameraDevices.value.length === 0) {
ElMessage.success('摄像头已开启'); // 如果没有设备列表,先获取
// 手动获取并附加视频轨道,增加延迟 await handleCameraVisibleChange(true);
}
if (cameraDevices.value.length === 0) {
ElMessage.error('未找到可用的摄像头设备');
return;
}
// 自动选择第一个可用设备(如果当前没有选中设备)
let deviceToUse = selectedCameraId.value;
if (!deviceToUse && cameraDevices.value.length > 0) {
deviceToUse = cameraDevices.value[0].deviceId;
selectedCameraId.value = deviceToUse;
}
if (deviceToUse) {
await enableCameraWithDevice(deviceToUse);
ElMessage.success(`摄像头已开启 - ${getDeviceName(cameraDevices.value, deviceToUse)}`);
} else {
// 使用默认方式开启
await room.localParticipant.setMicrophoneEnabled(true);
microphoneEnabled.value = true;
// 手动获取并附加视频轨道
setTimeout(() => { setTimeout(() => {
attachLocalCameraTrack(); attachLocalCameraTrack();
}, 200); }, 200);
ElMessage.success('麦克风已开启');
}
ElMessage.success('摄像头已开启');
} }
} catch (error) { } catch (error) {
errorHandling(error,'摄像头'); errorHandling(error,'摄像头');
@@ -1113,46 +1432,67 @@ async function toggleMicrophone() {
publication.track.stop(); // 停止轨道 publication.track.stop(); // 停止轨道
} }
} }
// 清空选中的麦克风设备ID
selectedMicrophoneId.value = '';
ElMessage.info('麦克风已关闭'); ElMessage.info('麦克风已关闭');
} else { } else {
// 确保有音频输入设备权限 // 开启麦克风
try { // 确保有音频输入设备权限和设备列表
// 先检查麦克风权限 if (microphoneDevices.value.length === 0) {
await navigator.mediaDevices.getUserMedia({ audio: true }); // 如果没有设备列表,先获取
} catch (error) { await handleMicrophoneVisibleChange(true);
ElMessage.error('无法访问麦克风,请检查权限设置'); }
if (microphoneDevices.value.length === 0) {
ElMessage.error('未找到可用的麦克风设备');
return; return;
} }
// 自动选择第一个可用设备(如果当前没有选中设备)
let deviceToUse = selectedMicrophoneId.value;
if (!deviceToUse && microphoneDevices.value.length > 0) {
deviceToUse = microphoneDevices.value[0].deviceId;
selectedMicrophoneId.value = deviceToUse;
}
if (deviceToUse) {
await enableMicrophoneWithDevice(deviceToUse);
ElMessage.success(`麦克风已开启 - ${getDeviceName(microphoneDevices.value, deviceToUse)}`);
} else {
// 使用默认方式开启
await room.localParticipant.setMicrophoneEnabled(true); await room.localParticipant.setMicrophoneEnabled(true);
microphoneEnabled.value = true; microphoneEnabled.value = true;
ElMessage.success('麦克风已开启'); ElMessage.success('麦克风已开启');
// 等待音频轨道发布
setTimeout(() => {
const audioPublications = Array.from(room.localParticipant.audioTrackPublications.values());
const audioPublication = audioPublications.find(pub => pub.track);
if (audioPublication && audioPublication.track) {
console.log('本地音频轨道已发布:', audioPublication.track);
} }
}, 500);
ElMessage.success('麦克风已开启');
} }
} catch (error) { } catch (error) {
errorHandling(error, '麦克风'); errorHandling(error, '麦克风');
// 如果开启失败,确保状态正确
if (!microphoneEnabled.value) {
selectedMicrophoneId.value = '';
}
} }
} }
function errorHandling(error,type) { function errorHandling(error,type) {
switch (error.name) { switch (error.name) {
case 'NotAllowedError': case 'NotAllowedError':
ElMessage.error('用户拒绝了权限请求'); ElMessage.error('用户拒绝了权限请求,请允许此网站使用摄像头');
break; break;
case 'NotFoundError': case 'NotFoundError':
ElMessage.error(`找到${type}设备`); ElMessage.error(`检测到可用的${type}设备,请检查${type}是否已正确连接`);
break; break;
case 'NotSupportedError': case 'NotSupportedError':
ElMessage.error('当前浏览器不支持此功能,请更换其他浏览器'); ElMessage.error(`当前浏览器不支持${type}功能请使用现代浏览器如Chrome、Firefox或Edge`);
break;
case 'NotReadableError':
ElMessage.error(`${type}设备正被其他应用程序占用,请关闭其他使用${type}的应用后重试`);
break;
case 'OverconstrainedError':
ElMessage.error(`${type}配置不兼容,请尝试调整${type}设置`);
break; break;
default: default:
ElMessage.error('服务错误,请刷新重试'); ElMessage.error('服务错误,请刷新重试');
@@ -1224,6 +1564,7 @@ async function joinRoomBtn() {
status.value = true; status.value = true;
} }
} }
// 离开会议函数 // 离开会议函数
async function leaveRoom() { async function leaveRoom() {
try { try {
@@ -1239,26 +1580,24 @@ async function leaveRoom() {
whiteboardRef.value.cleanup(); whiteboardRef.value.cleanup();
} }
} }
// 断开MQTT连接 // 断开MQTT连接
mqttClient.disconnect(); mqttClient.disconnect();
// const res = await exitRoomApi(room.name) // const res = await exitRoomApi(room.name)
// 停止屏幕共享(如果正在共享) // 停止屏幕共享(如果正在共享)
if (isScreenSharing.value) { if (isScreenSharing.value) {
await room.localParticipant.setScreenShareEnabled(false); await room.localParticipant.setScreenShareEnabled(false);
} }
// 关闭摄像头和麦克风 // 关闭摄像头和麦克风
await room.localParticipant.setCameraEnabled(false); await room.localParticipant.setCameraEnabled(false);
await room.localParticipant.setMicrophoneEnabled(false); await room.localParticipant.setMicrophoneEnabled(false);
microphoneEnabled.value = false;
cameraEnabled.value = false;
selectedMicrophoneId.value = '';
selectedCameraId.value = ''
// 断开与房间的连接 // 断开与房间的连接
await room.disconnect(); await room.disconnect();
// 重置所有状态 // 重置所有状态
resetRoomState(); resetRoomState();
ElMessage.success('已离开会议'); ElMessage.success('已离开会议');
router.push({ router.push({
path: '/coordinate', path: '/coordinate',
@@ -1282,6 +1621,10 @@ function resetRoomState() {
isLocalSpeaking.value = false; isLocalSpeaking.value = false;
status.value = true; status.value = true;
hostUid.value = ''; hostUid.value = '';
selectedMicrophoneId.value = '';
selectedCameraId.value = ''
microphoneDevices.value = [];
cameraDevices.value = [];
// 断开MQTT连接 // 断开MQTT连接
mqttClient.disconnect(); mqttClient.disconnect();
@@ -1345,16 +1688,6 @@ onUnmounted(() => {
}); });
onMounted(async () => { onMounted(async () => {
// 确保在连接前请求音频权限
// try {
// // 预请求音频权限
// await navigator.mediaDevices.getUserMedia({ audio: true });
// console.log('音频权限已获取');
// } catch (error) {
// console.warn('音频权限获取失败:', error);
// ElMessage.warning('请允许麦克风权限以使用音频功能');
// }
if(route.query.type == '1'){ if(route.query.type == '1'){
await joinRoomBtn() await joinRoomBtn()
hostUid.value = roomStore.userUid hostUid.value = roomStore.userUid
@@ -1379,7 +1712,7 @@ onMounted(async () => {
</script> </script>
<style> <style lang="scss" scoped>
/* 白板容器样式 */ /* 白板容器样式 */
.whiteboard-container { .whiteboard-container {
width: 100%; width: 100%;
@@ -1430,6 +1763,37 @@ onMounted(async () => {
.audio-element, .participant-audio { .audio-element, .participant-audio {
display: none; display: none;
} }
/* 控制下拉菜单样式 */
.control-dropdown {
margin: 0;
}
.control-dropdown .el-button {
display: flex;
align-items: center;
gap: 4px;
}
/* 选中的设备样式 */
.selected-device {
background-color: #f0f9ff;
color: #409eff;
font-weight: 500;
}
.check-icon {
margin-left: auto;
color: #67c23a;
}
/* 设备列表项样式 */
.el-dropdown-menu__item {
display: flex;
align-items: center;
gap: 8px;
}
/* 全局样式 */ /* 全局样式 */
* { * {
box-sizing: border-box; box-sizing: border-box;
@@ -1621,7 +1985,7 @@ body {
.screen-share-wrapper { .screen-share-wrapper {
width: 100%; width: 100%;
height: 100%; height: calc(100% - 36px) ;
max-height: 100%; max-height: 100%;
} }
@@ -1739,7 +2103,7 @@ body {
.video-wrapper { .video-wrapper {
position: relative; position: relative;
width: 100%; width: 100%;
height: 100%; /* 固定高度 */ height:calc(100% - 36px) ; /* 固定高度 */
overflow: hidden; overflow: hidden;
background: #000; /* 统一背景色 */ background: #000; /* 统一背景色 */
border-radius: 8px; border-radius: 8px;
@@ -1858,8 +2222,86 @@ body {
background: linear-gradient(135deg, #e64a4a 0%, #d63030 100%); background: linear-gradient(135deg, #e64a4a 0%, #d63030 100%);
} }
/* 开启摄像头和麦克风下拉框 */
.control-container {
display: flex;
align-items: center;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
.dropdown-btn {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.selected-device {
background-color: #f0f7ff;
color: #409eff;
}
.check-icon {
margin-left: 8px;
color: #67c23a;
}
.microphone-control-group {
display: flex;
align-items: center;
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.microphone-control-group .microphone-btn {
border-radius: 12px 0 0 12px;
border: none;
box-shadow: none;
margin: 0;
}
.microphone-control-group .microphone-dropdown {
margin: 0;
}
.microphone-control-group .dropdown-btn {
border-radius: 0 12px 12px 0;
border: none;
border-left: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: none;
width: 44px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
/* 悬停效果 */
.microphone-control-group:hover {
border-color: rgba(255, 255, 255, 0.2);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.3);
}
.microphone-control-group .control-btn:hover {
transform: none; /* 取消单独的悬停位移 */
}
/* 响应式调整 */ /* 响应式调整 */
@media (max-width: 768px) { @media (max-width: 768px) {
.microphone-control-group {
flex: 1;
min-width: 160px;
}
.microphone-control-group .microphone-btn {
flex: 1;
}
.microphone-control-group .dropdown-btn {
width: 40px;
height: 40px;
}
.whiteboard-container { .whiteboard-container {
height: 300px; /* 在移动端限制白板高度 */ height: 300px; /* 在移动端限制白板高度 */
} }
@@ -1920,6 +2362,19 @@ body {
/* 超小屏幕调整 */ /* 超小屏幕调整 */
@media (max-width: 480px) { @media (max-width: 480px) {
.microphone-control-group {
min-width: 140px;
}
.microphone-control-group .microphone-btn {
font-size: 11px;
padding: 8px 10px;
}
.microphone-control-group .dropdown-btn {
width: 36px;
height: 36px;
}
.controls-container { .controls-container {
gap: 6px; gap: 6px;
} }

File diff suppressed because it is too large Load Diff

View File

@@ -7,15 +7,15 @@
:class="'list-tab-item ' + (leftTab == 1 ? 'pitch-on' : '')" :class="'list-tab-item ' + (leftTab == 1 ? 'pitch-on' : '')"
@click="() => (leftTab = 1)" @click="() => (leftTab = 1)"
> >
<img src="@/assets/images/cooponents-tab3.png" v-if="leftTab == 1" /> <img src="@/assets/images/Gc_114_line-Level-action.png" v-if="leftTab == 1" />
<img src="@/assets/images/cooponents-tab4.png" v-else /> <img src="@/assets/images/Gc_114_line-Level.png" v-else />
</div> </div>
<div <div
:class="'list-tab-item ' + (leftTab == 2 ? 'pitch-on' : '')" :class="'list-tab-item ' + (leftTab == 2 ? 'pitch-on' : '')"
@click="() => (leftTab = 2)" @click="() => (leftTab = 2)"
> >
<img src="@/assets/images/cooponents-tab2.png" v-if="leftTab == 2" /> <img src="@/assets/images/book-read-fill-action.png" v-if="leftTab == 2" />
<img src="@/assets/images/cooponents-tab1.png" v-else /> <img src="@/assets/images/book-read-fill.png" v-else />
</div> </div>
</div> </div>
<div class="list-content"> <div class="list-content">
@@ -317,13 +317,9 @@ const loadNode = async(resolve,id)=>{
state.leftListLoading = true state.leftListLoading = true
if(!id){ if(!id){
let res = await getDirectories({level:1}) let res = await getDirectories({level:1})
if(res.meta.code == 401){ if(res.meta.code == 200){
emit('showLogin', true)
return
}else{
resolve(res.data) resolve(res.data)
} }
}else{ }else{
let res = await getDirectoriesUsers(id,{directory_uuid:id}) let res = await getDirectoriesUsers(id,{directory_uuid:id})
resolve(res.data) resolve(res.data)
@@ -436,8 +432,8 @@ const {
cursor: pointer; cursor: pointer;
img { img {
width: 50px; width: 40px;
height: 50px; height: 40px;
} }
} }
} }
@@ -453,7 +449,7 @@ const {
width: 100%; width: 100%;
height: 50px; height: 50px;
padding: 6px 20px; padding: 6px 20px;
background: #167bff; background: #666666;
} }
.content-datapicker { .content-datapicker {

View File

@@ -1,16 +1,11 @@
<template> <template>
<div v-if="showLogin" style="height:100%"> <div>
<!-- 登录界面 -->
<Login @loginSuccess="handleLoginSuccess" />
</div>
<div v-else>
<div class="app-container" v-loading="load" :element-loading-text="loadText"> <div class="app-container" v-loading="load" :element-loading-text="loadText">
<el-row :gutter="6" style="padding: 0 10px; background: #fefefe"> <el-row :gutter="6" style="padding: 0 10px; background: #fefefe">
<el-col :xs="24" :sm="24" :md="8" :lg="6"> <el-col :xs="24" :sm="24" :md="8" :lg="6">
<leftTab <leftTab
@updateDetail="updateDetail" @updateDetail="updateDetail"
@updateTab="updateTab" @updateTab="updateTab"
@showLogin="showLoginHandle"
:loading="!detail?.appId && !detail?.userId && isShow" :loading="!detail?.appId && !detail?.userId && isShow"
/> />
</el-col> </el-col>
@@ -212,7 +207,7 @@
</div> </div>
</div> </div>
<div class="user-card-btn"> <div class="user-card-btn">
<el-button type="primary" @click="clickInitiate"> <el-button type="info" @click="clickInitiate">
发起协作 发起协作
</el-button> </el-button>
</div> </div>
@@ -278,7 +273,6 @@ import { useRoomStore } from '@/stores/modules/room'
import { useUserStore } from '@/stores/modules/user.js' import { useUserStore } from '@/stores/modules/user.js'
import { mqttClient } from "@/utils/mqtt.js"; import { mqttClient } from "@/utils/mqtt.js";
import { getToken } from '@/utils/auth.js' import { getToken } from '@/utils/auth.js'
import Login from "@/components/Login/index.vue";
const roomStore = useRoomStore() const roomStore = useRoomStore()
const userStore = useUserStore() const userStore = useUserStore()
const router = useRouter() const router = useRouter()
@@ -295,20 +289,8 @@ const state = reactive({
inviteDialog: false, inviteDialog: false,
cooperation: import.meta.env.VITE_APP_COOPERATION_TYPE, cooperation: import.meta.env.VITE_APP_COOPERATION_TYPE,
}) })
const showLogin = ref(false); // 是否显示登录页面
const userLoading = ref(false); // 用户信息加载状态 const userLoading = ref(false); // 用户信息加载状态
function showLoginHandle(e){
showLogin.value = e;
}
/** 登录成功回调 */
function handleLoginSuccess() {
showLogin.value = false;
}
const isEmptyObject = (obj) => { const isEmptyObject = (obj) => {
return !obj || Object.keys(obj).length === 0 return !obj || Object.keys(obj).length === 0
} }
@@ -461,11 +443,6 @@ const showNotification = (data) => {
// 暴露给模板 // 暴露给模板
const { detail, weekName, tabValue, isShow, load, loadText, isLinkKnow, socketInformation, inviteDialog, cooperation } = toRefs(state) const { detail, weekName, tabValue, isShow, load, loadText, isLinkKnow, socketInformation, inviteDialog, cooperation } = toRefs(state)
onMounted(async () => { onMounted(async () => {
if(!getToken()){
showLogin.value = true;
}else{
showLogin.value = false;
}
await mqttClient.connect(`room${Math.random().toString(16).substr(2, 8)}`); await mqttClient.connect(`room${Math.random().toString(16).substr(2, 8)}`);
const res = await userStore.getInfo() const res = await userStore.getInfo()
const topic = `xSynergy/ROOM/+/rooms/${res.uid}`; const topic = `xSynergy/ROOM/+/rooms/${res.uid}`;
@@ -488,6 +465,7 @@ onMounted(async () => {
.app-container { .app-container {
padding: 20px; padding: 20px;
// margin: 0 17px; // margin: 0 17px;
// height: calc(100vh - 50px);
} }
.message-null { .message-null {
@@ -505,7 +483,7 @@ onMounted(async () => {
width: 100%; width: 100%;
height: 50px; height: 50px;
padding: 0 20px; padding: 0 20px;
background: #167bff; background: #666666;
color: #fff; color: #fff;
font-size: 18px; font-size: 18px;
} }
@@ -613,8 +591,8 @@ onMounted(async () => {
height: 40%; height: 40%;
background: linear-gradient( background: linear-gradient(
180deg, 180deg,
rgba(13, 116, 255, 0.22) 0%, rgba(153, 153, 153, 0.22) 0%,
rgba(30, 173, 255, 0) 100% rgba(153, 153, 153, 0) 100%
); );
img { img {

View File

@@ -1,11 +1,6 @@
<template> <template>
<div class="wrapper-content"> <div class="wrapper-content">
<div v-if="showLogin"> <div>
<!-- 登录界面 -->
<Login @loginSuccess="handleLoginSuccess" />
</div>
<div v-else>
<!-- 未加入时显示按钮 --> <!-- 未加入时显示按钮 -->
<div v-if="!hasJoined" class="login-button-container"> <div v-if="!hasJoined" class="login-button-container">
<el-button type="primary" size="large" link @click="joinWhiteboard"> <el-button type="primary" size="large" link @click="joinWhiteboard">
@@ -30,7 +25,6 @@ import { useRoute } from "vue-router";
import { mqttClient } from "@/utils/mqtt"; import { mqttClient } from "@/utils/mqtt";
import { WhiteboardSync } from "@/utils/whiteboardSync"; import { WhiteboardSync } from "@/utils/whiteboardSync";
import ToolBox from "@/components/ToolBox/index.vue"; import ToolBox from "@/components/ToolBox/index.vue";
import Login from "@/components/Login/index.vue";
import Canvas from "@/core/index.js"; import Canvas from "@/core/index.js";
import { getInfo } from "@/api/login"; import { getInfo } from "@/api/login";
@@ -45,7 +39,6 @@ const props = defineProps({
}, },
}) })
const showLogin = ref(false); // 是否显示登录页面
const hasJoined = ref(false); // 是否加入白板 const hasJoined = ref(false); // 是否加入白板
const canvas = ref(null); const canvas = ref(null);
const route = useRoute(); const route = useRoute();
@@ -78,11 +71,6 @@ async function joinWhiteboard() {
} }
} }
/** 登录成功回调 */
function handleLoginSuccess() {
showLogin.value = false;
}
/** 初始化白板 */ /** 初始化白板 */
function initWhiteboard() { function initWhiteboard() {
const el = document.getElementById("whiteboard"); const el = document.getElementById("whiteboard");
@@ -100,17 +88,6 @@ function initWhiteboard() {
} }
onMounted(async () => { onMounted(async () => {
try {
showLogin.value = false;
const res = await getInfo("self");
if (res.meta.code === 401) {
showLogin.value = true;
} else {
showLogin.value = false;
}
} catch (err) {
console.warn("⚠️ 用户信息校验失败:", err);
}
joinWhiteboard() joinWhiteboard()
}); });

View File

@@ -1,12 +1,6 @@
<template> <template>
<div class="loginView" v-loading="loginView"> <div class="loginView">
<div class="wrapper-content" v-if="showLogin"> <div class="wrapper-content" >
<!-- <div class="content-nav">
<div class="nav-left">
<img src="../assets/logo/logo.png" />
<div>多人互动白板</div>
</div>
</div> -->
<div class="login-form"> <div class="login-form">
<div class="selected-rectangle"></div> <div class="selected-rectangle"></div>
<el-form ref="loginRef" class="form-info" :model="loginForm" :rules="loginRules"> <el-form ref="loginRef" class="form-info" :model="loginForm" :rules="loginRules">
@@ -41,7 +35,6 @@ import { useUserStore } from '@/stores/modules/user.js'
import { watch, ref, getCurrentInstance, onMounted } from 'vue' import { watch, ref, getCurrentInstance, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElNotification,ElMessage } from 'element-plus' import { ElNotification,ElMessage } from 'element-plus'
import { getInfo } from "@/api/login";
import CryptoJS from 'crypto-js'; import CryptoJS from 'crypto-js';
import { useMeterStore } from '@/stores/modules/meter' import { useMeterStore } from '@/stores/modules/meter'
@@ -49,17 +42,18 @@ const userStore = useUserStore()
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const { proxy } = getCurrentInstance() const { proxy } = getCurrentInstance()
const showLogin = ref(false)
const meterStore = useMeterStore() const meterStore = useMeterStore()
const redirect = ref(undefined); const redirect = ref(undefined);
const loginView = ref(false) const loginView = ref(true)
watch(() => router.currentRoute.value, (newRoute) => { // 监听路由变化,获取重定向参数
redirect.value = newRoute.query && newRoute.query.redirect; // watch(() => route, (newRoute) => {
}, { immediate: true }); // redirect.value = newRoute.query && newRoute.query.redirect;
// console.log('重定向路径:', redirect.value);
// }, { immediate: true });
const loginForm = ref({ const loginForm = ref({
username: '', username: '',
@@ -86,11 +80,8 @@ function handleLogin() {
return return
} }
const secretKey = ((loginForm.value.username + localStorage?.getItem('UDID')).toLowerCase()).replaceAll('-', ''); // 用户名+UDID(32位16进制全小写) const secretKey = ((loginForm.value.username + localStorage?.getItem('UDID')).toLowerCase()).replaceAll('-', ''); // 用户名+UDID(32位16进制全小写)
const randomChars = generateRandomChars(6); const randomChars = generateRandomChars(6);
const message = `Gx${randomChars}${loginForm.value.password}`; const message = `Gx${randomChars}${loginForm.value.password}`;
const ciphertext = CryptoJS.Blowfish.encrypt(message, secretKey).toString(); const ciphertext = CryptoJS.Blowfish.encrypt(message, secretKey).toString();
// 调用action的登录方法 // 调用action的登录方法
userStore userStore
@@ -101,14 +92,7 @@ function handleLogin() {
}) })
.then(async (res) => { .then(async (res) => {
const userInfo = JSON.parse(sessionStorage.getItem('userData')) const userInfo = JSON.parse(sessionStorage.getItem('userData'))
router.push({ await handleLoginSuccess();
path: '/coordinate',
})
// router.push({
// path: '/whiteboard',
// query: { room_uid: 'nxst-ok4j' }
// })
}) })
.catch((e) => { .catch((e) => {
console.log('登录失败', e) console.log('登录失败', e)
@@ -119,6 +103,42 @@ function handleLogin() {
requestNotificationPermission() requestNotificationPermission()
} }
/**
* 处理登录成功后的跳转逻辑
*/
async function handleLoginSuccess() {
try {
// 如果有重定向路径且不是登录页,则跳转到重定向页面
if (redirect.value && redirect.value !== '/login') {
console.log('跳转到重定向页面:', redirect.value);
// 确保路由存在,如果不存在则跳转到默认页面
try {
// 解析路径,检查是否是有效路由
const resolved = router.resolve(redirect.value);
if (resolved.matched.length > 0) {
await router.push(redirect.value);
} else {
console.warn('重定向路径无效,跳转到默认页面');
await router.push('/coordinate');
}
} catch (error) {
console.warn('重定向跳转失败,跳转到默认页面:', error);
await router.push('/coordinate');
}
} else {
// 没有重定向或重定向到登录页,跳转到默认页面
await router.push('/coordinate');
}
} catch (error) {
console.error('登录跳转异常:', error);
// 降级处理:跳转到默认页面
await router.push('/coordinate');
} finally {
loading.value = false;
}
}
// 生成随机字符串 // 生成随机字符串
function generateRandomChars(length) { function generateRandomChars(length) {
const chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; const chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
@@ -144,27 +164,6 @@ function requestNotificationPermission() {
onMounted(async () => { onMounted(async () => {
meterStore.initUdid() meterStore.initUdid()
try {
loginView.value = true
const res = await getInfo("self");
showLogin.value = false;
if (res.meta.code === 401) {
showLogin.value = true;
} else {
// router.push({
// path: '/whiteboard',
// query: { room_uid: 'nxst-ok4j' }
// })
router.push({
path: '/coordinate',
})
}
loginView.value = false
} catch (err) {
console.warn("⚠️ 用户信息校验失败:", err);
} finally {
loginView.value = false
}
}); });
</script> </script>

View File

@@ -0,0 +1,14 @@
<template>
<div></div>
</template>
<script setup>
import { useRoute, useRouter } from 'vue-router'
const route = useRoute();
const router = useRouter();
const { params, query } = route
const { path } = params
router.replace({ path: '/' + path, query })
</script>