feat:更新侧边导航,修改密码等

This commit is contained in:
leilei
2025-10-20 17:41:54 +08:00
parent db72ea9f33
commit e0001ba430
59 changed files with 10434 additions and 775 deletions

3634
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.0.10", "@element-plus/icons-vue": "^2.0.10",
"@msgpack/msgpack": "^3.1.2", "@msgpack/msgpack": "^3.1.2",
"@vueuse/core": "^9.5.0",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"axios": "^0.27.2", "axios": "^0.27.2",
"code-inspector-plugin": "^0.20.12", "code-inspector-plugin": "^0.20.12",
@@ -24,14 +25,18 @@
"postcss": "^8.5.6", "postcss": "^8.5.6",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-svg-icons": "^2.0.1",
"vue": "^3.5.12", "vue": "^3.5.12",
"vue-router": "^4.4.5" "vue-router": "^4.4.5"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.1.4", "@vitejs/plugin-vue": "^5.1.4",
"@vue/compiler-sfc": "^3.2.45",
"sass": "^1.56.1", "sass": "^1.56.1",
"unplugin-auto-import": "^20.1.0", "unplugin-auto-import": "^0.11.4",
"unplugin-vue-components": "^29.0.0", "unplugin-vue-components": "^29.0.0",
"unplugin-vue-setup-extend-plus": "^0.4.9",
"vite": "^5.4.10" "vite": "^5.4.10"
} }
} }

View File

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

View File

@@ -64,3 +64,11 @@ export function exitRoomApi(room_uid) {
}) })
} }
//获取当前房间信息
export function getRoomInfoApi(room_uid) {
return request({
url: `/api/v1/meeting/${room_uid}/info`,
method: 'get',
})
}

View File

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

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

@@ -0,0 +1,9 @@
import request from '@/utils/request'
// 获取路由
export const getRouters = () => {
return request({
url: '/system/menu/getRouters',
method: 'get'
})
}

View File

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

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

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

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

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

View File

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

View File

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

View File

@@ -26,7 +26,7 @@
</template> </template>
<script setup> <script setup>
import useUserStore from '@/stores/modules/user' import { useUserStore } from '@/stores/modules/user.js'
import { watch, ref, getCurrentInstance,onMounted } from 'vue' import { watch, ref, getCurrentInstance,onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useMeterStore } from '@/stores/modules/meter' import { useMeterStore } from '@/stores/modules/meter'
@@ -123,7 +123,7 @@ onMounted(() => {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
height: 100%; height: calc(100vh - 60px);
padding: 0 40px; padding: 0 40px;
background: linear-gradient(135deg, #f5f7fa 0%, #eef1f6 100%); background: linear-gradient(135deg, #f5f7fa 0%, #eef1f6 100%);
} }

View File

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

View File

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

View File

@@ -63,6 +63,7 @@ class Canvas extends EventEmitter {
this.render(); this.render();
} }
setDrawingTool(tool) { this.drawingTool = tool; } setDrawingTool(tool) { this.drawingTool = tool; }
setColor(color) { this.currentColor = color; } setColor(color) { this.currentColor = color; }
setThickness(size) { this.currentThickness = size; } setThickness(size) { this.currentThickness = size; }

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,161 @@
<template>
<div>
<el-dialog
v-model="dialogVisible"
title="修改密码"
width="500"
:show-close='false'
:close-on-click-modal="false"
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>
</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() {
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
}
}
loading.value = true;
const res = await changePwd(user.oldPassword, user.newPassword);
if(res.meta.code !== 200){
ElMessage.error(res.meta?.message || '密码修改失败');
return
}else{
ElMessage.success('密码修改成功,请重新登录');
}
await userStore.logOut();
location.href = '/';
} catch (error) {
console.log(error,'error')
} finally {
loading.value = false;
}
}
/** 关闭按钮 */
function close() {
dialogVisible.value = false
proxy.$refs.pwdRef.resetFields()
}
function open(){
dialogVisible.value = true
}
defineExpose({
open
})
</script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
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'

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

