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); // return Promise.resolve(responseData);
// return handleUnauthorized().then(() => { const currentPath = router.currentRoute.value.name;
// return Promise.reject({ code: 401, message: '未授权' }); if(currentPath == 'ConferencingRoom'){
// }); return Promise.resolve(responseData);
}else{
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,31 +177,54 @@ 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('用户取消操作');
}); });
});
} }
export default service; export default service;

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,11 +156,8 @@ 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); resolve(res.data);
return;
} else {
resolve(res.data);
} }
} else { } else {
let res = await getDirectoriesUsers(id, { directory_uuid: id }); let res = await getDirectoriesUsers(id, { directory_uuid: id });

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>

File diff suppressed because it is too large Load Diff

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">
{{ cameraEnabled ? '关闭摄像头' : '开启摄像头' }} <div class="microphone-control-group">
</el-button> <el-button @click="toggleCamera" :type="cameraEnabled ? 'danger' : 'info'" class="control-btn microphone-btn" size="large">
<el-button @click="toggleMicrophone" :type="microphoneEnabled ? 'danger' : 'info'" class="control-btn" size="large"> {{ cameraEnabled ? '关闭摄像头' : '开启摄像头' }}
{{ microphoneEnabled ? '关闭麦克风' : '开启麦克风' }} </el-button>
</el-button> <!-- 摄像头选择下拉菜单 -->
<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 ? '关闭麦克风' : '开启麦克风' }}
</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>
<!-- 邀请人员组件 -->
<InviterJoinRoom ref="inviterJoinRoomRef" @confirmSelection="handleConfirmSelection" />
</div> </div>
<!-- 邀请人员组件 -->
<InviterJoinRoom ref="inviterJoinRoomRef" @confirmSelection="handleConfirmSelection" />
</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) {
// 如果没有设备列表,先获取
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(() => {
attachLocalCameraTrack();
}, 200);
ElMessage.success('麦克风已开启');
}
ElMessage.success('摄像头已开启'); ElMessage.success('摄像头已开启');
// 手动获取并附加视频轨道,增加延迟
setTimeout(() => {
attachLocalCameraTrack();
}, 200);
} }
} 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;
} }
await room.localParticipant.setMicrophoneEnabled(true);
microphoneEnabled.value = true;
ElMessage.success('麦克风已开启');
// 等待音频轨道发布 // 自动选择第一个可用设备(如果当前没有选中设备)
setTimeout(() => { let deviceToUse = selectedMicrophoneId.value;
const audioPublications = Array.from(room.localParticipant.audioTrackPublications.values()); if (!deviceToUse && microphoneDevices.value.length > 0) {
const audioPublication = audioPublications.find(pub => pub.track); deviceToUse = microphoneDevices.value[0].deviceId;
if (audioPublication && audioPublication.track) { selectedMicrophoneId.value = deviceToUse;
console.log('本地音频轨道已发布:', audioPublication.track); }
}
}, 500); if (deviceToUse) {
await enableMicrophoneWithDevice(deviceToUse);
ElMessage.success(`麦克风已开启 - ${getDeviceName(microphoneDevices.value, deviceToUse)}`);
} else {
// 使用默认方式开启
await room.localParticipant.setMicrophoneEnabled(true);
microphoneEnabled.value = true;
ElMessage.success('麦克风已开启');
}
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">
@@ -133,7 +133,7 @@
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { import {
@@ -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>
@@ -265,7 +260,7 @@
</el-dialog> </el-dialog>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { onActivated, onMounted, reactive, toRefs, watch, getCurrentInstance ,ref} from 'vue' import { onActivated, onMounted, reactive, toRefs, watch, getCurrentInstance ,ref} from 'vue'
@@ -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: '',
@@ -74,7 +68,7 @@ const loginRules = {
const loading = ref(false) const loading = ref(false)
function handleLogin() { function handleLogin() {
proxy.$refs.loginRef.validate((valid) => { proxy.$refs.loginRef.validate((valid) => {
if (valid) { if (valid) {
loading.value = true loading.value = true
@@ -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>