Compare commits

...

10 Commits

Author SHA1 Message Date
leilei
a894367dcc feat:更新协作信息 2025-11-18 17:42:15 +08:00
leilei
ca93e91326 feat:更新白板 2025-11-11 18:01:59 +08:00
leilei
6fbe53009c feat:修改文件上传,白板关闭不影响其他用户等 2025-11-10 17:55:52 +08:00
leilei
df359d01cc feat:修改存在的问题 2025-11-03 09:03:32 +08:00
leilei
528170fe2f feat:更新激光笔功能 2025-10-23 10:22:56 +08:00
leilei
f19ab86ada feat:集成Sentry 2025-10-21 15:23:39 +08:00
leilei
2b8e7349e6 feat:更新画板工具栏显示问题 2025-10-21 11:26:12 +08:00
leilei
e0001ba430 feat:更新侧边导航,修改密码等 2025-10-20 17:41:54 +08:00
leilei
db72ea9f33 feat:跟新密码加密 共享白板功能 2025-10-11 17:28:20 +08:00
leilei
e429e4286a feat:更新协同合作 2025-09-30 17:58:47 +08:00
92 changed files with 36520 additions and 1822 deletions

View File

@@ -6,7 +6,8 @@ VITE_APP_PORT = 80
VITE_BASE_PATH = '/' VITE_BASE_PATH = '/'
# 应用模板管理后台/生产环境 # 应用模板管理后台/生产环境
VITE_APP_BASE_API = 'https://xsynergy.gxtech.ltd' # VITE_APP_BASE_API = 'https://xsynergy.gxtech.ltd'
VITE_APP_BASE_API = ''
# 公网为“web” 私有化为不跳转为“private” 私有化跳转为“skip” # 公网为“web” 私有化为不跳转为“private” 私有化跳转为“skip”
VITE_APP_COOPERATION_TYPE = 'skip' VITE_APP_COOPERATION_TYPE = 'skip'

BIN
dist.zip Normal file

Binary file not shown.

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<link rel="icon" href="/favicon.ico"> <link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>互动白板</title> <title>xSynergy远程协作系统</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

6966
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,9 +11,12 @@
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.0.10", "@element-plus/icons-vue": "^2.0.10",
"@msgpack/msgpack": "^3.1.2", "@msgpack/msgpack": "^3.1.2",
"@sentry/vue": "^10.20.0",
"@vueuse/core": "^9.5.0",
"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",
@@ -23,14 +26,20 @@
"postcss": "^8.5.6", "postcss": "^8.5.6",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"vite-plugin-compression": "^0.5.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": {
"@vitejs/plugin-vue": "^5.1.4", "@vitejs/plugin-vue": "^5.1.4",
"@vue/compiler-sfc": "^3.2.45",
"sass": "^1.56.1", "sass": "^1.56.1",
"unplugin-auto-import": "^20.1.0", "unplugin-auto-import": "^0.11.4",
"unplugin-vue-components": "^29.0.0", "unplugin-vue-components": "^29.0.0",
"unplugin-vue-setup-extend-plus": "^0.4.9",
"vite": "^5.4.10" "vite": "^5.4.10"
} }
} }

View File

@@ -1,6 +1,15 @@
<script setup> <script setup>
import { RouterLink, RouterView } from 'vue-router' import { RouterLink, RouterView } from 'vue-router'
import { onMounted } from 'vue'
import { useUserStore } from '@/stores/modules/user.js'
// 在根组件预先初始化用户 store
onMounted(() => {
try {
const userStore = useUserStore()
} catch (error) {
console.warn('App.vue: Pinia 初始化中...', error)
}
})
</script> </script>
<template> <template>

View File

@@ -64,3 +64,77 @@ export function exitRoomApi(room_uid) {
}) })
} }
//获取当前房间信息
export function getRoomInfoApi(room_uid) {
return request({
url: `/api/v1/meeting/${room_uid}/info`,
method: 'get',
})
}
//获取文件列表
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',
})
}
//获取当前会议中所有参与者信息
export function getParticipantsApi(roomId) {
return request({
url: `/api/v1/meeting/${ roomId }/participants`,
method: 'get',
})
}
// 获取当前房间中视频
export function getvideoUrlApi(room_uid) {
return request({
url: `/api/v1/room/${ room_uid }/recordings`,
method: 'get',
})
}

View File

@@ -16,4 +16,23 @@ export function getDirectoriesUsers(directory_uuid,data) {
method: 'get', method: 'get',
params:data params:data
}) })
} }
//获取参与者历史参会记录
export function getParticipantsHistoryApi(userId,data) {
return request({
url: `/api/v1/rooms/${ userId }/participants/history`,
method: 'get',
params:data
})
}
// 获取用户详细信息
export function getInfo(userUid,type) {
return request({
url: `/api/v1/auth/users/${userUid}`,
method: 'get',
params:type
})
}

View File

@@ -34,3 +34,28 @@ export function logout() {
method: 'post' method: 'post'
}) })
} }
//修改密码
export function changePwd(oldPassword, newPassword) {
const params = {
old_password: oldPassword,
new_password: newPassword
}
return request({
url: '/api/v1/auth/change-password',
method: 'post',
data: params
})
}
//验证密码强度
export function checkPwdStrength(password) {
const params = {
password: password
}
return request({
url: '/api/v1/auth/check-password-strength',
method: 'post',
data: params
})
}

9
src/api/menu.js Normal file
View File

