Compare commits
10 Commits
3ac556d7a5
...
a894367dcc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a894367dcc | ||
|
|
ca93e91326 | ||
|
|
6fbe53009c | ||
|
|
df359d01cc | ||
|
|
528170fe2f | ||
|
|
f19ab86ada | ||
|
|
2b8e7349e6 | ||
|
|
e0001ba430 | ||
|
|
db72ea9f33 | ||
|
|
e429e4286a |
@@ -6,7 +6,8 @@ VITE_APP_PORT = 80
|
||||
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”
|
||||
VITE_APP_COOPERATION_TYPE = 'skip'
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>互动白板</title>
|
||||
<title>xSynergy远程协作系统</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
6966
package-lock.json
generated
11
package.json
@@ -11,9 +11,12 @@
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.0.10",
|
||||
"@msgpack/msgpack": "^3.1.2",
|
||||
"@sentry/vue": "^10.20.0",
|
||||
"@vueuse/core": "^9.5.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"axios": "^0.27.2",
|
||||
"code-inspector-plugin": "^0.20.12",
|
||||
"crypto-js": "^4.2.0",
|
||||
"element-plus": "^2.2.27",
|
||||
"js-cookie": "^3.0.1",
|
||||
"livekit-client": "^2.7.5",
|
||||
@@ -23,14 +26,20 @@
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"uuid": "^11.1.0",
|
||||
"vite-plugin-compression": "^0.5.1",
|
||||
"vite-plugin-svg-icons": "^2.0.1",
|
||||
"vue": "^3.5.12",
|
||||
"vue-pdf": "^4.3.0",
|
||||
"vue-pdf-embed": "^2.1.3",
|
||||
"vue-router": "^4.4.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.1.4",
|
||||
"@vue/compiler-sfc": "^3.2.45",
|
||||
"sass": "^1.56.1",
|
||||
"unplugin-auto-import": "^20.1.0",
|
||||
"unplugin-auto-import": "^0.11.4",
|
||||
"unplugin-vue-components": "^29.0.0",
|
||||
"unplugin-vue-setup-extend-plus": "^0.4.9",
|
||||
"vite": "^5.4.10"
|
||||
}
|
||||
}
|
||||
|
||||
11
src/App.vue
@@ -1,6 +1,15 @@
|
||||
<script setup>
|
||||
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>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -17,3 +17,22 @@ export function getDirectoriesUsers(directory_uuid,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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -34,3 +34,28 @@ export function logout() {
|
||||
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
@@ -0,0 +1,9 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 获取路由
|
||||
export const getRouters = () => {
|
||||
return request({
|
||||
url: '/system/menu/getRouters',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
BIN
src/assets/images/Gc_114_line-Level-action.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
src/assets/images/Gc_114_line-Level.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
src/assets/images/amplify.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
src/assets/images/book-read-fill-action.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
src/assets/images/book-read-fill.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
39
src/assets/images/dark.svg
Normal 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 |
BIN
src/assets/images/file-logo.png
Normal file
|
After Width: | Height: | Size: 861 B |
BIN
src/assets/images/file-logoW.png
Normal file
|
After Width: | Height: | Size: 744 B |
39
src/assets/images/light.svg
Normal 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 |
BIN
src/assets/images/shrink.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
@@ -1,4 +1,5 @@
|
||||
$menu-bg-color: #8290f0;
|
||||
// $menu-bg-color: #8290f0;
|
||||
$menu-bg-color: #434343;
|
||||
#app {
|
||||
|
||||
.main-container {
|
||||
@@ -92,12 +93,12 @@ $menu-bg-color: #8290f0;
|
||||
.sub-menu-title-noDropdown,
|
||||
.el-sub-menu__title {
|
||||
&:hover {
|
||||
background-color: #c4cbf3 !important;
|
||||
background-color: #e5e5e5 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.el-menu-item:hover {
|
||||
background-color: #c4cbf3 !important;
|
||||
background-color: #e5e5e5 !important;
|
||||
}
|
||||
|
||||
& .theme-dark .is-active>.el-sub-menu__title {
|
||||
@@ -109,7 +110,7 @@ $menu-bg-color: #8290f0;
|
||||
min-width: $base-sidebar-width !important;
|
||||
|
||||
&:hover {
|
||||
background-color: #c4cbf3 !important;
|
||||
background-color: #e5e5e5 !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
81
src/components/Breadcrumb/index.vue
Normal 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>
|
||||
41
src/components/Hamburger/index.vue
Normal 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>
|
||||
@@ -26,15 +26,18 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import useUserStore from '@/stores/modules/user'
|
||||
import { watch, ref, getCurrentInstance } from 'vue'
|
||||
import { useUserStore } from '@/stores/modules/user.js'
|
||||
import { watch, ref, getCurrentInstance,onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useMeterStore } from '@/stores/modules/meter'
|
||||
import CryptoJS from 'crypto-js';
|
||||
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
const { proxy } = getCurrentInstance()
|
||||
const redirect = ref(undefined);
|
||||
const emit = defineEmits(['loginSuccess'])
|
||||
const meterStore = useMeterStore()
|
||||
|
||||
const loginForm = ref({
|
||||
username: '',
|
||||
@@ -52,14 +55,31 @@ function handleLogin() {
|
||||
proxy.$refs.loginRef.validate((valid) => {
|
||||
if (valid) {
|
||||
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的登录方法
|
||||
userStore
|
||||
.login({
|
||||
password: loginForm.value.password,
|
||||
password: ciphertext,
|
||||
// password: loginForm.value.password,
|
||||
username: loginForm.value.username,
|
||||
})
|
||||
.then(async (res) => {
|
||||
const userInfo = JSON.parse(localStorage.getItem('userData'))
|
||||
const userInfo = JSON.parse(sessionStorage.getItem('userData'))
|
||||
emit('loginSuccess', userInfo)
|
||||
})
|
||||
.catch((e) => {
|
||||
@@ -71,6 +91,16 @@ function handleLogin() {
|
||||
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 请求浏览器的通知权限
|
||||
* @returns {*}
|
||||
@@ -83,13 +113,17 @@ function requestNotificationPermission() {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
meterStore.initUdid()
|
||||
})
|
||||
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.wrapper-content {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
height: calc(100vh - 60px);
|
||||
padding: 0 40px;
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #eef1f6 100%);
|
||||
}
|
||||
@@ -160,6 +194,7 @@ function requestNotificationPermission() {
|
||||
transition: background 0.3s ease, transform 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
// background: linear-gradient(135deg, #000, #000);
|
||||
background: linear-gradient(135deg, #00a5a1, #008f8b);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
3
src/components/ParentView/index.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template >
|
||||
<router-view />
|
||||
</template>
|
||||
210
src/components/TopNav/index.vue
Normal 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>
|
||||
@@ -42,7 +42,7 @@ class Canvas extends EventEmitter {
|
||||
const parent = this.canvas.parentElement;
|
||||
if (!parent) return;
|
||||
|
||||
const containerWidth = parent.offsetWidth;
|
||||
const containerWidth = parent.offsetWidth - 150;
|
||||
const containerHeight = parent.offsetHeight;
|
||||
|
||||
let width = containerWidth;
|
||||
@@ -63,6 +63,7 @@ class Canvas extends EventEmitter {
|
||||
this.render();
|
||||
}
|
||||
|
||||
|
||||
setDrawingTool(tool) { this.drawingTool = tool; }
|
||||
setColor(color) { this.currentColor = color; }
|
||||
setThickness(size) { this.currentThickness = size; }
|
||||
@@ -71,11 +72,12 @@ class Canvas extends EventEmitter {
|
||||
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
|
||||
x:((e.clientX - rect.left) / this.canvas.width).toFixed(4),
|
||||
y:((e.clientY - rect.top) / this.canvas.height).toFixed(4)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
285
src/core/index_old.js
Normal 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;
|
||||
49
src/layout/components/AppMain.vue
Normal 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>
|
||||
19
src/layout/components/IframeToggle/index.vue
Normal 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>
|
||||
24
src/layout/components/InnerLink/index.vue
Normal 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>
|
||||
149
src/layout/components/InviteJoin/index.vue
Normal 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>
|
||||
|
||||
69
src/layout/components/Navbar.vue
Normal 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>
|
||||
162
src/layout/components/ResetPwd/index.vue
Normal 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>
|
||||
|
||||
238
src/layout/components/Settings/index.vue
Normal 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>
|
||||
40
src/layout/components/Sidebar/Link.vue
Normal 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>
|
||||
81
src/layout/components/Sidebar/Logo.vue
Normal 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>
|
||||
116
src/layout/components/Sidebar/SidebarItem.vue
Normal 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>
|
||||
60
src/layout/components/Sidebar/index.vue
Normal 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>
|
||||
105
src/layout/components/TagsView/ScrollPane.vue
Normal 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>
|
||||
336
src/layout/components/TagsView/index.vue
Normal 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>
|
||||
6
src/layout/components/index.js
Normal 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
@@ -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>
|
||||
29
src/main.js
@@ -1,7 +1,7 @@
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
import * as Sentry from '@sentry/vue';
|
||||
|
||||
import {
|
||||
deepClone
|
||||
@@ -17,14 +17,39 @@ import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
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)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.use(ElementPlus, {
|
||||
locale: zhCn,
|
||||
|
||||
60
src/plugins/auth.js
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,57 +1,151 @@
|
||||
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({
|
||||
// history: createWebHistory(import.meta.env.VITE_BASE_PATH),
|
||||
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",
|
||||
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"),
|
||||
routes: constantRoutes,
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
if (savedPosition) {
|
||||
return savedPosition;
|
||||
} else {
|
||||
return { top: 0 };
|
||||
}
|
||||
],
|
||||
})
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
export default router
|
||||
|
||||
47
src/settings.js
Normal 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'
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { generateUUID } from '@/utils/tools.js'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useMeterStore = defineStore('meter', {
|
||||
state: () => ({
|
||||
|
||||
@@ -5,7 +5,10 @@ export const useRoomStore = defineStore('room', {
|
||||
roomId: '',
|
||||
token: '',
|
||||
userUid: '',
|
||||
//邀请进入房间的用户uid
|
||||
detailUid: '',
|
||||
//邀请用户名称
|
||||
detailName: '',
|
||||
}),
|
||||
actions: {
|
||||
setUserUid(data) {
|
||||
@@ -13,6 +16,9 @@ export const useRoomStore = defineStore('room', {
|
||||
},
|
||||
setDetailUid(data) {
|
||||
this.detailUid = data
|
||||
},
|
||||
setDetailName(data) {
|
||||
this.detailName = data
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
37
src/stores/modules/settings.js
Normal 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();
|
||||
}
|
||||
}
|
||||
})
|
||||
183
src/stores/modules/tagsView.js
Normal 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
|
||||
@@ -1,9 +1,9 @@
|
||||
|
||||
import { login, logout, getInfo } from '@/api/login'
|
||||
import { getToken, setToken, removeToken } from '@/utils/auth'
|
||||
import { login, logout, getInfo } from '@/api/login.js'
|
||||
import { getToken, setToken, removeToken } from '@/utils/auth.js'
|
||||
import { defineStore } from 'pinia'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const useUserStore = defineStore(
|
||||
export const useUserStore = defineStore(
|
||||
'user',
|
||||
{
|
||||
state: () => ({
|
||||
@@ -18,10 +18,14 @@ const useUserStore = defineStore(
|
||||
try {
|
||||
const { username, password } = userInfo;
|
||||
const trimmedUsername = username.trim();
|
||||
|
||||
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;
|
||||
localStorage.setItem('userData', JSON.stringify(user));
|
||||
this.name = user.name;
|
||||
sessionStorage.setItem('userData', JSON.stringify(user));
|
||||
setToken(token);
|
||||
this.token = token;
|
||||
|
||||
@@ -34,7 +38,7 @@ const useUserStore = defineStore(
|
||||
getInfo() {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const userData = localStorage.getItem('userData');
|
||||
const userData = sessionStorage.getItem('userData');
|
||||
|
||||
if (!userData) {
|
||||
return reject(new Error('未找到用户数据'));
|
||||
@@ -53,9 +57,14 @@ const useUserStore = defineStore(
|
||||
// 退出系统
|
||||
async logOut() {
|
||||
try {
|
||||
await logout();
|
||||
const res = await logout();
|
||||
if(res.meta.code !== 200){
|
||||
ElMessage({ message: res.meta?.message || '退出登录失败', type: 'error' });
|
||||
return
|
||||
}
|
||||
this.token = '';
|
||||
this.roles = '';
|
||||
sessionStorage.removeItem('userData');
|
||||
removeToken();
|
||||
} catch (error) {
|
||||
console.error('退出登录失败:', error);
|
||||
@@ -68,5 +77,3 @@ const useUserStore = defineStore(
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export default useUserStore
|
||||
|
||||
@@ -1,30 +1,71 @@
|
||||
|
||||
import Cookies from "js-cookie";
|
||||
|
||||
import router from '@/router';
|
||||
const TokenKey = "token";
|
||||
|
||||
const ExpiresInKey = "Meta-Enterprise-Expires-In";
|
||||
|
||||
export function getToken() {
|
||||
return Cookies.get(TokenKey);
|
||||
// return Cookies.get(TokenKey);
|
||||
return sessionStorage.getItem(TokenKey);
|
||||
}
|
||||
|
||||
export function setToken(token) {
|
||||
return Cookies.set(TokenKey, token);
|
||||
// return Cookies.set(TokenKey, token);
|
||||
return sessionStorage.setItem(TokenKey, token);
|
||||
}
|
||||
|
||||
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() {
|
||||
return Cookies.remove(ExpiresInKey);
|
||||
function handleUnauthorized() {
|
||||
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
@@ -0,0 +1,2 @@
|
||||
import mitt from 'mitt'
|
||||
export const emitter = mitt()
|
||||
15
src/utils/dynamicTitle.js
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -8,22 +8,50 @@ import { tansParams } from "@/utils/ruoyi";
|
||||
import cache from "@/plugins/cache";
|
||||
import { getToken, removeToken } from "@/utils/auth";
|
||||
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";
|
||||
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实例
|
||||
const service = axios.create({
|
||||
// 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拦截器
|
||||
service.interceptors.request.use(
|
||||
(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
|
||||
const isToken = (config.headers || {}).isToken === false;
|
||||
@@ -33,8 +61,8 @@ service.interceptors.request.use(
|
||||
|
||||
// 是否需要防止数据重复提交
|
||||
const isRepeatSubmit = (config.headers || {}).repeatSubmit === false;
|
||||
if (meterStore.getSudid()) {
|
||||
config.headers["X-User-Agent"] = `gxtech/web 1.0.0: c=GxTech, udid=${meterStore.getSudid()}, sv=15.4.1, app=stt`;
|
||||
if (sudid) {
|
||||
config.headers["X-User-Agent"] = `gxtech/web 1.0.0: c=GxTech, udid=${sudid}, sv=15.4.1, app=stt`;
|
||||
}
|
||||
// get请求映射params参数
|
||||
if (config.method === "get" && config.params) {
|
||||
@@ -122,14 +150,17 @@ service.interceptors.response.use(
|
||||
case 200:
|
||||
case 201:
|
||||
return Promise.resolve(responseData);
|
||||
|
||||
case 401:
|
||||
console.log('未授权', responseData)
|
||||
return Promise.resolve(responseData);
|
||||
// return handleUnauthorized().then(() => {
|
||||
// return Promise.reject({ code: 401, message: '未授权' });
|
||||
// });
|
||||
|
||||
// return Promise.resolve(responseData);
|
||||
// const currentPath = router.currentRoute.value.name;
|
||||
// if(currentPath == 'ConferencingRoom'){
|
||||
// return Promise.resolve(responseData);
|
||||
// }else{
|
||||
return handleUnauthorized()
|
||||
// .then(() => {
|
||||
// return Promise.reject({ code: 401, message: '未授权' });
|
||||
// });
|
||||
// }
|
||||
case 500:
|
||||
const serverErrorMsg = responseData.meta?.message || '服务器内部错误';
|
||||
ElMessage({ message: serverErrorMsg, type: 'error' });
|
||||
@@ -164,31 +195,52 @@ service.interceptors.response.use(
|
||||
);
|
||||
|
||||
// 单独处理401未授权
|
||||
// function handleUnauthorized() {
|
||||
// return ElMessageBox.confirm(
|
||||
// '认证信息已失效,您可以继续留在该页面,或者重新登录',
|
||||
// '系统提示',
|
||||
// {
|
||||
// confirmButtonText: '重新登录',
|
||||
// cancelButtonText: '取消',
|
||||
// type: 'warning',
|
||||
// }
|
||||
// )
|
||||
// .then(() => {
|
||||
// removeToken()
|
||||
// if (router.currentRoute.path !== '/login') {
|
||||
// router.push({
|
||||
// path: '/login',
|
||||
// query: { redirect: router.currentRoute.fullPath }
|
||||
// });
|
||||
// } else {
|
||||
// // 如果在登录页,强制刷新以清除残留状态
|
||||
// window.location.reload();
|
||||
// }
|
||||
// })
|
||||
// .catch(() => {
|
||||
// return Promise.reject('用户取消操作');
|
||||
// });
|
||||
// }
|
||||
|
||||
function handleUnauthorized() {
|
||||
return ElMessageBox.confirm(
|
||||
'认证信息已失效,您可以继续留在该页面,或者重新登录',
|
||||
'系统提示',
|
||||
{
|
||||
confirmButtonText: '重新登录',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
}
|
||||
)
|
||||
.then(() => {
|
||||
removeToken()
|
||||
if (router.currentRoute.path !== '/login') {
|
||||
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: router.currentRoute.fullPath }
|
||||
query: {
|
||||
redirect: currentPath !== '/login' ? currentPath : undefined
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 如果在登录页,强制刷新以清除残留状态
|
||||
window.location.reload();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
return Promise.reject('用户取消操作');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default service;
|
||||
|
||||
@@ -716,3 +716,26 @@ export function removeDuplicate(arr) {
|
||||
});
|
||||
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
@@ -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])
|
||||
}
|
||||
93
src/utils/validate.js
Normal 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)
|
||||
}
|
||||
@@ -2,7 +2,8 @@ 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';
|
||||
import { emitter } from "@/utils/bus.js";
|
||||
const meterStore = useMeterStore();
|
||||
meterStore.initUdid();
|
||||
|
||||
@@ -11,7 +12,7 @@ let canvasInstance = null;
|
||||
|
||||
// 获取本地缓存 userData
|
||||
function getLocalUserData() {
|
||||
const dataStr = localStorage.getItem('userData');
|
||||
const dataStr = sessionStorage.getItem('userData');
|
||||
if (!dataStr) return null;
|
||||
try {
|
||||
return JSON.parse(dataStr);
|
||||
@@ -24,29 +25,26 @@ function getLocalUserData() {
|
||||
export const WhiteboardSync = {
|
||||
async init(canvas, roomUid) {
|
||||
if (!canvas || !roomUid) return;
|
||||
console.log('初始化多人同步:', roomUid);
|
||||
canvasInstance = canvas;
|
||||
|
||||
const localUser = getLocalUserData();
|
||||
const localUid = localUser?.user?.uid;
|
||||
|
||||
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);
|
||||
}else if(res.meta.code === 401){
|
||||
emitter.emit('whiteboardFailed',true);
|
||||
}
|
||||
|
||||
// 订阅当前房间
|
||||
const topic = `xSynergy/ROOM/${roomUid}/whiteboard/#`;
|
||||
mqttClient.subscribe(topic, async (shapeData) => {
|
||||
// console.log(shapeData, 'shapeData++格式装换')
|
||||
const shapeDataNew = decode(shapeData);
|
||||
// console.log(shapeDataNew, '格式解码')
|
||||
const shapeDataNew = JSON.parse(shapeData.toString())
|
||||
// const shapeDataNew = decode(message);
|
||||
try {
|
||||
isRemote = true;
|
||||
// 如果 shape 来自本地用户,则跳过
|
||||
@@ -55,6 +53,7 @@ export const WhiteboardSync = {
|
||||
if (res.meta.code === 200) {
|
||||
canvasInstance.addShape(res.data.shapes);
|
||||
} else {
|
||||
ElMessage.error("获取历史数据失败");
|
||||
console.error("获取历史数据失败");
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -63,8 +62,6 @@ export const WhiteboardSync = {
|
||||
isRemote = false;
|
||||
}
|
||||
});
|
||||
|
||||
console.log("✅ 已订阅:", topic);
|
||||
} catch (err) {
|
||||
console.log("初始化多人同步失败:", err)
|
||||
// console.error("❌ 连接或订阅失败:", err);
|
||||
@@ -82,6 +79,7 @@ export const WhiteboardSync = {
|
||||
try {
|
||||
await getWhiteboardShapes(shape, roomUid);
|
||||
} catch (err) {
|
||||
ElMessage.error("提交形状失败");
|
||||
console.error("提交形状失败:", err);
|
||||
}
|
||||
});
|
||||
|
||||
103
src/utils/whiteboardSync_old.js
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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)}`;
|
||||
}
|
||||
366
src/views/conferencingRoom/components/InviterJoinRoom/index.vue
Normal 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>
|
||||
871
src/views/conferencingRoom/components/fileUpload/browseFile.vue
Normal 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>
|
||||
268
src/views/conferencingRoom/components/fileUpload/fileList.vue
Normal 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>
|
||||
@@ -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>
|
||||
293
src/views/conferencingRoom/components/fileUpload/upLoadFile.vue
Normal 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>
|
||||
3547
src/views/conferencingRoom/latest.vue
Normal file
3175
src/views/conferencingRoom/pathTransit.vue
Normal file
3519
src/views/conferencingRoom/text.vue
Normal file
3325
src/views/conferencingRoom/transit.vue
Normal file
3326
src/views/conferencingRoom/transits.vue
Normal file
236
src/views/coordinate/personnelList/components/assistWx/index.vue
Normal 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>
|
||||
|
||||
@@ -1,25 +1,56 @@
|
||||
<template>
|
||||
<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-item ' + (leftTab == 1 ? 'pitch-on' : '')"
|
||||
@click="() => (leftTab = 1)"
|
||||
>
|
||||
<img src="@/assets/images/cooponents-tab3.png" v-if="leftTab == 1" />
|
||||
<img src="@/assets/images/cooponents-tab4.png" v-else />
|
||||
<img src="@/assets/images/Gc_114_line-Level-action.png" v-if="leftTab == 1" />
|
||||
<img src="@/assets/images/Gc_114_line-Level.png" v-else />
|
||||
</div>
|
||||
<div
|
||||
:class="'list-tab-item ' + (leftTab == 2 ? 'pitch-on' : '')"
|
||||
@click="() => (leftTab = 2)"
|
||||
>
|
||||
<img src="@/assets/images/cooponents-tab2.png" v-if="leftTab == 2" />
|
||||
<img src="@/assets/images/cooponents-tab1.png" v-else />
|
||||
<img src="@/assets/images/book-read-fill-action.png" v-if="leftTab == 2" />
|
||||
<img src="@/assets/images/book-read-fill.png" v-else />
|
||||
</div>
|
||||
</div>
|
||||
<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
|
||||
v-model="queryFrom.nickName"
|
||||
placeholder="搜索成员"
|
||||
@@ -47,49 +78,50 @@
|
||||
v-infinite-scroll="infinite"
|
||||
v-if="dataList?.length"
|
||||
>
|
||||
<!-- @click="updateDetail(item)" -->
|
||||
<div
|
||||
v-for="(item, index) in dataList"
|
||||
:key="index"
|
||||
class="content-list-item"
|
||||
@click="updateDetail(item)"
|
||||
:style="
|
||||
item.assistanceId == assistanceId
|
||||
item.id == assistanceId
|
||||
? 'border-color: #409EFF; '
|
||||
: ''
|
||||
"
|
||||
>
|
||||
<div class="list-item-top">
|
||||
<span>
|
||||
{{ parseTime(item.beginTime, '{m}月{d}日') }}
|
||||
{{ weekName[new Date(item.beginTime).getDay()] }}
|
||||
{{ parseTime(item.created_at, '{m}月{d}日') }}
|
||||
{{ weekName[new Date(item.created_at).getDay()] }}
|
||||
</span>
|
||||
<span>
|
||||
{{ parseTime(item.beginTime, '{y}年') }}
|
||||
{{ parseTime(item.created_at, '{y}年') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="list-item-content">
|
||||
<div class="list-item-content-text">
|
||||
<div style="display: flex; flex-wrap: wrap">
|
||||
<span
|
||||
v-for="(items, indexs) in item.assistanceMemberList"
|
||||
v-for="(items, indexs) in item.all_participants"
|
||||
:key="indexs"
|
||||
>
|
||||
{{
|
||||
items.nickName +
|
||||
(indexs + 1 == item.assistanceMemberList.length
|
||||
items.display_name +
|
||||
(indexs + 1 == item.all_participants.length
|
||||
? ''
|
||||
: '、')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<span>
|
||||
发起人:{{ item.initiatorName ? item.initiatorName : '' }}
|
||||
发起人:{{ item.all_participants.find(item => item.participant_role == 'moderator')?.display_name || ''}}
|
||||
</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>
|
||||
</div>
|
||||
@@ -133,15 +165,19 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
// getAssistanceList,
|
||||
getParticipantsHistoryApi,
|
||||
getDirectories,
|
||||
getDirectoriesUsers
|
||||
getDirectoriesUsers,
|
||||
getInfo,
|
||||
} from '@/api/coordinate.js'
|
||||
import { nextTick, reactive, toRefs, watch, onMounted } from 'vue'
|
||||
import { deepClone,parseTime ,createThrottle} from '@/utils/ruoyi.js'
|
||||
import { getUserInfo } from '@/utils/auth.js'
|
||||
|
||||
// 接收 props
|
||||
const props = defineProps({
|
||||
@@ -157,13 +193,15 @@ const emit = defineEmits(['updateDetail', 'updateTab'])
|
||||
// state
|
||||
const state = reactive({
|
||||
isFirst: true,
|
||||
leftTab: 2,
|
||||
leftTab: 1,
|
||||
queryFrom: {
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
page_size: 10,
|
||||
page: 1,
|
||||
nickName: '',
|
||||
leftDatePicker: null,
|
||||
participant_user_uids:''
|
||||
},
|
||||
participant_user:[],
|
||||
leftListLoading: true,
|
||||
loading: false,
|
||||
dataList: [],
|
||||
@@ -216,7 +254,22 @@ const state = reactive({
|
||||
},
|
||||
},
|
||||
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 = () => {
|
||||
if (state.leftTab == 1) {
|
||||
state.queryFrom.pageNum = 1
|
||||
state.queryFrom.page = 1
|
||||
state.dataList = []
|
||||
state.queryFrom.participant_user_uids = state.participant_user.join(',')
|
||||
getList()
|
||||
} else {
|
||||
console.log('treeRef.filter',state.treeRef)
|
||||
state.treeRef.filter(state.queryFrom.nickName)
|
||||
}
|
||||
}
|
||||
@@ -245,7 +298,7 @@ const searchList = () => {
|
||||
*/
|
||||
const updateDetail = (item) => {
|
||||
if (state.leftTab == 1) {
|
||||
state.assistanceId = item.assistanceId
|
||||
state.assistanceId = item.id
|
||||
emit('updateDetail', item)
|
||||
} else {
|
||||
if (item.uid) {
|
||||
@@ -257,12 +310,12 @@ const updateDetail = (item) => {
|
||||
/**
|
||||
* 触底加载
|
||||
*/
|
||||
// const infinite = () => {
|
||||
// if (state.more) {
|
||||
// state.queryFrom.pageNum++
|
||||
// getList()
|
||||
// }
|
||||
// }
|
||||
const infinite = () => {
|
||||
if (state.more) {
|
||||
state.queryFrom.page++
|
||||
getList()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 协作记录
|
||||
@@ -270,30 +323,50 @@ const updateDetail = (item) => {
|
||||
const getList = async () => {
|
||||
try {
|
||||
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) {
|
||||
query.beginSignTime = query.leftDatePicker[0]
|
||||
query.endSignTime = query.leftDatePicker[1]
|
||||
const startTime = new Date(query.leftDatePicker[0]);
|
||||
const endTime = new Date(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 })
|
||||
|
||||
state.dataList = infoData.rows.length
|
||||
? state.dataList.concat(infoData.rows)
|
||||
const userData = await getUserInfo()
|
||||
if(!userData) return
|
||||
let infoData = await getParticipantsHistoryApi( userData?.uid ,{ ...query })
|
||||
state.dataList = infoData.data.history?.length
|
||||
? state.dataList.concat(infoData.data.history)
|
||||
: []
|
||||
|
||||
if (state.isFirst) {
|
||||
emit('updateDetail', state.dataList.length ? state.dataList[0] : null)
|
||||
state.assistanceId = state.dataList.length
|
||||
? state.dataList[0].assistanceId
|
||||
: ''
|
||||
emit('updateDetail', state.dataList?.length ? state.dataList[0] : null)
|
||||
state.assistanceId = state.dataList?.length
|
||||
? state.dataList[0].id
|
||||
: ''
|
||||
state.isFirst = false
|
||||
}
|
||||
|
||||
state.more = state.dataList.length < infoData.total
|
||||
state.isShow = Boolean(state.dataList.length)
|
||||
state.more = state.dataList?.length < infoData.data.total
|
||||
state.isShow = Boolean(state.dataList?.length)
|
||||
state.leftListLoading = false
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
@@ -305,42 +378,52 @@ const getList = async () => {
|
||||
* 通讯录 人员信息树
|
||||
*/
|
||||
const HandleLoadNode = async (node, resolve) => {
|
||||
if(node.level === 0){
|
||||
loadNode(resolve)
|
||||
}else if(node.level === 1){
|
||||
loadNode(resolve,node.data.directory_uid)
|
||||
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)=>{
|
||||
const loadNode = async(resolve,id,level)=>{
|
||||
try {
|
||||
// state.leftListLoading = true
|
||||
if(!id){
|
||||
let res = await getDirectories({level:1})
|
||||
resolve(res.data)
|
||||
}else{
|
||||
let res = await getDirectoriesUsers(id,{directory_uuid:id})
|
||||
state.leftListLoading = true
|
||||
let res = await getDirectories({level:1})
|
||||
if(res.meta.code == 200){
|
||||
resolve(res.data)
|
||||
}
|
||||
// state.leftListLoading = false
|
||||
state.leftListLoading = false
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
// state.leftListLoading = false
|
||||
state.leftListLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
// try {
|
||||
// if (userList.data.sub_units?.length) {
|
||||
// state.dataList = userList.data.sub_units
|
||||
// let user = getUser(state.dataList)
|
||||
// emit('updateDetail', user)
|
||||
// } else {
|
||||
// emit('updateDetail', null)
|
||||
// }
|
||||
// state.leftListLoading = false
|
||||
// }
|
||||
|
||||
|
||||
const loadUserNode = async(resolve,id,level)=>{
|
||||
try {
|
||||
state.leftListLoading = 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])
|
||||
state.leftListLoading = false
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
state.leftListLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
const getUser = (list) => {
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
@@ -375,9 +458,10 @@ watch(
|
||||
state.dataList = []
|
||||
state.isFirst = true
|
||||
state.queryFrom = {
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
page_size: 10,
|
||||
page: 1,
|
||||
nickName: '',
|
||||
participant_user_uids:'',
|
||||
leftDatePicker: null,
|
||||
}
|
||||
|
||||
@@ -393,7 +477,7 @@ watch(
|
||||
onMounted(() => {
|
||||
state.dataList = []
|
||||
state.isFirst = true
|
||||
// getList()
|
||||
getList()
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -402,7 +486,7 @@ onMounted(() => {
|
||||
const {
|
||||
isFirst, leftTab, queryFrom, leftListLoading, loading,
|
||||
dataList, more, isShow, shortcuts, weekName,
|
||||
treeRef, treeProps, assistanceId
|
||||
treeRef, treeProps, assistanceId,userList,participant_user
|
||||
} = toRefs(state)
|
||||
</script>
|
||||
|
||||
@@ -430,8 +514,8 @@ const {
|
||||
cursor: pointer;
|
||||
|
||||
img {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -447,7 +531,7 @@ const {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
padding: 6px 20px;
|
||||
background: #167bff;
|
||||
background: #666666;
|
||||
}
|
||||
|
||||
.content-datapicker {
|
||||
|
||||
@@ -1,274 +1,278 @@
|
||||
<template>
|
||||
<div class="app-container" v-loading="load" :element-loading-text="loadText">
|
||||
<el-row :gutter="6" style="padding: 0 10px; background: #fefefe">
|
||||
<el-col :xs="24" :sm="24" :md="8" :lg="6">
|
||||
<leftTab
|
||||
@updateDetail="updateDetail"
|
||||
@updateTab="updateTab"
|
||||
: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>
|
||||
<div>
|
||||
<div class="app-container" v-loading="load" :element-loading-text="loadText">
|
||||
<el-row :gutter="6" style="padding: 0 10px; background: #fefefe">
|
||||
<el-col :xs="24" :sm="24" :md="8" :lg="6">
|
||||
<div>
|
||||
<div class="file-top">
|
||||
附件({{
|
||||
detail?.fileList?.length ? detail.fileList.length : 0
|
||||
}})
|
||||
</div>
|
||||
<div class="content-file-list">
|
||||
<el-scrollbar
|
||||
class="file-list"
|
||||
height="calc(100vh - 380px)"
|
||||
>
|
||||
<div
|
||||
class="file-list-content"
|
||||
v-if="detail?.fileList?.length"
|
||||
>
|
||||
<div
|
||||
class="file-list-item"
|
||||
v-for="(item, index) in detail.fileList"
|
||||
:key="index"
|
||||
>
|
||||
<div class="file-list-item-icon"></div>
|
||||
<div class="file-list-item-text">
|
||||
<div class="list-item-text text-out-of-hiding-1">
|
||||
{{ item.name }}
|
||||
</div>
|
||||
<el-link
|
||||
:href="item.prefix + item.path"
|
||||
type="primary"
|
||||
target="_blank"
|
||||
:underline="false"
|
||||
>
|
||||
<el-icon
|
||||
:size="18"
|
||||
color="#0d74ff"
|
||||
style="cursor: pointer"
|
||||
>
|
||||
<Download />
|
||||
</el-icon>
|
||||
</el-link>
|
||||
<el-icon
|
||||
:size="18"
|
||||
color="#FF4646"
|
||||
style="cursor: pointer"
|
||||
@click="clickDeleteFile(item)"
|
||||
>
|
||||
<Delete />
|
||||
</el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
</div>
|
||||
<leftTab
|
||||
@updateDetail="updateDetail"
|
||||
@updateTab="updateTab"
|
||||
:loading="!detail?.appId && !detail?.userId && isShow"
|
||||
/>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
<div
|
||||
class="message-user"
|
||||
v-else-if="isShow && tabValue == 2"
|
||||
style="height: calc(100vh - 90px)"
|
||||
>
|
||||
<div class="message-user-card">
|
||||
<div class="user-card-nickName">
|
||||
<img v-if="detail.avatar" :src="detail.avatar" />
|
||||
<img v-else src="@/assets/images/profile.jpg" />
|
||||
<span>{{ detail.nickName || detail.name || '暂无信息' }}</span>
|
||||
</div>
|
||||
<div class="user-card-information">
|
||||
<div class="user-information-item">
|
||||
<div class="user-information-title">
|
||||
<img src="@/assets/images/user-information1.png" alt="" />
|
||||
<span>性别</span>
|
||||
<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 class="user-information-text">
|
||||
<!-- <dict-tag
|
||||
v-if="detail.sex"
|
||||
:options="sys_user_sex"
|
||||
:value="detail.sex"
|
||||
/>
|
||||
<div v-else>
|
||||
{{ '暂无' }}
|
||||
</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.initiator }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-information-item">
|
||||
<div class="user-information-title">
|
||||
<img src="@/assets/images/user-information2.png" alt="" />
|
||||
<span>手机号</span>
|
||||
<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="user-information-text">
|
||||
{{ detail.phonenumber || '暂无' }}
|
||||
<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"
|
||||
>
|
||||
<span
|
||||
v-for="(items, indexs) in detail.all_participants"
|
||||
:key="indexs"
|
||||
>
|
||||
{{
|
||||
items.display_name +
|
||||
(indexs + 1 == detail.all_participants.length
|
||||
? ''
|
||||
: '、')
|
||||
}}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-information-item">
|
||||
<div class="user-information-title">
|
||||
<img src="@/assets/images/user-information3.png" alt="" />
|
||||
<span>邮箱</span>
|
||||
<div class="agency-detail-cont-item">
|
||||
<span class="agency-detail-item-title">协作时长</span>
|
||||
<span class="agency-detail-item-content">
|
||||
{{ getTime() }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="user-information-text">
|
||||
{{ detail.email || '暂无' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-information-item">
|
||||
<div class="user-information-title">
|
||||
<img src="@/assets/images/user-information4.png" alt="" />
|
||||
<span>所属部门</span>
|
||||
</div>
|
||||
<div class="user-information-text">
|
||||
{{ detail.organization || '暂无' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-card-btn">
|
||||
<el-button type="primary" @click="clickInitiate">
|
||||
发起协作
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="message-null">
|
||||
<el-empty description="暂无内容" />
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-dialog
|
||||
v-model="inviteDialog"
|
||||
title="远程协作"
|
||||
width="400px"
|
||||
:close-on-press-escape="false"
|
||||
:close-on-click-modal="false"
|
||||
:show-close="false"
|
||||
>
|
||||
<div>
|
||||
<div style="width: 100%; margin-bottom: 30px; font-size: 20px">
|
||||
"
|
||||
{{
|
||||
socketInformation.room_name
|
||||
? socketInformation.room_name
|
||||
: ''
|
||||
}}
|
||||
" 邀请您参加远程协作
|
||||
</div>
|
||||
<div style="text-align: center">
|
||||
<el-button
|
||||
size="large"
|
||||
type="danger"
|
||||
style="font-size: 16px"
|
||||
@click="clickRefuseJoin"
|
||||
<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?.storage_url"
|
||||
:src="detail.remoteVideoFile.storage_url"
|
||||
id="videoPlayer"
|
||||
loop
|
||||
autoplay
|
||||
controls
|
||||
></video>
|
||||
<div v-else class="video-null">暂无视频</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="24" :md="8" :lg="6">
|
||||
<div>
|
||||
<div class="file-top">
|
||||
附件({{
|
||||
detail?.fileList?.length ? detail.fileList.length : 0
|
||||
}})
|
||||
</div>
|
||||
<div class="content-file-list">
|
||||
<el-scrollbar
|
||||
class="file-list"
|
||||
height="calc(100vh - 380px)"
|
||||
>
|
||||
<div
|
||||
class="file-list-content"
|
||||
v-if="detail?.fileList?.length"
|
||||
>
|
||||
<div
|
||||
class="file-list-item"
|
||||
v-for="(item, index) in detail.fileList"
|
||||
:key="index"
|
||||
>
|
||||
<div class="file-list-item-icon"></div>
|
||||
<div class="file-list-item-text">
|
||||
<div class="list-item-text text-out-of-hiding-1">
|
||||
{{ item.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
|
||||
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-row>
|
||||
|
||||
<!-- <el-dialog
|
||||
v-model="inviteDialog"
|
||||
title="远程协作"
|
||||
width="400px"
|
||||
:close-on-press-escape="false"
|
||||
:close-on-click-modal="false"
|
||||
:show-close="false"
|
||||
>
|
||||
拒 绝
|
||||
</el-button>
|
||||
<el-button
|
||||
size="large"
|
||||
type="primary"
|
||||
style="font-size: 16px"
|
||||
@click="clickJoin"
|
||||
>
|
||||
加 入
|
||||
</el-button>
|
||||
</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>
|
||||
</el-dialog> -->
|
||||
</div>
|
||||
</el-dialog>
|
||||
<!-- 文件预览 -->
|
||||
<BrowseFile ref="browseFileRef" />
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<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 AssistWx from './components/assistWx/index.vue'
|
||||
import BrowseFile from '@/views/conferencingRoom/components/fileUpload/browseFile.vue'
|
||||
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 { useRoute, useRouter } from 'vue-router'
|
||||
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 { getToken ,getUserInfo} from '@/utils/auth.js';
|
||||
import { deepClone,parseTime } from '@/utils/ruoyi.js'
|
||||
const roomStore = useRoomStore()
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
@@ -276,8 +280,9 @@ const { proxy } = getCurrentInstance()
|
||||
const state = reactive({
|
||||
detail: {},
|
||||
weekName: ['周日', '周一', '周二', '周三', '周四', '周五', '周六'],
|
||||
tabValue: 2,
|
||||
tabValue: 1,
|
||||
isShow: true,
|
||||
isShowLoading: false,
|
||||
load: false,
|
||||
loadText: '数据加载中',
|
||||
isLinkKnow: 'F',
|
||||
@@ -285,6 +290,9 @@ const state = reactive({
|
||||
inviteDialog: false,
|
||||
cooperation: import.meta.env.VITE_APP_COOPERATION_TYPE,
|
||||
})
|
||||
const userLoading = ref(false); // 用户信息加载状态
|
||||
//文件预览
|
||||
const browseFileRef = ref(null);
|
||||
|
||||
const isEmptyObject = (obj) => {
|
||||
return !obj || Object.keys(obj).length === 0
|
||||
@@ -294,10 +302,12 @@ const isEmptyObject = (obj) => {
|
||||
const clickInitiate = () => {
|
||||
let userData = null
|
||||
try {
|
||||
userData = JSON.parse(localStorage.getItem('userData')) || null
|
||||
userData = JSON.parse(sessionStorage.getItem('userData')) || null
|
||||
} catch (e) {
|
||||
console.error('解析 userData 失败:', e)
|
||||
}
|
||||
|
||||
|
||||
if (isEmptyObject(state.detail)) {
|
||||
ElMessage({
|
||||
message: '请先选择人员',
|
||||
@@ -314,6 +324,7 @@ const clickInitiate = () => {
|
||||
}
|
||||
roomStore.setUserUid(userData.uid)
|
||||
roomStore.setDetailUid(state.detail.uid)
|
||||
roomStore.setDetailName(state.detail.name)
|
||||
router.push({
|
||||
path: '/conferencingRoom',
|
||||
query:{
|
||||
@@ -330,29 +341,48 @@ const updateTab = (newValue) => {
|
||||
|
||||
/** 修改展示区内容 */
|
||||
const updateDetail = async (details) => {
|
||||
userLoading.value = true
|
||||
if (details) {
|
||||
console.log(details,'details')
|
||||
state.detail = {}
|
||||
if (state.tabValue == 1) {
|
||||
state.isShow = true
|
||||
state.isShowLoading = true
|
||||
getTheFileList(details)
|
||||
} else {
|
||||
const res = await getInfo(details.uid)
|
||||
state.detail = res.data
|
||||
// getInfo(details.uid)
|
||||
// .then((res) => {
|
||||
// console.log(res,'人员详细信息')
|
||||
// })
|
||||
// .finally(() => { state.isShow = true })
|
||||
userLoading.value = false
|
||||
}
|
||||
} else {
|
||||
state.isShow = false
|
||||
}
|
||||
userLoading.value = false
|
||||
}
|
||||
|
||||
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);
|
||||
// 可以根据需要添加错误处理逻辑
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取通话时长 */
|
||||
const getTime = () => {
|
||||
let begin = new Date(state.detail.beginTime).getTime()
|
||||
let end = new Date(state.detail.endTime).getTime()
|
||||
let begin = new Date(state.detail.created_at).getTime()
|
||||
let end = new Date(state.detail.updated_at).getTime()
|
||||
if (begin && end) {
|
||||
let diff = end - begin
|
||||
const h = Math.floor(diff / (1000 * 60 * 60))
|
||||
@@ -366,77 +396,92 @@ const getTime = () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 加入会议 */
|
||||
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
|
||||
|
||||
//文件预览
|
||||
function handlePreview(file) {
|
||||
if (!file.preview_url) {
|
||||
ElMessage.error('文件链接无效');
|
||||
return;
|
||||
}
|
||||
browseFileRef.value.showEdit(file)
|
||||
}
|
||||
|
||||
/** 加入会议 */
|
||||
// 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 () => {
|
||||
//status 1: 同意加入, 5: 拒绝加入
|
||||
const res = await getStatusApi(state.socketInformation.room_uid,{status:5})
|
||||
if(res.meta.code == 200){
|
||||
ElMessage({
|
||||
message: '已拒绝加入该协作',
|
||||
type: 'error',
|
||||
})
|
||||
state.inviteDialog = false
|
||||
}
|
||||
}
|
||||
// const clickRefuseJoin = async () => {
|
||||
// //status 1: 同意加入, 5: 拒绝加入
|
||||
// const res = await getStatusApi(state.socketInformation.room_uid,{status:5})
|
||||
// if(res.meta.code == 200){
|
||||
// ElMessage({
|
||||
// message: '已拒绝加入该协作',
|
||||
// type: 'error',
|
||||
// })
|
||||
// state.inviteDialog = false
|
||||
// }
|
||||
// }
|
||||
|
||||
/** 处理 mqtt 消息 */
|
||||
const processingSocket = (message) => {
|
||||
if (message) {
|
||||
state.socketInformation = JSON.parse(message)
|
||||
console.log(state.socketInformation,'state.socketInformation')
|
||||
state.inviteDialog = true
|
||||
showNotification(state.socketInformation)
|
||||
}
|
||||
}
|
||||
/** 处理加入房间和拒接房间 mqtt 消息 */
|
||||
// const processingSocket = (message) => {
|
||||
// const res = JSON.parse(message)
|
||||
// if (!res?.status) {
|
||||
// state.socketInformation = res
|
||||
// state.inviteDialog = true
|
||||
// showNotification(state.socketInformation)
|
||||
// }else if(res.status == 5){
|
||||
// ElMessage({
|
||||
// message: `${res?.display_name}拒绝加入该协作`,
|
||||
// type: 'error',
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
|
||||
/** 浏览器通知 */
|
||||
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() }
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
// 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() }
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
|
||||
|
||||
// 暴露给模板
|
||||
const { detail, weekName, tabValue, isShow, load, loadText, isLinkKnow, socketInformation, inviteDialog, cooperation } = toRefs(state)
|
||||
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) => {
|
||||
processingSocket(shapeData.toString())
|
||||
});
|
||||
})
|
||||
const { detail, weekName, tabValue, isShow, load, loadText, isLinkKnow, socketInformation, inviteDialog, cooperation,isShowLoading } = toRefs(state)
|
||||
// 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>
|
||||
|
||||
@@ -451,6 +496,7 @@ onMounted(async () => {
|
||||
.app-container {
|
||||
padding: 20px;
|
||||
// margin: 0 17px;
|
||||
// height: calc(100vh - 50px);
|
||||
}
|
||||
|
||||
.message-null {
|
||||
@@ -468,7 +514,7 @@ onMounted(async () => {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
padding: 0 20px;
|
||||
background: #167bff;
|
||||
background: #666666;
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
}
|
||||
@@ -576,8 +622,8 @@ onMounted(async () => {
|
||||
height: 40%;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(13, 116, 255, 0.22) 0%,
|
||||
rgba(30, 173, 255, 0) 100%
|
||||
rgba(153, 153, 153, 0.22) 0%,
|
||||
rgba(153, 153, 153, 0) 100%
|
||||
);
|
||||
|
||||
img {
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
<template>
|
||||
<div class="wrapper-content">
|
||||
<div v-if="showLogin">
|
||||
<!-- 登录界面 -->
|
||||
<Login @loginSuccess="handleLoginSuccess" />
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div>
|
||||
<!-- 未加入时显示按钮 -->
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -27,15 +22,23 @@ 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 props = defineProps({
|
||||
roomId: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
userId: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const hasJoined = ref(false); // 是否加入白板
|
||||
const canvas = ref(null);
|
||||
const route = useRoute();
|
||||
@@ -51,7 +54,7 @@ async function joinWhiteboard() {
|
||||
try {
|
||||
const clientId = `whiteboard-${uuidv4()}`;
|
||||
await mqttClient.connect(clientId);
|
||||
console.log("✅ 已连接 MQTT:", clientId);
|
||||
// console.log("✅ 已连接 MQTT:", clientId);
|
||||
|
||||
hasJoined.value = true;
|
||||
|
||||
@@ -68,11 +71,6 @@ async function joinWhiteboard() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 登录成功回调 */
|
||||
function handleLoginSuccess() {
|
||||
showLogin.value = false;
|
||||
}
|
||||
|
||||
/** 初始化白板 */
|
||||
function initWhiteboard() {
|
||||
const el = document.getElementById("whiteboard");
|
||||
@@ -80,55 +78,17 @@ function initWhiteboard() {
|
||||
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";
|
||||
|
||||
const roomUid = route.query.room_uid || props.roomId || "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");
|
||||
console.log(res, "用户信息校验")
|
||||
if (res.meta.code === 401) {
|
||||
showLogin.value = true;
|
||||
} else {
|
||||
showLogin.value = false;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("⚠️ 用户信息校验失败:", err);
|
||||
}
|
||||
joinWhiteboard()
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -136,17 +96,16 @@ onUnmounted(() => {
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
<style scoped>
|
||||
/* 外层容器全屏居中 */
|
||||
.wrapper-content {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
position: relative;
|
||||
position: relative; /* 关键:为绝对定位子元素提供参照 */
|
||||
}
|
||||
|
||||
/* 登录按钮容器居中 */
|
||||
@@ -159,9 +118,8 @@ onUnmounted(() => {
|
||||
/* 白板容器 */
|
||||
.whiteboard-wrapper {
|
||||
position: relative;
|
||||
width: 90vw;
|
||||
max-width: 1280px;
|
||||
aspect-ratio: 16 / 9;
|
||||
width: 72vw;
|
||||
height: 69vh;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -176,11 +134,11 @@ onUnmounted(() => {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 工具栏左侧垂直居中 */
|
||||
/* 工具栏固定在 wrapper-content 内 */
|
||||
.toolbox {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 10px;
|
||||
left: 40px; /* 距离左侧的间距 */
|
||||
transform: translateY(-50%);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
185
src/views/custom/tabulaRase/index_old.vue
Normal 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>
|
||||
@@ -1,12 +1,6 @@
|
||||
<template>
|
||||
<div class="loginView" v-loading="loginView">
|
||||
<div class="wrapper-content" v-if="showLogin">
|
||||
<!-- <div class="content-nav">
|
||||
<div class="nav-left">
|
||||
<img src="../assets/logo/logo.png" />
|
||||
<div>多人互动白板</div>
|
||||
</div>
|
||||
</div> -->
|
||||
<div class="loginView">
|
||||
<div class="wrapper-content" >
|
||||
<div class="login-form">
|
||||
<div class="selected-rectangle"></div>
|
||||
<el-form ref="loginRef" class="form-info" :model="loginForm" :rules="loginRules">
|
||||
@@ -37,22 +31,26 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import useUserStore from '@/stores/modules/user'
|
||||
import { useUserStore } from '@/stores/modules/user.js'
|
||||
import { watch, ref, getCurrentInstance, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElNotification } from 'element-plus'
|
||||
import { getInfo } from "@/api/login";
|
||||
import { ElNotification,ElMessage } from 'element-plus'
|
||||
import CryptoJS from 'crypto-js';
|
||||
import { useMeterStore } from '@/stores/modules/meter'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { proxy } = getCurrentInstance()
|
||||
const showLogin = ref(false)
|
||||
|
||||
const meterStore = useMeterStore()
|
||||
|
||||
|
||||
const redirect = ref(undefined);
|
||||
const loginView = ref(false)
|
||||
const loginView = ref(true)
|
||||
|
||||
watch(() => router.currentRoute.value, (newRoute) => {
|
||||
// 监听路由变化,获取重定向参数
|
||||
watch(() => route, (newRoute) => {
|
||||
redirect.value = newRoute.query && newRoute.query.redirect;
|
||||
}, { immediate: true });
|
||||
|
||||
@@ -69,26 +67,31 @@ const loginRules = {
|
||||
const loading = ref(false)
|
||||
|
||||
|
||||
function handleLogin() {
|
||||
function handleLogin() {
|
||||
proxy.$refs.loginRef.validate((valid) => {
|
||||
if (valid) {
|
||||
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的登录方法
|
||||
userStore
|
||||
.login({
|
||||
password: loginForm.value.password,
|
||||
// password: loginForm.value.password,
|
||||
password: ciphertext,
|
||||
username: loginForm.value.username,
|
||||
})
|
||||
.then(async (res) => {
|
||||
const userInfo = JSON.parse(localStorage.getItem('userData'))
|
||||
router.push({
|
||||
path: '/coordinate',
|
||||
})
|
||||
// router.push({
|
||||
// path: '/whiteboard',
|
||||
// query: { room_uid: 'nxst-ok4j' }
|
||||
// })
|
||||
|
||||
const userInfo = JSON.parse(sessionStorage.getItem('userData'))
|
||||
await handleLoginSuccess();
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log('登录失败', e)
|
||||
@@ -99,6 +102,51 @@ function handleLogin() {
|
||||
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 请求浏览器的通知权限
|
||||
* @returns {*}
|
||||
@@ -113,27 +161,7 @@ function requestNotificationPermission() {
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
loginView.value = true
|
||||
const res = await getInfo("self");
|
||||
showLogin.value = false;
|
||||
if (res.meta.code === 401) {
|
||||
showLogin.value = true;
|
||||
} else {
|
||||
// router.push({
|
||||
// path: '/whiteboard',
|
||||
// query: { room_uid: 'nxst-ok4j' }
|
||||
// })
|
||||
router.push({
|
||||
path: '/coordinate',
|
||||
})
|
||||
}
|
||||
loginView.value = false
|
||||
} catch (err) {
|
||||
console.warn("⚠️ 用户信息校验失败:", err);
|
||||
} finally {
|
||||
loginView.value = false
|
||||
}
|
||||
meterStore.initUdid()
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
14
src/views/redirect/index.vue
Normal 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>
|
||||
@@ -1,46 +1,44 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import path from "path";
|
||||
import { defineConfig } from 'vite'
|
||||
import { defineConfig ,loadEnv} from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { codeInspectorPlugin } from 'code-inspector-plugin'
|
||||
import createVitePlugins from './vite/plugins/index.js'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
codeInspectorPlugin({
|
||||
bundler: 'vite', // 使用 vite
|
||||
showSwitch: true,
|
||||
}),
|
||||
],
|
||||
server: {
|
||||
host: '0.0.0.0', // 关键配置,允许局域网访问
|
||||
port: 3000,
|
||||
open: true,
|
||||
hmr: { overlay: false },
|
||||
proxy: {
|
||||
'/dev-api': {
|
||||
target: 'https://xsynergy.gxtech.ltd', // 从环境变量读取
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
rewrite: (path) =>
|
||||
path.replace(new RegExp(`^/dev-api`), '')
|
||||
},
|
||||
'/livekit-api': {
|
||||
target: 'https://meeting.cnsdt.com/api/v1', // 从环境变量读取
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
rewrite: (path) =>
|
||||
path.replace(new RegExp(`^/livekit-api`), '')
|
||||
export default defineConfig(({ mode, command }) => {
|
||||
const env = loadEnv(mode, process.cwd());
|
||||
const { VITE_BASE_PATH } = env;
|
||||
return {
|
||||
plugins: createVitePlugins(env, command === "build"),
|
||||
server: {
|
||||
host: '0.0.0.0', // 关键配置,允许局域网访问
|
||||
port: 3000,
|
||||
open: true,
|
||||
hmr: { overlay: false },
|
||||
proxy: {
|
||||
'/dev-api': {
|
||||
target: 'https://xsynergy.gxtech.ltd',
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
rewrite: (path) =>
|
||||
path.replace(new RegExp(`^/dev-api`), '')
|
||||
},
|
||||
'/livekit-api': {
|
||||
target: 'https://meeting.cnsdt.com/api/v1',
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
rewrite: (path) =>
|
||||
path.replace(new RegExp(`^/livekit-api`), '')
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
css: {
|
||||
postcss: './postcss.config.js' // 纯外部配置
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
css: {
|
||||
postcss: './postcss.config.js' // 纯外部配置
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
13
vite/plugins/auto-import.js
Normal 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
|
||||
})
|
||||
}
|
||||
28
vite/plugins/compression.js
Normal 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
@@ -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;
|
||||
}
|
||||
5
vite/plugins/setup-extend.js
Normal 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
@@ -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
|
||||
})
|
||||
}
|
||||