feat(shell): add app shell and maintenance page driver

Introduce reusable shell components for layout, tabs, and global overlays.
Add maintenance page model, wrapper component, and composable driver to
standardize maintenance page state, search, and pagination handling.feat(shell): add app shell and maintenance page driver

Introduce reusable shell components for layout, tabs, and global overlays.
Add maintenance page model, wrapper component, and composable driver to
standardize maintenance page state, search, and pagination handling.
This commit is contained in:
skytek_xinliang
2026-05-19 11:35:01 +08:00
parent 005ba663d6
commit 9ae91418e0
9 changed files with 456 additions and 13 deletions
+199
View File
@@ -0,0 +1,199 @@
<script setup lang="ts">
import {
mdiBellOutline,
mdiCalendarOutline,
mdiHomeCityOutline,
mdiSchoolOutline,
} from '@mdi/js'
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { HTTP_TOAST_EVENT } from '@/services/http-toast'
import { useMessageStore } from '@/stores/messages'
import { useSnackbarStore } from '@/stores/snackbar'
import type { LayoutMenuItem } from '@/stores/menu'
const props = defineProps<{
menuItems?: LayoutMenuItem[]
}>()
const emit = defineEmits<{
(e: 'searchSelect', item: { title: string; path: string; icon?: string }): void
}>()
const snackbar = useSnackbarStore()
const messageStore = useMessageStore()
const searchDialog = ref(false)
const searchKeyword = ref('')
const searchResults = ref<
Array<{ title: string; path: string; icon?: string; parents: string[] }>
>([])
const messageItems = [
{ id: 1, title: '系統維護提醒', meta: '今天 09:00 · 資訊中心', icon: mdiBellOutline },
{ id: 2, title: '教務處公告', meta: '昨天 16:20 · 教務處', icon: mdiSchoolOutline },
{ id: 3, title: '宿舍申請結果', meta: '昨天 13:05 · 學務處', icon: mdiHomeCityOutline },
{ id: 4, title: '課表異動通知', meta: '2 天前 · 教務處', icon: mdiCalendarOutline },
]
function buildSearchResults(
items: LayoutMenuItem[] | undefined,
keyword: string,
parents: string[] = []
): Array<{ title: string; path: string; icon?: string; parents: string[] }> {
const results: Array<{ title: string; path: string; icon?: string; parents: string[] }> = []
for (const item of items || []) {
const currentParents = item?.title ? [...parents, item.title] : parents
if (item?.subItems?.length) {
results.push(...buildSearchResults(item.subItems, keyword, currentParents))
}
if (item?.path && item?.title) {
const hit = item.title.toLowerCase().includes(keyword)
if (hit) {
results.push({
title: item.title,
path: item.path,
icon: item.icon,
parents,
})
}
}
}
return results
}
function handleSearch(value: unknown) {
const keyword = String(value ?? '').trim()
searchKeyword.value = keyword
if (!keyword) {
searchResults.value = []
searchDialog.value = false
return
}
const lowered = keyword.toLowerCase()
searchResults.value = buildSearchResults(props.menuItems, lowered)
searchDialog.value = true
}
function handleSearchSelect(item: { title: string; path: string; icon?: string }) {
searchDialog.value = false
emit('searchSelect', item)
}
function resolveMessageItem(wrapped: unknown) {
if (wrapped && typeof wrapped === 'object' && 'raw' in (wrapped as object)) {
return (wrapped as { raw: (typeof messageItems)[0] }).raw
}
return wrapped as (typeof messageItems)[0]
}
function handleHttpToast(event: Event) {
const detail = (event as CustomEvent)?.detail
const message = detail?.message
if (!message) return
const level = detail?.level
const color =
level === 'error' ? 'error' : level === 'warning' ? 'warning' : 'info'
snackbar.show({ message, color, timeout: 3000, location: 'top right', variant: 'flat' })
}
onMounted(() => {
window.addEventListener(HTTP_TOAST_EVENT, handleHttpToast)
})
onBeforeUnmount(() => {
window.removeEventListener(HTTP_TOAST_EVENT, handleHttpToast)
})
defineExpose({ handleSearch })
</script>
<template>
<v-dialog v-model="searchDialog" max-width="640">
<v-card>
<v-card-title class="text-h6 bg-primary-variant pa-4">搜尋結果</v-card-title>
<v-card-subtitle v-if="searchKeyword" class="pt-4">
關鍵字{{ searchKeyword }}
</v-card-subtitle>
<v-card-text class="pt-2">
<v-alert
v-if="searchResults.length === 0"
class="mt-2"
density="compact"
type="info"
variant="tonal"
>
查無結果
</v-alert>
<v-list v-else density="compact">
<v-list-item
v-for="item in searchResults"
:key="item.path"
class="mb-2"
@click="handleSearchSelect(item)"
>
<template #prepend>
<v-icon v-if="item.icon" size="18" :icon="item.icon" />
</template>
<v-list-item-title>{{ item.title }}</v-list-item-title>
<v-list-item-subtitle v-if="item.parents?.length">
{{ item.parents.join(' / ') }}
</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn color="primary" variant="text" @click="searchDialog = false">關閉</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="messageStore.isOpen" max-width="720">
<v-card>
<v-card-title class="text-h6 bg-primary-variant pa-4">訊息清單</v-card-title>
<v-card-subtitle class="text-body-2 pt-4 text-medium-emphasis">
僅示意資料不含延伸功能
</v-card-subtitle>
<v-card-text class="pa-4">
<v-data-iterator item-key="id" :items="messageItems" :items-per-page="-1">
<template #default="{ items }">
<v-list density="compact">
<v-list-item
v-for="wrapped in items"
:key="resolveMessageItem(wrapped).id"
border="sm"
class="pa-2 mb-1"
>
<template #prepend>
<v-avatar color="primary" size="28" variant="tonal">
<v-icon size="16" :icon="resolveMessageItem(wrapped).icon" />
</v-avatar>
</template>
<v-list-item-title class="text-body-2 font-weight-medium">
{{ resolveMessageItem(wrapped).title }}
</v-list-item-title>
<v-list-item-subtitle class="text-caption text-medium-emphasis">
{{ resolveMessageItem(wrapped).meta }}
</v-list-item-subtitle>
</v-list-item>
</v-list>
</template>
</v-data-iterator>
</v-card-text>
<v-card-actions class="justify-end">
<v-btn color="primary" variant="text" @click="messageStore.close()">關閉</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar
v-model="snackbar.visible"
:color="snackbar.color"
:location="snackbar.location as any"
:timeout="snackbar.timeout"
:variant="snackbar.variant"
>
{{ snackbar.message }}
</v-snackbar>
</template>