249 lines
7.0 KiB
Vue
249 lines
7.0 KiB
Vue
<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>
|