346 lines
11 KiB
Vue
346 lines
11 KiB
Vue
<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>
|