sysmgr-admin/src/layouts/default.vue
2026-10-11 22:32:25 +08:00

346 lines
11 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" :class="{ 'is-not-fixed': !app.headerFixed }" :style="cssVars">
<ty-header
:title="title"
:logo="logo"
:show-title="showTitle"
:logo-width="logoWidth"
:has-sidebar="showSidebar"
: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"
@change-password="handleChangePassword"
@logout="handleLogout"
/>
<div class="app-body">
<ty-mixbar
v-if="app.hasMixbar"
:menus="menus"
:active-code="activeTop?.code ?? ''"
@select="handleTopSelect"
/>
<ty-sidebar
v-if="showSidebar"
: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="handleTabClick"
@remove="handleTabRemove"
@close-others="handleCloseOthers"
@close-left="handleCloseLeft"
@close-right="handleCloseRight"
@close-all="handleCloseAll"
/>
<div class="app-main">
<router-view v-slot="{ Component, route }">
<keep-alive :include="cachedNames">
<component :is="Component" :key="route.path" />
</keep-alive>
</router-view>
</div>
<ty-footer :fixed="app.footerFixed" :copyright="copyright" />
</main>
</div>
<ty-setting v-model="settingsOpen" :settings="settings" @update="applySettings" />
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useAppStore } from '@/stores/modules/app'
import { HOME_PATH, pathToName, useTabsStore } from '@/stores/modules/tabs'
import { useAuthStore } from '@/stores/modules/auth'
import { useMenuStore } from '@/stores/modules/menu'
import { useDictStore } from '@/stores/modules/dict'
import { usePermissionStore } from '@/stores/modules/permission'
import defaultLogo from '@/assets/images/logo.png'
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'
import { showDialog } from '@/utils/dialog'
import { changePassword } from '@/api/auth'
import ChangePasswordForm from '@/components/change-password-form.vue'
const app = useAppStore()
const tabsStore = useTabsStore()
const auth = useAuthStore()
const menuStore = useMenuStore()
const dictStore = useDictStore()
const permissionStore = usePermissionStore()
const route = useRoute()
const router = useRouter()
const { isFullscreen, toggle: toggleFullscreen } = useFullscreen()
const title = computed(() => auth.brand?.brandName || auth.brand?.name || '投云科技')
const logo = computed(() => auth.brand?.logoUrl || defaultLogo)
const copyright = computed(() => auth.brand?.copyright || '云南投云科技有限公司')
const HEADER_HEIGHT = 56
const menus = computed(() => menuStore.menus)
const settingsOpen = ref(false)
// 登录后拉取菜单、字典、权限码与租户品牌
onMounted(async () => {
menuStore.fetch()
dictStore.fetch()
permissionStore.fetch()
try {
await auth.fetchBrandState()
if (auth.brand?.themeColor) app.themeColor = auth.brand.themeColor
} catch {
// 品牌拉取失败则用默认
}
})
const activePath = computed(() => route.path)
/** keep-alive 缓存的组件名集合:打开且未关闭缓存的页签(keepAlive !== false 以兼容旧持久化数据)。 */
const cachedNames = computed(() => tabsStore.tabs.filter((t) => t.keepAlive !== false).map((t) => pathToName(t.path)))
/** 当前路由对应的顶级菜单(及其叶子)。 */
const activeTop = computed<ResourceNode | null>(() => {
for (const m of menus.value) {
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.value
})
/** 是否显示左侧菜单:有侧栏且当前顶级下存在子菜单。 */
const showSidebar = computed(() => app.hasSidebar && sidebarMenus.value.length > 0)
/** 面包屑:顶级 + 当前叶子。 */
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 }]
})
/** Logo 区宽度:与左侧栏(MixBar+菜单)对齐;无左侧栏时自适应。 */
const logoWidth = computed<number | null>(() => {
const mix = app.hasMixbar ? 64 : 0
const side = showSidebar.value ? (app.sidebarCollapsed ? 64 : app.sidebarWidth) : 0
const total = mix + side
return total > 0 ? total : null
})
/** 侧栏折叠/隐藏时仅显 Logo,展开时显 Logo + 标题。 */
const showTitle = computed(() => {
if (logoWidth.value == null) return true
return showSidebar.value && !app.sidebarCollapsed
})
/** 侧边栏 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.value.find((m) => m.code === code)
const target = top ? firstLeafPath(top) : null
if (target) router.push(target)
}
function handleTabClick(path: string): void {
router.push(path)
}
function handleTabRemove(path: string): void {
const index = tabsStore.tabs.findIndex((t) => t.path === path)
const neighbor = tabsStore.tabs[index - 1] ?? tabsStore.tabs[index + 1] ?? tabsStore.tabs[0]
tabsStore.removeTab(path)
if (activePath.value === path) {
router.push(neighbor?.path ?? HOME_PATH)
}
}
function handleCloseOthers(path: string): void {
tabsStore.removeOthers(path)
router.push(path)
}
function handleCloseLeft(path: string): void {
tabsStore.removeLeft(path)
if (!tabsStore.tabs.some((t) => t.path === activePath.value)) router.push(path)
}
function handleCloseRight(path: string): void {
tabsStore.removeRight(path)
if (!tabsStore.tabs.some((t) => t.path === activePath.value)) router.push(path)
}
function handleCloseAll(): void {
tabsStore.removeAll()
router.push(HOME_PATH)
}
async function handleLogout(): Promise<void> {
await auth.logout()
menuStore.reset()
dictStore.reset()
permissionStore.reset()
tabsStore.removeAll()
router.push('/login')
}
async function handleChangePassword(): Promise<void> {
const result = await showDialog({
component: ChangePasswordForm,
title: '修改密码',
width: '420px',
})
if (result == null) return
await changePassword(result.oldPassword, result.newPassword)
ElMessage.success('密码已修改,请重新登录')
await handleLogout()
}
/** 把菜单索引(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.value) {
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 !== '/', keepAlive: route.meta.keepAlive !== false })
},
{ immediate: true },
)
</script>
<style scoped lang="scss">
.app-shell {
@apply flex flex-col h-screen overflow-hidden;
&.is-not-fixed {
@apply h-auto min-h-screen overflow-visible;
}
}
.app-body {
@apply flex flex-1 items-stretch min-h-0;
}
.app-content {
@apply flex flex-1 flex-col min-w-0 min-h-0;
}
.app-main {
@apply flex-1 min-h-0 p-4 overflow-y-auto bg-bg-page;
.is-not-fixed & {
@apply overflow-y-visible;
}
}
</style>