feat:跟新密码加密 共享白板功能

This commit is contained in:
leilei
2025-10-11 17:28:20 +08:00
parent e429e4286a
commit db72ea9f33
18 changed files with 1434 additions and 412 deletions

BIN
dist.zip

Binary file not shown.

6
package-lock.json generated
View File

@@ -14,6 +14,7 @@
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"axios": "^0.27.2", "axios": "^0.27.2",
"code-inspector-plugin": "^0.20.12", "code-inspector-plugin": "^0.20.12",
"crypto-js": "^4.2.0",
"element-plus": "^2.2.27", "element-plus": "^2.2.27",
"js-cookie": "^3.0.1", "js-cookie": "^3.0.1",
"livekit-client": "^2.7.5", "livekit-client": "^2.7.5",
@@ -1597,6 +1598,11 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/crypto-js": {
"version": "4.2.0",
"resolved": "https://registry.npmmirror.com/crypto-js/-/crypto-js-4.2.0.tgz",
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q=="
},
"node_modules/cssesc": { "node_modules/cssesc": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz", "resolved": "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz",

View File

@@ -14,6 +14,7 @@
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"axios": "^0.27.2", "axios": "^0.27.2",
"code-inspector-plugin": "^0.20.12", "code-inspector-plugin": "^0.20.12",
"crypto-js": "^4.2.0",
"element-plus": "^2.2.27", "element-plus": "^2.2.27",
"js-cookie": "^3.0.1", "js-cookie": "^3.0.1",
"livekit-client": "^2.7.5", "livekit-client": "^2.7.5",

View File

@@ -27,14 +27,17 @@
<script setup> <script setup>
import useUserStore from '@/stores/modules/user' import useUserStore from '@/stores/modules/user'
import { watch, ref, getCurrentInstance } from 'vue' import { watch, ref, getCurrentInstance,onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useMeterStore } from '@/stores/modules/meter'
import CryptoJS from 'crypto-js';
const userStore = useUserStore() const userStore = useUserStore()
const router = useRouter() const router = useRouter()
const { proxy } = getCurrentInstance() const { proxy } = getCurrentInstance()
const redirect = ref(undefined); const redirect = ref(undefined);
const emit = defineEmits(['loginSuccess']) const emit = defineEmits(['loginSuccess'])
const meterStore = useMeterStore()
const loginForm = ref({ const loginForm = ref({
username: '', username: '',
@@ -52,14 +55,31 @@ function handleLogin() {
proxy.$refs.loginRef.validate((valid) => { proxy.$refs.loginRef.validate((valid) => {
if (valid) { if (valid) {
loading.value = true loading.value = true
if(!localStorage?.getItem('UDID')){
ElMessage({
message: '服务错误,请刷新页面',
type: 'warning',
})
return
}
const secretKey = ((loginForm.value.username + localStorage?.getItem('UDID')).toLowerCase()).replaceAll('-', ''); // 用户名+UDID(32位16进制全小写)
const randomChars = generateRandomChars(6);
const message = `Gx${randomChars}${loginForm.value.password}`;
const ciphertext = CryptoJS.Blowfish.encrypt(message, secretKey).toString();
// 调用action的登录方法 // 调用action的登录方法
userStore userStore
.login({ .login({
password: loginForm.value.password, password: ciphertext,
// password: loginForm.value.password,
username: loginForm.value.username, username: loginForm.value.username,
}) })
.then(async (res) => { .then(async (res) => {
const userInfo = JSON.parse(localStorage.getItem('userData')) const userInfo = JSON.parse(sessionStorage.getItem('userData'))
emit('loginSuccess', userInfo) emit('loginSuccess', userInfo)
}) })
.catch((e) => { .catch((e) => {
@@ -71,6 +91,16 @@ function handleLogin() {
requestNotificationPermission() requestNotificationPermission()
} }
// 生成随机字符串
function generateRandomChars(length) {
const chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
/** /**
* @description 请求浏览器的通知权限 * @description 请求浏览器的通知权限
* @returns {*} * @returns {*}
@@ -83,6 +113,10 @@ function requestNotificationPermission() {
} }
} }
onMounted(() => {
meterStore.initUdid()
})
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.wrapper-content { .wrapper-content {

285
src/core/index_old.js Normal file
View File

@@ -0,0 +1,285 @@
import EventEmitter from "@/utils/emitter";
/**
* Canvas绘图类支持多种图形绘制和多人同步
* 使用百分比坐标系统确保跨设备一致性
*/
class Canvas extends EventEmitter {
constructor(canvasId) {
super();
this.canvas = document.getElementById(canvasId);
if (!this.canvas) {
throw new Error(`Canvas element with id ${canvasId} not found`);
}
this.ctx = this.canvas.getContext('2d');
this.shapes = []; // 所有已绘制形状
this.currentShape = null; // 当前正在绘制的形状
this.isDrawing = false;
this.drawingTool = 'pencil';
this.pathOptimizationEnabled = true;
this.optimizationThreshold = 0.005;
this.currentColor = '#ffcc00';
this.currentThickness = 2;
this.resize();
// 绑定事件
this.handleMouseDown = this.handleMouseDown.bind(this);
this.handleMouseMove = this.handleMouseMove.bind(this);
this.handleMouseUp = this.handleMouseUp.bind(this);
this.handleMouseLeave = this.handleMouseLeave.bind(this);
this.canvas.addEventListener('mousedown', this.handleMouseDown);
this.canvas.addEventListener('mousemove', this.handleMouseMove);
this.canvas.addEventListener('mouseup', this.handleMouseUp);
this.canvas.addEventListener('mouseleave', this.handleMouseLeave);
window.addEventListener('resize', () => this.resize());
}
resize() {
const parent = this.canvas.parentElement;
if (!parent) return;
const containerWidth = parent.offsetWidth;
const containerHeight = parent.offsetHeight;
let width = containerWidth;
let height = Math.floor((width * 9) / 16);
if (height > containerHeight) {
height = containerHeight;
width = Math.floor((height * 16) / 9);
}
if (this.canvas.width === width && this.canvas.height === height) return;
this.canvas.width = width;
this.canvas.height = height;
this.canvas.style.width = width + "px";
this.canvas.style.height = height + "px";
this.render();
}
setDrawingTool(tool) { this.drawingTool = tool; }
setColor(color) { this.currentColor = color; }
setThickness(size) { this.currentThickness = size; }
setPathOptimization(enabled) { this.pathOptimizationEnabled = enabled; }
setOptimizationThreshold(threshold) { this.optimizationThreshold = threshold; }
getShapes() { return this.shapes; }
setShapes(shapes) { this.shapes = shapes; this.render(); }
getMouseCoordinates(e) {
const rect = this.canvas.getBoundingClientRect();
return {
x: (e.clientX - rect.left) / this.canvas.width,
y: (e.clientY - rect.top) / this.canvas.height
};
}
handleMouseDown(e) {
this.isDrawing = true;
const coords = this.getMouseCoordinates(e);
if (this.drawingTool === 'pencil') {
this.currentShape = { type: 'pencil', data: { color: this.currentColor, path: [coords], thickness: this.currentThickness } };
} else if (this.drawingTool === 'line') {
this.currentShape = { type: 'line', data: { color: this.currentColor, start: coords, end: coords, thickness: this.currentThickness } };
} else if (this.drawingTool === 'rectangle') {
this.currentShape = { type: 'rectangle', data: { color: this.currentColor, start: coords, end: coords, thickness: this.currentThickness, fill: false } };
} else if (this.drawingTool === 'circle') {
this.currentShape = { type: 'circle', data: { color: this.currentColor, start: coords, end: coords, thickness: this.currentThickness, fill: false } };
} else if (this.drawingTool === 'eraser') {
this.currentShape = { type: 'eraser', data: { color: '#ffffff', start: coords, end: coords, thickness: 3 } };
}
this.emit('drawingStart', this.currentShape);
}
handleMouseMove(e) {
if (!this.isDrawing || !this.currentShape) return;
const coords = this.getMouseCoordinates(e);
if (this.drawingTool === 'pencil') {
this.currentShape.data.path.push(coords);
} else {
this.currentShape.data.end = coords;
}
this.render();
this.emit('drawingUpdate', this.currentShape);
}
handleMouseUp(e) {
if (!this.isDrawing || !this.currentShape) return;
this.isDrawing = false;
const coords = this.getMouseCoordinates(e);
if (this.drawingTool === 'pencil' && this.pathOptimizationEnabled && this.currentShape.data.path.length > 10) {
this.currentShape.data.path = this.optimizePath(this.currentShape.data.path);
} else {
this.currentShape.data.end = coords;
}
this.shapes.push({ ...this.currentShape });
this.emit('drawingEnd', this.currentShape);
this.currentShape = null;
this.render();
}
handleMouseLeave(e) {
if (this.isDrawing) this.handleMouseUp(e);
}
optimizePath(path) {
if (path.length < 3) return path;
const optimizedPath = [path[0]];
for (let i = 1; i < path.length - 1;) {
let a = 1;
while (i + a < path.length && this.calculateDistance(path[i], path[i + a]) < this.optimizationThreshold) a++;
optimizedPath.push(path[i]);
i += a;
}
optimizedPath.push(path[path.length - 1]);
return optimizedPath;
}
calculateDistance(p1, p2) {
return Math.sqrt(Math.pow(p1.x - p2.x, 2) + Math.pow(p1.y - p2.y, 2));
}
addShape(shapeData) {
if (Array.isArray(shapeData)) this.shapes.push(...shapeData);
else this.shapes.push(shapeData);
this.render();
}
clearCanvas() {
this.shapes = [];
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.emit('clear');
}
exportToDataURL(type = 'image/png', quality = 1) {
return this.canvas.toDataURL(type, quality);
}
render() {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
// 绘制历史形状
this.shapes.forEach(shape => this.drawShape(shape));
// 绘制当前正在绘制的形状
if (this.currentShape) this.drawShape(this.currentShape);
// 画画布边框
this.ctx.save();
this.ctx.strokeStyle = "#000";
this.ctx.lineWidth = 2;
this.ctx.strokeRect(0, 0, this.canvas.width, this.canvas.height);
this.ctx.restore();
}
drawShape(shape) {
switch (shape.type) {
case 'pencil': this.drawPencil(shape.data); break;
case 'line': this.drawLine(shape.data); break;
case 'rectangle': this.drawRectangle(shape.data); break;
case 'circle': this.drawCircle(shape.data); break;
case 'eraser': this.drawEraser(shape.data, shape); break;
}
}
drawPencil(p) {
if (p.path.length < 2) return;
const path = p.path.map(pt => ({ x: pt.x * this.canvas.width, y: pt.y * this.canvas.height }));
this.ctx.beginPath();
this.ctx.strokeStyle = p.color;
this.ctx.lineWidth = p.thickness;
this.ctx.lineCap = 'round';
this.ctx.lineJoin = 'round';
this.drawSmoothCurve(this.ctx, path);
this.ctx.stroke();
}
drawSmoothCurve(ctx, path) {
if (path.length < 3) { ctx.moveTo(path[0].x, path[0].y); for (let i = 1; i < path.length; i++) ctx.lineTo(path[i].x, path[i].y); return; }
ctx.moveTo(path[0].x, path[0].y);
const threshold = 5;
for (let i = 1; i < path.length - 2;) {
let a = 1;
while (i + a < path.length - 2 &&
Math.sqrt(Math.pow(path[i].x - path[i + a].x, 2) + Math.pow(path[i].y - path[i + a].y, 2)) < threshold) a++;
const xc = (path[i].x + path[i + a].x) / 2;
const yc = (path[i].y + path[i + a].y) / 2;
ctx.quadraticCurveTo(path[i].x, path[i].y, xc, yc);
i += a;
}
ctx.lineTo(path[path.length - 1].x, path[path.length - 1].y);
}
drawLine(l) {
const sx = l.start.x * this.canvas.width, sy = l.start.y * this.canvas.height;
const ex = l.end.x * this.canvas.width, ey = l.end.y * this.canvas.height;
this.ctx.beginPath();
this.ctx.moveTo(sx, sy);
this.ctx.lineTo(ex, ey);
this.ctx.strokeStyle = l.color;
this.ctx.lineWidth = l.thickness;
this.ctx.stroke();
}
drawRectangle(r) {
const sx = r.start.x * this.canvas.width, sy = r.start.y * this.canvas.height;
const ex = r.end.x * this.canvas.width, ey = r.end.y * this.canvas.height;
const w = ex - sx, h = ey - sy;
this.ctx.beginPath();
this.ctx.rect(sx, sy, w, h);
if (r.fill) { this.ctx.fillStyle = r.color; this.ctx.fill(); }
else { this.ctx.strokeStyle = r.color; this.ctx.lineWidth = r.thickness; this.ctx.stroke(); }
}
drawCircle(c) {
const sx = c.start.x * this.canvas.width, sy = c.start.y * this.canvas.height;
const ex = c.end.x * this.canvas.width, ey = c.end.y * this.canvas.height;
const cx = (sx + ex) / 2, cy = (sy + ey) / 2;
const rx = Math.abs(ex - sx) / 2, ry = Math.abs(ey - sy) / 2;
this.ctx.beginPath();
this.ctx.ellipse(cx, cy, rx, ry, 0, 0, 2 * Math.PI);
if (c.fill) { this.ctx.fillStyle = c.color; this.ctx.fill(); }
else { this.ctx.strokeStyle = c.color; this.ctx.lineWidth = c.thickness; this.ctx.stroke(); }
}
drawEraser(e, shapeObj) {
const sx = e.start.x * this.canvas.width, sy = e.start.y * this.canvas.height;
const ex = e.end.x * this.canvas.width, ey = e.end.y * this.canvas.height;
const w = Math.abs(ex - sx), h = Math.abs(ey - sy);
const x = Math.min(sx, ex), y = Math.min(sy, ey);
this.ctx.beginPath();
this.ctx.rect(x, y, w, h);
this.ctx.fillStyle = 'rgba(255,255,255)';
this.ctx.fill();
// 仅当前正在绘制的橡皮擦显示边框
if (this.currentShape && shapeObj === this.currentShape) {
this.ctx.save();
this.ctx.strokeStyle = '#000';
this.ctx.lineWidth = 1;
this.ctx.strokeRect(x, y, w, h);
this.ctx.restore();
}
}
destroy() {
this.canvas.removeEventListener('mousedown', this.handleMouseDown);
this.canvas.removeEventListener('mousemove', this.handleMouseMove);
this.canvas.removeEventListener('mouseup', this.handleMouseUp);
this.canvas.removeEventListener('mouseleave', this.handleMouseLeave);
window.removeEventListener('resize', this.resize);
}
}
export default Canvas;

View File

@@ -2,6 +2,7 @@
import { login, logout, getInfo } from '@/api/login' import { login, logout, getInfo } from '@/api/login'
import { getToken, setToken, removeToken } from '@/utils/auth' import { getToken, setToken, removeToken } from '@/utils/auth'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ElMessage } from 'element-plus'
const useUserStore = defineStore( const useUserStore = defineStore(
'user', 'user',
@@ -20,8 +21,12 @@ const useUserStore = defineStore(
const trimmedUsername = username.trim(); const trimmedUsername = username.trim();
const res = await login(trimmedUsername, password); const res = await login(trimmedUsername, password);
if (res.meta.code !== 200) {
ElMessage({ message: res.meta?.message || '登录失败', type: 'error' });
return Promise.reject(res);
}
const { token, user } = res.data; const { token, user } = res.data;
localStorage.setItem('userData', JSON.stringify(user)); sessionStorage.setItem('userData', JSON.stringify(user));
setToken(token); setToken(token);
this.token = token; this.token = token;
@@ -34,7 +39,7 @@ const useUserStore = defineStore(
getInfo() { getInfo() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
try { try {
const userData = localStorage.getItem('userData'); const userData = sessionStorage.getItem('userData');
if (!userData) { if (!userData) {
return reject(new Error('未找到用户数据')); return reject(new Error('未找到用户数据'));

View File

@@ -3,8 +3,6 @@ import Cookies from "js-cookie";
const TokenKey = "token"; const TokenKey = "token";
const ExpiresInKey = "Meta-Enterprise-Expires-In";
export function getToken() { export function getToken() {
return Cookies.get(TokenKey); return Cookies.get(TokenKey);
} }
@@ -17,14 +15,3 @@ export function removeToken() {
return Cookies.remove(TokenKey); return Cookies.remove(TokenKey);
} }
export function getExpiresIn() {
return Cookies.get(ExpiresInKey) || -1;
}
export function setExpiresIn(time) {
return Cookies.set(ExpiresInKey, time);
}
export function removeExpiresIn() {
return Cookies.remove(ExpiresInKey);
}

View File

@@ -122,9 +122,7 @@ service.interceptors.response.use(
case 200: case 200:
case 201: case 201:
return Promise.resolve(responseData); return Promise.resolve(responseData);
case 401: case 401:
console.log('未授权', responseData)
return Promise.resolve(responseData); return Promise.resolve(responseData);
// return handleUnauthorized().then(() => { // return handleUnauthorized().then(() => {
// return Promise.reject({ code: 401, message: '未授权' }); // return Promise.reject({ code: 401, message: '未授权' });

View File

@@ -2,6 +2,7 @@ import { mqttClient } from "./mqtt";
import { getWhiteboardShapes, getWhiteboardHistory } from "@/views/custom/api"; 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';
const meterStore = useMeterStore(); const meterStore = useMeterStore();
meterStore.initUdid(); meterStore.initUdid();
@@ -11,7 +12,7 @@ let canvasInstance = null;
// 获取本地缓存 userData // 获取本地缓存 userData
function getLocalUserData() { function getLocalUserData() {
const dataStr = localStorage.getItem('userData'); const dataStr = sessionStorage.getItem('userData');
if (!dataStr) return null; if (!dataStr) return null;
try { try {
return JSON.parse(dataStr); return JSON.parse(dataStr);
@@ -28,8 +29,7 @@ export const WhiteboardSync = {
canvasInstance = canvas; canvasInstance = canvas;
const localUser = getLocalUserData(); const localUser = getLocalUserData();
const localUid = localUser?.user?.uid; const localUid = localUser?.uid;
try { try {
// 先连接 MQTT // 先连接 MQTT
await mqttClient.connect(meterStore.getSudid()); await mqttClient.connect(meterStore.getSudid());
@@ -44,8 +44,8 @@ export const WhiteboardSync = {
// 订阅当前房间 // 订阅当前房间
const topic = `xSynergy/ROOM/${roomUid}/whiteboard/#`; const topic = `xSynergy/ROOM/${roomUid}/whiteboard/#`;
mqttClient.subscribe(topic, async (shapeData) => { mqttClient.subscribe(topic, async (shapeData) => {
// console.log(shapeData, 'shapeData++格式装换') const shapeDataNew = JSON.parse(shapeData.toString())
const shapeDataNew = decode(shapeData); // const shapeDataNew = decode(message);
// console.log(shapeDataNew, '格式解码') // console.log(shapeDataNew, '格式解码')
try { try {
isRemote = true; isRemote = true;
@@ -55,6 +55,7 @@ export const WhiteboardSync = {
if (res.meta.code === 200) { if (res.meta.code === 200) {
canvasInstance.addShape(res.data.shapes); canvasInstance.addShape(res.data.shapes);
} else { } else {
ElMessage.error("获取历史数据失败");
console.error("获取历史数据失败"); console.error("获取历史数据失败");
} }
} catch (e) { } catch (e) {
@@ -82,6 +83,7 @@ export const WhiteboardSync = {
try { try {
await getWhiteboardShapes(shape, roomUid); await getWhiteboardShapes(shape, roomUid);
} catch (err) { } catch (err) {
ElMessage.error("提交形状失败");
console.error("提交形状失败:", err); console.error("提交形状失败:", err);
} }
}); });

View File

@@ -0,0 +1,103 @@
import { mqttClient } from "./mqtt";
import { getWhiteboardShapes, getWhiteboardHistory } from "@/views/custom/api";
import { useMeterStore } from '@/stores/modules/meter';
import { encode, decode } from '@msgpack/msgpack'
import { ElMessage } from 'element-plus';
const meterStore = useMeterStore();
meterStore.initUdid();
let isRemote = false;
let canvasInstance = null;
// 获取本地缓存 userData
function getLocalUserData() {
const dataStr = sessionStorage.getItem('userData');
if (!dataStr) return null;
try {
return JSON.parse(dataStr);
} catch (e) {
console.error("解析 userData 失败:", e);
return null;
}
}
export const WhiteboardSync = {
async init(canvas, roomUid) {
if (!canvas || !roomUid) return;
console.log('初始化多人同步:', roomUid);
canvasInstance = canvas;
const localUser = getLocalUserData();
const localUid = localUser?.uid;
try {
// 先连接 MQTT
await mqttClient.connect(meterStore.getSudid());
console.log("✅ MQTT 已连接");
// 获取历史数据
const res = await getWhiteboardHistory({ after_timestamp: 0 }, roomUid);
if (res.meta.code === 200 && res.data.shapes.length > 0) {
canvasInstance.addShape(res.data.shapes);
}
// 订阅当前房间
const topic = `xSynergy/ROOM/${roomUid}/whiteboard/#`;
mqttClient.subscribe(topic, async (shapeData) => {
const shapeDataNew = JSON.parse(shapeData.toString())
// const shapeDataNew = decode(message);
// console.log(shapeDataNew, '格式解码')
try {
isRemote = true;
// 如果 shape 来自本地用户,则跳过
if (shapeDataNew.user_uid === localUid) return;
const res = await getWhiteboardHistory({ after_timestamp: shapeDataNew.created_at }, roomUid);
if (res.meta.code === 200) {
canvasInstance.addShape(res.data.shapes);
} else {
ElMessage.error("获取历史数据失败");
console.error("获取历史数据失败");
}
} catch (e) {
console.error("处理MQTT数据失败:", e);
} finally {
isRemote = false;
}
});
console.log("✅ 已订阅:", topic);
} catch (err) {
console.log("初始化多人同步失败:", err)
// console.error("❌ 连接或订阅失败:", err);
}
// 监听画布事件:新增图形
canvas.on('drawingEnd', async (shape) => {
// 如果来自远程,或不是需要同步的类型,跳过
if (isRemote || !['pencil', 'line', 'rectangle', 'circle', 'eraser'].includes(shape.type)) return;
// 如果是本地用户自己的 shape则不调用接口
if (shape.user_uid && shape.user_uid === localUid) return;
shape.room_uid = roomUid;
try {
await getWhiteboardShapes(shape, roomUid);
} catch (err) {
ElMessage.error("提交形状失败");
console.error("提交形状失败:", err);
}
});
// 监听画布事件:清空
canvas.on('clear', async () => {
if (!isRemote) {
try {
// TODO: 调用接口,后端再发 MQTT
// await clearWhiteboard(roomUid);
} catch (err) {
console.error("提交清空失败:", err);
}
}
});
},
};

View File

@@ -5,17 +5,27 @@
<div class="livekit-container"> <div class="livekit-container">
<div v-if="!status" class="meeting-container"> <div v-if="!status" class="meeting-container">
<!-- 视频容器 --> <!-- 视频容器 -->
<div class="video-layout" :class="{ 'screen-sharing-active': hasActiveScreenShare }"> <div class="video-layout" :class="{ 'screen-sharing-active': hasActiveScreenShare || isWhiteboardActive}">
<!-- 左侧共享屏幕区域 --> <!-- 左侧共享屏幕/白板区域 -->
<div class="screen-share-area" v-if="hasActiveScreenShare"> <div class="screen-share-area" v-if="hasActiveScreenShare || isWhiteboardActive">
<div class="screen-share-header"> <div class="screen-share-header">
<h3>共享屏幕</h3> <h3>{{ isWhiteboardActive ? '共享白板' : '共享屏幕' }}</h3>
<div class="sharing-user"> <div class="sharing-user">
{{ screenSharingUser }} 共享 {{ screenSharingUser }} 共享
</div> </div>
</div> </div>
<div class="screen-share-video"> <div class="screen-share-video">
<div class="video-wrapper screen-share-wrapper"> <!-- 白板模式 -->
<div v-if="isWhiteboardActive" class="whiteboard-container">
<tabulaRase
ref="whiteboardRef"
:roomId="roomId"
:userId="hostUid"
class="whiteboard-component"
/>
</div>
<!-- 屏幕共享模式 -->
<div v-else class="video-wrapper screen-share-wrapper">
<div class="video-tracks"> <div class="video-tracks">
<!-- 屏幕共享视频 --> <!-- 屏幕共享视频 -->
<video <video
@@ -40,9 +50,9 @@
</div> </div>
<!-- 右侧视频区域 --> <!-- 右侧视频区域 -->
<div class="participants-area" :class="{ 'with-screen-share': hasActiveScreenShare }"> <div class="participants-area" :class="{ 'with-screen-share': hasActiveScreenShare || isWhiteboardActive }">
<div class="participants-header"> <div class="participants-header">
<h3>会议名称测试会议名称</h3> <h3>会议名称{{ roomName }}</h3>
<h3>参会者 ({{ participantCount }})</h3> <h3>参会者 ({{ participantCount }})</h3>
</div> </div>
<div class="video-grid" :class="{ 'grid-layout': !hasActiveScreenShare && participantCount > 1 }"> <div class="video-grid" :class="{ 'grid-layout': !hasActiveScreenShare && participantCount > 1 }">
@@ -129,9 +139,25 @@
<i :class="microphoneEnabled ? 'el-icon-microphone' : 'el-icon-turn-off-microphone'"></i> <i :class="microphoneEnabled ? 'el-icon-microphone' : 'el-icon-turn-off-microphone'"></i>
{{ microphoneEnabled ? '关闭麦克风' : '开启麦克风' }} {{ microphoneEnabled ? '关闭麦克风' : '开启麦克风' }}
</el-button> </el-button>
<el-button @click="toggleScreenShare" :type="isScreenSharing ? 'danger' : 'primary'" class="control-btn" size="large"> <!-- <el-button @click="toggleScreenShare" :type="isScreenSharing ? 'danger' : 'primary'" class="control-btn" size="large">
<i class="el-icon-monitor"></i> <i class="el-icon-monitor"></i>
{{ isScreenSharing ? '停止共享' : '共享屏幕' }} {{ isScreenSharing ? '停止共享' : '共享屏幕' }}
</el-button> -->
<el-button
@click="toggleScreenShare"
:type="isScreenSharing ? 'danger' : (isGlobalScreenSharing ? 'info' : 'primary')"
:disabled="isGlobalScreenSharing && !isScreenSharing"
class="control-btn"
size="large"
>
<i class="el-icon-monitor"></i>
<span v-if="isScreenSharing">停止共享</span>
<span v-else-if="isGlobalScreenSharing">他人共享中</span>
<span v-else>共享屏幕</span>
</el-button>
<el-button @click="toggleWhiteboard" :type="isWhiteboardActive ? 'danger' : 'info'" class="control-btn leave-btn" size="large">
<i class="el-icon-switch-button"></i>
{{ isWhiteboardActive ? '退出白板' : '共享白板' }}
</el-button> </el-button>
<el-button @click="leaveRoom" type="warning" class="control-btn leave-btn" size="large"> <el-button @click="leaveRoom" type="warning" class="control-btn leave-btn" size="large">
<i class="el-icon-switch-button"></i> <i class="el-icon-switch-button"></i>
@@ -145,12 +171,14 @@
<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 } from 'element-plus'; import { ElMessage ,ElMessageBox} from 'element-plus';
import { getRoomToken, getRoomList ,getInvite,getTokenApi,exitRoomApi} from "@/api/conferencingRoom.js" import { getRoomToken, getRoomList ,getInvite,getTokenApi,exitRoomApi} 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'
import { useRoomStore } from '@/stores/modules/room.js' import { useRoomStore } from '@/stores/modules/room.js'
import useUserStore from '@/stores/modules/user' import useUserStore from '@/stores/modules/user'
import tabulaRase from '@/views/custom/tabulaRase/index.vue'
import { mqttClient } from "@/utils/mqtt.js";
const userStore = useUserStore() const userStore = useUserStore()
const roomStore = useRoomStore() const roomStore = useRoomStore()
@@ -162,6 +190,8 @@ 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 roomId = ref('')
// 音视频相关引用和数据 // 音视频相关引用和数据
const localVideo = ref(null); const localVideo = ref(null);
@@ -181,6 +211,20 @@ const screenSharingUser = ref('');
const activeScreenShareTrack = ref(null); const activeScreenShareTrack = ref(null);
const screenShareVideo = ref(null); const screenShareVideo = ref(null);
//共享白板
const isWhiteboardActive = ref(false);
const whiteboardRef = ref(null);
const globalScreenSharingUser = ref(''); // 当前正在共享屏幕的用户
const isGlobalScreenSharing = ref(false); // 是否有用户正在共享屏幕
// 白板消息类型
const WHITEBOARD_MESSAGE_TYPES = {
OPEN: 'open_whiteboard',
CLOSE: 'close_whiteboard',
SYNC: 'sync_whiteboard'
};
// 计算属性 // 计算属性
const participantCount = computed(() => { const participantCount = computed(() => {
return remoteParticipants.value.size + 1; // 包括自己 return remoteParticipants.value.size + 1; // 包括自己
@@ -194,6 +238,18 @@ const hasActiveScreenShare = computed(() => {
return screenSharingUser.value !== '' || isScreenSharing.value; return screenSharingUser.value !== '' || isScreenSharing.value;
}); });
// 添加一个计算属性来显示屏幕共享状态提示
const screenShareStatusText = computed(() => {
if (isGlobalScreenSharing.value) {
if (globalScreenSharingUser.value === hostUid.value) {
return '您正在共享屏幕';
} else {
return `${globalScreenSharingUser.value} 正在共享屏幕`;
}
}
return '暂无屏幕共享';
});
// 创建 Room 实例 // 创建 Room 实例
const room = new Room({ const room = new Room({
adaptiveStream: true, adaptiveStream: true,
@@ -220,11 +276,209 @@ const room = new Room({
audioEncoding: { audioEncoding: {
maxBitrate: 64000, // 64kbps for audio maxBitrate: 64000, // 64kbps for audio
}, },
dtx: true, // 不连续传输,节省带宽 // dtx: true, // 不连续传输,节省带宽
red: true, // 冗余编码,提高抗丢包能力 red: true, // 冗余编码,提高抗丢包能力
} }
}); });
// 初始化MQTT连接
async function initMqtt() {
try {
// 使用随机客户端ID连接
const clientId = `whiteboard_${Date.now()}`;
await mqttClient.connect(clientId);
console.log('MQTT连接成功客户端ID:', clientId);
// 订阅白板主题
subscribeToWhiteboardTopic();
} catch (error) {
console.error('MQTT连接失败:', error);
ElMessage.error('白板同步连接失败');
}
}
// 订阅白板主题
function subscribeToWhiteboardTopic() {
try {
mqttClient.subscribe(`xSynergy/shareWhiteboard/${room.name}`, handleWhiteboardMessage);
} catch (error) {
console.error('订阅白板主题失败:', error);
}
}
// 处理白板消息
function handleWhiteboardMessage(payload, topic) {
try {
const messageStr = payload.toString();
const data = JSON.parse(messageStr);
console.log('收到白板消息:', data);
// 只处理当前房间的消息
if (data.roomId !== room.name) return;
// 忽略自己发送的消息
if (data.sender === hostUid.value) return;
switch (data.type) {
case WHITEBOARD_MESSAGE_TYPES.OPEN:
handleRemoteWhiteboardOpen(data);
break;
case WHITEBOARD_MESSAGE_TYPES.CLOSE:
handleRemoteWhiteboardClose(data);
break;
case WHITEBOARD_MESSAGE_TYPES.SYNC:
handleWhiteboardSync(data);
break;
default:
console.warn('未知的白板消息类型:', data.type);
}
} catch (error) {
console.error('处理白板消息失败:', error);
}
}
// 处理远程打开白板
function handleRemoteWhiteboardOpen(data) {
ElMessage.info(`${data.senderName || data.sender} 开启了白板`);
isWhiteboardActive.value = true;
// 如果正在屏幕共享,自动停止
if (isScreenSharing.value) {
room.localParticipant.setScreenShareEnabled(false);
isScreenSharing.value = false;
}
}
// 处理远程关闭白板
function handleRemoteWhiteboardClose(data) {
ElMessage.info(`${data.senderName || data.sender} 关闭了白板`);
console.log('data',data)
if(data.type == '1'){
isWhiteboardActive.value = false;
}
}
// 处理白板同步消息
function handleWhiteboardSync(data) {
// 这里可以处理白板内容的同步
if (whiteboardRef.value && data.payload) {
// 调用白板组件的同步方法
if (whiteboardRef.value.syncData) {
whiteboardRef.value.syncData(data.payload);
}
}
}
function publishWhiteboardMessage(type, payload = {}) {
try {
const message = {
type,
roomType: route.query.type,//用于判断是参与者还是发起者
roomId: roomId.value,
sender: hostUid.value,
senderName: hostUid.value,
timestamp: Date.now(),
payload
};
mqttClient.publish(`xSynergy/shareWhiteboard/${room.name}`, message);
console.log('白板消息发布成功:', type);
return true;
} catch (error) {
console.error('发布白板消息失败:', error);
ElMessage.warning('消息发送失败,但白板功能正常');
return false;
}
}
//共享白板
// async function whiteboard(){
// const baseUrl = 'http://localhost:3000/#/whiteboard'
// const url = `${baseUrl}?room_uid=${room.name}`
// window.open(url, '_blank')
// }
async function toggleWhiteboard() {
if(hasActiveScreenShare.value){
ElMessage.error('请先关闭屏幕共享');
return;
}
roomId.value = room.name
if (isWhiteboardActive.value) {
// 如果白板已经激活,点击则退出白板
await exitWhiteboard();
} else {
// 否则开启白板
await startWhiteboard();
}
}
async function startWhiteboard() {
try {
// 如果正在屏幕共享,先停止
if (isScreenSharing.value) {
await room.localParticipant.setScreenShareEnabled(false);
isScreenSharing.value = false;
ElMessage.info('已停止屏幕共享,开启白板');
}
// 激活白板状态
isWhiteboardActive.value = true;
const success = publishWhiteboardMessage(WHITEBOARD_MESSAGE_TYPES.OPEN, {
action: 'open',
whiteboardId: roomId.value,
roomName: roomName.value
});
if (success) {
ElMessage.success('白板已开启,已通知其他参会者');
} else {
ElMessage.success('白板已开启');
}
} catch (error) {
console.error('开启白板失败:', error);
ElMessage.error('开启白板失败');
}
}
async function exitWhiteboard() {
try {
// 确认退出
await ElMessageBox.confirm(
'确定要退出白板吗?',
'提示',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
);
// 发布关闭白板消息
publishWhiteboardMessage(WHITEBOARD_MESSAGE_TYPES.CLOSE, {
action: 'close',
whiteboardId: roomId.value,
roomName: roomName.value
});
// 执行退出白板逻辑
isWhiteboardActive.value = false;
// 可以在这里添加白板清理逻辑
if (whiteboardRef.value && whiteboardRef.value.cleanup) {
whiteboardRef.value.cleanup();
}
ElMessage.success('已退出白板');
} catch (error) {
if (error !== 'cancel') {
console.error('退出白板失败:', error);
ElMessage.error('退出白板失败');
}
}
}
// 设置视频元素引用 // 设置视频元素引用
function setParticipantVideoRef(el, identity, type) { function setParticipantVideoRef(el, identity, type) {
if (!el) return; if (!el) return;
@@ -286,7 +540,7 @@ function setupRoomListeners() {
.on(RoomEvent.ParticipantConnected, handleParticipantConnected) .on(RoomEvent.ParticipantConnected, handleParticipantConnected)
.on(RoomEvent.ParticipantDisconnected, handleParticipantDisconnected) .on(RoomEvent.ParticipantDisconnected, handleParticipantDisconnected)
.on(RoomEvent.LocalTrackPublished, handleLocalTrackPublished) .on(RoomEvent.LocalTrackPublished, handleLocalTrackPublished)
.on(RoomEvent.LocalTrackUnpublished, handleLocalTrackUnpublished) .on(RoomEvent.LocalTrackUnpublished, handleLocalTrackUnpublished)//
.on(RoomEvent.TrackMuted, handleTrackMuted) .on(RoomEvent.TrackMuted, handleTrackMuted)
.on(RoomEvent.TrackUnmuted, handleTrackUnmuted) .on(RoomEvent.TrackUnmuted, handleTrackUnmuted)
.on(RoomEvent.ActiveSpeakersChanged, handleActiveSpeakersChanged) .on(RoomEvent.ActiveSpeakersChanged, handleActiveSpeakersChanged)
@@ -296,11 +550,9 @@ function setupRoomListeners() {
// 事件处理函数 // 事件处理函数
async function handleConnected() { async function handleConnected() {
console.log("成功连接到房间:", room.name); await initMqtt();
status.value = false; status.value = false;
ElMessage.success('已成功连接到房间'); ElMessage.success('已成功连接到房间');
// 初始化现有远程参与者 // 初始化现有远程参与者
room.remoteParticipants.forEach(participant => { room.remoteParticipants.forEach(participant => {
addRemoteParticipant(participant); addRemoteParticipant(participant);
@@ -310,16 +562,16 @@ async function handleConnected() {
}); });
// 自动开启麦克风 // 自动开启麦克风
try { // try {
await enableMicrophone(); // await enableMicrophone();
ElMessage.success('麦克风已自动开启'); // ElMessage.success('麦克风已自动开启');
} catch (error) { // } catch (error) {
console.warn('自动开启麦克风失败:', error); // console.warn('自动开启麦克风失败:', error);
} // }
} }
function handleDisconnected(reason) { function handleDisconnected(reason) {
console.log("断开连接:", reason);
status.value = true; status.value = true;
cameraEnabled.value = false; cameraEnabled.value = false;
microphoneEnabled.value = false; microphoneEnabled.value = false;
@@ -334,13 +586,11 @@ function handleDisconnected(reason) {
} }
function handleReconnected() { function handleReconnected() {
console.log("已重新连接");
ElMessage.success('已重新连接到房间'); ElMessage.success('已重新连接到房间');
} }
// 处理轨道订阅事件 // 处理轨道订阅事件
function handleTrackSubscribed(track, publication, participant) { function handleTrackSubscribed(track, publication, participant) {
console.log("轨道已订阅:", track.kind, "轨道来源:", publication.source, "来自:", participant.identity);
if (track) { if (track) {
if (track.kind === Track.Kind.Video) { if (track.kind === Track.Kind.Video) {
@@ -350,6 +600,9 @@ function handleTrackSubscribed(track, publication, participant) {
// 如果是屏幕共享,更新屏幕共享状态 // 如果是屏幕共享,更新屏幕共享状态
if (publication.source === Track.Source.ScreenShare) { if (publication.source === Track.Source.ScreenShare) {
updateScreenShareState(participant, track); updateScreenShareState(participant, track);
// 设置全局屏幕共享用户
globalScreenSharingUser.value = participant.identity;
isGlobalScreenSharing.value = true;
} }
// 立即附加到视频元素(如果元素已存在) // 立即附加到视频元素(如果元素已存在)
@@ -365,6 +618,7 @@ function handleTrackSubscribed(track, publication, participant) {
updateParticipantTracks(participant); updateParticipantTracks(participant);
} }
function handleTrackUnsubscribed(track, publication, participant) { function handleTrackUnsubscribed(track, publication, participant) {
// 移除对应的轨道信息 // 移除对应的轨道信息
if (track.kind === Track.Kind.Video) { if (track.kind === Track.Kind.Video) {
@@ -375,6 +629,11 @@ function handleTrackUnsubscribed(track, publication, participant) {
// 如果是屏幕共享,更新屏幕共享状态 // 如果是屏幕共享,更新屏幕共享状态
if (publication.source === Track.Source.ScreenShare) { if (publication.source === Track.Source.ScreenShare) {
updateScreenShareState(participant, null); updateScreenShareState(participant, null);
// 清除全局屏幕共享状态
if (globalScreenSharingUser.value === participant.identity) {
globalScreenSharingUser.value = '';
isGlobalScreenSharing.value = false;
}
} }
} else if (track.kind === Track.Kind.Audio) { } else if (track.kind === Track.Kind.Audio) {
// 处理音频轨道取消订阅 // 处理音频轨道取消订阅
@@ -402,6 +661,12 @@ function handleParticipantDisconnected(participant) {
} }
} }
// 如果离开的用户是全局屏幕共享者,清除全局状态
if (participant.identity === globalScreenSharingUser.value) {
globalScreenSharingUser.value = '';
isGlobalScreenSharing.value = false;
}
removeRemoteParticipant(participant); removeRemoteParticipant(participant);
ElMessage.info(`用户离开: ${participant.identity}`); ElMessage.info(`用户离开: ${participant.identity}`);
} }
@@ -415,6 +680,11 @@ function handleLocalTrackPublished(publication) {
// 本地用户开始屏幕共享 // 本地用户开始屏幕共享
screenSharingUser.value = room.localParticipant.identity; screenSharingUser.value = room.localParticipant.identity;
activeScreenShareTrack.value = publication.track; activeScreenShareTrack.value = publication.track;
// 设置全局屏幕共享状态
globalScreenSharingUser.value = room.localParticipant.identity;
isGlobalScreenSharing.value = true;
if (screenShareVideo.value) { if (screenShareVideo.value) {
attachTrackToVideo(screenShareVideo.value, publication.track); attachTrackToVideo(screenShareVideo.value, publication.track);
} }
@@ -434,8 +704,14 @@ function handleLocalTrackUnpublished(publication) {
} else if (publication.source === Track.Source.ScreenShare) { } else if (publication.source === Track.Source.ScreenShare) {
// 本地用户停止屏幕共享 // 本地用户停止屏幕共享
if (screenSharingUser.value === room.localParticipant.identity) { if (screenSharingUser.value === room.localParticipant.identity) {
isScreenSharing.value = false;
screenSharingUser.value = ''; screenSharingUser.value = '';
activeScreenShareTrack.value = null; activeScreenShareTrack.value = null;
// 清除全局屏幕共享状态
if (globalScreenSharingUser.value === room.localParticipant.identity) {
globalScreenSharingUser.value = '';
isGlobalScreenSharing.value = false;
}
if (screenShareVideo.value && screenShareVideo.value.srcObject) { if (screenShareVideo.value && screenShareVideo.value.srcObject) {
screenShareVideo.value.srcObject = null; screenShareVideo.value.srcObject = null;
} }
@@ -490,7 +766,6 @@ function handleActiveSpeakersChanged(speakers) {
} }
}); });
} }
// 处理数据接收事件 // 处理数据接收事件
function handleDataReceived(payload, participant, kind) { function handleDataReceived(payload, participant, kind) {
try { try {
@@ -505,7 +780,6 @@ 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) {
@@ -528,7 +802,6 @@ function updateScreenShareState(participant, track) {
} }
} }
} }
// 参与者管理函数 // 参与者管理函数
function addRemoteParticipant(participant) { function addRemoteParticipant(participant) {
if (!participant || participant.identity === room.localParticipant?.identity) { if (!participant || participant.identity === room.localParticipant?.identity) {
@@ -561,7 +834,6 @@ 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);
@@ -605,7 +877,6 @@ 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;
@@ -618,7 +889,6 @@ 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;
@@ -634,7 +904,6 @@ 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);
@@ -645,7 +914,6 @@ 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);
@@ -653,7 +921,6 @@ 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);
@@ -664,7 +931,6 @@ 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);
@@ -731,7 +997,6 @@ 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) {
@@ -745,7 +1010,6 @@ function attachLocalVideoTrack(track) {
} }
} }
} }
// 媒体控制函数 // 媒体控制函数
async function enableCamera() { async function enableCamera() {
try { try {
@@ -799,8 +1063,7 @@ async function toggleCamera() {
}, 200); }, 200);
} }
} catch (error) { } catch (error) {
console.error('切换摄像头失败:', error); errorHandling(error,'摄像头');
ElMessage.error('切换摄像头失败: ' + error.message);
} }
} }
@@ -839,6 +1102,14 @@ async function toggleMicrophone() {
if (microphoneEnabled.value) { if (microphoneEnabled.value) {
await room.localParticipant.setMicrophoneEnabled(false); await room.localParticipant.setMicrophoneEnabled(false);
microphoneEnabled.value = false; microphoneEnabled.value = false;
// 停止所有音频轨道
const audioPublications = Array.from(room.localParticipant.audioTrackPublications.values());
for (const publication of audioPublications) {
if (publication.track) {
publication.track.stop(); // 停止轨道
}
}
ElMessage.info('麦克风已关闭'); ElMessage.info('麦克风已关闭');
} else { } else {
// 确保有音频输入设备权限 // 确保有音频输入设备权限
@@ -864,32 +1135,80 @@ async function toggleMicrophone() {
}, 500); }, 500);
} }
} catch (error) { } catch (error) {
console.error('切换麦克风失败:', error); errorHandling(error,'麦克风');
ElMessage.error('切换麦克风失败: ' + error.message);
} }
} }
function errorHandling(error,type) {
switch (error.name) {
case 'NotAllowedError':
ElMessage.error('用户拒绝了权限请求');
break;
case 'NotFoundError':
ElMessage.error(`未找到${type}设备`);
break;
case 'NotSupportedError':
ElMessage.error('当前浏览器不支持此功能,请更换其他浏览器');
break;
default:
ElMessage.error('服务错误,请刷新重试');
}
}
async function toggleScreenShare() { async function toggleScreenShare() {
try { try {
if(isWhiteboardActive.value){
ElMessage.error('请先关闭白板');
return;
}
// 检查是否已经有其他用户在共享屏幕
if (!isScreenSharing.value && isGlobalScreenSharing.value && globalScreenSharingUser.value !== hostUid.value) {
ElMessage.error(`当前 ${globalScreenSharingUser.value} 正在共享屏幕,请等待其结束后再共享`);
return;
}
if (isScreenSharing.value) { if (isScreenSharing.value) {
await room.localParticipant.setScreenShareEnabled(false); await room.localParticipant.setScreenShareEnabled(false);
isScreenSharing.value = false; isScreenSharing.value = false;
// 清除全局屏幕共享状态
if (globalScreenSharingUser.value === hostUid.value) {
globalScreenSharingUser.value = '';
isGlobalScreenSharing.value = false;
}
ElMessage.info('屏幕共享已停止'); ElMessage.info('屏幕共享已停止');
} else { } else {
await room.localParticipant.setScreenShareEnabled(true); await room.localParticipant.setScreenShareEnabled(true);
isScreenSharing.value = true; isScreenSharing.value = true;
// 设置全局屏幕共享状态
globalScreenSharingUser.value = hostUid.value;
isGlobalScreenSharing.value = true;
ElMessage.success('屏幕共享已开始'); ElMessage.success('屏幕共享已开始');
} }
} catch (error) { } catch (error) {
console.error('切换屏幕共享失败:', error); errorHandling(error,'屏幕共享');
ElMessage.error('切换屏幕共享失败: ' + error.message);
} }
} }
function handleScreenShareEnded() {
console.log('用户通过浏览器控件停止了屏幕共享');
isScreenSharing.value = false;
ElMessage.info('屏幕共享已停止');
// 移除事件监听器
room.localParticipant.off('screenShareEnded', handleScreenShareEnded);
}
async function joinRoomBtn() { async function joinRoomBtn() {
try { try {
const res = await getRoomToken({max_participants: 20}); const res = await getRoomToken({max_participants: 20});
if(res.meta.code != 200){
ElMessage.error(res.meta.message);
return;
}
const token = res.data.access_token; const token = res.data.access_token;
roomName.value = res.data.room.name
if (!token) { if (!token) {
throw new Error('获取 token 失败'); throw new Error('获取 token 失败');
} }
@@ -906,8 +1225,23 @@ async function joinRoomBtn() {
// 离开会议函数 // 离开会议函数
async function leaveRoom() { async function leaveRoom() {
try { try {
// 如果白板正在运行,先退出白板
if (isWhiteboardActive.value) {
publishWhiteboardMessage(WHITEBOARD_MESSAGE_TYPES.CLOSE, {
action: 'close',
reason: 'host_left',
roomName: roomName.value
});
isWhiteboardActive.value = false;
if (whiteboardRef.value && whiteboardRef.value.cleanup) {
whiteboardRef.value.cleanup();
}
}
// 断开MQTT连接
mqttClient.disconnect();
const res = await exitRoomApi(room.name) const res = await exitRoomApi(room.name)
console.log(res,'离开房间成功')
// 停止屏幕共享(如果正在共享) // 停止屏幕共享(如果正在共享)
if (isScreenSharing.value) { if (isScreenSharing.value) {
await room.localParticipant.setScreenShareEnabled(false); await room.localParticipant.setScreenShareEnabled(false);
@@ -930,13 +1264,16 @@ async function leaveRoom() {
} catch (error) { } catch (error) {
console.error('离开会议失败:', error); console.error('离开会议失败:', error);
ElMessage.error('离开会议失败: ' + error.message); ElMessage.error('离开会议失败');
} }
} }
// 重置房间状态函数 // 重置房间状态函数
function resetRoomState() { function resetRoomState() {
// 重置本地状态 // 重置本地状态
globalScreenSharingUser.value = '';
isGlobalScreenSharing.value = false;
isWhiteboardActive.value = false;
cameraEnabled.value = false; cameraEnabled.value = false;
microphoneEnabled.value = false; microphoneEnabled.value = false;
isScreenSharing.value = false; isScreenSharing.value = false;
@@ -944,6 +1281,9 @@ function resetRoomState() {
status.value = true; status.value = true;
hostUid.value = ''; hostUid.value = '';
// 断开MQTT连接
mqttClient.disconnect();
// 重置远程参与者 // 重置远程参与者
remoteParticipants.value.clear(); remoteParticipants.value.clear();
videoElementsMap.value.clear(); videoElementsMap.value.clear();
@@ -992,6 +1332,11 @@ function resetRoomState() {
// 在组件卸载时也清理资源 // 在组件卸载时也清理资源
onUnmounted(() => { onUnmounted(() => {
if (isWhiteboardActive.value && whiteboardRef.value && whiteboardRef.value.cleanup) {
whiteboardRef.value.cleanup();
}
// 清理MQTT
mqttClient.disconnect();
if (room && room.state === 'connected') { if (room && room.state === 'connected') {
leaveRoom(); leaveRoom();
} }
@@ -999,14 +1344,14 @@ onUnmounted(() => {
onMounted(async () => { onMounted(async () => {
// 确保在连接前请求音频权限 // 确保在连接前请求音频权限
try { // try {
// 预请求音频权限 // // 预请求音频权限
await navigator.mediaDevices.getUserMedia({ audio: true }); // await navigator.mediaDevices.getUserMedia({ audio: true });
console.log('音频权限已获取'); // console.log('音频权限已获取');
} catch (error) { // } catch (error) {
console.warn('音频权限获取失败:', error); // console.warn('音频权限获取失败:', error);
ElMessage.warning('请允许麦克风权限以使用音频功能'); // ElMessage.warning('请允许麦克风权限以使用音频功能');
} // }
if(route.query.type == '1'){ if(route.query.type == '1'){
await joinRoomBtn() await joinRoomBtn()
@@ -1014,23 +1359,47 @@ onMounted(async () => {
// 邀请用户参与房间 // 邀请用户参与房间
await getInvite(room.name,{user_uids:[roomStore.detailUid], participant_role: "participant"}) await getInvite(room.name,{user_uids:[roomStore.detailUid], participant_role: "participant"})
} else { } else {
const userInfo = await userStore.getInfo() // const userInfo = await userStore.getInfo()
hostUid.value = userInfo.uid // hostUid.value = userInfo.uid
// 确保 hostUid 更新到模板
await nextTick();
const res = await getTokenApi(route.query.room_uid) const res = await getTokenApi(route.query.room_uid)
if(res.meta.code == 200){ if(res.meta.code == 200){
const token = res.data.access_token; const token = res.data.access_token;
hostUid.value = res.data.user_uid
await nextTick();
setupRoomListeners(); setupRoomListeners();
await room.connect(wsURL, token, { await room.connect(wsURL, token, {
autoSubscribe: true, autoSubscribe: true,
}); });
}else{
ElMessage.error(res.meta.message);
return;
} }
} }
}); });
</script> </script>
<style> <style>
/* 白板容器样式 */
.whiteboard-container {
width: 100%;
height: 100%;
background: #f8f9fa;
border-radius: 8px;
overflow: hidden;
}
.whiteboard-component {
width: 100%;
height: 100%;
}
/* 退出白板按钮样式 */
.exit-whiteboard-btn {
margin-left: 10px;
padding: 4px 8px;
font-size: 12px;
}
/* 添加音频状态指示器样式 */ /* 添加音频状态指示器样式 */
.audio-indicator { .audio-indicator {
margin-left: 8px; margin-left: 8px;
@@ -1490,6 +1859,9 @@ body {
/* 响应式调整 */ /* 响应式调整 */
@media (max-width: 768px) { @media (max-width: 768px) {
.whiteboard-container {
height: 300px; /* 在移动端限制白板高度 */
}
.form-container { .form-container {
margin: 20px auto; margin: 20px auto;
padding: 24px; padding: 24px;

View File

@@ -305,9 +305,9 @@ const getList = async () => {
* 通讯录 人员信息树 * 通讯录 人员信息树
*/ */
const HandleLoadNode = async (node, resolve) => { const HandleLoadNode = async (node, resolve) => {
if(node.level === 0){ if(node?.level === 0){
loadNode(resolve) loadNode(resolve)
}else if(node.level === 1){ }else if(node?.level === 1){
loadNode(resolve,node.data.directory_uid) loadNode(resolve,node.data.directory_uid)
} }
} }
@@ -317,7 +317,13 @@ 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})
resolve(res.data) if(res.meta.code == 401){
emit('showLogin', true)
return
}else{
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)
@@ -383,7 +389,7 @@ watch(
if (newValue == 1) { if (newValue == 1) {
state.isShow = false state.isShow = false
getList() // getList()
} else { } else {
HandleLoadNode() HandleLoadNode()
} }

View File

@@ -1,266 +1,273 @@
<template> <template>
<div class="app-container" v-loading="load" :element-loading-text="loadText"> <div v-if="showLogin" style="height:100%">
<el-row :gutter="6" style="padding: 0 10px; background: #fefefe"> <!-- 登录界面 -->
<el-col :xs="24" :sm="24" :md="8" :lg="6"> <Login @loginSuccess="handleLoginSuccess" />
<leftTab
@updateDetail="updateDetail"
@updateTab="updateTab"
:loading="!detail?.appId && !detail?.userId && isShow"
/>
</el-col>
<el-col :xs="24" :sm="24" :md="16" :lg="18">
<div
class="right-content"
>
<!-- v-loading="!detail?.appId && !detail?.userId && isShow" -->
<div class="right-content-title">
{{ tabValue == 1 ? '协作信息' : '员工信息' }}
</div>
<div
class="agency-detail-massage-cont right-content-message"
v-if="isShow && tabValue == 1"
>
<div class="agency-detail-cont-item">
<span class="agency-detail-item-title">发起人</span>
<span class="agency-detail-item-content">
{{ detail.initiatorName }}
</span>
</div>
<div class="agency-detail-cont-item">
<span class="agency-detail-item-title">协作时间</span>
<span class="agency-detail-item-content">
<!-- {{
parseTime(detail.beginTime, '{y}年{m}月{d}日') +
' ' +
weekName[new Date(detail.beginTime).getDay()] +
' ' +
parseTime(detail.beginTime, '{h}:{i}')
}} -->
</span>
</div>
<div class="agency-detail-cont-item">
<span class="agency-detail-item-title">成员</span>
<span
class="agency-detail-item-content"
v-if="detail?.assistanceMemberList?.length"
style="display: flex; flex-wrap: wrap"
>
<span
v-for="(items, indexs) in detail.assistanceMemberList"
:key="indexs"
>
{{
items.nickName +
(indexs + 1 == detail.assistanceMemberList.length
? ''
: '、')
}}
</span>
</span>
</div>
<div class="agency-detail-cont-item">
<span class="agency-detail-item-title">协作时长</span>
<span class="agency-detail-item-content">
<!-- {{ getTime() }} -->
</span>
</div>
</div>
<div class="right-content-file" v-if="isShow && tabValue == 1">
<el-row :gutter="15">
<el-col :xs="24" :sm="24" :md="16" :lg="18">
<div class="content-file-video">
<div class="file-top">协作视频</div>
<div class="file-video-bottom">
<!-- autoplay="autoplay" -->
<video
v-if="
detail.remoteVideoFile?.prefix &&
detail.remoteVideoFile.path
"
:src="
detail.remoteVideoFile.prefix +
detail.remoteVideoFile.path
"
id="videoPlayer"
loop
controls
></video>
<div v-else class="video-null">暂无视频</div>
</div>
</div>
</el-col>
<el-col :xs="24" :sm="24" :md="8" :lg="6">
<div>
<div class="file-top">
附件({{
detail?.fileList?.length ? detail.fileList.length : 0
}}
</div>
<div class="content-file-list">
<el-scrollbar
class="file-list"
height="calc(100vh - 380px)"
>
<div
class="file-list-content"
v-if="detail?.fileList?.length"
>
<div
class="file-list-item"
v-for="(item, index) in detail.fileList"
:key="index"
>
<div class="file-list-item-icon"></div>
<div class="file-list-item-text">
<div class="list-item-text text-out-of-hiding-1">
{{ item.name }}
</div>
<el-link
:href="item.prefix + item.path"
type="primary"
target="_blank"
:underline="false"
>
<el-icon
:size="18"
color="#0d74ff"
style="cursor: pointer"
>
<Download />
</el-icon>
</el-link>
<el-icon
:size="18"
color="#FF4646"
style="cursor: pointer"
@click="clickDeleteFile(item)"
>
<Delete />
</el-icon>
</div>
</div>
</div>
</el-scrollbar>
</div>
</div>
</el-col>
</el-row>
</div>
<div
class="message-user"
v-else-if="isShow && tabValue == 2"
style="height: calc(100vh - 90px)"
>
<div class="message-user-card">
<div class="user-card-nickName">
<img v-if="detail.avatar" :src="detail.avatar" />
<img v-else src="@/assets/images/profile.jpg" />
<span>{{ detail.nickName || detail.name || '暂无信息' }}</span>
</div>
<div class="user-card-information">
<div class="user-information-item">
<div class="user-information-title">
<img src="@/assets/images/user-information1.png" alt="" />
<span>性别</span>
</div>
<div class="user-information-text">
<!-- <dict-tag
v-if="detail.sex"
:options="sys_user_sex"
:value="detail.sex"
/>
<div v-else>
{{ '暂无' }}
</div> -->
</div>
</div>
<div class="user-information-item">
<div class="user-information-title">
<img src="@/assets/images/user-information2.png" alt="" />
<span>手机号</span>
</div>
<div class="user-information-text">
{{ detail.phonenumber || '暂无' }}
</div>
</div>
<div class="user-information-item">
<div class="user-information-title">
<img src="@/assets/images/user-information3.png" alt="" />
<span>邮箱</span>
</div>
<div class="user-information-text">
{{ detail.email || '暂无' }}
</div>
</div>
<div class="user-information-item">
<div class="user-information-title">
<img src="@/assets/images/user-information4.png" alt="" />
<span>所属部门</span>
</div>
<div class="user-information-text">
{{ detail.organization || '暂无' }}
</div>
</div>
</div>
<div class="user-card-btn">
<el-button type="primary" @click="clickInitiate">
发起协作
</el-button>
</div>
</div>
</div>
<div v-else class="message-null">
<el-empty description="暂无内容" />
</div>
</div>
</el-col>
</el-row>
<el-dialog
v-model="inviteDialog"
title="远程协作"
width="400px"
:close-on-press-escape="false"
:close-on-click-modal="false"
:show-close="false"
>
<div>
<div style="width: 100%; margin-bottom: 30px; font-size: 20px">
"
{{
socketInformation.room_name
? socketInformation.room_name
: ''
}}
" 邀请您参加远程协作
</div>
<div style="text-align: center">
<el-button
size="large"
type="danger"
style="font-size: 16px"
@click="clickRefuseJoin"
>
拒 绝
</el-button>
<el-button
size="large"
type="primary"
style="font-size: 16px"
@click="clickJoin"
>
加 入
</el-button>
</div>
</div>
</el-dialog>
</div> </div>
<div v-else>
<div class="app-container" v-loading="load" :element-loading-text="loadText">
<el-row :gutter="6" style="padding: 0 10px; background: #fefefe">
<el-col :xs="24" :sm="24" :md="8" :lg="6">
<leftTab
@updateDetail="updateDetail"
@updateTab="updateTab"
@showLogin="showLoginHandle"
:loading="!detail?.appId && !detail?.userId && isShow"
/>
</el-col>
<el-col :xs="24" :sm="24" :md="16" :lg="18">
<div
class="right-content"
>
<!-- v-loading="!detail?.appId && !detail?.userId && isShow" -->
<div class="right-content-title">
{{ tabValue == 1 ? '协作信息' : '员工信息' }}
</div>
<div
class="agency-detail-massage-cont right-content-message"
v-if="isShow && tabValue == 1"
>
<div class="agency-detail-cont-item">
<span class="agency-detail-item-title">发起人</span>
<span class="agency-detail-item-content">
{{ detail.initiatorName }}
</span>
</div>
<div class="agency-detail-cont-item">
<span class="agency-detail-item-title">协作时间</span>
<span class="agency-detail-item-content">
<!-- {{
parseTime(detail.beginTime, '{y}年{m}月{d}日') +
' ' +
weekName[new Date(detail.beginTime).getDay()] +
' ' +
parseTime(detail.beginTime, '{h}:{i}')
}} -->
</span>
</div>
<div class="agency-detail-cont-item">
<span class="agency-detail-item-title">成员</span>
<span
class="agency-detail-item-content"
v-if="detail?.assistanceMemberList?.length"
style="display: flex; flex-wrap: wrap"
>
<span
v-for="(items, indexs) in detail.assistanceMemberList"
:key="indexs"
>
{{
items.nickName +
(indexs + 1 == detail.assistanceMemberList.length
? ''
: '、')
}}
</span>
</span>
</div>
<div class="agency-detail-cont-item">
<span class="agency-detail-item-title">协作时长</span>
<span class="agency-detail-item-content">
<!-- {{ getTime() }} -->
</span>
</div>
</div>
<div class="right-content-file" v-if="isShow && tabValue == 1">
<el-row :gutter="15">
<el-col :xs="24" :sm="24" :md="16" :lg="18">
<div class="content-file-video">
<div class="file-top">协作视频</div>
<div class="file-video-bottom">
<!-- autoplay="autoplay" -->
<video
v-if="
detail.remoteVideoFile?.prefix &&
detail.remoteVideoFile.path
"
:src="
detail.remoteVideoFile.prefix +
detail.remoteVideoFile.path
"
id="videoPlayer"
loop
controls
></video>
<div v-else class="video-null">暂无视频</div>
</div>
</div>
</el-col>
<el-col :xs="24" :sm="24" :md="8" :lg="6">
<div>
<div class="file-top">
附件({{
detail?.fileList?.length ? detail.fileList.length : 0
}}
</div>
<div class="content-file-list">
<el-scrollbar
class="file-list"
height="calc(100vh - 380px)"
>
<div
class="file-list-content"
v-if="detail?.fileList?.length"
>
<div
class="file-list-item"
v-for="(item, index) in detail.fileList"
:key="index"
>
<div class="file-list-item-icon"></div>
<div class="file-list-item-text">
<div class="list-item-text text-out-of-hiding-1">
{{ item.name }}
</div>
<el-link
:href="item.prefix + item.path"
type="primary"
target="_blank"
:underline="false"
>
<el-icon
:size="18"
color="#0d74ff"
style="cursor: pointer"
>
<Download />
</el-icon>
</el-link>
<el-icon
:size="18"
color="#FF4646"
style="cursor: pointer"
@click="clickDeleteFile(item)"
>
<Delete />
</el-icon>
</div>
</div>
</div>
</el-scrollbar>
</div>
</div>
</el-col>
</el-row>
</div>
<div
class="message-user"
v-else-if="isShow && tabValue == 2"
style="height: calc(100vh - 90px)"
>
<div class="message-user-card">
<div class="user-card-nickName">
<img v-if="detail.avatar" :src="detail.avatar" />
<img v-else src="@/assets/images/profile.jpg" />
<span>{{ detail.nickName || detail.name || '暂无信息' }}</span>
</div>
<div class="user-card-information">
<div class="user-information-item">
<div class="user-information-title">
<img src="@/assets/images/user-information1.png" alt="" />
<span>性别</span>
</div>
<div class="user-information-text">
<!-- <dict-tag
v-if="detail.sex"
:options="sys_user_sex"
:value="detail.sex"
/>
<div v-else>
{{ '暂无' }}
</div> -->
</div>
</div>
<div class="user-information-item">
<div class="user-information-title">
<img src="@/assets/images/user-information2.png" alt="" />
<span>手机号</span>
</div>
<div class="user-information-text">
{{ detail.phonenumber || '暂无' }}
</div>
</div>
<div class="user-information-item">
<div class="user-information-title">
<img src="@/assets/images/user-information3.png" alt="" />
<span>邮箱</span>
</div>
<div class="user-information-text">
{{ detail.email || '暂无' }}
</div>
</div>
<div class="user-information-item">
<div class="user-information-title">
<img src="@/assets/images/user-information4.png" alt="" />
<span>所属部门</span>
</div>
<div class="user-information-text">
{{ detail.organization || '暂无' }}
</div>
</div>
</div>
<div class="user-card-btn">
<el-button type="primary" @click="clickInitiate">
发起协作
</el-button>
</div>
</div>
</div>
<div v-else class="message-null">
<el-empty description="暂无内容" />
</div>
</div>
</el-col>
</el-row>
<el-dialog
v-model="inviteDialog"
title="远程协作"
width="400px"
:close-on-press-escape="false"
:close-on-click-modal="false"
:show-close="false"
>
<div>
<div style="width: 100%; margin-bottom: 30px; font-size: 20px">
"
{{
socketInformation.room_name
? socketInformation.room_name
: ''
}}
" 邀请您参加远程协作
</div>
<div style="text-align: center">
<el-button
size="large"
type="danger"
style="font-size: 16px"
@click="clickRefuseJoin"
>
拒 绝
</el-button>
<el-button
size="large"
type="primary"
style="font-size: 16px"
@click="clickJoin"
>
加 入
</el-button>
</div>
</div>
</el-dialog>
</div>
</div>
</template> </template>
<script setup> <script setup>
import { onActivated, onMounted, reactive, toRefs, watch, getCurrentInstance } from 'vue' import { onActivated, onMounted, reactive, toRefs, watch, getCurrentInstance ,ref} from 'vue'
import leftTab from './components/leftTab/index.vue' import leftTab from './components/leftTab/index.vue'
import { getInfo } from '@/api/login.js' import { getInfo } from '@/api/login.js'
import { getStatusApi } from '@/api/conferencingRoom.js' import { getStatusApi } from '@/api/conferencingRoom.js'
@@ -269,6 +276,8 @@ import { useRoute, useRouter } from 'vue-router'
import { useRoomStore } from '@/stores/modules/room' import { useRoomStore } from '@/stores/modules/room'
import useUserStore from '@/stores/modules/user' import useUserStore from '@/stores/modules/user'
import { mqttClient } from "@/utils/mqtt.js"; import { mqttClient } from "@/utils/mqtt.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()
@@ -285,6 +294,17 @@ 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); // 是否显示登录页面
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
@@ -294,7 +314,7 @@ const isEmptyObject = (obj) => {
const clickInitiate = () => { const clickInitiate = () => {
let userData = null let userData = null
try { try {
userData = JSON.parse(localStorage.getItem('userData')) || null userData = JSON.parse(sessionStorage.getItem('userData')) || null
} catch (e) { } catch (e) {
console.error('解析 userData 失败:', e) console.error('解析 userData 失败:', e)
} }
@@ -404,7 +424,6 @@ const clickRefuseJoin = async () => {
const processingSocket = (message) => { const processingSocket = (message) => {
if (message) { if (message) {
state.socketInformation = JSON.parse(message) state.socketInformation = JSON.parse(message)
console.log(state.socketInformation,'state.socketInformation')
state.inviteDialog = true state.inviteDialog = true
showNotification(state.socketInformation) showNotification(state.socketInformation)
} }
@@ -430,6 +449,11 @@ 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}`;

View File

@@ -27,7 +27,6 @@ import { ref, nextTick, onUnmounted, onMounted } from "vue";
import { ElLoading, ElMessage } from "element-plus"; import { ElLoading, ElMessage } from "element-plus";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { useRoute } from "vue-router"; 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";
@@ -35,6 +34,17 @@ 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";
const props = defineProps({
roomId: {
type: String,
default: '',
},
userId: {
type: String,
default: '',
},
})
const showLogin = ref(false); // 是否显示登录页面 const showLogin = ref(false); // 是否显示登录页面
const hasJoined = ref(false); // 是否加入白板 const hasJoined = ref(false); // 是否加入白板
const canvas = ref(null); const canvas = ref(null);
@@ -80,47 +90,21 @@ function initWhiteboard() {
console.error("⚠️ 找不到 canvas 元素"); console.error("⚠️ 找不到 canvas 元素");
return; return;
} }
// 设置画布宽高保持16:9
const { width, height } = getCanvasSize(el.parentElement);
Object.assign(el, { width, height });
Object.assign(el.style, {
width: `${width}px`,
height: `${height}px`,
border: "2px solid #000",
});
// 初始化画布 // 初始化画布
canvas.value = new Canvas("whiteboard"); canvas.value = new Canvas("whiteboard");
// 获取房间号 // 获取房间号
const roomUid = route.query.room_uid || "default-room"; const roomUid = route.query.room_uid || props.roomId || "default-room";
// 初始化多人同步 // 初始化多人同步
WhiteboardSync.init(canvas.value, roomUid); WhiteboardSync.init(canvas.value, roomUid);
} }
/** 计算画布大小保持16:9 */
function getCanvasSize(container) {
const containerWidth = container.offsetWidth;
const containerHeight = container.offsetHeight;
let width = containerWidth;
let height = Math.floor((width * 9) / 16);
if (height > containerHeight) {
height = containerHeight;
width = Math.floor((height * 16) / 9);
}
return { width, height };
}
onMounted(async () => { onMounted(async () => {
try { try {
showLogin.value = false; showLogin.value = false;
const res = await getInfo("self"); const res = await getInfo("self");
console.log(res, "用户信息校验")
if (res.meta.code === 401) { if (res.meta.code === 401) {
showLogin.value = true; showLogin.value = true;
} else { } else {
@@ -143,8 +127,8 @@ onUnmounted(() => {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
width: 100vw; width: 100%;
height: 100vh; height: 100%;
background: #fff; background: #fff;
position: relative; position: relative;
} }
@@ -159,9 +143,7 @@ onUnmounted(() => {
/* 白板容器 */ /* 白板容器 */
.whiteboard-wrapper { .whiteboard-wrapper {
position: relative; position: relative;
width: 90vw; width: 72vw;
max-width: 1280px;
aspect-ratio: 16 / 9;
box-sizing: border-box; box-sizing: border-box;
display: flex; display: flex;
justify-content: center; justify-content: center;

View File

@@ -0,0 +1,185 @@
<template>
<div class="wrapper-content">
<div v-if="showLogin">
<!-- 登录界面 -->
<Login @loginSuccess="handleLoginSuccess" />
</div>
<div v-else>
<!-- 未加入时显示按钮 -->
<div v-if="!hasJoined" class="login-button-container">
<el-button type="primary" size="large" round plain @click="joinWhiteboard">
加入互动画板
</el-button>
</div>
<!-- 已加入时显示白板 -->
<div v-else class="whiteboard-wrapper">
<ToolBox v-if="canvas" class="toolbox" :canvas="canvas" />
<canvas id="whiteboard" class="whiteboard-canvas"></canvas>
</div>
</div>
</div>
</template>
<script setup>
import { ref, nextTick, onUnmounted, onMounted } from "vue";
import { ElLoading, ElMessage } from "element-plus";
import { v4 as uuidv4 } from "uuid";
import { useRoute } from "vue-router";
import { mqttClient } from "@/utils/mqtt";
import { WhiteboardSync } from "@/utils/whiteboardSync";
import ToolBox from "@/components/ToolBox/index.vue";
import Login from "@/components/Login/index.vue";
import Canvas from "@/core/index.js";
import { getInfo } from "@/api/login";
const showLogin = ref(false); // 是否显示登录页面
const hasJoined = ref(false); // 是否加入白板
const canvas = ref(null);
const route = useRoute();
/** 进入白板 */
async function joinWhiteboard() {
const loading = ElLoading.service({
lock: true,
text: "正在进入互动画板...",
background: "rgba(0, 0, 0, 0.4)",
});
try {
const clientId = `whiteboard-${uuidv4()}`;
await mqttClient.connect(clientId);
console.log("✅ 已连接 MQTT:", clientId);
hasJoined.value = true;
// 等待 DOM 更新后再初始化画布
await nextTick();
initWhiteboard();
ElMessage.success("已进入互动画板 🎉");
} catch (err) {
console.error("❌ 连接失败:", err);
ElMessage.error("连接白板失败,请重试");
} finally {
loading.close();
}
}
/** 登录成功回调 */
function handleLoginSuccess() {
showLogin.value = false;
}
/** 初始化白板 */
function initWhiteboard() {
const el = document.getElementById("whiteboard");
if (!el) {
console.error("⚠️ 找不到 canvas 元素");
return;
}
// 设置画布宽高保持16:9
const { width, height } = getCanvasSize(el.parentElement);
Object.assign(el, { width, height });
Object.assign(el.style, {
width: `${width}px`,
height: `${height}px`,
border: "2px solid #000",
});
// 初始化画布
canvas.value = new Canvas("whiteboard");
// 获取房间号
const roomUid = route.query.room_uid || "default-room";
// 初始化多人同步
WhiteboardSync.init(canvas.value, roomUid);
}
/** 计算画布大小保持16:9 */
function getCanvasSize(container) {
const containerWidth = container.offsetWidth;
const containerHeight = container.offsetHeight;
let width = containerWidth;
let height = Math.floor((width * 9) / 16);
if (height > containerHeight) {
height = containerHeight;
width = Math.floor((height * 16) / 9);
}
return { width, height };
}
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);
}
});
onUnmounted(() => {
if (canvas.value) canvas.value.destroy();
});
</script>
<style scoped>
/* 外层容器全屏居中 */
.wrapper-content {
display: flex;
justify-content: center;
align-items: center;
width: 100vw;
height: 100vh;
background: #fff;
position: relative;
}
/* 登录按钮容器居中 */
.login-button-container {
display: flex;
justify-content: center;
align-items: center;
}
/* 白板容器 */
.whiteboard-wrapper {
position: relative;
width: 90vw;
max-width: 1280px;
aspect-ratio: 16 / 9;
box-sizing: border-box;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
}
/* 画布占满白板容器 */
.whiteboard-canvas {
width: 100%;
height: 100%;
display: block;
}
/* 工具栏左侧垂直居中 */
.toolbox {
position: absolute;
top: 50%;
left: 10px;
transform: translateY(-50%);
z-index: 1000;
}
</style>

View File

@@ -40,8 +40,10 @@
import useUserStore from '@/stores/modules/user' import useUserStore from '@/stores/modules/user'
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 } from 'element-plus' import { ElNotification,ElMessage } from 'element-plus'
import { getInfo } from "@/api/login"; import { getInfo } from "@/api/login";
import CryptoJS from 'crypto-js';
import { useMeterStore } from '@/stores/modules/meter'
const userStore = useUserStore() const userStore = useUserStore()
const router = useRouter() const router = useRouter()
@@ -49,6 +51,9 @@ const route = useRoute()
const { proxy } = getCurrentInstance() const { proxy } = getCurrentInstance()
const showLogin = ref(false) const showLogin = ref(false)
const meterStore = useMeterStore()
const redirect = ref(undefined); const redirect = ref(undefined);
const loginView = ref(false) const loginView = ref(false)
@@ -73,14 +78,30 @@ function handleLogin() {
proxy.$refs.loginRef.validate((valid) => { proxy.$refs.loginRef.validate((valid) => {
if (valid) { if (valid) {
loading.value = true loading.value = true
if(!localStorage?.getItem('UDID')){
ElMessage({
message: '服务错误,请刷新页面',
type: 'warning',
})
return
}
const secretKey = ((loginForm.value.username + localStorage?.getItem('UDID')).toLowerCase()).replaceAll('-', ''); // 用户名+UDID(32位16进制全小写)
const randomChars = generateRandomChars(6);
const message = `Gx${randomChars}${loginForm.value.password}`;
const ciphertext = CryptoJS.Blowfish.encrypt(message, secretKey).toString();
console.log(secretKey,message,ciphertext)
// 调用action的登录方法 // 调用action的登录方法
userStore userStore
.login({ .login({
password: loginForm.value.password, // password: loginForm.value.password,
password: ciphertext,
username: loginForm.value.username, username: loginForm.value.username,
}) })
.then(async (res) => { .then(async (res) => {
const userInfo = JSON.parse(localStorage.getItem('userData')) const userInfo = JSON.parse(sessionStorage.getItem('userData'))
router.push({ router.push({
path: '/coordinate', path: '/coordinate',
}) })
@@ -99,6 +120,16 @@ function handleLogin() {
requestNotificationPermission() requestNotificationPermission()
} }
// 生成随机字符串
function generateRandomChars(length) {
const chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
/** /**
* @description 请求浏览器的通知权限 * @description 请求浏览器的通知权限
* @returns {*} * @returns {*}
@@ -113,6 +144,7 @@ function requestNotificationPermission() {
onMounted(async () => { onMounted(async () => {
meterStore.initUdid()
try { try {
loginView.value = true loginView.value = true
const res = await getInfo("self"); const res = await getInfo("self");
@@ -125,8 +157,8 @@ onMounted(async () => {
// query: { room_uid: 'nxst-ok4j' } // query: { room_uid: 'nxst-ok4j' }
// }) // })
router.push({ router.push({
path: '/coordinate', path: '/coordinate',
}) })
} }
loginView.value = false loginView.value = false
} catch (err) { } catch (err) {