ec-admin/src/layouts/default.vue
liuyun 6ef0b1121c feat: admin layout shell with Element Plus and configurable theme
- 集成 Element Plus(on-demand resolver + 中文 locale + 暗黑 css-vars)
- 布局壳组件(ty-header/ty-mixbar/ty-sidebar/ty-tabs/ty-breadcrumb/ty-footer/ty-setting/ty-setting-panel),中划线 + ty 前缀 + 自动导入,props/emits 解耦
- 5 种布局/导航模式 + 主题色(#2E7FA3) + 暗黑 + 侧栏折叠/宽度 + 固定顶栏/底栏 + 面包屑/多页签/全屏/手风琴
- 默认值集中配置 src/config/layout.ts;菜单字段对齐后端 resource(src/types/resource.ts + src/config/menu.ts)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 00:12:59 +08:00

250 lines
7.1 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>
<div class="app-shell" :style="cssVars">
<ty-header
v-if="app.hasHeader"
:title="title"
:has-sidebar="app.hasSidebar"
:collapsed="app.sidebarCollapsed"
:fixed="app.headerFixed"
:show-breadcrumb="app.showBreadcrumb"
:breadcrumbs="breadcrumbs"
:show-fullscreen="app.showFullscreen"
:is-fullscreen="isFullscreen"
:is-dark="app.isDark"
:layout="app.layout"
:menus="menus"
:active-path="activePath"
:username="username"
@toggle-collapse="app.sidebarCollapsed = !app.sidebarCollapsed"
@toggle-dark="app.isDark = !app.isDark"
@toggle-fullscreen="toggleFullscreen"
@open-settings="settingsOpen = true"
@navigate="handleNavigate"
@logout="handleLogout"
/>
<div class="app-body">
<ty-mixbar
v-if="app.hasMixbar"
:menus="menus"
:active-code="activeTop?.code ?? ''"
@select="handleTopSelect"
/>
<ty-sidebar
v-if="app.hasSidebar"
:menus="sidebarMenus"
:collapsed="app.sidebarCollapsed"
:width="app.sidebarWidth"
:accordion="app.menuAccordion"
:active-path="activePath"
@select="handleNavigate"
/>
<main class="app-content">
<ty-tabs
v-if="app.showTabs"
:tabs="tabsStore.tabs"
:active-path="activePath"
@click="handleNavigate"
@remove="handleTabRemove"
/>
<div class="app-main">
<router-view />
</div>
<ty-footer :fixed="app.footerFixed" />
</main>
</div>
<ty-setting v-model="settingsOpen" :settings="settings" @update="applySettings" />
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAppStore } from '@/stores/modules/app'
import { useTabsStore } from '@/stores/modules/tabs'
import { useAuthStore } from '@/stores/modules/auth'
import { placeholderMenus } from '@/config/menu'
import type { LayoutSettings } from '@/config/layout'
import type { ResourceNode } from '@/types/resource'
import type { BreadcrumbItem } from '@/components/ty-breadcrumb.vue'
import { applyTheme } from '@/utils/theme'
const app = useAppStore()
const tabsStore = useTabsStore()
const auth = useAuthStore()
const route = useRoute()
const router = useRouter()
const { isFullscreen, toggle: toggleFullscreen } = useFullscreen()
const title = '投云科技'
const HEADER_HEIGHT = 56
const menus: ResourceNode[] = placeholderMenus
const settingsOpen = ref(false)
const activePath = computed(() => route.path)
/** 当前路由对应的顶级菜单(及其叶子)。 */
const activeTop = computed<ResourceNode | null>(() => {
for (const m of menus) {
if ((m.path ?? m.code) === activePath.value) return m
if (m.children?.some((c) => (c.path ?? c.code) === activePath.value)) return m
}
return null
})
/** 侧边栏菜单:horizontal/mixbar 显示当前顶级下的子菜单;sidebar 显示全树。 */
const sidebarMenus = computed<ResourceNode[]>(() => {
if (app.layout === 'top') return []
if (app.layout === 'horizontal') return activeTop.value?.children ?? []
return app.mixbar ? (activeTop.value?.children ?? []) : menus
})
/** 面包屑:顶级 + 当前叶子。 */
const breadcrumbs = computed<BreadcrumbItem[]>(() => {
const top = activeTop.value
if (!top) return []
if ((top.path ?? top.code) === activePath.value) return [{ title: top.name }]
const leaf = top.children?.find((c) => (c.path ?? c.code) === activePath.value)
if (leaf) return [{ title: top.name }, { title: leaf.name }]
return [{ title: top.name }]
})
/** 侧边栏 sticky 定位(有固定顶栏时往下让出顶栏高度)。 */
const cssVars = computed(() => ({
'--ty-header-height': `${HEADER_HEIGHT}px`,
'--ty-sidebar-top': app.hasHeader && app.headerFixed ? `${HEADER_HEIGHT}px` : '0px',
'--ty-sidebar-height': app.hasHeader && app.headerFixed ? `calc(100vh - ${HEADER_HEIGHT}px)` : '100vh',
}))
/** 布局设置快照(传给设置面板)。 */
const settings = computed<LayoutSettings>(() => ({
layout: app.layout,
mixbar: app.mixbar,
themeColor: app.themeColor,
isDark: app.isDark,
headerFixed: app.headerFixed,
sidebarWidth: app.sidebarWidth,
footerFixed: app.footerFixed,
sidebarCollapsed: app.sidebarCollapsed,
showBreadcrumb: app.showBreadcrumb,
showTabs: app.showTabs,
showFullscreen: app.showFullscreen,
menuAccordion: app.menuAccordion,
}))
/** 用户名(从 access token 解出)。 */
const username = computed(() => decodeUsername(auth.accessToken))
function applySettings(s: LayoutSettings): void {
app.$patch(s)
}
function handleNavigate(index: string): void {
const target = resolveTarget(index)
if (target) router.push(target)
}
function handleTopSelect(code: string): void {
const top = menus.find((m) => m.code === code)
const target = top ? firstLeafPath(top) : null
if (target) router.push(target)
}
function handleTabRemove(path: string): void {
tabsStore.removeTab(path)
if (activePath.value === path) {
const last = tabsStore.tabs[tabsStore.tabs.length - 1]
router.push(last?.path ?? '/')
}
}
async function handleLogout(): Promise<void> {
await auth.logout()
router.push('/login')
}
/** 把菜单索引(path 或 code)解析为可跳转的叶子路径。 */
function resolveTarget(index: string): string | null {
const node = findMenu(index)
if (!node) return index
if (node.children?.length) return firstLeafPath(node)
return node.path ?? node.code
}
function findMenu(index: string): ResourceNode | null {
for (const m of menus) {
if ((m.path ?? m.code) === index) return m
const child = m.children?.find((c) => (c.path ?? c.code) === index)
if (child) return child
}
return null
}
function firstLeafPath(node: ResourceNode): string | null {
if (!node.children?.length) return node.path ?? node.code
return firstLeafPath(node.children[0])
}
function decodeUsername(token: string | null): string {
if (!token) return ''
try {
const payload = token.split('.')[1]
const base64 = payload.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(payload.length / 4) * 4, '=')
const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0))
const json = JSON.parse(new TextDecoder().decode(bytes))
return (json.username as string) ?? ''
} catch {
return ''
}
}
// 主题应用
watch(
[() => app.themeColor, () => app.isDark],
([color, dark]) => applyTheme(color, dark),
{ immediate: true },
)
// 页签同步
watch(
activePath,
(p) => {
const top = activeTop.value
const leaf = top?.children?.find((c) => (c.path ?? c.code) === p)
const title = leaf?.name ?? top?.name ?? p
tabsStore.addTab({ path: p, title, closable: p !== '/' })
},
{ immediate: true },
)
</script>
<style scoped lang="scss">
.app-shell {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.app-body {
display: flex;
flex: 1;
align-items: flex-start;
min-height: 0;
}
.app-content {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
}
.app-main {
flex: 1;
padding: 16px;
}
</style>