feat: add SingleRecordMnt component for student record maintenance with search, add, edit, view, and delete functionalities
This commit is contained in:
+518
-7
@@ -1,11 +1,522 @@
|
||||
<template>
|
||||
<v-app>
|
||||
<v-main>
|
||||
<router-view />
|
||||
</v-main>
|
||||
</v-app>
|
||||
<!-- 根據路由設定 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">{{ favoriteActionIcon }}</v-icon>
|
||||
{{ favoriteActionLabel }}
|
||||
</v-btn>
|
||||
<v-btn class="ml-2" color="primary" size="small" variant="text" @click="goHome">
|
||||
<v-icon class="mr-1" size="14">mdi-home</v-icon>
|
||||
返回首頁
|
||||
</v-btn>
|
||||
</template>
|
||||
<!-- 如果是預設佈局,顯示分頁標籤 -->
|
||||
<template v-if="showTabs">
|
||||
<div class="d-flex flex-column h-100">
|
||||
<v-tabs
|
||||
v-model="activeTab" bg-color="background" color="primary" density="compact" show-arrows
|
||||
style="flex-shrink: 0;">
|
||||
<v-tab v-for="tab in tabs" :key="tab.path" border="sm" :to="tab.path" :value="tab.path">
|
||||
{{ tab.title }}
|
||||
<v-btn
|
||||
class="pl-2" color="grey" density="compact" :disabled="tabs.length <= 1" icon size="x-small"
|
||||
variant="text" @click.prevent.stop="closeTab(tab.path)">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</v-tab>
|
||||
</v-tabs>
|
||||
|
||||
<div class="flex-grow-1 overflow-auto" style="min-height: 0;">
|
||||
<router-view v-slot="{ Component }">
|
||||
<keep-alive>
|
||||
<component :is="Component" :key="route.fullPath" />
|
||||
</keep-alive>
|
||||
</router-view>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 其他佈局直接顯示內容 -->
|
||||
<router-view v-else />
|
||||
</component>
|
||||
|
||||
<v-dialog v-model="searchDialog" max-width="640">
|
||||
<v-card>
|
||||
<v-card-title class="text-h6 bg-primary-variant pa-4">搜尋結果</v-card-title>
|
||||
<v-card-subtitle v-if="searchKeyword" class="pt-4">關鍵字:{{ searchKeyword }}</v-card-subtitle>
|
||||
<v-card-text class="pt-2">
|
||||
<v-alert v-if="searchResults.length === 0" class="mt-2" density="compact" type="info" variant="tonal">
|
||||
查無結果
|
||||
</v-alert>
|
||||
<v-list v-else density="compact">
|
||||
<v-list-item v-for="item in searchResults" :key="item.path" class="mb-2" @click="handleSearchSelect(item)">
|
||||
<template #prepend>
|
||||
<v-icon v-if="item.icon" size="18">{{ item.icon }}</v-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">{{ resolveMessageItem(wrapped).icon }}</v-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 lang="ts" setup>
|
||||
//
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { SKAdminLayout, SKEmptyLayout } from '../src'
|
||||
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: 'mdi-file-document-outline', path: '/single-record-maintenance' },
|
||||
{ title: '主從資料維護A', icon: 'mdi-file-tree-outline', path: '/master-detail-maintenance' },
|
||||
{ title: '主從資料維護B', icon: 'mdi-file-tree-outline', path: '/master-detail-maintenance-b' },
|
||||
{ title: '主從資料維護C', icon: 'mdi-file-tree-outline', path: '/master-detail-maintenance-c' },
|
||||
{ title: '可編輯表格維護', icon: 'mdi-table-edit', path: '/editable-grid-maintenance' },
|
||||
],
|
||||
},
|
||||
{ title: '登入頁', path: '/login' },
|
||||
]
|
||||
|
||||
// 範例選單(用於 tab 顯示名稱的保底資料)
|
||||
const _menuItemsExample = [
|
||||
{ title: '首頁', icon: 'mdi-home', path: '/' },
|
||||
{ title: '工作台', icon: 'mdi-view-dashboard-variant', path: '/dashboard' },
|
||||
{ title: '分析頁', icon: 'mdi-chart-box-outline', path: '/analysis' },
|
||||
{
|
||||
title: '設定',
|
||||
icon: 'mdi-cog',
|
||||
path: '/settings',
|
||||
navigable: false,
|
||||
subItems: [
|
||||
{ title: '角色管理', icon: 'mdi-account-group', path: '/role-management' },
|
||||
{ title: '選單管理', icon: 'mdi-menu', path: '/menu-management' },
|
||||
{ title: '部門管理', icon: 'mdi-domain', path: '/dept-management' },
|
||||
],
|
||||
},
|
||||
..._fixedMenuItems,
|
||||
]
|
||||
|
||||
/**
|
||||
* 佈局對映表
|
||||
*/
|
||||
const layoutMap = {
|
||||
default: SKAdminLayout,
|
||||
none: SKEmptyLayout,
|
||||
}
|
||||
|
||||
// 取得當前應使用的組件
|
||||
const activeLayout = computed(() => {
|
||||
return layoutMap[route.meta.layout] || SKAdminLayout
|
||||
})
|
||||
|
||||
function buildMergedMenuItems (items) {
|
||||
const flatPaths = new Set()
|
||||
const collectPaths = (list) => {
|
||||
for (const item of list || []) {
|
||||
if (item?.path) flatPaths.add(item.path)
|
||||
if (item?.subItems?.length) collectPaths(item.subItems)
|
||||
}
|
||||
}
|
||||
|
||||
collectPaths(items)
|
||||
|
||||
const mergeFixedItems = (list) => {
|
||||
return (list || []).map((item) => {
|
||||
if (!item?.subItems?.length) return item
|
||||
const subItems = item.subItems.filter((sub) => !sub?.path || !flatPaths.has(sub.path))
|
||||
return { ...item, subItems }
|
||||
})
|
||||
}
|
||||
|
||||
const fixedItems = mergeFixedItems(_fixedMenuItems).filter((item) => {
|
||||
if (!item?.subItems?.length) return !item?.path || !flatPaths.has(item.path)
|
||||
return item.subItems.length > 0
|
||||
})
|
||||
|
||||
return [...(items || []), ...fixedItems]
|
||||
}
|
||||
|
||||
// 根據不同 Layout 傳遞不同的 Props
|
||||
const mergedMenuItems = computed(() => buildMergedMenuItems(menuStore.menuItems))
|
||||
|
||||
const mergedFavoriteItems = computed(() => {
|
||||
const combined = [...menuStore.favoriteItems, ...favoritesStore.layoutItems]
|
||||
const seen = new Set()
|
||||
return combined.filter((item) => {
|
||||
const key = item.path ?? item.title
|
||||
if (!key) return false
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
const layoutProps = computed(() => {
|
||||
const layout = route.meta.layout
|
||||
if (layout === 'default') {
|
||||
return {
|
||||
systemTitle: '測試環境',
|
||||
favoriteItems: mergedFavoriteItems.value,
|
||||
menuItems: mergedMenuItems.value,
|
||||
breadcrumbItems: breadcrumbStore.breadcrumbItems,
|
||||
}
|
||||
}
|
||||
return {}
|
||||
})
|
||||
|
||||
function handleSelect (item) {
|
||||
console.log('Selected:', item)
|
||||
if (item.path) {
|
||||
router.push(item.path)
|
||||
}
|
||||
}
|
||||
|
||||
const searchDialog = ref(false)
|
||||
const searchKeyword = ref('')
|
||||
const searchResults = ref([])
|
||||
|
||||
function buildSearchResults (items, keyword, parents = []) {
|
||||
const results = []
|
||||
for (const item of items || []) {
|
||||
const currentParents = item?.title ? [...parents, item.title] : parents
|
||||
if (item?.subItems?.length) {
|
||||
results.push(...buildSearchResults(item.subItems, keyword, currentParents))
|
||||
}
|
||||
if (item?.path && item?.title) {
|
||||
const hit = item.title.toLowerCase().includes(keyword)
|
||||
if (hit) {
|
||||
results.push({
|
||||
title: item.title,
|
||||
path: item.path,
|
||||
icon: item.icon,
|
||||
parents: parents,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// 接收 Layout 的搜尋事件:僅在「按鈕或 Enter」時觸發
|
||||
function handleSearch (value) {
|
||||
const keyword = String(value ?? '').trim()
|
||||
searchKeyword.value = keyword
|
||||
if (!keyword) {
|
||||
// 空字串時不顯示結果彈窗
|
||||
searchResults.value = []
|
||||
searchDialog.value = false
|
||||
return
|
||||
}
|
||||
const lowered = keyword.toLowerCase()
|
||||
// 依合併後的 menuItems 進行比對
|
||||
searchResults.value = buildSearchResults(mergedMenuItems.value, lowered)
|
||||
// 開啟彈窗顯示搜尋結果
|
||||
searchDialog.value = true
|
||||
}
|
||||
|
||||
// 點擊搜尋結果後導頁(行為等同選單點擊)
|
||||
function handleSearchSelect (item) {
|
||||
searchDialog.value = false
|
||||
handleSelect(item)
|
||||
}
|
||||
|
||||
// 訊息中心的示意資料,僅用於展示列表,不進行 API 呼叫
|
||||
const messageItems = [
|
||||
{ id: 1, title: '系統維護提醒', meta: '今天 09:00 · 資訊中心', icon: 'mdi-bell-outline' },
|
||||
{ id: 2, title: '教務處公告', meta: '昨天 16:20 · 教務處', icon: 'mdi-school-outline' },
|
||||
{ id: 3, title: '宿舍申請結果', meta: '昨天 13:05 · 學務處', icon: 'mdi-home-city-outline' },
|
||||
{ id: 4, title: '課表異動通知', meta: '2 天前 · 教務處', icon: 'mdi-calendar-outline' },
|
||||
]
|
||||
|
||||
// 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 ? 'mdi-close-circle' : 'mdi-plus-circle'))
|
||||
|
||||
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: 'mdi-home',
|
||||
})
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user