525 lines
17 KiB
Vue
525 lines
17 KiB
Vue
<template>
|
||
<!-- 根據路由設定 meta.layout 動態切換佈局 -->
|
||
<component
|
||
:is="activeLayout" v-bind="layoutProps" v-model:breadcrumb-bar-visible="favoritesStore.breadcrumbBarVisible"
|
||
v-model:favorites-bar-visible="favoritesStore.favoritesBarVisible"
|
||
v-model:is-rail="menuStore.isRail" @action="handleLayoutAction" @logout="handleLogout"
|
||
@remove-favorite="handleRemoveFavorite" @search="handleSearch" @select="handleSelect">
|
||
<template #breadcrumb-actions>
|
||
<v-btn
|
||
color="secondary" :disabled="isFavoriteActionDisabled" size="small" variant="outlined"
|
||
@click="toggleFavorite">
|
||
<v-icon class="mr-1" size="14" :icon="favoriteActionIcon" />
|
||
{{ favoriteActionLabel }}
|
||
</v-btn>
|
||
<v-btn class="ml-2" color="primary" size="small" variant="text" @click="goHome">
|
||
<v-icon class="mr-1" size="14" :icon="mdiHome" />
|
||
返回首頁
|
||
</v-btn>
|
||
</template>
|
||
<!-- 如果是預設佈局,顯示分頁標籤 -->
|
||
<template v-if="showTabs">
|
||
<div 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
|
||
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;">
|
||
<router-view v-slot="{ Component }">
|
||
<keep-alive>
|
||
<component :is="Component" :key="route.fullPath" />
|
||
</keep-alive>
|
||
</router-view>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- 其他佈局直接顯示內容 -->
|
||
<router-view v-else />
|
||
</component>
|
||
|
||
<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>
|
||
|
||
<!--
|
||
訊息中心 Dialog:
|
||
放在 App.vue 的原因是要被「首頁卡片」與「頂部工具列訊息按鈕」共同觸發,
|
||
並且避免在 layout/template 層放入業務 UI,維持模板的純展示特性。
|
||
-->
|
||
<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 進行資料展示,
|
||
這樣若未來要加排序或分頁,不需改動結構。
|
||
-->
|
||
<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"
|
||
:timeout="snackbar.timeout" :variant="snackbar.variant">
|
||
{{ snackbar.message }}
|
||
</v-snackbar>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { mdiAccountGroup, mdiBellOutline, mdiCalendarOutline, mdiChartBoxOutline, mdiClose, mdiCloseCircle, mdiCog, mdiDomain, mdiFileDocumentOutline, mdiFileTreeOutline, mdiHome, mdiHomeCityOutline, mdiMenu, mdiPlusCircle, mdiSchoolOutline, mdiTableEdit, mdiViewDashboardVariant } from '@mdi/js'
|
||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import SKAdminLayout from '@/components/layouts/SKAdminLayout.vue'
|
||
import SKEmptyLayout from '@/components/layouts/SKEmptyLayout.vue'
|
||
import { HTTP_TOAST_EVENT } from './services/http-toast'
|
||
import { SESSION_FORCE_LOGOUT_EVENT } from './services/session'
|
||
import { useAuthStore } from './stores/auth'
|
||
import { useBreadcrumbStore } from './stores/breadcrumbs'
|
||
import { useFavoritesStore } from './stores/favorites'
|
||
import { useMenuStore } from './stores/menu'
|
||
import { useMessageStore } from './stores/messages'
|
||
import { useSnackbarStore } from './stores/snackbar'
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const snackbar = useSnackbarStore()
|
||
const authStore = useAuthStore()
|
||
const menuStore = useMenuStore()
|
||
const breadcrumbStore = useBreadcrumbStore()
|
||
const favoritesStore = useFavoritesStore()
|
||
// 訊息中心:集中控制 dialog 顯示狀態
|
||
const messageStore = useMessageStore()
|
||
|
||
// 固定選單(合併到 API 回傳的選單)
|
||
const _fixedMenuItems = [
|
||
{
|
||
title: '資料維護',
|
||
navigable: false,
|
||
subItems: [
|
||
{ title: '單筆資料維護', icon: mdiFileDocumentOutline, path: '/single-record-maintenance' },
|
||
{ title: '主從資料維護A', icon: mdiFileTreeOutline, path: '/master-detail-maintenance' },
|
||
{ title: '主從資料維護B', icon: mdiFileTreeOutline, path: '/master-detail-maintenance-b' },
|
||
{ title: '主從資料維護C', icon: mdiFileTreeOutline, path: '/master-detail-maintenance-c' },
|
||
{ title: '可編輯表格維護', icon: mdiTableEdit, path: '/editable-grid-maintenance' },
|
||
],
|
||
},
|
||
{ title: '登入頁', path: '/login' },
|
||
]
|
||
|
||
// 範例選單(用於 tab 顯示名稱的保底資料)
|
||
const _menuItemsExample = [
|
||
{ title: '首頁', icon: mdiHome, path: '/' },
|
||
{ title: '工作台', icon: mdiViewDashboardVariant, path: '/dashboard' },
|
||
{ title: '分析頁', icon: mdiChartBoxOutline, path: '/analysis' },
|
||
{
|
||
title: '設定',
|
||
icon: mdiCog,
|
||
path: '/settings',
|
||
navigable: false,
|
||
subItems: [
|
||
{ title: '角色管理', icon: mdiAccountGroup, path: '/role-management' },
|
||
{ title: '選單管理', icon: mdiMenu, path: '/menu-management' },
|
||
{ title: '部門管理', icon: mdiDomain, path: '/dept-management' },
|
||
],
|
||
},
|
||
..._fixedMenuItems,
|
||
]
|
||
|
||
/**
|
||
* 佈局對映表
|
||
*/
|
||
const layoutMap = {
|
||
default: SKAdminLayout,
|
||
none: SKEmptyLayout,
|
||
}
|
||
|
||
// 取得當前應使用的組件
|
||
const activeLayout = computed(() => {
|
||
return layoutMap[route.meta.layout] || SKAdminLayout
|
||
})
|
||
|
||
function buildMergedMenuItems (items) {
|
||
const flatPaths = new Set()
|
||
const collectPaths = (list) => {
|
||
for (const item of list || []) {
|
||
if (item?.path) flatPaths.add(item.path)
|
||
if (item?.subItems?.length) collectPaths(item.subItems)
|
||
}
|
||
}
|
||
|
||
collectPaths(items)
|
||
|
||
const mergeFixedItems = (list) => {
|
||
return (list || []).map((item) => {
|
||
if (!item?.subItems?.length) return item
|
||
const subItems = item.subItems.filter((sub) => !sub?.path || !flatPaths.has(sub.path))
|
||
return { ...item, subItems }
|
||
})
|
||
}
|
||
|
||
const fixedItems = mergeFixedItems(_fixedMenuItems).filter((item) => {
|
||
if (!item?.subItems?.length) return !item?.path || !flatPaths.has(item.path)
|
||
return item.subItems.length > 0
|
||
})
|
||
|
||
return [...(items || []), ...fixedItems]
|
||
}
|
||
|
||
// 根據不同 Layout 傳遞不同的 Props
|
||
const mergedMenuItems = computed(() => buildMergedMenuItems(menuStore.menuItems))
|
||
|
||
const mergedFavoriteItems = computed(() => {
|
||
const combined = [...menuStore.favoriteItems, ...favoritesStore.layoutItems]
|
||
const seen = new Set()
|
||
return combined.filter((item) => {
|
||
const key = item.path ?? item.title
|
||
if (!key) return false
|
||
if (seen.has(key)) return false
|
||
seen.add(key)
|
||
return true
|
||
})
|
||
})
|
||
|
||
const layoutProps = computed(() => {
|
||
const layout = route.meta.layout
|
||
if (layout === 'default') {
|
||
return {
|
||
systemTitle: '測試環境',
|
||
favoriteItems: mergedFavoriteItems.value,
|
||
menuItems: mergedMenuItems.value,
|
||
breadcrumbItems: breadcrumbStore.breadcrumbItems,
|
||
}
|
||
}
|
||
return {}
|
||
})
|
||
|
||
function handleSelect (item) {
|
||
console.log('Selected:', item)
|
||
if (item.path) {
|
||
router.push(item.path)
|
||
}
|
||
}
|
||
|
||
const searchDialog = ref(false)
|
||
const searchKeyword = ref('')
|
||
const searchResults = ref([])
|
||
|
||
function buildSearchResults (items, keyword, parents = []) {
|
||
const results = []
|
||
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: parents,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
return results
|
||
}
|
||
|
||
// 接收 Layout 的搜尋事件:僅在「按鈕或 Enter」時觸發
|
||
function handleSearch (value) {
|
||
const keyword = String(value ?? '').trim()
|
||
searchKeyword.value = keyword
|
||
if (!keyword) {
|
||
// 空字串時不顯示結果彈窗
|
||
searchResults.value = []
|
||
searchDialog.value = false
|
||
return
|
||
}
|
||
const lowered = keyword.toLowerCase()
|
||
// 依合併後的 menuItems 進行比對
|
||
searchResults.value = buildSearchResults(mergedMenuItems.value, lowered)
|
||
// 開啟彈窗顯示搜尋結果
|
||
searchDialog.value = true
|
||
}
|
||
|
||
// 點擊搜尋結果後導頁(行為等同選單點擊)
|
||
function handleSearchSelect (item) {
|
||
searchDialog.value = false
|
||
handleSelect(item)
|
||
}
|
||
|
||
// 訊息中心的示意資料,僅用於展示列表,不進行 API 呼叫
|
||
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 },
|
||
]
|
||
|
||
// v-data-iterator 會包裝 items,這裡取回原始資料物件
|
||
function resolveMessageItem (wrapped) {
|
||
if (wrapped && typeof wrapped === 'object' && 'raw' in wrapped) {
|
||
return wrapped.raw
|
||
}
|
||
return wrapped
|
||
}
|
||
|
||
// 由 layout 的 action 事件統一進入此處處理
|
||
// 目前只處理訊息中心,其他 action 可在此擴充
|
||
function handleLayoutAction (type) {
|
||
if (type === 'messages') {
|
||
messageStore.open()
|
||
return
|
||
}
|
||
}
|
||
|
||
function performLogout ({ message, color }) {
|
||
authStore.logout()
|
||
tabs.value = []
|
||
activeTab.value = null
|
||
snackbar.show({ message, color })
|
||
router.replace({ name: 'login' })
|
||
}
|
||
|
||
function handleLogout () {
|
||
performLogout({ message: '登出成功', color: 'success' })
|
||
}
|
||
|
||
function handleForceLogout (event) {
|
||
const message = event?.detail?.message || '請重新登入'
|
||
performLogout({ message, color: 'warning' })
|
||
}
|
||
|
||
function handleHttpToast (event) {
|
||
const detail = event?.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(SESSION_FORCE_LOGOUT_EVENT, handleForceLogout)
|
||
window.addEventListener(HTTP_TOAST_EVENT, handleHttpToast)
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
window.removeEventListener(SESSION_FORCE_LOGOUT_EVENT, handleForceLogout)
|
||
window.removeEventListener(HTTP_TOAST_EVENT, handleHttpToast)
|
||
})
|
||
|
||
// --- Tabs Logic ---
|
||
|
||
const tabs = ref([])
|
||
const activeTab = ref(null)
|
||
|
||
const showTabs = computed(() => {
|
||
return route.meta.layout === 'default'
|
||
})
|
||
|
||
// 遞迴尋找標題
|
||
function findTitle (path) {
|
||
const recursiveFind = (items) => {
|
||
for (const item of items) {
|
||
if (item.path === path) return item.title
|
||
if (item.subItems?.length) {
|
||
const found = recursiveFind(item.subItems)
|
||
if (found) return found
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
// 1. 搜尋 Store 中的選單
|
||
let title = recursiveFind(menuStore.menuItems)
|
||
if (title) return title
|
||
|
||
// 2. 搜尋最愛選單
|
||
title = recursiveFind(menuStore.favoriteItems)
|
||
if (title) return title
|
||
|
||
// 3. 搜尋靜態範例選單
|
||
title = recursiveFind(_menuItemsExample)
|
||
if (title) return title
|
||
|
||
// 4. 特殊路徑處理
|
||
if (path === '/') return '首頁'
|
||
|
||
return path
|
||
}
|
||
|
||
function findMenuItem (path) {
|
||
const recursiveFind = (items) => {
|
||
for (const item of items) {
|
||
if (item.path === path) return item
|
||
if (item.subItems?.length) {
|
||
const found = recursiveFind(item.subItems)
|
||
if (found) return found
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
return recursiveFind(mergedMenuItems.value)
|
||
}
|
||
|
||
const currentFavoriteInfo = computed(() => {
|
||
const path = route.path
|
||
const menuItem = findMenuItem(path)
|
||
const title =
|
||
menuItem?.title || (typeof route.meta?.title === 'string' ? route.meta.title : null) || findTitle(path)
|
||
return {
|
||
title,
|
||
path,
|
||
icon: menuItem?.icon,
|
||
}
|
||
})
|
||
|
||
const isCurrentFavorite = computed(() => favoritesStore.isFavorite(route.path))
|
||
const isFavoriteActionDisabled = computed(() => !currentFavoriteInfo.value?.path || route.path === '/')
|
||
|
||
const favoriteActionLabel = computed(() => (isCurrentFavorite.value ? '移除常用' : '加入常用'))
|
||
const favoriteActionIcon = computed(() => (isCurrentFavorite.value ? mdiCloseCircle : mdiPlusCircle))
|
||
|
||
function toggleFavoriteItem (item) {
|
||
if (!item?.path || item.path === '/') return
|
||
favoritesStore.toggle({
|
||
title: item.title || findTitle(item.path),
|
||
path: item.path,
|
||
icon: item.icon,
|
||
})
|
||
}
|
||
|
||
function toggleFavorite () {
|
||
toggleFavoriteItem(currentFavoriteInfo.value)
|
||
}
|
||
|
||
function handleRemoveFavorite (item) {
|
||
toggleFavoriteItem(item)
|
||
}
|
||
|
||
function goHome () {
|
||
router.push('/')
|
||
}
|
||
|
||
function updateBreadcrumbs () {
|
||
const resolvedTitle = findTitle(route.path)
|
||
const fallbackTitle =
|
||
resolvedTitle && resolvedTitle !== route.path
|
||
? resolvedTitle
|
||
: typeof route.meta?.title === 'string'
|
||
? route.meta.title
|
||
: null
|
||
|
||
breadcrumbStore.setBreadcrumbs({
|
||
path: route.path,
|
||
menuItems: mergedMenuItems.value,
|
||
favoriteItems: mergedFavoriteItems.value,
|
||
fallbackTitle,
|
||
homeLabel: '首頁',
|
||
homeIcon: mdiHome,
|
||
})
|
||
}
|
||
|
||
watch(
|
||
[() => route.path, () => menuStore.menuItems, () => menuStore.favoriteItems, () => favoritesStore.items],
|
||
() => updateBreadcrumbs(),
|
||
{ immediate: true, deep: true }
|
||
)
|
||
|
||
// 監聽路由變化,新增 Tab
|
||
watch(
|
||
() => route.path,
|
||
(newPath) => {
|
||
if (!showTabs.value) return
|
||
|
||
const existingTab = tabs.value.find((t) => t.path === newPath)
|
||
if (!existingTab) {
|
||
const title = findTitle(newPath)
|
||
tabs.value.push({ title, path: newPath })
|
||
}
|
||
activeTab.value = newPath
|
||
},
|
||
{ immediate: true }
|
||
)
|
||
|
||
function closeTab (path) {
|
||
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('/')
|
||
}
|
||
}
|
||
}
|
||
</script>
|