357 lines
8.4 KiB
Vue
357 lines
8.4 KiB
Vue
<template>
|
|
<n-layout has-sider class="app-root app-backdrop">
|
|
<n-layout-sider
|
|
collapse-mode="width"
|
|
:collapsed-width="isFullscreen ? 0 : 72"
|
|
:width="siderWidth"
|
|
:collapsed="isFullscreen || authStore.layoutCollapsed"
|
|
class="layout-sider"
|
|
:class="{
|
|
'layout-sider-collapsed': isSiderCollapsed,
|
|
'layout-sider-hidden': isFullscreen
|
|
}"
|
|
:show-trigger="!isFullscreen"
|
|
@collapse="authStore.layoutCollapsed = true"
|
|
@expand="authStore.layoutCollapsed = false"
|
|
>
|
|
<div class="brand-mark">
|
|
<span class="brand-dot"></span>
|
|
<span class="brand-name" :title="appTitle">{{ appTitle }}</span>
|
|
</div>
|
|
<div class="menu-wrap">
|
|
<AppMenu />
|
|
</div>
|
|
<div
|
|
v-if="!isSiderCollapsed"
|
|
class="sider-resizer"
|
|
title="拖动调整菜单宽度"
|
|
@pointerdown="startSiderResize"
|
|
></div>
|
|
</n-layout-sider>
|
|
|
|
<n-layout class="app-main" :class="{ 'app-main-fullscreen': isFullscreen }">
|
|
<header v-if="!isFullscreen" class="app-header">
|
|
<div class="header-main">
|
|
<n-button
|
|
tertiary
|
|
circle
|
|
size="small"
|
|
class="header-refresh"
|
|
title="刷新当前页面"
|
|
@click="authStore.refreshTab(route.path)"
|
|
>
|
|
<template #icon>
|
|
<n-icon><RefreshCw /></n-icon>
|
|
</template>
|
|
</n-button>
|
|
<n-button
|
|
tertiary
|
|
circle
|
|
size="small"
|
|
class="header-fullscreen"
|
|
:title="isFullscreen ? '退出全屏' : '全屏'"
|
|
@click="toggleFullscreen"
|
|
>
|
|
<template #icon>
|
|
<n-icon>
|
|
<Minimize2 v-if="isFullscreen" />
|
|
<Maximize2 v-else />
|
|
</n-icon>
|
|
</template>
|
|
</n-button>
|
|
<div class="breadcrumb-group">
|
|
<AppBreadcrumb />
|
|
</div>
|
|
<div class="summary-actions">
|
|
<n-tag type="info" size="small">{{ userDisplayName }}</n-tag>
|
|
<n-button tertiary size="small" @click="handleLogout">退出</n-button>
|
|
</div>
|
|
</div>
|
|
<AppTabs />
|
|
</header>
|
|
|
|
<n-layout-content embedded class="layout-content bg-transparent">
|
|
<div class="content-scroll">
|
|
<router-view v-slot="{ Component, route: currentRoute }">
|
|
<transition name="fade-slide" mode="out-in">
|
|
<keep-alive :include="cachedRouteNames">
|
|
<component
|
|
:is="Component"
|
|
:key="`${currentRoute.fullPath}-${authStore.tabRefreshMarks[currentRoute.path === '/workbench' ? '/dashboard' : currentRoute.path] ?? 0}`"
|
|
/>
|
|
</keep-alive>
|
|
</transition>
|
|
</router-view>
|
|
</div>
|
|
</n-layout-content>
|
|
</n-layout>
|
|
</n-layout>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
import { Maximize2, Minimize2, RefreshCw } from 'lucide-vue-next'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { useAuthStore } from '@/stores/auth'
|
|
import AppMenu from '@/components/AppMenu.vue'
|
|
import AppTabs from '@/components/AppTabs.vue'
|
|
import AppBreadcrumb from '@/components/AppBreadcrumb.vue'
|
|
import { appEnv } from '@/config/env'
|
|
import { resetDynamicRoutes } from '@/router'
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const authStore = useAuthStore()
|
|
const appTitle = appEnv.appTitle
|
|
const isFullscreen = ref(false)
|
|
const SIDER_WIDTH_KEY = 'platform.layout.siderWidth'
|
|
const MIN_SIDER_WIDTH = 200
|
|
const MAX_SIDER_WIDTH = 360
|
|
const DEFAULT_SIDER_WIDTH = 248
|
|
const storedSiderWidth = Number(localStorage.getItem(SIDER_WIDTH_KEY))
|
|
const siderWidth = ref(
|
|
Number.isFinite(storedSiderWidth)
|
|
? Math.min(MAX_SIDER_WIDTH, Math.max(MIN_SIDER_WIDTH, storedSiderWidth))
|
|
: DEFAULT_SIDER_WIDTH
|
|
)
|
|
const isSiderCollapsed = computed(() => isFullscreen.value || authStore.layoutCollapsed)
|
|
|
|
const cachedRouteNames = computed(() =>
|
|
router
|
|
.getRoutes()
|
|
.filter((item) => item.meta.keepAlive && typeof item.name === 'string')
|
|
.map((item) => item.name as string)
|
|
)
|
|
|
|
const userDisplayName = computed(() => {
|
|
const nickname = authStore.user?.nickname?.trim()
|
|
return nickname || authStore.user?.username || '访客'
|
|
})
|
|
|
|
watch(
|
|
() => route.fullPath,
|
|
() => {
|
|
if (!route.meta.requiresAuth || route.path === '/403' || route.path === '/404') return
|
|
const name = (route.name as string | undefined) ?? route.path
|
|
authStore.addTab({
|
|
name,
|
|
title: (route.meta.title as string | undefined) ?? '未命名页面',
|
|
path: route.path,
|
|
closable: route.path !== '/dashboard' && route.path !== '/workbench'
|
|
})
|
|
},
|
|
{ immediate: true }
|
|
)
|
|
|
|
function syncFullscreenState() {
|
|
isFullscreen.value = Boolean(document.fullscreenElement)
|
|
}
|
|
|
|
onMounted(() => {
|
|
syncFullscreenState()
|
|
document.addEventListener('fullscreenchange', syncFullscreenState)
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
document.removeEventListener('fullscreenchange', syncFullscreenState)
|
|
stopSiderResize()
|
|
})
|
|
|
|
async function toggleFullscreen() {
|
|
if (document.fullscreenElement) {
|
|
await document.exitFullscreen()
|
|
return
|
|
}
|
|
await document.documentElement.requestFullscreen()
|
|
}
|
|
|
|
function startSiderResize(event: PointerEvent) {
|
|
event.preventDefault()
|
|
document.body.classList.add('sider-resizing')
|
|
window.addEventListener('pointermove', resizeSider)
|
|
window.addEventListener('pointerup', stopSiderResize)
|
|
}
|
|
|
|
function resizeSider(event: PointerEvent) {
|
|
const nextWidth = Math.min(MAX_SIDER_WIDTH, Math.max(MIN_SIDER_WIDTH, event.clientX))
|
|
siderWidth.value = nextWidth
|
|
localStorage.setItem(SIDER_WIDTH_KEY, String(nextWidth))
|
|
}
|
|
|
|
function stopSiderResize() {
|
|
document.body.classList.remove('sider-resizing')
|
|
window.removeEventListener('pointermove', resizeSider)
|
|
window.removeEventListener('pointerup', stopSiderResize)
|
|
}
|
|
|
|
async function handleLogout() {
|
|
await authStore.logout()
|
|
resetDynamicRoutes()
|
|
await router.replace('/login')
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.fade-slide-enter-active,
|
|
.fade-slide-leave-active {
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.fade-slide-enter-from,
|
|
.fade-slide-leave-to {
|
|
opacity: 0;
|
|
transform: translateY(6px);
|
|
}
|
|
|
|
.app-root {
|
|
height: 100vh;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.app-main {
|
|
height: 100vh;
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
display: flex;
|
|
flex-direction: column;
|
|
padding: 0;
|
|
}
|
|
|
|
.app-main-fullscreen .layout-content {
|
|
padding: 0;
|
|
}
|
|
|
|
.app-header {
|
|
flex: 0 0 auto;
|
|
border-bottom: 1px solid #e2e8f0;
|
|
background: #ffffff;
|
|
padding: 10px 18px 8px;
|
|
}
|
|
|
|
.header-main {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
min-height: 40px;
|
|
}
|
|
|
|
.header-refresh,
|
|
.header-fullscreen {
|
|
flex: 0 0 auto;
|
|
}
|
|
|
|
.breadcrumb-group {
|
|
flex: 1 1 auto;
|
|
min-width: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.summary-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex: 0 0 auto;
|
|
}
|
|
|
|
.layout-content {
|
|
flex: 1 1 auto;
|
|
min-height: 0;
|
|
overflow: hidden;
|
|
padding: 16px 18px 18px;
|
|
}
|
|
|
|
.content-scroll {
|
|
height: 100%;
|
|
min-height: 0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.app-backdrop {
|
|
background: #f5f7fb;
|
|
}
|
|
|
|
.layout-sider {
|
|
position: relative;
|
|
background: #ffffff;
|
|
border-right: 1px solid #e2e8f0;
|
|
}
|
|
|
|
.layout-sider-hidden {
|
|
border-right: 0;
|
|
}
|
|
|
|
.brand-mark {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 11px;
|
|
height: 64px;
|
|
padding: 0 18px;
|
|
border-bottom: 1px solid #eef2f7;
|
|
}
|
|
|
|
.brand-dot {
|
|
width: 28px;
|
|
height: 28px;
|
|
border-radius: 8px;
|
|
background: linear-gradient(135deg, #111827, #2563eb);
|
|
box-shadow: 0 10px 22px rgba(37, 99, 235, 0.18);
|
|
}
|
|
|
|
.brand-name {
|
|
color: #111827;
|
|
font-size: 15px;
|
|
font-weight: 700;
|
|
letter-spacing: 0;
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.menu-wrap {
|
|
padding: 12px 10px;
|
|
}
|
|
|
|
.sider-resizer {
|
|
position: absolute;
|
|
top: 0;
|
|
right: -3px;
|
|
z-index: 3;
|
|
width: 6px;
|
|
height: 100%;
|
|
cursor: col-resize;
|
|
touch-action: none;
|
|
}
|
|
|
|
.sider-resizer::after {
|
|
content: '';
|
|
position: absolute;
|
|
top: 0;
|
|
right: 2px;
|
|
width: 2px;
|
|
height: 100%;
|
|
background: transparent;
|
|
transition: background 0.16s ease;
|
|
}
|
|
|
|
.sider-resizer:hover::after,
|
|
:global(.sider-resizing) .sider-resizer::after {
|
|
background: #2563eb;
|
|
}
|
|
|
|
:global(.sider-resizing) {
|
|
cursor: col-resize;
|
|
user-select: none;
|
|
}
|
|
|
|
.layout-sider-collapsed .brand-mark {
|
|
justify-content: center;
|
|
padding: 0;
|
|
}
|
|
|
|
.layout-sider-collapsed .brand-name {
|
|
display: none;
|
|
}
|
|
</style>
|