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:
@@ -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>
|
||||
Reference in New Issue
Block a user