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
+114
View File
@@ -0,0 +1,114 @@
<script setup lang="ts">
import { mdiClose } from '@mdi/js'
import { ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import type { LayoutMenuItem } from '@/stores/menu'
const props = defineProps<{
menuItems?: LayoutMenuItem[]
showTabs?: boolean
}>()
const emit = defineEmits<{
(e: 'close', path: string): void
}>()
const route = useRoute()
const router = useRouter()
const tabs = ref<Array<{ title: string; path: string }>>([])
const activeTab = ref<string | null>(null)
function findTitle(path: string, items?: LayoutMenuItem[]): string | null {
const searchIn = items || []
for (const item of searchIn) {
if (item.path === path) return item.title
if (item.subItems?.length) {
const found = findTitle(path, item.subItems)
if (found) return found
}
}
return null
}
function resolveTitle(path: string): string {
const fromProps = findTitle(path, props.menuItems)
if (fromProps) return fromProps
if (path === '/') return '首頁'
return path
}
watch(
() => route.path,
(newPath) => {
if (!props.showTabs) return
const existingTab = tabs.value.find((t) => t.path === newPath)
if (!existingTab) {
const title = resolveTitle(newPath)
tabs.value.push({ title, path: newPath })
}
activeTab.value = newPath
},
{ immediate: true }
)
function closeTab(path: string) {
if (tabs.value.length <= 1) return
const index = tabs.value.findIndex((t) => t.path === path)
if (index === -1) return
tabs.value.splice(index, 1)
if (route.path === path) {
const nextTab = tabs.value[index] || tabs.value[index - 1]
if (nextTab) {
router.push(nextTab.path)
} else {
router.push('/')
}
}
emit('close', path)
}
defineExpose({ tabs, activeTab, closeTab })
</script>
<template>
<div v-if="showTabs" class="d-flex flex-column h-100">
<v-tabs
v-model="activeTab"
bg-color="background"
color="primary"
density="compact"
show-arrows
style="flex-shrink: 0"
>
<v-tab v-for="tab in tabs" :key="tab.path" border="sm" :to="tab.path" :value="tab.path">
{{ tab.title }}
<v-btn
aria-label="關閉頁籤"
class="pl-2"
color="grey"
density="compact"
:disabled="tabs.length <= 1"
icon
size="x-small"
variant="text"
@click.prevent.stop="closeTab(tab.path)"
>
<v-icon :icon="mdiClose" />
</v-btn>
</v-tab>
</v-tabs>
<div class="flex-grow-1 overflow-auto" style="min-height: 0" tabindex="0">
<slot />
</div>
</div>
<slot v-else />
</template>