refactor(app): extract page logic into composable drivers

This commit is contained in:
skytek_xinliang
2026-05-19 16:38:08 +08:00
parent 2b780a12c2
commit 51fbbd7101
13 changed files with 576 additions and 692 deletions
+6 -588
View File
@@ -1,589 +1,7 @@
<template> <script setup lang="ts">
<!-- 根據路由設定 meta.layout 動態切換佈局 --> import AppShell from '@/shell/AppShell.vue'
<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
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">
<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 {
mdiBellOutline,
mdiCalendarOutline,
mdiClose,
mdiCloseCircle,
mdiCog,
mdiFileDocumentOutline,
mdiFileTreeOutline,
mdiHome,
mdiHomeCityOutline,
mdiPlusCircle,
mdiSchoolOutline,
mdiTableEdit,
} from '@mdi/js'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import MainLayout from '@/components/layouts/MainLayout.vue'
import PlainLayout from '@/components/layouts/PlainLayout.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: mdiCog,
path: '/settings',
navigable: false,
},
..._fixedMenuItems,
]
/**
* 佈局對映表
*/
const layoutMap = {
default: MainLayout,
none: PlainLayout,
}
// 取得當前應使用的組件
const activeLayout = computed(() => {
return layoutMap[route.meta.layout] || MainLayout
})
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> </script>
<template>
<AppShell />
</template>
+13
View File
@@ -0,0 +1,13 @@
<script setup lang="ts">
import type { FunctionPageModel } from '@/composables/page-drivers/useFunctionPage'
defineProps<{
page: FunctionPageModel
}>()
</script>
<template>
<v-sheet height="100%" width="100%">
{{ page.fncId }}
</v-sheet>
</template>
+29
View File
@@ -0,0 +1,29 @@
<script setup lang="ts">
import PageIndex from '@/components/PageIndex.vue'
import type { HomeNewsItem, HomePageModel, HomeQuickItem } from '@/composables/page-drivers/useHomePage'
defineProps<{
page: HomePageModel
selectedNews: HomeNewsItem | null
}>()
const emit = defineEmits<{
news: [item: HomeNewsItem]
'message-center': []
quick: [item: HomeQuickItem]
}>()
const isNewsDialogOpen = defineModel<boolean>('newsDialogOpen', { default: false })
</script>
<template>
<PageIndex
v-model:is-news-dialog-open="isNewsDialogOpen"
:news-items="page.newsItems"
:quick-items="page.quickItems"
:selected-news="selectedNews"
@message-center="emit('message-center')"
@news="emit('news', $event)"
@quick="emit('quick', $event)"
/>
</template>
+11
View File
@@ -0,0 +1,11 @@
<script setup lang="ts">
import type { SettingsPageModel } from '@/composables/page-drivers/useSettingsPage'
defineProps<{
page: SettingsPageModel
}>()
</script>
<template>
<div>{{ page.title }}</div>
</template>
+280
View File
@@ -0,0 +1,280 @@
import {
mdiCloseCircle,
mdiCog,
mdiFileDocumentOutline,
mdiFileTreeOutline,
mdiHome,
mdiPlusCircle,
mdiTableEdit,
} from '@mdi/js'
import { computed, onBeforeUnmount, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
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, type LayoutMenuItem } from '@/stores/menu'
import { useMessageStore } from '@/stores/messages'
import { useSnackbarStore } from '@/stores/snackbar'
const fixedMenuItems: LayoutMenuItem[] = [
{
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' },
]
const menuItemsExample: LayoutMenuItem[] = [
{ title: '首頁', icon: mdiHome, path: '/' },
{
title: '設定',
icon: mdiCog,
path: '/settings',
navigable: false,
},
...fixedMenuItems,
]
function buildMergedMenuItems(items: LayoutMenuItem[]) {
const flatPaths = new Set<string>()
const collectPaths = (list: LayoutMenuItem[]) => {
for (const item of list || []) {
if (item?.path) flatPaths.add(item.path)
if (item?.subItems?.length) collectPaths(item.subItems)
}
}
collectPaths(items)
const mergeFixedItems = (list: LayoutMenuItem[]) => {
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 filteredFixedItems = mergeFixedItems(fixedMenuItems).filter((item) => {
if (!item?.subItems?.length) return !item?.path || !flatPaths.has(item.path)
return item.subItems.length > 0
})
return [...(items || []), ...filteredFixedItems]
}
type UseAppShellOptions = {
onLogout?: () => void
}
export function useAppShell(options: UseAppShellOptions = {}) {
const route = useRoute()
const router = useRouter()
const snackbar = useSnackbarStore()
const authStore = useAuthStore()
const menuStore = useMenuStore()
const breadcrumbStore = useBreadcrumbStore()
const favoritesStore = useFavoritesStore()
const messageStore = useMessageStore()
const mergedMenuItems = computed(() => buildMergedMenuItems(menuStore.menuItems))
const mergedFavoriteItems = computed(() => {
const combined = [...menuStore.favoriteItems, ...favoritesStore.layoutItems]
const seen = new Set<string>()
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: LayoutMenuItem) {
if (item.path) {
router.push(item.path)
}
}
function recursiveFindTitle(path: string, items: LayoutMenuItem[]): string | null {
for (const item of items) {
if (item.path === path) return item.title
if (item.subItems?.length) {
const found = recursiveFindTitle(path, item.subItems)
if (found) return found
}
}
return null
}
function findTitle(path: string) {
const menuTitle = recursiveFindTitle(path, menuStore.menuItems)
if (menuTitle) return menuTitle
const favoriteTitle = recursiveFindTitle(path, menuStore.favoriteItems)
if (favoriteTitle) return favoriteTitle
const exampleTitle = recursiveFindTitle(path, menuItemsExample)
if (exampleTitle) return exampleTitle
if (path === '/') return '首頁'
return path
}
function findMenuItem(path: string) {
const recursiveFind = (items: LayoutMenuItem[]): LayoutMenuItem | null => {
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: LayoutMenuItem) {
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: LayoutMenuItem) {
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,
})
}
function handleLayoutAction(type: string) {
if (type === 'messages') {
messageStore.open()
}
}
function performLogout(feedback: { message: string; color: string }) {
authStore.logout()
options.onLogout?.()
snackbar.show(feedback)
router.replace({ name: 'login' })
}
function handleLogout() {
performLogout({ message: '登出成功', color: 'success' })
}
function handleForceLogout(event: Event) {
const message = (event as CustomEvent)?.detail?.message || '請重新登入'
performLogout({ message, color: 'warning' })
}
watch(
[
() => route.path,
() => menuStore.menuItems,
() => menuStore.favoriteItems,
() => favoritesStore.items,
],
() => updateBreadcrumbs(),
{ immediate: true, deep: true }
)
onMounted(() => {
window.addEventListener(SESSION_FORCE_LOGOUT_EVENT, handleForceLogout)
})
onBeforeUnmount(() => {
window.removeEventListener(SESSION_FORCE_LOGOUT_EVENT, handleForceLogout)
})
return {
favoriteActionIcon,
favoriteActionLabel,
favoritesStore,
goHome,
handleLayoutAction,
handleLogout,
handleRemoveFavorite,
handleSelect,
isFavoriteActionDisabled,
layoutProps,
menuStore,
mergedMenuItems,
toggleFavorite,
}
}
@@ -0,0 +1,18 @@
import { computed } from 'vue'
import { useRoute } from 'vue-router'
export interface FunctionPageModel {
fncId: string
}
export function useFunctionPage() {
const route = useRoute()
const pageModel = computed<FunctionPageModel>(() => ({
fncId: String(route.params.fncId ?? ''),
}))
return {
pageModel,
}
}
+101
View File
@@ -0,0 +1,101 @@
import { computed, ref } from 'vue'
import { useMessageStore } from '@/stores/messages'
import { useSnackbarStore } from '@/stores/snackbar'
export interface HomeNewsItem {
id: number
date: string
month: string
title: string
desc: string
dept: string
views: string
isNew: boolean
}
export interface HomeQuickItem {
icon: string
title: string
}
export interface HomePageModel {
type: 'home'
newsItems: HomeNewsItem[]
quickItems: HomeQuickItem[]
}
const newsItems: HomeNewsItem[] = [
{
id: 1,
date: '29',
month: '1月',
title: '113學年度第2學期加退選開始',
desc: '加退選時間為1月29日至2月9日止,請同學把握時間完成選課作業。',
dept: '教務處',
views: '1,234',
isNew: true,
},
{
id: 2,
date: '27',
month: '1月',
title: '場地借用系統維護通知',
desc: '系統將於本週六凌晨01:00-05:00進行維護,期間暫停服務。',
dept: '總務處',
views: '856',
isNew: false,
},
{
id: 3,
date: '25',
month: '1月',
title: '112學年度第1學期期末成績已開放查詢',
desc: '同學可至「查詢 > 教務資訊查詢 > 學期成績查詢」查看本學期成績。',
dept: '教務處',
views: '3,567',
isNew: false,
},
]
const quickItems: HomeQuickItem[] = [
{ icon: '', title: '線上加選' },
{ icon: '', title: '線上退選' },
{ icon: '📊', title: '成績查詢' },
{ icon: '📅', title: '個人課表' },
{ icon: '📝', title: '網路請假' },
{ icon: '🏢', title: '場地借用' },
]
export function useHomePage() {
const snackbar = useSnackbarStore()
const messageStore = useMessageStore()
const selectedNews = ref<HomeNewsItem | null>(null)
const isNewsDialogOpen = ref(false)
const pageModel = computed<HomePageModel>(() => ({
type: 'home',
newsItems,
quickItems,
}))
function handleNews(item: HomeNewsItem) {
selectedNews.value = item
isNewsDialogOpen.value = true
}
function handleMessageCenter() {
messageStore.open()
}
function handleQuick(item: HomeQuickItem) {
snackbar.show({ message: `前往:${item.title}`, color: 'info' })
}
return {
pageModel,
selectedNews,
isNewsDialogOpen,
handleNews,
handleMessageCenter,
handleQuick,
}
}
@@ -0,0 +1,13 @@
import { computed } from 'vue'
export interface SettingsPageModel {
title: string
}
export function useSettingsPage() {
const pageModel = computed<SettingsPageModel>(() => ({
title: '設定頁面',
}))
return { pageModel }
}
+69 -9
View File
@@ -1,17 +1,19 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, ref } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { mdiHome } from '@mdi/js'
import MainLayout from '@/components/layouts/MainLayout.vue' import MainLayout from '@/components/layouts/MainLayout.vue'
import PlainLayout from '@/components/layouts/PlainLayout.vue' import PlainLayout from '@/components/layouts/PlainLayout.vue'
import { useAppShell } from '@/composables/layout/useAppShell'
import AppTabs from './AppTabs.vue' import AppTabs from './AppTabs.vue'
import GlobalOverlays from './GlobalOverlays.vue' import GlobalOverlays from './GlobalOverlays.vue'
import type { LayoutMenuItem } from '@/stores/menu'
defineProps<{ type AppTabsInstance = InstanceType<typeof AppTabs>
menuItems?: LayoutMenuItem[] type GlobalOverlaysInstance = InstanceType<typeof GlobalOverlays>
}>()
const route = useRoute() const route = useRoute()
const appTabs = ref<AppTabsInstance | null>(null)
const globalOverlays = ref<GlobalOverlaysInstance | null>(null)
const layoutMap = { const layoutMap = {
default: MainLayout, default: MainLayout,
@@ -22,14 +24,72 @@ const activeLayout = computed(
() => layoutMap[route.meta.layout as 'default' | 'none'] || MainLayout () => layoutMap[route.meta.layout as 'default' | 'none'] || MainLayout
) )
const showTabs = computed(() => route.meta.layout === 'default') const showTabs = computed(() => route.meta.layout === 'default')
function clearTabs() {
appTabs.value?.clearTabs()
}
const {
favoriteActionIcon,
favoriteActionLabel,
favoritesStore,
goHome,
handleLayoutAction,
handleLogout,
handleRemoveFavorite,
handleSelect,
isFavoriteActionDisabled,
layoutProps,
menuStore,
mergedMenuItems,
toggleFavorite,
} = useAppShell({ onLogout: clearTabs })
function handleSearch(value: string) {
globalOverlays.value?.handleSearch(value)
}
</script> </script>
<template> <template>
<component :is="activeLayout"> <component
<AppTabs :menu-items="menuItems" :show-tabs="showTabs"> :is="activeLayout"
<slot /> 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>
<AppTabs ref="appTabs" :menu-items="mergedMenuItems" :show-tabs="showTabs">
<router-view v-if="showTabs" v-slot="{ Component }">
<keep-alive>
<component :is="Component" :key="route.fullPath" />
</keep-alive>
</router-view>
<router-view v-else />
</AppTabs> </AppTabs>
</component> </component>
<GlobalOverlays :menu-items="menuItems" @search-select="$emit('searchSelect', $event)" /> <GlobalOverlays ref="globalOverlays" :menu-items="mergedMenuItems" @search-select="handleSelect" />
</template> </template>
+6 -1
View File
@@ -74,7 +74,12 @@ function closeTab(path: string) {
emit('close', path) emit('close', path)
} }
defineExpose({ tabs, activeTab, closeTab }) function clearTabs() {
tabs.value = []
activeTab.value = null
}
defineExpose({ tabs, activeTab, closeTab, clearTabs })
</script> </script>
<template> <template>
+7 -10
View File
@@ -1,13 +1,10 @@
<template>
<v-sheet height="100%" width="100%">
{{ fncId }}
</v-sheet>
</template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import PageFunction from '@/components/pages/PageFunction.vue'
import { useRoute } from 'vue-router' import { useFunctionPage } from '@/composables/page-drivers/useFunctionPage'
const route = useRoute() const { pageModel } = useFunctionPage()
const fncId = computed(() => String(route.params.fncId ?? ''))
</script> </script>
<template>
<PageFunction :page="pageModel" />
</template>
+14 -80
View File
@@ -1,83 +1,17 @@
<script setup lang="ts">
import PageHome from '@/components/pages/PageHome.vue'
import { useHomePage } from '@/composables/page-drivers/useHomePage'
const page = useHomePage()
</script>
<template> <template>
<page-index <PageHome
:is-news-dialog-open="isNewsDialogOpen" v-model:news-dialog-open="page.isNewsDialogOpen.value"
:news-items="newsItems" :page="page.pageModel.value"
:quick-items="quickItems" :selected-news="page.selectedNews.value"
:selected-news="selectedNews" @message-center="page.handleMessageCenter"
@message-center="handleMessageCenter" @news="page.handleNews"
@news="handleNews" @quick="page.handleQuick"
@quick="handleQuick"
@update:is-news-dialog-open="isNewsDialogOpen = $event"
/> />
</template> </template>
<script setup lang="ts">
import { ref } from 'vue'
import PageIndex from '@/components/PageIndex.vue'
import { useMessageStore } from '@/stores/messages'
import { useSnackbarStore } from '@/stores/snackbar'
const snackbar = useSnackbarStore()
const messageStore = useMessageStore()
const newsItems = [
{
id: 1,
date: '29',
month: '1月',
title: '113學年度第2學期加退選開始',
desc: '加退選時間為1月29日至2月9日止,請同學把握時間完成選課作業。',
dept: '教務處',
views: '1,234',
isNew: true,
},
{
id: 2,
date: '27',
month: '1月',
title: '場地借用系統維護通知',
desc: '系統將於本週六凌晨01:00-05:00進行維護,期間暫停服務。',
dept: '總務處',
views: '856',
isNew: false,
},
{
id: 3,
date: '25',
month: '1月',
title: '112學年度第1學期期末成績已開放查詢',
desc: '同學可至「查詢 > 教務資訊查詢 > 學期成績查詢」查看本學期成績。',
dept: '教務處',
views: '3,567',
isNew: false,
},
]
type NewsItem = (typeof newsItems)[number]
const quickItems = [
{ icon: '', title: '線上加選' },
{ icon: '', title: '線上退選' },
{ icon: '📊', title: '成績查詢' },
{ icon: '📅', title: '個人課表' },
{ icon: '📝', title: '網路請假' },
{ icon: '🏢', title: '場地借用' },
]
const selectedNews = ref<NewsItem | null>(null)
const isNewsDialogOpen = ref(false)
function handleNews(item: NewsItem) {
selectedNews.value = item
isNewsDialogOpen.value = true
}
// 點擊首頁「訊息中心」卡片,開啟共用的訊息清單 dialog
function handleMessageCenter() {
messageStore.open()
}
function handleQuick(item: (typeof quickItems)[number]) {
snackbar.show({ message: `前往:${item.title}`, color: 'info' })
}
</script>
+9 -4
View File
@@ -1,5 +1,10 @@
<template> <script setup lang="ts">
<div>設定頁面</div> import PageSettings from '@/components/pages/PageSettings.vue'
</template> import { useSettingsPage } from '@/composables/page-drivers/useSettingsPage'
<script setup lang="ts"></script> const { pageModel } = useSettingsPage()
</script>
<template>
<PageSettings :page="pageModel" />
</template>