From 51fbbd7101a51352a73316e792cb06a381cb6c0b Mon Sep 17 00:00:00 2001 From: skytek_xinliang Date: Tue, 19 May 2026 16:38:08 +0800 Subject: [PATCH] refactor(app): extract page logic into composable drivers --- src/App.vue | 594 +----------------- src/components/pages/PageFunction.vue | 13 + src/components/pages/PageHome.vue | 29 + src/components/pages/PageSettings.vue | 11 + src/composables/layout/useAppShell.ts | 280 +++++++++ .../page-drivers/useFunctionPage.ts | 18 + src/composables/page-drivers/useHomePage.ts | 101 +++ .../page-drivers/useSettingsPage.ts | 13 + src/shell/AppShell.vue | 78 ++- src/shell/AppTabs.vue | 7 +- src/views/FncPage.vue | 17 +- src/views/Home.vue | 94 +-- src/views/Settings.vue | 13 +- 13 files changed, 576 insertions(+), 692 deletions(-) create mode 100644 src/components/pages/PageFunction.vue create mode 100644 src/components/pages/PageHome.vue create mode 100644 src/components/pages/PageSettings.vue create mode 100644 src/composables/layout/useAppShell.ts create mode 100644 src/composables/page-drivers/useFunctionPage.ts create mode 100644 src/composables/page-drivers/useHomePage.ts create mode 100644 src/composables/page-drivers/useSettingsPage.ts diff --git a/src/App.vue b/src/App.vue index 64a0ea3..0cf0aea 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,589 +1,7 @@ - - - + + diff --git a/src/components/pages/PageFunction.vue b/src/components/pages/PageFunction.vue new file mode 100644 index 0000000..13c4691 --- /dev/null +++ b/src/components/pages/PageFunction.vue @@ -0,0 +1,13 @@ + + + diff --git a/src/components/pages/PageHome.vue b/src/components/pages/PageHome.vue new file mode 100644 index 0000000..5f3534f --- /dev/null +++ b/src/components/pages/PageHome.vue @@ -0,0 +1,29 @@ + + + diff --git a/src/components/pages/PageSettings.vue b/src/components/pages/PageSettings.vue new file mode 100644 index 0000000..01abcd4 --- /dev/null +++ b/src/components/pages/PageSettings.vue @@ -0,0 +1,11 @@ + + + diff --git a/src/composables/layout/useAppShell.ts b/src/composables/layout/useAppShell.ts new file mode 100644 index 0000000..6dfdb16 --- /dev/null +++ b/src/composables/layout/useAppShell.ts @@ -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() + 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() + 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, + } +} diff --git a/src/composables/page-drivers/useFunctionPage.ts b/src/composables/page-drivers/useFunctionPage.ts new file mode 100644 index 0000000..4259a1d --- /dev/null +++ b/src/composables/page-drivers/useFunctionPage.ts @@ -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(() => ({ + fncId: String(route.params.fncId ?? ''), + })) + + return { + pageModel, + } +} diff --git a/src/composables/page-drivers/useHomePage.ts b/src/composables/page-drivers/useHomePage.ts new file mode 100644 index 0000000..e4072d5 --- /dev/null +++ b/src/composables/page-drivers/useHomePage.ts @@ -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(null) + const isNewsDialogOpen = ref(false) + const pageModel = computed(() => ({ + 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, + } +} diff --git a/src/composables/page-drivers/useSettingsPage.ts b/src/composables/page-drivers/useSettingsPage.ts new file mode 100644 index 0000000..6101239 --- /dev/null +++ b/src/composables/page-drivers/useSettingsPage.ts @@ -0,0 +1,13 @@ +import { computed } from 'vue' + +export interface SettingsPageModel { + title: string +} + +export function useSettingsPage() { + const pageModel = computed(() => ({ + title: '設定頁面', + })) + + return { pageModel } +} diff --git a/src/shell/AppShell.vue b/src/shell/AppShell.vue index 376d7a0..3b06167 100644 --- a/src/shell/AppShell.vue +++ b/src/shell/AppShell.vue @@ -1,17 +1,19 @@ diff --git a/src/shell/AppTabs.vue b/src/shell/AppTabs.vue index e958e66..01cff75 100644 --- a/src/shell/AppTabs.vue +++ b/src/shell/AppTabs.vue @@ -74,7 +74,12 @@ function closeTab(path: string) { emit('close', path) } -defineExpose({ tabs, activeTab, closeTab }) +function clearTabs() { + tabs.value = [] + activeTab.value = null +} + +defineExpose({ tabs, activeTab, closeTab, clearTabs })