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 @@
-
-
-
-
-
-
- {{ favoriteActionLabel }}
-
-
-
- 返回首頁
-
-
-
-
-
-
-
- {{ tab.title }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 搜尋結果
- 關鍵字:{{ searchKeyword }}
-
-
- 查無結果
-
-
-
-
-
-
- {{ item.title }}
-
- {{ item.parents.join(' / ') }}
-
-
-
-
-
-
- 關閉
-
-
-
-
-
-
-
- 訊息清單
- 僅示意資料,不含延伸功能
-
-
-
-
-
-
-
-
-
-
-
-
- {{ resolveMessageItem(wrapped).title }}
-
-
- {{ resolveMessageItem(wrapped).meta }}
-
-
-
-
-
-
-
- 關閉
-
-
-
-
-
- {{ snackbar.message }}
-
-
-
-
+
+
+
+
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 @@
+
+
+
+
+ {{ page.fncId }}
+
+
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 @@
+
+
+
+ {{ page.title }}
+
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 @@
-
-
-
+
+
+
+
+ {{ favoriteActionLabel }}
+
+
+
+ 返回首頁
+
+
+
+
+
+
+
+
+
+
+
-
+
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 })
diff --git a/src/views/FncPage.vue b/src/views/FncPage.vue
index 75b86f7..4a6aa7c 100644
--- a/src/views/FncPage.vue
+++ b/src/views/FncPage.vue
@@ -1,13 +1,10 @@
-
-
- {{ fncId }}
-
-
-
+
+
+
+
diff --git a/src/views/Home.vue b/src/views/Home.vue
index c1cb1eb..11edc13 100644
--- a/src/views/Home.vue
+++ b/src/views/Home.vue
@@ -1,83 +1,17 @@
+
+
-
-
-
diff --git a/src/views/Settings.vue b/src/views/Settings.vue
index 52f1e87..07b1c35 100644
--- a/src/views/Settings.vue
+++ b/src/views/Settings.vue
@@ -1,5 +1,10 @@
-
- 設定頁面
-
+
+const { pageModel } = useSettingsPage()
+
+
+
+
+