@@ -0,0 +1,9 @@
import request from '@/utils/request'
// 获取路由
export const getRouters = () => {
return request({
url: '/system/menu/getRouters',
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: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="52px" height="45px" viewBox="0 0 52 45" version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<filter x="-9.4%" y="-6.2%" width="118.8%" height="122.5%" filterUnits="objectBoundingBox" id="filter-1">
<feOffset dx="0" dy="1" in="SourceAlpha" result="shadowOffsetOuter1"></feOffset>
<feGaussianBlur stdDeviation="1" in="shadowOffsetOuter1" result="shadowBlurOuter1"></feGaussianBlur>
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0" type="matrix" in="shadowBlurOuter1" result="shadowMatrixOuter1"></feColorMatrix>
<feMerge>
<feMergeNode in="shadowMatrixOuter1"></feMergeNode>
<feMergeNode in="SourceGraphic"></feMergeNode>
</feMerge>
</filter>
<rect id="path-2" x="0" y="0" width="48" height="40" rx="4"></rect>
<filter x="-4.2%" y="-2.5%" width="108.3%" height="110.0%" filterUnits="objectBoundingBox" id="filter-4">
<feOffset dx="0" dy="1" in="SourceAlpha" result="shadowOffsetOuter1"></feOffset>
<feGaussianBlur stdDeviation="0.5" in="shadowOffsetOuter1" result="shadowBlurOuter1"></feGaussianBlur>
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0" type="matrix" in="shadowBlurOuter1"></feColorMatrix>
</filter>
</defs>
<g id="配置面板" width="48" height="40" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="setting-copy-2" width="48" height="40" transform="translate(-1190.000000, -136.000000)">
<g id="Group-8" width="48" height="40" transform="translate(1167.000000, 0.000000)">
<g id="Group-5-Copy-5" filter="url(#filter-1)" transform="translate(25.000000, 137.000000)">
<mask id="mask-3" fill="white">
<use xlink:href="#path-2"></use>
</mask>
<g id="Rectangle-18">
<use fill="black" fill-opacity="1" filter="url(#filter-4)" xlink:href="#path-2"></use>
<use fill="#F0F2F5" fill-rule="evenodd" xlink:href="#path-2"></use>
</g>
<rect id="Rectangle-11" fill="#FFFFFF" mask="url(#mask-3)" x="0" y="0" width="48" height="10"></rect>
<rect id="Rectangle-18" fill="#303648" mask="url(#mask-3)" x="0" y="0" width="16" height="40"></rect>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 861 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 744 B

View File

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="52px" height="45px" viewBox="0 0 52 45" version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<filter x="-9.4%" y="-6.2%" width="118.8%" height="122.5%" filterUnits="objectBoundingBox" id="filter-1">
<feOffset dx="0" dy="1" in="SourceAlpha" result="shadowOffsetOuter1"></feOffset>
<feGaussianBlur stdDeviation="1" in="shadowOffsetOuter1" result="shadowBlurOuter1"></feGaussianBlur>
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0" type="matrix" in="shadowBlurOuter1" result="shadowMatrixOuter1"></feColorMatrix>
<feMerge>
<feMergeNode in="shadowMatrixOuter1"></feMergeNode>
<feMergeNode in="SourceGraphic"></feMergeNode>
</feMerge>
</filter>
<rect id="path-2" x="0" y="0" width="48" height="40" rx="4"></rect>
<filter x="-4.2%" y="-2.5%" width="108.3%" height="110.0%" filterUnits="objectBoundingBox" id="filter-4">
<feOffset dx="0" dy="1" in="SourceAlpha" result="shadowOffsetOuter1"></feOffset>
<feGaussianBlur stdDeviation="0.5" in="shadowOffsetOuter1" result="shadowBlurOuter1"></feGaussianBlur>
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0" type="matrix" in="shadowBlurOuter1"></feColorMatrix>
</filter>
</defs>
<g id="配置面板" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="setting-copy-2" transform="translate(-1254.000000, -136.000000)">
<g id="Group-8" transform="translate(1167.000000, 0.000000)">
<g id="Group-5" filter="url(#filter-1)" transform="translate(89.000000, 137.000000)">
<mask id="mask-3" fill="white">
<use xlink:href="#path-2"></use>
</mask>
<g id="Rectangle-18">
<use fill="black" fill-opacity="1" filter="url(#filter-4)" xlink:href="#path-2"></use>
<use fill="#F0F2F5" fill-rule="evenodd" xlink:href="#path-2"></use>
</g>
<rect id="Rectangle-18" fill="#FFFFFF" mask="url(#mask-3)" x="0" y="0" width="16" height="40"></rect>
<rect id="Rectangle-11" fill="#FFFFFF" mask="url(#mask-3)" x="0" y="0" width="48" height="10"></rect>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@@ -1,4 +1,5 @@
$menu-bg-color: #8290f0; // $menu-bg-color: #8290f0;
$menu-bg-color: #434343;
#app { #app {
.main-container { .main-container {
@@ -92,12 +93,12 @@ $menu-bg-color: #8290f0;
.sub-menu-title-noDropdown, .sub-menu-title-noDropdown,
.el-sub-menu__title { .el-sub-menu__title {
&:hover { &:hover {
background-color: #c4cbf3 !important; background-color: #e5e5e5 !important;
} }
} }
.el-menu-item:hover { .el-menu-item:hover {
background-color: #c4cbf3 !important; background-color: #e5e5e5 !important;
} }
& .theme-dark .is-active>.el-sub-menu__title { & .theme-dark .is-active>.el-sub-menu__title {
@@ -109,7 +110,7 @@ $menu-bg-color: #8290f0;
min-width: $base-sidebar-width !important; min-width: $base-sidebar-width !important;
&:hover { &:hover {
background-color: #c4cbf3 !important; background-color: #e5e5e5 !important;
} }
} }

View File

@@ -0,0 +1,81 @@
<template>
<el-breadcrumb class="app-breadcrumb" separator="/">
<transition-group name="breadcrumb">
<el-breadcrumb-item v-for="(item, index) in levelList" :key="item.path">
<span
v-if="item.redirect === 'noRedirect' || index == levelList.length - 1"
class="no-redirect"
>
{{ item.meta.title }}
</span>
<a v-else @click.prevent="handleLink(item)">
{{ item.meta.title }}
</a>
</el-breadcrumb-item>
</transition-group>
</el-breadcrumb>
</template>
<script setup>
const route = useRoute()
const router = useRouter()
const levelList = ref([])
function getBreadcrumb() {
// only show routes with meta.title
let matched = route.matched.filter((item) => item.meta && item.meta.title)
const first = matched[0]
// 判断是否为首页
if (!isDashboard(first)) {
matched = matched
}
levelList.value = matched.filter(
(item) => item.meta && item.meta.title && item.meta.breadcrumb !== false
)
}
function isDashboard(route) {
const name = route && route.name
if (!name) {
return false
}
return name.trim() === 'Index'
}
function handleLink(item) {
const { redirect, path } = item
if (redirect) {
router.push(redirect)
return
}
router.push(path)
}
watchEffect(() => {
// if you go to the redirect page, do not update the breadcrumbs
if (route.path.startsWith('/redirect/')) {
return
}
getBreadcrumb()
})
getBreadcrumb()
</script>
<style lang="scss" scoped>
.app-breadcrumb.el-breadcrumb {
display: inline-flex;
align-items: center;
height: 56px;
font-size: 16px;
line-height: 1;
margin-left: 8px;
.no-redirect {
color: #333333;
font-weight: 600;
cursor: text;
}
}
::v-deep .el-breadcrumb__separator {
color: #333333;
}
</style>

View File

@@ -0,0 +1,41 @@
<template>
<div style="padding: 0 15px;" @click="toggleClick">
<svg
:class="{'is-active':isActive}"
class="hamburger"
viewBox="0 0 1024 1024"
xmlns="http://www.w3.org/2000/svg"
width="64"
height="64"
>
<path d="M408 442h480c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8H408c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8zm-8 204c0 4.4 3.6 8 8 8h480c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8H408c-4.4 0-8 3.6-8 8v56zm504-486H120c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zm0 632H120c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zM142.4 642.1L298.7 519a8.84 8.84 0 0 0 0-13.9L142.4 381.9c-5.8-4.6-14.4-.5-14.4 6.9v246.3a8.9 8.9 0 0 0 14.4 7z" />
</svg>
</div>
</template>
<script setup>
defineProps({
isActive: {
type: Boolean,
default: false
}
})
const emit = defineEmits()
const toggleClick = () => {
emit('toggleClick');
}
</script>
<style scoped>
.hamburger {
display: inline-block;
vertical-align: middle;
width: 20px;
height: 20px;
}
.hamburger.is-active {
transform: rotate(180deg);
}
</style>

View File

@@ -26,15 +26,18 @@
</template> </template>
<script setup> <script setup>
import useUserStore from '@/stores/modules/user' import { useUserStore } from '@/stores/modules/user.js'
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,13 +113,17 @@ function requestNotificationPermission() {
} }
} }
onMounted(() => {
meterStore.initUdid()
})
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.wrapper-content { .wrapper-content {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
height: 100%; height: calc(100vh - 60px);
padding: 0 40px; padding: 0 40px;
background: linear-gradient(135deg, #f5f7fa 0%, #eef1f6 100%); background: linear-gradient(135deg, #f5f7fa 0%, #eef1f6 100%);
} }
@@ -160,6 +194,7 @@ function requestNotificationPermission() {
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

@@ -0,0 +1,3 @@
<template >
<router-view />
</template>

View File

@@ -0,0 +1,210 @@
<template>
<el-menu
:default-active="activeMenu"
mode="horizontal"
@select="handleSelect"
:ellipsis="false"
>
<template v-for="(item, index) in topMenus">
<el-menu-item :style="{'--theme': theme}" :index="item.path" :key="index" v-if="index < visibleNumber"
><svg-icon
v-if="item.meta && item.meta.icon && item.meta.icon !== '#'"
:icon-class="item.meta.icon"
/>
{{ item.meta.title }}</el-menu-item
>
</template>
<!-- 顶部菜单超出数量折叠 -->
<el-sub-menu :style="{'--theme': theme}" index="more" v-if="topMenus.length > visibleNumber">
<template #title>更多菜单</template>
<template v-for="(item, index) in topMenus">
<el-menu-item
:index="item.path"
:key="index"
v-if="index >= visibleNumber"
><svg-icon :icon-class="item.meta.icon" />
{{ item.meta.title }}</el-menu-item
>
</template>
</el-sub-menu>
</el-menu>
</template>
<script setup>
import { constantRoutes } from "@/router/index.js"
import { isHttp } from '@/utils/validate.js'
import { useAppStore } from '@/stores/modules/app.js'
import { useSettingsStore } from '@/stores/modules/settings.js'
// 顶部栏初始数
const visibleNumber = ref(null);
// 当前激活菜单的 index
const currentIndex = ref(null);
// 隐藏侧边栏路由
const hideList = ['/index', '/user/profile'];
const appStore = useAppStore()
const settingsStore = useSettingsStore()
const route = useRoute();
const router = useRouter();
// 主题颜色
const theme = computed(() => settingsStore.theme);
// 顶部显示菜单
const topMenus = computed(() => {
let topMenus = [];
routers.value.map((menu) => {
if (menu.hidden !== true) {
// 兼容顶部栏一级菜单内部跳转
if (menu.path === "/") {
topMenus.push(menu.children[0]);
} else {
topMenus.push(menu);
}
}
})
return topMenus;
})
// 设置子路由
const childrenMenus = computed(() => {
let childrenMenus = [];
routers.value.map((router) => {
for (let item in router.children) {
if (router.children[item].parentPath === undefined) {
if(router.path === "/") {
router.children[item].path = "/" + router.children[item].path;
} else {
if(!isHttp(router.children[item].path)) {
router.children[item].path = router.path + "/" + router.children[item].path;
}
}
router.children[item].parentPath = router.path;
}
childrenMenus.push(router.children[item]);
}
})
return constantRoutes.concat(childrenMenus);
})
// 默认激活的菜单
const activeMenu = computed(() => {
const path = route.path;
let activePath = path;
if (path !== undefined && path.lastIndexOf("/") > 0 && hideList.indexOf(path) === -1) {
const tmpPath = path.substring(1, path.length);
activePath = "/" + tmpPath.substring(0, tmpPath.indexOf("/"));
if (!route.meta.link) {
appStore.toggleSideBarHide(false);
}
} else if(!route.children) {
activePath = path;
appStore.toggleSideBarHide(true);
}
activeRoutes(activePath);
return activePath;
})
function setVisibleNumber() {
const width = document.body.getBoundingClientRect().width / 3;
visibleNumber.value = parseInt(width / 85);
}
function handleSelect(key, keyPath) {
currentIndex.value = key;
const route = routers.value.find(item => item.path === key);
if (isHttp(key)) {
// http(s):// 路径新窗口打开
window.open(key, "_blank");
} else if (!route || !route.children) {
// 没有子路由路径内部打开
const routeMenu = childrenMenus.value.find(item => item.path === key);
if (routeMenu && routeMenu.query) {
let query = JSON.parse(routeMenu.query);
router.push({ path: key, query: query });
} else {
router.push({ path: key });
}
appStore.toggleSideBarHide(true);
} else {
// 显示左侧联动菜单
activeRoutes(key);
appStore.toggleSideBarHide(false);
}
}
function activeRoutes(key) {
let routes = [];
if (childrenMenus.value && childrenMenus.value.length > 0) {
childrenMenus.value.map((item) => {
if (key == item.parentPath || (key == "index" && "" == item.path)) {
routes.push(item);
}
});
}
if(routes.length > 0) {
} else {
appStore.toggleSideBarHide(true);
}
return routes;
}
onMounted(() => {
window.addEventListener('resize', setVisibleNumber)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', setVisibleNumber)
})
onMounted(() => {
setVisibleNumber()
})
</script>
<style lang="scss">
.topmenu-container.el-menu--horizontal > .el-menu-item {
float: left;
height: 50px !important;
line-height: 50px !important;
color: #999093 !important;
padding: 0 5px !important;
margin: 0 10px !important;
}
.topmenu-container.el-menu--horizontal > .el-menu-item.is-active, .el-menu--horizontal > .el-sub-menu.is-active .el-submenu__title {
border-bottom: 2px solid #{'var(--theme)'} !important;
color: #303133;
}
/* sub-menu item */
.topmenu-container.el-menu--horizontal > .el-sub-menu .el-sub-menu__title {
float: left;
height: 50px !important;
line-height: 50px !important;
color: #999093 !important;
padding: 0 5px !important;
margin: 0 10px !important;
}
/* 背景色隐藏 */
.topmenu-container.el-menu--horizontal>.el-menu-item:not(.is-disabled):focus, .topmenu-container.el-menu--horizontal>.el-menu-item:not(.is-disabled):hover, .topmenu-container.el-menu--horizontal>.el-submenu .el-submenu__title:hover {
background-color: #ffffff !important;
}
/* 图标右间距 */
.topmenu-container .svg-icon {
margin-right: 4px;
}
/* topmenu more arrow */
.topmenu-container .el-sub-menu .el-sub-menu__icon-arrow {
position: static;
vertical-align: middle;
margin-left: 8px;
margin-top: 0px;
}
</style>

View File

@@ -38,13 +38,13 @@ class Canvas extends EventEmitter {
window.addEventListener('resize', () => this.resize()); window.addEventListener('resize', () => this.resize());
} }
resize() { resize() {
const parent = this.canvas.parentElement; const parent = this.canvas.parentElement;
if (!parent) return; if (!parent) return;
const containerWidth = parent.offsetWidth; const containerWidth = parent.offsetWidth - 150;
const containerHeight = parent.offsetHeight; const containerHeight = parent.offsetHeight;
let width = containerWidth; let width = containerWidth;
let height = Math.floor((width * 9) / 16); let height = Math.floor((width * 9) / 16);
@@ -62,6 +62,7 @@ class Canvas extends EventEmitter {
this.render(); this.render();
} }
setDrawingTool(tool) { this.drawingTool = tool; } setDrawingTool(tool) { this.drawingTool = tool; }
setColor(color) { this.currentColor = color; } setColor(color) { this.currentColor = color; }
@@ -71,11 +72,12 @@ class Canvas extends EventEmitter {
getShapes() { return this.shapes; } getShapes() { return this.shapes; }
setShapes(shapes) { this.shapes = shapes; this.render(); } setShapes(shapes) { this.shapes = shapes; this.render(); }
// 获取鼠标坐标,转换为百分比坐标
getMouseCoordinates(e) { getMouseCoordinates(e) {
const rect = this.canvas.getBoundingClientRect(); const rect = this.canvas.getBoundingClientRect();
return { return {
x: (e.clientX - rect.left) / this.canvas.width, x:((e.clientX - rect.left) / this.canvas.width).toFixed(4),
y: (e.clientY - rect.top) / this.canvas.height y:((e.clientY - rect.top) / this.canvas.height).toFixed(4)
}; };
} }
@@ -112,7 +114,7 @@ class Canvas extends EventEmitter {
this.emit('drawingUpdate', this.currentShape); this.emit('drawingUpdate', this.currentShape);
} }
handleMouseUp(e) { handleMouseUp(e) {
if (!this.isDrawing || !this.currentShape) return; if (!this.isDrawing || !this.currentShape) return;
this.isDrawing = false; this.isDrawing = false;
const coords = this.getMouseCoordinates(e); const coords = this.getMouseCoordinates(e);

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

@@ -0,0 +1,49 @@
<template>
<section class="app-main">
<router-view v-slot="{ Component, route }">
<transition name="fade-transform" mode="out-in">
<keep-alive :include="tagsViewStore.cachedViews">
<component
v-if="!route.meta.link"
:is="Component"
:key="route.path"
/>
</keep-alive>
</transition>
</router-view>
<iframe-toggle />
</section>
</template>
<script setup>
import iframeToggle from "./IframeToggle/index.vue";
import useTagsViewStore from "@/stores/modules/tagsView.js";
const tagsViewStore = useTagsViewStore();
</script>
<style lang="scss" scoped>
.app-main {
/* 50= navbar 50 */
min-height: calc(100vh - 176px);
width: 100%;
position: relative;
overflow: hidden;
}
.fixed-header + .app-main {
padding-top: 50px;
}
.hasTagsView {
.app-main {
/* 84 = navbar + tags-view = 50 + 34 */
// min-height: calc(100vh - 84px);
}
.fixed-header + .app-main {
padding-top: 84px;
}
}
</style>

View File

@@ -0,0 +1,19 @@
<template>
<transition-group name="fade-transform" mode="out-in">
<inner-link
v-for="(item, index) in tagsViewStore.iframeViews"
:key="item.path"
:iframeId="'iframe' + index"
v-show="route.path === item.path"
:src="item.meta.link"
></inner-link>
</transition-group>
</template>
<script setup>
import InnerLink from "../InnerLink/index.vue"
import useTagsViewStore from '@/stores/modules/tagsView.js'
const route = useRoute();
const tagsViewStore = useTagsViewStore()
</script>

View File

@@ -0,0 +1,24 @@
<template>
<div :style="'height:' + height">
<iframe
:id="iframeId"
style="width: 100%; height: 100%"
:src="src"
frameborder="no"
></iframe>
</div>
</template>
<script setup>
const props = defineProps({
src: {
type: String,
default: "/"
},
iframeId: {
type: String
}
});
const height = ref(document.documentElement.clientHeight - 94.5 + "px");
</script>

View File

@@ -0,0 +1,149 @@
<template>
<el-dialog
v-model="inviteDialog"
title="远程协作"
width="400px"
:close-on-press-escape="false"
:close-on-click-modal="false"
:show-close="false"
>
<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>
</el-dialog>
</template>
<script setup>
import { ref ,onMounted} from 'vue'
import { getStatusApi } from '@/api/conferencingRoom.js'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { mqttClient } from "@/utils/mqtt.js";
import { useUserStore } from '@/stores/modules/user.js'
const router = useRouter()
const userStore = useUserStore()
const inviteDialog = ref(false)
const socketInformation = ref(null)
/** 拒绝加入 */
const clickRefuseJoin = async () => {
//status 1: 同意加入, 5: 拒绝加入
try{
const res = await getStatusApi(socketInformation.value.room_uid,{status:5})
if(res.meta.code == 200){
ElMessage({
message: '已拒绝加入该协作',
type: 'error',
})
inviteDialog.value = false
}
} catch (error) {
console.log(error,'error')
inviteDialog.value = false
} finally {
inviteDialog.value = false
}
}
const clickJoin = async () => {
const res = await getStatusApi(socketInformation.value.room_uid,{status:1})
if(res.meta.code == 200){
ElMessage({
message: '成功加入该协作',
type: 'success',
})
inviteDialog.value = false
router.push({
path: '/conferencingRoom',
query:{
type:2,//创建房间,加入房间 2
room_uid:socketInformation.value.room_uid
}
})
}
inviteDialog.value = false
}
/** 浏览器通知 */
const showNotification = (data) => {
if ('Notification' in window) {
Notification.requestPermission().then((permission) => {
if (permission === 'granted') {
const notification = new Notification('协作邀请', {
// body: String(data.room_name) + '邀请您参加远程协作'
body: '远程协作有新的邀请'
// icon: logo,
})
notification.onclick = () => { clickJoin() }
}
})
}
}
/** 处理加入房间和拒接房间 mqtt 消息 */
const processingSocket = (message) => {
const res = JSON.parse(message)
console.log(res,'收到用户信息 邀请')
if (!res?.status) {
socketInformation.value = res
inviteDialog.value = true
showNotification(socketInformation.value)
}else if(res.status == 5){
ElMessage({
message: `${res?.display_name}拒绝加入该协作`,
type: 'error',
})
}
}
defineExpose({
processingSocket,
});
// onMounted(async () => {
// await mqttClient.connect(`room${Math.random().toString(16).substr(2, 8)}`);
// const res = await userStore.getInfo()
// const topic = `xSynergy/ROOM/+/rooms/${res.uid}`;
// mqttClient.subscribe(topic, async (shapeData) => {
// // console.log(shapeData.toString(),'shapeData发送邀请')
// processingSocket(shapeData.toString())
// });
// })
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,69 @@
<template>
<div class="navbar">
<hamburger
id="hamburger-container"
:is-active="appStore.sidebar.opened"
class="hamburger-container"
@toggleClick="toggleSideBar"
/>
<breadcrumb
id="breadcrumb-container"
class="breadcrumb-container"
v-if="!settingsStore.topNav"
/>
<top-nav id="topmenu-container" class="topmenu-container" v-if="settingsStore.topNav" />
</div>
</template>
<script setup>
import Breadcrumb from '@/components/Breadcrumb/index.vue'
import TopNav from '@/components/TopNav/index.vue'
import Hamburger from '@/components/Hamburger/index.vue'
import { useAppStore } from '@/stores/modules/app.js'
import { useSettingsStore } from '@/stores/modules/settings.js'
const appStore = useAppStore()
const settingsStore = useSettingsStore()
function toggleSideBar() {
appStore.toggleSideBar(true)
}
</script>
<style lang="scss" scoped>
.navbar {
height: 56px;
overflow: hidden;
position: relative;
.hamburger-container {
line-height: 56px;
height: 100%;
float: left;
cursor: pointer;
transition: background 0.3s;
-webkit-tap-highlight-color: transparent;
&:hover {
background: rgba(0, 0, 0, 0.025);
}
}
.breadcrumb-container {
display: flex;
align-content: center;
float: left;
height: 100%;
}
.topmenu-container {
position: absolute;
left: 50px;
}
.errLog-container {
display: inline-block;
vertical-align: top;
}
}
</style>

View File

@@ -0,0 +1,162 @@
<template>
<div>
<el-dialog
v-model="dialogVisible"
title="修改密码"
width="500"
:show-close='false'
:close-on-click-modal="false"
>
<div v-loading="loading">
<el-form ref="pwdRef" :model="user" :rules="rules" label-width="100px">
<el-form-item label="旧密码" prop="oldPassword">
<el-input
v-model="user.oldPassword"
placeholder="请输入旧密码"
type="password"
show-password
/>
</el-form-item>
<el-form-item label="新密码" prop="newPassword">
<el-input
v-model="user.newPassword"
placeholder="请输入新密码"
type="password"
show-password
/>
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<el-input
v-model="user.confirmPassword"
placeholder="请确认新密码"
type="password"
show-password
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submit">保存</el-button>
<el-button type="danger" @click="close">关闭</el-button>
</el-form-item>
</el-form>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { changePwd ,checkPwdStrength } from '@/api/login.js'
import { useUserStore } from '@/stores/modules/user.js'
import { ref, reactive } from 'vue'
import { ElMessage } from 'element-plus'
const { proxy } = getCurrentInstance()
const userStore = useUserStore()
const dialogVisible = ref(false)
const loading = ref(false)
// 密码规则
function passwords(value) {
let validatorA = /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,}$/
let validatorB = /^(?=.*\d)(?=.*[a-z])(?=.*[!@#$%^&*()\-_=+{};:,<.>]).{8,}$/
let validatorC = /^(?=.*\d)(?=.*[A-Z])(?=.*[!@#$%^&*()\-_=+{};:,<.>]).{8,}$/
let validatorD = /^(?=.*[a-z])(?=.*[A-Z])(?=.*[!@#$%^&*()\-_=+{};:,<.>]).{8,}$/
if (
validatorA.test(value) ||
validatorB.test(value) ||
validatorC.test(value) ||
validatorD.test(value)
) {
return true
} else {
return false
}
}
// 新增用户检验密码规则
const validatorPasswords = async (rule, value, callback) => {
const resPwd = await checkPwdStrength(user.newPassword);
if(resPwd.meta.code !== 200) {
ElMessage.error(resPwd.meta?.message || '密码强度校验失败');
return
}else{
if(!resPwd.data.is_strong){
callback(new Error(resPwd.data.suggestions.join(', ')|| '密码强度校验失败'))
return
}
}
}
const user = reactive({
oldPassword: undefined,
newPassword: undefined,
confirmPassword: undefined,
})
const equalToPassword = (rule, value, callback) => {
if (user.newPassword !== value) {
callback(new Error('两次输入的密码不一致'))
} else {
callback()
}
}
const rules = ref({
oldPassword: [{ required: true, message: '旧密码不能为空', trigger: 'blur' }],
newPassword: [
{ required: true, message: '新密码不能为空', trigger: 'blur' },
{ required: true, validator: validatorPasswords, trigger: 'blur' }
],
confirmPassword: [
{ required: true, message: '确认密码不能为空', trigger: 'blur' },
{ required: true, validator: equalToPassword, trigger: 'blur' },
],
})
/** 提交按钮 */
async function submit() {
loading.value = true;
try {
const valid = await proxy.$refs.pwdRef.validate();
if (!valid) return;
const resPwd = await checkPwdStrength(user.newPassword);
if(resPwd.meta.code !== 200) {
ElMessage.error(resPwd.meta?.message || '密码强度校验失败');
return
}else{
if(!resPwd.data.is_strong){
ElMessage.error(resPwd.data.suggestions.join(', ') || '密码强度校验失败');
return
}
}
const res = await changePwd(user.oldPassword, user.newPassword);
if(res.meta.code !== 200){
ElMessage.error(res.meta?.message || '密码修改失败');
return
}else{
ElMessage.success('密码修改成功,请重新登录');
}
await userStore.logOut();
location.href = '/';
} catch (error) {
console.log(error,'error')
} finally {
loading.value = false;
}
}
/** 关闭按钮 */
function close() {
dialogVisible.value = false
proxy.$refs.pwdRef.resetFields()
}
function open(){
dialogVisible.value = true
}
defineExpose({
open
})
</script>

View File

@@ -0,0 +1,238 @@
<template>
<el-drawer v-model="showSettings" :withHeader="false" direction="rtl" size="300px">
<div class="setting-drawer-title">
<h3 class="drawer-title">主题风格设置</h3>
</div>
<div class="setting-drawer-block-checbox">
<div class="setting-drawer-block-checbox-item" @click="handleTheme('theme-dark')">
<img src="@/assets/images/dark.svg" alt="dark" />
<div v-if="sideTheme === 'theme-dark'" class="setting-drawer-block-checbox-selectIcon" style="display: block;">
<i aria-label="图标: check" class="anticon anticon-check">
<svg viewBox="64 64 896 896" data-icon="check" width="1em" height="1em" :fill="theme" aria-hidden="true" focusable="false" class>
<path d="M912 190h-69.9c-9.8 0-19.1 4.5-25.1 12.2L404.7 724.5 207 474a32 32 0 0 0-25.1-12.2H112c-6.7 0-10.4 7.7-6.3 12.9l273.9 347c12.8 16.2 37.4 16.2 50.3 0l488.4-618.9c4.1-5.1.4-12.8-6.3-12.8z" />
</svg>
</i>
</div>
</div>
<div class="setting-drawer-block-checbox-item" @click="handleTheme('theme-light')">
<img src="@/assets/images/light.svg" alt="light" />
<div v-if="sideTheme === 'theme-light'" class="setting-drawer-block-checbox-selectIcon" style="display: block;">
<i aria-label="图标: check" class="anticon anticon-check">
<svg viewBox="64 64 896 896" data-icon="check" width="1em" height="1em" :fill="theme" aria-hidden="true" focusable="false" class>
<path d="M912 190h-69.9c-9.8 0-19.1 4.5-25.1 12.2L404.7 724.5 207 474a32 32 0 0 0-25.1-12.2H112c-6.7 0-10.4 7.7-6.3 12.9l273.9 347c12.8 16.2 37.4 16.2 50.3 0l488.4-618.9c4.1-5.1.4-12.8-6.3-12.8z" />
</svg>
</i>
</div>
</div>
</div>
<div class="drawer-item">
<span>主题颜色</span>
<span class="comp-style">
<el-color-picker v-model="theme" :predefine="predefineColors" @change="themeChange"/>
</span>
</div>
<el-divider />
<h3 class="drawer-title">系统布局配置</h3>
<div class="drawer-item">
<span>开启 TopNav</span>
<span class="comp-style">
<el-switch v-model="topNav" class="drawer-switch" />
</span>
</div>
<div class="drawer-item">
<span>开启 Tags-Views</span>
<span class="comp-style">
<el-switch v-model="tagsView" class="drawer-switch" />
</span>
</div>
<div class="drawer-item">
<span>固定 Header</span>
<span class="comp-style">
<el-switch v-model="fixedHeader" class="drawer-switch" />
</span>
</div>
<div class="drawer-item">
<span>显示 Logo</span>
<span class="comp-style">
<el-switch v-model="sidebarLogo" class="drawer-switch" />
</span>
</div>
<div class="drawer-item">
<span>动态标题</span>
<span class="comp-style">
<el-switch v-model="dynamicTitle" class="drawer-switch" />
</span>
</div>
<el-divider />
<el-button type="primary" plain icon="DocumentAdd" @click="saveSetting">保存配置</el-button>
<el-button plain icon="Refresh" @click="resetSetting">重置配置</el-button>
</el-drawer>
</template>
<script setup>
import variables from '@/assets/styles/variables.module.scss'
// import originElementPlus from 'element-plus/theme-chalk/index.css'
import axios from 'axios'
import { ElLoading, ElMessage } from 'element-plus'
import { useDynamicTitle } from '@/utils/dynamicTitle.js'
import { useAppStore } from '@/stores/modules/app.js'
import { useSettingsStore } from '@/stores/modules/settings.js'
import { handleThemeStyle } from '@/utils/theme'
const { proxy } = getCurrentInstance();
const appStore = useAppStore()
const settingsStore = useSettingsStore()
const showSettings = ref(false);
const theme = ref(settingsStore.theme);
const sideTheme = ref(settingsStore.sideTheme);
const storeSettings = computed(() => settingsStore);
const predefineColors = ref(["#409EFF", "#ff4500", "#ff8c00", "#ffd700", "#90ee90", "#00ced1", "#1e90ff", "#c71585"]);
/** 是否需要topnav */
const topNav = computed({
get: () => storeSettings.value.topNav,
set: (val) => {
settingsStore.changeSetting({ key: 'topNav', value: val })
if (!val) {
appStore.toggleSideBarHide(false);
}
}
})
/** 是否需要tagview */
const tagsView = computed({
get: () => storeSettings.value.tagsView,
set: (val) => {
settingsStore.changeSetting({ key: 'tagsView', value: val })
}
})
/**是否需要固定头部 */
const fixedHeader = computed({
get: () => storeSettings.value.fixedHeader,
set: (val) => {
settingsStore.changeSetting({ key: 'fixedHeader', value: val })
}
})
/**是否需要侧边栏的logo */
const sidebarLogo = computed({
get: () => storeSettings.value.sidebarLogo,
set: (val) => {
settingsStore.changeSetting({ key: 'sidebarLogo', value: val })
}
})
/**是否需要侧边栏的动态网页的title */
const dynamicTitle = computed({
get: () => storeSettings.value.dynamicTitle,
set: (val) => {
settingsStore.changeSetting({ key: 'dynamicTitle', value: val })
// 动态设置网页标题
useDynamicTitle()
}
})
function themeChange(val) {
settingsStore.changeSetting({ key: 'theme', value: val })
theme.value = val;
handleThemeStyle(val);
}
function handleTheme(val) {
settingsStore.changeSetting({ key: 'sideTheme', value: val })
sideTheme.value = val;
}
function saveSetting() {
proxy.$modal.loading("正在保存到本地,请稍候...");
let layoutSetting = {
"topNav": storeSettings.value.topNav,
"tagsView": storeSettings.value.tagsView,
"fixedHeader": storeSettings.value.fixedHeader,
"sidebarLogo": storeSettings.value.sidebarLogo,
"dynamicTitle": storeSettings.value.dynamicTitle,
"sideTheme": storeSettings.value.sideTheme,
"theme": storeSettings.value.theme
};
localStorage.setItem("layout-setting", JSON.stringify(layoutSetting));
setTimeout(proxy.$modal.closeLoading(), 1000)
}
function resetSetting() {
proxy.$modal.loading("正在清除设置缓存并刷新,请稍候...");
localStorage.removeItem("layout-setting")
setTimeout("window.location.reload()", 1000)
}
function openSetting() {
showSettings.value = true;
}
defineExpose({
openSetting,
})
</script>
<style lang='scss' scoped>
.setting-drawer-title {
margin-bottom: 12px;
color: rgba(0, 0, 0, 0.85);
line-height: 22px;
font-weight: bold;
.drawer-title {
font-size: 14px;
}
}
.setting-drawer-block-checbox {
display: flex;
justify-content: flex-start;
align-items: center;
margin-top: 10px;
margin-bottom: 20px;
.setting-drawer-block-checbox-item {
position: relative;
margin-right: 16px;
border-radius: 2px;
cursor: pointer;
img {
width: 48px;
height: 48px;
}
.custom-img {
width: 48px;
height: 38px;
border-radius: 5px;
box-shadow: 1px 1px 2px #898484;
}
.setting-drawer-block-checbox-selectIcon {
position: absolute;
top: 0;
right: 0;
width: 100%;
height: 100%;
padding-top: 15px;
padding-left: 24px;
color: #1890ff;
font-weight: 700;
font-size: 14px;
}
}
}
.drawer-item {
color: rgba(0, 0, 0, 0.65);
padding: 12px 0;
font-size: 14px;
.comp-style {
float: right;
margin: -3px 8px 0px 0px;
}
}
</style>

View File

@@ -0,0 +1,40 @@
<template>
<component :is="type" v-bind="linkProps()">
<slot />
</component>
</template>
<script setup>
import { isExternal } from '@/utils/validate'
const props = defineProps({
to: {
type: [String, Object],
required: true
}
})
const isExt = computed(() => {
return isExternal(props.to)
})
const type = computed(() => {
if (isExt.value) {
return 'a'
}
return 'router-link'
})
function linkProps() {
if (isExt.value) {
return {
href: props.to,
target: '_blank',
rel: 'noopener'
}
}
return {
to: props.to
}
}
</script>

View File

@@ -0,0 +1,81 @@
<template>
<div class="sidebar-logo-container" :class="{ 'collapse': collapse }" :style="{ backgroundColor: sideTheme === 'theme-dark' ? variables.menuBackground : variables.menuLightBackground }">
<transition name="sidebarLogoFade">
<router-link v-if="collapse" key="collapse" class="sidebar-logo-link" to="/">
<img v-if="logo" :src="logo" class="sidebar-logo" />
<h1 v-else class="sidebar-title" :style="{ color: sideTheme === 'theme-dark' ? variables.logoTitleColor : variables.logoLightTitleColor }">{{ title }}</h1>
</router-link>
<router-link v-else key="expand" class="sidebar-logo-link" to="/">
<img v-if="logo" :src="logo" class="sidebar-logo" />
<h1 class="sidebar-title" :style="{ color: sideTheme === 'theme-dark' ? variables.logoTitleColor : variables.logoLightTitleColor }">{{ title }}</h1>
</router-link>
</transition>
</div>
</template>
<script setup>
import variables from '@/assets/styles/variables.module.scss'
import logo from '@/assets/logo/logo.png'
import useSettingsStore from '@/store/modules/settings'
defineProps({
collapse: {
type: Boolean,
required: true
}
})
const title = import.meta.env.VITE_APP_TITLE;
const settingsStore = useSettingsStore();
const sideTheme = computed(() => settingsStore.sideTheme);
</script>
<style lang="scss" scoped>
.sidebarLogoFade-enter-active {
transition: opacity 1.5s;
}
.sidebarLogoFade-enter,
.sidebarLogoFade-leave-to {
opacity: 0;
}
.sidebar-logo-container {
position: relative;
width: 100%;
height: 50px;
line-height: 50px;
background: #2b2f3a;
text-align: center;
overflow: hidden;
& .sidebar-logo-link {
height: 100%;
width: 100%;
& .sidebar-logo {
width: 32px;
height: 32px;
vertical-align: middle;
margin-right: 12px;
}
& .sidebar-title {
display: inline-block;
margin: 0;
color: #fff;
font-weight: 600;
line-height: 50px;
font-size: 14px;
font-family: Avenir, Helvetica Neue, Arial, Helvetica, sans-serif;
vertical-align: middle;
}
}
&.collapse {
.sidebar-logo {
margin-right: 0px;
}
}
}
</style>

View File

@@ -0,0 +1,116 @@
<template>
<div v-if="!item.hidden">
<template
v-if="
hasOneShowingChild(item.children, item) &&
(!onlyOneChild.children || onlyOneChild.noShowingChildren) &&
!item.alwaysShow
"
>
<app-link v-if="onlyOneChild.meta" :to="resolvePath(onlyOneChild.path, onlyOneChild.query)">
<el-menu-item
:index="resolvePath(onlyOneChild.path)"
:class="{ 'submenu-title-noDropdown': !isNest }"
>
<!-- <svg-icon :icon-class="onlyOneChild.meta.icon || (item.meta && item.meta.icon)" /> -->
<!-- {{ onlyOneChild.meta.title }} -->
<!-- <template #title> -->
<span class="menu-title" :title="hasTitle(onlyOneChild.meta.title)">
{{ onlyOneChild.meta.title }}
</span>
<!-- </template> -->
</el-menu-item>
</app-link>
</template>
<el-sub-menu v-else ref="subMenu" :index="resolvePath(item.path)" popper-append-to-body>
<template v-if="item.meta" #title>
<!-- <svg-icon :icon-class="item.meta && item.meta.icon" /> -->
<span class="menu-title" :title="hasTitle(item.meta.title)">{{ item.meta.title }}</span>
</template>
<sidebar-item
v-for="child in item.children"
:key="child.path"
:is-nest="true"
:item="child"
:base-path="resolvePath(child.path)"
class="nest-menu"
/>
</el-sub-menu>
</div>
</template>
<script setup>
import { isExternal } from '@/utils/validate.js'
import AppLink from './Link.vue'
import { getNormalPath } from '@/utils/ruoyi'
const props = defineProps({
// route object
item: {
type: Object,
required: true,
},
isNest: {
type: Boolean,
default: false,
},
basePath: {
type: String,
default: '',
},
})
const onlyOneChild = ref({})
function hasOneShowingChild(children = [], parent) {
if (!children) {
children = []
}
const showingChildren = children.filter((item) => {
if (item.hidden) {
return false
} else {
// Temp set(will be used if only has one showing child)
onlyOneChild.value = item
return true
}
})
// When there is only one child router, the child router is displayed by default
if (showingChildren.length === 1) {
return true
}
// Show parent if there are no child router to display
if (showingChildren.length === 0) {
onlyOneChild.value = { ...parent, path: '', noShowingChildren: true }
return true
}
return false
}
function resolvePath(routePath, routeQuery) {
if (isExternal(routePath)) {
return routePath
}
if (isExternal(props.basePath)) {
return props.basePath
}
if (routeQuery) {
let query = JSON.parse(routeQuery)
return { path: getNormalPath(props.basePath + '/' + routePath), query: query }
}
return getNormalPath(props.basePath + '/' + routePath)
}
function hasTitle(title) {
if (title.length > 5) {
return title
} else {
return ''
}
}
</script>

View File

@@ -0,0 +1,60 @@
<template>
<div
:class="{ 'has-logo': showLogo }"
:style="{
backgroundColor:
sideTheme === 'theme-dark' ? variables.menuBackground : variables.menuLightBackground,
}"
>
<!-- <logo v-if="showLogo" :collapse="isCollapse" /> -->
<el-scrollbar :class="sideTheme" wrap-class="scrollbar-wrapper">
<el-menu
:default-active="activeMenu"
:collapse="false"
:background-color="
sideTheme === 'theme-dark' ? variables.menuBackground : variables.menuLightBackground
"
:text-color="sideTheme === 'theme-dark' ? variables.menuColor : variables.menuLightColor"
:unique-opened="true"
:active-text-color="theme"
:collapse-transition="false"
mode="vertical"
>
<sidebar-item
v-for="(route, index) in constantRoutes"
:key="route.path + index"
:item="route"
:base-path="route.path"
/>
</el-menu>
</el-scrollbar>
</div>
</template>
<script setup>
import SidebarItem from './SidebarItem.vue'
import variables from '@/assets/styles/variables.module.scss'
import { useAppStore } from '@/stores/modules/app.js'
import { useSettingsStore } from '@/stores/modules/settings.js'
import router, { constantRoutes, dynamicRoutes } from '@/router'
const route = useRoute()
const appStore = useAppStore()
const settingsStore = useSettingsStore()
const showLogo = computed(() => settingsStore.sidebarLogo)
const sideTheme = computed(() => settingsStore.sideTheme)
const theme = computed(() => settingsStore.theme)
const isCollapse = computed(() => !appStore.sidebar.opened)
const activeMenu = computed(() => {
const { meta, path } = route
// if set path, the sidebar will highlight the path you set
if (meta.activeMenu) {
return meta.activeMenu
}
return path
})
</script>

View File

@@ -0,0 +1,105 @@
<template>
<el-scrollbar
ref="scrollContainer"
:vertical="false"
class="scroll-container"
@wheel.prevent="handleScroll"
>
<slot />
</el-scrollbar>
</template>
<script setup>
import useTagsViewStore from '@/stores/modules/tagsView.js'
const tagAndTagSpacing = ref(4);
const { proxy } = getCurrentInstance();
const scrollWrapper = computed(() => proxy.$refs.scrollContainer.$refs.wrapRef);
onMounted(() => {
scrollWrapper.value.addEventListener('scroll', emitScroll, true)
})
onBeforeUnmount(() => {
scrollWrapper.value.removeEventListener('scroll', emitScroll)
})
function handleScroll(e) {
const eventDelta = e.wheelDelta || -e.deltaY * 40
const $scrollWrapper = scrollWrapper.value;
$scrollWrapper.scrollLeft = $scrollWrapper.scrollLeft + eventDelta / 4
}
const emits = defineEmits()
const emitScroll = () => {
emits('scroll')
}
const tagsViewStore = useTagsViewStore()
const visitedViews = computed(() => tagsViewStore.visitedViews);
function moveToTarget(currentTag) {
const $container = proxy.$refs.scrollContainer.$el
const $containerWidth = $container.offsetWidth
const $scrollWrapper = scrollWrapper.value;
let firstTag = null
let lastTag = null
// find first tag and last tag
if (visitedViews.value.length > 0) {
firstTag = visitedViews.value[0]
lastTag = visitedViews.value[visitedViews.value.length - 1]
}
if (firstTag === currentTag) {
$scrollWrapper.scrollLeft = 0
} else if (lastTag === currentTag) {
$scrollWrapper.scrollLeft = $scrollWrapper.scrollWidth - $containerWidth
} else {
const tagListDom = document.getElementsByClassName('tags-view-item');
const currentIndex = visitedViews.value.findIndex(item => item === currentTag)
let prevTag = null
let nextTag = null
for (const k in tagListDom) {
if (k !== 'length' && Object.hasOwnProperty.call(tagListDom, k)) {
if (tagListDom[k].dataset.path === visitedViews.value[currentIndex - 1].path) {
prevTag = tagListDom[k];
}
if (tagListDom[k].dataset.path === visitedViews.value[currentIndex + 1].path) {
nextTag = tagListDom[k];
}
}
}
// the tag's offsetLeft after of nextTag
const afterNextTagOffsetLeft = nextTag.offsetLeft + nextTag.offsetWidth + tagAndTagSpacing.value
// the tag's offsetLeft before of prevTag
const beforePrevTagOffsetLeft = prevTag.offsetLeft - tagAndTagSpacing.value
if (afterNextTagOffsetLeft > $scrollWrapper.scrollLeft + $containerWidth) {
$scrollWrapper.scrollLeft = afterNextTagOffsetLeft - $containerWidth
} else if (beforePrevTagOffsetLeft < $scrollWrapper.scrollLeft) {
$scrollWrapper.scrollLeft = beforePrevTagOffsetLeft
}
}
}
defineExpose({
moveToTarget,
})
</script>
<style lang='scss' scoped>
.scroll-container {
white-space: nowrap;
position: relative;
overflow: hidden;
width: 100%;
:deep(.el-scrollbar__bar) {
bottom: 0px;
}
:deep(.el-scrollbar__wrap) {
height: 39px;
}
}
</style>

View File

@@ -0,0 +1,336 @@
<template>
<div id="tags-view-container" class="tags-view-container">
<scroll-pane ref="scrollPaneRef" class="tags-view-wrapper" @scroll="handleScroll">
<router-link
v-for="tag in visitedViews"
:key="tag.path"
:data-path="tag.path"
:class="isActive(tag) ? 'active' : ''"
:to="{ path: tag.path, query: tag.query, fullPath: tag.fullPath }"
class="tags-view-item"
:style="activeStyle(tag)"
@click.middle="!isAffix(tag) ? closeSelectedTag(tag) : ''"
@contextmenu.prevent="openMenu(tag, $event)"
>
{{ tag.title }}
<span v-if="!isAffix(tag)" @click.prevent.stop="closeSelectedTag(tag)">
<close class="el-icon-close" style="width: 1em; height: 1em;vertical-align: middle;" />
</span>
</router-link>
</scroll-pane>
<ul v-show="visible" :style="{ left: left + 'px', top: top + 'px' }" class="contextmenu">
<li @click="refreshSelectedTag(selectedTag)">
<refresh-right style="width: 1em; height: 1em;" /> 刷新页面
</li>
<li v-if="!isAffix(selectedTag)" @click="closeSelectedTag(selectedTag)">
<close style="width: 1em; height: 1em;" /> 关闭当前
</li>
<li @click="closeOthersTags">
<circle-close style="width: 1em; height: 1em;" /> 关闭其他
</li>
<li v-if="!isFirstView()" @click="closeLeftTags">
<back style="width: 1em; height: 1em;" /> 关闭左侧
</li>
<li v-if="!isLastView()" @click="closeRightTags">
<right style="width: 1em; height: 1em;" /> 关闭右侧
</li>
<li @click="closeAllTags(selectedTag)">
<circle-close style="width: 1em; height: 1em;" /> 全部关闭
</li>
</ul>
</div>
</template>
<script setup>
import ScrollPane from './ScrollPane.vue'
import { getNormalPath } from '@/utils/ruoyi'
import useTagsViewStore from '@/stores/modules/tagsView.js'
import { useSettingsStore } from '@/stores/modules/settings.js'
const visible = ref(false);
const top = ref(0);
const left = ref(0);
const selectedTag = ref({});
const affixTags = ref([]);
const scrollPaneRef = ref(null);
const { proxy } = getCurrentInstance();
const route = useRoute();
const router = useRouter();
const visitedViews = computed(() => useTagsViewStore().visitedViews);
const theme = computed(() => useSettingsStore().theme);
watch(route, () => {
addTags()
moveToCurrentTag()
})
watch(visible, (value) => {
if (value) {
document.body.addEventListener('click', closeMenu)
} else {
document.body.removeEventListener('click', closeMenu)
}
})
onMounted(() => {
initTags()
addTags()
})
function isActive(r) {
return r.path === route.path
}
function activeStyle(tag) {
if (!isActive(tag)) return {};
return {
"background-color": theme.value,
"border-color": theme.value
};
}
function isAffix(tag) {
return tag.meta && tag.meta.affix
}
function isFirstView() {
try {
return selectedTag.value.fullPath === '/index' || selectedTag.value.fullPath === visitedViews.value[1].fullPath
} catch (err) {
return false
}
}
function isLastView() {
try {
return selectedTag.value.fullPath === visitedViews.value[visitedViews.value.length - 1].fullPath
} catch (err) {
return false
}
}
function filterAffixTags(routes, basePath = '') {
let tags = []
routes.forEach(route => {
if (route.meta && route.meta.affix) {
const tagPath = getNormalPath(basePath + '/' + route.path)
tags.push({
fullPath: tagPath,
path: tagPath,
name: route.name,
meta: { ...route.meta }
})
}
if (route.children) {
const tempTags = filterAffixTags(route.children, route.path)
if (tempTags.length >= 1) {
tags = [...tags, ...tempTags]
}
}
})
return tags
}
function initTags() {
const res = filterAffixTags(routes.value);
affixTags.value = res;
for (const tag of res) {
// Must have tag name
if (tag.name) {
useTagsViewStore().addVisitedView(tag)
}
}
}
function addTags() {
const { name } = route
if (name) {
useTagsViewStore().addView(route)
if (route.meta.link) {
useTagsViewStore().addIframeView(route);
}
}
return false
}
function moveToCurrentTag() {
nextTick(() => {
for (const r of visitedViews.value) {
if (r.path === route.path) {
scrollPaneRef.value.moveToTarget(r);
// when query is different then update
if (r.fullPath !== route.fullPath) {
useTagsViewStore().updateVisitedView(route)
}
}
}
})
}
function refreshSelectedTag(view) {
proxy.$tab.refreshPage(view);
if (route.meta.link) {
useTagsViewStore().delIframeView(route);
}
}
function closeSelectedTag(view) {
proxy.$tab.closePage(view).then(({ visitedViews }) => {
if (isActive(view)) {
toLastView(visitedViews, view)
}
})
}
function closeRightTags() {
proxy.$tab.closeRightPage(selectedTag.value).then(visitedViews => {
if (!visitedViews.find(i => i.fullPath === route.fullPath)) {
toLastView(visitedViews)
}
})
}
function closeLeftTags() {
proxy.$tab.closeLeftPage(selectedTag.value).then(visitedViews => {
if (!visitedViews.find(i => i.fullPath === route.fullPath)) {
toLastView(visitedViews)
}
})
}
function closeOthersTags() {
router.push(selectedTag.value).catch(() => { });
proxy.$tab.closeOtherPage(selectedTag.value).then(() => {
moveToCurrentTag()
})
}
function closeAllTags(view) {
proxy.$tab.closeAllPage().then(({ visitedViews }) => {
if (affixTags.value.some(tag => tag.path === route.path)) {
return
}
toLastView(visitedViews, view)
})
}
function toLastView(visitedViews, view) {
const latestView = visitedViews.slice(-1)[0]
if (latestView) {
router.push(latestView.fullPath)
} else {
// now the default is to redirect to the home page if there is no tags-view,
// you can adjust it according to your needs.
if (view.name === 'Dashboard') {
// to reload home page
router.replace({ path: '/redirect' + view.fullPath })
} else {
router.push('/')
}
}
}
function openMenu(tag, e) {
const menuMinWidth = 105
const offsetLeft = proxy.$el.getBoundingClientRect().left // container margin left
const offsetWidth = proxy.$el.offsetWidth // container width
const maxLeft = offsetWidth - menuMinWidth // left boundary
const l = e.clientX - offsetLeft + 15 // 15: margin right
if (l > maxLeft) {
left.value = maxLeft
} else {
left.value = l
}
top.value = e.clientY
visible.value = true
selectedTag.value = tag
}
function closeMenu() {
visible.value = false
}
function handleScroll() {
closeMenu()
}
</script>
<style lang='scss' scoped>
.tags-view-container {
height: 34px;
width: 100%;
background: #fff;
border-bottom: 1px solid #d8dce5;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.12), 0 0 3px 0 rgba(0, 0, 0, 0.04);
.tags-view-wrapper {
.tags-view-item {
display: inline-block;
position: relative;
cursor: pointer;
height: 26px;
line-height: 26px;
border: 1px solid #d8dce5;
color: #495060;
background: #fff;
padding: 0 8px;
font-size: 12px;
margin-left: 5px;
margin-top: 4px;
&:first-of-type {
margin-left: 15px;
}
&:last-of-type {
margin-right: 15px;
}
&.active {
background-color: #42b983;
color: #fff;
border-color: #42b983;
&::before {
content: "";
background: #fff;
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
position: relative;
margin-right: 5px;
}
}
}
}
.contextmenu {
margin: 0;
background: #fff;
z-index: 3000;
position: absolute;
list-style-type: none;
padding: 5px 0;
border-radius: 4px;
font-size: 12px;
font-weight: 400;
color: #333;
box-shadow: 2px 2px 3px 0 rgba(0, 0, 0, 0.3);
li {
margin: 0;
padding: 7px 16px;
cursor: pointer;
&:hover {
background: #eee;
}
}
}
}
</style>
<style lang="scss">
//reset element css of el-icon-close
.tags-view-wrapper {
.tags-view-item {
.el-icon-close {
width: 16px;
height: 16px;
vertical-align: 2px;
border-radius: 50%;
text-align: center;
transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
transform-origin: 100% 50%;
&:before {
transform: scale(0.6);
display: inline-block;
vertical-align: -3px;
}
&:hover {
background-color: #b4bccc;
color: #fff;
width: 12px !important;
height: 12px !important;
}
}
}
}
</style>

View File

@@ -0,0 +1,6 @@
export { default as AppMain } from './AppMain.vue'
export { default as Navbar } from './Navbar.vue'
export { default as Settings } from './Settings/index.vue'
export { default as TagsView } from './TagsView/index.vue'
export { default as ResetPwd } from './ResetPwd/index.vue'
export { default as InviteJoin } from './InviteJoin/index.vue'

326
src/layout/index.vue Normal file
View File

@@ -0,0 +1,326 @@
<template>
<div class="wrapper-content">
<div class="content-nav">
<div class="nav-left">
<div>xSynergy远程协作系统</div>
</div>
<div class="nav-right">
<el-dropdown trigger="click" @command="handleCommand">
<div class="avatar-wrapper">
<span class="username">{{ nickName }}</span>
<!-- <el-icon><caret-bottom /></el-icon> -->
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="changePassword">
<span>修改密码</span>
</el-dropdown-item>
<el-dropdown-item command="logout" divided>
<span>退出登录</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<div :class="classObj" class="app-wrapper" :style="{ '--current-color': theme }">
<div
v-if="device === 'mobile' && sidebar.opened"
class="drawer-bg"
@click="handleClickOutside"
/>
<div class="main-container" :class="{ hasTagsView: needTagsView, sidebarHide: sidebar.hide }">
<sidebar v-if="!sidebar.hide" class="sidebar-container" />
<div class="sidebar-right">
<app-main />
</div>
</div>
</div>
<ResetPwd ref="resetPwdRef"/>
<InviteJoin ref="inviteJoinRef"/>
</div>
</template>
<script setup>
import { ElMessageBox ,ElMessage} from 'element-plus'
import { useWindowSize } from '@vueuse/core'
import Sidebar from './components/Sidebar/index.vue'
import { AppMain, TagsView ,ResetPwd,InviteJoin} from './components/index.js'
import { useAppStore } from '@/stores/modules/app.js'
import { useSettingsStore } from '@/stores/modules/settings.js'
import { useUserStore } from '@/stores/modules/user.js'
import { removeToken } from '@/utils/auth.js'
import { onMounted ,ref} from 'vue'
import { mqttClient } from "@/utils/mqtt.js";
const settingsStore = useSettingsStore()
const userStore = useUserStore()
const useAppStoreStore = useAppStore()
const router = useRouter()
const inviteJoinRef = ref(null)
const theme = computed(() => settingsStore.theme)
const sidebar = computed(() => useAppStoreStore.sidebar)
const device = computed(() => useAppStoreStore.device)
const needTagsView = computed(() => settingsStore.tagsView)
const nickName = computed(() => {
// 优先从 userStore 获取
if (userStore.name) {
return userStore.name
}
try {
const userData = sessionStorage.getItem('userData')
if (userData) {
const parsedData = JSON.parse(userData)
return parsedData.name || ''
}
} catch (error) {
console.error('解析 userData 失败:', error)
}
return ''
})
const classObj = computed(() => ({
hideSidebar: !sidebar.value.opened,
openSidebar: sidebar.value.opened,
withoutAnimation: sidebar.value.withoutAnimation,
mobile: device.value === 'mobile',
}))
const resetPwdRef = ref(null)
const { width, height } = useWindowSize()
const WIDTH = 992
watchEffect(() => {
if (device.value === 'mobile' && sidebar.value.opened) {
useAppStoreStore.closeSideBar({ withoutAnimation: false })
}
if (width.value - 1 < WIDTH) {
useAppStoreStore.toggleDevice('mobile')
useAppStoreStore.closeSideBar({ withoutAnimation: true })
} else {
useAppStoreStore.toggleDevice('desktop')
}
})
function handleCommand(command) {
switch (command) {
case 'changePassword':
resetPwdRef.value.open()
break
case 'logout':
logout()
break
default:
break
}
}
function logout() {
ElMessageBox.confirm('确定注销并退出系统吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => {
userStore.logOut().then(() => {
location.href = '/'
})
})
.catch(() => {
ElMessage({
type: 'info',
message: '取消注销'
})
})
}
function handleClickOutside() {
useAppStoreStore.closeSideBar({ withoutAnimation: false })
}
onMounted(async () => {
await mqttClient.connect(`room${Math.random().toString(16).substr(2, 8)}`);
const res = await userStore.getInfo()
const topic = `xSynergy/ROOM/+/rooms/${res.uid}`;
mqttClient.subscribe(topic, async (shapeData) => {
if(inviteJoinRef.value){
inviteJoinRef.value.processingSocket(shapeData.toString())
}
});
})
</script>
<style lang="scss" scoped>
@import '@/assets/styles/mixin.scss';
@import '@/assets/styles/variables.module.scss';
.tags-view {
opacity: 0;
top: 0;
position: fixed;
}
.main-container {
position: relative;
display: flex;
height: calc(100vh - 50px);
overflow-y: auto;
overflow: hidden;
margin: 0;
background-color: #fff;
// border-radius: 30px;
box-shadow: 0 0 10px 4px #ece9e3;
.sidebar-container {
box-shadow: rgba(0, 0, 0, 0.15) 1.95px 1.95px 2.6px;
width: 200px;
// box-shadow: 0 0 10px 4px #f6f6f6;
}
.sidebar-right {
width: calc(100% - 200px);
position: relative;
overflow: hidden;
overflow-x: hidden;
overflow-y: scroll;
// padding: 0 20px;
.right-bar {
width: 100%;
background: #f6f6f6;
overflow-x: hidden;
// border-radius: 0 30px 0 0;
}
}
/* 滚动条整体 */
.sidebar-right::-webkit-scrollbar {
height: 0px;
width: 0px;
}
}
.app-wrapper {
@include clearfix;
position: relative;
height: 100%;
width: 100%;
background: #f6f6f6;
// padding: 20px;
&.mobile.openSidebar {
position: fixed;
top: 0;
}
}
// @media screen and (max-width: 995px) {
// .wrapper-content {
// .content-nav {
// z-index: 0 !important;
// }
// }
// }
.wrapper-content {
.content-nav {
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
z-index: 1111;
width: 100%;
height: 50px;
-webkit-box-shadow: 2px 0 9px rgba(0, 21, 41, 0.35);
box-shadow: 2px 0 9px rgba(0, 21, 41, 0.35);
padding: 3px 20px;
// background-color: #8290f0;
background-color: #434343;
.nav-left {
display: flex;
align-items: center;
color: #fff;
box-sizing: border-box;
div {
font-size: 19px;
}
}
.nav-right {
display: flex;
flex-wrap: nowrap;
align-items: center;
height: 100%;
.screenfull {
font-size: 22px;
cursor: pointer;
color: #ffe565;
}
.avatar-wrapper {
position: relative;
display: flex;
flex-wrap: nowrap;
cursor: pointer;
align-items: center;
margin-right: 30px;
.user-avatar {
cursor: pointer;
width: 50px;
height: 50px;
border-radius: 10px;
border-radius: 50%;
}
.username {
padding: 0 13px;
font-weight: 700;
font-size: 18px;
line-height: 1;
color: #fff;
}
.nickName {
font-weight: 700;
font-size: 18px;
color: #fff;
}
i {
cursor: pointer;
margin-left: 6px;
font-size: 20px;
color: #ffe565;
}
}
}
}
}
.drawer-bg {
background: #000;
opacity: 0.3;
width: 100%;
top: 0;
height: 100%;
position: absolute;
z-index: 999;
}
.fixed-header {
position: fixed;
top: 0;
right: 0;
z-index: 9;
width: calc(100% - #{$base-sidebar-width});
transition: width 0.28s;
}
.hideSidebar .fixed-header {
width: calc(100% - 54px);
}
.sidebarHide .fixed-header {
width: 100%;
}
.mobile .fixed-header {
width: 100%;
}
</style>

View File

@@ -1,7 +1,7 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import * as Sentry from '@sentry/vue';
import { import {
deepClone deepClone
@@ -17,14 +17,39 @@ import zhCn from 'element-plus/es/locale/lang/zh-cn'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
const app = createApp(App) const pinia = createPinia()
const app = createApp(App)
Sentry.init({
app,
dsn: 'https://34a0d76174a64db09d31d13a8042560b@sentry.cnsdt.com/2', // 替换为你的 DSN
integrations: [
// 浏览器性能追踪集成
Sentry.browserTracingIntegration({
router,
}),
// 会话回放集成
Sentry.replayIntegration({
maskAllText: false,
blockAllMedia: false,
}),
],
// 性能监控采样率
tracesSampleRate: 1.0, // 生产环境建议设置为 0.1-0.2
// 会话回放采样率
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0,
// 环境配置
environment: import.meta.env.MODE,
// 开发环境下可禁用 Sentry
enabled: import.meta.env.PROD,
});
for (const [key, component] of Object.entries(ElementPlusIconsVue)) { for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component) app.component(key, component)
} }
app.use(createPinia()) app.use(pinia)
app.use(router) app.use(router)
app.use(ElementPlus, { app.use(ElementPlus, {
locale: zhCn, locale: zhCn,

60
src/plugins/auth.js Normal file
View File

@@ -0,0 +1,60 @@
import { useUserStore } from '@/stores/modules/user.js'
function authPermission(permission) {
const all_permission = "*:*:*";
const permissions = useUserStore().permissions
if (permission && permission.length > 0) {
return permissions.some(v => {
return all_permission === v || v === permission
})
} else {
return false
}
}
function authRole(role) {
const super_admin = "admin";
const roles = useUserStore().roles
if (role && role.length > 0) {
return roles.some(v => {
return super_admin === v || v === role
})
} else {
return false
}
}
export default {
// 验证用户是否具备某权限
hasPermi(permission) {
return authPermission(permission);
},
// 验证用户是否含有指定权限,只需包含其中一个
hasPermiOr(permissions) {
return permissions.some(item => {
return authPermission(item)
})
},
// 验证用户是否含有指定权限,必须全部拥有
hasPermiAnd(permissions) {
return permissions.every(item => {
return authPermission(item)
})
},
// 验证用户是否具备某角色
hasRole(role) {
return authRole(role);
},
// 验证用户是否含有指定角色,只需包含其中一个
hasRoleOr(roles) {
return roles.some(item => {
return authRole(item)
})
},
// 验证用户是否含有指定角色,必须全部拥有
hasRoleAnd(roles) {
return roles.every(item => {
return authRole(item)
})
}
}

View File

@@ -1,57 +1,151 @@
import { createRouter, createWebHistory, createWebHashHistory } from 'vue-router' import { createRouter, createWebHistory, createWebHashHistory } from 'vue-router'
import Layout from "@/layout/index.vue";
export const constantRoutes = [
{
path: '/redirect',
component: Layout,
hidden: true,
children: [
{
path: '/redirect/:path(.*)',
component: () => import('@/views/redirect/index.vue')
}
]
},
{
path: '/',
redirect: '/login', // 这里做重定向
hidden: true,
},
{
path: "/login",
component: () => import("@/views/login.vue"),
meta: { title: "登录" },
hidden: true,
},
{
path: '/whiteboard',
component: () => import('@/views/custom/tabulaRase/index.vue'),
meta: { title: "白板" },
hidden: true,
},
{
path: "/coordinate",
redirect: "/coordinate/CoordinatePage",
component: Layout,
children: [
{
path: 'CoordinatePage',
name: "Coordinate",
component: () => import("@/views/coordinate/personnelList/index.vue"),
meta: { title: "远程协作", icon: "client", affix: true },
}
]
},
{
path: "/conferencingRoom",
// component: Layout,
hidden: true,
children: [
{
path: '',
name: "ConferencingRoom",
component: () => import("@/views/conferencingRoom/index.vue"),
meta: { title: "会议房间", icon: "client", affix: true },
}
]
},
// 错误页面路由
{
path: "/:pathMatch(.*)*",
component: () => import("@/views/error/404.vue"),
hidden: true,
},
{
path: "/401",
component: () => import("@/views/error/401.vue"),
hidden: true,
meta: { title: "401未授权" }
},
{
path: '/assistWx',
component: () => import('@/views/coordinate/personnelList/components/assistWx/index.vue'),
meta: { title: "白板" },
hidden: true,
},
]
export const dynamicRoutes = [
]
// const router = createRouter({
// history: createWebHashHistory(import.meta.env.BASE_URL),
// routes: [
// // {
// // path: '/',
// // component: () => import("@/views/custom/tabulaRase/index.vue"),
// // },
// {
// path: '/',
// redirect: '/login', // 这里做重定向
// },
// {
// path: '/whiteboard',
// component: () => import('@/views/custom/tabulaRase/index.vue'),
// },
// {
// path: "/login",
// component: () => import("@/views/login.vue"),
// },
// {
// path: "/coordinate",
// component: Layout,
// meta: { title: "远程协作", icon: "client", affix: true },
// children: [
// {
// path: '',
// name: "Coordinate",
// component: () => import("@/views/coordinate/personnelList/index.vue")
// }
// ]
// },
// {
// path: "/conferencingRoom",
// children: [
// {
// path: '',
// name: "ConferencingRoom",
// component: () => import("@/views/conferencingRoom/index.vue")
// }
// ]
// },
// // 错误页面路由
// {
// path: "/:pathMatch(.*)*",
// component: () => import("@/views/error/404.vue"),
// },
// {
// path: "/401",
// component: () => import("@/views/error/401.vue"),
// }
// ],
// })
const router = createRouter({ const router = createRouter({
// history: createWebHistory(import.meta.env.VITE_BASE_PATH),
history: createWebHashHistory(import.meta.env.BASE_URL), history: createWebHashHistory(import.meta.env.BASE_URL),
routes: [ routes: constantRoutes,
// { scrollBehavior(to, from, savedPosition) {
// path: '/', if (savedPosition) {
// component: () => import("@/views/custom/tabulaRase/index.vue"), return savedPosition;
// }, } else {
{ return { top: 0 };
path: '/',
redirect: '/login', // 这里做重定向
},
{
path: '/whiteboard',
component: () => import('@/views/custom/tabulaRase/index.vue'),
},
{
path: "/login",
component: () => import("@/views/login.vue"),
},
{
path: "/coordinate",
children: [
{
path: '',
name: "Coordinate",
component: () => import("@/views/coordinate/personnelList/index.vue")
}
]
},
{
path: "/conferencingRoom",
children: [
{
path: '',
name: "ConferencingRoom",
component: () => import("@/views/conferencingRoom/index.vue")
}
]
},
// 错误页面路由
{
path: "/:pathMatch(.*)*",
component: () => import("@/views/error/404.vue"),
},
{
path: "/401",
component: () => import("@/views/error/401.vue"),
} }
], },
}) });
export default router export default router

47
src/settings.js Normal file
View File

@@ -0,0 +1,47 @@
export default {
/**
* 网页标题
*/
title: import.meta.env.VITE_APP_TITLE,
/**
* 侧边栏主题 深色主题theme-dark浅色主题theme-light
*/
sideTheme: 'theme-dark',
/**
* 是否系统布局配置
*/
showSettings: true,
/**
* 是否显示顶部导航
*/
topNav: false,
/**
* 是否显示 tagsView
*/
tagsView: true,
/**
* 是否固定头部
*/
fixedHeader: false,
/**
* 是否显示logo
*/
sidebarLogo: false,
/**
* 是否显示动态标题
*/
dynamicTitle: false,
/**
* @type {string | array} 'production' | ['production', 'development']
* @description Need show err logs component.
* The default is only used in the production env
* If you want to also use it in dev, you can pass ['production', 'development']
*/
errorLog: 'production'
}

View File

@@ -1,12 +0,0 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, doubleCount, increment }
})

45
src/stores/modules/app.js Normal file
View File

@@ -0,0 +1,45 @@
import Cookies from 'js-cookie'
import { defineStore } from 'pinia'
export const useAppStore = defineStore(
'app',
{
state: () => ({
sidebar: {
opened: Cookies.get('sidebarStatus') ? !!+Cookies.get('sidebarStatus') : true,
withoutAnimation: false,
hide: false
},
device: 'desktop',
size: Cookies.get('size') || 'default'
}),
actions: {
toggleSideBar(withoutAnimation) {
if (this.sidebar.hide) {
return false;
}
this.sidebar.opened = !this.sidebar.opened
this.sidebar.withoutAnimation = withoutAnimation
if (this.sidebar.opened) {
// Cookies.set('sidebarStatus', 1)
} else {
// Cookies.set('sidebarStatus', 0)
}
},
closeSideBar({ withoutAnimation }) {
// Cookies.set('sidebarStatus', 0)
this.sidebar.opened = false
this.sidebar.withoutAnimation = withoutAnimation
},
toggleDevice(device) {
this.device = device
},
setSize(size) {
this.size = size;
Cookies.set('size', size)
},
toggleSideBarHide(status) {
this.sidebar.hide = status
}
}
})

View File

@@ -1,5 +1,5 @@
import { defineStore } from 'pinia'
import { generateUUID } from '@/utils/tools.js' import { generateUUID } from '@/utils/tools.js'
import { defineStore } from 'pinia'
export const useMeterStore = defineStore('meter', { export const useMeterStore = defineStore('meter', {
state: () => ({ state: () => ({
@@ -9,7 +9,7 @@ export const useMeterStore = defineStore('meter', {
initUdid() { initUdid() {
var udid = window.localStorage.getItem('UDID') var udid = window.localStorage.getItem('UDID')
if (!udid) { if (!udid) {
udid = generateUUID(); udid = generateUUID();
window.localStorage.setItem("UDID", udid); window.localStorage.setItem("UDID", udid);
} }
this.setUdid(udid) this.setUdid(udid)
@@ -30,4 +30,4 @@ export const useMeterStore = defineStore('meter', {
} }
} }
}) })

View File

@@ -5,7 +5,10 @@ export const useRoomStore = defineStore('room', {
roomId: '', roomId: '',
token: '', token: '',
userUid: '', userUid: '',
detailUid: '', //邀请进入房间的用户uid
detailUid: '',
//邀请用户名称
detailName: '',
}), }),
actions: { actions: {
setUserUid(data) { setUserUid(data) {
@@ -13,6 +16,9 @@ export const useRoomStore = defineStore('room', {
}, },
setDetailUid(data) { setDetailUid(data) {
this.detailUid = data this.detailUid = data
},
setDetailName(data) {
this.detailName = data
} }
} }

View File

@@ -0,0 +1,37 @@
import defaultSettings from '@/settings'
import { useDynamicTitle } from '@/utils/dynamicTitle'
import { defineStore } from 'pinia'
const { sideTheme, showSettings, topNav, tagsView, fixedHeader, sidebarLogo, dynamicTitle } = defaultSettings
const storageSetting = JSON.parse(localStorage.getItem('layout-setting')) || ''
export const useSettingsStore = defineStore(
'settings',
{
state: () => ({
title: '',
theme: storageSetting.theme === undefined ? '#434343' : storageSetting.theme,
sideTheme: storageSetting.sideTheme === undefined ? sideTheme : storageSetting.sideTheme,
showSettings: showSettings,
topNav: storageSetting.topNav === undefined ? topNav : storageSetting.topNav,
tagsView: storageSetting.tagsView === undefined ? tagsView : storageSetting.tagsView,
fixedHeader: storageSetting.fixedHeader === undefined ? fixedHeader : storageSetting.fixedHeader,
sidebarLogo: storageSetting.sidebarLogo === undefined ? sidebarLogo : storageSetting.sidebarLogo,
dynamicTitle: storageSetting.dynamicTitle === undefined ? dynamicTitle : storageSetting.dynamicTitle
}),
actions: {
// 修改布局设置
changeSetting(data) {
const { key, value } = data
if (this.hasOwnProperty(key)) {
this[key] = value
}
},
// 设置网页标题
setTitle(title) {
this.title = title
useDynamicTitle();
}
}
})

View File

@@ -0,0 +1,183 @@
import { defineStore } from 'pinia'
const useTagsViewStore = defineStore(
'tags-view',
{
state: () => ({
visitedViews: [],
cachedViews: [],
iframeViews: []
}),
actions: {
addView(view) {
this.addVisitedView(view)
this.addCachedView(view)
},
addIframeView(view) {
if (this.iframeViews.some(v => v.path === view.path)) return
this.iframeViews.push(
Object.assign({}, view, {
title: view.meta.title || 'no-name'
})
)
},
addVisitedView(view) {
if (this.visitedViews.some(v => v.path === view.path)) return
this.visitedViews.push(
Object.assign({}, view, {
title: view.meta.title || 'no-name'
})
)
},
addCachedView(view) {
if (this.cachedViews.includes(view.name)) return
if (!view.meta.noCache) {
this.cachedViews.push(view.name)
}
},
delView(view) {
return new Promise(resolve => {
this.delVisitedView(view)
this.delCachedView(view)
resolve({
visitedViews: [...this.visitedViews],
cachedViews: [...this.cachedViews]
})
})
},
delVisitedView(view) {
return new Promise(resolve => {
for (const [i, v] of this.visitedViews.entries()) {
if (v.path === view.path) {
this.visitedViews.splice(i, 1)
break
}
}
this.iframeViews = this.iframeViews.filter(item => item.path !== view.path)
resolve([...this.visitedViews])
})
},
delIframeView(view) {
return new Promise(resolve => {
this.iframeViews = this.iframeViews.filter(item => item.path !== view.path)
resolve([...this.iframeViews])
})
},
delCachedView(view) {
return new Promise(resolve => {
const index = this.cachedViews.indexOf(view.name)
index > -1 && this.cachedViews.splice(index, 1)
resolve([...this.cachedViews])
})
},
delOthersViews(view) {
return new Promise(resolve => {
this.delOthersVisitedViews(view)
this.delOthersCachedViews(view)
resolve({
visitedViews: [...this.visitedViews],
cachedViews: [...this.cachedViews]
})
})
},
delOthersVisitedViews(view) {
return new Promise(resolve => {
this.visitedViews = this.visitedViews.filter(v => {
return v.meta.affix || v.path === view.path
})
this.iframeViews = this.iframeViews.filter(item => item.path === view.path)
resolve([...this.visitedViews])
})
},
delOthersCachedViews(view) {
return new Promise(resolve => {
const index = this.cachedViews.indexOf(view.name)
if (index > -1) {
this.cachedViews = this.cachedViews.slice(index, index + 1)
} else {
this.cachedViews = []
}
resolve([...this.cachedViews])
})
},
delAllViews(view) {
return new Promise(resolve => {
this.delAllVisitedViews(view)
this.delAllCachedViews(view)
resolve({
visitedViews: [...this.visitedViews],
cachedViews: [...this.cachedViews]
})
})
},
delAllVisitedViews(view) {
return new Promise(resolve => {
const affixTags = this.visitedViews.filter(tag => tag.meta.affix)
this.visitedViews = affixTags
this.iframeViews = []
resolve([...this.visitedViews])
})
},
delAllCachedViews(view) {
return new Promise(resolve => {
this.cachedViews = []
resolve([...this.cachedViews])
})
},
updateVisitedView(view) {
for (let v of this.visitedViews) {
if (v.path === view.path) {
v = Object.assign(v, view)
break
}
}
},
delRightTags(view) {
return new Promise(resolve => {
const index = this.visitedViews.findIndex(v => v.path === view.path)
if (index === -1) {
return
}
this.visitedViews = this.visitedViews.filter((item, idx) => {
if (idx <= index || (item.meta && item.meta.affix)) {
return true
}
const i = this.cachedViews.indexOf(item.name)
if (i > -1) {
this.cachedViews.splice(i, 1)
}
if(item.meta.link) {
const fi = this.iframeViews.findIndex(v => v.path === item.path)
this.iframeViews.splice(fi, 1)
}
return false
})
resolve([...this.visitedViews])
})
},
delLeftTags(view) {
return new Promise(resolve => {
const index = this.visitedViews.findIndex(v => v.path === view.path)
if (index === -1) {
return
}
this.visitedViews = this.visitedViews.filter((item, idx) => {
if (idx >= index || (item.meta && item.meta.affix)) {
return true
}
const i = this.cachedViews.indexOf(item.name)
if (i > -1) {
this.cachedViews.splice(i, 1)
}
if(item.meta.link) {
const fi = this.iframeViews.findIndex(v => v.path === item.path)
this.iframeViews.splice(fi, 1)
}
return false
})
resolve([...this.visitedViews])
})
}
}
})
export default useTagsViewStore

View File

@@ -1,9 +1,9 @@
import { login, logout, getInfo } from '@/api/login.js'
import { login, logout, getInfo } from '@/api/login' import { getToken, setToken, removeToken } from '@/utils/auth.js'
import { getToken, setToken, removeToken } from '@/utils/auth'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ElMessage } from 'element-plus'
const useUserStore = defineStore( export const useUserStore = defineStore(
'user', 'user',
{ {
state: () => ({ state: () => ({
@@ -17,11 +17,15 @@ const useUserStore = defineStore(
async login(userInfo) { async login(userInfo) {
try { try {
const { username, password } = userInfo; const { username, password } = userInfo;
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)); this.name = user.name;
sessionStorage.setItem('userData', JSON.stringify(user));
setToken(token); setToken(token);
this.token = token; this.token = token;
@@ -34,7 +38,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('未找到用户数据'));
@@ -53,9 +57,14 @@ const useUserStore = defineStore(
// 退出系统 // 退出系统
async logOut() { async logOut() {
try { try {
await logout(); const res = await logout();
if(res.meta.code !== 200){
ElMessage({ message: res.meta?.message || '退出登录失败', type: 'error' });
return
}
this.token = ''; this.token = '';
this.roles = ''; this.roles = '';
sessionStorage.removeItem('userData');
removeToken(); removeToken();
} catch (error) { } catch (error) {
console.error('退出登录失败:', error); console.error('退出登录失败:', error);
@@ -67,6 +76,4 @@ const useUserStore = defineStore(
return !!getToken(); return !!getToken();
} }
} }
}) })
export default useUserStore

View File

@@ -1,30 +1,71 @@
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import router from '@/router';
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);
return sessionStorage.getItem(TokenKey);
} }
export function setToken(token) { export function setToken(token) {
return Cookies.set(TokenKey, token); // return Cookies.set(TokenKey, token);
return sessionStorage.setItem(TokenKey, token);
} }
export function removeToken() { export function removeToken() {
return Cookies.remove(TokenKey); // return Cookies.remove(TokenKey);
return sessionStorage.removeItem(TokenKey);
} }
export function getExpiresIn() { //获取用户信息
return Cookies.get(ExpiresInKey) || -1; export function getUserInfo() {
try {
const userData = sessionStorage.getItem("userData");
// 如果userData不存在执行未授权处理
if (!userData) {
handleUnauthorized();
return null;
}
// 尝试解析JSON数据
try {
const parsedData = JSON.parse(userData);
return parsedData;
} catch (parseError) {
console.error('用户数据格式错误无法解析JSON:', parseError);
// 数据格式错误也视为未登录
sessionStorage.removeItem("userData");
handleUnauthorized();
return null;
}
} catch (error) {
console.error('获取用户信息时发生错误:', error);
handleUnauthorized();
return null;
}
} }
export function setExpiresIn(time) {
return Cookies.set(ExpiresInKey, time);
}
export function removeExpiresIn() { function handleUnauthorized() {
return Cookies.remove(ExpiresInKey); removeToken();
}
// 使用 nextTick 确保路由状态已更新
import('vue').then(({ nextTick }) => {
nextTick(() => {
const currentPath = router.currentRoute.value.fullPath;
if (router.currentRoute.value.path !== '/login') {
router.push({
path: '/login',
query: {
redirect: currentPath !== '/login' ? currentPath : undefined
}
});
} else {
window.location.reload();
}
});
});
}

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

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

15
src/utils/dynamicTitle.js Normal file
View File

@@ -0,0 +1,15 @@
// import store from '@/stores/index.js'
import defaultSettings from '@/settings'
import { useSettingsStore } from '@/stores/modules/settings.js'
/**
* 动态修改标题
*/
export function useDynamicTitle() {
const settingsStore = useSettingsStore();
if (settingsStore.dynamicTitle) {
document.title = settingsStore.title + ' - ' + defaultSettings.title;
} else {
document.title = defaultSettings.title;
}
}

View File

@@ -8,23 +8,51 @@ import { tansParams } from "@/utils/ruoyi";
import cache from "@/plugins/cache"; import cache from "@/plugins/cache";
import { getToken, removeToken } from "@/utils/auth"; import { getToken, removeToken } from "@/utils/auth";
import router from '@/router'; import router from '@/router';
import { useMeterStore } from '@/stores/modules/meter' import { useMeterStore } from '@/stores/modules/meter.js'
axios.defaults.headers["Content-Type"] = "application/json;charset=utf-8"; axios.defaults.headers["Content-Type"] = "application/json;charset=utf-8";
const meterStore = useMeterStore()
meterStore.initUdid() // 动态获取 baseURL
const getBaseURL = () => {
// 开发环境使用配置的完整 URL
if (import.meta.env.DEV) {
return import.meta.env.VITE_APP_BASE_API;
}
// 生产环境使用相对路径
// 返回空字符串,让浏览器自动使用当前域名
return '';
// 或者如果后端 API 有固定路径前缀,可以这样设置:
// return '/api'; // 这样请求会变成 https://当前域名/api/xxx
};
// 创建axios实例 // 创建axios实例
const service = axios.create({ 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,
baseURL: getBaseURL(),
// 超时 // 超时
timeout: 10000, // timeout: 10000,
}); });
// request拦截器 // request拦截器
service.interceptors.request.use( service.interceptors.request.use(
(config) => { (config) => {
// 在拦截器内部安全地使用 store
let sudid = ''
try {
const meterStore = useMeterStore()
if (!meterStore.udid) {
meterStore.initUdid();
sudid = meterStore.getSudid();
} else {
sudid = meterStore.getSudid();
}
} catch (error) {
console.warn('MeterStore 初始化失败:', error);
}
// 是否需要设置 token // 是否需要设置 token
const isToken = (config.headers || {}).isToken === false; const isToken = (config.headers || {}).isToken === false;
if (getToken() && !isToken) { if (getToken() && !isToken) {
@@ -33,8 +61,8 @@ service.interceptors.request.use(
// 是否需要防止数据重复提交 // 是否需要防止数据重复提交
const isRepeatSubmit = (config.headers || {}).repeatSubmit === false; const isRepeatSubmit = (config.headers || {}).repeatSubmit === false;
if (meterStore.getSudid()) { if (sudid) {
config.headers["X-User-Agent"] = `gxtech/web 1.0.0: c=GxTech, udid=${meterStore.getSudid()}, sv=15.4.1, app=stt`; config.headers["X-User-Agent"] = `gxtech/web 1.0.0: c=GxTech, udid=${sudid}, sv=15.4.1, app=stt`;
} }
// get请求映射params参数 // get请求映射params参数
if (config.method === "get" && config.params) { if (config.method === "get" && config.params) {
@@ -121,15 +149,18 @@ service.interceptors.response.use(
switch (businessCode) { switch (businessCode) {
case 200: case 200:
case 201: case 201:
return Promise.resolve(responseData); return Promise.resolve(responseData);
case 401:
case 401: // return Promise.resolve(responseData);
console.log('未授权', responseData) // const currentPath = router.currentRoute.value.name;
return Promise.resolve(responseData); // if(currentPath == 'ConferencingRoom'){
// return handleUnauthorized().then(() => { // return Promise.resolve(responseData);
// return Promise.reject({ code: 401, message: '未授权' }); // }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' });
@@ -164,31 +195,52 @@ service.interceptors.response.use(
); );
// 单独处理401未授权 // 单独处理401未授权
function handleUnauthorized() { // function handleUnauthorized() {
return ElMessageBox.confirm( // return ElMessageBox.confirm(
'认证信息已失效,您可以继续留在该页面,或者重新登录', // '认证信息已失效,您可以继续留在该页面,或者重新登录',
'系统提示', // '系统提示',
{ // {
confirmButtonText: '重新登录', // confirmButtonText: '重新登录',
cancelButtonText: '取消', // cancelButtonText: '取消',
type: 'warning', // type: 'warning',
} // }
) // )
.then(() => { // .then(() => {
removeToken() // removeToken()
if (router.currentRoute.path !== '/login') { // if (router.currentRoute.path !== '/login') {
// router.push({
// path: '/login',
// query: { redirect: router.currentRoute.fullPath }
// });
// } else {
// // 如果在登录页,强制刷新以清除残留状态
// window.location.reload();
// }
// })
// .catch(() => {
// return Promise.reject('用户取消操作');
// });
// }
function handleUnauthorized() {
removeToken();
// 使用 nextTick 确保路由状态已更新
import('vue').then(({ nextTick }) => {
nextTick(() => {
const currentPath = router.currentRoute.value.fullPath;
if (router.currentRoute.value.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

@@ -716,3 +716,26 @@ export function removeDuplicate(arr) {
}); });
return newArr; // 返回一个新数组 return newArr; // 返回一个新数组
} }
export function createThrottle(func, delay){
let timeoutId;
let lastExecTime = 0;
return (...args) => {
const currentTime = Date.now();
// 立即执行第一次
if (currentTime - lastExecTime > delay) {
func.apply(this, args);
lastExecTime = currentTime;
} else {
// 清除之前的定时器,设置新的定时器
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
func.apply(this, args);
lastExecTime = Date.now();
}, delay);
}
};
};

49
src/utils/theme.js Normal file
View File

@@ -0,0 +1,49 @@
// 处理主题样式
export function handleThemeStyle(theme) {
document.documentElement.style.setProperty('--el-color-primary', theme)
for (let i = 1; i <= 9; i++) {
document.documentElement.style.setProperty(`--el-color-primary-light-${i}`, `${getLightColor(theme, i / 10)}`)
}
for (let i = 1; i <= 9; i++) {
document.documentElement.style.setProperty(`--el-color-primary-dark-${i}`, `${getDarkColor(theme, i / 10)}`)
}
}
// hex颜色转rgb颜色
export function hexToRgb(str) {
str = str.replace('#', '')
let hexs = str.match(/../g)
for (let i = 0; i < 3; i++) {
hexs[i] = parseInt(hexs[i], 16)
}
return hexs
}
// rgb颜色转Hex颜色
export function rgbToHex(r, g, b) {
let hexs = [r.toString(16), g.toString(16), b.toString(16)]
for (let i = 0; i < 3; i++) {
if (hexs[i].length == 1) {
hexs[i] = `0${hexs[i]}`
}
}
return `#${hexs.join('')}`
}
// 变浅颜色值
export function getLightColor(color, level) {
let rgb = hexToRgb(color)
for (let i = 0; i < 3; i++) {
rgb[i] = Math.floor((255 - rgb[i]) * level + rgb[i])
}
return rgbToHex(rgb[0], rgb[1], rgb[2])
}
// 变深颜色值
export function getDarkColor(color, level) {
let rgb = hexToRgb(color)
for (let i = 0; i < 3; i++) {
rgb[i] = Math.floor(rgb[i] * (1 - level))
}
return rgbToHex(rgb[0], rgb[1], rgb[2])
}

View File

@@ -157,4 +157,4 @@ function isValidBox(x1, y1, x2, y2, imgWidth, imgHeight) {
x2 > x1 && y2 > y1 && x2 > x1 && y2 > y1 &&
x2 <= imgWidth && y2 <= imgHeight x2 <= imgWidth && y2 <= imgHeight
); );
} }

93
src/utils/validate.js Normal file
View File

@@ -0,0 +1,93 @@
/**
* 判断url是否是http或https
* @param {string} path
* @returns {Boolean}
*/
export function isHttp(url) {
return url.indexOf('http://') !== -1 || url.indexOf('https://') !== -1
}
/**
* 判断path是否为外链
* @param {string} path
* @returns {Boolean}
*/
export function isExternal(path) {
return /^(https?:|mailto:|tel:)/.test(path)
}
/**
* @param {string} str
* @returns {Boolean}
*/
export function validUsername(str) {
const valid_map = ['admin', 'editor']
return valid_map.indexOf(str.trim()) >= 0
}
/**
* @param {string} url
* @returns {Boolean}
*/
export function validURL(url) {
const reg = /^(https?|ftp):\/\/([a-zA-Z0-9.-]+(:[a-zA-Z0-9.&%$-]+)*@)*((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}|([a-zA-Z0-9-]+\.)*[a-zA-Z0-9-]+\.(com|edu|gov|int|mil|net|org|biz|arpa|info|name|pro|aero|coop|museum|[a-zA-Z]{2}))(:[0-9]+)*(\/($|[a-zA-Z0-9.,?'\\+&%$#=~_-]+))*$/
return reg.test(url)
}
/**
* @param {string} str
* @returns {Boolean}
*/
export function validLowerCase(str) {
const reg = /^[a-z]+$/
return reg.test(str)
}
/**
* @param {string} str
* @returns {Boolean}
*/
export function validUpperCase(str) {
const reg = /^[A-Z]+$/
return reg.test(str)
}
/**
* @param {string} str
* @returns {Boolean}
*/
export function validAlphabets(str) {
const reg = /^[A-Za-z]+$/
return reg.test(str)
}
/**
* @param {string} email
* @returns {Boolean}
*/
export function validEmail(email) {
const reg = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
return reg.test(email)
}
/**
* @param {string} str
* @returns {Boolean}
*/
export function isString(str) {
if (typeof str === 'string' || str instanceof String) {
return true
}
return false
}
/**
* @param {Array} arg
* @returns {Boolean}
*/
export function isArray(arg) {
if (typeof Array.isArray === 'undefined') {
return Object.prototype.toString.call(arg) === '[object Array]'
}
return Array.isArray(arg)
}

View File

@@ -2,7 +2,8 @@ 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';
import { emitter } from "@/utils/bus.js";
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);
@@ -23,38 +24,36 @@ function getLocalUserData() {
export const WhiteboardSync = { export const WhiteboardSync = {
async init(canvas, roomUid) { async init(canvas, roomUid) {
if (!canvas || !roomUid) return; if (!canvas || !roomUid) return;
console.log('初始化多人同步:', roomUid);
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());
console.log("✅ MQTT 已连接");
// 获取历史数据 // 获取历史数据
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);
} }
// 订阅当前房间 // 订阅当前房间
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, '格式解码')
try { try {
isRemote = true; isRemote = true;
// 如果 shape 来自本地用户,则跳过 // 如果 shape 来自本地用户,则跳过
if (shapeDataNew.user_uid === localUid) return; if (shapeDataNew.user_uid === localUid) return;
const res = await getWhiteboardHistory({ after_timestamp: shapeDataNew.created_at }, roomUid); const res = await getWhiteboardHistory({ after_timestamp: shapeDataNew.created_at }, roomUid);
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) {
@@ -62,16 +61,14 @@ export const WhiteboardSync = {
} finally { } finally {
isRemote = false; isRemote = false;
} }
}); });
console.log("✅ 已订阅:", topic);
} catch (err) { } catch (err) {
console.log("初始化多人同步失败:", err) console.log("初始化多人同步失败:", err)
// console.error("❌ 连接或订阅失败:", err); // console.error("❌ 连接或订阅失败:", err);
} }
// 监听画布事件:新增图形 // 监听画布事件:新增图形
canvas.on('drawingEnd', async (shape) => { canvas.on('drawingEnd', async (shape) => {
// 如果来自远程,或不是需要同步的类型,跳过 // 如果来自远程,或不是需要同步的类型,跳过
if (isRemote || !['pencil', 'line', 'rectangle', 'circle', 'eraser'].includes(shape.type)) return; if (isRemote || !['pencil', 'line', 'rectangle', 'circle', 'eraser'].includes(shape.type)) return;
@@ -82,6 +79,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

@@ -1,3 +1,107 @@
export function generateUUID() { import { ElMessage } from 'element-plus';
export function errorHandling(error,type) {
switch (error.name) {
case 'NotAllowedError':
ElMessage.error(`用户拒绝了权限请求,请允许此网站使用${type}权限`);
break;
case 'NotFoundError':
ElMessage.error(`未检测到可用的${type}设备,请检查${type}是否已正确连接`);
break;
case 'NotSupportedError':
ElMessage.error(`当前浏览器不支持${type}功能请使用现代浏览器如Chrome、Firefox或Edge`);
break;
case 'NotReadableError':
ElMessage.error(`${type}设备正被其他应用程序占用,请关闭其他使用${type}的应用后重试`);
break;
case 'OverconstrainedError':
ElMessage.error(`${type}配置不兼容,请尝试调整${type}设置`);
break;
default:
ElMessage.error('服务错误,请刷新重试');
}
}
// 处理数据接收事件
export function handleDataReceived(payload, participant, kind) {
try {
const decoder = new TextDecoder();
const strData = decoder.decode(payload);
ElMessage.info(`收到消息 from ${participant.identity}: ${strData}`);
} catch (error) {
console.error('处理接收消息失败:', error);
}
}
export function handleReconnected() {
ElMessage.success('已重新连接到房间');
}
// 获取设备名称
export function getDeviceName(devices, deviceId) {
const device = devices.find(d => d.deviceId === deviceId);
return device ? (device.label || '未知设备') : '未知设备';
}
export function handleVideoLoaded(identity, type) {
// console.log(`视频加载完成: ${identity}的${type}视频`);
}
export function handleConnectionStateChanged(state) {
// console.log('连接状态改变:', state);
}
// 平滑曲线绘制函数
export function drawSmoothCurve(ctx, path) {
if (path.length < 3) {
// 如果点太少,直接绘制直线
ctx.beginPath();
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.beginPath();
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);
}
//计算文件的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);
}
// 生成唯一ID
export function generateElementId() {
return `laser_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
} }

View File

@@ -0,0 +1,366 @@
<template>
<el-drawer v-model="drawerVisible" direction="rtl" title="请选择要加入房间的人员" size="40%">
<template #header>
<h4>请选择要加入房间的人员</h4>
</template>
<div class="drawer-content">
<!-- 搜索框 -->
<div class="search-section">
<el-input
v-model="searchKeyword"
placeholder="搜索人员或部门"
clearable
prefix-icon="Search"
@input="handleSearch"
/>
</div>
<!-- 已选人员展示 -->
<div class="selected-section" v-if="selectedUsers.length > 0">
<div class="selected-header">
<span>已选择 ({{ selectedUsers.length }})</span>
<el-button type="text" @click="clearAllSelected">清空</el-button>
</div>
<div class="selected-tags">
<el-tag
v-for="user in selectedUsers"
:key="user.uid"
closable
@close="removeSelectedUser(user)"
class="selected-tag"
>
{{ user.name }}
</el-tag>
</div>
</div>
<!-- 树形结构 -->
<el-scrollbar v-loading="leftListLoading" class="left-list-scrollbar" height="calc(100vh - 240px)">
<el-tree
ref="treeRef"
lazy
:load="handleLoadNode"
:props="treeProps"
node-key="uid"
show-checkbox
:default-expand-all="false"
:expand-on-click-node="false"
:check-strictly="false"
:filter-node-method="filterNode"
@check="handleCheckChange"
style="width: 100%"
>
<template #default="{ node, data }">
<div class="tree-item">
<div class="tree-item-content">
<span v-if="data.uid" class="user-icon">👤</span>
<span v-else class="dept-icon">📁</span>
<span class="tree-item-text">{{ data.name }}</span>
<span v-if="data.users_count" class="user-count">({{ data.users_count }})</span>
<!-- <span v-if="isNodeDisabled(data)" class="already-joined-tag">已在房间中</span> -->
</div>
</div>
</template>
</el-tree>
</el-scrollbar>
</div>
<template #footer>
<div class="footer-actions">
<el-button @click="cancelClick"> </el-button>
<el-button type="primary" @click="confirmClick" :disabled="selectedUsers.length === 0">
({{ selectedUsers.length }})
</el-button>
</div>
</template>
</el-drawer>
</template>
<script setup>
import {
getDirectories,
getDirectoriesUsers
} from '@/api/coordinate.js'
import { getParticipantsApi } from '@/api/conferencingRoom.js'
import { nextTick, reactive, toRefs, watch, onMounted, ref } from "vue";
// 定义 emit
const emit = defineEmits(["confirmSelection"]);
// 接收 props
const props = defineProps({
loading: {
type: Boolean,
default: true,
},
});
// 响应式数据
const drawerVisible = ref(false);
const treeRef = ref();
const searchKeyword = ref('');
const selectedUsers = ref([]);
const checkedNodes = ref([]);
const leftListLoading = ref(false)
const joinedUserIds = ref([]); // 存储已在房间中的用户ID
// 树形配置
const treeProps = reactive({
children: 'users',
label: 'name',
value: 'uid',
disabled: (data) => {
// 只有人员节点有uid且该用户已在房间中时才禁用
return data.uid && joinedUserIds.value.includes(data.uid);
},
isLeaf: (node) => {
return !!node.uid; // 有 uid 的为叶子节点(人员)
},
});
const isNodeDisabled = (data) => {
// 只有人员节点有uid且该用户已在房间中时才禁用
return data.uid && joinedUserIds.value.includes(data.uid);
};
// 显示抽屉
async function show(roomId) {
drawerVisible.value = true;
// 重置选择状态
selectedUsers.value = [];
checkedNodes.value = [];
searchKeyword.value = '';
// 获取已在房间中的用户
try {
const res = await getParticipantsApi(roomId);
// 提取状态为1在房间中的用户ID
joinedUserIds.value = res.data
.filter(item => item.status == 1)
.map(item => item.user_uid);
} catch (error) {
console.error('获取房间参与者失败:', error);
joinedUserIds.value = [];
}
// 延迟加载树形数据,确保 DOM 已渲染
nextTick(() => {
if (treeRef.value) {
treeRef.value.setCheckedKeys([]);
}
});
}
async function getJoinUsers(){
const res = await getParticipantsApi(roomId)
const joinUsers = res.data.filter(item => item.status == 1).map(item => item.user_uid)
return joinUsers;
}
// 确认选择
function confirmClick() {
if (selectedUsers.value.length > 0) {
emit('confirmSelection', selectedUsers.value);
drawerVisible.value = false;
}
}
// 取消选择
function cancelClick() {
drawerVisible.value = false;
}
// 加载树节点
const handleLoadNode = async (node, resolve) => {
if(node?.level === 0){
loadNode(resolve,'',node?.level)
}else if(node?.level > 0){
if(node.data.directory_uid){
loadUserNode(resolve,node.data.directory_uid,node?.level)
}else{
resolve(resolve)
}
}
}
// 加载节点数据
const loadNode = async (resolve, id) => {
try {
leftListLoading.value = true
let res = await getDirectories({level:1})
if(res.meta.code == 200){
resolve(res.data)
}
leftListLoading.value = false
} catch (error) {
console.log(error)
leftListLoading.value = false
}
}
const loadUserNode = async(resolve,id,level)=>{
try {
leftListLoading.value = true
let userData = []
let orgData = []
const resOrg = await getDirectories({level: 1,parent_uuid:id})
if(resOrg?.data){
orgData = resOrg.data
}
if(id){
const res = await getDirectoriesUsers(id,{directory_uuid:id})
userData = res.data
}
resolve([...orgData, ...userData])
leftListLoading.value = false
} catch (error) {
console.log(error)
leftListLoading.value = false
}
}
// 处理复选框选择变化
const handleCheckChange = (checkedNode, checkedNodesInfo) => {
// 获取所有选中的节点
const checkedKeys = treeRef.value.getCheckedKeys();
const halfCheckedKeys = treeRef.value.getHalfCheckedKeys();
// 获取所有选中节点数据
const allCheckedNodes = treeRef.value.getCheckedNodes(false, true);
// 筛选出人员节点(有 uid 的节点)且不在已加入用户列表中的
const userNodes = allCheckedNodes.filter(node =>
node.uid && !joinedUserIds.value.includes(node.uid)
);
// 更新已选用户列表
selectedUsers.value = userNodes;
checkedNodes.value = allCheckedNodes;
}
// 移除已选用户
const removeSelectedUser = (user) => {
// 从已选列表中移除
const index = selectedUsers.value.findIndex(u => u.uid === user.uid);
if (index !== -1) {
selectedUsers.value.splice(index, 1);
}
// 更新树的选中状态
if (treeRef.value) {
treeRef.value.setChecked(user.uid, false, false);
}
}
// 清空所有选择
const clearAllSelected = () => {
selectedUsers.value = [];
checkedNodes.value = [];
if (treeRef.value) {
treeRef.value.setCheckedKeys([]);
}
}
// 搜索过滤
const handleSearch = () => {
if (treeRef.value) {
treeRef.value.filter(searchKeyword.value);
}
}
// 节点过滤方法
const filterNode = (value, data) => {
if (!value) return true;
return data.name && data.name.toLowerCase().includes(value.toLowerCase());
}
// 暴露方法给父组件
defineExpose({
show,
getSelectedUsers: () => selectedUsers.value
});
</script>
<style lang="scss" scoped>
.drawer-content {
padding: 0 10px;
}
.search-section {
margin-bottom: 16px;
}
.selected-section {
margin-bottom: 16px;
padding: 12px;
background-color: #f5f7fa;
border-radius: 4px;
}
.selected-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
font-size: 14px;
color: #606266;
}
.selected-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.selected-tag {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tree-item {
display: flex;
align-items: center;
height: 36px;
font-size: 14px;
.tree-item-content {
display: flex;
align-items: center;
width: 100%;
}
.user-icon, .dept-icon {
margin-right: 8px;
font-size: 16px;
}
.tree-item-text {
color: #333333;
font-weight: 500;
}
.user-count {
margin-left: 6px;
color: #999999;
font-size: 12px;
}
}
.footer-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
}
:deep(.el-tree-node__content) {
height: 40px;
}
:deep(.el-tree-node.is-current > .el-tree-node__content) {
background-color: #f0f7ff;
}
</style>

View File

@@ -0,0 +1,871 @@
<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>
<el-dialog
v-model="dialogFileVisible"
title="文件转换"
width="50%"
class="file-preview-dialog"
>
<div class="preview-container">
<div class="loading-container">
<el-icon class="is-loading" size="48">
<Loading />
</el-icon>
<p>正在下载文件请稍候...</p>
</div>
</div>
</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('')
const dialogFileVisible = ref(false)
// 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)
emitter.on('fileSuccess',fileSuccess)
function fileSuccess(){
dialogFileVisible.value = true
}
//
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){
// console.log('文件上传成功mqtt消息')
}
// 初始化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')
console.log(data,'data文件转换完成预览通知')
const userId = JSON.parse(sessionStorage.getItem('userData'))?.uid
if(dialogFileVisible.value){
dialogFileVisible.value = false
}
if(dialogFormVisible.value && userId != data.user_uid){
// 显示确认对话框
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,268 @@
<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){
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

@@ -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,293 @@
<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
emitter.emit('fileSuccess')
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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -116,7 +116,7 @@ service.interceptors.response.use(
return Promise.resolve(responseData); return Promise.resolve(responseData);
case 401: case 401:
console.log('未授权', responseData) 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: '未授权' });
@@ -168,12 +168,12 @@ function handleUnauthorized() {
) )
.then(() => { .then(() => {
removeToken() removeToken()
if (router.currentRoute.path !== '/login') { if (router.currentRoute.path !== '/login') {
router.push({ router.push({
path: '/login', path: '/login',
query: { redirect: router.currentRoute.fullPath } query: { redirect: router.currentRoute.fullPath }
}); });
} else { } else {
// 如果在登录页,强制刷新以清除残留状态 // 如果在登录页,强制刷新以清除残留状态
window.location.reload(); window.location.reload();
} }

View File

@@ -0,0 +1,236 @@
<template>
<div>
<el-dialog
v-model="dialogFormVisible"
width="80%"
:show-close="false"
:destroy-on-close="true"
:modal="false"
:lock-scroll="true"
:before-close="handleClose"
class="call-dialog"
>
<div class="call-wrapper">
<!-- 上方头像与状态 -->
<div class="avatar-section">
<img class="avatar" :src="avatarUrl" alt="头像" />
<div class="user-name">{{ userName }}</div>
<div class="status-text">{{ statusText }}</div>
</div>
<!-- 底部操作按钮 -->
<div class="control-section">
<!-- 呼叫模式 -->
<template v-if="mode === 'call'">
<el-button
v-if="callStatus === 'calling'"
type="danger"
round
class="control-btn hangup"
@click="hangup"
>
挂断
</el-button>
<el-button
v-else
type="primary"
round
class="control-btn call"
@click="startCall"
>
呼叫中...
</el-button>
</template>
<!-- 接听模式 -->
<template v-else>
<el-button
type="success"
round
class="control-btn accept"
@click="acceptCall"
>
接听
</el-button>
<el-button
type="danger"
round
class="control-btn hangup"
@click="hangup"
>
挂断
</el-button>
</template>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed } from "vue";
import { ElMessage } from "element-plus";
const props = defineProps({
mode: { type: String, default: "call" }, // call: 呼叫模式, receive: 接听模式
avatarUrl: {
type: String,
default: "https://cdn-icons-png.flaticon.com/512/1946/1946429.png",
},
userName: { type: String, default: "对方用户" },
});
const dialogFormVisible = ref(false);
const callStatus = ref("calling"); // calling | active | ended
const statusText = computed(() => {
if (props.mode === "call") {
if (callStatus.value === "calling") return "正在呼叫对方...";
if (callStatus.value === "active") return "通话中";
return "通话结束";
} else {
return callStatus.value === "active" ? "通话中" : "对方来电...";
}
});
function startCall() {
callStatus.value = "active";
ElMessage.success("开始通话");
}
function acceptCall() {
callStatus.value = "active";
ElMessage.success("已接听");
}
function hangup() {
callStatus.value = "ended";
ElMessage.error("通话已结束");
}
function handleClose() {
dialogFormVisible.value = false;
}
function show() {
dialogFormVisible.value = true;
}
defineExpose({
show,
});
</script>
<style scoped>
/* ::v-deep .el-dialog__header{
background-color:red;
} */
/* 去除 el-dialog 默认样式,让它全屏覆盖且透明 */
.call-dialog :deep(.el-dialog) {
background: transparent;
box-shadow: none;
padding: 0;
margin: 0;
border-radius: 0;
height: calc(100vh - 30vh);
width: 100%;
max-width: none;
display: flex;
align-items: center;
justify-content: center;
}
.call-dialog :deep(.el-dialog__body) {
padding: 0;
height: 100%;
}
/* 通话主背景 */
.call-wrapper {
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
height: calc(100vh - 30vh);
width: 100%;
background: linear-gradient(180deg, #0f2027, #203a43, #2c5364);
color: #fff;
box-sizing: border-box;
padding: 80px 0;
position: relative;
}
/* 头像部分 */
.avatar-section {
display: flex;
flex-direction: column;
align-items: center;
}
.avatar {
width: 180px;
height: 180px;
border-radius: 50%;
border: 6px solid rgba(255, 255, 255, 0.25);
box-shadow: 0 0 40px rgba(0, 0, 0, 0.4);
object-fit: cover;
transition: transform 0.3s ease;
}
.avatar:hover {
transform: scale(1.05);
}
.user-name {
margin-top: 24px;
font-size: 26px;
font-weight: 600;
color: #fff;
}
.status-text {
margin-top: 12px;
font-size: 18px;
opacity: 0.8;
}
/* 控制按钮部分 */
.control-section {
display: flex;
justify-content: center;
align-items: center;
gap: 80px;
}
.control-btn {
width: 160px;
height: 60px;
font-size: 18px;
font-weight: 500;
letter-spacing: 1px;
transition: all 0.3s ease;
}
.control-btn:hover {
transform: scale(1.05);
}
.control-btn.accept {
background: linear-gradient(135deg, #00c853, #4caf50);
color: #fff;
border: none;
}
.control-btn.hangup {
background: linear-gradient(135deg, #ff1744, #d50000);
color: #fff;
border: none;
}
.control-btn.call {
background: linear-gradient(135deg, #2196f3, #1976d2);
color: #fff;
border: none;
}
</style>

View File

@@ -1,32 +1,63 @@
<template> <template>
<div> <div>
<!-- v-loading="leftListLoading || loading" -->
<div class="left-list" > <div class="left-list" v-loading="leftListLoading || loading">
<div class="list-tab"> <div class="list-tab">
<div <div
: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">
<div class="content-top-input"> <div class="content-top-input" v-if="leftTab == 1">
<!-- <el-input
v-model="queryFrom.nickName"
placeholder="搜索成员"
type="text"
prefix-icon="Search"
@change="searchList"
/> -->
<el-select
v-model="participant_user"
multiple
filterable
clearable
remote
reserve-keyword
placeholder="搜索成员"
:remote-method="remoteMethod"
:loading="loading"
collapse-tags
collapse-tags-tooltip
style="width: 100%"
@change="searchList"
>
<el-option
v-for="item in userList"
:key="item.uid"
:label="item.name"
:value="item.uid"
/>
</el-select>
</div>
<div class="content-top-input" v-if="leftTab == 2">
<el-input <el-input
v-model="queryFrom.nickName" v-model="queryFrom.nickName"
placeholder="搜索成员" placeholder="搜索成员"
type="text" type="text"
prefix-icon="Search" prefix-icon="Search"
@change="searchList" @change="searchList"
/> />
</div> </div>
<div class="content-datapicker" v-if="leftTab == 1"> <div class="content-datapicker" v-if="leftTab == 1">
<el-date-picker <el-date-picker
@@ -47,49 +78,50 @@
v-infinite-scroll="infinite" v-infinite-scroll="infinite"
v-if="dataList?.length" v-if="dataList?.length"
> >
<!-- @click="updateDetail(item)" -->
<div <div
v-for="(item, index) in dataList" v-for="(item, index) in dataList"
:key="index" :key="index"
class="content-list-item" class="content-list-item"
@click="updateDetail(item)" @click="updateDetail(item)"
:style=" :style="
item.assistanceId == assistanceId item.id == assistanceId
? 'border-color: #409EFF; ' ? 'border-color: #409EFF; '
: '' : ''
" "
> >
<div class="list-item-top"> <div class="list-item-top">
<span> <span>
{{ parseTime(item.beginTime, '{m}月{d}日') }} {{ parseTime(item.created_at, '{m}月{d}日') }}
{{ weekName[new Date(item.beginTime).getDay()] }} {{ weekName[new Date(item.created_at).getDay()] }}
</span> </span>
<span> <span>
{{ parseTime(item.beginTime, '{y}年') }} {{ parseTime(item.created_at, '{y}年') }}
</span> </span>
</div> </div>
<div class="list-item-content"> <div class="list-item-content">
<div class="list-item-content-text"> <div class="list-item-content-text">
<div style="display: flex; flex-wrap: wrap"> <div style="display: flex; flex-wrap: wrap">
<span <span
v-for="(items, indexs) in item.assistanceMemberList" v-for="(items, indexs) in item.all_participants"
:key="indexs" :key="indexs"
> >
{{ {{
items.nickName + items.display_name +
(indexs + 1 == item.assistanceMemberList.length (indexs + 1 == item.all_participants.length
? '' ? ''
: '、') : '、')
}} }}
</span> </span>
</div> </div>
<span> <span>
发起人{{ item.initiatorName ? item.initiatorName : '' }} 发起人{{ item.all_participants.find(item => item.participant_role == 'moderator')?.display_name || ''}}
</span> </span>
<span> <span>
时间{{ 时间{{
parseTime(item.beginTime, '{h}:{i}') + parseTime(item.created_at, '{h}:{i}') +
' ~ ' + ' ~ ' +
(item.endTime ? parseTime(item.endTime, '{h}:{i}') : '') (item.updated_at ? parseTime(item.updated_at, '{h}:{i}') : '')
}} }}
</span> </span>
</div> </div>
@@ -133,15 +165,19 @@
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { import {
// getAssistanceList, // getAssistanceList,
getParticipantsHistoryApi,
getDirectories, getDirectories,
getDirectoriesUsers getDirectoriesUsers,
getInfo,
} from '@/api/coordinate.js' } from '@/api/coordinate.js'
import { nextTick, reactive, toRefs, watch, onMounted } from 'vue' import { nextTick, reactive, toRefs, watch, onMounted } from 'vue'
import { deepClone,parseTime ,createThrottle} from '@/utils/ruoyi.js'
import { getUserInfo } from '@/utils/auth.js'
// 接收 props // 接收 props
const props = defineProps({ const props = defineProps({
@@ -157,13 +193,15 @@ const emit = defineEmits(['updateDetail', 'updateTab'])
// state // state
const state = reactive({ const state = reactive({
isFirst: true, isFirst: true,
leftTab: 2, leftTab: 1,
queryFrom: { queryFrom: {
pageNum: 1, page_size: 10,
pageSize: 10, page: 1,
nickName: '', nickName: '',
leftDatePicker: null, leftDatePicker: null,
participant_user_uids:''
}, },
participant_user:[],
leftListLoading: true, leftListLoading: true,
loading: false, loading: false,
dataList: [], dataList: [],
@@ -216,7 +254,22 @@ const state = reactive({
}, },
}, },
assistanceId: '', assistanceId: '',
}) userList:[],
})
// state.loading = true;
const remoteMethod = createThrottle(async (query) => {
if (!query || query.trim() === '') {
state.userList = [];
return;
}
try {
const res = await getInfo(query, { search_type: 'name' });
state.userList = res.data || [];
} catch (error) {
console.error('搜索用户失败:', error);
state.userList = [];
}
}, 500);
/** /**
* 树状列表筛选 * 树状列表筛选
@@ -231,11 +284,11 @@ const filterNode = (value, data) => {
*/ */
const searchList = () => { const searchList = () => {
if (state.leftTab == 1) { if (state.leftTab == 1) {
state.queryFrom.pageNum = 1 state.queryFrom.page = 1
state.dataList = [] state.dataList = []
state.queryFrom.participant_user_uids = state.participant_user.join(',')
getList() getList()
} else { } else {
console.log('treeRef.filter',state.treeRef)
state.treeRef.filter(state.queryFrom.nickName) state.treeRef.filter(state.queryFrom.nickName)
} }
} }
@@ -244,8 +297,8 @@ const searchList = () => {
* 变更详情 * 变更详情
*/ */
const updateDetail = (item) => { const updateDetail = (item) => {
if (state.leftTab == 1) { if (state.leftTab == 1) {
state.assistanceId = item.assistanceId state.assistanceId = item.id
emit('updateDetail', item) emit('updateDetail', item)
} else { } else {
if (item.uid) { if (item.uid) {
@@ -257,43 +310,63 @@ const updateDetail = (item) => {
/** /**
* 触底加载 * 触底加载
*/ */
// const infinite = () => { const infinite = () => {
// if (state.more) { if (state.more) {
// state.queryFrom.pageNum++ state.queryFrom.page++
// getList() getList()
// } }
// } }
/** /**
* 协作记录 * 协作记录
*/ */
const getList = async () => { const getList = async () => {
try { try {
state.leftListLoading = true state.leftListLoading = true
let query = deepClone(state.queryFrom)
// if (query.leftDatePicker?.length) {
// query.beginSignTime = query.leftDatePicker[0]
// query.endSignTime = query.leftDatePicker[1]
// }
let query = structuredClone(state.queryFrom) if (query.leftDatePicker?.length) {
if (query.leftDatePicker?.length) { const startTime = new Date(query.leftDatePicker[0]);
query.beginSignTime = query.leftDatePicker[0] const endTime = new Date(query.leftDatePicker[1]);
query.endSignTime = query.leftDatePicker[1]
// 添加日期有效性验证
if (!isNaN(startTime.getTime())) {
query.start_time = Math.floor(startTime.getTime());
} else {
console.error('开始时间格式无效:', query.leftDatePicker[0]);
query.start_time = '';
}
if (!isNaN(endTime.getTime())) {
query.end_time = Math.floor(endTime.getTime());
} else {
console.error('结束时间格式无效:', query.leftDatePicker[1]);
query.end_time = '';
}
} }
delete query.leftDatePicker
let infoData = await getAssistanceList({ ...query }) delete query.leftDatePicker
const userData = await getUserInfo()
state.dataList = infoData.rows.length if(!userData) return
? state.dataList.concat(infoData.rows) let infoData = await getParticipantsHistoryApi( userData?.uid ,{ ...query })
state.dataList = infoData.data.history?.length
? state.dataList.concat(infoData.data.history)
: [] : []
if (state.isFirst) { if (state.isFirst) {
emit('updateDetail', state.dataList.length ? state.dataList[0] : null) emit('updateDetail', state.dataList?.length ? state.dataList[0] : null)
state.assistanceId = state.dataList.length state.assistanceId = state.dataList?.length
? state.dataList[0].assistanceId ? state.dataList[0].id
: '' : ''
state.isFirst = false state.isFirst = false
} }
state.more = state.dataList.length < infoData.total state.more = state.dataList?.length < infoData.data.total
state.isShow = Boolean(state.dataList.length) state.isShow = Boolean(state.dataList?.length)
state.leftListLoading = false state.leftListLoading = false
} catch (err) { } catch (err) {
console.log(err) console.log(err)
@@ -304,43 +377,53 @@ 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,'',node?.level)
}else if(node.level === 1){ }else if(node?.level > 0){
loadNode(resolve,node.data.directory_uid) if(node.data.directory_uid){
loadUserNode(resolve,node.data.directory_uid,node?.level)
}else{
resolve(resolve)
}
} }
} }
const loadNode = async(resolve,id)=>{ const loadNode = async(resolve,id,level)=>{
try { try {
// state.leftListLoading = true state.leftListLoading = true
if(!id){ let res = await getDirectories({level:1})
let res = await getDirectories({level:1}) if(res.meta.code == 200){
resolve(res.data) resolve(res.data)
}else{ }
let res = await getDirectoriesUsers(id,{directory_uuid:id}) state.leftListLoading = false
resolve(res.data)
}
// state.leftListLoading = false
} catch (error) { } catch (error) {
console.log(error) console.log(error)
// state.leftListLoading = false state.leftListLoading = false
} }
} }
// try { const loadUserNode = async(resolve,id,level)=>{
// if (userList.data.sub_units?.length) { try {
// state.dataList = userList.data.sub_units state.leftListLoading = true
// let user = getUser(state.dataList) let userData = []
// emit('updateDetail', user) let orgData = []
// } else { const resOrg = await getDirectories({level: 1,parent_uuid:id})
// emit('updateDetail', null) if(resOrg?.data){
// } orgData = resOrg.data
// state.leftListLoading = false }
// } if(id){
const res = await getDirectoriesUsers(id,{directory_uuid:id})
userData = res.data
}
resolve([...orgData, ...userData])
state.leftListLoading = false
} catch (error) {
console.log(error)
state.leftListLoading = false
}
}
const getUser = (list) => { const getUser = (list) => {
for (let i = 0; i < list.length; i++) { for (let i = 0; i < list.length; i++) {
@@ -375,9 +458,10 @@ watch(
state.dataList = [] state.dataList = []
state.isFirst = true state.isFirst = true
state.queryFrom = { state.queryFrom = {
pageNum: 1, page_size: 10,
pageSize: 10, page: 1,
nickName: '', nickName: '',
participant_user_uids:'',
leftDatePicker: null, leftDatePicker: null,
} }
@@ -393,7 +477,7 @@ watch(
onMounted(() => { onMounted(() => {
state.dataList = [] state.dataList = []
state.isFirst = true state.isFirst = true
// getList() getList()
}) })
/** /**
@@ -402,7 +486,7 @@ onMounted(() => {
const { const {
isFirst, leftTab, queryFrom, leftListLoading, loading, isFirst, leftTab, queryFrom, leftListLoading, loading,
dataList, more, isShow, shortcuts, weekName, dataList, more, isShow, shortcuts, weekName,
treeRef, treeProps, assistanceId treeRef, treeProps, assistanceId,userList,participant_user
} = toRefs(state) } = toRefs(state)
</script> </script>
@@ -430,8 +514,8 @@ const {
cursor: pointer; cursor: pointer;
img { img {
width: 50px; width: 40px;
height: 50px; height: 40px;
} }
} }
} }
@@ -447,7 +531,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,290 +1,298 @@
<template> <template>
<div class="app-container" v-loading="load" :element-loading-text="loadText"> <div>
<el-row :gutter="6" style="padding: 0 10px; background: #fefefe"> <div class="app-container" v-loading="load" :element-loading-text="loadText">
<el-col :xs="24" :sm="24" :md="8" :lg="6"> <el-row :gutter="6" style="padding: 0 10px; background: #fefefe">
<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"> <el-col :xs="24" :sm="24" :md="8" :lg="6">
<div> <leftTab
<div class="file-top"> @updateDetail="updateDetail"
附件({{ @updateTab="updateTab"
detail?.fileList?.length ? detail.fileList.length : 0 :loading="!detail?.appId && !detail?.userId && isShow"
}} />
</el-col>
<el-col :xs="24" :sm="24" :md="16" :lg="18">
<div
class="right-content"
v-loading='isShowLoading'
>
<div class="right-content-title">
{{ tabValue == 1 ? '协作信息' : '员工信息' }}
</div> </div>
<div class="content-file-list"> <div
<el-scrollbar class="agency-detail-massage-cont right-content-message"
class="file-list" v-if="isShow && tabValue == 1"
height="calc(100vh - 380px)" >
> <div class="agency-detail-cont-item">
<div <span class="agency-detail-item-title">发起人</span>
class="file-list-content" <span class="agency-detail-item-content">
v-if="detail?.fileList?.length" {{ detail.initiator }}
</span>
</div>
<div class="agency-detail-cont-item">
<span class="agency-detail-item-title">协作时间</span>
<span class="agency-detail-item-content">
{{
detail.created_at ?
parseTime(detail.created_at, '{y}年{m}月{d}日') +
' ' +
weekName[new Date(detail.created_at).getDay()] +
' ' +
parseTime(detail.created_at, '{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?.all_participants?.length"
style="display: flex; flex-wrap: wrap"
> >
<div <span
class="file-list-item" v-for="(items, indexs) in detail.all_participants"
v-for="(item, index) in detail.fileList" :key="indexs"
:key="index" >
> {{
<div class="file-list-item-icon"></div> items.display_name +
<div class="file-list-item-text"> (indexs + 1 == detail.all_participants.length
<div class="list-item-text text-out-of-hiding-1"> ? ''
{{ item.name }} : '、')
</div> }}
<el-link </span>
:href="item.prefix + item.path" </span>
type="primary" </div>
target="_blank" <div class="agency-detail-cont-item">
:underline="false" <span class="agency-detail-item-title">协作时长</span>
> <span class="agency-detail-item-content">
<el-icon {{ getTime() }}
:size="18" </span>
color="#0d74ff" </div>
style="cursor: pointer" </div>
>
<Download /> <div class="right-content-file" v-if="isShow && tabValue == 1">
</el-icon> <el-row :gutter="15">
</el-link> <el-col :xs="24" :sm="24" :md="16" :lg="18">
<el-icon <div class="content-file-video">
:size="18" <div class="file-top">协作视频</div>
color="#FF4646" <div class="file-video-bottom">
style="cursor: pointer" <!-- autoplay="autoplay" -->
@click="clickDeleteFile(item)" <video
> v-if="detail.remoteVideoFile?.storage_url"
<Delete /> :src="detail.remoteVideoFile.storage_url"
</el-icon> id="videoPlayer"
loop
autoplay
controls
></video>
<div v-else class="video-null">暂无视频</div>
</div> </div>
</div>
</div> </div>
</el-scrollbar> </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.file_name }}
</div>
<el-icon
:size="18"
color="#0d74ff"
style="cursor: pointer"
@click="handlePreview(item)"
>
<View />
</el-icon>
<el-link
:href="item.source_url"
type="primary"
target="_blank"
:underline="false"
>
<el-icon
:size="18"
color="#0d74ff"
style="cursor: pointer"
>
<Download />
</el-icon>
</el-link>
</div>
</div>
</div>
</el-scrollbar>
</div>
</div>
</el-col>
</el-row>
</div> </div>
</div> <div
class="message-user"
v-else-if="isShow && tabValue == 2"
style="height: calc(100vh - 90px)"
v-loading="userLoading"
>
<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="info" @click="clickInitiate">
发起协作
</el-button>
</div>
</div>
</div>
<div v-else class="message-null">
<el-empty description="暂无内容" />
</div>
</div>
</el-col> </el-col>
</el-row> </el-row>
</div>
<div <!-- <el-dialog
class="message-user" v-model="inviteDialog"
v-else-if="isShow && tabValue == 2" title="远程协作"
style="height: calc(100vh - 90px)" width="400px"
> :close-on-press-escape="false"
<div class="message-user-card"> :close-on-click-modal="false"
<div class="user-card-nickName"> :show-close="false"
<img v-if="detail.avatar" :src="detail.avatar" /> >
<img v-else src="@/assets/images/profile.jpg" />
<span>{{ detail.nickName || detail.name || '暂无信息' }}</span> <div style="width: 100%; margin-bottom: 30px; font-size: 20px">
"
{{
socketInformation.room_name
? socketInformation.room_name
: ''
}}
" 邀请您参加远程协作
</div> </div>
<div class="user-card-information"> <div style="text-align: center">
<div class="user-information-item"> <el-button
<div class="user-information-title"> size="large"
<img src="@/assets/images/user-information1.png" alt="" /> type="danger"
<span>性别</span> style="font-size: 16px"
</div> @click="clickRefuseJoin"
<div class="user-information-text"> >
<!-- <dict-tag
v-if="detail.sex" </el-button>
:options="sys_user_sex" <el-button
:value="detail.sex" size="large"
/> type="primary"
<div v-else> style="font-size: 16px"
{{ '暂无' }} @click="clickJoin"
</div> --> >
</div>
</div> </el-button>
<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>
<div class="user-card-btn"> </el-dialog> -->
<el-button type="primary" @click="clickInitiate"> </div>
发起协作 <!-- 文件预览 -->
</el-button> <BrowseFile ref="browseFileRef" />
</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 AssistWx from './components/assistWx/index.vue'
import BrowseFile from '@/views/conferencingRoom/components/fileUpload/browseFile.vue'
import { getInfo } from '@/api/login.js' import { getInfo } from '@/api/login.js'
import { getStatusApi } from '@/api/conferencingRoom.js' import { getStatusApi ,getFileListApi ,getvideoUrlApi} from '@/api/conferencingRoom.js'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { useRoute, useRouter } from 'vue-router' 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.js'
import { mqttClient } from "@/utils/mqtt.js"; import { mqttClient } from "@/utils/mqtt.js";
import { getToken ,getUserInfo} from '@/utils/auth.js';
import { deepClone,parseTime } from '@/utils/ruoyi.js'
const roomStore = useRoomStore() const roomStore = useRoomStore()
const userStore = useUserStore() const userStore = useUserStore()
const router = useRouter() const router = useRouter()
const { proxy } = getCurrentInstance() const { proxy } = getCurrentInstance()
const state = reactive({ const state = reactive({
detail: {}, detail: {},
weekName: ['周日', '周一', '周二', '周三', '周四', '周五', '周六'], weekName: ['周日', '周一', '周二', '周三', '周四', '周五', '周六'],
tabValue: 2, tabValue: 1,
isShow: true, isShow: true,
isShowLoading: false,
load: false, load: false,
loadText: '数据加载中', loadText: '数据加载中',
isLinkKnow: 'F', isLinkKnow: 'F',
socketInformation: null, socketInformation: null,
inviteDialog: false, inviteDialog: false,
cooperation: import.meta.env.VITE_APP_COOPERATION_TYPE, cooperation: import.meta.env.VITE_APP_COOPERATION_TYPE,
}) })
const userLoading = ref(false); // 用户信息加载状态
//文件预览
const browseFileRef = ref(null);
const isEmptyObject = (obj) => { const isEmptyObject = (obj) => {
return !obj || Object.keys(obj).length === 0 return !obj || Object.keys(obj).length === 0
@@ -294,10 +302,12 @@ 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)
} }
if (isEmptyObject(state.detail)) { if (isEmptyObject(state.detail)) {
ElMessage({ ElMessage({
message: '请先选择人员', message: '请先选择人员',
@@ -313,13 +323,14 @@ const clickInitiate = () => {
return return
} }
roomStore.setUserUid(userData.uid) roomStore.setUserUid(userData.uid)
roomStore.setDetailUid(state.detail.uid) roomStore.setDetailUid(state.detail.uid)
roomStore.setDetailName(state.detail.name)
router.push({ router.push({
path: '/conferencingRoom', path: '/conferencingRoom',
query:{ query:{
type:1//创建房间,加入房间 2 type:1//创建房间,加入房间 2
} }
}) })
} }
/** 修改展示列表 */ /** 修改展示列表 */
@@ -329,30 +340,49 @@ const updateTab = (newValue) => {
} }
/** 修改展示区内容 */ /** 修改展示区内容 */
const updateDetail = async (details) => { const updateDetail = async (details) => {
if (details) { userLoading.value = true
console.log(details,'details') if (details) {
state.detail = {} state.detail = {}
if (state.tabValue == 1) { if (state.tabValue == 1) {
state.isShow = true state.isShowLoading = true
getTheFileList(details)
} else { } else {
const res = await getInfo(details.uid) const res = await getInfo(details.uid)
state.detail = res.data state.detail = res.data
// getInfo(details.uid) userLoading.value = false
// .then((res) => { }
// console.log(res,'人员详细信息') }
// }) userLoading.value = false
// .finally(() => { state.isShow = true }) }
const getTheFileList = async (details) => {
try {
let detail = deepClone(details);
const [fileResponse, videoResponse] = await Promise.all([
getFileListApi(details.room_uid),
getvideoUrlApi(details.room_uid)
]);
const processedDetail = {
...details,
fileList: fileResponse.data?.files || [],
remoteVideoFile: videoResponse.data?.recordings?.[0] || {},
initiator: details.all_participants?.find(item =>
item.participant_role === 'moderator' // 使用严格相等
)?.display_name || '未知发起人'
};
state.detail = processedDetail;
state.isShowLoading = false;
} catch (error) {
console.error('获取文件列表失败:', error);
// 可以根据需要添加错误处理逻辑
} }
} else {
state.isShow = false
}
} }
/** 获取通话时长 */ /** 获取通话时长 */
const getTime = () => { const getTime = () => {
let begin = new Date(state.detail.beginTime).getTime() let begin = new Date(state.detail.created_at).getTime()
let end = new Date(state.detail.endTime).getTime() let end = new Date(state.detail.updated_at).getTime()
if (begin && end) { if (begin && end) {
let diff = end - begin let diff = end - begin
const h = Math.floor(diff / (1000 * 60 * 60)) const h = Math.floor(diff / (1000 * 60 * 60))
@@ -366,77 +396,92 @@ const getTime = () => {
} }
} }
/** 加入会议 */ //文件预览
const clickJoin = async () => { function handlePreview(file) {
const res = await getStatusApi(state.socketInformation.room_uid,{status:1}) if (!file.preview_url) {
if(res.meta.code == 200){ ElMessage.error('文件链接无效');
ElMessage({ return;
message: '成功加入该协作', }
type: 'success', browseFileRef.value.showEdit(file)
})
state.inviteDialog = false
router.push({
path: '/conferencingRoom',
query:{
type:2,//创建房间,加入房间 2
room_uid:state.socketInformation.room_uid
}
})
}
state.inviteDialog = false
} }
/** 加入会议 */
// const clickJoin = async () => {
// const res = await getStatusApi(state.socketInformation.room_uid,{status:1})
// if(res.meta.code == 200){
// ElMessage({
// message: '成功加入该协作',
// type: 'success',
// })
// state.inviteDialog = false
// router.push({
// path: '/conferencingRoom',
// query:{
// type:2,//创建房间,加入房间 2
// room_uid:state.socketInformation.room_uid
// }
// })
// }
// state.inviteDialog = false
// }
/** 拒绝加入 */ /** 拒绝加入 */
const clickRefuseJoin = async () => { // const clickRefuseJoin = async () => {
//status 1: 同意加入, 5: 拒绝加入 // //status 1: 同意加入, 5: 拒绝加入
const res = await getStatusApi(state.socketInformation.room_uid,{status:5}) // const res = await getStatusApi(state.socketInformation.room_uid,{status:5})
if(res.meta.code == 200){ // if(res.meta.code == 200){
ElMessage({ // ElMessage({
message: '已拒绝加入该协作', // message: '已拒绝加入该协作',
type: 'error', // type: 'error',
}) // })
state.inviteDialog = false // state.inviteDialog = false
} // }
} // }
/** 处理 mqtt 消息 */ /** 处理加入房间和拒接房间 mqtt 消息 */
const processingSocket = (message) => { // const processingSocket = (message) => {
if (message) { // const res = JSON.parse(message)
state.socketInformation = JSON.parse(message) // if (!res?.status) {
console.log(state.socketInformation,'state.socketInformation') // state.socketInformation = res
state.inviteDialog = true // state.inviteDialog = true
showNotification(state.socketInformation) // showNotification(state.socketInformation)
} // }else if(res.status == 5){
} // ElMessage({
// message: `${res?.display_name}拒绝加入该协作`,
// type: 'error',
// })
// }
// }
/** 浏览器通知 */ /** 浏览器通知 */
const showNotification = (data) => { // const showNotification = (data) => {
if ('Notification' in window) { // if ('Notification' in window) {
Notification.requestPermission().then((permission) => { // Notification.requestPermission().then((permission) => {
if (permission === 'granted') { // if (permission === 'granted') {
const notification = new Notification('协作邀请', { // const notification = new Notification('协作邀请', {
// body: String(data.room_name) + '邀请您参加远程协作' // // body: String(data.room_name) + '邀请您参加远程协作'
body: '远程协作有新的邀请' // body: '远程协作有新的邀请'
// icon: logo, // // icon: logo,
}) // })
notification.onclick = () => { clickJoin() } // notification.onclick = () => { clickJoin() }
} // }
}) // })
} // }
} // }
// 暴露给模板 // 暴露给模板
const { detail, weekName, tabValue, isShow, load, loadText, isLinkKnow, socketInformation, inviteDialog, cooperation } = toRefs(state) const { detail, weekName, tabValue, isShow, load, loadText, isLinkKnow, socketInformation, inviteDialog, cooperation,isShowLoading } = toRefs(state)
onMounted(async () => { // onMounted(async () => {
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}`;
mqttClient.subscribe(topic, async (shapeData) => { // mqttClient.subscribe(topic, async (shapeData) => {
processingSocket(shapeData.toString()) // // console.log(shapeData.toString(),'shapeData发送邀请')
}); // processingSocket(shapeData.toString())
}) // });
// })
</script> </script>
@@ -450,7 +495,8 @@ onMounted(async () => {
.app-container { .app-container {
padding: 20px; padding: 20px;
// margin: 0 17px; // margin: 0 17px;
// height: calc(100vh - 50px);
} }
.message-null { .message-null {
@@ -468,7 +514,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;
} }
@@ -576,8 +622,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,15 +1,10 @@
<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" round plain @click="joinWhiteboard"> <el-button type="primary" size="large" link @click="joinWhiteboard">
入互动画板 正在进入互动画板
</el-button> </el-button>
</div> </div>
@@ -26,16 +21,24 @@
import { ref, nextTick, onUnmounted, onMounted } from "vue"; 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";
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 showLogin = ref(false); // 是否显示登录页面 const props = defineProps({
roomId: {
type: String,
default: '',
},
userId: {
type: String,
default: '',
},
})
const hasJoined = ref(false); // 是否加入白板 const hasJoined = ref(false); // 是否加入白板
const canvas = ref(null); const canvas = ref(null);
const route = useRoute(); const route = useRoute();
@@ -51,7 +54,7 @@ async function joinWhiteboard() {
try { try {
const clientId = `whiteboard-${uuidv4()}`; const clientId = `whiteboard-${uuidv4()}`;
await mqttClient.connect(clientId); await mqttClient.connect(clientId);
console.log("✅ 已连接 MQTT:", clientId); // console.log("✅ 已连接 MQTT:", clientId);
hasJoined.value = true; hasJoined.value = true;
@@ -68,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");
@@ -80,55 +78,17 @@ 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 */ onMounted(async () => {
function getCanvasSize(container) { joinWhiteboard()
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");
console.log(res, "用户信息校验")
if (res.meta.code === 401) {
showLogin.value = true;
} else {
showLogin.value = false;
}
} catch (err) {
console.warn("⚠️ 用户信息校验失败:", err);
}
}); });
onUnmounted(() => { onUnmounted(() => {
@@ -136,17 +96,16 @@ onUnmounted(() => {
}); });
</script> </script>
<style scoped> <style scoped>
/* 外层容器全屏居中 */ /* 外层容器全屏居中 */
.wrapper-content { .wrapper-content {
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,14 +118,13 @@ onUnmounted(() => {
/* 白板容器 */ /* 白板容器 */
.whiteboard-wrapper { .whiteboard-wrapper {
position: relative; position: relative;
width: 90vw; width: 72vw;
max-width: 1280px; height: 69vh;
aspect-ratio: 16 / 9;
box-sizing: border-box; box-sizing: border-box;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
overflow: hidden; overflow: hidden;
} }
/* 画布占满白板容器 */ /* 画布占满白板容器 */
@@ -176,11 +134,11 @@ onUnmounted(() => {
display: block; display: block;
} }
/* 工具栏左侧垂直居中 */ /* 工具栏固定在 wrapper-content 内 */
.toolbox { .toolbox {
position: absolute; position: absolute;
top: 50%; top: 50%;
left: 10px; left: 40px; /* 距离左侧的间距 */
transform: translateY(-50%); transform: translateY(-50%);
z-index: 1000; z-index: 1000;
} }

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

@@ -36,7 +36,7 @@ let { proxy } = getCurrentInstance();
const errGif = ref(errImage + "?" + +new Date()); const errGif = ref(errImage + "?" + +new Date());
function back() { function back() {
if (proxy.$route.query.noGoBack) { if (proxy.$route.query.noGoBack) {
proxy.$router.push({ path: "/" }); proxy.$router.push({ path: "/" });
} else { } else {
proxy.$router.go(-1); proxy.$router.go(-1);

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">
@@ -37,23 +31,27 @@
</template> </template>
<script setup> <script setup>
import useUserStore from '@/stores/modules/user' 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 } from 'element-plus' import { ElNotification,ElMessage } from 'element-plus'
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()
const route = useRoute() const route = useRoute()
const { proxy } = getCurrentInstance() const { proxy } = getCurrentInstance()
const showLogin = ref(false)
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) => {
redirect.value = newRoute.query && newRoute.query.redirect;
}, { immediate: true }); }, { immediate: true });
const loginForm = ref({ const loginForm = ref({
@@ -69,26 +67,31 @@ 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
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: 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({ await handleLoginSuccess();
path: '/coordinate',
})
// router.push({
// path: '/whiteboard',
// query: { room_uid: 'nxst-ok4j' }
// })
}) })
.catch((e) => { .catch((e) => {
console.log('登录失败', e) console.log('登录失败', e)
@@ -99,6 +102,51 @@ function handleLogin() {
requestNotificationPermission() requestNotificationPermission()
} }
/**
* 处理登录成功后的跳转逻辑
*/
async function handleLoginSuccess() {
try {
// 如果有重定向路径且不是登录页,则跳转到重定向页面
if (redirect.value && redirect.value !== '/login') {
// 确保路由存在,如果不存在则跳转到默认页面
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) {
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,27 +161,7 @@ function requestNotificationPermission() {
onMounted(async () => { onMounted(async () => {
try { meterStore.initUdid()
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>

View File

@@ -1,46 +1,44 @@
import { fileURLToPath, URL } from 'node:url' import { fileURLToPath, URL } from 'node:url'
import path from "path"; import path from "path";
import { defineConfig } from 'vite' import { defineConfig ,loadEnv} from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import { codeInspectorPlugin } from 'code-inspector-plugin' import createVitePlugins from './vite/plugins/index.js'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig(({ mode, command }) => {
plugins: [ const env = loadEnv(mode, process.cwd());
vue(), const { VITE_BASE_PATH } = env;
codeInspectorPlugin({ return {
bundler: 'vite', // 使用 vite plugins: createVitePlugins(env, command === "build"),
showSwitch: true, server: {
}), host: '0.0.0.0', // 关键配置,允许局域网访问
], port: 3000,
server: { open: true,
host: '0.0.0.0', // 关键配置,允许局域网访问 hmr: { overlay: false },
port: 3000, proxy: {
open: true, '/dev-api': {
hmr: { overlay: false }, target: 'https://xsynergy.gxtech.ltd',
proxy: { changeOrigin: true,
'/dev-api': { ws: true,
target: 'https://xsynergy.gxtech.ltd', // 从环境变量读取 rewrite: (path) =>
changeOrigin: true, path.replace(new RegExp(`^/dev-api`), '')
ws: true, },
rewrite: (path) => '/livekit-api': {
path.replace(new RegExp(`^/dev-api`), '') target: 'https://meeting.cnsdt.com/api/v1',
}, changeOrigin: true,
'/livekit-api': { ws: true,
target: 'https://meeting.cnsdt.com/api/v1', // 从环境变量读取 rewrite: (path) =>
changeOrigin: true, path.replace(new RegExp(`^/livekit-api`), '')
ws: true, }
rewrite: (path) =>
path.replace(new RegExp(`^/livekit-api`), '')
} }
}
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
}, },
}, resolve: {
css: { alias: {
postcss: './postcss.config.js' // 纯外部配置 "@": path.resolve(__dirname, "./src"),
}, },
},
css: {
postcss: './postcss.config.js' // 纯外部配置
},
}
}) })

View File

@@ -0,0 +1,13 @@
import autoImport from 'unplugin-auto-import/vite'
export default function createAutoImport() {
return autoImport({
imports: [
'vue',
'vue-router',
'pinia',
'@vueuse/core'
],
dts: false
})
}

View File

@@ -0,0 +1,28 @@
import compression from 'vite-plugin-compression'
export default function createCompression(env) {
const { VITE_BUILD_COMPRESS } = env
const plugin = []
if (VITE_BUILD_COMPRESS) {
const compressList = VITE_BUILD_COMPRESS.split(',')
if (compressList.includes('gzip')) {
// http://doc.ruoyi.vip/ruoyi-vue/other/faq.html#使用gzip解压缩静态文件
plugin.push(
compression({
ext: '.gz',
deleteOriginFile: false
})
)
}
if (compressList.includes('brotli')) {
plugin.push(
compression({
ext: '.br',
algorithm: 'brotliCompress',
deleteOriginFile: false
})
)
}
}
return plugin
}

19
vite/plugins/index.js Normal file
View File

@@ -0,0 +1,19 @@
import vue from "@vitejs/plugin-vue";
import createAutoImport from "./auto-import";
import createSvgIcon from "./svg-icon";
import createCompression from "./compression";
import createSetupExtend from "./setup-extend";
import { codeInspectorPlugin } from 'code-inspector-plugin'
export default function createVitePlugins(viteEnv, isBuild = false) {
const vitePlugins = [vue()];
vitePlugins.push(codeInspectorPlugin({
bundler: 'vite',
showSwitch: true,
}))
vitePlugins.push(createAutoImport());
vitePlugins.push(createSetupExtend());
vitePlugins.push(createSvgIcon(isBuild));
isBuild && vitePlugins.push(...createCompression(viteEnv));
return vitePlugins;
}

View File

@@ -0,0 +1,5 @@
import setupExtend from 'unplugin-vue-setup-extend-plus/vite'
export default function createSetupExtend() {
return setupExtend({})
}

10
vite/plugins/svg-icon.js Normal file
View File

@@ -0,0 +1,10 @@
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'
import path from 'path'
export default function createSvgIcon(isBuild) {
return createSvgIconsPlugin({
iconDirs: [path.resolve(process.cwd(), 'src/assets/icons/svg')],
symbolId: 'icon-[dir]-[name]',
svgoOptions: isBuild
})
}