robot-admin-web/src/layouts/AdminLayout.vue

760 lines
19 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.

<template>
<!-- ========== PC 端布局 ========== -->
<el-container v-if="!isMobile" class="layout-container">
<el-aside :width="isCollapse ? '64px' : '220px'" class="layout-aside">
<div class="logo-area">
<img src="@/assets/logo.svg" alt="logo" class="logo-icon" />
<span v-show="!isCollapse" class="logo-text">机器任务管理</span>
</div>
<el-menu
:default-active="activeMenu"
:collapse="isCollapse"
router
background-color="#001529"
text-color="#ffffffa6"
active-text-color="#ffffff"
class="aside-menu"
>
<el-menu-item v-if="!isMonitorMode" index="/dashboard">
<el-icon><Monitor /></el-icon>
<template #title>首页</template>
</el-menu-item>
<el-menu-item index="/task/chain">
<el-icon><List /></el-icon>
<template #title>任务链管理</template>
</el-menu-item>
<el-menu-item index="/elevator">
<el-icon><SetUp /></el-icon>
<template #title>提升机查看</template>
</el-menu-item>
<el-menu-item index="/station/queue">
<el-icon><Tickets /></el-icon>
<template #title>站点排队</template>
</el-menu-item>
<el-menu-item index="/resource/extension">
<el-icon><Box /></el-icon>
<template #title>拓展资源</template>
</el-menu-item>
<el-menu-item v-if="!isMonitorMode" index="/vendor/load">
<el-icon><DataLine /></el-icon>
<template #title>厂商负载</template>
</el-menu-item>
<el-menu-item v-if="!isMonitorMode" index="/generate">
<el-icon><Plus /></el-icon>
<template #title>任务生成</template>
</el-menu-item>
<el-menu-item index="/docs/exception">
<el-icon><Document /></el-icon>
<template #title>异常处理</template>
</el-menu-item>
</el-menu>
</el-aside>
<el-container>
<el-header class="layout-header">
<div class="header-left">
<el-icon class="collapse-btn" @click="isCollapse = !isCollapse">
<Fold v-if="!isCollapse" />
<Expand v-else />
</el-icon>
<el-tooltip content="刷新当前页" placement="bottom">
<el-icon class="refresh-btn" @click="refreshCurrentTab"><Refresh /></el-icon>
</el-tooltip>
<el-breadcrumb separator="/">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item v-if="currentTitle">{{ currentTitle }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="header-right">
<el-dropdown @command="handleCommand">
<span class="user-info">
<el-icon><User /></el-icon>
<span>{{ userName }}</span>
<el-icon class="el-icon--right"><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<!-- PC 端标签栏 -->
<div v-if="tabsStore.tabs.length > 0" class="layout-tabs">
<div class="tabs-scroll">
<template v-for="(t, index) in tabsStore.tabs" :key="t.path">
<div
v-if="dragFromIndex >= 0 && dragOverIndex === index"
class="tab-drop-indicator"
/>
<div
:class="['tab-item', { 'tab-active': route.path === t.path, 'tab-dragging': dragFromIndex === index }]"
draggable="true"
@click="goTab(t.path)"
@contextmenu.prevent="showTabMenu($event, t.path, index)"
@dragstart="onTabDragStart($event, index)"
@dragover.prevent="onTabDragOver($event, index)"
@dragleave="onTabDragLeave"
@drop.prevent="onTabDrop($event, index)"
@dragend="onTabDragEnd"
>
<el-tooltip :content="t.title" placement="top" :show-after="300">
<span class="tab-title">{{ t.title }}</span>
</el-tooltip>
<span class="tab-close" @click.stop="closeTab(t.path)">×</span>
</div>
</template>
</div>
<div
v-if="tabMenuVisible"
class="tab-context-menu"
:style="{ left: tabMenuX + 'px', top: tabMenuY + 'px' }"
@mousedown.stop
>
<div class="tab-menu-item" @mousedown.prevent.stop="closeOthersTab">关闭其他</div>
<div
v-if="hasRightTabs"
class="tab-menu-item"
@mousedown.prevent.stop="closeRightTab"
>
关闭右边
</div>
</div>
</div>
<el-main ref="layoutMainRef" class="layout-main">
<router-view v-slot="{ Component }">
<keep-alive :include="keepAliveNames" :max="20">
<component :is="Component" :key="componentKey" />
</keep-alive>
</router-view>
</el-main>
</el-container>
</el-container>
<!-- ========== 移动端布局 ========== -->
<div v-else class="mobile-layout">
<header class="mobile-header">
<span class="mobile-header-title">{{ currentTitle || '首页' }}</span>
<div class="mobile-header-actions">
<el-icon class="mobile-refresh-btn" @click="refreshCurrentTab"><Refresh /></el-icon>
<el-dropdown @command="handleCommand">
<el-icon class="mobile-header-avatar"><User /></el-icon>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</header>
<main ref="mobileMainRef" class="mobile-main">
<router-view v-slot="{ Component }">
<keep-alive :include="keepAliveNames" :max="20">
<component :is="Component" :key="componentKey" />
</keep-alive>
</router-view>
</main>
<nav class="mobile-tab-bar">
<router-link
v-for="tab in tabs"
:key="tab.path"
:to="tab.path"
:class="['tab-item', { 'tab-active': activeTab === tab.path }]"
>
<el-icon :size="22"><component :is="tab.icon" /></el-icon>
<span class="tab-label">{{ tab.label }}</span>
</router-link>
</nav>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { useDevice } from '@/composables/useDevice'
import { Monitor, List, SetUp, Plus, DataLine, Fold, Expand, User, ArrowDown, Refresh, Tickets, Box, Document } from '@element-plus/icons-vue'
import { useTabsStore } from '@/stores/tabs'
const { isMobile } = useDevice()
const route = useRoute()
const router = useRouter()
const tabsStore = useTabsStore()
const refreshKeys = ref<Record<string, number>>({})
const layoutMainRef = ref<{ $el?: HTMLElement } | HTMLElement | null>(null)
const mobileMainRef = ref<HTMLElement | null>(null)
const scrollPositions = ref<Record<string, number>>({})
const skipNextScrollSavePaths = new Set<string>()
const tabMenuVisible = ref(false)
const tabMenuX = ref(0)
const tabMenuY = ref(0)
const tabMenuPath = ref('')
const tabMenuIndex = ref(-1)
const dragFromIndex = ref(-1)
const dragOverIndex = ref(-1)
const hasRightTabs = computed(() => tabMenuIndex.value >= 0 && tabMenuIndex.value < tabsStore.tabs.length - 1)
const componentKey = computed(() => {
const k = refreshKeys.value[route.path] ?? 0
return `${route.fullPath}-${k}`
})
function refreshCurrentTab() {
clearScrollPosition(route.fullPath)
refreshKeys.value[route.path] = (refreshKeys.value[route.path] ?? 0) + 1
nextTick(() => {
restoreScrollPosition(route.fullPath)
})
}
const userStore = useUserStore()
const isMonitorMode = computed(() => userStore.isMonitorMode())
const keepAliveNames = computed(() => {
if (isMonitorMode.value) {
return ['TaskChainList', 'TaskChainDetail', 'ElevatorList', 'StationQueueView', 'ExtensionResourceView', 'ExceptionDocsView']
}
return [
'DashboardView',
'TaskChainList',
'TaskChainDetail',
'ElevatorList',
'StationQueueView',
'ExtensionResourceView',
'VendorLoadView',
'TaskGenerateView',
'ExceptionDocsView',
]
})
// 必须在 watch 之前恢复,否则 watch 的 immediate 会先执行并覆盖 storage
const activePath = tabsStore.restoreFromStorage()
if (activePath && activePath !== route.path) {
router.replace(activePath).then(() => {
tabsStore.enableSave()
})
} else {
tabsStore.enableSave()
}
watch(
() => route.path,
() => {
if (route.path.startsWith('/login')) return
tabsStore.addOrSwitch(route)
},
{ immediate: true },
)
watch(
() => route.fullPath,
async (fullPath, oldFullPath) => {
if (oldFullPath) {
saveScrollPosition(oldFullPath)
}
await nextTick()
restoreScrollPosition(fullPath)
},
)
function getScrollContainer() {
if (isMobile.value) return mobileMainRef.value
const el = layoutMainRef.value
if (!el) return null
return el instanceof HTMLElement ? el : el.$el ?? null
}
function saveScrollPosition(path: string) {
if (shouldSkipScrollSave(path)) return
const container = getScrollContainer()
if (!container) return
scrollPositions.value[path] = container.scrollTop
}
function restoreScrollPosition(path: string) {
const container = getScrollContainer()
if (!container) return
container.scrollTop = scrollPositions.value[path] ?? 0
}
function clearScrollPosition(path: string, skipNextSave = false) {
if (skipNextSave) {
skipNextScrollSavePaths.add(path)
}
Object.keys(scrollPositions.value).forEach((key) => {
if (key === path || key.startsWith(`${path}?`)) {
delete scrollPositions.value[key]
}
})
}
function keepOnlyScrollPosition(path: string) {
Object.keys(scrollPositions.value).forEach((key) => {
if (key !== path && !key.startsWith(`${path}?`)) {
delete scrollPositions.value[key]
}
})
}
function shouldSkipScrollSave(path: string) {
for (const skipPath of skipNextScrollSavePaths) {
if (path === skipPath || path.startsWith(`${skipPath}?`)) {
skipNextScrollSavePaths.delete(skipPath)
return true
}
}
return false
}
function goTab(path: string) {
if (route.path === path) return
router.push(path)
}
function closeTab(path: string) {
const idx = tabsStore.tabs.findIndex((t) => t.path === path)
if (idx < 0) return
const next = tabsStore.tabs[idx - 1] ?? tabsStore.tabs[idx + 1]
const nextPath = next ? next.path : '/dashboard'
tabsStore.remove(path)
clearScrollPosition(path, route.path === path)
if (route.path === path) {
router.push(nextPath)
}
tabsStore.saveToStorage(route.path === path ? nextPath : route.path)
}
function showTabMenu(e: MouseEvent, path: string, index: number) {
tabMenuPath.value = path
tabMenuIndex.value = index
tabMenuX.value = e.clientX
tabMenuY.value = e.clientY
tabMenuVisible.value = true
}
function closeOthersTab() {
const path = tabMenuPath.value
tabMenuVisible.value = false
if (tabsStore.tabs.length <= 1) return
tabsStore.closeOthers(path)
keepOnlyScrollPosition(path)
if (route.path !== path) {
skipNextScrollSavePaths.add(route.fullPath)
router.push(path)
}
tabsStore.saveToStorage(path)
}
function closeRightTab() {
const path = tabMenuPath.value
const idx = tabMenuIndex.value
tabMenuVisible.value = false
tabMenuIndex.value = -1
if (idx < 0 || idx >= tabsStore.tabs.length - 1) return
const closedPaths = tabsStore.tabs.slice(idx + 1).map((t) => t.path)
tabsStore.closeRight(idx)
closedPaths.forEach((closedPath) => {
clearScrollPosition(closedPath, closedPath === route.path)
})
const needNav = closedPaths.includes(route.path)
if (needNav) {
router.push(path)
}
tabsStore.saveToStorage(needNav ? path : route.path)
}
function hideTabMenu() {
tabMenuVisible.value = false
}
function onTabDragStart(e: DragEvent, index: number) {
dragFromIndex.value = index
e.dataTransfer!.effectAllowed = 'move'
e.dataTransfer!.setData('text/plain', String(index))
}
function onTabDragOver(e: DragEvent, index: number) {
e.dataTransfer!.dropEffect = 'move'
dragOverIndex.value = index
}
function onTabDragLeave() {
dragOverIndex.value = -1
}
function onTabDragEnd() {
dragFromIndex.value = -1
dragOverIndex.value = -1
}
function onTabDrop(_e: DragEvent, toIndex: number) {
const fromIndex = dragFromIndex.value
if (fromIndex >= 0 && fromIndex !== toIndex) {
tabsStore.reorder(fromIndex, toIndex)
tabsStore.saveToStorage(route.path)
}
dragFromIndex.value = -1
dragOverIndex.value = -1
}
onMounted(() => {
document.addEventListener('click', hideTabMenu)
})
onUnmounted(() => {
document.removeEventListener('click', hideTabMenu)
})
const isCollapse = ref(false)
const tabs = computed(() => [
...(!isMonitorMode.value ? [{ path: '/dashboard', label: '首页', icon: Monitor }] : []),
{ path: '/task/chain', label: '任务链', icon: List },
{ path: '/elevator', label: '提升机', icon: SetUp },
{ path: '/station/queue', label: '排队', icon: Tickets },
{ path: '/resource/extension', label: '资源', icon: Box },
...(!isMonitorMode.value ? [{ path: '/vendor/load', label: '厂商负载', icon: DataLine }] : []),
...(!isMonitorMode.value ? [{ path: '/generate', label: '生成', icon: Plus }] : []),
{ path: '/docs/exception', label: '异常', icon: Document },
])
const activeMenu = computed(() => {
const path = route.path
if (path.startsWith('/task/chain')) return '/task/chain'
if (path.startsWith('/station/queue')) return '/station/queue'
if (path.startsWith('/resource/extension')) return '/resource/extension'
if (path.startsWith('/docs/exception')) return '/docs/exception'
return path
})
const activeTab = computed(() => {
const path = route.path
if (path.startsWith('/task/chain')) return '/task/chain'
if (path.startsWith('/station/queue')) return '/station/queue'
if (path.startsWith('/resource/extension')) return '/resource/extension'
if (path.startsWith('/docs/exception')) return '/docs/exception'
return path
})
const currentTitle = computed(() => (route.meta.title as string) || '')
const userName = computed(() => userStore.user?.name || '管理员')
function handleCommand(command: string) {
if (command === 'logout') {
userStore.logout()
}
}
</script>
<style scoped>
/* ===== PC 端 ===== */
.layout-container {
height: 100vh;
}
.layout-aside {
background-color: #001529;
transition: width 0.3s;
overflow: hidden;
}
.logo-area {
height: 56px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 16px;
border-bottom: 1px solid #ffffff1a;
}
.logo-icon {
width: 28px;
height: 28px;
flex-shrink: 0;
}
.logo-text {
color: #fff;
font-size: 16px;
font-weight: 600;
white-space: nowrap;
}
.aside-menu {
border-right: none;
}
.layout-header {
background: #fff;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
padding: 0 20px;
z-index: 1;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.collapse-btn {
font-size: 20px;
cursor: pointer;
color: #606266;
}
.collapse-btn:hover {
color: #409eff;
}
.refresh-btn {
font-size: 18px;
cursor: pointer;
color: #606266;
margin-left: 4px;
}
.refresh-btn:hover {
color: #409eff;
}
.header-right {
display: flex;
align-items: center;
}
.user-info {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
font-size: 14px;
color: #606266;
}
.layout-tabs {
background: #f5f7fa;
padding: 0 16px;
border-bottom: 1px solid #e4e7ed;
flex-shrink: 0;
position: relative;
}
.tab-context-menu {
position: fixed;
z-index: 9999;
min-width: 120px;
padding: 4px 0;
background: #fff;
border-radius: 4px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
}
.tab-menu-item {
padding: 8px 16px;
font-size: 13px;
color: #606266;
cursor: pointer;
}
.tab-menu-item:hover {
background: #f5f7fa;
color: #409eff;
}
.tabs-scroll {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
padding: 10px 0 8px;
}
.tab-drop-indicator {
width: 3px;
height: 24px;
background: #409eff;
border-radius: 2px;
flex-shrink: 0;
animation: tab-drop-pulse 0.6s ease-in-out infinite;
}
@keyframes tab-drop-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.layout-tabs .tab-item.tab-dragging {
opacity: 0.5;
}
.layout-tabs .tab-item {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
gap: 6px;
padding: 10px 14px;
border-radius: 6px 6px 0 0;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
color: #909399;
background: #e9ecef;
transition: all 0.2s;
user-select: none;
}
.layout-tabs .tab-item:hover {
color: #606266;
background: #dee2e6;
}
.layout-tabs .tab-item.tab-active {
background: #fff;
color: #409eff;
font-weight: 500;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.04);
border-bottom: 2px solid #409eff;
margin-bottom: -1px;
}
.layout-tabs .tab-item.tab-active:hover {
background: #fff;
color: #409eff;
}
.layout-tabs .tab-title {
font-size: 13px;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 20px;
flex-shrink: 1;
min-width: 0;
}
.layout-tabs .tab-close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
flex-shrink: 0;
margin-left: 2px;
font-size: 18px;
line-height: 1;
font-weight: 300;
color: #c0c4cc;
transition: color 0.2s;
border-radius: 2px;
}
.layout-tabs .tab-item:hover .tab-close {
color: #909399;
}
.layout-tabs .tab-close:hover {
color: #f56c6c;
background: rgba(245, 108, 108, 0.1);
}
.layout-main {
background: #f0f2f5;
padding: 20px;
}
/* ===== 移动端 ===== */
.mobile-layout {
display: flex;
flex-direction: column;
height: 100vh;
height: 100dvh;
background: #f5f7fa;
}
.mobile-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
height: 48px;
background: #fff;
border-bottom: 1px solid #ebeef5;
flex-shrink: 0;
}
.mobile-header-title {
font-size: 17px;
font-weight: 600;
color: #303133;
}
.mobile-header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.mobile-refresh-btn {
font-size: 20px;
color: #606266;
cursor: pointer;
}
.mobile-refresh-btn:active {
color: #409eff;
}
.mobile-header-avatar {
font-size: 22px;
color: #606266;
cursor: pointer;
}
.mobile-main {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 12px;
}
.mobile-tab-bar {
display: flex;
align-items: center;
justify-content: space-around;
height: 56px;
background: #fff;
border-top: 1px solid #ebeef5;
flex-shrink: 0;
padding-bottom: env(safe-area-inset-bottom);
}
.tab-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
text-decoration: none;
color: #909399;
transition: color 0.2s;
-webkit-tap-highlight-color: transparent;
}
.tab-active {
color: #409eff;
}
.tab-label {
font-size: 11px;
line-height: 1;
}
</style>