Files
yunzerwebsiteallinone/backend/src/views/Main.vue
T
2026-08-19 08:53:50 +08:00

885 lines
27 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import CommonAside from '@/components/CommonAside.vue';
import CommonHeader from '@/components/CommonHeader.vue';
import { useTabsStore } from '@/stores';
import { useRouter, useRoute } from 'vue-router';
import { ref, watch, reactive, nextTick, onMounted, onBeforeUnmount, computed } from 'vue';
import { More, Close, CircleClose, ArrowUp, Fold, Expand } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
const tabsStore = useTabsStore();
const router = useRouter();
const route = useRoute();
const defaultDashboardPath = '/home';
// ========== 手机端侧栏抽屉控制 ==========
const MOBILE_QUERY = '(max-width: 768px)';
const mobileMedia = window.matchMedia(MOBILE_QUERY);
const isMobile = ref(mobileMedia.matches);
// 手机端侧栏默认隐藏,桌面端无影响(CSS 仅在媒体查询内生效)
const sidebarVisible = ref(false);
const handleMobileMediaChange = (e) => {
isMobile.value = e.matches;
// 切回桌面端时重置侧栏状态,避免残留
if (!e.matches) {
sidebarVisible.value = false;
}
};
// 兼容旧版 Safari 的 addListener 写法
if (typeof mobileMedia.addEventListener === 'function') {
mobileMedia.addEventListener('change', handleMobileMediaChange);
} else {
mobileMedia.addListener(handleMobileMediaChange);
}
onBeforeUnmount(() => {
if (typeof mobileMedia.removeEventListener === 'function') {
mobileMedia.removeEventListener('change', handleMobileMediaChange);
} else {
mobileMedia.removeListener(handleMobileMediaChange);
}
});
const toggleMobileSidebar = () => {
sidebarVisible.value = !sidebarVisible.value;
};
const closeMobileSidebar = () => {
sidebarVisible.value = false;
};
// 根据当前路由恢复 tab(刷新时使用)
function restoreTabFromRoute() {
const currentPath = route.fullPath;
const currentName = route.name;
const currentMeta = route.meta || {};
// 如果当前路径不在 tabList 中,添加它
const existTab = tabsStore.tabList.find(t => t.fullPath === currentPath);
if (!existTab) {
// 从路由 meta 获取标题,如果没有则使用路由 name 或路径
const title = currentMeta.title || currentName || '页面';
// 使用 addTab 会自动保存到 localStorage
tabsStore.addTab({
title: title,
fullPath: currentPath,
name: currentName || title,
icon: currentMeta.icon
});
} else {
// 如果已存在,只激活它(不触发路由跳转)
tabsStore.setActiveTab(currentPath);
}
}
// 组件挂载后,根据当前路由恢复 tab
onMounted(() => {
// 等待路由完全加载后再恢复
nextTick(() => {
// 刷新时,localStorage 中已保存的 tabs 会在 store 初始化时恢复
// 这里只需要确保当前路由对应的 tab 存在并激活
restoreTabFromRoute();
// 为 tabs 容器添加右键事件监听(使用事件委托)
const tabsWrapper = document.querySelector('.multi-tabs-wrapper');
if (tabsWrapper) {
tabsWrapper.addEventListener('contextmenu', (e) => {
// 排除编辑器相关区域,避免影响编辑器功能
// 如果点击在编辑器区域内,完全不做任何处理(优先检查)
const editorWrapper = e.target.closest?.('.tiptap-editor-wrapper');
if (editorWrapper) {
// 在编辑器区域内,不处理右键菜单,也不阻止事件
return;
}
// 检查其他编辑器元素(工具栏、下拉面板等)
const editorElements = [
'.tiptap-toolbar',
'.tiptap',
'[class*="tiptap-"]'
];
for (const selector of editorElements) {
if (e.target.closest && e.target.closest(selector)) {
// 在编辑器元素内,不处理右键菜单
return;
}
}
// 只有在非编辑器区域的 tab item 上才处理右键菜单
const tabItem = e.target.closest('.el-tabs__item');
if (tabItem) {
e.preventDefault();
e.stopPropagation();
handleTabsContextMenu(e);
}
});
}
});
});
// 监听路由变化,自动添加/激活对应的 tab
watch(
() => route.fullPath,
(newPath) => {
if (newPath) {
restoreTabFromRoute();
// 路由变化时自动关闭手机端侧栏
if (isMobile.value) {
sidebarVisible.value = false;
}
}
},
{ immediate: false }
);
// 1. 侧栏菜单点击:加入/激活Tab并切换路由
const handleAsideMenuClick = async (menuItem) => {
const targetPath = menuItem.path;
if (targetPath === '/home') {
tabsStore.closeAll();
router.push('/home');
return;
}
// 先添加tab
tabsStore.addTab({
title: menuItem.title,
fullPath: targetPath,
name: menuItem.title,
icon: menuItem.icon
});
// 如果当前路由已经是目标路由,不需要跳转
if (router.currentRoute.value.fullPath === targetPath) {
return;
}
// 检查路由是否存在,如果不存在等待一下再尝试
let routeExists = router.resolve(targetPath).matched.length > 0;
if (!routeExists) {
// 等待路由加载(最多等待500ms)
for (let i = 0; i < 5; i++) {
await new Promise(resolve => setTimeout(resolve, 100));
routeExists = router.resolve(targetPath).matched.length > 0;
if (routeExists) break;
}
}
// 如果路由存在,直接跳转
if (routeExists) {
router.push(targetPath).catch(err => {
if (err.name !== 'NavigationDuplicated') {
console.error('路由跳转失败:', err);
}
});
} else {
// 如果路由不存在,尝试刷新页面(最后的手段)
console.warn('路由不存在,尝试刷新页面:', targetPath);
// 不刷新页面,而是显示错误提示
ElMessage.warning(`路由 ${targetPath} 不存在,请刷新缓存后重试`);
}
};
const tabsCloseTab = (targetKey) => {
tabsStore.removeTab(targetKey);
if (route.fullPath !== tabsStore.activeTab) {
router.push(tabsStore.activeTab);
}
};
const closeOthers = () => {
tabsStore.closeOthers();
if (!tabsStore.tabList.find(tab => tab.fullPath === route.fullPath)) {
router.push(tabsStore.activeTab);
}
};
const closeAll = () => {
tabsStore.closeAll();
router.push(defaultDashboardPath);
};
// 主动监听tab激活,保证切tab时内容区切换(仅用于tab点击切换,菜单点击由handleAsideMenuClick处理)
// 使用 immediate: false 避免初始化时触发,使用 flush: 'post' 确保在 DOM 更新后执行
watch(
() => tabsStore.activeTab,
(newVal, oldVal) => {
// 如果新值和当前路由路径不同,且不是初始化(oldVal 不为 undefined),才进行跳转
// 注意:这个watch主要用于处理tab点击切换,菜单点击由handleAsideMenuClick直接处理
if (newVal && oldVal !== undefined && router.currentRoute.value.fullPath !== newVal) {
// 如果切换到首页,关闭其他所有tab
if (newVal === '/home') {
tabsStore.closeAll();
}
// 检查路由是否存在
const routeExists = router.resolve(newVal).matched.length > 0;
if (routeExists) {
// 路由存在,直接跳转
nextTick(() => {
router.push(newVal).catch(err => {
if (err.name !== 'NavigationDuplicated') {
console.error('路由跳转失败:', err);
}
});
});
} else {
// 如果路由不存在,等待一下再重试(可能是路由还在加载中)
setTimeout(() => {
const retryRouteExists = router.resolve(newVal).matched.length > 0;
if (retryRouteExists && router.currentRoute.value.fullPath !== newVal) {
router.push(newVal).catch(err => {
if (err.name !== 'NavigationDuplicated') {
console.error('路由跳转失败:', err);
}
});
}
}, 100);
}
}
},
{ flush: 'post' }
);
// ========== 右键菜单逻辑 ========== //
const contextMenu = reactive({
visible: false,
x: 0,
y: 0,
tab: null,
});
const contextDropdownRef = ref(null);
// 处理 tabs 容器的右键事件
const handleTabsContextMenu = (event) => {
event.preventDefault();
event.stopPropagation();
// 找到点击的 tab item 元素(el-tabs__item)
let target = event.target;
let tabItem = null;
// 向上查找 el-tabs__item 元素
while (target && target !== event.currentTarget) {
if (target.classList && target.classList.contains('el-tabs__item')) {
tabItem = target;
break;
}
target = target.parentElement;
}
if (!tabItem) return;
// Element Plus 的 tab item 的 id 格式通常是 "tab-{name}",其中 name 是 tab-pane 的 name 属性
const tabId = tabItem.id;
if (tabId && tabId.startsWith('tab-')) {
const tabName = tabId.replace('tab-', '');
const matchedTab = tabsStore.tabList.find(t => t.fullPath === tabName);
if (matchedTab) {
showContextMenu(event, matchedTab);
return;
}
}
// 如果通过 id 找不到,尝试通过 aria-controls 或其他属性
const ariaControls = tabItem.getAttribute('aria-controls');
if (ariaControls) {
const tabName = ariaControls.replace('pane-', '');
const matchedTab = tabsStore.tabList.find(t => t.fullPath === tabName);
if (matchedTab) {
showContextMenu(event, matchedTab);
}
}
};
// 显示右键菜单
const showContextMenu = (event, tab) => {
// 使用 nextTick 确保在下一个事件循环中更新状态,避免在渲染过程中触发更新
nextTick(() => {
contextMenu.visible = true;
contextMenu.x = event.clientX;
contextMenu.y = event.clientY;
contextMenu.tab = tab;
// 延迟添加事件监听器,确保菜单已渲染
// 关键:完全排除编辑器区域,确保编辑器的事件不被干扰
setTimeout(() => {
const hideMenuHandler = (e) => {
// 如果右键菜单已经隐藏,直接返回
if (!contextMenu.visible) {
return;
}
const target = e.target;
if (!target) {
hideContextMenu();
return;
}
// 检查点击是否在右键菜单本身上
if (target.closest?.('.context-menu')) {
return; // 点击在菜单上,不隐藏
}
// ========== 关键修复:优先检查编辑器区域 ==========
// 如果在编辑器区域内,立即返回,不执行任何操作,让编辑器的事件正常处理
// 先检查是否在编辑器包装器内(最快判断)
const tiptapEditorWrapper = target.closest?.('.tiptap-editor-wrapper');
if (tiptapEditorWrapper) {
// 在编辑器包装器内,完全不做任何处理,让编辑器正常处理
return;
}
// 检查所有可能的编辑器元素(包括工具栏、下拉面板、模态框等)
const editorSelectors = [
'.tiptap-toolbar',
'.tiptap',
'.editor-container',
'[class*="tiptap-"]'
];
for (const selector of editorSelectors) {
if (target.closest?.(selector)) {
// 在编辑器区域内,完全不做任何处理,让编辑器的事件正常处理
return;
}
}
// 其他区域点击,隐藏菜单
hideContextMenu();
};
// 关键修复:使用 capture: false 在冒泡阶段处理,并且延迟注册
// 这样可以确保编辑器的监听器(通常在目标元素上)先处理事件
// 然后再处理我们的监听器(在 body 上)
// 注意:只有在右键菜单显示时才注册,并且排除编辑器区域
setTimeout(() => {
// 再次检查右键菜单是否仍然可见
if (contextMenu.visible) {
document.body.addEventListener('click', hideMenuHandler, { once: true, capture: false, passive: true });
}
}, 100); // 延迟注册,确保编辑器的事件监听器已经注册并可以正常处理
const hideContextMenuHandler = (e) => {
const target = e.target;
if (!target) {
hideContextMenu();
return;
}
// 排除编辑器区域和右键菜单本身
if (target.closest?.('.tiptap-toolbar') || target.closest?.('.context-menu') || target.closest?.('.tiptap-editor-wrapper')) {
return;
}
hideContextMenu();
};
document.body.addEventListener('contextmenu', hideContextMenuHandler, { once: true });
}, 0);
});
};
function hideContextMenu() {
contextMenu.visible = false;
contextMenu.tab = null;
}
function closeTabContextTab() {
if (contextMenu.tab && contextMenu.tab.fullPath !== defaultDashboardPath) {
tabsStore.removeTab(contextMenu.tab.fullPath);
}
hideContextMenu();
}
// 关闭左侧
function closeLeftContextTab() {
if (contextMenu.tab) {
tabsStore.closeLeft(contextMenu.tab.fullPath);
// 如果当前路由对应的tab被关闭了,跳转到激活的tab
if (!tabsStore.tabList.find(tab => tab.fullPath === route.fullPath)) {
router.push(tabsStore.activeTab);
}
}
hideContextMenu();
}
// 关闭右侧
function closeRightContextTab() {
if (contextMenu.tab) {
tabsStore.closeRight(contextMenu.tab.fullPath);
// 如果当前路由对应的tab被关闭了,跳转到激活的tab
if (!tabsStore.tabList.find(tab => tab.fullPath === route.fullPath)) {
router.push(tabsStore.activeTab);
}
}
hideContextMenu();
}
// 关闭其他
function closeOthersContextTab() {
if (contextMenu.tab) {
tabsStore.setActiveTab(contextMenu.tab.fullPath);
tabsStore.closeOthers();
if (!tabsStore.tabList.find(tab => tab.fullPath === route.fullPath)) {
router.push(tabsStore.activeTab);
}
}
hideContextMenu();
}
// 关闭全部
function closeAllTabs() {
tabsStore.closeAll();
hideContextMenu();
router.push(defaultDashboardPath);
}
// 计算是否可以关闭左侧/右侧
const canCloseLeft = computed(() => {
if (!contextMenu.tab) return false;
const targetIndex = tabsStore.tabList.findIndex(t => t.fullPath === contextMenu.tab.fullPath);
// 至少左侧有一个可关闭的tab(排除首页)
return targetIndex > 0 && tabsStore.tabList.slice(0, targetIndex).some(t => t.fullPath !== defaultDashboardPath);
});
const canCloseRight = computed(() => {
if (!contextMenu.tab) return false;
const targetIndex = tabsStore.tabList.findIndex(t => t.fullPath === contextMenu.tab.fullPath);
// 右侧至少有一个tab
return targetIndex < tabsStore.tabList.length - 1;
});
</script>
<template>
<div class="common-layout">
<!-- 手机端汉堡菜单按钮(仅 ≤768px 显示,CSS 控制) -->
<button
class="mobile-menu-btn"
:aria-label="sidebarVisible ? '关闭菜单' : '打开菜单'"
@click="toggleMobileSidebar"
>
<el-icon :size="20">
<Expand v-if="sidebarVisible" />
<Fold v-else />
</el-icon>
</button>
<!-- 手机端遮罩层,点击关闭侧栏 -->
<transition name="mask-fade">
<div
v-if="isMobile && sidebarVisible"
class="mobile-sidebar-mask"
@click="closeMobileSidebar"
></div>
</transition>
<el-container class="main-container">
<common-aside
class="common-aside-wrapper"
:class="{ 'mobile-open': sidebarVisible }"
@menu-click="handleAsideMenuClick"
/>
<el-container>
<el-header class="main-header">
<common-header />
</el-header>
<el-main class="right-main">
<div class="multi-tabs-wrapper">
<el-tabs
v-model="tabsStore.activeTab"
type="card"
class="multi-tabs"
closable
@tab-remove="tabsCloseTab"
>
<el-tab-pane
v-for="tab in tabsStore.tabList"
:key="tab.fullPath"
:label="tab.title"
:name="tab.fullPath"
:closable="tab.fullPath !== defaultDashboardPath"
:data-tab-path="tab.fullPath"
/>
</el-tabs>
<!-- 右键菜单 -->
<teleport to="body">
<div
v-if="contextMenu.visible"
class="context-menu"
:style="{
left: contextMenu.x + 'px',
top: contextMenu.y + 'px'
}"
@click.stop
>
<div class="context-menu-item"
:class="{ 'is-disabled': contextMenu.tab && contextMenu.tab.fullPath === defaultDashboardPath }"
@click="!((contextMenu.tab && contextMenu.tab.fullPath === defaultDashboardPath)) && closeTabContextTab()">
关闭
</div>
<div class="context-menu-item"
:class="{ 'is-disabled': !canCloseLeft }"
@click="canCloseLeft && closeLeftContextTab()">
关闭左侧
</div>
<div class="context-menu-item"
:class="{ 'is-disabled': !canCloseRight }"
@click="canCloseRight && closeRightContextTab()">
关闭右侧
</div>
<div class="context-menu-item"
:class="{ 'is-disabled': contextMenu.tab && contextMenu.tab.fullPath === defaultDashboardPath && tabsStore.tabList.length <= 1 }"
@click="!((contextMenu.tab && contextMenu.tab.fullPath === defaultDashboardPath && tabsStore.tabList.length <= 1)) && closeOthersContextTab()">
关闭其他
</div>
<div class="context-menu-item"
:class="{ 'is-disabled': tabsStore.tabList.length <= 1 }"
@click="tabsStore.tabList.length > 1 && closeAllTabs()">
关闭全部
</div>
</div>
</teleport>
<!-- 右侧操作按钮 -->
<div class="tabs-extra-actions">
<el-dropdown trigger="click">
<el-button type="primary" link size="small" class="extra-btn">
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="closeOthers">
关闭其他
</el-dropdown-item>
<el-dropdown-item @click="closeAll">
关闭全部
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<!-- 主内容毛玻璃卡片容器 -->
<div class="main-panel glass-card">
<router-view v-slot="{ Component }">
<keep-alive :max="10">
<component :is="Component" />
</keep-alive>
</router-view>
</div>
<!-- 回到顶部按钮 -->
<el-backtop :target="'.right-main'" :visibility-height="300" :right="30" :bottom="50">
<div class="backtop-button">
<el-icon :size="20">
<ArrowUp />
</el-icon>
</div>
</el-backtop>
</el-main>
</el-container>
</el-container>
</div>
</template>
<style scoped lang="less">
.common-layout, .main-container {
height: 100vh;
width: 100%;
display: flex;
background-color: var(--bg-color-page);
transition: background-color 0.3s ease;
:deep(.el-aside) {
display: block !important;
visibility: visible !important;
height: 100vh;
}
.main-header {
background-color: var(--header-bg-color, #3973ff);
transition: background-color 0.3s ease;
height: 80px;
padding: 0;
}
.right-main {
background-color: var(--el-bg-color-page);
color: var(--el-text-color-primary);
transition: background-color 0.3s ease, color 0.3s ease;
padding: 20px;
overflow-y: auto;
.multi-tabs-wrapper {
position: relative;
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
background: var(--el-bg-color);
border-radius: 8px;
padding: 8px 12px;
border: 1px solid var(--el-border-color-lighter);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.multi-tabs {
flex: 1;
min-width: 0;
:deep(.el-tabs__header) {
margin: 0;
border-bottom: none;
}
:deep(.el-tabs__nav-wrap) {
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: thin;
scrollbar-color: var(--el-border-color) transparent;
&::-webkit-scrollbar {
height: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--el-border-color);
border-radius: 2px;
&:hover {
background: var(--el-border-color-darker);
}
}
}
:deep(.el-tabs__item) {
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
margin-right: 8px;
padding: 8px 16px;
height: 36px;
line-height: 20px;
color: var(--el-text-color-regular);
background: var(--el-fill-color-lighter);
transition: all 0.3s;
&:hover {
color: var(--el-color-primary);
border-color: var(--el-color-primary-light-7);
background: var(--el-color-primary-light-9);
}
&.is-active {
color: var(--el-color-primary);
border-color: #4f84ff;
background: #4f84ff;
color: #fff;
.el-icon-close {
color: rgba(255, 255, 255, 0.8);
&:hover {
color: #fff;
background-color: rgba(255, 255, 255, 0.2);
}
}
}
.el-icon-close {
margin-left: 8px;
border-radius: 50%;
width: 16px;
height: 16px;
line-height: 16px;
transition: all 0.2s;
&:hover {
background-color: var(--el-fill-color);
}
}
}
:deep(.el-tabs__nav) {
border: none;
}
:deep(.el-tabs__content) {
display: none;
}
}
.tabs-extra-actions {
flex-shrink: 0;
display: flex;
align-items: center;
.extra-btn {
padding: 8px;
font-size: 18px;
color: var(--el-text-color-regular);
&:hover {
color: var(--el-color-primary);
}
}
}
}
}
:deep(.el-menu){
border-right: none !important;
}
// ========== 手机端侧栏抽屉(仅 ≤768px 生效,桌面端不受影响) ==========
// 汉堡按钮:默认隐藏,手机端显示
.mobile-menu-btn {
display: none;
position: fixed;
top: 20px;
left: 12px;
z-index: 2200;
width: 40px;
height: 40px;
align-items: center;
justify-content: center;
border: none;
border-radius: 50%;
background: var(--el-color-primary);
color: #fff;
box-shadow: 0 4px 12px rgba(57, 115, 255, 0.35);
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
&:active {
transform: scale(0.92);
}
}
// 遮罩层
.mobile-sidebar-mask {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 2000;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(2px);
}
.mask-fade-enter-active,
.mask-fade-leave-active {
transition: opacity 0.3s ease;
}
.mask-fade-enter-from,
.mask-fade-leave-to {
opacity: 0;
}
@media (max-width: 768px) {
.mobile-menu-btn {
display: flex;
}
// 覆盖 el-aside 的 :width 内联宽度(200px/64px),改为 fixed 抽屉
.common-layout .common-aside-wrapper {
position: fixed !important;
top: 0;
bottom: 0;
left: 0;
width: 260px !important;
max-width: 80vw;
height: 100vh;
z-index: 2100;
transform: translateX(-100%);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: none;
&.mobile-open {
transform: translateX(0);
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.25);
}
}
// 内容区不再为侧栏预留空间
.main-container {
:deep(.el-header),
:deep(.el-main) {
width: 100%;
}
}
.right-main {
padding: 64px 12px 12px;
}
}
// 回到顶部按钮样式
.backtop-button {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: var(--el-color-primary);
color: #fff;
border-radius: 50%;
box-shadow: 0 4px 12px rgba(64, 129, 255, 0.4);
transition: all 0.3s ease;
cursor: pointer;
&:hover {
background: var(--el-color-primary-light-3);
box-shadow: 0 6px 16px rgba(64, 129, 255, 0.5);
transform: translateY(-2px);
}
&:active {
transform: translateY(0);
}
}
</style>
<style lang="less">
// 右键菜单样式 - 全局样式,因为使用了 teleport 到 body
.context-menu {
position: fixed !important;
z-index: 9999 !important;
background: var(--el-bg-color-overlay) !important;
border: 1px solid var(--el-border-color-lighter) !important;
border-radius: 4px !important;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1) !important;
min-width: 120px !important;
padding: 4px 0 !important;
pointer-events: auto !important; // 确保菜单本身可以接收点击
.context-menu-item {
padding: 8px 16px !important;
cursor: pointer !important;
color: var(--el-text-color-primary) !important;
font-size: 14px !important;
transition: background-color 0.2s !important;
&:hover:not(.is-disabled) {
background-color: var(--el-fill-color-light) !important;
}
&.is-disabled {
color: var(--el-text-color-disabled) !important;
cursor: not-allowed !important;
opacity: 0.5 !important;
}
}
}
// 确保编辑器工具栏的 z-index 高于右键菜单
:deep(.tiptap-toolbar) {
z-index: 10000 !important;
pointer-events: auto !important;
}
</style>