760 lines
19 KiB
Vue
760 lines
19 KiB
Vue
<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>
|