feat:更新侧边导航,修改密码等
This commit is contained in:
11
src/App.vue
11
src/App.vue
@@ -1,6 +1,15 @@
|
||||
<script setup>
|
||||
import { RouterLink, RouterView } from 'vue-router'
|
||||
|
||||
import { onMounted } from 'vue'
|
||||
import { useUserStore } from '@/stores/modules/user.js'
|
||||
// 在根组件预先初始化用户 store
|
||||
onMounted(() => {
|
||||
try {
|
||||
const userStore = useUserStore()
|
||||
} catch (error) {
|
||||
console.warn('App.vue: Pinia 初始化中...', error)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -64,3 +64,11 @@ export function exitRoomApi(room_uid) {
|
||||
})
|
||||
}
|
||||
|
||||
//获取当前房间信息
|
||||
export function getRoomInfoApi(room_uid) {
|
||||
return request({
|
||||
url: `/api/v1/meeting/${room_uid}/info`,
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -34,3 +34,28 @@ export function logout() {
|
||||
method: 'post'
|
||||
})
|
||||
}
|
||||
|
||||
//修改密码
|
||||
export function changePwd(oldPassword, newPassword) {
|
||||
const params = {
|
||||
old_password: oldPassword,
|
||||
new_password: newPassword
|
||||
}
|
||||
return request({
|
||||
url: '/api/v1/auth/change-password',
|
||||
method: 'post',
|
||||
data: params
|
||||
})
|
||||
}
|
||||
|
||||
//验证密码强度
|
||||
export function checkPwdStrength(password) {
|
||||
const params = {
|
||||
password: password
|
||||
}
|
||||
return request({
|
||||
url: '/api/v1/auth/check-password-strength',
|
||||
method: 'post',
|
||||
data: params
|
||||
})
|
||||
}
|
||||
9
src/api/menu.js
Normal file
9
src/api/menu.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 获取路由
|
||||
export const getRouters = () => {
|
||||
return request({
|
||||
url: '/system/menu/getRouters',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
39
src/assets/images/dark.svg
Normal file
39
src/assets/images/dark.svg
Normal file
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="52px" height="45px" viewBox="0 0 52 45" version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<defs>
|
||||
<filter x="-9.4%" y="-6.2%" width="118.8%" height="122.5%" filterUnits="objectBoundingBox" id="filter-1">
|
||||
<feOffset dx="0" dy="1" in="SourceAlpha" result="shadowOffsetOuter1"></feOffset>
|
||||
<feGaussianBlur stdDeviation="1" in="shadowOffsetOuter1" result="shadowBlurOuter1"></feGaussianBlur>
|
||||
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0" type="matrix" in="shadowBlurOuter1" result="shadowMatrixOuter1"></feColorMatrix>
|
||||
<feMerge>
|
||||
<feMergeNode in="shadowMatrixOuter1"></feMergeNode>
|
||||
<feMergeNode in="SourceGraphic"></feMergeNode>
|
||||
</feMerge>
|
||||
</filter>
|
||||
<rect id="path-2" x="0" y="0" width="48" height="40" rx="4"></rect>
|
||||
<filter x="-4.2%" y="-2.5%" width="108.3%" height="110.0%" filterUnits="objectBoundingBox" id="filter-4">
|
||||
<feOffset dx="0" dy="1" in="SourceAlpha" result="shadowOffsetOuter1"></feOffset>
|
||||
<feGaussianBlur stdDeviation="0.5" in="shadowOffsetOuter1" result="shadowBlurOuter1"></feGaussianBlur>
|
||||
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0" type="matrix" in="shadowBlurOuter1"></feColorMatrix>
|
||||
</filter>
|
||||
</defs>
|
||||
<g id="配置面板" width="48" height="40" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="setting-copy-2" width="48" height="40" transform="translate(-1190.000000, -136.000000)">
|
||||
<g id="Group-8" width="48" height="40" transform="translate(1167.000000, 0.000000)">
|
||||
<g id="Group-5-Copy-5" filter="url(#filter-1)" transform="translate(25.000000, 137.000000)">
|
||||
<mask id="mask-3" fill="white">
|
||||
<use xlink:href="#path-2"></use>
|
||||
</mask>
|
||||
<g id="Rectangle-18">
|
||||
<use fill="black" fill-opacity="1" filter="url(#filter-4)" xlink:href="#path-2"></use>
|
||||
<use fill="#F0F2F5" fill-rule="evenodd" xlink:href="#path-2"></use>
|
||||
</g>
|
||||
<rect id="Rectangle-11" fill="#FFFFFF" mask="url(#mask-3)" x="0" y="0" width="48" height="10"></rect>
|
||||
<rect id="Rectangle-18" fill="#303648" mask="url(#mask-3)" x="0" y="0" width="16" height="40"></rect>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
39
src/assets/images/light.svg
Normal file
39
src/assets/images/light.svg
Normal file
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="52px" height="45px" viewBox="0 0 52 45" version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<defs>
|
||||
<filter x="-9.4%" y="-6.2%" width="118.8%" height="122.5%" filterUnits="objectBoundingBox" id="filter-1">
|
||||
<feOffset dx="0" dy="1" in="SourceAlpha" result="shadowOffsetOuter1"></feOffset>
|
||||
<feGaussianBlur stdDeviation="1" in="shadowOffsetOuter1" result="shadowBlurOuter1"></feGaussianBlur>
|
||||
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0" type="matrix" in="shadowBlurOuter1" result="shadowMatrixOuter1"></feColorMatrix>
|
||||
<feMerge>
|
||||
<feMergeNode in="shadowMatrixOuter1"></feMergeNode>
|
||||
<feMergeNode in="SourceGraphic"></feMergeNode>
|
||||
</feMerge>
|
||||
</filter>
|
||||
<rect id="path-2" x="0" y="0" width="48" height="40" rx="4"></rect>
|
||||
<filter x="-4.2%" y="-2.5%" width="108.3%" height="110.0%" filterUnits="objectBoundingBox" id="filter-4">
|
||||
<feOffset dx="0" dy="1" in="SourceAlpha" result="shadowOffsetOuter1"></feOffset>
|
||||
<feGaussianBlur stdDeviation="0.5" in="shadowOffsetOuter1" result="shadowBlurOuter1"></feGaussianBlur>
|
||||
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0" type="matrix" in="shadowBlurOuter1"></feColorMatrix>
|
||||
</filter>
|
||||
</defs>
|
||||
<g id="配置面板" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="setting-copy-2" transform="translate(-1254.000000, -136.000000)">
|
||||
<g id="Group-8" transform="translate(1167.000000, 0.000000)">
|
||||
<g id="Group-5" filter="url(#filter-1)" transform="translate(89.000000, 137.000000)">
|
||||
<mask id="mask-3" fill="white">
|
||||
<use xlink:href="#path-2"></use>
|
||||
</mask>
|
||||
<g id="Rectangle-18">
|
||||
<use fill="black" fill-opacity="1" filter="url(#filter-4)" xlink:href="#path-2"></use>
|
||||
<use fill="#F0F2F5" fill-rule="evenodd" xlink:href="#path-2"></use>
|
||||
</g>
|
||||
<rect id="Rectangle-18" fill="#FFFFFF" mask="url(#mask-3)" x="0" y="0" width="16" height="40"></rect>
|
||||
<rect id="Rectangle-11" fill="#FFFFFF" mask="url(#mask-3)" x="0" y="0" width="48" height="10"></rect>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -1,4 +1,5 @@
|
||||
$menu-bg-color: #8290f0;
|
||||
// $menu-bg-color: #8290f0;
|
||||
$menu-bg-color: #434343;
|
||||
#app {
|
||||
|
||||
.main-container {
|
||||
@@ -92,12 +93,12 @@ $menu-bg-color: #8290f0;
|
||||
.sub-menu-title-noDropdown,
|
||||
.el-sub-menu__title {
|
||||
&:hover {
|
||||
background-color: #c4cbf3 !important;
|
||||
background-color: #e5e5e5 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.el-menu-item:hover {
|
||||
background-color: #c4cbf3 !important;
|
||||
background-color: #e5e5e5 !important;
|
||||
}
|
||||
|
||||
& .theme-dark .is-active>.el-sub-menu__title {
|
||||
@@ -109,7 +110,7 @@ $menu-bg-color: #8290f0;
|
||||
min-width: $base-sidebar-width !important;
|
||||
|
||||
&:hover {
|
||||
background-color: #c4cbf3 !important;
|
||||
background-color: #e5e5e5 !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
81
src/components/Breadcrumb/index.vue
Normal file
81
src/components/Breadcrumb/index.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<el-breadcrumb class="app-breadcrumb" separator="/">
|
||||
<transition-group name="breadcrumb">
|
||||
<el-breadcrumb-item v-for="(item, index) in levelList" :key="item.path">
|
||||
<span
|
||||
v-if="item.redirect === 'noRedirect' || index == levelList.length - 1"
|
||||
class="no-redirect"
|
||||
>
|
||||
{{ item.meta.title }}
|
||||
</span>
|
||||
<a v-else @click.prevent="handleLink(item)">
|
||||
{{ item.meta.title }}
|
||||
</a>
|
||||
</el-breadcrumb-item>
|
||||
</transition-group>
|
||||
</el-breadcrumb>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const levelList = ref([])
|
||||
|
||||
function getBreadcrumb() {
|
||||
// only show routes with meta.title
|
||||
let matched = route.matched.filter((item) => item.meta && item.meta.title)
|
||||
const first = matched[0]
|
||||
// 判断是否为首页
|
||||
if (!isDashboard(first)) {
|
||||
matched = matched
|
||||
}
|
||||
|
||||
levelList.value = matched.filter(
|
||||
(item) => item.meta && item.meta.title && item.meta.breadcrumb !== false
|
||||
)
|
||||
}
|
||||
function isDashboard(route) {
|
||||
const name = route && route.name
|
||||
if (!name) {
|
||||
return false
|
||||
}
|
||||
return name.trim() === 'Index'
|
||||
}
|
||||
function handleLink(item) {
|
||||
const { redirect, path } = item
|
||||
if (redirect) {
|
||||
router.push(redirect)
|
||||
return
|
||||
}
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
// if you go to the redirect page, do not update the breadcrumbs
|
||||
if (route.path.startsWith('/redirect/')) {
|
||||
return
|
||||
}
|
||||
getBreadcrumb()
|
||||
})
|
||||
getBreadcrumb()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.app-breadcrumb.el-breadcrumb {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 56px;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
margin-left: 8px;
|
||||
|
||||
.no-redirect {
|
||||
color: #333333;
|
||||
font-weight: 600;
|
||||
cursor: text;
|
||||
}
|
||||
}
|
||||
::v-deep .el-breadcrumb__separator {
|
||||
color: #333333;
|
||||
}
|
||||
</style>
|
||||
41
src/components/Hamburger/index.vue
Normal file
41
src/components/Hamburger/index.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<div style="padding: 0 15px;" @click="toggleClick">
|
||||
<svg
|
||||
:class="{'is-active':isActive}"
|
||||
class="hamburger"
|
||||
viewBox="0 0 1024 1024"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="64"
|
||||
height="64"
|
||||
>
|
||||
<path d="M408 442h480c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8H408c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8zm-8 204c0 4.4 3.6 8 8 8h480c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8H408c-4.4 0-8 3.6-8 8v56zm504-486H120c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zm0 632H120c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zM142.4 642.1L298.7 519a8.84 8.84 0 0 0 0-13.9L142.4 381.9c-5.8-4.6-14.4-.5-14.4 6.9v246.3a8.9 8.9 0 0 0 14.4 7z" />
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits()
|
||||
const toggleClick = () => {
|
||||
emit('toggleClick');
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hamburger {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.hamburger.is-active {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
</style>
|
||||
@@ -26,7 +26,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import useUserStore from '@/stores/modules/user'
|
||||
import { useUserStore } from '@/stores/modules/user.js'
|
||||
import { watch, ref, getCurrentInstance,onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useMeterStore } from '@/stores/modules/meter'
|
||||
@@ -123,7 +123,7 @@ onMounted(() => {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
height: calc(100vh - 60px);
|
||||
padding: 0 40px;
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #eef1f6 100%);
|
||||
}
|
||||
|
||||
3
src/components/ParentView/index.vue
Normal file
3
src/components/ParentView/index.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template >
|
||||
<router-view />
|
||||
</template>
|
||||
210
src/components/TopNav/index.vue
Normal file
210
src/components/TopNav/index.vue
Normal file
@@ -0,0 +1,210 @@
|
||||
<template>
|
||||
<el-menu
|
||||
:default-active="activeMenu"
|
||||
mode="horizontal"
|
||||
@select="handleSelect"
|
||||
:ellipsis="false"
|
||||
>
|
||||
<template v-for="(item, index) in topMenus">
|
||||
<el-menu-item :style="{'--theme': theme}" :index="item.path" :key="index" v-if="index < visibleNumber"
|
||||
><svg-icon
|
||||
v-if="item.meta && item.meta.icon && item.meta.icon !== '#'"
|
||||
:icon-class="item.meta.icon"
|
||||
/>
|
||||
{{ item.meta.title }}</el-menu-item
|
||||
>
|
||||
</template>
|
||||
|
||||
<!-- 顶部菜单超出数量折叠 -->
|
||||
<el-sub-menu :style="{'--theme': theme}" index="more" v-if="topMenus.length > visibleNumber">
|
||||
<template #title>更多菜单</template>
|
||||
<template v-for="(item, index) in topMenus">
|
||||
<el-menu-item
|
||||
:index="item.path"
|
||||
:key="index"
|
||||
v-if="index >= visibleNumber"
|
||||
><svg-icon :icon-class="item.meta.icon" />
|
||||
{{ item.meta.title }}</el-menu-item
|
||||
>
|
||||
</template>
|
||||
</el-sub-menu>
|
||||
</el-menu>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { constantRoutes } from "@/router/index.js"
|
||||
import { isHttp } from '@/utils/validate.js'
|
||||
import { useAppStore } from '@/stores/modules/app.js'
|
||||
import { useSettingsStore } from '@/stores/modules/settings.js'
|
||||
|
||||
|
||||
// 顶部栏初始数
|
||||
const visibleNumber = ref(null);
|
||||
// 当前激活菜单的 index
|
||||
const currentIndex = ref(null);
|
||||
// 隐藏侧边栏路由
|
||||
const hideList = ['/index', '/user/profile'];
|
||||
|
||||
const appStore = useAppStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
// 主题颜色
|
||||
const theme = computed(() => settingsStore.theme);
|
||||
|
||||
|
||||
// 顶部显示菜单
|
||||
const topMenus = computed(() => {
|
||||
let topMenus = [];
|
||||
routers.value.map((menu) => {
|
||||
if (menu.hidden !== true) {
|
||||
// 兼容顶部栏一级菜单内部跳转
|
||||
if (menu.path === "/") {
|
||||
topMenus.push(menu.children[0]);
|
||||
} else {
|
||||
topMenus.push(menu);
|
||||
}
|
||||
}
|
||||
})
|
||||
return topMenus;
|
||||
})
|
||||
|
||||
// 设置子路由
|
||||
const childrenMenus = computed(() => {
|
||||
let childrenMenus = [];
|
||||
routers.value.map((router) => {
|
||||
for (let item in router.children) {
|
||||
if (router.children[item].parentPath === undefined) {
|
||||
if(router.path === "/") {
|
||||
router.children[item].path = "/" + router.children[item].path;
|
||||
} else {
|
||||
if(!isHttp(router.children[item].path)) {
|
||||
router.children[item].path = router.path + "/" + router.children[item].path;
|
||||
}
|
||||
}
|
||||
router.children[item].parentPath = router.path;
|
||||
}
|
||||
childrenMenus.push(router.children[item]);
|
||||
}
|
||||
})
|
||||
return constantRoutes.concat(childrenMenus);
|
||||
})
|
||||
|
||||
// 默认激活的菜单
|
||||
const activeMenu = computed(() => {
|
||||
const path = route.path;
|
||||
let activePath = path;
|
||||
if (path !== undefined && path.lastIndexOf("/") > 0 && hideList.indexOf(path) === -1) {
|
||||
const tmpPath = path.substring(1, path.length);
|
||||
activePath = "/" + tmpPath.substring(0, tmpPath.indexOf("/"));
|
||||
if (!route.meta.link) {
|
||||
appStore.toggleSideBarHide(false);
|
||||
}
|
||||
} else if(!route.children) {
|
||||
activePath = path;
|
||||
appStore.toggleSideBarHide(true);
|
||||
}
|
||||
activeRoutes(activePath);
|
||||
return activePath;
|
||||
})
|
||||
|
||||
function setVisibleNumber() {
|
||||
const width = document.body.getBoundingClientRect().width / 3;
|
||||
visibleNumber.value = parseInt(width / 85);
|
||||
}
|
||||
|
||||
function handleSelect(key, keyPath) {
|
||||
currentIndex.value = key;
|
||||
const route = routers.value.find(item => item.path === key);
|
||||
if (isHttp(key)) {
|
||||
// http(s):// 路径新窗口打开
|
||||
window.open(key, "_blank");
|
||||
} else if (!route || !route.children) {
|
||||
// 没有子路由路径内部打开
|
||||
const routeMenu = childrenMenus.value.find(item => item.path === key);
|
||||
if (routeMenu && routeMenu.query) {
|
||||
let query = JSON.parse(routeMenu.query);
|
||||
router.push({ path: key, query: query });
|
||||
} else {
|
||||
router.push({ path: key });
|
||||
}
|
||||
appStore.toggleSideBarHide(true);
|
||||
} else {
|
||||
// 显示左侧联动菜单
|
||||
activeRoutes(key);
|
||||
appStore.toggleSideBarHide(false);
|
||||
}
|
||||
}
|
||||
|
||||
function activeRoutes(key) {
|
||||
let routes = [];
|
||||
if (childrenMenus.value && childrenMenus.value.length > 0) {
|
||||
childrenMenus.value.map((item) => {
|
||||
if (key == item.parentPath || (key == "index" && "" == item.path)) {
|
||||
routes.push(item);
|
||||
}
|
||||
});
|
||||
}
|
||||
if(routes.length > 0) {
|
||||
} else {
|
||||
appStore.toggleSideBarHide(true);
|
||||
}
|
||||
return routes;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', setVisibleNumber)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', setVisibleNumber)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
setVisibleNumber()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.topmenu-container.el-menu--horizontal > .el-menu-item {
|
||||
float: left;
|
||||
height: 50px !important;
|
||||
line-height: 50px !important;
|
||||
color: #999093 !important;
|
||||
padding: 0 5px !important;
|
||||
margin: 0 10px !important;
|
||||
}
|
||||
|
||||
.topmenu-container.el-menu--horizontal > .el-menu-item.is-active, .el-menu--horizontal > .el-sub-menu.is-active .el-submenu__title {
|
||||
border-bottom: 2px solid #{'var(--theme)'} !important;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
/* sub-menu item */
|
||||
.topmenu-container.el-menu--horizontal > .el-sub-menu .el-sub-menu__title {
|
||||
float: left;
|
||||
height: 50px !important;
|
||||
line-height: 50px !important;
|
||||
color: #999093 !important;
|
||||
padding: 0 5px !important;
|
||||
margin: 0 10px !important;
|
||||
}
|
||||
|
||||
/* 背景色隐藏 */
|
||||
.topmenu-container.el-menu--horizontal>.el-menu-item:not(.is-disabled):focus, .topmenu-container.el-menu--horizontal>.el-menu-item:not(.is-disabled):hover, .topmenu-container.el-menu--horizontal>.el-submenu .el-submenu__title:hover {
|
||||
background-color: #ffffff !important;
|
||||
}
|
||||
|
||||
/* 图标右间距 */
|
||||
.topmenu-container .svg-icon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
/* topmenu more arrow */
|
||||
.topmenu-container .el-sub-menu .el-sub-menu__icon-arrow {
|
||||
position: static;
|
||||
vertical-align: middle;
|
||||
margin-left: 8px;
|
||||
margin-top: 0px;
|
||||
}
|
||||
</style>
|
||||
@@ -38,13 +38,13 @@ class Canvas extends EventEmitter {
|
||||
window.addEventListener('resize', () => this.resize());
|
||||
}
|
||||
|
||||
resize() {
|
||||
const parent = this.canvas.parentElement;
|
||||
resize() {
|
||||
const parent = this.canvas.parentElement;
|
||||
if (!parent) return;
|
||||
|
||||
const containerWidth = parent.offsetWidth;
|
||||
const containerHeight = parent.offsetHeight;
|
||||
|
||||
|
||||
let width = containerWidth;
|
||||
let height = Math.floor((width * 9) / 16);
|
||||
|
||||
@@ -62,6 +62,7 @@ class Canvas extends EventEmitter {
|
||||
|
||||
this.render();
|
||||
}
|
||||
|
||||
|
||||
setDrawingTool(tool) { this.drawingTool = tool; }
|
||||
setColor(color) { this.currentColor = color; }
|
||||
|
||||
48
src/layout/components/AppMain.vue
Normal file
48
src/layout/components/AppMain.vue
Normal 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>
|
||||
19
src/layout/components/IframeToggle/index.vue
Normal file
19
src/layout/components/IframeToggle/index.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<transition-group name="fade-transform" mode="out-in">
|
||||
<inner-link
|
||||
v-for="(item, index) in tagsViewStore.iframeViews"
|
||||
:key="item.path"
|
||||
:iframeId="'iframe' + index"
|
||||
v-show="route.path === item.path"
|
||||
:src="item.meta.link"
|
||||
></inner-link>
|
||||
</transition-group>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import InnerLink from "../InnerLink/index.vue"
|
||||
import useTagsViewStore from '@/stores/modules/tagsView.js'
|
||||
|
||||
const route = useRoute();
|
||||
const tagsViewStore = useTagsViewStore()
|
||||
</script>
|
||||
24
src/layout/components/InnerLink/index.vue
Normal file
24
src/layout/components/InnerLink/index.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<div :style="'height:' + height">
|
||||
<iframe
|
||||
:id="iframeId"
|
||||
style="width: 100%; height: 100%"
|
||||
:src="src"
|
||||
frameborder="no"
|
||||
></iframe>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
src: {
|
||||
type: String,
|
||||
default: "/"
|
||||
},
|
||||
iframeId: {
|
||||
type: String
|
||||
}
|
||||
});
|
||||
|
||||
const height = ref(document.documentElement.clientHeight - 94.5 + "px");
|
||||
</script>
|
||||
69
src/layout/components/Navbar.vue
Normal file
69
src/layout/components/Navbar.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<div class="navbar">
|
||||
<hamburger
|
||||
id="hamburger-container"
|
||||
:is-active="appStore.sidebar.opened"
|
||||
class="hamburger-container"
|
||||
@toggleClick="toggleSideBar"
|
||||
/>
|
||||
<breadcrumb
|
||||
id="breadcrumb-container"
|
||||
class="breadcrumb-container"
|
||||
v-if="!settingsStore.topNav"
|
||||
/>
|
||||
<top-nav id="topmenu-container" class="topmenu-container" v-if="settingsStore.topNav" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Breadcrumb from '@/components/Breadcrumb/index.vue'
|
||||
import TopNav from '@/components/TopNav/index.vue'
|
||||
import Hamburger from '@/components/Hamburger/index.vue'
|
||||
import { useAppStore } from '@/stores/modules/app.js'
|
||||
import { useSettingsStore } from '@/stores/modules/settings.js'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
function toggleSideBar() {
|
||||
appStore.toggleSideBar(true)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.navbar {
|
||||
height: 56px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
.hamburger-container {
|
||||
line-height: 56px;
|
||||
height: 100%;
|
||||
float: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.025);
|
||||
}
|
||||
}
|
||||
|
||||
.breadcrumb-container {
|
||||
display: flex;
|
||||
align-content: center;
|
||||
float: left;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.topmenu-container {
|
||||
position: absolute;
|
||||
left: 50px;
|
||||
}
|
||||
|
||||
.errLog-container {
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
161
src/layout/components/ResetPwd/index.vue
Normal file
161
src/layout/components/ResetPwd/index.vue
Normal 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>
|
||||
|
||||
238
src/layout/components/Settings/index.vue
Normal file
238
src/layout/components/Settings/index.vue
Normal file
@@ -0,0 +1,238 @@
|
||||
<template>
|
||||
<el-drawer v-model="showSettings" :withHeader="false" direction="rtl" size="300px">
|
||||
<div class="setting-drawer-title">
|
||||
<h3 class="drawer-title">主题风格设置</h3>
|
||||
</div>
|
||||
<div class="setting-drawer-block-checbox">
|
||||
<div class="setting-drawer-block-checbox-item" @click="handleTheme('theme-dark')">
|
||||
<img src="@/assets/images/dark.svg" alt="dark" />
|
||||
<div v-if="sideTheme === 'theme-dark'" class="setting-drawer-block-checbox-selectIcon" style="display: block;">
|
||||
<i aria-label="图标: check" class="anticon anticon-check">
|
||||
<svg viewBox="64 64 896 896" data-icon="check" width="1em" height="1em" :fill="theme" aria-hidden="true" focusable="false" class>
|
||||
<path d="M912 190h-69.9c-9.8 0-19.1 4.5-25.1 12.2L404.7 724.5 207 474a32 32 0 0 0-25.1-12.2H112c-6.7 0-10.4 7.7-6.3 12.9l273.9 347c12.8 16.2 37.4 16.2 50.3 0l488.4-618.9c4.1-5.1.4-12.8-6.3-12.8z" />
|
||||
</svg>
|
||||
</i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-drawer-block-checbox-item" @click="handleTheme('theme-light')">
|
||||
<img src="@/assets/images/light.svg" alt="light" />
|
||||
<div v-if="sideTheme === 'theme-light'" class="setting-drawer-block-checbox-selectIcon" style="display: block;">
|
||||
<i aria-label="图标: check" class="anticon anticon-check">
|
||||
<svg viewBox="64 64 896 896" data-icon="check" width="1em" height="1em" :fill="theme" aria-hidden="true" focusable="false" class>
|
||||
<path d="M912 190h-69.9c-9.8 0-19.1 4.5-25.1 12.2L404.7 724.5 207 474a32 32 0 0 0-25.1-12.2H112c-6.7 0-10.4 7.7-6.3 12.9l273.9 347c12.8 16.2 37.4 16.2 50.3 0l488.4-618.9c4.1-5.1.4-12.8-6.3-12.8z" />
|
||||
</svg>
|
||||
</i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="drawer-item">
|
||||
<span>主题颜色</span>
|
||||
<span class="comp-style">
|
||||
<el-color-picker v-model="theme" :predefine="predefineColors" @change="themeChange"/>
|
||||
</span>
|
||||
</div>
|
||||
<el-divider />
|
||||
|
||||
<h3 class="drawer-title">系统布局配置</h3>
|
||||
|
||||
<div class="drawer-item">
|
||||
<span>开启 TopNav</span>
|
||||
<span class="comp-style">
|
||||
<el-switch v-model="topNav" class="drawer-switch" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="drawer-item">
|
||||
<span>开启 Tags-Views</span>
|
||||
<span class="comp-style">
|
||||
<el-switch v-model="tagsView" class="drawer-switch" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="drawer-item">
|
||||
<span>固定 Header</span>
|
||||
<span class="comp-style">
|
||||
<el-switch v-model="fixedHeader" class="drawer-switch" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="drawer-item">
|
||||
<span>显示 Logo</span>
|
||||
<span class="comp-style">
|
||||
<el-switch v-model="sidebarLogo" class="drawer-switch" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="drawer-item">
|
||||
<span>动态标题</span>
|
||||
<span class="comp-style">
|
||||
<el-switch v-model="dynamicTitle" class="drawer-switch" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<el-button type="primary" plain icon="DocumentAdd" @click="saveSetting">保存配置</el-button>
|
||||
<el-button plain icon="Refresh" @click="resetSetting">重置配置</el-button>
|
||||
</el-drawer>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import variables from '@/assets/styles/variables.module.scss'
|
||||
// import originElementPlus from 'element-plus/theme-chalk/index.css'
|
||||
import axios from 'axios'
|
||||
import { ElLoading, ElMessage } from 'element-plus'
|
||||
import { useDynamicTitle } from '@/utils/dynamicTitle.js'
|
||||
import { useAppStore } from '@/stores/modules/app.js'
|
||||
import { useSettingsStore } from '@/stores/modules/settings.js'
|
||||
import { handleThemeStyle } from '@/utils/theme'
|
||||
|
||||
const { proxy } = getCurrentInstance();
|
||||
const appStore = useAppStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const showSettings = ref(false);
|
||||
const theme = ref(settingsStore.theme);
|
||||
const sideTheme = ref(settingsStore.sideTheme);
|
||||
const storeSettings = computed(() => settingsStore);
|
||||
const predefineColors = ref(["#409EFF", "#ff4500", "#ff8c00", "#ffd700", "#90ee90", "#00ced1", "#1e90ff", "#c71585"]);
|
||||
|
||||
/** 是否需要topnav */
|
||||
const topNav = computed({
|
||||
get: () => storeSettings.value.topNav,
|
||||
set: (val) => {
|
||||
settingsStore.changeSetting({ key: 'topNav', value: val })
|
||||
if (!val) {
|
||||
appStore.toggleSideBarHide(false);
|
||||
}
|
||||
}
|
||||
})
|
||||
/** 是否需要tagview */
|
||||
const tagsView = computed({
|
||||
get: () => storeSettings.value.tagsView,
|
||||
set: (val) => {
|
||||
settingsStore.changeSetting({ key: 'tagsView', value: val })
|
||||
}
|
||||
})
|
||||
/**是否需要固定头部 */
|
||||
const fixedHeader = computed({
|
||||
get: () => storeSettings.value.fixedHeader,
|
||||
set: (val) => {
|
||||
settingsStore.changeSetting({ key: 'fixedHeader', value: val })
|
||||
}
|
||||
})
|
||||
/**是否需要侧边栏的logo */
|
||||
const sidebarLogo = computed({
|
||||
get: () => storeSettings.value.sidebarLogo,
|
||||
set: (val) => {
|
||||
settingsStore.changeSetting({ key: 'sidebarLogo', value: val })
|
||||
}
|
||||
})
|
||||
/**是否需要侧边栏的动态网页的title */
|
||||
const dynamicTitle = computed({
|
||||
get: () => storeSettings.value.dynamicTitle,
|
||||
set: (val) => {
|
||||
settingsStore.changeSetting({ key: 'dynamicTitle', value: val })
|
||||
// 动态设置网页标题
|
||||
useDynamicTitle()
|
||||
}
|
||||
})
|
||||
|
||||
function themeChange(val) {
|
||||
settingsStore.changeSetting({ key: 'theme', value: val })
|
||||
theme.value = val;
|
||||
handleThemeStyle(val);
|
||||
}
|
||||
function handleTheme(val) {
|
||||
settingsStore.changeSetting({ key: 'sideTheme', value: val })
|
||||
sideTheme.value = val;
|
||||
}
|
||||
function saveSetting() {
|
||||
proxy.$modal.loading("正在保存到本地,请稍候...");
|
||||
let layoutSetting = {
|
||||
"topNav": storeSettings.value.topNav,
|
||||
"tagsView": storeSettings.value.tagsView,
|
||||
"fixedHeader": storeSettings.value.fixedHeader,
|
||||
"sidebarLogo": storeSettings.value.sidebarLogo,
|
||||
"dynamicTitle": storeSettings.value.dynamicTitle,
|
||||
"sideTheme": storeSettings.value.sideTheme,
|
||||
"theme": storeSettings.value.theme
|
||||
};
|
||||
localStorage.setItem("layout-setting", JSON.stringify(layoutSetting));
|
||||
setTimeout(proxy.$modal.closeLoading(), 1000)
|
||||
}
|
||||
function resetSetting() {
|
||||
proxy.$modal.loading("正在清除设置缓存并刷新,请稍候...");
|
||||
localStorage.removeItem("layout-setting")
|
||||
setTimeout("window.location.reload()", 1000)
|
||||
}
|
||||
function openSetting() {
|
||||
showSettings.value = true;
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
openSetting,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
.setting-drawer-title {
|
||||
margin-bottom: 12px;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
line-height: 22px;
|
||||
font-weight: bold;
|
||||
.drawer-title {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
.setting-drawer-block-checbox {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.setting-drawer-block-checbox-item {
|
||||
position: relative;
|
||||
margin-right: 16px;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
|
||||
img {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.custom-img {
|
||||
width: 48px;
|
||||
height: 38px;
|
||||
border-radius: 5px;
|
||||
box-shadow: 1px 1px 2px #898484;
|
||||
}
|
||||
|
||||
.setting-drawer-block-checbox-selectIcon {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding-top: 15px;
|
||||
padding-left: 24px;
|
||||
color: #1890ff;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.drawer-item {
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
padding: 12px 0;
|
||||
font-size: 14px;
|
||||
|
||||
.comp-style {
|
||||
float: right;
|
||||
margin: -3px 8px 0px 0px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
40
src/layout/components/Sidebar/Link.vue
Normal file
40
src/layout/components/Sidebar/Link.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<component :is="type" v-bind="linkProps()">
|
||||
<slot />
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { isExternal } from '@/utils/validate'
|
||||
|
||||
const props = defineProps({
|
||||
to: {
|
||||
type: [String, Object],
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const isExt = computed(() => {
|
||||
return isExternal(props.to)
|
||||
})
|
||||
|
||||
const type = computed(() => {
|
||||
if (isExt.value) {
|
||||
return 'a'
|
||||
}
|
||||
return 'router-link'
|
||||
})
|
||||
|
||||
function linkProps() {
|
||||
if (isExt.value) {
|
||||
return {
|
||||
href: props.to,
|
||||
target: '_blank',
|
||||
rel: 'noopener'
|
||||
}
|
||||
}
|
||||
return {
|
||||
to: props.to
|
||||
}
|
||||
}
|
||||
</script>
|
||||
81
src/layout/components/Sidebar/Logo.vue
Normal file
81
src/layout/components/Sidebar/Logo.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div class="sidebar-logo-container" :class="{ 'collapse': collapse }" :style="{ backgroundColor: sideTheme === 'theme-dark' ? variables.menuBackground : variables.menuLightBackground }">
|
||||
<transition name="sidebarLogoFade">
|
||||
<router-link v-if="collapse" key="collapse" class="sidebar-logo-link" to="/">
|
||||
<img v-if="logo" :src="logo" class="sidebar-logo" />
|
||||
<h1 v-else class="sidebar-title" :style="{ color: sideTheme === 'theme-dark' ? variables.logoTitleColor : variables.logoLightTitleColor }">{{ title }}</h1>
|
||||
</router-link>
|
||||
<router-link v-else key="expand" class="sidebar-logo-link" to="/">
|
||||
<img v-if="logo" :src="logo" class="sidebar-logo" />
|
||||
<h1 class="sidebar-title" :style="{ color: sideTheme === 'theme-dark' ? variables.logoTitleColor : variables.logoLightTitleColor }">{{ title }}</h1>
|
||||
</router-link>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import variables from '@/assets/styles/variables.module.scss'
|
||||
import logo from '@/assets/logo/logo.png'
|
||||
import useSettingsStore from '@/store/modules/settings'
|
||||
|
||||
defineProps({
|
||||
collapse: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const title = import.meta.env.VITE_APP_TITLE;
|
||||
const settingsStore = useSettingsStore();
|
||||
const sideTheme = computed(() => settingsStore.sideTheme);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sidebarLogoFade-enter-active {
|
||||
transition: opacity 1.5s;
|
||||
}
|
||||
|
||||
.sidebarLogoFade-enter,
|
||||
.sidebarLogoFade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.sidebar-logo-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
line-height: 50px;
|
||||
background: #2b2f3a;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
|
||||
& .sidebar-logo-link {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
& .sidebar-logo {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
vertical-align: middle;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
& .sidebar-title {
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
line-height: 50px;
|
||||
font-size: 14px;
|
||||
font-family: Avenir, Helvetica Neue, Arial, Helvetica, sans-serif;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
&.collapse {
|
||||
.sidebar-logo {
|
||||
margin-right: 0px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
116
src/layout/components/Sidebar/SidebarItem.vue
Normal file
116
src/layout/components/Sidebar/SidebarItem.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<div v-if="!item.hidden">
|
||||
<template
|
||||
v-if="
|
||||
hasOneShowingChild(item.children, item) &&
|
||||
(!onlyOneChild.children || onlyOneChild.noShowingChildren) &&
|
||||
!item.alwaysShow
|
||||
"
|
||||
>
|
||||
<app-link v-if="onlyOneChild.meta" :to="resolvePath(onlyOneChild.path, onlyOneChild.query)">
|
||||
<el-menu-item
|
||||
:index="resolvePath(onlyOneChild.path)"
|
||||
:class="{ 'submenu-title-noDropdown': !isNest }"
|
||||
>
|
||||
<svg-icon :icon-class="onlyOneChild.meta.icon || (item.meta && item.meta.icon)" />
|
||||
<!-- {{ onlyOneChild.meta.title }} -->
|
||||
<!-- <template #title> -->
|
||||
<span class="menu-title" :title="hasTitle(onlyOneChild.meta.title)">
|
||||
{{ onlyOneChild.meta.title }}
|
||||
</span>
|
||||
<!-- </template> -->
|
||||
</el-menu-item>
|
||||
</app-link>
|
||||
</template>
|
||||
|
||||
<el-sub-menu v-else ref="subMenu" :index="resolvePath(item.path)" popper-append-to-body>
|
||||
<template v-if="item.meta" #title>
|
||||
<svg-icon :icon-class="item.meta && item.meta.icon" />
|
||||
<span class="menu-title" :title="hasTitle(item.meta.title)">{{ item.meta.title }}</span>
|
||||
</template>
|
||||
|
||||
<sidebar-item
|
||||
v-for="child in item.children"
|
||||
:key="child.path"
|
||||
:is-nest="true"
|
||||
:item="child"
|
||||
:base-path="resolvePath(child.path)"
|
||||
class="nest-menu"
|
||||
/>
|
||||
</el-sub-menu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { isExternal } from '@/utils/validate.js'
|
||||
import AppLink from './Link.vue'
|
||||
import { getNormalPath } from '@/utils/ruoyi'
|
||||
|
||||
const props = defineProps({
|
||||
// route object
|
||||
item: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
isNest: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
basePath: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const onlyOneChild = ref({})
|
||||
|
||||
function hasOneShowingChild(children = [], parent) {
|
||||
if (!children) {
|
||||
children = []
|
||||
}
|
||||
const showingChildren = children.filter((item) => {
|
||||
if (item.hidden) {
|
||||
return false
|
||||
} else {
|
||||
// Temp set(will be used if only has one showing child)
|
||||
onlyOneChild.value = item
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
// When there is only one child router, the child router is displayed by default
|
||||
if (showingChildren.length === 1) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Show parent if there are no child router to display
|
||||
if (showingChildren.length === 0) {
|
||||
onlyOneChild.value = { ...parent, path: '', noShowingChildren: true }
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function resolvePath(routePath, routeQuery) {
|
||||
if (isExternal(routePath)) {
|
||||
return routePath
|
||||
}
|
||||
if (isExternal(props.basePath)) {
|
||||
return props.basePath
|
||||
}
|
||||
if (routeQuery) {
|
||||
let query = JSON.parse(routeQuery)
|
||||
return { path: getNormalPath(props.basePath + '/' + routePath), query: query }
|
||||
}
|
||||
return getNormalPath(props.basePath + '/' + routePath)
|
||||
}
|
||||
|
||||
function hasTitle(title) {
|
||||
if (title.length > 5) {
|
||||
return title
|
||||
} else {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
60
src/layout/components/Sidebar/index.vue
Normal file
60
src/layout/components/Sidebar/index.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div
|
||||
:class="{ 'has-logo': showLogo }"
|
||||
:style="{
|
||||
backgroundColor:
|
||||
sideTheme === 'theme-dark' ? variables.menuBackground : variables.menuLightBackground,
|
||||
}"
|
||||
>
|
||||
<!-- <logo v-if="showLogo" :collapse="isCollapse" /> -->
|
||||
<el-scrollbar :class="sideTheme" wrap-class="scrollbar-wrapper">
|
||||
<el-menu
|
||||
:default-active="activeMenu"
|
||||
:collapse="false"
|
||||
:background-color="
|
||||
sideTheme === 'theme-dark' ? variables.menuBackground : variables.menuLightBackground
|
||||
"
|
||||
:text-color="sideTheme === 'theme-dark' ? variables.menuColor : variables.menuLightColor"
|
||||
:unique-opened="true"
|
||||
:active-text-color="theme"
|
||||
:collapse-transition="false"
|
||||
mode="vertical"
|
||||
>
|
||||
<sidebar-item
|
||||
v-for="(route, index) in constantRoutes"
|
||||
:key="route.path + index"
|
||||
:item="route"
|
||||
:base-path="route.path"
|
||||
/>
|
||||
</el-menu>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import SidebarItem from './SidebarItem.vue'
|
||||
import variables from '@/assets/styles/variables.module.scss'
|
||||
import { useAppStore } from '@/stores/modules/app.js'
|
||||
import { useSettingsStore } from '@/stores/modules/settings.js'
|
||||
import router, { constantRoutes, dynamicRoutes } from '@/router'
|
||||
|
||||
|
||||
const route = useRoute()
|
||||
const appStore = useAppStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
|
||||
const showLogo = computed(() => settingsStore.sidebarLogo)
|
||||
const sideTheme = computed(() => settingsStore.sideTheme)
|
||||
// const theme = computed(() => settingsStore.theme)
|
||||
const isCollapse = computed(() => !appStore.sidebar.opened)
|
||||
|
||||
const activeMenu = computed(() => {
|
||||
const { meta, path } = route
|
||||
// if set path, the sidebar will highlight the path you set
|
||||
if (meta.activeMenu) {
|
||||
return meta.activeMenu
|
||||
}
|
||||
return path
|
||||
})
|
||||
</script>
|
||||
105
src/layout/components/TagsView/ScrollPane.vue
Normal file
105
src/layout/components/TagsView/ScrollPane.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<el-scrollbar
|
||||
ref="scrollContainer"
|
||||
:vertical="false"
|
||||
class="scroll-container"
|
||||
@wheel.prevent="handleScroll"
|
||||
>
|
||||
<slot />
|
||||
</el-scrollbar>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import useTagsViewStore from '@/stores/modules/tagsView.js'
|
||||
|
||||
const tagAndTagSpacing = ref(4);
|
||||
const { proxy } = getCurrentInstance();
|
||||
|
||||
const scrollWrapper = computed(() => proxy.$refs.scrollContainer.$refs.wrapRef);
|
||||
|
||||
onMounted(() => {
|
||||
scrollWrapper.value.addEventListener('scroll', emitScroll, true)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
scrollWrapper.value.removeEventListener('scroll', emitScroll)
|
||||
})
|
||||
|
||||
function handleScroll(e) {
|
||||
const eventDelta = e.wheelDelta || -e.deltaY * 40
|
||||
const $scrollWrapper = scrollWrapper.value;
|
||||
$scrollWrapper.scrollLeft = $scrollWrapper.scrollLeft + eventDelta / 4
|
||||
}
|
||||
const emits = defineEmits()
|
||||
const emitScroll = () => {
|
||||
emits('scroll')
|
||||
}
|
||||
|
||||
const tagsViewStore = useTagsViewStore()
|
||||
const visitedViews = computed(() => tagsViewStore.visitedViews);
|
||||
|
||||
function moveToTarget(currentTag) {
|
||||
const $container = proxy.$refs.scrollContainer.$el
|
||||
const $containerWidth = $container.offsetWidth
|
||||
const $scrollWrapper = scrollWrapper.value;
|
||||
|
||||
let firstTag = null
|
||||
let lastTag = null
|
||||
|
||||
// find first tag and last tag
|
||||
if (visitedViews.value.length > 0) {
|
||||
firstTag = visitedViews.value[0]
|
||||
lastTag = visitedViews.value[visitedViews.value.length - 1]
|
||||
}
|
||||
|
||||
if (firstTag === currentTag) {
|
||||
$scrollWrapper.scrollLeft = 0
|
||||
} else if (lastTag === currentTag) {
|
||||
$scrollWrapper.scrollLeft = $scrollWrapper.scrollWidth - $containerWidth
|
||||
} else {
|
||||
const tagListDom = document.getElementsByClassName('tags-view-item');
|
||||
const currentIndex = visitedViews.value.findIndex(item => item === currentTag)
|
||||
let prevTag = null
|
||||
let nextTag = null
|
||||
for (const k in tagListDom) {
|
||||
if (k !== 'length' && Object.hasOwnProperty.call(tagListDom, k)) {
|
||||
if (tagListDom[k].dataset.path === visitedViews.value[currentIndex - 1].path) {
|
||||
prevTag = tagListDom[k];
|
||||
}
|
||||
if (tagListDom[k].dataset.path === visitedViews.value[currentIndex + 1].path) {
|
||||
nextTag = tagListDom[k];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// the tag's offsetLeft after of nextTag
|
||||
const afterNextTagOffsetLeft = nextTag.offsetLeft + nextTag.offsetWidth + tagAndTagSpacing.value
|
||||
|
||||
// the tag's offsetLeft before of prevTag
|
||||
const beforePrevTagOffsetLeft = prevTag.offsetLeft - tagAndTagSpacing.value
|
||||
if (afterNextTagOffsetLeft > $scrollWrapper.scrollLeft + $containerWidth) {
|
||||
$scrollWrapper.scrollLeft = afterNextTagOffsetLeft - $containerWidth
|
||||
} else if (beforePrevTagOffsetLeft < $scrollWrapper.scrollLeft) {
|
||||
$scrollWrapper.scrollLeft = beforePrevTagOffsetLeft
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
moveToTarget,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
.scroll-container {
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
:deep(.el-scrollbar__bar) {
|
||||
bottom: 0px;
|
||||
}
|
||||
:deep(.el-scrollbar__wrap) {
|
||||
height: 39px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
336
src/layout/components/TagsView/index.vue
Normal file
336
src/layout/components/TagsView/index.vue
Normal file
@@ -0,0 +1,336 @@
|
||||
<template>
|
||||
<div id="tags-view-container" class="tags-view-container">
|
||||
<scroll-pane ref="scrollPaneRef" class="tags-view-wrapper" @scroll="handleScroll">
|
||||
<router-link
|
||||
v-for="tag in visitedViews"
|
||||
:key="tag.path"
|
||||
:data-path="tag.path"
|
||||
:class="isActive(tag) ? 'active' : ''"
|
||||
:to="{ path: tag.path, query: tag.query, fullPath: tag.fullPath }"
|
||||
class="tags-view-item"
|
||||
:style="activeStyle(tag)"
|
||||
@click.middle="!isAffix(tag) ? closeSelectedTag(tag) : ''"
|
||||
@contextmenu.prevent="openMenu(tag, $event)"
|
||||
>
|
||||
{{ tag.title }}
|
||||
<span v-if="!isAffix(tag)" @click.prevent.stop="closeSelectedTag(tag)">
|
||||
<close class="el-icon-close" style="width: 1em; height: 1em;vertical-align: middle;" />
|
||||
</span>
|
||||
</router-link>
|
||||
</scroll-pane>
|
||||
<ul v-show="visible" :style="{ left: left + 'px', top: top + 'px' }" class="contextmenu">
|
||||
<li @click="refreshSelectedTag(selectedTag)">
|
||||
<refresh-right style="width: 1em; height: 1em;" /> 刷新页面
|
||||
</li>
|
||||
<li v-if="!isAffix(selectedTag)" @click="closeSelectedTag(selectedTag)">
|
||||
<close style="width: 1em; height: 1em;" /> 关闭当前
|
||||
</li>
|
||||
<li @click="closeOthersTags">
|
||||
<circle-close style="width: 1em; height: 1em;" /> 关闭其他
|
||||
</li>
|
||||
<li v-if="!isFirstView()" @click="closeLeftTags">
|
||||
<back style="width: 1em; height: 1em;" /> 关闭左侧
|
||||
</li>
|
||||
<li v-if="!isLastView()" @click="closeRightTags">
|
||||
<right style="width: 1em; height: 1em;" /> 关闭右侧
|
||||
</li>
|
||||
<li @click="closeAllTags(selectedTag)">
|
||||
<circle-close style="width: 1em; height: 1em;" /> 全部关闭
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ScrollPane from './ScrollPane.vue'
|
||||
import { getNormalPath } from '@/utils/ruoyi'
|
||||
import useTagsViewStore from '@/stores/modules/tagsView.js'
|
||||
import { useSettingsStore } from '@/stores/modules/settings.js'
|
||||
|
||||
const visible = ref(false);
|
||||
const top = ref(0);
|
||||
const left = ref(0);
|
||||
const selectedTag = ref({});
|
||||
const affixTags = ref([]);
|
||||
const scrollPaneRef = ref(null);
|
||||
|
||||
const { proxy } = getCurrentInstance();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const visitedViews = computed(() => useTagsViewStore().visitedViews);
|
||||
const theme = computed(() => useSettingsStore().theme);
|
||||
|
||||
watch(route, () => {
|
||||
addTags()
|
||||
moveToCurrentTag()
|
||||
})
|
||||
watch(visible, (value) => {
|
||||
if (value) {
|
||||
document.body.addEventListener('click', closeMenu)
|
||||
} else {
|
||||
document.body.removeEventListener('click', closeMenu)
|
||||
}
|
||||
})
|
||||
onMounted(() => {
|
||||
initTags()
|
||||
addTags()
|
||||
})
|
||||
|
||||
function isActive(r) {
|
||||
return r.path === route.path
|
||||
}
|
||||
function activeStyle(tag) {
|
||||
if (!isActive(tag)) return {};
|
||||
return {
|
||||
"background-color": theme.value,
|
||||
"border-color": theme.value
|
||||
};
|
||||
}
|
||||
function isAffix(tag) {
|
||||
return tag.meta && tag.meta.affix
|
||||
}
|
||||
function isFirstView() {
|
||||
try {
|
||||
return selectedTag.value.fullPath === '/index' || selectedTag.value.fullPath === visitedViews.value[1].fullPath
|
||||
} catch (err) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
function isLastView() {
|
||||
try {
|
||||
return selectedTag.value.fullPath === visitedViews.value[visitedViews.value.length - 1].fullPath
|
||||
} catch (err) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
function filterAffixTags(routes, basePath = '') {
|
||||
let tags = []
|
||||
routes.forEach(route => {
|
||||
if (route.meta && route.meta.affix) {
|
||||
const tagPath = getNormalPath(basePath + '/' + route.path)
|
||||
tags.push({
|
||||
fullPath: tagPath,
|
||||
path: tagPath,
|
||||
name: route.name,
|
||||
meta: { ...route.meta }
|
||||
})
|
||||
}
|
||||
if (route.children) {
|
||||
const tempTags = filterAffixTags(route.children, route.path)
|
||||
if (tempTags.length >= 1) {
|
||||
tags = [...tags, ...tempTags]
|
||||
}
|
||||
}
|
||||
})
|
||||
return tags
|
||||
}
|
||||
function initTags() {
|
||||
const res = filterAffixTags(routes.value);
|
||||
affixTags.value = res;
|
||||
for (const tag of res) {
|
||||
// Must have tag name
|
||||
if (tag.name) {
|
||||
useTagsViewStore().addVisitedView(tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
function addTags() {
|
||||
const { name } = route
|
||||
if (name) {
|
||||
useTagsViewStore().addView(route)
|
||||
if (route.meta.link) {
|
||||
useTagsViewStore().addIframeView(route);
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
function moveToCurrentTag() {
|
||||
nextTick(() => {
|
||||
for (const r of visitedViews.value) {
|
||||
if (r.path === route.path) {
|
||||
scrollPaneRef.value.moveToTarget(r);
|
||||
// when query is different then update
|
||||
if (r.fullPath !== route.fullPath) {
|
||||
useTagsViewStore().updateVisitedView(route)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
function refreshSelectedTag(view) {
|
||||
proxy.$tab.refreshPage(view);
|
||||
if (route.meta.link) {
|
||||
useTagsViewStore().delIframeView(route);
|
||||
}
|
||||
}
|
||||
function closeSelectedTag(view) {
|
||||
proxy.$tab.closePage(view).then(({ visitedViews }) => {
|
||||
if (isActive(view)) {
|
||||
toLastView(visitedViews, view)
|
||||
}
|
||||
})
|
||||
}
|
||||
function closeRightTags() {
|
||||
proxy.$tab.closeRightPage(selectedTag.value).then(visitedViews => {
|
||||
if (!visitedViews.find(i => i.fullPath === route.fullPath)) {
|
||||
toLastView(visitedViews)
|
||||
}
|
||||
})
|
||||
}
|
||||
function closeLeftTags() {
|
||||
proxy.$tab.closeLeftPage(selectedTag.value).then(visitedViews => {
|
||||
if (!visitedViews.find(i => i.fullPath === route.fullPath)) {
|
||||
toLastView(visitedViews)
|
||||
}
|
||||
})
|
||||
}
|
||||
function closeOthersTags() {
|
||||
router.push(selectedTag.value).catch(() => { });
|
||||
proxy.$tab.closeOtherPage(selectedTag.value).then(() => {
|
||||
moveToCurrentTag()
|
||||
})
|
||||
}
|
||||
function closeAllTags(view) {
|
||||
proxy.$tab.closeAllPage().then(({ visitedViews }) => {
|
||||
if (affixTags.value.some(tag => tag.path === route.path)) {
|
||||
return
|
||||
}
|
||||
toLastView(visitedViews, view)
|
||||
})
|
||||
}
|
||||
function toLastView(visitedViews, view) {
|
||||
const latestView = visitedViews.slice(-1)[0]
|
||||
if (latestView) {
|
||||
router.push(latestView.fullPath)
|
||||
} else {
|
||||
// now the default is to redirect to the home page if there is no tags-view,
|
||||
// you can adjust it according to your needs.
|
||||
if (view.name === 'Dashboard') {
|
||||
// to reload home page
|
||||
router.replace({ path: '/redirect' + view.fullPath })
|
||||
} else {
|
||||
router.push('/')
|
||||
}
|
||||
}
|
||||
}
|
||||
function openMenu(tag, e) {
|
||||
const menuMinWidth = 105
|
||||
const offsetLeft = proxy.$el.getBoundingClientRect().left // container margin left
|
||||
const offsetWidth = proxy.$el.offsetWidth // container width
|
||||
const maxLeft = offsetWidth - menuMinWidth // left boundary
|
||||
const l = e.clientX - offsetLeft + 15 // 15: margin right
|
||||
|
||||
if (l > maxLeft) {
|
||||
left.value = maxLeft
|
||||
} else {
|
||||
left.value = l
|
||||
}
|
||||
|
||||
top.value = e.clientY
|
||||
visible.value = true
|
||||
selectedTag.value = tag
|
||||
}
|
||||
function closeMenu() {
|
||||
visible.value = false
|
||||
}
|
||||
function handleScroll() {
|
||||
closeMenu()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
.tags-view-container {
|
||||
height: 34px;
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #d8dce5;
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.12), 0 0 3px 0 rgba(0, 0, 0, 0.04);
|
||||
.tags-view-wrapper {
|
||||
.tags-view-item {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
height: 26px;
|
||||
line-height: 26px;
|
||||
border: 1px solid #d8dce5;
|
||||
color: #495060;
|
||||
background: #fff;
|
||||
padding: 0 8px;
|
||||
font-size: 12px;
|
||||
margin-left: 5px;
|
||||
margin-top: 4px;
|
||||
&:first-of-type {
|
||||
margin-left: 15px;
|
||||
}
|
||||
&:last-of-type {
|
||||
margin-right: 15px;
|
||||
}
|
||||
&.active {
|
||||
background-color: #42b983;
|
||||
color: #fff;
|
||||
border-color: #42b983;
|
||||
&::before {
|
||||
content: "";
|
||||
background: #fff;
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.contextmenu {
|
||||
margin: 0;
|
||||
background: #fff;
|
||||
z-index: 3000;
|
||||
position: absolute;
|
||||
list-style-type: none;
|
||||
padding: 5px 0;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: #333;
|
||||
box-shadow: 2px 2px 3px 0 rgba(0, 0, 0, 0.3);
|
||||
li {
|
||||
margin: 0;
|
||||
padding: 7px 16px;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
background: #eee;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
//reset element css of el-icon-close
|
||||
.tags-view-wrapper {
|
||||
.tags-view-item {
|
||||
.el-icon-close {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
vertical-align: 2px;
|
||||
border-radius: 50%;
|
||||
text-align: center;
|
||||
transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
|
||||
transform-origin: 100% 50%;
|
||||
&:before {
|
||||
transform: scale(0.6);
|
||||
display: inline-block;
|
||||
vertical-align: -3px;
|
||||
}
|
||||
&:hover {
|
||||
background-color: #b4bccc;
|
||||
color: #fff;
|
||||
width: 12px !important;
|
||||
height: 12px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
5
src/layout/components/index.js
Normal file
5
src/layout/components/index.js
Normal 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
309
src/layout/index.vue
Normal 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>
|
||||
@@ -17,14 +17,14 @@ import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
const pinia = createPinia()
|
||||
const app = createApp(App)
|
||||
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.use(ElementPlus, {
|
||||
locale: zhCn,
|
||||
|
||||
60
src/plugins/auth.js
Normal file
60
src/plugins/auth.js
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useUserStore } from '@/stores/modules/user.js'
|
||||
|
||||
function authPermission(permission) {
|
||||
const all_permission = "*:*:*";
|
||||
const permissions = useUserStore().permissions
|
||||
if (permission && permission.length > 0) {
|
||||
return permissions.some(v => {
|
||||
return all_permission === v || v === permission
|
||||
})
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function authRole(role) {
|
||||
const super_admin = "admin";
|
||||
const roles = useUserStore().roles
|
||||
if (role && role.length > 0) {
|
||||
return roles.some(v => {
|
||||
return super_admin === v || v === role
|
||||
})
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
// 验证用户是否具备某权限
|
||||
hasPermi(permission) {
|
||||
return authPermission(permission);
|
||||
},
|
||||
// 验证用户是否含有指定权限,只需包含其中一个
|
||||
hasPermiOr(permissions) {
|
||||
return permissions.some(item => {
|
||||
return authPermission(item)
|
||||
})
|
||||
},
|
||||
// 验证用户是否含有指定权限,必须全部拥有
|
||||
hasPermiAnd(permissions) {
|
||||
return permissions.every(item => {
|
||||
return authPermission(item)
|
||||
})
|
||||
},
|
||||
// 验证用户是否具备某角色
|
||||
hasRole(role) {
|
||||
return authRole(role);
|
||||
},
|
||||
// 验证用户是否含有指定角色,只需包含其中一个
|
||||
hasRoleOr(roles) {
|
||||
return roles.some(item => {
|
||||
return authRole(item)
|
||||
})
|
||||
},
|
||||
// 验证用户是否含有指定角色,必须全部拥有
|
||||
hasRoleAnd(roles) {
|
||||
return roles.every(item => {
|
||||
return authRole(item)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,57 +1,134 @@
|
||||
import { createRouter, createWebHistory, createWebHashHistory } from 'vue-router'
|
||||
import Layout from "@/layout/index.vue";
|
||||
|
||||
export const constantRoutes = [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/login', // 这里做重定向
|
||||
hidden: true,
|
||||
},
|
||||
{
|
||||
path: "/login",
|
||||
component: () => import("@/views/login.vue"),
|
||||
meta: { title: "登录" },
|
||||
hidden: true,
|
||||
},
|
||||
{
|
||||
path: '/whiteboard',
|
||||
component: () => import('@/views/custom/tabulaRase/index.vue'),
|
||||
meta: { title: "白板" },
|
||||
hidden: true,
|
||||
},
|
||||
{
|
||||
path: "/coordinate",
|
||||
redirect: "/coordinate/CoordinatePage",
|
||||
component: Layout,
|
||||
children: [
|
||||
{
|
||||
path: 'CoordinatePage',
|
||||
name: "Coordinate",
|
||||
component: () => import("@/views/coordinate/personnelList/index.vue"),
|
||||
meta: { title: "远程协作", icon: "client", affix: true },
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: "/conferencingRoom",
|
||||
// component: Layout,
|
||||
hidden: true,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: "ConferencingRoom",
|
||||
component: () => import("@/views/conferencingRoom/index.vue"),
|
||||
meta: { title: "会议房间", icon: "client", affix: true },
|
||||
}
|
||||
]
|
||||
},
|
||||
// 错误页面路由
|
||||
{
|
||||
path: "/:pathMatch(.*)*",
|
||||
component: () => import("@/views/error/404.vue"),
|
||||
hidden: true,
|
||||
},
|
||||
{
|
||||
path: "/401",
|
||||
component: () => import("@/views/error/401.vue"),
|
||||
hidden: true,
|
||||
meta: { title: "401未授权" }
|
||||
},
|
||||
]
|
||||
|
||||
export const dynamicRoutes = [
|
||||
|
||||
]
|
||||
|
||||
// const router = createRouter({
|
||||
// history: createWebHashHistory(import.meta.env.BASE_URL),
|
||||
// routes: [
|
||||
// // {
|
||||
// // path: '/',
|
||||
// // component: () => import("@/views/custom/tabulaRase/index.vue"),
|
||||
// // },
|
||||
// {
|
||||
// path: '/',
|
||||
// redirect: '/login', // 这里做重定向
|
||||
// },
|
||||
// {
|
||||
// path: '/whiteboard',
|
||||
// component: () => import('@/views/custom/tabulaRase/index.vue'),
|
||||
// },
|
||||
// {
|
||||
// path: "/login",
|
||||
// component: () => import("@/views/login.vue"),
|
||||
// },
|
||||
// {
|
||||
// path: "/coordinate",
|
||||
// component: Layout,
|
||||
// meta: { title: "远程协作", icon: "client", affix: true },
|
||||
// children: [
|
||||
// {
|
||||
// path: '',
|
||||
// name: "Coordinate",
|
||||
// component: () => import("@/views/coordinate/personnelList/index.vue")
|
||||
// }
|
||||
// ]
|
||||
// },
|
||||
// {
|
||||
// path: "/conferencingRoom",
|
||||
// children: [
|
||||
// {
|
||||
// path: '',
|
||||
// name: "ConferencingRoom",
|
||||
// component: () => import("@/views/conferencingRoom/index.vue")
|
||||
// }
|
||||
// ]
|
||||
// },
|
||||
|
||||
|
||||
// // 错误页面路由
|
||||
// {
|
||||
// path: "/:pathMatch(.*)*",
|
||||
// component: () => import("@/views/error/404.vue"),
|
||||
// },
|
||||
// {
|
||||
// path: "/401",
|
||||
// component: () => import("@/views/error/401.vue"),
|
||||
// }
|
||||
// ],
|
||||
// })
|
||||
const router = createRouter({
|
||||
// history: createWebHistory(import.meta.env.VITE_BASE_PATH),
|
||||
history: createWebHashHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
// {
|
||||
// path: '/',
|
||||
// component: () => import("@/views/custom/tabulaRase/index.vue"),
|
||||
// },
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/login', // 这里做重定向
|
||||
},
|
||||
{
|
||||
path: '/whiteboard',
|
||||
component: () => import('@/views/custom/tabulaRase/index.vue'),
|
||||
},
|
||||
{
|
||||
path: "/login",
|
||||
component: () => import("@/views/login.vue"),
|
||||
},
|
||||
{
|
||||
path: "/coordinate",
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: "Coordinate",
|
||||
component: () => import("@/views/coordinate/personnelList/index.vue")
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: "/conferencingRoom",
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: "ConferencingRoom",
|
||||
component: () => import("@/views/conferencingRoom/index.vue")
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
|
||||
// 错误页面路由
|
||||
{
|
||||
path: "/:pathMatch(.*)*",
|
||||
component: () => import("@/views/error/404.vue"),
|
||||
},
|
||||
{
|
||||
path: "/401",
|
||||
component: () => import("@/views/error/401.vue"),
|
||||
routes: constantRoutes,
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
if (savedPosition) {
|
||||
return savedPosition;
|
||||
} else {
|
||||
return { top: 0 };
|
||||
}
|
||||
],
|
||||
})
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
export default router
|
||||
|
||||
47
src/settings.js
Normal file
47
src/settings.js
Normal file
@@ -0,0 +1,47 @@
|
||||
export default {
|
||||
/**
|
||||
* 网页标题
|
||||
*/
|
||||
title: import.meta.env.VITE_APP_TITLE,
|
||||
/**
|
||||
* 侧边栏主题 深色主题theme-dark,浅色主题theme-light
|
||||
*/
|
||||
sideTheme: 'theme-dark',
|
||||
/**
|
||||
* 是否系统布局配置
|
||||
*/
|
||||
showSettings: true,
|
||||
|
||||
/**
|
||||
* 是否显示顶部导航
|
||||
*/
|
||||
topNav: false,
|
||||
|
||||
/**
|
||||
* 是否显示 tagsView
|
||||
*/
|
||||
tagsView: true,
|
||||
|
||||
/**
|
||||
* 是否固定头部
|
||||
*/
|
||||
fixedHeader: false,
|
||||
|
||||
/**
|
||||
* 是否显示logo
|
||||
*/
|
||||
sidebarLogo: false,
|
||||
|
||||
/**
|
||||
* 是否显示动态标题
|
||||
*/
|
||||
dynamicTitle: false,
|
||||
|
||||
/**
|
||||
* @type {string | array} 'production' | ['production', 'development']
|
||||
* @description Need show err logs component.
|
||||
* The default is only used in the production env
|
||||
* If you want to also use it in dev, you can pass ['production', 'development']
|
||||
*/
|
||||
errorLog: 'production'
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useCounterStore = defineStore('counter', () => {
|
||||
const count = ref(0)
|
||||
const doubleCount = computed(() => count.value * 2)
|
||||
function increment() {
|
||||
count.value++
|
||||
}
|
||||
|
||||
return { count, doubleCount, increment }
|
||||
})
|
||||
45
src/stores/modules/app.js
Normal file
45
src/stores/modules/app.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import Cookies from 'js-cookie'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useAppStore = defineStore(
|
||||
'app',
|
||||
{
|
||||
state: () => ({
|
||||
sidebar: {
|
||||
opened: Cookies.get('sidebarStatus') ? !!+Cookies.get('sidebarStatus') : true,
|
||||
withoutAnimation: false,
|
||||
hide: false
|
||||
},
|
||||
device: 'desktop',
|
||||
size: Cookies.get('size') || 'default'
|
||||
}),
|
||||
actions: {
|
||||
toggleSideBar(withoutAnimation) {
|
||||
if (this.sidebar.hide) {
|
||||
return false;
|
||||
}
|
||||
this.sidebar.opened = !this.sidebar.opened
|
||||
this.sidebar.withoutAnimation = withoutAnimation
|
||||
if (this.sidebar.opened) {
|
||||
Cookies.set('sidebarStatus', 1)
|
||||
} else {
|
||||
Cookies.set('sidebarStatus', 0)
|
||||
}
|
||||
},
|
||||
closeSideBar({ withoutAnimation }) {
|
||||
Cookies.set('sidebarStatus', 0)
|
||||
this.sidebar.opened = false
|
||||
this.sidebar.withoutAnimation = withoutAnimation
|
||||
},
|
||||
toggleDevice(device) {
|
||||
this.device = device
|
||||
},
|
||||
setSize(size) {
|
||||
this.size = size;
|
||||
Cookies.set('size', size)
|
||||
},
|
||||
toggleSideBarHide(status) {
|
||||
this.sidebar.hide = status
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { generateUUID } from '@/utils/tools.js'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useMeterStore = defineStore('meter', {
|
||||
state: () => ({
|
||||
@@ -30,4 +30,4 @@ export const useMeterStore = defineStore('meter', {
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,7 +5,8 @@ export const useRoomStore = defineStore('room', {
|
||||
roomId: '',
|
||||
token: '',
|
||||
userUid: '',
|
||||
detailUid: '',
|
||||
//邀请进入房间的用户uid
|
||||
detailUid: '',
|
||||
}),
|
||||
actions: {
|
||||
setUserUid(data) {
|
||||
|
||||
37
src/stores/modules/settings.js
Normal file
37
src/stores/modules/settings.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import defaultSettings from '@/settings'
|
||||
import { useDynamicTitle } from '@/utils/dynamicTitle'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
const { sideTheme, showSettings, topNav, tagsView, fixedHeader, sidebarLogo, dynamicTitle } = defaultSettings
|
||||
|
||||
const storageSetting = JSON.parse(localStorage.getItem('layout-setting')) || ''
|
||||
|
||||
export const useSettingsStore = defineStore(
|
||||
'settings',
|
||||
{
|
||||
state: () => ({
|
||||
title: '',
|
||||
theme: storageSetting.theme === undefined ? '#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();
|
||||
}
|
||||
}
|
||||
})
|
||||
183
src/stores/modules/tagsView.js
Normal file
183
src/stores/modules/tagsView.js
Normal file
@@ -0,0 +1,183 @@
|
||||
import { defineStore } from 'pinia'
|
||||
const useTagsViewStore = defineStore(
|
||||
'tags-view',
|
||||
{
|
||||
state: () => ({
|
||||
visitedViews: [],
|
||||
cachedViews: [],
|
||||
iframeViews: []
|
||||
}),
|
||||
actions: {
|
||||
addView(view) {
|
||||
this.addVisitedView(view)
|
||||
this.addCachedView(view)
|
||||
},
|
||||
addIframeView(view) {
|
||||
if (this.iframeViews.some(v => v.path === view.path)) return
|
||||
this.iframeViews.push(
|
||||
Object.assign({}, view, {
|
||||
title: view.meta.title || 'no-name'
|
||||
})
|
||||
)
|
||||
},
|
||||
addVisitedView(view) {
|
||||
if (this.visitedViews.some(v => v.path === view.path)) return
|
||||
this.visitedViews.push(
|
||||
Object.assign({}, view, {
|
||||
title: view.meta.title || 'no-name'
|
||||
})
|
||||
)
|
||||
},
|
||||
addCachedView(view) {
|
||||
if (this.cachedViews.includes(view.name)) return
|
||||
if (!view.meta.noCache) {
|
||||
this.cachedViews.push(view.name)
|
||||
}
|
||||
},
|
||||
delView(view) {
|
||||
return new Promise(resolve => {
|
||||
this.delVisitedView(view)
|
||||
this.delCachedView(view)
|
||||
resolve({
|
||||
visitedViews: [...this.visitedViews],
|
||||
cachedViews: [...this.cachedViews]
|
||||
})
|
||||
})
|
||||
},
|
||||
delVisitedView(view) {
|
||||
return new Promise(resolve => {
|
||||
for (const [i, v] of this.visitedViews.entries()) {
|
||||
if (v.path === view.path) {
|
||||
this.visitedViews.splice(i, 1)
|
||||
break
|
||||
}
|
||||
}
|
||||
this.iframeViews = this.iframeViews.filter(item => item.path !== view.path)
|
||||
resolve([...this.visitedViews])
|
||||
})
|
||||
},
|
||||
delIframeView(view) {
|
||||
return new Promise(resolve => {
|
||||
this.iframeViews = this.iframeViews.filter(item => item.path !== view.path)
|
||||
resolve([...this.iframeViews])
|
||||
})
|
||||
},
|
||||
delCachedView(view) {
|
||||
return new Promise(resolve => {
|
||||
const index = this.cachedViews.indexOf(view.name)
|
||||
index > -1 && this.cachedViews.splice(index, 1)
|
||||
resolve([...this.cachedViews])
|
||||
})
|
||||
},
|
||||
delOthersViews(view) {
|
||||
return new Promise(resolve => {
|
||||
this.delOthersVisitedViews(view)
|
||||
this.delOthersCachedViews(view)
|
||||
resolve({
|
||||
visitedViews: [...this.visitedViews],
|
||||
cachedViews: [...this.cachedViews]
|
||||
})
|
||||
})
|
||||
},
|
||||
delOthersVisitedViews(view) {
|
||||
return new Promise(resolve => {
|
||||
this.visitedViews = this.visitedViews.filter(v => {
|
||||
return v.meta.affix || v.path === view.path
|
||||
})
|
||||
this.iframeViews = this.iframeViews.filter(item => item.path === view.path)
|
||||
resolve([...this.visitedViews])
|
||||
})
|
||||
},
|
||||
delOthersCachedViews(view) {
|
||||
return new Promise(resolve => {
|
||||
const index = this.cachedViews.indexOf(view.name)
|
||||
if (index > -1) {
|
||||
this.cachedViews = this.cachedViews.slice(index, index + 1)
|
||||
} else {
|
||||
this.cachedViews = []
|
||||
}
|
||||
resolve([...this.cachedViews])
|
||||
})
|
||||
},
|
||||
delAllViews(view) {
|
||||
return new Promise(resolve => {
|
||||
this.delAllVisitedViews(view)
|
||||
this.delAllCachedViews(view)
|
||||
resolve({
|
||||
visitedViews: [...this.visitedViews],
|
||||
cachedViews: [...this.cachedViews]
|
||||
})
|
||||
})
|
||||
},
|
||||
delAllVisitedViews(view) {
|
||||
return new Promise(resolve => {
|
||||
const affixTags = this.visitedViews.filter(tag => tag.meta.affix)
|
||||
this.visitedViews = affixTags
|
||||
this.iframeViews = []
|
||||
resolve([...this.visitedViews])
|
||||
})
|
||||
},
|
||||
delAllCachedViews(view) {
|
||||
return new Promise(resolve => {
|
||||
this.cachedViews = []
|
||||
resolve([...this.cachedViews])
|
||||
})
|
||||
},
|
||||
updateVisitedView(view) {
|
||||
for (let v of this.visitedViews) {
|
||||
if (v.path === view.path) {
|
||||
v = Object.assign(v, view)
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
delRightTags(view) {
|
||||
return new Promise(resolve => {
|
||||
const index = this.visitedViews.findIndex(v => v.path === view.path)
|
||||
if (index === -1) {
|
||||
return
|
||||
}
|
||||
this.visitedViews = this.visitedViews.filter((item, idx) => {
|
||||
if (idx <= index || (item.meta && item.meta.affix)) {
|
||||
return true
|
||||
}
|
||||
const i = this.cachedViews.indexOf(item.name)
|
||||
if (i > -1) {
|
||||
this.cachedViews.splice(i, 1)
|
||||
}
|
||||
if(item.meta.link) {
|
||||
const fi = this.iframeViews.findIndex(v => v.path === item.path)
|
||||
this.iframeViews.splice(fi, 1)
|
||||
}
|
||||
return false
|
||||
})
|
||||
resolve([...this.visitedViews])
|
||||
})
|
||||
},
|
||||
delLeftTags(view) {
|
||||
return new Promise(resolve => {
|
||||
const index = this.visitedViews.findIndex(v => v.path === view.path)
|
||||
if (index === -1) {
|
||||
return
|
||||
}
|
||||
this.visitedViews = this.visitedViews.filter((item, idx) => {
|
||||
if (idx >= index || (item.meta && item.meta.affix)) {
|
||||
return true
|
||||
}
|
||||
const i = this.cachedViews.indexOf(item.name)
|
||||
if (i > -1) {
|
||||
this.cachedViews.splice(i, 1)
|
||||
}
|
||||
if(item.meta.link) {
|
||||
const fi = this.iframeViews.findIndex(v => v.path === item.path)
|
||||
this.iframeViews.splice(fi, 1)
|
||||
}
|
||||
return false
|
||||
})
|
||||
resolve([...this.visitedViews])
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export default useTagsViewStore
|
||||
@@ -1,10 +1,9 @@
|
||||
|
||||
import { login, logout, getInfo } from '@/api/login'
|
||||
import { getToken, setToken, removeToken } from '@/utils/auth'
|
||||
import { login, logout, getInfo } from '@/api/login.js'
|
||||
import { getToken, setToken, removeToken } from '@/utils/auth.js'
|
||||
import { defineStore } from 'pinia'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const useUserStore = defineStore(
|
||||
export const useUserStore = defineStore(
|
||||
'user',
|
||||
{
|
||||
state: () => ({
|
||||
@@ -26,6 +25,7 @@ const useUserStore = defineStore(
|
||||
return Promise.reject(res);
|
||||
}
|
||||
const { token, user } = res.data;
|
||||
this.name = user.name;
|
||||
sessionStorage.setItem('userData', JSON.stringify(user));
|
||||
setToken(token);
|
||||
this.token = token;
|
||||
@@ -58,9 +58,14 @@ const useUserStore = defineStore(
|
||||
// 退出系统
|
||||
async logOut() {
|
||||
try {
|
||||
await logout();
|
||||
const res = await logout();
|
||||
if(res.meta.code !== 200){
|
||||
ElMessage({ message: res.meta?.message || '退出登录失败', type: 'error' });
|
||||
return
|
||||
}
|
||||
this.token = '';
|
||||
this.roles = '';
|
||||
this.roles = '';
|
||||
sessionStorage.removeItem('userData');
|
||||
removeToken();
|
||||
} catch (error) {
|
||||
console.error('退出登录失败:', error);
|
||||
@@ -72,6 +77,4 @@ const useUserStore = defineStore(
|
||||
return !!getToken();
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export default useUserStore
|
||||
})
|
||||
@@ -4,14 +4,17 @@ import Cookies from "js-cookie";
|
||||
const TokenKey = "token";
|
||||
|
||||
export function getToken() {
|
||||
return Cookies.get(TokenKey);
|
||||
// return Cookies.get(TokenKey);
|
||||
return sessionStorage.getItem(TokenKey);
|
||||
}
|
||||
|
||||
export function setToken(token) {
|
||||
return Cookies.set(TokenKey, token);
|
||||
// return Cookies.set(TokenKey, token);
|
||||
return sessionStorage.setItem(TokenKey, token);
|
||||
}
|
||||
|
||||
export function removeToken() {
|
||||
return Cookies.remove(TokenKey);
|
||||
// return Cookies.remove(TokenKey);
|
||||
return sessionStorage.removeItem(TokenKey);
|
||||
}
|
||||
|
||||
|
||||
15
src/utils/dynamicTitle.js
Normal file
15
src/utils/dynamicTitle.js
Normal file
@@ -0,0 +1,15 @@
|
||||
// import store from '@/stores/index.js'
|
||||
import defaultSettings from '@/settings'
|
||||
import { useSettingsStore } from '@/stores/modules/settings.js'
|
||||
|
||||
/**
|
||||
* 动态修改标题
|
||||
*/
|
||||
export function useDynamicTitle() {
|
||||
const settingsStore = useSettingsStore();
|
||||
if (settingsStore.dynamicTitle) {
|
||||
document.title = settingsStore.title + ' - ' + defaultSettings.title;
|
||||
} else {
|
||||
document.title = defaultSettings.title;
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,9 @@ import { tansParams } from "@/utils/ruoyi";
|
||||
import cache from "@/plugins/cache";
|
||||
import { getToken, removeToken } from "@/utils/auth";
|
||||
import router from '@/router';
|
||||
import { useMeterStore } from '@/stores/modules/meter'
|
||||
import { useMeterStore } from '@/stores/modules/meter.js'
|
||||
|
||||
axios.defaults.headers["Content-Type"] = "application/json;charset=utf-8";
|
||||
const meterStore = useMeterStore()
|
||||
meterStore.initUdid()
|
||||
// 创建axios实例
|
||||
const service = axios.create({
|
||||
// axios中请求配置有baseURL选项,表示请求URL公共部分
|
||||
@@ -24,7 +22,20 @@ const service = axios.create({
|
||||
// request拦截器
|
||||
service.interceptors.request.use(
|
||||
(config) => {
|
||||
|
||||
// 在拦截器内部安全地使用 store
|
||||
let sudid = ''
|
||||
try {
|
||||
const meterStore = useMeterStore()
|
||||
if (!meterStore.udid) {
|
||||
meterStore.initUdid();
|
||||
sudid = meterStore.getSudid();
|
||||
} else {
|
||||
sudid = meterStore.getSudid();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('MeterStore 初始化失败:', error);
|
||||
}
|
||||
|
||||
// 是否需要设置 token
|
||||
const isToken = (config.headers || {}).isToken === false;
|
||||
if (getToken() && !isToken) {
|
||||
@@ -33,8 +44,8 @@ service.interceptors.request.use(
|
||||
|
||||
// 是否需要防止数据重复提交
|
||||
const isRepeatSubmit = (config.headers || {}).repeatSubmit === false;
|
||||
if (meterStore.getSudid()) {
|
||||
config.headers["X-User-Agent"] = `gxtech/web 1.0.0: c=GxTech, udid=${meterStore.getSudid()}, sv=15.4.1, app=stt`;
|
||||
if (sudid) {
|
||||
config.headers["X-User-Agent"] = `gxtech/web 1.0.0: c=GxTech, udid=${sudid}, sv=15.4.1, app=stt`;
|
||||
}
|
||||
// get请求映射params参数
|
||||
if (config.method === "get" && config.params) {
|
||||
|
||||
49
src/utils/theme.js
Normal file
49
src/utils/theme.js
Normal file
@@ -0,0 +1,49 @@
|
||||
// 处理主题样式
|
||||
export function handleThemeStyle(theme) {
|
||||
document.documentElement.style.setProperty('--el-color-primary', theme)
|
||||
for (let i = 1; i <= 9; i++) {
|
||||
document.documentElement.style.setProperty(`--el-color-primary-light-${i}`, `${getLightColor(theme, i / 10)}`)
|
||||
}
|
||||
for (let i = 1; i <= 9; i++) {
|
||||
document.documentElement.style.setProperty(`--el-color-primary-dark-${i}`, `${getDarkColor(theme, i / 10)}`)
|
||||
}
|
||||
}
|
||||
|
||||
// hex颜色转rgb颜色
|
||||
export function hexToRgb(str) {
|
||||
str = str.replace('#', '')
|
||||
let hexs = str.match(/../g)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
hexs[i] = parseInt(hexs[i], 16)
|
||||
}
|
||||
return hexs
|
||||
}
|
||||
|
||||
// rgb颜色转Hex颜色
|
||||
export function rgbToHex(r, g, b) {
|
||||
let hexs = [r.toString(16), g.toString(16), b.toString(16)]
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (hexs[i].length == 1) {
|
||||
hexs[i] = `0${hexs[i]}`
|
||||
}
|
||||
}
|
||||
return `#${hexs.join('')}`
|
||||
}
|
||||
|
||||
// 变浅颜色值
|
||||
export function getLightColor(color, level) {
|
||||
let rgb = hexToRgb(color)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
rgb[i] = Math.floor((255 - rgb[i]) * level + rgb[i])
|
||||
}
|
||||
return rgbToHex(rgb[0], rgb[1], rgb[2])
|
||||
}
|
||||
|
||||
// 变深颜色值
|
||||
export function getDarkColor(color, level) {
|
||||
let rgb = hexToRgb(color)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
rgb[i] = Math.floor(rgb[i] * (1 - level))
|
||||
}
|
||||
return rgbToHex(rgb[0], rgb[1], rgb[2])
|
||||
}
|
||||
93
src/utils/validate.js
Normal file
93
src/utils/validate.js
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 判断url是否是http或https
|
||||
* @param {string} path
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
export function isHttp(url) {
|
||||
return url.indexOf('http://') !== -1 || url.indexOf('https://') !== -1
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断path是否为外链
|
||||
* @param {string} path
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
export function isExternal(path) {
|
||||
return /^(https?:|mailto:|tel:)/.test(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} str
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
export function validUsername(str) {
|
||||
const valid_map = ['admin', 'editor']
|
||||
return valid_map.indexOf(str.trim()) >= 0
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
export function validURL(url) {
|
||||
const reg = /^(https?|ftp):\/\/([a-zA-Z0-9.-]+(:[a-zA-Z0-9.&%$-]+)*@)*((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}|([a-zA-Z0-9-]+\.)*[a-zA-Z0-9-]+\.(com|edu|gov|int|mil|net|org|biz|arpa|info|name|pro|aero|coop|museum|[a-zA-Z]{2}))(:[0-9]+)*(\/($|[a-zA-Z0-9.,?'\\+&%$#=~_-]+))*$/
|
||||
return reg.test(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} str
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
export function validLowerCase(str) {
|
||||
const reg = /^[a-z]+$/
|
||||
return reg.test(str)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} str
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
export function validUpperCase(str) {
|
||||
const reg = /^[A-Z]+$/
|
||||
return reg.test(str)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} str
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
export function validAlphabets(str) {
|
||||
const reg = /^[A-Za-z]+$/
|
||||
return reg.test(str)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} email
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
export function validEmail(email) {
|
||||
const reg = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
|
||||
return reg.test(email)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} str
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
export function isString(str) {
|
||||
if (typeof str === 'string' || str instanceof String) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array} arg
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
export function isArray(arg) {
|
||||
if (typeof Array.isArray === 'undefined') {
|
||||
return Object.prototype.toString.call(arg) === '[object Array]'
|
||||
}
|
||||
return Array.isArray(arg)
|
||||
}
|
||||
316
src/views/conferencingRoom/components/InviterJoinRoom/index.vue
Normal file
316
src/views/conferencingRoom/components/InviterJoinRoom/index.vue
Normal 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>
|
||||
@@ -121,8 +121,9 @@
|
||||
<audio
|
||||
:ref="el => setParticipantAudioRef(el, participant.identity)"
|
||||
autoplay
|
||||
class="participant-audio">
|
||||
</audio>
|
||||
class="participant-audio"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -131,54 +132,107 @@
|
||||
<!-- 固定在底部的控制按钮 -->
|
||||
<div class="fixed-controls">
|
||||
<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>
|
||||
{{ cameraEnabled ? '关闭摄像头' : '开启摄像头' }}
|
||||
</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>
|
||||
{{ microphoneEnabled ? '关闭麦克风' : '开启麦克风' }}
|
||||
</el-button>
|
||||
<!-- <el-button @click="toggleScreenShare" :type="isScreenSharing ? 'danger' : 'primary'" class="control-btn" size="large">
|
||||
<i class="el-icon-monitor"></i>
|
||||
{{ isScreenSharing ? '停止共享' : '共享屏幕' }}
|
||||
</el-button> -->
|
||||
|
||||
<div class="microphone-control-group">
|
||||
<el-button @click="toggleCamera" :type="cameraEnabled ? 'danger' : 'info'" class="control-btn microphone-btn" size="large">
|
||||
{{ cameraEnabled ? '关闭摄像头' : '开启摄像头' }}
|
||||
</el-button>
|
||||
<!-- 摄像头选择下拉菜单 -->
|
||||
<el-dropdown trigger="click" @command="handleCameraCommand" @visible-change="handleCameraVisibleChange" class="control-dropdown microphone-dropdown">
|
||||
<el-button :type="cameraEnabled ? 'danger' : 'info'" class="control-btn dropdown-btn" size="large">
|
||||
<el-icon class="el-icon--right"><arrow-down /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
v-for="device in cameraDevices"
|
||||
:key="device.deviceId"
|
||||
:command="device.deviceId"
|
||||
:class="{ 'selected-device': selectedCameraId === device.deviceId }"
|
||||
>
|
||||
<i class="el-icon-video-camera"></i>
|
||||
{{ device.label || `摄像头 ${cameraDevices.indexOf(device) + 1}` }}
|
||||
<el-icon v-if="selectedCameraId === device.deviceId" class="check-icon"><check /></el-icon>
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item divided command="refresh">
|
||||
<el-icon><refresh /></el-icon>
|
||||
刷新设备列表
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
<div class="microphone-control-group">
|
||||
<el-button @click="toggleMicrophone" :type="microphoneEnabled ? 'danger' : 'info'" class="control-btn microphone-btn" size="large">
|
||||
{{ microphoneEnabled ? '关闭麦克风' : '开启麦克风' }}
|
||||
</el-button>
|
||||
<!-- 麦克风选择下拉菜单 -->
|
||||
<el-dropdown trigger="click" @command="handleMicrophoneCommand" @visible-change="handleMicrophoneVisibleChange" class="control-dropdown microphone-dropdown">
|
||||
<el-button :type="microphoneEnabled ? 'danger' : 'info'" class="control-btn dropdown-btn" size="large">
|
||||
<el-icon class="el-icon--right"><arrow-down /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
v-for="device in microphoneDevices"
|
||||
:key="device.deviceId"
|
||||
:command="device.deviceId"
|
||||
:class="{ 'selected-device': selectedMicrophoneId === device.deviceId }"
|
||||
>
|
||||
<i class="el-icon-microphone"></i>
|
||||
{{ device.label || `麦克风 ${microphoneDevices.indexOf(device) + 1}` }}
|
||||
<el-icon v-if="selectedMicrophoneId === device.deviceId" class="check-icon"><check /></el-icon>
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item divided command="refresh">
|
||||
<el-icon><refresh /></el-icon>
|
||||
刷新设备列表
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
|
||||
|
||||
<el-button
|
||||
@click="toggleScreenShare"
|
||||
:type="isScreenSharing ? 'danger' : (isGlobalScreenSharing ? 'info' : 'primary')"
|
||||
:type="isScreenSharing ? 'danger' : (isGlobalScreenSharing ? 'primary' : 'info')"
|
||||
:disabled="isGlobalScreenSharing && !isScreenSharing"
|
||||
class="control-btn"
|
||||
size="large"
|
||||
>
|
||||
<i class="el-icon-monitor"></i>
|
||||
>
|
||||
<span v-if="isScreenSharing">停止共享</span>
|
||||
<span v-else-if="isGlobalScreenSharing">他人共享中</span>
|
||||
<span v-else>共享屏幕</span>
|
||||
</el-button>
|
||||
<el-button @click="toggleWhiteboard" :type="isWhiteboardActive ? 'danger' : 'info'" class="control-btn leave-btn" size="large">
|
||||
<i class="el-icon-switch-button"></i>
|
||||
<el-button @click="toggleWhiteboard" :type="isWhiteboardActive ? 'danger' : 'info'" class="control-btn" size="large">
|
||||
{{ isWhiteboardActive ? '退出白板' : '共享白板' }}
|
||||
</el-button>
|
||||
<el-button @click="leaveRoom" type="warning" class="control-btn leave-btn" size="large">
|
||||
<i class="el-icon-switch-button"></i>
|
||||
<el-button @click="inviterJoinRoom" type="info" class="control-btn" size="large">
|
||||
邀请人员
|
||||
</el-button>
|
||||
<el-button @click="leaveRoom" type="info" class="control-btn" size="large">
|
||||
离开会议
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 邀请人员组件 -->
|
||||
<InviterJoinRoom ref="inviterJoinRoomRef" @confirmSelection="handleConfirmSelection" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, onMounted, onUnmounted, nextTick, computed, watch } from "vue";
|
||||
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 { useRoute, useRouter } from 'vue-router'
|
||||
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 { mqttClient } from "@/utils/mqtt.js";
|
||||
import InviterJoinRoom from "@/views/conferencingRoom/components/InviterJoinRoom/index.vue"
|
||||
|
||||
const userStore = useUserStore()
|
||||
const roomStore = useRoomStore()
|
||||
@@ -201,6 +255,12 @@ const microphoneEnabled = ref(false);
|
||||
const isScreenSharing = ref(false);
|
||||
const isLocalSpeaking = ref(false);
|
||||
|
||||
// 设备列表
|
||||
const cameraDevices = ref([]);
|
||||
const microphoneDevices = ref([]);
|
||||
const selectedCameraId = ref('');
|
||||
const selectedMicrophoneId = ref('');
|
||||
|
||||
// 远程参与者管理
|
||||
const remoteParticipants = ref(new Map());
|
||||
const videoElementsMap = ref(new Map());
|
||||
@@ -210,13 +270,19 @@ const audioElementsMap = ref(new Map());
|
||||
const screenSharingUser = ref('');
|
||||
const activeScreenShareTrack = ref(null);
|
||||
const screenShareVideo = ref(null);
|
||||
const globalScreenSharingUser = ref(''); // 当前正在共享屏幕的用户
|
||||
const isGlobalScreenSharing = ref(false); // 是否有用户正在共享屏幕
|
||||
|
||||
//共享白板
|
||||
const isWhiteboardActive = ref(false);
|
||||
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 = {
|
||||
@@ -261,7 +327,7 @@ const room = new Room({
|
||||
autoGainControl: true,
|
||||
},
|
||||
videoCaptureDefaults: {
|
||||
resolution: { width: 1280, height: 720 }
|
||||
resolution: { width: 1280, height: 720 },
|
||||
},
|
||||
publishDefaults: {
|
||||
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连接
|
||||
async function initMqtt() {
|
||||
try {
|
||||
@@ -309,8 +577,7 @@ function subscribeToWhiteboardTopic() {
|
||||
function handleWhiteboardMessage(payload, topic) {
|
||||
try {
|
||||
const messageStr = payload.toString();
|
||||
const data = JSON.parse(messageStr);
|
||||
console.log('收到白板消息:', data);
|
||||
const data = JSON.parse(messageStr);
|
||||
// 只处理当前房间的消息
|
||||
if (data.roomId !== room.name) return;
|
||||
// 忽略自己发送的消息
|
||||
@@ -350,9 +617,10 @@ function handleRemoteWhiteboardOpen(data) {
|
||||
|
||||
// 处理远程关闭白板
|
||||
function handleRemoteWhiteboardClose(data) {
|
||||
console.log('关闭白板')
|
||||
ElMessage.info(`${data.senderName || data.sender} 关闭了白板`);
|
||||
console.log('data',data)
|
||||
if(data.type == '1'){
|
||||
if(data.roomType == '1'){
|
||||
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 = {}) {
|
||||
try {
|
||||
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() {
|
||||
if(hasActiveScreenShare.value){
|
||||
ElMessage.error('请先关闭屏幕共享');
|
||||
@@ -530,7 +807,6 @@ function setScreenShareVideoRef(el) {
|
||||
// 设置事件监听器
|
||||
function setupRoomListeners() {
|
||||
room.removeAllListeners();
|
||||
|
||||
room
|
||||
.on(RoomEvent.Connected, handleConnected)
|
||||
.on(RoomEvent.Disconnected, handleDisconnected)
|
||||
@@ -551,7 +827,9 @@ function setupRoomListeners() {
|
||||
// 事件处理函数
|
||||
async function handleConnected() {
|
||||
await initMqtt();
|
||||
roomId.value = room.name
|
||||
status.value = false;
|
||||
|
||||
ElMessage.success('已成功连接到房间');
|
||||
// 初始化现有远程参与者
|
||||
room.remoteParticipants.forEach(participant => {
|
||||
@@ -560,14 +838,6 @@ async function handleConnected() {
|
||||
// 立即检查并更新参与者的轨道状态
|
||||
updateParticipantTracks(participant);
|
||||
});
|
||||
|
||||
// 自动开启麦克风
|
||||
// try {
|
||||
// await enableMicrophone();
|
||||
// ElMessage.success('麦克风已自动开启');
|
||||
// } catch (error) {
|
||||
// console.warn('自动开启麦克风失败:', error);
|
||||
// }
|
||||
}
|
||||
|
||||
function handleDisconnected(reason) {
|
||||
@@ -618,7 +888,6 @@ function handleTrackSubscribed(track, publication, participant) {
|
||||
updateParticipantTracks(participant);
|
||||
}
|
||||
|
||||
|
||||
function handleTrackUnsubscribed(track, publication, participant) {
|
||||
// 移除对应的轨道信息
|
||||
if (track.kind === Track.Kind.Video) {
|
||||
@@ -766,6 +1035,7 @@ function handleActiveSpeakersChanged(speakers) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 处理数据接收事件
|
||||
function handleDataReceived(payload, participant, kind) {
|
||||
try {
|
||||
@@ -780,6 +1050,7 @@ function handleDataReceived(payload, participant, kind) {
|
||||
function handleConnectionStateChanged(state) {
|
||||
console.log('连接状态改变:', state);
|
||||
}
|
||||
|
||||
// 更新屏幕共享状态
|
||||
function updateScreenShareState(participant, track) {
|
||||
if (track) {
|
||||
@@ -802,6 +1073,7 @@ function updateScreenShareState(participant, track) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 参与者管理函数
|
||||
function addRemoteParticipant(participant) {
|
||||
if (!participant || participant.identity === room.localParticipant?.identity) {
|
||||
@@ -834,6 +1106,7 @@ function removeRemoteParticipant(participant) {
|
||||
audioElementsMap.value.delete(identity);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新参与者轨道信息
|
||||
function updateParticipantTrack(participant, source, track) {
|
||||
const data = remoteParticipants.value.get(participant.identity);
|
||||
@@ -877,6 +1150,7 @@ function removeParticipantAudioTrack(participant) {
|
||||
data.audioEnabled = false;
|
||||
remoteParticipants.value.set(participant.identity, { ...data });
|
||||
}
|
||||
|
||||
// 附加轨道到视频元素
|
||||
function attachTrackToVideo(videoElement, track) {
|
||||
if (!videoElement || !track) return;
|
||||
@@ -889,6 +1163,7 @@ function attachTrackToVideo(videoElement, track) {
|
||||
console.error('附加轨道到视频元素失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 附加轨道到音频元素
|
||||
function attachTrackToAudio(audioElement, track) {
|
||||
if (!audioElement || !track) return;
|
||||
@@ -904,6 +1179,7 @@ function attachTrackToAudio(audioElement, track) {
|
||||
console.error('附加轨道到音频元素失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 附加轨道到参与者的视频元素
|
||||
function attachTrackToParticipantVideo(identity, source, track) {
|
||||
const videoElements = videoElementsMap.value.get(identity);
|
||||
@@ -914,6 +1190,7 @@ function attachTrackToParticipantVideo(identity, source, track) {
|
||||
attachTrackToVideo(videoElement, track);
|
||||
}
|
||||
}
|
||||
|
||||
// 附加轨道到参与者的音频元素
|
||||
function attachTrackToParticipantAudio(identity, track) {
|
||||
const audioElement = audioElementsMap.value.get(identity);
|
||||
@@ -921,6 +1198,7 @@ function attachTrackToParticipantAudio(identity, track) {
|
||||
attachTrackToAudio(audioElement, track);
|
||||
}
|
||||
}
|
||||
|
||||
// 从参与者的视频元素分离轨道
|
||||
function detachTrackFromParticipantVideo(identity, source) {
|
||||
const videoElements = videoElementsMap.value.get(identity);
|
||||
@@ -931,6 +1209,7 @@ function detachTrackFromParticipantVideo(identity, source) {
|
||||
videoElement.srcObject = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 从参与者的音频元素分离轨道
|
||||
function detachTrackFromParticipantAudio(identity) {
|
||||
const audioElement = audioElementsMap.value.get(identity);
|
||||
@@ -997,6 +1276,7 @@ function handleVideoLoaded(identity, type) {
|
||||
function handleScreenShareLoaded() {
|
||||
console.log('屏幕共享视频加载完成');
|
||||
}
|
||||
|
||||
// 视频轨道处理函数
|
||||
function attachLocalVideoTrack(track) {
|
||||
if (localVideo.value && track) {
|
||||
@@ -1010,6 +1290,7 @@ function attachLocalVideoTrack(track) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 媒体控制函数
|
||||
async function enableCamera() {
|
||||
try {
|
||||
@@ -1046,21 +1327,50 @@ async function toggleCamera() {
|
||||
localVideo.value.srcObject.getTracks().forEach(track => track.stop());
|
||||
localVideo.value.srcObject = null;
|
||||
}
|
||||
// 清空选中的视屏设备ID
|
||||
selectedCameraId.value = '';
|
||||
ElMessage.info('摄像头已关闭');
|
||||
} else {
|
||||
// 确保视频元素存在
|
||||
if (!localVideo.value) {
|
||||
console.warn('本地视频元素未找到,等待DOM更新');
|
||||
await nextTick();
|
||||
}
|
||||
}
|
||||
|
||||
// 开启摄像头
|
||||
await room.localParticipant.setCameraEnabled(true);
|
||||
cameraEnabled.value = true;
|
||||
// 确保有视屏输入设备权限和设备列表
|
||||
if (cameraDevices.value.length === 0) {
|
||||
// 如果没有设备列表,先获取
|
||||
await handleCameraVisibleChange(true);
|
||||
}
|
||||
if (cameraDevices.value.length === 0) {
|
||||
ElMessage.error('未找到可用的摄像头设备');
|
||||
return;
|
||||
}
|
||||
|
||||
// 自动选择第一个可用设备(如果当前没有选中设备)
|
||||
let deviceToUse = selectedCameraId.value;
|
||||
if (!deviceToUse && cameraDevices.value.length > 0) {
|
||||
deviceToUse = cameraDevices.value[0].deviceId;
|
||||
selectedCameraId.value = deviceToUse;
|
||||
}
|
||||
|
||||
if (deviceToUse) {
|
||||
await enableCameraWithDevice(deviceToUse);
|
||||
ElMessage.success(`摄像头已开启 - ${getDeviceName(cameraDevices.value, deviceToUse)}`);
|
||||
} else {
|
||||
// 使用默认方式开启
|
||||
await room.localParticipant.setMicrophoneEnabled(true);
|
||||
microphoneEnabled.value = true;
|
||||
// 手动获取并附加视频轨道
|
||||
setTimeout(() => {
|
||||
attachLocalCameraTrack();
|
||||
}, 200);
|
||||
ElMessage.success('麦克风已开启');
|
||||
}
|
||||
|
||||
|
||||
ElMessage.success('摄像头已开启');
|
||||
// 手动获取并附加视频轨道,增加延迟
|
||||
setTimeout(() => {
|
||||
attachLocalCameraTrack();
|
||||
}, 200);
|
||||
}
|
||||
} catch (error) {
|
||||
errorHandling(error,'摄像头');
|
||||
@@ -1101,61 +1411,81 @@ async function toggleMicrophone() {
|
||||
try {
|
||||
if (microphoneEnabled.value) {
|
||||
await room.localParticipant.setMicrophoneEnabled(false);
|
||||
microphoneEnabled.value = false;
|
||||
microphoneEnabled.value = false;
|
||||
// 停止所有音频轨道
|
||||
const audioPublications = Array.from(room.localParticipant.audioTrackPublications.values());
|
||||
for (const publication of audioPublications) {
|
||||
if (publication.track) {
|
||||
publication.track.stop(); // 停止轨道
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
// 清空选中的麦克风设备ID
|
||||
selectedMicrophoneId.value = '';
|
||||
ElMessage.info('麦克风已关闭');
|
||||
} else {
|
||||
// 确保有音频输入设备权限
|
||||
try {
|
||||
// 先检查麦克风权限
|
||||
await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
} catch (error) {
|
||||
ElMessage.error('无法访问麦克风,请检查权限设置');
|
||||
return;
|
||||
// 开启麦克风
|
||||
// 确保有音频输入设备权限和设备列表
|
||||
if (microphoneDevices.value.length === 0) {
|
||||
// 如果没有设备列表,先获取
|
||||
await handleMicrophoneVisibleChange(true);
|
||||
}
|
||||
|
||||
await room.localParticipant.setMicrophoneEnabled(true);
|
||||
microphoneEnabled.value = true;
|
||||
ElMessage.success('麦克风已开启');
|
||||
if (microphoneDevices.value.length === 0) {
|
||||
ElMessage.error('未找到可用的麦克风设备');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// 等待音频轨道发布
|
||||
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);
|
||||
// 自动选择第一个可用设备(如果当前没有选中设备)
|
||||
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);
|
||||
microphoneEnabled.value = true;
|
||||
ElMessage.success('麦克风已开启');
|
||||
}
|
||||
|
||||
ElMessage.success('麦克风已开启');
|
||||
}
|
||||
} catch (error) {
|
||||
errorHandling(error,'麦克风');
|
||||
errorHandling(error, '麦克风');
|
||||
// 如果开启失败,确保状态正确
|
||||
if (!microphoneEnabled.value) {
|
||||
selectedMicrophoneId.value = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function errorHandling(error,type) {
|
||||
switch (error.name) {
|
||||
case 'NotAllowedError':
|
||||
ElMessage.error('用户拒绝了权限请求');
|
||||
ElMessage.error('用户拒绝了权限请求,请允许此网站使用摄像头');
|
||||
break;
|
||||
case 'NotFoundError':
|
||||
ElMessage.error(`未找到${type}设备`);
|
||||
ElMessage.error(`未检测到可用的${type}设备,请检查${type}是否已正确连接`);
|
||||
break;
|
||||
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;
|
||||
default:
|
||||
ElMessage.error('服务错误,请刷新重试');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function toggleScreenShare() {
|
||||
try {
|
||||
if(isWhiteboardActive.value){
|
||||
@@ -1241,7 +1571,7 @@ async function leaveRoom() {
|
||||
// 断开MQTT连接
|
||||
mqttClient.disconnect();
|
||||
|
||||
const res = await exitRoomApi(room.name)
|
||||
// const res = await exitRoomApi(room.name)
|
||||
// 停止屏幕共享(如果正在共享)
|
||||
if (isScreenSharing.value) {
|
||||
await room.localParticipant.setScreenShareEnabled(false);
|
||||
@@ -1250,7 +1580,10 @@ async function leaveRoom() {
|
||||
// 关闭摄像头和麦克风
|
||||
await room.localParticipant.setCameraEnabled(false);
|
||||
await room.localParticipant.setMicrophoneEnabled(false);
|
||||
|
||||
microphoneEnabled.value = false;
|
||||
cameraEnabled.value = false;
|
||||
selectedMicrophoneId.value = '';
|
||||
selectedCameraId.value = ''
|
||||
// 断开与房间的连接
|
||||
await room.disconnect();
|
||||
|
||||
@@ -1279,7 +1612,11 @@ function resetRoomState() {
|
||||
isScreenSharing.value = false;
|
||||
isLocalSpeaking.value = false;
|
||||
status.value = true;
|
||||
hostUid.value = '';
|
||||
hostUid.value = '';
|
||||
selectedMicrophoneId.value = '';
|
||||
selectedCameraId.value = ''
|
||||
microphoneDevices.value = [];
|
||||
cameraDevices.value = [];
|
||||
|
||||
// 断开MQTT连接
|
||||
mqttClient.disconnect();
|
||||
@@ -1343,24 +1680,12 @@ onUnmounted(() => {
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
// 确保在连接前请求音频权限
|
||||
// try {
|
||||
// // 预请求音频权限
|
||||
// await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
// console.log('音频权限已获取');
|
||||
// } catch (error) {
|
||||
// console.warn('音频权限获取失败:', error);
|
||||
// ElMessage.warning('请允许麦克风权限以使用音频功能');
|
||||
// }
|
||||
|
||||
if(route.query.type == '1'){
|
||||
await joinRoomBtn()
|
||||
hostUid.value = roomStore.userUid
|
||||
// 邀请用户参与房间
|
||||
await getInvite(room.name,{user_uids:[roomStore.detailUid], participant_role: "participant"})
|
||||
} else {
|
||||
// const userInfo = await userStore.getInfo()
|
||||
// hostUid.value = userInfo.uid
|
||||
const res = await getTokenApi(route.query.room_uid)
|
||||
if(res.meta.code == 200){
|
||||
const token = res.data.access_token;
|
||||
@@ -1376,9 +1701,10 @@ onMounted(async () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style>
|
||||
<style lang="scss" scoped>
|
||||
/* 白板容器样式 */
|
||||
.whiteboard-container {
|
||||
width: 100%;
|
||||
@@ -1429,6 +1755,37 @@ onMounted(async () => {
|
||||
.audio-element, .participant-audio {
|
||||
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;
|
||||
@@ -1857,8 +2214,86 @@ body {
|
||||
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) {
|
||||
.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 {
|
||||
height: 300px; /* 在移动端限制白板高度 */
|
||||
}
|
||||
@@ -1919,6 +2354,19 @@ body {
|
||||
|
||||
/* 超小屏幕调整 */
|
||||
@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 {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1721
src/views/conferencingRoom/text.vue
Normal file
1721
src/views/conferencingRoom/text.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- v-loading="leftListLoading || loading" -->
|
||||
<div class="left-list" >
|
||||
|
||||
<div class="left-list" v-loading="leftListLoading || loading">
|
||||
<div class="list-tab">
|
||||
<div
|
||||
:class="'list-tab-item ' + (leftTab == 1 ? 'pitch-on' : '')"
|
||||
@@ -314,7 +314,7 @@ const HandleLoadNode = async (node, resolve) => {
|
||||
|
||||
const loadNode = async(resolve,id)=>{
|
||||
try {
|
||||
// state.leftListLoading = true
|
||||
state.leftListLoading = true
|
||||
if(!id){
|
||||
let res = await getDirectories({level:1})
|
||||
if(res.meta.code == 401){
|
||||
@@ -328,10 +328,10 @@ const loadNode = async(resolve,id)=>{
|
||||
let res = await getDirectoriesUsers(id,{directory_uuid:id})
|
||||
resolve(res.data)
|
||||
}
|
||||
// state.leftListLoading = false
|
||||
state.leftListLoading = false
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
// state.leftListLoading = false
|
||||
state.leftListLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -157,7 +157,8 @@
|
||||
<div
|
||||
class="message-user"
|
||||
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="user-card-nickName">
|
||||
@@ -274,7 +275,7 @@ import { getStatusApi } from '@/api/conferencingRoom.js'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useRoomStore } from '@/stores/modules/room'
|
||||
import useUserStore from '@/stores/modules/user'
|
||||
import { useUserStore } from '@/stores/modules/user.js'
|
||||
import { mqttClient } from "@/utils/mqtt.js";
|
||||
import { getToken } from '@/utils/auth.js'
|
||||
import Login from "@/components/Login/index.vue";
|
||||
@@ -282,7 +283,7 @@ const roomStore = useRoomStore()
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
const { proxy } = getCurrentInstance()
|
||||
const state = reactive({
|
||||
const state = reactive({
|
||||
detail: {},
|
||||
weekName: ['周日', '周一', '周二', '周三', '周四', '周五', '周六'],
|
||||
tabValue: 2,
|
||||
@@ -295,6 +296,8 @@ const state = reactive({
|
||||
cooperation: import.meta.env.VITE_APP_COOPERATION_TYPE,
|
||||
})
|
||||
const showLogin = ref(false); // 是否显示登录页面
|
||||
const userLoading = ref(false); // 用户信息加载状态
|
||||
|
||||
|
||||
function showLoginHandle(e){
|
||||
showLogin.value = e;
|
||||
@@ -350,23 +353,26 @@ const updateTab = (newValue) => {
|
||||
|
||||
/** 修改展示区内容 */
|
||||
const updateDetail = async (details) => {
|
||||
if (details) {
|
||||
console.log(details,'details')
|
||||
userLoading.value = true
|
||||
if (details) {
|
||||
state.detail = {}
|
||||
if (state.tabValue == 1) {
|
||||
state.isShow = true
|
||||
} else {
|
||||
const res = await getInfo(details.uid)
|
||||
state.detail = res.data
|
||||
userLoading.value = false
|
||||
// getInfo(details.uid)
|
||||
// .then((res) => {
|
||||
// console.log(res,'人员详细信息')
|
||||
// })
|
||||
// .finally(() => { state.isShow = true })
|
||||
|
||||
}
|
||||
} else {
|
||||
state.isShow = false
|
||||
state.isShow = false
|
||||
}
|
||||
userLoading.value = false
|
||||
}
|
||||
|
||||
/** 获取通话时长 */
|
||||
@@ -420,12 +426,18 @@ const clickRefuseJoin = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 处理 mqtt 消息 */
|
||||
const processingSocket = (message) => {
|
||||
if (message) {
|
||||
state.socketInformation = JSON.parse(message)
|
||||
/** 处理加入房间和拒接房间 mqtt 消息 */
|
||||
const processingSocket = (message) => {
|
||||
const res = JSON.parse(message)
|
||||
if (!res?.status) {
|
||||
state.socketInformation = res
|
||||
state.inviteDialog = true
|
||||
showNotification(state.socketInformation)
|
||||
}else if(res.status == 5){
|
||||
ElMessage({
|
||||
message: `${res?.display_name}拒绝加入该协作`,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -458,6 +470,7 @@ onMounted(async () => {
|
||||
const res = await userStore.getInfo()
|
||||
const topic = `xSynergy/ROOM/+/rooms/${res.uid}`;
|
||||
mqttClient.subscribe(topic, async (shapeData) => {
|
||||
console.log(shapeData.toString(),'shapeData发送邀请')
|
||||
processingSocket(shapeData.toString())
|
||||
});
|
||||
})
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
<div v-else>
|
||||
<!-- 未加入时显示按钮 -->
|
||||
<div v-if="!hasJoined" class="login-button-container">
|
||||
<el-button type="primary" size="large" round plain @click="joinWhiteboard">
|
||||
加入互动画板
|
||||
<el-button type="primary" size="large" link @click="joinWhiteboard">
|
||||
正在进入互动画板。。。
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
@@ -113,6 +113,7 @@ onMounted(async () => {
|
||||
} catch (err) {
|
||||
console.warn("⚠️ 用户信息校验失败:", err);
|
||||
}
|
||||
joinWhiteboard()
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -144,6 +145,7 @@ onUnmounted(() => {
|
||||
.whiteboard-wrapper {
|
||||
position: relative;
|
||||
width: 72vw;
|
||||
height: 69vh; /* 或者适当的高度 */
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -162,7 +164,7 @@ onUnmounted(() => {
|
||||
.toolbox {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 10px;
|
||||
left: 2vw;
|
||||
transform: translateY(-50%);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
@@ -37,14 +37,14 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import useUserStore from '@/stores/modules/user'
|
||||
import { useUserStore } from '@/stores/modules/user.js'
|
||||
import { watch, ref, getCurrentInstance, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElNotification,ElMessage } from 'element-plus'
|
||||
import { getInfo } from "@/api/login";
|
||||
import CryptoJS from 'crypto-js';
|
||||
import { useMeterStore } from '@/stores/modules/meter'
|
||||
|
||||
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -92,7 +92,6 @@ function handleLogin() {
|
||||
const message = `Gx${randomChars}${loginForm.value.password}`;
|
||||
|
||||
const ciphertext = CryptoJS.Blowfish.encrypt(message, secretKey).toString();
|
||||
console.log(secretKey,message,ciphertext)
|
||||
// 调用action的登录方法
|
||||
userStore
|
||||
.login({
|
||||
|
||||
Reference in New Issue
Block a user