feat: 重構主佈局及相關元件,更新命名規則並新增功能

This commit is contained in:
skytek_xinliang
2026-03-30 15:14:12 +08:00
parent 79b20ded3b
commit d7ebbda3d3
10 changed files with 27 additions and 27 deletions
@@ -0,0 +1,91 @@
<template>
<v-col
v-if="features.showBreadcrumb && breadcrumbBarVisible && !isMobile"
class="d-flex align-center justify-space-between pr-2 pl-3 py-1 bg-surface"
>
<v-breadcrumbs class="pa-0" density="compact" :items="breadcrumbItems">
<template #prepend>
<v-btn
v-if="features.showFavorites && !showFavoritesBar"
class="mr-2"
color="primary"
size="small"
variant="outlined"
@click="emit('toggle-favorites-bar', true)"
>
常用
</v-btn>
</template>
<template #item="{ item }">
<div class="d-flex align-center ga-1">
<v-icon
v-if="getBreadcrumbIcon(item)"
class="mr-1"
size="14"
:icon="getBreadcrumbIcon(item)"
/>
<span class="text-caption text-no-wrap">{{ getBreadcrumbTitle(item) }}</span>
</div>
</template>
<template #divider>
<v-icon color="primary-variant" size="12" :icon="mdiChevronRight" />
</template>
</v-breadcrumbs>
<div class="page-actions">
<slot name="breadcrumb-actions"></slot>
</div>
</v-col>
</template>
<script setup lang="ts">
import type { AdminLayoutBreadcrumbItem, AdminLayoutFeatures } from './types'
import { mdiChevronRight } from '@mdi/js'
interface Props {
features?: AdminLayoutFeatures
breadcrumbBarVisible?: boolean
isMobile?: boolean
breadcrumbItems?: AdminLayoutBreadcrumbItem[]
showFavoritesBar?: boolean
}
withDefaults(defineProps<Props>(), {
features: () => ({
showThemeToggle: false,
showFavorites: true,
showBreadcrumb: true,
showSearch: true,
showToolbarActions: true,
showUserInfo: true,
}),
breadcrumbBarVisible: true,
isMobile: false,
breadcrumbItems: () => [],
showFavoritesBar: true,
})
const emit = defineEmits<{
'toggle-favorites-bar': [value: boolean]
}>()
function getBreadcrumbItem(item: unknown): AdminLayoutBreadcrumbItem | null {
if (typeof item !== 'object' || item === null) return null
if ('title' in item) {
return item as AdminLayoutBreadcrumbItem
}
const raw = (item as { raw?: unknown }).raw
if (typeof raw !== 'object' || raw === null || !('title' in raw)) return null
return raw as AdminLayoutBreadcrumbItem
}
function getBreadcrumbIcon(item: unknown) {
return getBreadcrumbItem(item)?.icon
}
function getBreadcrumbTitle(item: unknown) {
return getBreadcrumbItem(item)?.title ?? ''
}
</script>
@@ -0,0 +1,99 @@
<template>
<v-col
v-if="features.showFavorites && showFavoritesBar && !isMobile"
class="d-flex align-center pr-2 pl-3 py-1 bg-surface"
>
<div class="favorites-label text-body-2 text-no-wrap pe-2">
{{ favoritesConfig.label }}
</div>
<div class="favorites-list flex-grow-1 d-flex flex-wrap ga-2">
<transition-group class="d-flex flex-wrap ga-2" name="favorite-list" tag="div">
<v-chip
v-for="item in favoriteItems"
:key="item.path ?? item.title"
class="favorite-item"
closable
color="secondary"
size="small"
variant="outlined"
@click="emit('select', item)"
@click:close="emit('remove-favorite', item)"
>
<v-icon v-if="item.icon" class="me-1" size="16" :icon="item.icon" />
<span class="text-caption">{{ item.title }}</span>
</v-chip>
</transition-group>
<v-btn
v-if="favoritesConfig.showAdd"
class="favorite-add"
color="primary"
size="small"
variant="outlined"
@click="emit('add-favorite')"
>
<v-icon class="mr-1" size="16" :icon="mdiPlus" />
<span class="text-caption">{{ favoritesConfig.addLabel }}</span>
</v-btn>
</div>
<v-btn color="grey" size="small" variant="text" @click="emit('toggle-favorites-bar', false)">
<v-icon :icon="mdiEyeOff" />
</v-btn>
</v-col>
</template>
<script setup lang="ts">
import type { AdminLayoutFavoritesConfig, AdminLayoutFeatures, AdminLayoutMenuItem } from './types'
import { mdiEyeOff, mdiPlus } from '@mdi/js'
interface Props {
features?: AdminLayoutFeatures
showFavoritesBar?: boolean
isMobile?: boolean
favoritesConfig?: AdminLayoutFavoritesConfig
favoriteItems?: AdminLayoutMenuItem[]
}
withDefaults(defineProps<Props>(), {
features: () => ({
showThemeToggle: false,
showFavorites: true,
showBreadcrumb: true,
showSearch: true,
showToolbarActions: true,
showUserInfo: true,
}),
showFavoritesBar: true,
isMobile: false,
favoritesConfig: () => ({
label: '',
addLabel: '',
showAdd: false,
}),
favoriteItems: () => [],
})
const emit = defineEmits<{
select: [item: AdminLayoutMenuItem]
'add-favorite': []
'remove-favorite': [item: AdminLayoutMenuItem]
'toggle-favorites-bar': [value: boolean]
}>()
</script>
<style scoped>
.favorite-item {
text-transform: none;
}
.favorite-list-enter-active,
.favorite-list-leave-active,
.favorite-list-move {
transition: all 0.2s ease;
}
.favorite-list-enter-from,
.favorite-list-leave-to {
opacity: 0;
transform: scale(0.92);
}
</style>
@@ -0,0 +1,314 @@
<template>
<v-col class="d-flex align-center bg-surface pr-2 pl-2 pl-m-3 py-1">
<v-btn
v-if="isMobile"
:icon="mdiMenu"
size="small"
variant="text"
@click="emit('toggle-drawer')"
></v-btn>
<div v-if="features.showSearch" class="search-input-wrapper">
<span id="admin-layout-search-label" class="sr-only">{{ searchConfig.label }}</span>
<v-text-field
v-model="searchValueModel"
aria-labelledby="admin-layout-search-label"
:aria-label="searchConfig.label"
class="search-input"
density="compact"
hide-details
name="layout-search"
:placeholder="searchConfig.placeholder"
variant="outlined"
@keyup.enter="triggerSearch"
>
<template v-if="!isMobile" #prepend-inner>
<v-icon size="small" :icon="mdiMagnify" />
</template>
<template #append-inner>
<v-btn
:aria-label="searchConfig.label"
color="primary"
size="small"
variant="text"
@click="triggerSearch"
>
開始搜尋
</v-btn>
</template>
</v-text-field>
</div>
<div v-if="features.showToolbarActions" class="top-actions">
<slot name="actions">
<!-- 通知 -->
<v-tooltip location="bottom" :text="toolbarActions.notificationsLabel">
<template #activator="{ props: activatorProps }">
<v-btn
v-bind="activatorProps"
:aria-label="toolbarActions.notificationsLabel"
icon
size="small"
variant="text"
@click="emit('action', 'notifications')"
>
<v-badge
v-if="toolbarCounts.notifications"
color="error"
:content="toolbarCounts.notifications"
offset-x="4"
offset-y="-2"
>
<v-icon :icon="mdiBellOutline" />
</v-badge>
<v-icon v-else :icon="mdiBellOutline" />
</v-btn>
</template>
</v-tooltip>
<!-- 訊息 -->
<v-tooltip location="bottom" :text="toolbarActions.messagesLabel">
<template #activator="{ props: activatorProps }">
<v-btn
v-bind="activatorProps"
:aria-label="toolbarActions.messagesLabel"
icon
size="small"
variant="text"
@click="emit('action', 'messages')"
>
<v-badge
v-if="toolbarCounts.messages"
color="warning"
:content="toolbarCounts.messages"
offset-x="4"
offset-y="-2"
>
<v-icon :icon="mdiMessageTextOutline" />
</v-badge>
<v-icon v-else :icon="mdiMessageTextOutline" />
</v-btn>
</template>
</v-tooltip>
<!-- 說明 -->
<v-tooltip v-if="false" location="bottom" :text="toolbarActions.helpLabel">
<template #activator="{ props: activatorProps }">
<v-btn
v-bind="activatorProps"
:aria-label="toolbarActions.helpLabel"
icon
size="small"
variant="text"
@click="emit('action', 'help')"
>
<v-icon :icon="mdiHelp" />
</v-btn>
</template>
</v-tooltip>
<!-- 設定 -->
<v-menu :close-on-content-click="false" location="bottom end">
<template #activator="{ props: menuProps }">
<v-tooltip location="bottom" :text="toolbarActions.settingsLabel">
<template #activator="{ props: tooltipProps }">
<v-btn
v-bind="{ ...menuProps, ...tooltipProps }"
:aria-label="toolbarActions.settingsLabel"
icon
size="small"
variant="text"
>
<v-icon :icon="mdiCogOutline" />
</v-btn>
</template>
</v-tooltip>
</template>
<v-list density="compact" width="180">
<v-list-subheader class="text-subtitle-1 py-2">顯示設定</v-list-subheader>
<v-list-item>
<v-switch
v-model="showFavoritesBarModel"
color="primary"
density="comfortable"
hide-details
>
<template #label>
<span class="text-body-2" style="width: 8ch">常用功能</span>
</template>
</v-switch>
</v-list-item>
<v-list-item>
<v-switch
v-model="breadcrumbBarVisibleModel"
color="primary"
density="comfortable"
hide-details
>
<template #label>
<span class="text-body-2" style="width: 8ch">路徑</span>
</template>
</v-switch>
</v-list-item>
</v-list>
</v-menu>
<!-- 登出 -->
<v-tooltip location="bottom" :text="logoutLabel">
<template #activator="{ props: activatorProps }">
<v-btn
v-bind="activatorProps"
:aria-label="logoutLabel"
icon
size="small"
variant="text"
@click="emit('logout')"
>
<v-icon :icon="mdiLogout" />
</v-btn>
</template>
</v-tooltip>
<v-tooltip v-if="features.showThemeToggle" location="bottom" :text="themeToggleLabel">
<template #activator="{ props: activatorProps }">
<v-btn
v-bind="activatorProps"
:aria-label="themeToggleLabel"
icon
variant="text"
@click="emit('toggle-theme')"
>
<v-icon :icon="mdiPaletteOutline" />
</v-btn>
</template>
</v-tooltip>
</slot>
</div>
</v-col>
</template>
<script setup lang="ts">
import type {
AdminLayoutActionType,
AdminLayoutFeatures,
AdminLayoutSearchConfig,
AdminLayoutToolbarActions,
AdminLayoutToolbarCounts,
} from './types'
import {
mdiBellOutline,
mdiCogOutline,
mdiHelp,
mdiLogout,
mdiMagnify,
mdiMenu,
mdiMessageTextOutline,
mdiPaletteOutline,
} from '@mdi/js'
import { computed } from 'vue'
interface Props {
isMobile?: boolean
features?: AdminLayoutFeatures
searchValue?: string
searchConfig?: AdminLayoutSearchConfig
toolbarActions?: AdminLayoutToolbarActions
toolbarCounts?: AdminLayoutToolbarCounts
logoutLabel?: string
themeToggleLabel?: string
showFavoritesBar?: boolean
breadcrumbBarVisible?: boolean
}
const props = withDefaults(defineProps<Props>(), {
isMobile: false,
features: () => ({
showThemeToggle: false,
showFavorites: true,
showBreadcrumb: true,
showSearch: true,
showToolbarActions: true,
showUserInfo: true,
}),
searchValue: '',
searchConfig: () => ({
placeholder: '',
label: '',
}),
toolbarActions: () => ({
notificationsLabel: '',
messagesLabel: '',
helpLabel: '',
settingsLabel: '',
}),
toolbarCounts: () => ({
notifications: 0,
messages: 0,
}),
logoutLabel: '',
themeToggleLabel: '',
showFavoritesBar: true,
breadcrumbBarVisible: true,
})
const emit = defineEmits<{
'toggle-drawer': []
'update:searchValue': [value: string]
search: []
action: [type: AdminLayoutActionType]
logout: []
'toggle-theme': []
'update:show-favorites-bar': [value: boolean]
'update:breadcrumb-bar-visible': [value: boolean]
}>()
const searchValueModel = computed({
get: () => props.searchValue,
set: (value) => emit('update:searchValue', value),
})
const showFavoritesBarModel = computed({
get: () => props.showFavoritesBar,
set: (value) => emit('update:show-favorites-bar', value),
})
const breadcrumbBarVisibleModel = computed({
get: () => props.breadcrumbBarVisible,
set: (value) => emit('update:breadcrumb-bar-visible', value),
})
function triggerSearch() {
emit('search')
}
</script>
<style scoped>
.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: 8px;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
</style>
@@ -0,0 +1,258 @@
<template>
<v-list v-model:opened="openedModel" color="primary" density="compact" prepend-gap="8">
<template v-for="item in menuItems" :key="item.path ?? item.title">
<v-list-group
v-if="item.subItems?.length"
:id="getGroupId(item)"
:value="getGroupValue(item)"
>
<template #activator="{ props: activatorProps }">
<v-list-item
v-bind="isShrink ? undefined : activatorProps"
:class="{ 'px-0': isShrink }"
:link="isNavigable(item) && !!item.path"
:to="isNavigable(item) ? item.path : undefined"
@click="emitSelect(item)"
>
<template #prepend>
<v-icon v-if="item.icon" size="20" :icon="item.icon" />
<v-btn
v-if="isShrink && !item.icon"
class=""
rounded
size="36"
spaced="start"
variant="text"
>{{ item.title?.charAt(0) }}</v-btn
>
</template>
<template #title>
<span v-if="!isShrink" class="text-body-2 nav-text-overflow">{{ item.title }}</span>
</template>
<template #append>
<v-chip
v-if="!isShrink && getItemCount(item) > 0"
class="menu-count"
color="secondary"
size="x-small"
variant="tonal"
>
{{ getItemCount(item) }}
</v-chip>
</template>
</v-list-item>
</template>
<template v-for="subItem in item.subItems" :key="subItem.path ?? subItem.title">
<v-list-group
v-if="subItem.subItems?.length"
:id="getGroupId(subItem, getGroupId(item))"
:value="getGroupValue(subItem, getGroupValue(item))"
>
<template #activator="{ props: subProps }">
<v-list-item
v-bind="subProps"
:link="isNavigable(subItem)"
:prepend-icon="subItem.icon || mdiMenuRight"
:to="isNavigable(subItem) ? subItem.path : undefined"
@click="emitSelect(subItem)"
>
<template #title>
<span class="text-body-2 nav-text-overflow">{{ subItem.title }}</span>
</template>
<template #append>
<v-chip
v-if="getItemCount(subItem) > 0"
class="menu-count"
color="secondary"
size="x-small"
variant="tonal"
>
{{ getItemCount(subItem) }}
</v-chip>
</template>
</v-list-item>
</template>
<v-list-item
v-for="subSubItem in subItem.subItems"
:key="subSubItem.path ?? subSubItem.title"
:link="!!subSubItem.path"
:prepend-icon="mdiCircleSmall"
:to="subSubItem.path"
@click="emitSelect(subSubItem)"
>
<template #title>
<v-tooltip location="end" :text="subSubItem.title">
<template #activator="{ props: tooltipProps }">
<span v-bind="tooltipProps" class="text-body-2 nav-text-overflow">{{
subSubItem.title
}}</span>
</template>
</v-tooltip>
</template>
</v-list-item>
</v-list-group>
<v-list-item
v-else
:link="!!subItem.path"
:prepend-icon="subItem.icon || mdiMenuRight"
:to="subItem.path"
@click="emitSelect(subItem)"
>
<template #title>
<v-tooltip location="end" :text="subItem.title">
<template #activator="{ props: tooltipProps }">
<span v-bind="tooltipProps" class="text-body-2 nav-text-overflow">{{
subItem.title
}}</span>
</template>
</v-tooltip>
</template>
</v-list-item>
</template>
</v-list-group>
<v-list-item
v-else
:class="{ 'px-0': isShrink }"
:link="!!item.path"
:to="item.path"
@click="emitSelect(item)"
>
<template #prepend>
<v-icon v-if="item.icon" size="20" :icon="item.icon" />
<v-btn
v-if="isShrink && !item.icon"
class=""
rounded
size="36"
spaced="start"
variant="text"
>{{ item.title?.charAt(0) }}</v-btn
>
</template>
<template #title>
<v-tooltip v-if="!isShrink" location="end" :text="item.title">
<template #activator="{ props: tooltipProps }">
<span v-bind="tooltipProps" class="text-body-2 nav-text-overflow">{{
item.title
}}</span>
</template>
</v-tooltip>
</template>
</v-list-item>
</template>
</v-list>
</template>
<script setup lang="ts">
import type { AdminLayoutMenuItem } from './types'
import { mdiCircleSmall, mdiMenuRight } from '@mdi/js'
import { computed, watch } from 'vue'
interface Props {
opened?: string[]
menuItems?: AdminLayoutMenuItem[]
isShrink?: boolean
}
const props = withDefaults(defineProps<Props>(), {
opened: () => [],
menuItems: () => [],
isShrink: false,
})
const emit = defineEmits<{
'update:opened': [value: string[]]
select: [item: AdminLayoutMenuItem]
unshrink: []
}>()
const openedModel = computed({
get: () => (props.isShrink ? [] : props.opened),
set: (value) => {
if (!props.isShrink) {
emit('update:opened', value)
}
},
})
// 當側邊欄收縮時,自動收起所有展開的子選單
watch(
() => props.isShrink,
(newVal) => {
if (newVal) {
openedModel.value = []
}
}
)
const isNavigable = (item: AdminLayoutMenuItem) => item?.navigable !== false
function emitSelect(item: AdminLayoutMenuItem) {
// 收縮狀態下點擊選單項目時,先解除收縮再進行選擇
// 這樣可以讓使用者看到完整的選單結構和導航結果
if (props.isShrink) {
emit('unshrink')
}
emit('select', item)
}
function getItemCount(item: AdminLayoutMenuItem) {
if (!item?.subItems?.length) return 0
const countLeaf = (list: AdminLayoutMenuItem[]): number =>
(list || []).reduce((total: number, current: AdminLayoutMenuItem) => {
if (current?.subItems?.length) return total + countLeaf(current.subItems)
return total + 1
}, 0)
return countLeaf(item.subItems)
}
function getGroupValue(item: AdminLayoutMenuItem, parentKey?: string) {
const rawKey = item.path ?? item.title ?? 'group'
const normalizedKey = Array.from(rawKey.trim())
.map((char) => {
if (/[a-z0-9]/i.test(char)) {
return char.toLowerCase()
}
return `u${char.codePointAt(0)?.toString(16)}`
})
.join('-')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '')
if (!parentKey) {
return `menu-${normalizedKey || 'group'}`
}
return `${parentKey}__${normalizedKey || 'group'}`
}
function getGroupId(item: AdminLayoutMenuItem, parentId?: string) {
const groupId = getGroupValue(item).replace(/^menu-/, 'group-')
if (!parentId) {
return `nav-${groupId}`
}
return `${parentId}__${groupId}`
}
</script>
<style scoped>
.menu-count {
min-width: 28px;
justify-content: center;
}
.nav-text-overflow {
display: block;
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
@@ -0,0 +1,39 @@
<template>
<v-sheet class="mobile-favorites-panel d-flex flex-column" color="surface">
<v-list class="px-2 py-2 flex-grow-1 overflow-auto" density="comfortable">
<v-list-item
v-for="item in favoriteItems"
:key="item.path ?? item.title"
class="mb-1"
rounded="lg"
@click="emit('select', item)"
>
<v-list-item-title class="text-body-2">{{ item.title }}</v-list-item-title>
</v-list-item>
</v-list>
</v-sheet>
</template>
<script setup lang="ts">
import type { AdminLayoutMenuItem } from './types'
withDefaults(
defineProps<{
favoriteItems?: AdminLayoutMenuItem[]
}>(),
{
favoriteItems: () => [],
}
)
const emit = defineEmits<{
select: [item: AdminLayoutMenuItem]
}>()
</script>
<style scoped>
.mobile-favorites-panel {
min-height: 0;
height: 100%;
}
</style>
@@ -0,0 +1,43 @@
<template>
<v-sheet class="mobile-menu-panel d-flex flex-column" color="surface">
<v-list class="px-2 py-2 flex-grow-1 overflow-auto" density="comfortable">
<v-list-item
v-for="item in mobileCurrentItems"
:key="item.path ?? item.title"
class="mb-1"
rounded="lg"
@click="emit('item-click', item)"
>
<v-list-item-title class="text-body-2">{{ item.title }}</v-list-item-title>
<template #append>
<v-icon size="18" :icon="item.subItems?.length ? mdiChevronRight : mdiArrowTopRight" />
</template>
</v-list-item>
</v-list>
</v-sheet>
</template>
<script setup lang="ts">
import type { AdminLayoutMenuItem } from './types'
import { mdiArrowTopRight, mdiChevronRight } from '@mdi/js'
withDefaults(
defineProps<{
mobileCurrentItems?: AdminLayoutMenuItem[]
}>(),
{
mobileCurrentItems: () => [],
}
)
const emit = defineEmits<{
'item-click': [item: AdminLayoutMenuItem]
}>()
</script>
<style scoped>
.mobile-menu-panel {
min-height: 0;
height: 100%;
}
</style>
@@ -0,0 +1,68 @@
export type AdminLayoutIcon = string
export interface AdminLayoutMenuItem {
title: string
path?: string
icon?: AdminLayoutIcon
navigable?: boolean
subItems?: AdminLayoutMenuItem[]
}
export interface AdminLayoutUserProfile {
name: string
role: string
avatarText: string
}
export interface AdminLayoutSearchConfig {
placeholder: string
label: string
}
export interface AdminLayoutToolbarActions {
notificationsLabel: string
messagesLabel: string
helpLabel: string
settingsLabel: string
}
export interface AdminLayoutToolbarCounts {
notifications: number
messages: number
}
export interface AdminLayoutFavoritesConfig {
label: string
addLabel: string
showAdd: boolean
}
export interface AdminLayoutBreadcrumbConfig {
homeLabel: string
homeDisabled: boolean
homeIcon?: AdminLayoutIcon
}
export interface AdminLayoutBreadcrumbItem {
title: string
disabled?: boolean
icon?: AdminLayoutIcon
href?: string
to?: string
}
export interface AdminLayoutFeatures {
showThemeToggle: boolean
showFavorites: boolean
showBreadcrumb: boolean
showSearch: boolean
showToolbarActions: boolean
showUserInfo: boolean
}
export interface AdminLayoutDrawerConfig {
width: number
railWidth: number
}
export type AdminLayoutActionType = 'notifications' | 'messages' | 'help'