9ae91418e0
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.
200 lines
6.4 KiB
Vue
200 lines
6.4 KiB
Vue
<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>
|