refactor(app): extract page logic into composable drivers
This commit is contained in:
+6
-588
@@ -1,589 +1,7 @@
|
||||
<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
|
||||
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 setup lang="ts">
|
||||
import AppShell from '@/shell/AppShell.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell />
|
||||
</template>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -1,17 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { mdiHome } from '@mdi/js'
|
||||
import MainLayout from '@/components/layouts/MainLayout.vue'
|
||||
import PlainLayout from '@/components/layouts/PlainLayout.vue'
|
||||
import { useAppShell } from '@/composables/layout/useAppShell'
|
||||
import AppTabs from './AppTabs.vue'
|
||||
import GlobalOverlays from './GlobalOverlays.vue'
|
||||
import type { LayoutMenuItem } from '@/stores/menu'
|
||||
|
||||
defineProps<{
|
||||
menuItems?: LayoutMenuItem[]
|
||||
}>()
|
||||
type AppTabsInstance = InstanceType<typeof AppTabs>
|
||||
type GlobalOverlaysInstance = InstanceType<typeof GlobalOverlays>
|
||||
|
||||
const route = useRoute()
|
||||
const appTabs = ref<AppTabsInstance | null>(null)
|
||||
const globalOverlays = ref<GlobalOverlaysInstance | null>(null)
|
||||
|
||||
const layoutMap = {
|
||||
default: MainLayout,
|
||||
@@ -22,14 +24,72 @@ const activeLayout = computed(
|
||||
() => layoutMap[route.meta.layout as 'default' | 'none'] || MainLayout
|
||||
)
|
||||
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>
|
||||
|
||||
<template>
|
||||
<component :is="activeLayout">
|
||||
<AppTabs :menu-items="menuItems" :show-tabs="showTabs">
|
||||
<slot />
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</component>
|
||||
|
||||
<GlobalOverlays :menu-items="menuItems" @search-select="$emit('searchSelect', $event)" />
|
||||
<GlobalOverlays ref="globalOverlays" :menu-items="mergedMenuItems" @search-select="handleSelect" />
|
||||
</template>
|
||||
|
||||
@@ -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 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
+7
-10
@@ -1,13 +1,10 @@
|
||||
<template>
|
||||
<v-sheet height="100%" width="100%">
|
||||
{{ fncId }}
|
||||
</v-sheet>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import PageFunction from '@/components/pages/PageFunction.vue'
|
||||
import { useFunctionPage } from '@/composables/page-drivers/useFunctionPage'
|
||||
|
||||
const route = useRoute()
|
||||
const fncId = computed(() => String(route.params.fncId ?? ''))
|
||||
const { pageModel } = useFunctionPage()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageFunction :page="pageModel" />
|
||||
</template>
|
||||
|
||||
+14
-80
@@ -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>
|
||||
<page-index
|
||||
:is-news-dialog-open="isNewsDialogOpen"
|
||||
:news-items="newsItems"
|
||||
:quick-items="quickItems"
|
||||
:selected-news="selectedNews"
|
||||
@message-center="handleMessageCenter"
|
||||
@news="handleNews"
|
||||
@quick="handleQuick"
|
||||
@update:is-news-dialog-open="isNewsDialogOpen = $event"
|
||||
<PageHome
|
||||
v-model:news-dialog-open="page.isNewsDialogOpen.value"
|
||||
:page="page.pageModel.value"
|
||||
:selected-news="page.selectedNews.value"
|
||||
@message-center="page.handleMessageCenter"
|
||||
@news="page.handleNews"
|
||||
@quick="page.handleQuick"
|
||||
/>
|
||||
</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>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<template>
|
||||
<div>設定頁面</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import PageSettings from '@/components/pages/PageSettings.vue'
|
||||
import { useSettingsPage } from '@/composables/page-drivers/useSettingsPage'
|
||||
|
||||
<script setup lang="ts"></script>
|
||||
const { pageModel } = useSettingsPage()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageSettings :page="pageModel" />
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user