通用中后台框架第一版

This commit is contained in:
BBIT-Kai
2026-04-28 16:27:16 +08:00
commit b8d25869c6
115 changed files with 15223 additions and 0 deletions
+242
View File
@@ -0,0 +1,242 @@
<template>
<n-layout has-sider class="app-root app-backdrop">
<n-layout-sider
collapse-mode="width"
:collapsed-width="72"
:width="248"
:collapsed="authStore.layoutCollapsed"
class="layout-sider"
:class="{ 'layout-sider-collapsed': authStore.layoutCollapsed }"
show-trigger
@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>
</n-layout-sider>
<n-layout class="app-main">
<header class="app-header">
<div class="header-main">
<div class="title-group">
<h1>{{ currentTitle }}</h1>
<AppBreadcrumb />
</div>
<div class="summary-actions">
<n-button
tertiary
circle
size="small"
title="刷新当前页面"
@click="authStore.refreshTab(route.path)"
>
<template #icon>
<n-icon><RefreshCw /></n-icon>
</template>
</n-button>
<n-tag type="info" size="small">{{ authStore.user?.username ?? '访客' }}</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, watch } from 'vue'
import { 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 currentTitle = computed(() => (route.meta.title as string | undefined) ?? '工作台')
const cachedRouteNames = computed(() =>
router
.getRoutes()
.filter((item) => item.meta.keepAlive && typeof item.name === 'string')
.map((item) => item.name as string)
)
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 }
)
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: 16px 18px 18px;
}
.app-header {
flex: 0 0 auto;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 14px 34px rgba(15, 23, 42, 0.06);
padding: 12px 14px 8px;
}
.header-main {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 40px;
}
.title-group {
min-width: 0;
display: flex;
align-items: baseline;
gap: 14px;
}
.title-group h1 {
margin: 0;
color: #0f172a;
font-size: 20px;
font-weight: 700;
min-width: 0;
white-space: nowrap;
}
.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-top: 14px;
}
.content-scroll {
height: 100%;
min-height: 0;
overflow: hidden;
}
.app-backdrop {
background: #f5f7fb;
}
.layout-sider {
background: #ffffff;
border-right: 1px solid #e2e8f0;
}
.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;
}
.layout-sider-collapsed .brand-mark {
justify-content: center;
padding: 0;
}
.layout-sider-collapsed .brand-name {
display: none;
}
</style>