Files
skt-vuetify-templates/src/components/layouts/MainLayout.vue
T

601 lines
17 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>
<v-app v-bind="$attrs" class="main-layout">
<v-navigation-drawer
v-model="drawer"
class="sk-admin-drawer"
color="surface"
:rail="isRail"
:rail-width="railWidth"
:temporary="isMobile"
:width="drawerWidth"
>
<template #prepend>
<!-- Sidebar Title -->
<v-card class="sidebar-header d-flex align-center pa-0 pl-3 py-2" flat>
<v-btn
:aria-label="sidebarToggleLabel"
color="grey"
:icon="isRail ? mdiMenuOpen : mdiMenu"
size="32"
variant="text"
@click="toggleSidebar"
/>
<v-card-text v-if="!isRail" class="sidebar-title flex-grow-1 py-0">
<slot name="title">
<div class="text-subtitle-1 font-weight-bold text-on-surface">
{{ branding.title }}
</div>
<div class="text-caption text-medium-emphasis">
{{ branding.subtitle }}
</div>
</slot>
</v-card-text>
</v-card>
<v-divider />
<!-- User Info -->
<v-card
v-if="features.showUserInfo"
class="user-info d-flex align-center pa-0 pl-3 py-2"
flat
>
<v-avatar class="user-avatar" color="primary" size="32" variant="tonal">
<span class="text-subtitle-2 font-weight-bold">{{ userProfile.avatarText }}</span>
</v-avatar>
<v-card-text v-if="!isRail" class="user-details flex-grow-1 py-0">
<div class="user-name text-body-2 font-weight-medium">
{{ userProfile.name }}
</div>
<div class="user-role text-caption text-medium-emphasis">
{{ userProfile.role }}
</div>
</v-card-text>
</v-card>
<v-divider />
<v-sheet
v-if="isMobile"
class="mobile-menu-subheader d-flex flex-column align-stretch ga-2 px-3 py-2"
>
<v-btn
v-if="features.showFavorites"
class="justify-start text-none"
color="primary"
rounded="pill"
size="small"
:variant="mobileFavoritesPanel ? 'flat' : 'outlined'"
@click="openMobileFavoritesPanel"
>
<span class="text-on-secondary text-caption font-weight-medium">{{
favoritesConfig.label
}}</span>
</v-btn>
<v-btn
v-for="step in mobileMenuLevels"
:key="`mobile-level-${step.level}`"
block
class="justify-start text-none"
:color="getMobileMenuBtnColor(step.level)"
rounded="pill"
size="small"
:variant="getMobileMenuBtnVariant(step.level)"
@click="goToMobileLevel(step.level)"
>
{{ step.title }}
</v-btn>
</v-sheet>
</template>
<!-- 桌面板選單 -->
<template v-if="!isMobile">
<DrawerDesktopMenu
v-model:opened="opened"
:is-shrink="isRail"
:menu-items="menuItems"
@select="handleSelect"
@unshrink="handleUnshrink"
/>
</template>
<!-- 行動版選單 -->
<template v-if="isMobile">
<DrawerMobileFavoritesPanel
v-if="features.showFavorites && mobileFavoritesPanel"
:favorite-items="favoriteItems"
@select="onSelectFavorite"
/>
<DrawerMobileMenuPanel
v-else
:mobile-current-items="mobileCurrentItems"
@item-click="onMobileMenuClick"
/>
</template>
</v-navigation-drawer>
<v-app-bar ref="appBarRef" class="" height="auto">
<v-row class="flex-column" no-gutters>
<AppBarTopCol
:features="features"
:is-mobile="isMobile"
:logout-label="logoutLabel"
:search-config="searchConfig"
:theme-toggle-label="themeToggleLabel"
:toolbar-actions="toolbarActions"
:toolbar-counts="toolbarCounts"
v-model:search-value="searchValue"
v-model:breadcrumb-bar-visible="breadcrumbBarVisible"
v-model:show-favorites-bar="showFavoritesBar"
@action="handleAction"
@logout="emitLogout"
@search="triggerSearch"
@toggle-drawer="drawer = !drawer"
@toggle-theme="toggleTheme"
>
<template v-if="$slots.actions" #actions>
<slot name="actions"></slot>
</template>
</AppBarTopCol>
<AppBarFavoritesCol
:favorite-items="favoriteItems"
:favorites-config="favoritesConfig"
:features="features"
:is-mobile="isMobile"
:show-favorites-bar="showFavoritesBar"
@add-favorite="emitAddFavorite"
@remove-favorite="emitRemoveFavorite"
@select="handleSelect"
@toggle-favorites-bar="toggleFavoritesBar"
/>
<AppBarBreadcrumbCol
:breadcrumb-items="breadcrumbItems"
:features="features"
:is-mobile="isMobile"
:breadcrumb-bar-visible="breadcrumbBarVisible"
:show-favorites-bar="showFavoritesBar"
@toggle-favorites-bar="toggleFavoritesBar"
>
<template v-if="$slots['breadcrumb-actions']" #breadcrumb-actions>
<slot name="breadcrumb-actions"></slot>
</template>
</AppBarBreadcrumbCol>
</v-row>
</v-app-bar>
<!--
動態 paddingTop避免可變高度的 v-app-bar 遮住內容
同時固定 v-main 的總高度避免整頁滾動改由內容區域自行滾動
-->
<v-main class="d-flex flex-column overflow-hidden" :style="mainStyle">
<v-container class="content-area" fluid>
<slot></slot>
</v-container>
</v-main>
<v-slide-y-reverse-transition>
<v-card v-if="helpWidgetVisible" class="help-widget" rounded="lg">
<v-card-item class="py-2">
<template #prepend>
<v-icon color="primary" :icon="mdiHelpCircleOutline" />
</template>
<v-card-title class="text-subtitle-2">操作說明</v-card-title>
<template #append>
<v-btn
aria-label="關閉說明"
:icon="mdiClose"
size="small"
variant="text"
@click="helpWidgetVisible = false"
/>
</template>
</v-card-item>
<v-divider />
<v-card-text class="text-body-2">
這裡先放暫時說明內容你可以保持此視窗開啟並繼續操作頁面上的其他功能
</v-card-text>
<v-card-actions class="justify-end pt-0">
<v-btn color="primary" size="small" variant="text" @click="helpWidgetVisible = false">
了解
</v-btn>
</v-card-actions>
</v-card>
</v-slide-y-reverse-transition>
<!-- <v-btn v-if="isMobile" class="mobile-menu-btn" color="primary" :icon="mdiMenu" @click="drawer = true" /> -->
</v-app>
</template>
<script setup lang="ts">
import type {
AdminLayoutActionType,
AdminLayoutBreadcrumbConfig,
AdminLayoutBreadcrumbItem,
AdminLayoutDrawerConfig,
AdminLayoutFavoritesConfig,
AdminLayoutFeatures,
AdminLayoutMenuItem,
AdminLayoutSearchConfig,
AdminLayoutToolbarActions,
AdminLayoutToolbarCounts,
AdminLayoutUserProfile,
} from './main-layout/types'
import { mdiClose, mdiHelpCircleOutline, mdiHome, mdiMenu, mdiMenuOpen } from '@mdi/js'
import { computed, ref, toRef } from 'vue'
import { useDisplay } from 'vuetify'
import { useAdminLayoutState } from '@/composables/layout/useAdminLayoutState'
import { useThemeToggle } from '@/composables/layout/useThemeToggle'
import AppBarBreadcrumbCol from './main-layout/AppBarBreadcrumbCol.vue'
import AppBarFavoritesCol from './main-layout/AppBarFavoritesCol.vue'
import AppBarTopCol from './main-layout/AppBarTopCol.vue'
import DrawerDesktopMenu from './main-layout/DrawerDesktopMenu.vue'
import DrawerMobileFavoritesPanel from './main-layout/DrawerMobileFavoritesPanel.vue'
import DrawerMobileMenuPanel from './main-layout/DrawerMobileMenuPanel.vue'
const emit = defineEmits<{
logout: []
select: [item: AdminLayoutMenuItem]
search: [keyword: string]
action: [type: AdminLayoutActionType]
'toggle-sidebar': [payload: { drawer: boolean; rail: boolean }]
'toggle-theme': [themeName: string]
'add-favorite': []
'remove-favorite': [item: AdminLayoutMenuItem]
'update:isRail': [value: boolean]
'update:favoritesBarVisible': [value: boolean]
'update:breadcrumbBarVisible': [value: boolean]
}>()
const defaultFeatures: AdminLayoutFeatures = {
showThemeToggle: false,
showFavorites: true,
showBreadcrumb: true,
showSearch: true,
showToolbarActions: true,
showUserInfo: true,
}
const defaultBreadcrumbConfig: AdminLayoutBreadcrumbConfig = {
homeLabel: '首頁',
homeDisabled: true,
homeIcon: mdiHome,
}
interface Props {
systemTitle?: string
systemSubtitle?: string
themeToggleLabel?: string
logoutLabel?: string
sidebarToggleLabel?: string
favoriteHeaderLabel?: string
favoriteItems?: AdminLayoutMenuItem[]
menuItems?: AdminLayoutMenuItem[]
userProfile?: AdminLayoutUserProfile
searchConfig?: AdminLayoutSearchConfig
toolbarActions?: AdminLayoutToolbarActions
toolbarCounts?: AdminLayoutToolbarCounts
favoritesConfig?: AdminLayoutFavoritesConfig
breadcrumbConfig?: AdminLayoutBreadcrumbConfig
breadcrumbItems?: AdminLayoutBreadcrumbItem[]
favoritesBarVisible?: boolean | null
breadcrumbBarVisible?: boolean | null
isRail?: boolean | null
features?: Partial<AdminLayoutFeatures>
drawerConfig?: AdminLayoutDrawerConfig
}
const props = withDefaults(defineProps<Props>(), {
systemTitle: '管理系統',
systemSubtitle: 'Campus System',
themeToggleLabel: '切換主題',
logoutLabel: '登出',
sidebarToggleLabel: '切換側欄',
favoriteHeaderLabel: '我的最愛',
favoriteItems: () => [],
menuItems: () => [{ title: '首頁', path: '/' }],
userProfile: () => ({
name: '王小明',
role: '資訊工程系 - 學生',
avatarText: '王',
}),
searchConfig: () => ({
placeholder: '搜尋功能名稱... (試試「成績」、「選課」、「請假」)',
label: '搜尋',
}),
toolbarActions: () => ({
notificationsLabel: '通知',
messagesLabel: '訊息',
helpLabel: '說明',
settingsLabel: '設定',
}),
toolbarCounts: () => ({
notifications: 0,
messages: 0,
}),
favoritesConfig: () => ({
label: '常用',
addLabel: '新增常用',
showAdd: false,
}),
breadcrumbConfig: () => ({
homeLabel: '首頁',
homeDisabled: true,
homeIcon: mdiHome,
}),
breadcrumbItems: () => [],
favoritesBarVisible: null,
breadcrumbBarVisible: null,
isRail: null,
features: () => ({
showThemeToggle: false,
showFavorites: true,
showBreadcrumb: true,
showSearch: true,
showToolbarActions: true,
showUserInfo: true,
}),
drawerConfig: () => ({
width: 280,
railWidth: 56,
}),
})
// Feature toggle: UI 區塊顯示
const features = computed(() => ({ ...defaultFeatures, ...props.features }))
// i18n / constants
const branding = computed(() => ({
title: props.systemTitle,
subtitle: props.systemSubtitle,
}))
const display = useDisplay()
const isMobile = computed(() => display.mdAndDown.value)
const appBarRef = ref<HTMLElement | null>(null)
const helpWidgetVisible = ref(false)
const drawerWidth = computed(() => props.drawerConfig?.width)
const railWidth = computed(() => props.drawerConfig?.railWidth)
const searchValue = ref('')
const { toggleTheme: switchTheme } = useThemeToggle()
const breadcrumbConfig = computed(() => ({ ...defaultBreadcrumbConfig, ...props.breadcrumbConfig }))
const breadcrumbItems = computed(() => {
if (props.breadcrumbItems?.length) return props.breadcrumbItems
return [
{
title: breadcrumbConfig.value.homeLabel,
disabled: breadcrumbConfig.value.homeDisabled,
icon: breadcrumbConfig.value.homeIcon,
},
]
})
const {
drawer,
goToMobileLevel,
handleMobileMenuClick,
handleSelectFavorite,
handleUnshrink,
isRail,
mainStyle,
mobileCurrentItems,
mobileCurrentLevel,
mobileFavoritesPanel,
mobileMenuLevels,
openMobileFavoritesPanel,
opened,
breadcrumbBarVisible,
showFavoritesBar,
toggleFavoritesBar,
toggleSidebar,
} = useAdminLayoutState({
appBarRef,
breadcrumbBarVisible: toRef(props, 'breadcrumbBarVisible'),
emitUpdateBreadcrumbBarVisible: (value) => emit('update:breadcrumbBarVisible', value),
emitUpdateFavoritesBarVisible: (value) => emit('update:favoritesBarVisible', value),
emitUpdateIsRail: (value) => emit('update:isRail', value),
favoritesBarVisible: toRef(props, 'favoritesBarVisible'),
isMobile,
isRail: toRef(props, 'isRail'), // 傳 Ref 確保 composable getter 能隨 prop 更新
menuItems: props.menuItems,
onToggleSidebar: (payload) => emit('toggle-sidebar', payload),
})
function toggleTheme() {
const next = switchTheme()
if (!next) return
emit('toggle-theme', next)
}
const emitLogout = () => emit('logout')
function handleAction(type: AdminLayoutActionType) {
if (type === 'help') {
helpWidgetVisible.value = true
}
emit('action', type)
}
// 以按鈕或 Enter 觸發搜尋,避免每個字都觸發
function triggerSearch() {
const keyword = searchValue.value
emit('search', keyword)
// 觸發後清空欄位,避免彈窗出現仍保留文字
searchValue.value = ''
}
function emitAddFavorite() {
emit('add-favorite')
}
function emitRemoveFavorite(item: AdminLayoutMenuItem) {
emit('remove-favorite', item)
}
function onSelectFavorite(item: AdminLayoutMenuItem) {
handleSelectFavorite(item, handleSelect)
}
function onMobileMenuClick(item: AdminLayoutMenuItem) {
handleMobileMenuClick(item, handleSelect)
}
function handleSelect(item: AdminLayoutMenuItem) {
emit('select', item)
if (isMobile.value) drawer.value = false
}
function getMobileMenuBtnVariant(level: number) {
return !mobileFavoritesPanel.value && level === mobileCurrentLevel.value ? 'flat' : 'outlined'
}
function getMobileMenuBtnColor(level: number) {
return level === mobileCurrentLevel.value ? 'primary' : 'secondary'
}
</script>
<style scoped>
.main-layout {
background: rgb(var(--v-theme-background));
}
.sk-admin-drawer {
border-right: 1px solid rgb(var(--v-theme-surface-variant));
}
/* 第二層選單Padding */
:deep(.sk-admin-drawer .v-list-group__items) {
--indent-padding: 12px;
}
/* 第三層選單Padding */
:deep(.sk-admin-drawer .v-list-group__items .v-list-group__items) {
--indent-padding: 20px;
}
.menu-count {
min-width: 28px;
justify-content: center;
}
.content-area {
display: flex;
flex-direction: column;
flex: 1 1 0;
min-height: 0;
padding: 8px;
padding-top: 4px;
background: rgb(var(--v-theme-background));
}
.search-input-wrapper {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
}
:deep(.search-input-wrapper .v-field--appended) {
padding-inline-end: 4px;
}
.top-actions {
display: flex;
align-items: center;
gap: 4px;
}
.favorites-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
padding: 12px 16px;
border-radius: 16px;
}
.favorites-label {
margin-right: 4px;
}
.mobile-favorites-panel,
.mobile-menu-panel {
min-height: 0;
height: 100%;
}
.mobile-menu-subheader {
border-bottom: 1px solid rgb(var(--v-theme-surface-variant));
}
.favorites-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.favorite-item,
.favorite-add {
text-transform: none;
}
.mobile-menu-btn {
position: fixed;
right: 20px;
bottom: 20px;
z-index: 200;
box-shadow: 0 10px 24px rgba(var(--v-theme-on-surface), 0.25);
}
.help-widget {
position: fixed;
right: 16px;
bottom: 16px;
z-index: 220;
width: min(360px, calc(100vw - 32px));
border: 1px solid rgb(var(--v-theme-surface-variant));
box-shadow: 0 12px 24px rgba(var(--v-theme-on-surface), 0.15);
}
.nav-text-overflow {
display: block;
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 確保 v-list-group 的 activator 也有 hover 效果 */
:deep(.v-list-group > .v-list-item) {
cursor: pointer;
transition:
background-color 0.2s ease,
color 0.2s ease;
}
:deep(.v-list-group > .v-list-item:hover) {
background: rgb(var(--v-theme-on-surface-variant));
color: rgb(var(--v-theme-on-surface));
}
/* 為所有 v-list-item 加上 transition */
:deep(.v-list-item) {
transition:
background-color 0.2s ease,
color 0.2s ease;
}
/* 確保滾軸邊距 */
:deep(.v-navigation-drawer__content) {
/* scrollbar-gutter: stable; */
}
@supports not (scrollbar-gutter: stable) {
:deep(.v-navigation-drawer__content) {
/* overflow-y: scroll; */
}
}
</style>