feat: add SingleRecordMnt component for student record maintenance with search, add, edit, view, and delete functionalities

This commit is contained in:
skytek_xinliang
2026-03-26 11:24:37 +08:00
parent 507afcc99c
commit 069141794e
116 changed files with 15247 additions and 107 deletions
+518 -7
View File
@@ -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>