platform-vue/src/views/Main.vue
2026-04-09 15:04:31 +08:00

704 lines
22 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, computed } from 'vue';
import { More, Close, CircleClose, ArrowUp } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
const tabsStore = useTabsStore();
const router = useRouter();
const route = useRoute();
const defaultDashboardPath = '/home';
// 根据当前路由恢复 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?.('.wang-editor-wrapper');
if (editorWrapper) {
// 在编辑器区域内,不处理右键菜单,也不阻止事件
return;
}
// 检查其他编辑器元素(工具栏、下拉面板等)
const editorElements = [
'.w-e-toolbar',
'.w-e-drop-panel',
'.w-e-modal',
'.w-e-toolbar-menu',
'[data-menu-key]',
'[class*="w-e-"]'
];
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();
}
},
{ immediate: false }
);
// 1. 侧栏菜单点击:加入/激活Tab并切换路由
const handleAsideMenuClick = async (menuItem) => {
const targetPath = menuItem.path;
if (targetPath === '/home' || targetPath === defaultDashboardPath) {
tabsStore.closeAll();
router.push(defaultDashboardPath);
return;
}
// 先添加tab
tabsStore.addTab({
title: menuItem.title,
fullPath: targetPath,
name: menuItem.title,
icon: menuItem.icon
});
// 如果当前路由已经是目标路由,不需要跳转
if (router.currentRoute.value.fullPath === targetPath) {
return;
}
router.push(targetPath).catch(err => {
if (err.name !== 'NavigationDuplicated') {
console.error('路由跳转失败:', err);
}
});
};
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) => {
if (newVal && oldVal !== undefined && router.currentRoute.value.fullPath !== newVal) {
if (newVal === defaultDashboardPath) {
tabsStore.closeAll();
}
nextTick(() => {
router.push(newVal).catch(err => {
if (err.name !== 'NavigationDuplicated') {
console.error('路由跳转失败:', err);
}
});
});
}
},
{ 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 wangEditorWrapper = target.closest?.('.wang-editor-wrapper');
if (wangEditorWrapper) {
// 在编辑器包装器内,完全不做任何处理,让编辑器正常处理
return;
}
// 检查所有可能的编辑器元素(包括工具栏、下拉面板、模态框等)
const editorSelectors = [
'.w-e-toolbar',
'.w-e-drop-panel',
'.w-e-modal',
'.w-e-toolbar-menu',
'.toolbar-container',
'.editor-container',
'.w-e-text-container',
'.w-e-text',
// 检查是否有 WangEditor 相关的元素
'[data-menu-key]',
'[class*="w-e-"]'
];
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?.('.w-e-toolbar') || target.closest?.('.context-menu') || target.closest?.('.wang-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">
<el-container class="main-container">
<common-aside @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;
}
// 回到顶部按钮样式
.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(.w-e-toolbar),
:deep(.w-e-drop-panel),
:deep(.w-e-modal),
:deep(.w-e-toolbar-menu) {
z-index: 10000 !important; // 高于右键菜单的 9999
pointer-events: auto !important;
}
</style>