@@ -0,0 +1,309 @@
<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"/>
</div>
</template>
<script setup>
import { ElMessageBox } from 'element-plus'
import { useWindowSize } from '@vueuse/core'
import Sidebar from './components/Sidebar/index.vue'
import { AppMain, TagsView ,ResetPwd} 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'
const settingsStore = useSettingsStore()
const userStore = useUserStore()
const useAppStoreStore = useAppStore()
const router = useRouter()
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 })
}
</script>
<style lang="scss" scoped>
@import '@/assets/styles/mixin.scss';
@import '@/assets/styles/variables.module.scss';
.tags-view {
opacity: 0;
top: 0;
position: fixed;
}
.main-container {
position: relative;
display: flex;
height: calc(100vh - 50px);
overflow-y: auto;
overflow: hidden;
margin: 0;
background-color: #fff;
// border-radius: 30px;
box-shadow: 0 0 10px 4px #ece9e3;
.sidebar-container {
box-shadow: rgba(0, 0, 0, 0.15) 1.95px 1.95px 2.6px;
width: 200px;
// box-shadow: 0 0 10px 4px #f6f6f6;
}
.sidebar-right {
width: calc(100% - 200px);
position: relative;
overflow: hidden;
overflow-x: hidden;
overflow-y: scroll;
// padding: 0 20px;
.right-bar {
width: 100%;
background: #f6f6f6;
overflow-x: hidden;
// border-radius: 0 30px 0 0;
}
}
/* 滚动条整体 */
.sidebar-right::-webkit-scrollbar {
height: 0px;
width: 0px;
}
}
.app-wrapper {
@include clearfix;
position: relative;
height: 100%;
width: 100%;
background: #f6f6f6;
// padding: 20px;
&.mobile.openSidebar {
position: fixed;
top: 0;
}
}
// @media screen and (max-width: 995px) {
// .wrapper-content {
// .content-nav {
// z-index: 0 !important;
// }
// }
// }
.wrapper-content {
.content-nav {
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
z-index: 1111;
width: 100%;
height: 50px;
-webkit-box-shadow: 2px 0 9px rgba(0, 21, 41, 0.35);
box-shadow: 2px 0 9px rgba(0, 21, 41, 0.35);
padding: 3px 20px;
// background-color: #8290f0;
background-color: #434343;
.nav-left {
display: flex;
align-items: center;
color: #fff;
box-sizing: border-box;
div {
font-size: 19px;
}
}
.nav-right {
display: flex;
flex-wrap: nowrap;
align-items: center;
height: 100%;
.screenfull {
font-size: 22px;
cursor: pointer;
color: #ffe565;
}
.avatar-wrapper {
position: relative;
display: flex;
flex-wrap: nowrap;
cursor: pointer;
align-items: center;
margin-right: 30px;
.user-avatar {
cursor: pointer;
width: 50px;
height: 50px;
border-radius: 10px;
border-radius: 50%;
}
.username {
padding: 0 13px;
font-weight: 700;
font-size: 18px;
line-height: 1;
color: #fff;
}
.nickName {
font-weight: 700;
font-size: 18px;
color: #fff;
}
i {
cursor: pointer;
margin-left: 6px;
font-size: 20px;
color: #ffe565;
}
}
}
}
}
.drawer-bg {
background: #000;
opacity: 0.3;
width: 100%;
top: 0;
height: 100%;
position: absolute;
z-index: 999;
}
.fixed-header {
position: fixed;
top: 0;
right: 0;
z-index: 9;
width: calc(100% - #{$base-sidebar-width});
transition: width 0.28s;
}
.hideSidebar .fixed-header {
width: calc(100% - 54px);
}
.sidebarHide .fixed-header {
width: 100%;
}
.mobile .fixed-header {
width: 100%;
}
</style>

View File

@@ -17,14 +17,14 @@ import zhCn from 'element-plus/es/locale/lang/zh-cn'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
const pinia = createPinia()
const app = createApp(App) const app = createApp(App)
for (const [key, component] of Object.entries(ElementPlusIconsVue)) { for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component) app.component(key, component)
} }
app.use(createPinia()) app.use(pinia)
app.use(router) app.use(router)
app.use(ElementPlus, { app.use(ElementPlus, {
locale: zhCn, locale: zhCn,

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

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

View File

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

47
src/settings.js Normal file
View File

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

View File

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

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

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

View File

@@ -1,5 +1,5 @@
import { defineStore } from 'pinia'
import { generateUUID } from '@/utils/tools.js' import { generateUUID } from '@/utils/tools.js'
import { defineStore } from 'pinia'
export const useMeterStore = defineStore('meter', { export const useMeterStore = defineStore('meter', {
state: () => ({ state: () => ({

View File

@@ -5,6 +5,7 @@ export const useRoomStore = defineStore('room', {
roomId: '', roomId: '',
token: '', token: '',
userUid: '', userUid: '',
//邀请进入房间的用户uid
detailUid: '', detailUid: '',
}), }),
actions: { actions: {

View File

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

View File

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

View File

@@ -1,10 +1,9 @@
import { login, logout, getInfo } from '@/api/login.js'
import { login, logout, getInfo } from '@/api/login' import { getToken, setToken, removeToken } from '@/utils/auth.js'
import { getToken, setToken, removeToken } from '@/utils/auth'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
const useUserStore = defineStore( export const useUserStore = defineStore(
'user', 'user',
{ {
state: () => ({ state: () => ({
@@ -26,6 +25,7 @@ const useUserStore = defineStore(
return Promise.reject(res); return Promise.reject(res);
} }
const { token, user } = res.data; const { token, user } = res.data;
this.name = user.name;
sessionStorage.setItem('userData', JSON.stringify(user)); sessionStorage.setItem('userData', JSON.stringify(user));
setToken(token); setToken(token);
this.token = token; this.token = token;
@@ -58,9 +58,14 @@ const useUserStore = defineStore(
// 退出系统 // 退出系统
async logOut() { async logOut() {
try { try {
await logout(); const res = await logout();
if(res.meta.code !== 200){
ElMessage({ message: res.meta?.message || '退出登录失败', type: 'error' });
return
}
this.token = ''; this.token = '';
this.roles = ''; this.roles = '';
sessionStorage.removeItem('userData');
removeToken(); removeToken();
} catch (error) { } catch (error) {
console.error('退出登录失败:', error); console.error('退出登录失败:', error);
@@ -73,5 +78,3 @@ const useUserStore = defineStore(
} }
} }
}) })
export default useUserStore

View File

@@ -4,14 +4,17 @@ import Cookies from "js-cookie";
const TokenKey = "token"; const TokenKey = "token";
export function getToken() { export function getToken() {
return Cookies.get(TokenKey); // return Cookies.get(TokenKey);
return sessionStorage.getItem(TokenKey);
} }
export function setToken(token) { export function setToken(token) {
return Cookies.set(TokenKey, token); // return Cookies.set(TokenKey, token);
return sessionStorage.setItem(TokenKey, token);
} }
export function removeToken() { export function removeToken() {
return Cookies.remove(TokenKey); // return Cookies.remove(TokenKey);
return sessionStorage.removeItem(TokenKey);
} }

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

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

View File

@@ -8,11 +8,9 @@ import { tansParams } from "@/utils/ruoyi";
import cache from "@/plugins/cache"; import cache from "@/plugins/cache";
import { getToken, removeToken } from "@/utils/auth"; import { getToken, removeToken } from "@/utils/auth";
import router from '@/router'; import router from '@/router';
import { useMeterStore } from '@/stores/modules/meter' import { useMeterStore } from '@/stores/modules/meter.js'
axios.defaults.headers["Content-Type"] = "application/json;charset=utf-8"; axios.defaults.headers["Content-Type"] = "application/json;charset=utf-8";
const meterStore = useMeterStore()
meterStore.initUdid()
// 创建axios实例 // 创建axios实例
const service = axios.create({ const service = axios.create({
// axios中请求配置有baseURL选项表示请求URL公共部分 // axios中请求配置有baseURL选项表示请求URL公共部分
@@ -24,6 +22,19 @@ const service = axios.create({
// request拦截器 // request拦截器
service.interceptors.request.use( service.interceptors.request.use(
(config) => { (config) => {
// 在拦截器内部安全地使用 store
let sudid = ''
try {
const meterStore = useMeterStore()
if (!meterStore.udid) {
meterStore.initUdid();
sudid = meterStore.getSudid();
} else {
sudid = meterStore.getSudid();
}
} catch (error) {
console.warn('MeterStore 初始化失败:', error);
}
// 是否需要设置 token // 是否需要设置 token
const isToken = (config.headers || {}).isToken === false; const isToken = (config.headers || {}).isToken === false;
@@ -33,8 +44,8 @@ service.interceptors.request.use(
// 是否需要防止数据重复提交 // 是否需要防止数据重复提交
const isRepeatSubmit = (config.headers || {}).repeatSubmit === false; const isRepeatSubmit = (config.headers || {}).repeatSubmit === false;
if (meterStore.getSudid()) { if (sudid) {
config.headers["X-User-Agent"] = `gxtech/web 1.0.0: c=GxTech, udid=${meterStore.getSudid()}, sv=15.4.1, app=stt`; config.headers["X-User-Agent"] = `gxtech/web 1.0.0: c=GxTech, udid=${sudid}, sv=15.4.1, app=stt`;
} }
// get请求映射params参数 // get请求映射params参数
if (config.method === "get" && config.params) { if (config.method === "get" && config.params) {

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

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

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

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

View File

@@ -0,0 +1,316 @@
<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 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>
</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 { 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 treeProps = reactive({
children: 'users',
label: 'name',
value: 'uid',
isLeaf: (node) => {
return !!node.uid; // 有 uid 的为叶子节点(人员)
},
});
// 显示抽屉
function show() {
drawerVisible.value = true;
// 重置选择状态
selectedUsers.value = [];
checkedNodes.value = [];
searchKeyword.value = '';
// 延迟加载树形数据,确保 DOM 已渲染
nextTick(() => {
if (treeRef.value) {
treeRef.value.setCheckedKeys([]);
}
});
}
// 确认选择
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);
} else if (node?.level === 1) {
loadNode(resolve, node.data.directory_uid);
}
}
// 加载节点数据
const loadNode = async (resolve, id) => {
try {
if (!id) {
let res = await getDirectories({ level: 1 });
if (res.meta.code == 401) {
emit('showLogin', true);
return;
} else {
resolve(res.data);
}
} else {
let res = await getDirectoriesUsers(id, { directory_uuid: id });
resolve(res.data);
}
} catch (error) {
console.log(error);
}
}
// 处理复选框选择变化
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);
// 更新已选用户列表
selectedUsers.value = userNodes;
checkedNodes.value = allCheckedNodes;
}
// 移除已选用户
const removeSelectedUser = (user) => {
// 从已选列表中移除
const index = selectedUsers.value.findIndex(u => u.uid === user.uid);
if (index !== -1) {
selectedUsers.value.splice(index, 1);
}
// 更新树的选中状态
if (treeRef.value) {
treeRef.value.setChecked(user.uid, false, false);
}
}
// 清空所有选择
const clearAllSelected = () => {
selectedUsers.value = [];
checkedNodes.value = [];
if (treeRef.value) {
treeRef.value.setCheckedKeys([]);
}
}
// 搜索过滤
const handleSearch = () => {
if (treeRef.value) {
treeRef.value.filter(searchKeyword.value);
}
}
// 节点过滤方法
const filterNode = (value, data) => {
if (!value) return true;
return data.name && data.name.toLowerCase().includes(value.toLowerCase());
}
// 暴露方法给父组件
defineExpose({
show,
getSelectedUsers: () => selectedUsers.value
});
</script>
<style lang="scss" scoped>
.drawer-content {
padding: 0 10px;
}
.search-section {
margin-bottom: 16px;
}
.selected-section {
margin-bottom: 16px;
padding: 12px;
background-color: #f5f7fa;
border-radius: 4px;
}
.selected-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
font-size: 14px;
color: #606266;
}
.selected-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.selected-tag {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tree-item {
display: flex;
align-items: center;
height: 36px;
font-size: 14px;
.tree-item-content {
display: flex;
align-items: center;
width: 100%;
}
.user-icon, .dept-icon {
margin-right: 8px;
font-size: 16px;
}
.tree-item-text {
color: #333333;
font-weight: 500;
}
.user-count {
margin-left: 6px;
color: #999999;
font-size: 12px;
}
}
.footer-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
}
:deep(.el-tree-node__content) {
height: 40px;
}
:deep(.el-tree-node.is-current > .el-tree-node__content) {
background-color: #f0f7ff;
}
</style>

View File

@@ -121,8 +121,9 @@
<audio <audio
:ref="el => setParticipantAudioRef(el, participant.identity)" :ref="el => setParticipantAudioRef(el, participant.identity)"
autoplay autoplay
class="participant-audio"> class="participant-audio"
</audio> />
</div> </div>
</div> </div>
</div> </div>
@@ -131,54 +132,107 @@
<!-- 固定在底部的控制按钮 --> <!-- 固定在底部的控制按钮 -->
<div class="fixed-controls"> <div class="fixed-controls">
<div class="controls-container"> <div class="controls-container">
<el-button @click="toggleCamera" :type="cameraEnabled ? 'danger' : 'success'" class="control-btn" size="large">
<i :class="cameraEnabled ? 'el-icon-video-camera' : 'el-icon-video-camera-solid'"></i> <div class="microphone-control-group">
<el-button @click="toggleCamera" :type="cameraEnabled ? 'danger' : 'info'" class="control-btn microphone-btn" size="large">
{{ cameraEnabled ? '关闭摄像头' : '开启摄像头' }} {{ cameraEnabled ? '关闭摄像头' : '开启摄像头' }}
</el-button> </el-button>
<el-button @click="toggleMicrophone" :type="microphoneEnabled ? 'danger' : 'success'" class="control-btn" size="large"> <!-- 摄像头选择下拉菜单 -->
<i :class="microphoneEnabled ? 'el-icon-microphone' : 'el-icon-turn-off-microphone'"></i> <el-dropdown trigger="click" @command="handleCameraCommand" @visible-change="handleCameraVisibleChange" class="control-dropdown microphone-dropdown">
<el-button :type="cameraEnabled ? 'danger' : 'info'" class="control-btn dropdown-btn" size="large">
<el-icon class="el-icon--right"><arrow-down /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="device in cameraDevices"
:key="device.deviceId"
:command="device.deviceId"
:class="{ 'selected-device': selectedCameraId === device.deviceId }"
>
<i class="el-icon-video-camera"></i>
{{ device.label || `摄像头 ${cameraDevices.indexOf(device) + 1}` }}
<el-icon v-if="selectedCameraId === device.deviceId" class="check-icon"><check /></el-icon>
</el-dropdown-item>
<el-dropdown-item divided command="refresh">
<el-icon><refresh /></el-icon>
刷新设备列表
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<div class="microphone-control-group">
<el-button @click="toggleMicrophone" :type="microphoneEnabled ? 'danger' : 'info'" class="control-btn microphone-btn" size="large">
{{ microphoneEnabled ? '关闭麦克风' : '开启麦克风' }} {{ microphoneEnabled ? '关闭麦克风' : '开启麦克风' }}
</el-button> </el-button>
<!-- <el-button @click="toggleScreenShare" :type="isScreenSharing ? 'danger' : 'primary'" class="control-btn" size="large"> <!-- 麦克风选择下拉菜单 -->
<i class="el-icon-monitor"></i> <el-dropdown trigger="click" @command="handleMicrophoneCommand" @visible-change="handleMicrophoneVisibleChange" class="control-dropdown microphone-dropdown">
{{ isScreenSharing ? '停止共享' : '共享屏幕' }} <el-button :type="microphoneEnabled ? 'danger' : 'info'" class="control-btn dropdown-btn" size="large">
</el-button> --> <el-icon class="el-icon--right"><arrow-down /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="device in microphoneDevices"
:key="device.deviceId"
:command="device.deviceId"
:class="{ 'selected-device': selectedMicrophoneId === device.deviceId }"
>
<i class="el-icon-microphone"></i>
{{ device.label || `麦克风 ${microphoneDevices.indexOf(device) + 1}` }}
<el-icon v-if="selectedMicrophoneId === device.deviceId" class="check-icon"><check /></el-icon>
</el-dropdown-item>
<el-dropdown-item divided command="refresh">
<el-icon><refresh /></el-icon>
刷新设备列表
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<el-button <el-button
@click="toggleScreenShare" @click="toggleScreenShare"
:type="isScreenSharing ? 'danger' : (isGlobalScreenSharing ? 'info' : 'primary')" :type="isScreenSharing ? 'danger' : (isGlobalScreenSharing ? 'primary' : 'info')"
:disabled="isGlobalScreenSharing && !isScreenSharing" :disabled="isGlobalScreenSharing && !isScreenSharing"
class="control-btn" class="control-btn"
size="large" size="large"
> >
<i class="el-icon-monitor"></i>
<span v-if="isScreenSharing">停止共享</span> <span v-if="isScreenSharing">停止共享</span>
<span v-else-if="isGlobalScreenSharing">他人共享中</span> <span v-else-if="isGlobalScreenSharing">他人共享中</span>
<span v-else>共享屏幕</span> <span v-else>共享屏幕</span>
</el-button> </el-button>
<el-button @click="toggleWhiteboard" :type="isWhiteboardActive ? 'danger' : 'info'" class="control-btn leave-btn" size="large"> <el-button @click="toggleWhiteboard" :type="isWhiteboardActive ? 'danger' : 'info'" class="control-btn" size="large">
<i class="el-icon-switch-button"></i>
{{ isWhiteboardActive ? '退出白板' : '共享白板' }} {{ isWhiteboardActive ? '退出白板' : '共享白板' }}
</el-button> </el-button>
<el-button @click="leaveRoom" type="warning" class="control-btn leave-btn" size="large"> <el-button @click="inviterJoinRoom" type="info" class="control-btn" size="large">
<i class="el-icon-switch-button"></i> 邀请人员
</el-button>
<el-button @click="leaveRoom" type="info" class="control-btn" size="large">
离开会议 离开会议
</el-button> </el-button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- 邀请人员组件 -->
<InviterJoinRoom ref="inviterJoinRoomRef" @confirmSelection="handleConfirmSelection" />
</template> </template>
<script setup> <script setup>
import { reactive, ref, onMounted, onUnmounted, nextTick, computed, watch } from "vue"; import { reactive, ref, onMounted, onUnmounted, nextTick, computed, watch } from "vue";
import { ElMessage ,ElMessageBox} from 'element-plus'; import { ElMessage ,ElMessageBox} from 'element-plus';
import { getRoomToken, getRoomList ,getInvite,getTokenApi,exitRoomApi} from "@/api/conferencingRoom.js" import { ArrowDown, Refresh, Check } from '@element-plus/icons-vue';
import { getRoomToken, getRoomList ,getInvite,getTokenApi,exitRoomApi,getRoomInfoApi} from "@/api/conferencingRoom.js"
import { Room, RoomEvent, ParticipantEvent, Track } from "livekit-client"; import { Room, RoomEvent, ParticipantEvent, Track } from "livekit-client";
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useRoomStore } from '@/stores/modules/room.js' import { useRoomStore } from '@/stores/modules/room.js'
import useUserStore from '@/stores/modules/user' import { useUserStore } from '@/stores/modules/user.js'
import tabulaRase from '@/views/custom/tabulaRase/index.vue' import tabulaRase from '@/views/custom/tabulaRase/index.vue'
import { mqttClient } from "@/utils/mqtt.js"; import { mqttClient } from "@/utils/mqtt.js";
import InviterJoinRoom from "@/views/conferencingRoom/components/InviterJoinRoom/index.vue"
const userStore = useUserStore() const userStore = useUserStore()
const roomStore = useRoomStore() const roomStore = useRoomStore()
@@ -201,6 +255,12 @@ const microphoneEnabled = ref(false);
const isScreenSharing = ref(false); const isScreenSharing = ref(false);
const isLocalSpeaking = ref(false); const isLocalSpeaking = ref(false);
// 设备列表
const cameraDevices = ref([]);
const microphoneDevices = ref([]);
const selectedCameraId = ref('');
const selectedMicrophoneId = ref('');
// 远程参与者管理 // 远程参与者管理
const remoteParticipants = ref(new Map()); const remoteParticipants = ref(new Map());
const videoElementsMap = ref(new Map()); const videoElementsMap = ref(new Map());
@@ -210,13 +270,19 @@ const audioElementsMap = ref(new Map());
const screenSharingUser = ref(''); const screenSharingUser = ref('');
const activeScreenShareTrack = ref(null); const activeScreenShareTrack = ref(null);
const screenShareVideo = ref(null); const screenShareVideo = ref(null);
const globalScreenSharingUser = ref(''); // 当前正在共享屏幕的用户
const isGlobalScreenSharing = ref(false); // 是否有用户正在共享屏幕
//共享白板 //共享白板
const isWhiteboardActive = ref(false); const isWhiteboardActive = ref(false);
const whiteboardRef = ref(null); const whiteboardRef = ref(null);
const isGlobalWhiteboardSharing = ref(false); // 是否有用户正在共享白板
const globalScreenSharingUser = ref(''); // 当前正在共享屏幕的用户 //当前房间信息
const isGlobalScreenSharing = ref(false); // 是否有用户正在共享屏幕 const roomInfo = ref('')
//邀请参会人员
const inviterJoinRoomRef = ref()
// 白板消息类型 // 白板消息类型
const WHITEBOARD_MESSAGE_TYPES = { const WHITEBOARD_MESSAGE_TYPES = {
@@ -261,7 +327,7 @@ const room = new Room({
autoGainControl: true, autoGainControl: true,
}, },
videoCaptureDefaults: { videoCaptureDefaults: {
resolution: { width: 1280, height: 720 } resolution: { width: 1280, height: 720 },
}, },
publishDefaults: { publishDefaults: {
screenShareEncoding: { screenShareEncoding: {
@@ -281,6 +347,208 @@ const room = new Room({
} }
}); });
//摄像头打开下拉框触发
async function handleCameraVisibleChange(e){
try {
if(e){
// 请求摄像头权限
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
// 获取设备列表
const devices = await navigator.mediaDevices.enumerateDevices();
// 过滤摄像头设备
cameraDevices.value = devices.filter(device => device.kind === 'videoinput');
// 重要:立即停止临时媒体流,避免占用摄像头
stream.getTracks().forEach(track => {
track.stop();
console.log('临时摄像头轨道已停止');
});
}
} catch (error) {
console.error('摄像头访问失败:', error);
// 使用更友好的方式提示用户
errorHandling(error,'摄像头');
// 清空设备列表
cameraDevices.value = [];
}
}
// 处理摄像头设备选择
async function handleCameraCommand(deviceId) {
if (deviceId === 'refresh') {
await handleCameraVisibleChange(true);
ElMessage.success('设备列表已刷新');
return;
}
selectedCameraId.value = deviceId;
// 如果摄像头已经开启,重新开启以应用新设备
if (cameraEnabled.value) {
await switchCameraDevice(deviceId);
} else {
// 如果摄像头未开启,直接开启选中的设备
await enableCameraWithDevice(deviceId);
}
ElMessage.success(`已切换到摄像头: ${getDeviceName(cameraDevices.value, deviceId)}`);
}
//麦克风打开下拉框触发
async function handleMicrophoneVisibleChange(e){
try {
if(e){
// 请求麦克风权限
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
// 获取设备列表
const devices = await navigator.mediaDevices.enumerateDevices();
// 过滤麦克风设备
microphoneDevices.value = devices.filter(device => device.kind === 'audioinput');
// 停止所有轨道来关闭临时的麦克风访问
stream.getTracks().forEach(track => track.stop());
}
} catch (error) {
console.error('麦克风访问失败:', error);
// 使用更友好的方式提示用户
errorHandling(error,'麦克风');
// 清空设备列表
microphoneDevices.value = [];
}
}
// 处理麦克风设备选择
async function handleMicrophoneCommand(deviceId) {
if (deviceId === 'refresh') {
await handleMicrophoneVisibleChange();
ElMessage.success('设备列表已刷新');
return;
}
selectedMicrophoneId.value = deviceId;
// 如果麦克风已经开启,重新开启以应用新设备
if (microphoneEnabled.value) {
await switchMicrophoneDevice(deviceId);
} else {
// 如果麦克风未开启,直接开启选中的设备
await enableMicrophoneWithDevice(deviceId);
}
ElMessage.success(`已切换到麦克风: ${getDeviceName(microphoneDevices.value, deviceId)}`);
}
// 使用指定设备开启摄像头
async function enableCameraWithDevice(deviceId) {
try {
// 更新设备配置
room.options.videoCaptureDefaults.deviceId = deviceId;
// 开启摄像头
await room.localParticipant.setCameraEnabled(true);
cameraEnabled.value = true;
// 手动获取并附加视频轨道
setTimeout(() => {
attachLocalCameraTrack();
}, 200);
return true;
} catch (error) {
ElMessage.error(`使用指定设备开启摄像头失败`);
try {
if (cameraEnabled.value) {
await room.localParticipant.setCameraEnabled(true);
}
} catch (e) {
cameraEnabled.value = false;
selectedCameraId.value = '';
}
throw error;
}
}
// 使用指定设备开启麦克风
async function enableMicrophoneWithDevice(deviceId) {
try {
// 更新设备配置
room.options.audioCaptureDefaults.deviceId = deviceId;
// 开启麦克风
await room.localParticipant.setMicrophoneEnabled(true);
microphoneEnabled.value = true;
return true;
} catch (error) {
console.error('使用指定设备开启麦克风失败:', error);
microphoneEnabled.value = false;
throw error;
}
}
// 切换摄像头设备
async function switchCameraDevice(deviceId) {
try {
// 先关闭当前摄像头
await room.localParticipant.setCameraEnabled(false);
// 更新设备配置
room.options.videoCaptureDefaults.deviceId = deviceId;
// 重新开启摄像头
await room.localParticipant.setCameraEnabled(true);
// 手动获取并附加视频轨道
setTimeout(() => {
attachLocalCameraTrack();
}, 200);
return true;
} catch (error) {
console.error('切换摄像头设备失败:', error);
// 如果切换失败,尝试重新开启之前的设备
try {
await room.localParticipant.setCameraEnabled(true);
} catch (e) {
cameraEnabled.value = false;
}
throw error;
}
}
// 切换麦克风设备
async function switchMicrophoneDevice(deviceId) {
try {
// 先关闭当前麦克风
await room.localParticipant.setMicrophoneEnabled(false);
// 更新设备配置
room.options.audioCaptureDefaults.deviceId = deviceId;
// 重新开启麦克风
await room.localParticipant.setMicrophoneEnabled(true);
return true;
} catch (error) {
console.error('切换麦克风设备失败:', error);
// 如果切换失败,尝试重新开启之前的设备
try {
await room.localParticipant.setMicrophoneEnabled(true);
} catch (e) {
microphoneEnabled.value = false;
}
throw error;
}
}
// 获取设备名称
function getDeviceName(devices, deviceId) {
const device = devices.find(d => d.deviceId === deviceId);
return device ? (device.label || '未知设备') : '未知设备';
}
// 初始化MQTT连接 // 初始化MQTT连接
async function initMqtt() { async function initMqtt() {
try { try {
@@ -310,7 +578,6 @@ function handleWhiteboardMessage(payload, topic) {
try { try {
const messageStr = payload.toString(); const messageStr = payload.toString();
const data = JSON.parse(messageStr); const data = JSON.parse(messageStr);
console.log('收到白板消息:', data);
// 只处理当前房间的消息 // 只处理当前房间的消息
if (data.roomId !== room.name) return; if (data.roomId !== room.name) return;
// 忽略自己发送的消息 // 忽略自己发送的消息
@@ -350,9 +617,10 @@ function handleRemoteWhiteboardOpen(data) {
// 处理远程关闭白板 // 处理远程关闭白板
function handleRemoteWhiteboardClose(data) { function handleRemoteWhiteboardClose(data) {
console.log('关闭白板')
ElMessage.info(`${data.senderName || data.sender} 关闭了白板`); ElMessage.info(`${data.senderName || data.sender} 关闭了白板`);
console.log('data',data) console.log('data',data)
if(data.type == '1'){ if(data.roomType == '1'){
isWhiteboardActive.value = false; isWhiteboardActive.value = false;
} }
} }
@@ -368,6 +636,22 @@ function handleWhiteboardSync(data) {
} }
} }
//邀请进入房间
async function inviterJoinRoom(){
inviterJoinRoomRef.value.show()
}
// 确认选择房间
async function handleConfirmSelection(userInfo){
console.log(userInfo,'加入房间人员信息')
if(userInfo.length < 0){
ElMessage.error('请选择加入房间的人员')
return
}
const joinUserIds = userInfo.map(item => item.uid)
await getInvite(room.name,{user_uids:joinUserIds, participant_role: "participant"})
}
function publishWhiteboardMessage(type, payload = {}) { function publishWhiteboardMessage(type, payload = {}) {
try { try {
const message = { const message = {
@@ -390,13 +674,6 @@ function publishWhiteboardMessage(type, payload = {}) {
} }
} }
//共享白板
// async function whiteboard(){
// const baseUrl = 'http://localhost:3000/#/whiteboard'
// const url = `${baseUrl}?room_uid=${room.name}`
// window.open(url, '_blank')
// }
async function toggleWhiteboard() { async function toggleWhiteboard() {
if(hasActiveScreenShare.value){ if(hasActiveScreenShare.value){
ElMessage.error('请先关闭屏幕共享'); ElMessage.error('请先关闭屏幕共享');
@@ -530,7 +807,6 @@ function setScreenShareVideoRef(el) {
// 设置事件监听器 // 设置事件监听器
function setupRoomListeners() { function setupRoomListeners() {
room.removeAllListeners(); room.removeAllListeners();
room room
.on(RoomEvent.Connected, handleConnected) .on(RoomEvent.Connected, handleConnected)
.on(RoomEvent.Disconnected, handleDisconnected) .on(RoomEvent.Disconnected, handleDisconnected)
@@ -551,7 +827,9 @@ function setupRoomListeners() {
// 事件处理函数 // 事件处理函数
async function handleConnected() { async function handleConnected() {
await initMqtt(); await initMqtt();
roomId.value = room.name
status.value = false; status.value = false;
ElMessage.success('已成功连接到房间'); ElMessage.success('已成功连接到房间');
// 初始化现有远程参与者 // 初始化现有远程参与者
room.remoteParticipants.forEach(participant => { room.remoteParticipants.forEach(participant => {
@@ -560,14 +838,6 @@ async function handleConnected() {
// 立即检查并更新参与者的轨道状态 // 立即检查并更新参与者的轨道状态
updateParticipantTracks(participant); updateParticipantTracks(participant);
}); });
// 自动开启麦克风
// try {
// await enableMicrophone();
// ElMessage.success('麦克风已自动开启');
// } catch (error) {
// console.warn('自动开启麦克风失败:', error);
// }
} }
function handleDisconnected(reason) { function handleDisconnected(reason) {
@@ -618,7 +888,6 @@ function handleTrackSubscribed(track, publication, participant) {
updateParticipantTracks(participant); updateParticipantTracks(participant);
} }
function handleTrackUnsubscribed(track, publication, participant) { function handleTrackUnsubscribed(track, publication, participant) {
// 移除对应的轨道信息 // 移除对应的轨道信息
if (track.kind === Track.Kind.Video) { if (track.kind === Track.Kind.Video) {
@@ -766,6 +1035,7 @@ function handleActiveSpeakersChanged(speakers) {
} }
}); });
} }
// 处理数据接收事件 // 处理数据接收事件
function handleDataReceived(payload, participant, kind) { function handleDataReceived(payload, participant, kind) {
try { try {
@@ -780,6 +1050,7 @@ function handleDataReceived(payload, participant, kind) {
function handleConnectionStateChanged(state) { function handleConnectionStateChanged(state) {
console.log('连接状态改变:', state); console.log('连接状态改变:', state);
} }
// 更新屏幕共享状态 // 更新屏幕共享状态
function updateScreenShareState(participant, track) { function updateScreenShareState(participant, track) {
if (track) { if (track) {
@@ -802,6 +1073,7 @@ function updateScreenShareState(participant, track) {
} }
} }
} }
// 参与者管理函数 // 参与者管理函数
function addRemoteParticipant(participant) { function addRemoteParticipant(participant) {
if (!participant || participant.identity === room.localParticipant?.identity) { if (!participant || participant.identity === room.localParticipant?.identity) {
@@ -834,6 +1106,7 @@ function removeRemoteParticipant(participant) {
audioElementsMap.value.delete(identity); audioElementsMap.value.delete(identity);
} }
} }
// 更新参与者轨道信息 // 更新参与者轨道信息
function updateParticipantTrack(participant, source, track) { function updateParticipantTrack(participant, source, track) {
const data = remoteParticipants.value.get(participant.identity); const data = remoteParticipants.value.get(participant.identity);
@@ -877,6 +1150,7 @@ function removeParticipantAudioTrack(participant) {
data.audioEnabled = false; data.audioEnabled = false;
remoteParticipants.value.set(participant.identity, { ...data }); remoteParticipants.value.set(participant.identity, { ...data });
} }
// 附加轨道到视频元素 // 附加轨道到视频元素
function attachTrackToVideo(videoElement, track) { function attachTrackToVideo(videoElement, track) {
if (!videoElement || !track) return; if (!videoElement || !track) return;
@@ -889,6 +1163,7 @@ function attachTrackToVideo(videoElement, track) {
console.error('附加轨道到视频元素失败:', error); console.error('附加轨道到视频元素失败:', error);
} }
} }
// 附加轨道到音频元素 // 附加轨道到音频元素
function attachTrackToAudio(audioElement, track) { function attachTrackToAudio(audioElement, track) {
if (!audioElement || !track) return; if (!audioElement || !track) return;
@@ -904,6 +1179,7 @@ function attachTrackToAudio(audioElement, track) {
console.error('附加轨道到音频元素失败:', error); console.error('附加轨道到音频元素失败:', error);
} }
} }
// 附加轨道到参与者的视频元素 // 附加轨道到参与者的视频元素
function attachTrackToParticipantVideo(identity, source, track) { function attachTrackToParticipantVideo(identity, source, track) {
const videoElements = videoElementsMap.value.get(identity); const videoElements = videoElementsMap.value.get(identity);
@@ -914,6 +1190,7 @@ function attachTrackToParticipantVideo(identity, source, track) {
attachTrackToVideo(videoElement, track); attachTrackToVideo(videoElement, track);
} }
} }
// 附加轨道到参与者的音频元素 // 附加轨道到参与者的音频元素
function attachTrackToParticipantAudio(identity, track) { function attachTrackToParticipantAudio(identity, track) {
const audioElement = audioElementsMap.value.get(identity); const audioElement = audioElementsMap.value.get(identity);
@@ -921,6 +1198,7 @@ function attachTrackToParticipantAudio(identity, track) {
attachTrackToAudio(audioElement, track); attachTrackToAudio(audioElement, track);
} }
} }
// 从参与者的视频元素分离轨道 // 从参与者的视频元素分离轨道
function detachTrackFromParticipantVideo(identity, source) { function detachTrackFromParticipantVideo(identity, source) {
const videoElements = videoElementsMap.value.get(identity); const videoElements = videoElementsMap.value.get(identity);
@@ -931,6 +1209,7 @@ function detachTrackFromParticipantVideo(identity, source) {
videoElement.srcObject = null; videoElement.srcObject = null;
} }
} }
// 从参与者的音频元素分离轨道 // 从参与者的音频元素分离轨道
function detachTrackFromParticipantAudio(identity) { function detachTrackFromParticipantAudio(identity) {
const audioElement = audioElementsMap.value.get(identity); const audioElement = audioElementsMap.value.get(identity);
@@ -997,6 +1276,7 @@ function handleVideoLoaded(identity, type) {
function handleScreenShareLoaded() { function handleScreenShareLoaded() {
console.log('屏幕共享视频加载完成'); console.log('屏幕共享视频加载完成');
} }
// 视频轨道处理函数 // 视频轨道处理函数
function attachLocalVideoTrack(track) { function attachLocalVideoTrack(track) {
if (localVideo.value && track) { if (localVideo.value && track) {
@@ -1010,6 +1290,7 @@ function attachLocalVideoTrack(track) {
} }
} }
} }
// 媒体控制函数 // 媒体控制函数
async function enableCamera() { async function enableCamera() {
try { try {
@@ -1046,6 +1327,8 @@ async function toggleCamera() {
localVideo.value.srcObject.getTracks().forEach(track => track.stop()); localVideo.value.srcObject.getTracks().forEach(track => track.stop());
localVideo.value.srcObject = null; localVideo.value.srcObject = null;
} }
// 清空选中的视屏设备ID
selectedCameraId.value = '';
ElMessage.info('摄像头已关闭'); ElMessage.info('摄像头已关闭');
} else { } else {
// 确保视频元素存在 // 确保视频元素存在
@@ -1053,14 +1336,41 @@ async function toggleCamera() {
console.warn('本地视频元素未找到等待DOM更新'); console.warn('本地视频元素未找到等待DOM更新');
await nextTick(); await nextTick();
} }
// 开启摄像头 // 开启摄像头
await room.localParticipant.setCameraEnabled(true); // 确保有视屏输入设备权限和设备列表
cameraEnabled.value = true; if (cameraDevices.value.length === 0) {
ElMessage.success('摄像头已开启'); // 如果没有设备列表,先获取
// 手动获取并附加视频轨道,增加延迟 await handleCameraVisibleChange(true);
}
if (cameraDevices.value.length === 0) {
ElMessage.error('未找到可用的摄像头设备');
return;
}
// 自动选择第一个可用设备(如果当前没有选中设备)
let deviceToUse = selectedCameraId.value;
if (!deviceToUse && cameraDevices.value.length > 0) {
deviceToUse = cameraDevices.value[0].deviceId;
selectedCameraId.value = deviceToUse;
}
if (deviceToUse) {
await enableCameraWithDevice(deviceToUse);
ElMessage.success(`摄像头已开启 - ${getDeviceName(cameraDevices.value, deviceToUse)}`);
} else {
// 使用默认方式开启
await room.localParticipant.setMicrophoneEnabled(true);
microphoneEnabled.value = true;
// 手动获取并附加视频轨道
setTimeout(() => { setTimeout(() => {
attachLocalCameraTrack(); attachLocalCameraTrack();
}, 200); }, 200);
ElMessage.success('麦克风已开启');
}
ElMessage.success('摄像头已开启');
} }
} catch (error) { } catch (error) {
errorHandling(error,'摄像头'); errorHandling(error,'摄像头');
@@ -1109,53 +1419,73 @@ async function toggleMicrophone() {
publication.track.stop(); // 停止轨道 publication.track.stop(); // 停止轨道
} }
} }
// 清空选中的麦克风设备ID
selectedMicrophoneId.value = '';
ElMessage.info('麦克风已关闭'); ElMessage.info('麦克风已关闭');
} else { } else {
// 确保有音频输入设备权限 // 开启麦克风
try { // 确保有音频输入设备权限和设备列表
// 先检查麦克风权限 if (microphoneDevices.value.length === 0) {
await navigator.mediaDevices.getUserMedia({ audio: true }); // 如果没有设备列表,先获取
} catch (error) { await handleMicrophoneVisibleChange(true);
ElMessage.error('无法访问麦克风,请检查权限设置'); }
if (microphoneDevices.value.length === 0) {
ElMessage.error('未找到可用的麦克风设备');
return; return;
} }
// 自动选择第一个可用设备(如果当前没有选中设备)
let deviceToUse = selectedMicrophoneId.value;
if (!deviceToUse && microphoneDevices.value.length > 0) {
deviceToUse = microphoneDevices.value[0].deviceId;
selectedMicrophoneId.value = deviceToUse;
}
if (deviceToUse) {
await enableMicrophoneWithDevice(deviceToUse);
ElMessage.success(`麦克风已开启 - ${getDeviceName(microphoneDevices.value, deviceToUse)}`);
} else {
// 使用默认方式开启
await room.localParticipant.setMicrophoneEnabled(true); await room.localParticipant.setMicrophoneEnabled(true);
microphoneEnabled.value = true; microphoneEnabled.value = true;
ElMessage.success('麦克风已开启'); ElMessage.success('麦克风已开启');
// 等待音频轨道发布
setTimeout(() => {
const audioPublications = Array.from(room.localParticipant.audioTrackPublications.values());
const audioPublication = audioPublications.find(pub => pub.track);
if (audioPublication && audioPublication.track) {
console.log('本地音频轨道已发布:', audioPublication.track);
} }
}, 500);
ElMessage.success('麦克风已开启');
} }
} catch (error) { } catch (error) {
errorHandling(error, '麦克风'); errorHandling(error, '麦克风');
// 如果开启失败,确保状态正确
if (!microphoneEnabled.value) {
selectedMicrophoneId.value = '';
}
} }
} }
function errorHandling(error,type) { function errorHandling(error,type) {
switch (error.name) { switch (error.name) {
case 'NotAllowedError': case 'NotAllowedError':
ElMessage.error('用户拒绝了权限请求'); ElMessage.error('用户拒绝了权限请求,请允许此网站使用摄像头');
break; break;
case 'NotFoundError': case 'NotFoundError':
ElMessage.error(`找到${type}设备`); ElMessage.error(`检测到可用的${type}设备,请检查${type}是否已正确连接`);
break; break;
case 'NotSupportedError': case 'NotSupportedError':
ElMessage.error('当前浏览器不支持此功能,请更换其他浏览器'); ElMessage.error(`当前浏览器不支持${type}功能请使用现代浏览器如Chrome、Firefox或Edge`);
break;
case 'NotReadableError':
ElMessage.error(`${type}设备正被其他应用程序占用,请关闭其他使用${type}的应用后重试`);
break;
case 'OverconstrainedError':
ElMessage.error(`${type}配置不兼容,请尝试调整${type}设置`);
break; break;
default: default:
ElMessage.error('服务错误,请刷新重试'); ElMessage.error('服务错误,请刷新重试');
} }
} }
async function toggleScreenShare() { async function toggleScreenShare() {
try { try {
if(isWhiteboardActive.value){ if(isWhiteboardActive.value){
@@ -1241,7 +1571,7 @@ async function leaveRoom() {
// 断开MQTT连接 // 断开MQTT连接
mqttClient.disconnect(); mqttClient.disconnect();
const res = await exitRoomApi(room.name) // const res = await exitRoomApi(room.name)
// 停止屏幕共享(如果正在共享) // 停止屏幕共享(如果正在共享)
if (isScreenSharing.value) { if (isScreenSharing.value) {
await room.localParticipant.setScreenShareEnabled(false); await room.localParticipant.setScreenShareEnabled(false);
@@ -1250,7 +1580,10 @@ async function leaveRoom() {
// 关闭摄像头和麦克风 // 关闭摄像头和麦克风
await room.localParticipant.setCameraEnabled(false); await room.localParticipant.setCameraEnabled(false);
await room.localParticipant.setMicrophoneEnabled(false); await room.localParticipant.setMicrophoneEnabled(false);
microphoneEnabled.value = false;
cameraEnabled.value = false;
selectedMicrophoneId.value = '';
selectedCameraId.value = ''
// 断开与房间的连接 // 断开与房间的连接
await room.disconnect(); await room.disconnect();
@@ -1280,6 +1613,10 @@ function resetRoomState() {
isLocalSpeaking.value = false; isLocalSpeaking.value = false;
status.value = true; status.value = true;
hostUid.value = ''; hostUid.value = '';
selectedMicrophoneId.value = '';
selectedCameraId.value = ''
microphoneDevices.value = [];
cameraDevices.value = [];
// 断开MQTT连接 // 断开MQTT连接
mqttClient.disconnect(); mqttClient.disconnect();
@@ -1343,24 +1680,12 @@ onUnmounted(() => {
}); });
onMounted(async () => { onMounted(async () => {
// 确保在连接前请求音频权限
// try {
// // 预请求音频权限
// await navigator.mediaDevices.getUserMedia({ audio: true });
// console.log('音频权限已获取');
// } catch (error) {
// console.warn('音频权限获取失败:', error);
// ElMessage.warning('请允许麦克风权限以使用音频功能');
// }
if(route.query.type == '1'){ if(route.query.type == '1'){
await joinRoomBtn() await joinRoomBtn()
hostUid.value = roomStore.userUid hostUid.value = roomStore.userUid
// 邀请用户参与房间 // 邀请用户参与房间
await getInvite(room.name,{user_uids:[roomStore.detailUid], participant_role: "participant"}) await getInvite(room.name,{user_uids:[roomStore.detailUid], participant_role: "participant"})
} else { } else {
// const userInfo = await userStore.getInfo()
// hostUid.value = userInfo.uid
const res = await getTokenApi(route.query.room_uid) const res = await getTokenApi(route.query.room_uid)
if(res.meta.code == 200){ if(res.meta.code == 200){
const token = res.data.access_token; const token = res.data.access_token;
@@ -1376,9 +1701,10 @@ onMounted(async () => {
} }
} }
}); });
</script> </script>
<style> <style lang="scss" scoped>
/* 白板容器样式 */ /* 白板容器样式 */
.whiteboard-container { .whiteboard-container {
width: 100%; width: 100%;
@@ -1429,6 +1755,37 @@ onMounted(async () => {
.audio-element, .participant-audio { .audio-element, .participant-audio {
display: none; display: none;
} }
/* 控制下拉菜单样式 */
.control-dropdown {
margin: 0;
}
.control-dropdown .el-button {
display: flex;
align-items: center;
gap: 4px;
}
/* 选中的设备样式 */
.selected-device {
background-color: #f0f9ff;
color: #409eff;
font-weight: 500;
}
.check-icon {
margin-left: auto;
color: #67c23a;
}
/* 设备列表项样式 */
.el-dropdown-menu__item {
display: flex;
align-items: center;
gap: 8px;
}
/* 全局样式 */ /* 全局样式 */
* { * {
box-sizing: border-box; box-sizing: border-box;
@@ -1857,8 +2214,86 @@ body {
background: linear-gradient(135deg, #e64a4a 0%, #d63030 100%); background: linear-gradient(135deg, #e64a4a 0%, #d63030 100%);
} }
/* 开启摄像头和麦克风下拉框 */
.control-container {
display: flex;
align-items: center;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
.dropdown-btn {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.selected-device {
background-color: #f0f7ff;
color: #409eff;
}
.check-icon {
margin-left: 8px;
color: #67c23a;
}
.microphone-control-group {
display: flex;
align-items: center;
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.microphone-control-group .microphone-btn {
border-radius: 12px 0 0 12px;
border: none;
box-shadow: none;
margin: 0;
}
.microphone-control-group .microphone-dropdown {
margin: 0;
}
.microphone-control-group .dropdown-btn {
border-radius: 0 12px 12px 0;
border: none;
border-left: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: none;
width: 44px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
/* 悬停效果 */
.microphone-control-group:hover {
border-color: rgba(255, 255, 255, 0.2);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.3);
}
.microphone-control-group .control-btn:hover {
transform: none; /* 取消单独的悬停位移 */
}
/* 响应式调整 */ /* 响应式调整 */
@media (max-width: 768px) { @media (max-width: 768px) {
.microphone-control-group {
flex: 1;
min-width: 160px;
}
.microphone-control-group .microphone-btn {
flex: 1;
}
.microphone-control-group .dropdown-btn {
width: 40px;
height: 40px;
}
.whiteboard-container { .whiteboard-container {
height: 300px; /* 在移动端限制白板高度 */ height: 300px; /* 在移动端限制白板高度 */
} }
@@ -1919,6 +2354,19 @@ body {
/* 超小屏幕调整 */ /* 超小屏幕调整 */
@media (max-width: 480px) { @media (max-width: 480px) {
.microphone-control-group {
min-width: 140px;
}
.microphone-control-group .microphone-btn {
font-size: 11px;
padding: 8px 10px;
}
.microphone-control-group .dropdown-btn {
width: 36px;
height: 36px;
}
.controls-container { .controls-container {
gap: 6px; gap: 6px;
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
<template> <template>
<div> <div>
<!-- v-loading="leftListLoading || loading" -->
<div class="left-list" > <div class="left-list" v-loading="leftListLoading || loading">
<div class="list-tab"> <div class="list-tab">
<div <div
:class="'list-tab-item ' + (leftTab == 1 ? 'pitch-on' : '')" :class="'list-tab-item ' + (leftTab == 1 ? 'pitch-on' : '')"
@@ -314,7 +314,7 @@ const HandleLoadNode = async (node, resolve) => {
const loadNode = async(resolve,id)=>{ const loadNode = async(resolve,id)=>{
try { try {
// state.leftListLoading = true state.leftListLoading = true
if(!id){ if(!id){
let res = await getDirectories({level:1}) let res = await getDirectories({level:1})
if(res.meta.code == 401){ if(res.meta.code == 401){
@@ -328,10 +328,10 @@ const loadNode = async(resolve,id)=>{
let res = await getDirectoriesUsers(id,{directory_uuid:id}) let res = await getDirectoriesUsers(id,{directory_uuid:id})
resolve(res.data) resolve(res.data)
} }
// state.leftListLoading = false state.leftListLoading = false
} catch (error) { } catch (error) {
console.log(error) console.log(error)
// state.leftListLoading = false state.leftListLoading = false
} }
} }

View File

@@ -158,6 +158,7 @@
class="message-user" class="message-user"
v-else-if="isShow && tabValue == 2" v-else-if="isShow && tabValue == 2"
style="height: calc(100vh - 90px)" style="height: calc(100vh - 90px)"
v-loading="userLoading"
> >
<div class="message-user-card"> <div class="message-user-card">
<div class="user-card-nickName"> <div class="user-card-nickName">
@@ -274,7 +275,7 @@ import { getStatusApi } from '@/api/conferencingRoom.js'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useRoomStore } from '@/stores/modules/room' import { useRoomStore } from '@/stores/modules/room'
import useUserStore from '@/stores/modules/user' import { useUserStore } from '@/stores/modules/user.js'
import { mqttClient } from "@/utils/mqtt.js"; import { mqttClient } from "@/utils/mqtt.js";
import { getToken } from '@/utils/auth.js' import { getToken } from '@/utils/auth.js'
import Login from "@/components/Login/index.vue"; import Login from "@/components/Login/index.vue";
@@ -295,6 +296,8 @@ const state = reactive({
cooperation: import.meta.env.VITE_APP_COOPERATION_TYPE, cooperation: import.meta.env.VITE_APP_COOPERATION_TYPE,
}) })
const showLogin = ref(false); // 是否显示登录页面 const showLogin = ref(false); // 是否显示登录页面
const userLoading = ref(false); // 用户信息加载状态
function showLoginHandle(e){ function showLoginHandle(e){
showLogin.value = e; showLogin.value = e;
@@ -350,23 +353,26 @@ const updateTab = (newValue) => {
/** 修改展示区内容 */ /** 修改展示区内容 */
const updateDetail = async (details) => { const updateDetail = async (details) => {
userLoading.value = true
if (details) { if (details) {
console.log(details,'details')
state.detail = {} state.detail = {}
if (state.tabValue == 1) { if (state.tabValue == 1) {
state.isShow = true state.isShow = true
} else { } else {
const res = await getInfo(details.uid) const res = await getInfo(details.uid)
state.detail = res.data state.detail = res.data
userLoading.value = false
// getInfo(details.uid) // getInfo(details.uid)
// .then((res) => { // .then((res) => {
// console.log(res,'人员详细信息') // console.log(res,'人员详细信息')
// }) // })
// .finally(() => { state.isShow = true }) // .finally(() => { state.isShow = true })
} }
} else { } else {
state.isShow = false state.isShow = false
} }
userLoading.value = false
} }
/** 获取通话时长 */ /** 获取通话时长 */
@@ -420,12 +426,18 @@ const clickRefuseJoin = async () => {
} }
} }
/** 处理 mqtt 消息 */ /** 处理加入房间和拒接房间 mqtt 消息 */
const processingSocket = (message) => { const processingSocket = (message) => {
if (message) { const res = JSON.parse(message)
state.socketInformation = JSON.parse(message) if (!res?.status) {
state.socketInformation = res
state.inviteDialog = true state.inviteDialog = true
showNotification(state.socketInformation) showNotification(state.socketInformation)
}else if(res.status == 5){
ElMessage({
message: `${res?.display_name}拒绝加入该协作`,
type: 'error',
})
} }
} }
@@ -458,6 +470,7 @@ onMounted(async () => {
const res = await userStore.getInfo() const res = await userStore.getInfo()
const topic = `xSynergy/ROOM/+/rooms/${res.uid}`; const topic = `xSynergy/ROOM/+/rooms/${res.uid}`;
mqttClient.subscribe(topic, async (shapeData) => { mqttClient.subscribe(topic, async (shapeData) => {
console.log(shapeData.toString(),'shapeData发送邀请')
processingSocket(shapeData.toString()) processingSocket(shapeData.toString())
}); });
}) })

View File

@@ -8,8 +8,8 @@
<div v-else> <div v-else>
<!-- 未加入时显示按钮 --> <!-- 未加入时显示按钮 -->
<div v-if="!hasJoined" class="login-button-container"> <div v-if="!hasJoined" class="login-button-container">
<el-button type="primary" size="large" round plain @click="joinWhiteboard"> <el-button type="primary" size="large" link @click="joinWhiteboard">
入互动画板 正在进入互动画板
</el-button> </el-button>
</div> </div>
@@ -113,6 +113,7 @@ onMounted(async () => {
} catch (err) { } catch (err) {
console.warn("⚠️ 用户信息校验失败:", err); console.warn("⚠️ 用户信息校验失败:", err);
} }
joinWhiteboard()
}); });
onUnmounted(() => { onUnmounted(() => {
@@ -144,6 +145,7 @@ onUnmounted(() => {
.whiteboard-wrapper { .whiteboard-wrapper {
position: relative; position: relative;
width: 72vw; width: 72vw;
height: 69vh; /* 或者适当的高度 */
box-sizing: border-box; box-sizing: border-box;
display: flex; display: flex;
justify-content: center; justify-content: center;
@@ -162,7 +164,7 @@ onUnmounted(() => {
.toolbox { .toolbox {
position: absolute; position: absolute;
top: 50%; top: 50%;
left: 10px; left: 2vw;
transform: translateY(-50%); transform: translateY(-50%);
z-index: 1000; z-index: 1000;
} }

View File

@@ -37,7 +37,7 @@
</template> </template>
<script setup> <script setup>
import useUserStore from '@/stores/modules/user' import { useUserStore } from '@/stores/modules/user.js'
import { watch, ref, getCurrentInstance, onMounted } from 'vue' import { watch, ref, getCurrentInstance, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElNotification,ElMessage } from 'element-plus' import { ElNotification,ElMessage } from 'element-plus'
@@ -92,7 +92,6 @@ function handleLogin() {
const message = `Gx${randomChars}${loginForm.value.password}`; const message = `Gx${randomChars}${loginForm.value.password}`;
const ciphertext = CryptoJS.Blowfish.encrypt(message, secretKey).toString(); const ciphertext = CryptoJS.Blowfish.encrypt(message, secretKey).toString();
console.log(secretKey,message,ciphertext)
// 调用action的登录方法 // 调用action的登录方法
userStore userStore
.login({ .login({

View File

@@ -1,18 +1,15 @@
import { fileURLToPath, URL } from 'node:url' import { fileURLToPath, URL } from 'node:url'
import path from "path"; import path from "path";
import { defineConfig } from 'vite' import { defineConfig ,loadEnv} from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import { codeInspectorPlugin } from 'code-inspector-plugin' import createVitePlugins from './vite/plugins/index.js'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig(({ mode, command }) => {
plugins: [ const env = loadEnv(mode, process.cwd());
vue(), const { VITE_BASE_PATH } = env;
codeInspectorPlugin({ return {
bundler: 'vite', // 使用 vite plugins: createVitePlugins(env, command === "build"),
showSwitch: true,
}),
],
server: { server: {
host: '0.0.0.0', // 关键配置,允许局域网访问 host: '0.0.0.0', // 关键配置,允许局域网访问
port: 3000, port: 3000,
@@ -43,4 +40,5 @@ export default defineConfig({
css: { css: {
postcss: './postcss.config.js' // 纯外部配置 postcss: './postcss.config.js' // 纯外部配置
}, },
}
}) })

View File

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

View File

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

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

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

View File

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

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

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