ec-admin/src/layouts/default.vue
liuyun 8a0f510e3a fix: let main content fill height so footer stays at bottom
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 00:26:00 +08:00

249 lines
7.0 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
: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.headerFixed ? `${HEADER_HEIGHT}px` : '0px',
'--ty-sidebar-height': 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: stretch;
min-height: 0;
}
.app-content {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
}
.app-main {
flex: 1;
padding: 16px;
}
</style>