From 389ec56480dbeb0ef2e5012c687a21b9695909dc Mon Sep 17 00:00:00 2001 From: skytek_xinliang Date: Thu, 26 Mar 2026 16:01:20 +0800 Subject: [PATCH] refactor: replace common confirm dialogs with maintenance CRUD dialogs and streamline form handling in MasterDetailMntC.vue and SingleRecordMnt.vue --- src/App.vue | 3 +- {public => src/assets}/robot-svgrepo-com.svg | 0 src/components/HelloWorld.vue | 5 - src/components/layouts/SKAdminLayout.vue | 385 +++++-------- src/components/layouts/SKMainLayout.vue | 286 ++------- src/components/layouts/SKSimpleLayout.vue | 137 +---- .../SkAdminAppBarBreadcrumbCol.vue | 56 +- .../SkAdminAppBarFavoritesCol.vue | 42 +- .../sk-admin-layout/SkAdminAppBarTopCol.vue | 76 ++- .../SkAdminDrawerDesktopMenu.vue | 24 +- .../SkAdminDrawerMobileFavoritesPanel.vue | 17 +- .../SkAdminDrawerMobileMenuPanel.vue | 17 +- .../layouts/sk-admin-layout/types.ts | 71 +++ .../maintenance/EditableStudentGrid.vue | 349 +++++++++++ .../maintenance/MaintenanceCrudDialogs.vue | 120 ++++ .../MaintenanceStudentFormFields.vue | 127 ++++ .../MasterDetailBSemesterMobilePanel.vue | 62 +- .../MasterDetailCCourseMobilePanel.vue | 46 +- src/composables/layout/useAdminLayoutState.ts | 211 +++++++ src/composables/layout/useThemeToggle.ts | 28 + .../maintenance/useEditableStudentGrid.ts | 267 +++++++++ .../maintenance/useMaintenanceCrudFlow.ts | 245 ++++++++ .../maintenance/useStudentMaintenanceForm.ts | 228 ++++++++ src/composables/useApiCall.ts | 16 +- src/index.ts | 2 - src/pages/index.vue | 7 - src/shims-vue.d.ts | 6 + src/views/maint/EditableGridMnt.vue | 390 +------------ src/views/maint/MasterDetailMnt.vue | 542 ++++-------------- src/views/maint/MasterDetailMntB.vue | 540 ++++------------- src/views/maint/MasterDetailMntC.vue | 540 ++++------------- src/views/maint/SingleRecordMnt.vue | 467 +++------------ 32 files changed, 2549 insertions(+), 2763 deletions(-) rename {public => src/assets}/robot-svgrepo-com.svg (100%) delete mode 100644 src/components/HelloWorld.vue create mode 100644 src/components/layouts/sk-admin-layout/types.ts create mode 100644 src/components/maintenance/EditableStudentGrid.vue create mode 100644 src/components/maintenance/MaintenanceCrudDialogs.vue create mode 100644 src/components/maintenance/MaintenanceStudentFormFields.vue create mode 100644 src/composables/layout/useAdminLayoutState.ts create mode 100644 src/composables/layout/useThemeToggle.ts create mode 100644 src/composables/maintenance/useEditableStudentGrid.ts create mode 100644 src/composables/maintenance/useMaintenanceCrudFlow.ts create mode 100644 src/composables/maintenance/useStudentMaintenanceForm.ts delete mode 100644 src/index.ts delete mode 100644 src/pages/index.vue create mode 100644 src/shims-vue.d.ts diff --git a/src/App.vue b/src/App.vue index 3dda6b5..dd9f01c 100644 --- a/src/App.vue +++ b/src/App.vue @@ -125,7 +125,8 @@ v-model="snackbar.visible" :color="snackbar.color" :location="snackbar.location" import { mdiAccountGroup, mdiBellOutline, mdiCalendarOutline, mdiChartBoxOutline, mdiClose, mdiCloseCircle, mdiCog, mdiDomain, mdiFileDocumentOutline, mdiFileTreeOutline, mdiHome, mdiHomeCityOutline, mdiMenu, mdiPlusCircle, mdiSchoolOutline, mdiTableEdit, mdiViewDashboardVariant } from '@mdi/js' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' -import { SKAdminLayout, SKEmptyLayout } from '../src' +import SKAdminLayout from '@/components/layouts/SKAdminLayout.vue' +import SKEmptyLayout from '@/components/layouts/SKEmptyLayout.vue' import { HTTP_TOAST_EVENT } from './services/http-toast' import { SESSION_FORCE_LOGOUT_EVENT } from './services/session' import { useAuthStore } from './stores/auth' diff --git a/public/robot-svgrepo-com.svg b/src/assets/robot-svgrepo-com.svg similarity index 100% rename from public/robot-svgrepo-com.svg rename to src/assets/robot-svgrepo-com.svg diff --git a/src/components/HelloWorld.vue b/src/components/HelloWorld.vue deleted file mode 100644 index 750eb46..0000000 --- a/src/components/HelloWorld.vue +++ /dev/null @@ -1,5 +0,0 @@ - diff --git a/src/components/layouts/SKAdminLayout.vue b/src/components/layouts/SKAdminLayout.vue index 88867fd..db7b99d 100644 --- a/src/components/layouts/SKAdminLayout.vue +++ b/src/components/layouts/SKAdminLayout.vue @@ -66,10 +66,10 @@ v-model:opened="opened" :is-shrink="isRail" :menu-items="menuItems" @@ -145,11 +145,25 @@ aria-label="關閉說明" :icon="mdiClose" size="small" variant="text" - diff --git a/src/components/layouts/SKMainLayout.vue b/src/components/layouts/SKMainLayout.vue index 31ad977..0062f05 100644 --- a/src/components/layouts/SKMainLayout.vue +++ b/src/components/layouts/SKMainLayout.vue @@ -1,252 +1,62 @@ - - - diff --git a/src/components/layouts/SKSimpleLayout.vue b/src/components/layouts/SKSimpleLayout.vue index 52ebdba..29d43db 100644 --- a/src/components/layouts/SKSimpleLayout.vue +++ b/src/components/layouts/SKSimpleLayout.vue @@ -1,119 +1,36 @@ - diff --git a/src/components/layouts/sk-admin-layout/SkAdminAppBarBreadcrumbCol.vue b/src/components/layouts/sk-admin-layout/SkAdminAppBarBreadcrumbCol.vue index f1a6bbe..4939014 100644 --- a/src/components/layouts/sk-admin-layout/SkAdminAppBarBreadcrumbCol.vue +++ b/src/components/layouts/sk-admin-layout/SkAdminAppBarBreadcrumbCol.vue @@ -12,8 +12,8 @@ v-if="features.showFavorites && !showFavoritesBar" class="mr-2" color="primary" - diff --git a/src/components/layouts/sk-admin-layout/SkAdminAppBarFavoritesCol.vue b/src/components/layouts/sk-admin-layout/SkAdminAppBarFavoritesCol.vue index 42839e2..3e581b6 100644 --- a/src/components/layouts/sk-admin-layout/SkAdminAppBarFavoritesCol.vue +++ b/src/components/layouts/sk-admin-layout/SkAdminAppBarFavoritesCol.vue @@ -28,17 +28,43 @@ v-if="favoritesConfig.showAdd" class="favorite-add" color="primary" size="small" - diff --git a/src/components/maintenance/MaintenanceCrudDialogs.vue b/src/components/maintenance/MaintenanceCrudDialogs.vue new file mode 100644 index 0000000..3de7c1d --- /dev/null +++ b/src/components/maintenance/MaintenanceCrudDialogs.vue @@ -0,0 +1,120 @@ + + + diff --git a/src/components/maintenance/MaintenanceStudentFormFields.vue b/src/components/maintenance/MaintenanceStudentFormFields.vue new file mode 100644 index 0000000..1bcd692 --- /dev/null +++ b/src/components/maintenance/MaintenanceStudentFormFields.vue @@ -0,0 +1,127 @@ + + + diff --git a/src/components/maintenance/master-detail-b/MasterDetailBSemesterMobilePanel.vue b/src/components/maintenance/master-detail-b/MasterDetailBSemesterMobilePanel.vue index b725432..28419d9 100644 --- a/src/components/maintenance/master-detail-b/MasterDetailBSemesterMobilePanel.vue +++ b/src/components/maintenance/master-detail-b/MasterDetailBSemesterMobilePanel.vue @@ -27,7 +27,12 @@ @@ -55,7 +63,7 @@ label="學期名稱" :model-value="semester.semesterName" variant="outlined" - @update:model-value="(value) => $emit('update-semester', semester.id, { semesterName: String(value) })" + @update:model-value="(value) => updateSemester({ semesterName: String(value) })" /> - +
課程 {{ idx + 1 }}
@@ -115,7 +128,7 @@ label="課程名稱" :model-value="course.name" variant="outlined" - @update:model-value="(value) => $emit('update-course', semester.id, idx, { name: String(value) })" + @update:model-value="(value) => updateCourse(idx, { name: String(value) })" />
-
+
尚無課程資料
@@ -159,9 +175,8 @@ diff --git a/src/components/maintenance/master-detail-c/MasterDetailCCourseMobilePanel.vue b/src/components/maintenance/master-detail-c/MasterDetailCCourseMobilePanel.vue index 3277f18..d4d65f5 100644 --- a/src/components/maintenance/master-detail-c/MasterDetailCCourseMobilePanel.vue +++ b/src/components/maintenance/master-detail-c/MasterDetailCCourseMobilePanel.vue @@ -29,7 +29,9 @@
課程列表
-
手機版改用卡片式維護,不使用扁平表格
+
+ 手機版改用卡片式維護,不使用扁平表格 +
diff --git a/src/composables/layout/useAdminLayoutState.ts b/src/composables/layout/useAdminLayoutState.ts new file mode 100644 index 0000000..9b069f6 --- /dev/null +++ b/src/composables/layout/useAdminLayoutState.ts @@ -0,0 +1,211 @@ +import { computed, onBeforeUnmount, onMounted, ref, watch, type Ref } from 'vue' +import type { AdminLayoutMenuItem } from '@/components/layouts/sk-admin-layout/types' + +type ToggleSidebarPayload = { + drawer: boolean + rail: boolean +} + +type UseAdminLayoutStateOptions = { + appBarRef: Ref + breadcrumbBarVisible: boolean | null + emitUpdateBreadcrumbBarVisible: (value: boolean) => void + emitUpdateFavoritesBarVisible: (value: boolean) => void + emitUpdateIsRail: (value: boolean) => void + favoritesBarVisible: boolean | null + isMobile: Ref + isRail: boolean | null + menuItems: AdminLayoutMenuItem[] + onToggleSidebar: (payload: ToggleSidebarPayload) => void +} + +export function useAdminLayoutState (options: UseAdminLayoutStateOptions) { + const drawer = ref(true) + const mobileFavoritesPanel = ref(false) + const mobileMenuPath = ref([]) + const localBreadcrumbBarVisible = ref(true) + const localFavoritesBarVisible = ref(true) + const localIsRail = ref(false) + const opened = ref([]) + const appBarHeight = ref(0) + + const isRail = computed({ + get: () => (options.isRail ?? localIsRail.value), + set: (value: boolean) => { + if (options.isRail === null) { + localIsRail.value = value + return + } + + options.emitUpdateIsRail(value) + }, + }) + + const showFavoritesBar = computed({ + get: () => (options.favoritesBarVisible ?? localFavoritesBarVisible.value), + set: (value: boolean) => { + if (options.favoritesBarVisible === null) { + localFavoritesBarVisible.value = value + return + } + + options.emitUpdateFavoritesBarVisible(value) + }, + }) + + const showBreadcrumbBar = computed({ + get: () => (options.breadcrumbBarVisible ?? localBreadcrumbBarVisible.value), + set: (value: boolean) => { + if (options.breadcrumbBarVisible === null) { + localBreadcrumbBarVisible.value = value + return + } + + options.emitUpdateBreadcrumbBarVisible(value) + }, + }) + + const mobileCurrentItems = computed(() => + mobileMenuPath.value.reduce( + (items, currentItem) => currentItem?.subItems ?? [], + options.menuItems || [] + ) + ) + + const mobileCurrentLevel = computed(() => mobileMenuPath.value.length + 1) + + const mobileMenuLevels = computed(() => + Array.from({ length: mobileCurrentLevel.value }, (_, index) => ({ + level: index + 1, + title: index === 0 ? '主選單' : (mobileMenuPath.value[index - 1]?.title ?? `第${index + 1}層`), + })) + ) + + function resetMobilePanels () { + mobileFavoritesPanel.value = false + mobileMenuPath.value = [] + } + + function toggleSidebar () { + if (options.isMobile.value) { + drawer.value = !drawer.value + } else { + isRail.value = !isRail.value + } + + options.onToggleSidebar({ + drawer: drawer.value, + rail: isRail.value, + }) + } + + function goToMobileLevel (level: number) { + mobileFavoritesPanel.value = false + mobileMenuPath.value = mobileMenuPath.value.slice(0, Math.max(0, level - 1)) + } + + function openMobileFavoritesPanel () { + mobileMenuPath.value = [] + mobileFavoritesPanel.value = true + } + + function handleMobileMenuClick ( + item: AdminLayoutMenuItem, + onSelect: (selectedItem: AdminLayoutMenuItem) => void + ) { + if (item?.subItems?.length) { + mobileMenuPath.value = [...mobileMenuPath.value, item] + return + } + + onSelect(item) + } + + function handleSelectFavorite ( + item: AdminLayoutMenuItem, + onSelect: (selectedItem: AdminLayoutMenuItem) => void + ) { + onSelect(item) + mobileFavoritesPanel.value = false + } + + function toggleFavoritesBar (nextValue?: boolean) { + showFavoritesBar.value = + typeof nextValue === 'boolean' ? nextValue : !showFavoritesBar.value + } + + function handleUnshrink () { + isRail.value = false + } + + let appBarObserver: ResizeObserver | null = null + + function resolveObservedElement () { + const target = options.appBarRef.value as HTMLElement | { $el?: HTMLElement } | null + if (!target) return null + if (target instanceof HTMLElement) return target + return target.$el ?? null + } + + function updateAppBarHeight () { + const el = resolveObservedElement() + if (!el) return + appBarHeight.value = Math.round(el.getBoundingClientRect().height || 0) + } + + onMounted(() => { + updateAppBarHeight() + if (typeof ResizeObserver === 'undefined') return + + const el = resolveObservedElement() + if (!el) return + + appBarObserver = new ResizeObserver(() => updateAppBarHeight()) + appBarObserver.observe(el) + }) + + onBeforeUnmount(() => { + if (!appBarObserver) return + appBarObserver.disconnect() + appBarObserver = null + }) + + watch(options.isMobile, (value) => { + if (!value) { + resetMobilePanels() + } + }) + + watch(drawer, (value) => { + if (!value) { + resetMobilePanels() + } + }) + + const mainStyle = computed(() => ({ + paddingTop: appBarHeight.value ? `${appBarHeight.value}px` : undefined, + height: '100vh', + minHeight: 0, + flex: '1 1 0', + })) + + return { + drawer, + goToMobileLevel, + handleMobileMenuClick, + handleSelectFavorite, + handleUnshrink, + isRail, + mainStyle, + mobileCurrentItems, + mobileCurrentLevel, + mobileFavoritesPanel, + mobileMenuLevels, + openMobileFavoritesPanel, + opened, + showBreadcrumbBar, + showFavoritesBar, + toggleFavoritesBar, + toggleSidebar, + } +} diff --git a/src/composables/layout/useThemeToggle.ts b/src/composables/layout/useThemeToggle.ts new file mode 100644 index 0000000..6d5a8fb --- /dev/null +++ b/src/composables/layout/useThemeToggle.ts @@ -0,0 +1,28 @@ +import { computed } from 'vue' +import { useTheme } from 'vuetify' +import { getNextThemeName } from '@/utils/theme' + +export function useThemeToggle () { + const theme = useTheme() + + const availableThemeNames = computed(() => + Object.keys(theme.themes.value ?? {}).filter((name) => name.startsWith('theme')) + ) + + function toggleTheme () { + const names = availableThemeNames.value + if (names.length === 0) return null + + const current = theme.global.name.value + const next = getNextThemeName(names, current) + if (!next) return null + + theme.change(next) + return next + } + + return { + availableThemeNames, + toggleTheme, + } +} diff --git a/src/composables/maintenance/useEditableStudentGrid.ts b/src/composables/maintenance/useEditableStudentGrid.ts new file mode 100644 index 0000000..bb08118 --- /dev/null +++ b/src/composables/maintenance/useEditableStudentGrid.ts @@ -0,0 +1,267 @@ +import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' +import { type StudentRecord, useStudentStore } from '@/stores/students' + +type StudentPayload = Omit + +const departments = ['資訊工程', '企業管理', '應用外語', '視覺設計', '財務金融'] +const gradeOptions = [ + { title: '大一', value: 1 }, + { title: '大二', value: 2 }, + { title: '大三', value: 3 }, + { title: '大四', value: 4 }, + { title: '研究所', value: 5 }, +] +const enrollYears = [2024, 2023, 2022, 2021, 2020, 2019] +const statuses = ['在學', '休學', '畢業'] + +const tableHeaders = [ + { title: '', key: 'select', sortable: false, nowrap: true }, + { title: '學號', key: 'studentId', sortable: true, minWidth: 120, nowrap: true, cellProps: { class: 'px-1' } }, + { title: '姓名', key: 'name', sortable: true, minWidth: 120, nowrap: true, cellProps: { class: 'px-1' } }, + { title: '系所', key: 'department', sortable: true, minWidth: 140, nowrap: true, cellProps: { class: 'px-1' } }, + { title: '年級', key: 'grade', sortable: true, minWidth: 140, nowrap: true, cellProps: { class: 'px-1' } }, + { title: '入學年度', key: 'enrollYear', sortable: true, minWidth: 120, nowrap: true, cellProps: { class: 'px-1' } }, + { title: '已修學分', key: 'credits', sortable: true, minWidth: 120, nowrap: true, cellProps: { class: 'px-1' } }, + { title: '指導老師', key: 'advisor', sortable: true, minWidth: 120, nowrap: true, cellProps: { class: 'px-1' } }, + { title: 'Email', key: 'email', sortable: true, minWidth: 200, nowrap: true, cellProps: { class: 'px-1' } }, + { title: '電話', key: 'phone', sortable: true, minWidth: 150, nowrap: true, cellProps: { class: 'px-1' } }, + { title: '狀態', key: 'status', sortable: true, minWidth: 120, nowrap: true, cellProps: { class: 'px-1' } }, + { title: '操作', key: 'actions', sortable: false, width: 'auto', nowrap: true, cellProps: { class: 'bg-background' } }, +] as const + +const TABLE_BOTTOM_GAP = 64 +const TABLE_MIN_HEIGHT = 240 + +export function useEditableStudentGrid () { + const studentStore = useStudentStore() + const students = computed(() => studentStore.students) + const search = ref({ + studentId: '', + name: '', + department: '', + }) + const isBulkEditEnabled = ref(false) + const isSearchVisible = ref(false) + const tableContainerRef = ref(null) + const tableScrollParentRef = ref(null) + const tableResizeObserver = ref(null) + const tableHeight = ref(300) + const draftRows = ref>({}) + const selectedRowIds = ref([]) + + function toPayload (student: StudentRecord): StudentPayload { + return { + studentId: student.studentId, + name: student.name, + department: student.department, + grade: student.grade, + enrollYear: student.enrollYear, + credits: student.credits, + advisor: student.advisor, + email: student.email, + phone: student.phone, + status: student.status, + } + } + + function rebuildDraftRows () { + draftRows.value = Object.fromEntries(students.value.map((item) => [item.id, toPayload(item)])) + } + + function getDraftRow (id: number) { + return draftRows.value[id] ?? null + } + + const getSourceRow = (id: number) => students.value.find((item) => item.id === id) || null + + function isRowDirty (id: number) { + const source = getSourceRow(id) + const draft = getDraftRow(id) + if (!source || !draft) return false + + return ( + source.studentId !== draft.studentId || + source.name !== draft.name || + source.department !== draft.department || + source.grade !== draft.grade || + source.enrollYear !== draft.enrollYear || + source.credits !== draft.credits || + source.advisor !== draft.advisor || + source.email !== draft.email || + source.phone !== draft.phone || + source.status !== draft.status + ) + } + + const filteredStudents = computed(() => { + const keywordStudentId = search.value.studentId.trim().toLowerCase() + const keywordName = search.value.name.trim().toLowerCase() + const selectedDepartment = search.value.department + + return students.value.filter((item) => { + if (studentStore.isMarkedAsDeleted(item.id)) return false + + const matchesStudentId = keywordStudentId + ? item.studentId.toLowerCase().includes(keywordStudentId) + : true + const matchesName = keywordName ? item.name.toLowerCase().includes(keywordName) : true + const matchesDepartment = selectedDepartment ? item.department === selectedDepartment : true + + return matchesStudentId && matchesName && matchesDepartment + }) + }) + + const visibleStudentIds = computed(() => filteredStudents.value.map((item) => item.id)) + const isAllVisibleSelected = computed(() => + visibleStudentIds.value.length > 0 && + visibleStudentIds.value.every((id) => selectedRowIds.value.includes(id)) + ) + const isPartiallyVisibleSelected = computed(() => + visibleStudentIds.value.some((id) => selectedRowIds.value.includes(id)) && + !isAllVisibleSelected.value + ) + const hasAnyChange = computed(() => + students.value.some((item) => isRowDirty(item.id)) || studentStore.deletedIds.size > 0 + ) + const hasSelectedRows = computed(() => selectedRowIds.value.length > 0) + + function toggleSelectAll (checked: boolean | null) { + if (isPartiallyVisibleSelected.value) { + selectedRowIds.value = selectedRowIds.value.filter((id) => !visibleStudentIds.value.includes(id)) + return + } + + if (!checked) { + selectedRowIds.value = selectedRowIds.value.filter((id) => !visibleStudentIds.value.includes(id)) + return + } + + selectedRowIds.value = Array.from(new Set([...selectedRowIds.value, ...visibleStudentIds.value])) + } + + function toggleSingleRowSelection (id: number, checked: boolean | null) { + if (checked) { + if (!selectedRowIds.value.includes(id)) { + selectedRowIds.value = [...selectedRowIds.value, id] + } + return + } + + selectedRowIds.value = selectedRowIds.value.filter((selectedId) => selectedId !== id) + } + + function deleteSingleRow (id: number) { + selectedRowIds.value = selectedRowIds.value.filter((selectedId) => selectedId !== id) + studentStore.markAsDeleted(id) + rebuildDraftRows() + } + + function deleteSelectedRows () { + if (selectedRowIds.value.length === 0) return + + for (const id of selectedRowIds.value) { + studentStore.markAsDeleted(id) + } + + selectedRowIds.value = [] + rebuildDraftRows() + } + + function saveAllRows () { + for (const item of students.value) { + const draft = getDraftRow(item.id) + if (!draft) continue + if (!isRowDirty(item.id)) continue + studentStore.updateStudent(item.id, draft) + } + + studentStore.commitDeleted() + rebuildDraftRows() + } + + function resetAllRows () { + studentStore.restoreDeleted() + selectedRowIds.value = [] + rebuildDraftRows() + } + + function recalculateTableHeight () { + const container = tableContainerRef.value + if (!container) return + + const scrollParent = tableScrollParentRef.value || (container.closest('.overflow-auto') as HTMLElement | null) + tableScrollParentRef.value = scrollParent + + const parentBottom = scrollParent + ? scrollParent.getBoundingClientRect().bottom + : window.innerHeight + const top = container.getBoundingClientRect().top + const availableHeight = parentBottom - top - TABLE_BOTTOM_GAP + tableHeight.value = Math.max(TABLE_MIN_HEIGHT, Math.floor(availableHeight)) + } + + watch(students, () => { + const currentIds = new Set(students.value.map((item) => item.id)) + selectedRowIds.value = selectedRowIds.value.filter((id) => currentIds.has(id)) + }) + + watch(students, rebuildDraftRows, { immediate: true }) + + watch([isSearchVisible, isBulkEditEnabled], async () => { + await nextTick() + requestAnimationFrame(() => { + recalculateTableHeight() + }) + }) + + onMounted(() => { + const container = tableContainerRef.value + if (container) { + tableScrollParentRef.value = container.closest('.overflow-auto') as HTMLElement | null + tableResizeObserver.value = new ResizeObserver(() => { + recalculateTableHeight() + }) + tableResizeObserver.value.observe(container) + if (tableScrollParentRef.value) { + tableResizeObserver.value.observe(tableScrollParentRef.value) + } + } + + requestAnimationFrame(() => { + recalculateTableHeight() + }) + window.addEventListener('resize', recalculateTableHeight) + }) + + onBeforeUnmount(() => { + tableResizeObserver.value?.disconnect() + window.removeEventListener('resize', recalculateTableHeight) + }) + + return { + departments, + draftRows, + enrollYears, + filteredStudents, + getDraftRow, + gradeOptions, + hasAnyChange, + hasSelectedRows, + isAllVisibleSelected, + isBulkEditEnabled, + isPartiallyVisibleSelected, + isSearchVisible, + saveAllRows, + search, + selectedRowIds, + statuses, + tableContainerRef, + tableHeaders, + tableHeight, + toggleSelectAll, + toggleSingleRowSelection, + deleteSelectedRows, + deleteSingleRow, + resetAllRows, + } +} diff --git a/src/composables/maintenance/useMaintenanceCrudFlow.ts b/src/composables/maintenance/useMaintenanceCrudFlow.ts new file mode 100644 index 0000000..c8f21ab --- /dev/null +++ b/src/composables/maintenance/useMaintenanceCrudFlow.ts @@ -0,0 +1,245 @@ +import { computed, ref, type ComputedRef, type Ref } from 'vue' + +type DialogMode = 'create' | 'edit' | 'view' + +interface UseMaintenanceCrudFlowOptions { + records: ComputedRef + editingId: Ref + dialogMode: Ref + dialogVisible: Ref + isLoading: Ref + isSaving: Ref + isDirty: Readonly> | ComputedRef + clearAllErrors: () => void + resetForm: () => void + openEditDialog: (record: T) => void + openViewDialog: (record: T) => void + removeRecord: (id: number) => void + describeRecord: (record: T) => string + onCloseReset?: () => void + onAfterDelete?: (deletedId: number) => void +} + +interface UseMaintenanceCrudFlowResult { + confirmCloseVisible: Ref + confirmSaveVisible: Ref + confirmDeleteVisible: Ref + confirmSwitchVisible: Ref + confirmNavigateVisible: Ref + pendingDelete: Ref + pendingDeleteLabel: ComputedRef + currentRecordIndex: ComputedRef + currentEditingRecord: ComputedRef + hasPrevRecord: ComputedRef + hasNextRecord: ComputedRef + isEditMode: ComputedRef + isViewMode: ComputedRef + openAdjacentRecord: (direction: 'prev' | 'next') => void + openEdgeRecord: (position: 'first' | 'last') => void + switchToEditMode: () => void + switchToViewMode: () => void + confirmSwitch: () => void + confirmNavigate: () => void + requestDeleteConfirmation: (record: T) => void + requestDeleteCurrent: () => void + confirmDelete: () => void + requestCloseDialog: () => void + confirmClose: () => void + closeDialog: () => void + handleDialogVisibility: (nextValue: boolean) => void +} + +export function useMaintenanceCrudFlow ( + options: UseMaintenanceCrudFlowOptions, +): UseMaintenanceCrudFlowResult { + const confirmCloseVisible = ref(false) + const confirmSaveVisible = ref(false) + const confirmDeleteVisible = ref(false) + const confirmSwitchVisible = ref(false) + const confirmNavigateVisible = ref(false) + const pendingDelete = ref(null) as Ref + const pendingSwitchTarget = ref(null) + const pendingNavigateTarget = ref(null) + + const isEditMode = computed(() => options.dialogMode.value === 'edit') + const isViewMode = computed(() => options.dialogMode.value === 'view') + const currentRecordIndex = computed(() => + options.records.value.findIndex((item) => item.id === options.editingId.value), + ) + const currentEditingRecord = computed( + () => options.records.value.find((item) => item.id === options.editingId.value) || null, + ) + const hasPrevRecord = computed(() => currentRecordIndex.value > 0) + const hasNextRecord = computed( + () => + currentRecordIndex.value >= 0 + && currentRecordIndex.value < options.records.value.length - 1, + ) + const pendingDeleteLabel = computed(() => { + if (!pendingDelete.value) return '這筆資料' + return options.describeRecord(pendingDelete.value) + }) + + function openAdjacentRecord (direction: 'prev' | 'next') { + if (!isViewMode.value && !isEditMode.value) return + const index = currentRecordIndex.value + if (index < 0) return + const targetIndex = direction === 'prev' ? index - 1 : index + 1 + const target = options.records.value[targetIndex] + if (!target) return + if (isEditMode.value) { + if (options.isDirty.value) { + pendingNavigateTarget.value = target + confirmNavigateVisible.value = true + return + } + options.openEditDialog(target) + return + } + options.openViewDialog(target) + } + + function openEdgeRecord (position: 'first' | 'last') { + if (!isViewMode.value && !isEditMode.value) return + if (options.records.value.length === 0) return + const target = position === 'first' ? options.records.value[0] : options.records.value.at(-1) + if (!target) return + if (isEditMode.value) { + if (options.isDirty.value) { + pendingNavigateTarget.value = target + confirmNavigateVisible.value = true + return + } + options.openEditDialog(target) + return + } + options.openViewDialog(target) + } + + function switchToEditMode () { + if (!isViewMode.value) return + const current = currentEditingRecord.value + if (!current) return + options.openEditDialog(current) + } + + function switchToViewMode () { + if (!isEditMode.value) return + const current = currentEditingRecord.value + if (!current) return + if (options.isDirty.value) { + pendingSwitchTarget.value = current + confirmSwitchVisible.value = true + return + } + options.openViewDialog(current) + } + + function confirmSwitch () { + const target = pendingSwitchTarget.value + pendingSwitchTarget.value = null + confirmSwitchVisible.value = false + if (!target) return + options.openViewDialog(target) + } + + function confirmNavigate () { + const target = pendingNavigateTarget.value + pendingNavigateTarget.value = null + confirmNavigateVisible.value = false + if (!target) return + options.openEditDialog(target) + } + + function requestDeleteConfirmation (record: T) { + pendingDelete.value = record + confirmDeleteVisible.value = true + } + + function requestDeleteCurrent () { + const current = currentEditingRecord.value + if (!current) return + requestDeleteConfirmation(current) + } + + function closeDialog () { + options.dialogVisible.value = false + options.isLoading.value = false + options.isSaving.value = false + confirmCloseVisible.value = false + confirmSaveVisible.value = false + confirmDeleteVisible.value = false + confirmSwitchVisible.value = false + confirmNavigateVisible.value = false + pendingDelete.value = null + pendingSwitchTarget.value = null + pendingNavigateTarget.value = null + options.dialogMode.value = 'create' + options.editingId.value = null + options.clearAllErrors() + options.resetForm() + options.onCloseReset?.() + } + + function confirmDelete () { + if (!pendingDelete.value) return + const deletedId = pendingDelete.value.id + options.removeRecord(deletedId) + pendingDelete.value = null + confirmDeleteVisible.value = false + options.onAfterDelete?.(deletedId) + if (options.editingId.value === deletedId) { + closeDialog() + } + } + + function requestCloseDialog () { + if (options.isDirty.value && !options.isSaving.value) { + confirmCloseVisible.value = true + return + } + closeDialog() + } + + function confirmClose () { + confirmCloseVisible.value = false + closeDialog() + } + + function handleDialogVisibility (nextValue: boolean) { + if (nextValue) { + options.dialogVisible.value = true + return + } + requestCloseDialog() + } + + return { + confirmCloseVisible, + confirmSaveVisible, + confirmDeleteVisible, + confirmSwitchVisible, + confirmNavigateVisible, + pendingDelete, + pendingDeleteLabel, + currentRecordIndex, + currentEditingRecord, + hasPrevRecord, + hasNextRecord, + isEditMode, + isViewMode, + openAdjacentRecord, + openEdgeRecord, + switchToEditMode, + switchToViewMode, + confirmSwitch, + confirmNavigate, + requestDeleteConfirmation, + requestDeleteCurrent, + confirmDelete, + requestCloseDialog, + confirmClose, + closeDialog, + handleDialogVisibility, + } +} diff --git a/src/composables/maintenance/useStudentMaintenanceForm.ts b/src/composables/maintenance/useStudentMaintenanceForm.ts new file mode 100644 index 0000000..ddd8122 --- /dev/null +++ b/src/composables/maintenance/useStudentMaintenanceForm.ts @@ -0,0 +1,228 @@ +import { computed, ref, type ComputedRef, type Ref } from 'vue' +import type { StudentRecord } from '@/stores/students' + +interface GradeOption { + title: string + value: number +} + +interface StudentFormState { + studentId: string + name: string + department: string + grade: number + enrollYear: number + credits: number + advisor: string + email: string + phone: string + status: string +} + +interface UseStudentMaintenanceFormOptions { + departments: string[] + gradeOptions: GradeOption[] + enrollYears: number[] + statuses: string[] + students: ComputedRef + editingId: Ref + highlightedId: Ref +} + +type SaveSummaryItem = { + key: string + label: string + before: string | null + after: string +} + +const fieldLabels: Record = { + studentId: '學號', + name: '姓名', + department: '系所', + grade: '年級', + enrollYear: '入學年度', + credits: '已修學分', + advisor: '指導老師', + email: 'Email', + phone: '電話', + status: '狀態', +} + +function createDefaultForm ( + departments: string[], + gradeOptions: GradeOption[], + enrollYears: number[], + statuses: string[], +): StudentFormState { + return { + studentId: '', + name: '', + department: departments[0] ?? '', + grade: gradeOptions[0]?.value ?? 1, + enrollYear: enrollYears[0] ?? 2024, + credits: 0, + advisor: '', + email: '', + phone: '', + status: statuses[0] ?? '', + } +} + +function createEmptyFieldErrors () { + return { + studentId: [], + name: [], + department: [], + grade: [], + enrollYear: [], + credits: [], + advisor: [], + email: [], + phone: [], + status: [], + } as Record +} + +export function useStudentMaintenanceForm (options: UseStudentMaintenanceFormOptions) { + const form = ref( + createDefaultForm(options.departments, options.gradeOptions, options.enrollYears, options.statuses), + ) + const initialForm = ref({ ...form.value }) + const fieldErrors = ref(createEmptyFieldErrors()) + + const isDirty = computed( + () => JSON.stringify(form.value) !== JSON.stringify(initialForm.value), + ) + + function gradeLabel (grade: number) { + return options.gradeOptions.find((option) => option.value === grade)?.title ?? `年級 ${grade}` + } + + function formatSummaryValue (key: string, value: string | number | null | undefined) { + if (value === null || value === undefined || value === '') return '—' + if (key === 'grade') return gradeLabel(Number(value)) + return String(value) + } + + const saveSummary = computed(() => { + const before = initialForm.value + const after = form.value + const entries = Object.keys(fieldLabels).map((key) => { + const fieldKey = key as keyof StudentFormState + return { + key, + label: fieldLabels[fieldKey], + before: options.editingId.value ? formatSummaryValue(key, before[fieldKey]) : null, + after: formatSummaryValue(key, after[fieldKey]), + } + }) + + if (!options.editingId.value) return entries + return entries.filter((entry) => entry.before !== entry.after) + }) + + const errorSummary = computed(() => { + const entries = Object.entries(fieldErrors.value).flatMap(([field, messages]) => + messages.map((message) => ({ field, message })), + ) + return entries.slice(0, 3) + }) + + function setForm (nextForm: StudentFormState) { + form.value = { ...nextForm } + } + + function syncInitialForm () { + initialForm.value = { ...form.value } + } + + function resetForm () { + form.value = createDefaultForm( + options.departments, + options.gradeOptions, + options.enrollYears, + options.statuses, + ) + syncInitialForm() + clearAllErrors() + } + + function clearAllErrors () { + fieldErrors.value = createEmptyFieldErrors() + } + + function clearFieldError (field: keyof StudentFormState | string) { + if (!fieldErrors.value[field as keyof StudentFormState]?.length) return + fieldErrors.value[field as keyof StudentFormState] = [] + } + + function validateForm () { + const errors: Array<{ field: keyof StudentFormState; message: string }> = [] + const studentId = form.value.studentId.trim() + const name = form.value.name.trim() + const email = form.value.email.trim() + const credits = form.value.credits + + if (!studentId) errors.push({ field: 'studentId', message: '請輸入學號' }) + if (!name) errors.push({ field: 'name', message: '請輸入姓名' }) + if (!form.value.department) errors.push({ field: 'department', message: '請選擇系所' }) + if (!form.value.grade) errors.push({ field: 'grade', message: '請選擇年級' }) + if (!form.value.enrollYear) errors.push({ field: 'enrollYear', message: '請選擇入學年度' }) + if (credits === null || Number.isNaN(credits)) { + errors.push({ field: 'credits', message: '請輸入已修學分' }) + } else if (credits < 0) { + errors.push({ field: 'credits', message: '已修學分不可小於 0' }) + } + if (!form.value.status) errors.push({ field: 'status', message: '請選擇狀態' }) + if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + errors.push({ field: 'email', message: 'Email 格式不正確' }) + } + + const duplicate = options.students.value.find( + (item) => item.studentId === studentId && item.id !== options.editingId.value, + ) + if (studentId && duplicate) { + errors.push({ field: 'studentId', message: '學號已存在,請確認是否重複' }) + } + + return errors + } + + function statusColor (status: string) { + if (status === '在學') return 'success' + if (status === '休學') return 'warning' + if (status === '畢業') return 'secondary' + return 'default' + } + + function rowProps (data: { item: StudentRecord }) { + return { + class: data.item.id === options.highlightedId.value ? 'is-highlighted' : '', + } + } + + const isPlaceholderValue = (value: string | null) => value === '—' + + return { + errorSummary, + fieldErrors, + form, + initialForm, + isDirty, + isPlaceholderValue, + saveSummary, + clearAllErrors, + clearFieldError, + formatSummaryValue, + gradeLabel, + resetForm, + rowProps, + setForm, + statusColor, + syncInitialForm, + validateForm, + } +} + +export type { StudentFormState, SaveSummaryItem } diff --git a/src/composables/useApiCall.ts b/src/composables/useApiCall.ts index 013e478..9792a9a 100644 --- a/src/composables/useApiCall.ts +++ b/src/composables/useApiCall.ts @@ -1,4 +1,4 @@ -import { ref } from 'vue' +import { ref, type Ref } from 'vue' import { type ApiRequestError, normalizeError } from '@/services/error' import { useSnackbarStore } from '@/stores/snackbar' @@ -9,6 +9,15 @@ type Options = { errorToastLevel?: (error: ApiRequestError) => ToastLevel } +interface UseApiCallResult { + loading: Ref + data: Ref + error: Ref + execute: (...args: TArgs) => Promise + executeSafe: (...args: TArgs) => Promise + reset: () => void +} + function getDefaultToastLevel (error: ApiRequestError): ToastLevel { if (typeof error.status === 'number' && error.status >= 500) return 'error' return 'warning' @@ -21,9 +30,9 @@ function levelToColor (level: ToastLevel): string { } export function useApiCall (action: (...args: TArgs) => Promise, - options?: Options) { + options?: Options): UseApiCallResult { const loading = ref(false) - const data = ref(null) + const data = ref(null) as Ref const error = ref(null) const snackbar = useSnackbarStore() @@ -80,4 +89,3 @@ export function useApiCall (action: (...args: reset, } } - diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index a334323..0000000 --- a/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default as SKAdminLayout } from '@/components/layouts/SKAdminLayout.vue' -export { default as SKEmptyLayout } from '@/components/layouts/SKEmptyLayout.vue' diff --git a/src/pages/index.vue b/src/pages/index.vue deleted file mode 100644 index 7646ab7..0000000 --- a/src/pages/index.vue +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/src/shims-vue.d.ts b/src/shims-vue.d.ts new file mode 100644 index 0000000..6d1887e --- /dev/null +++ b/src/shims-vue.d.ts @@ -0,0 +1,6 @@ +declare module '*.vue' { + import type { DefineComponent } from 'vue' + + const component: DefineComponent, Record, unknown> + export default component +} diff --git a/src/views/maint/EditableGridMnt.vue b/src/views/maint/EditableGridMnt.vue index 22d678d..a0cef61 100644 --- a/src/views/maint/EditableGridMnt.vue +++ b/src/views/maint/EditableGridMnt.vue @@ -1,393 +1,7 @@ - diff --git a/src/views/maint/MasterDetailMnt.vue b/src/views/maint/MasterDetailMnt.vue index 2ab0445..8ac59bf 100644 --- a/src/views/maint/MasterDetailMnt.vue +++ b/src/views/maint/MasterDetailMnt.vue @@ -123,54 +123,17 @@ v-for="error in errorSummary" :key="error.field" color="error" size="small" vari - - - - - - - - - - - - - - - - - - - - - - - + @@ -217,44 +180,26 @@ v-if="!isViewMode" color="primary" :disabled="!isDirty || isLoading" :loading="i - - - -
-
-
{{ item.label }}
-
- 原: - - {{ item.before }} - -
-
- 新: - - {{ item.after }} - -
-
-
-
目前沒有可儲存的變更。
-
- - - - - - + @@ -267,12 +212,15 @@ import { mdiBroom, mdiDelete, mdiEye, mdiMagnify, mdiPencil } from '@mdi/js' import { computed, nextTick, ref } from 'vue' import { useDisplay } from 'vuetify' -import CommonConfirmDialog from '@/components/maintenance/CommonConfirmDialog.vue' +import MaintenanceCrudDialogs from '@/components/maintenance/MaintenanceCrudDialogs.vue' +import MaintenanceStudentFormFields from '@/components/maintenance/MaintenanceStudentFormFields.vue' import MasterDetailSemesterList from '@/components/maintenance/master-detail/MasterDetailSemesterList.vue' import MasterDetailSemesterPanel from '@/components/maintenance/master-detail/MasterDetailSemesterPanel.vue' import MntDialogCard from '@/components/maintenance/MntDialogCard.vue' import MntPageCards from '@/components/maintenance/MntPageCards.vue' import MntRecordNavToolbar from '@/components/maintenance/MntRecordNavToolbar.vue' +import { useMaintenanceCrudFlow } from '@/composables/maintenance/useMaintenanceCrudFlow' +import { useStudentMaintenanceForm } from '@/composables/maintenance/useStudentMaintenanceForm' import { type SemesterRecord, useSemesterStore } from '@/stores/semesters' import { type StudentRecord, useStudentStore } from '@/stores/students' @@ -329,21 +277,11 @@ const editingId = ref(null) const dialogMode = ref<'create' | 'edit' | 'view'>('create') const isLoading = ref(false) const isSaving = ref(false) -// 各種確認彈窗 -const confirmCloseVisible = ref(false) -const confirmSaveVisible = ref(false) -const confirmDeleteVisible = ref(false) -const confirmSwitchVisible = ref(false) -const confirmNavigateVisible = ref(false) // UI 回饋 const snackbarVisible = ref(false) const highlightedId = ref(null) // 防止快速切換導致的異步覆蓋 const loadSequence = ref(0) -// 暫存待處理的目標 -const pendingDelete = ref(null) -const pendingSwitchTarget = ref(null) -const pendingNavigateTarget = ref(null) // Master-Detail 擴充 const studentSemesters = ref([]) @@ -433,32 +371,29 @@ function removeCourseFromDetail (index: number) { } -// 表單資料與初始快照(用於 dirty 判斷) -const form = ref({ - studentId: '', - name: '', - department: departments[0] ?? '', - grade: gradeOptions[0]?.value ?? 1, - enrollYear: enrollYears[0] ?? 2024, - credits: 0, - advisor: '', - email: '', - phone: '', - status: statuses[0] ?? '', -}) -const initialForm = ref({ ...form.value }) -// 欄位錯誤訊息(僅保存一則) -const fieldErrors = ref>({ - studentId: [], - name: [], - department: [], - grade: [], - enrollYear: [], - credits: [], - advisor: [], - email: [], - phone: [], - status: [], +const { + errorSummary, + fieldErrors, + form, + isDirty, + saveSummary, + clearAllErrors, + clearFieldError, + gradeLabel, + resetForm, + rowProps, + setForm, + statusColor, + syncInitialForm, + validateForm, +} = useStudentMaintenanceForm({ + departments, + gradeOptions, + enrollYears, + statuses, + students, + editingId, + highlightedId, }) // 表單欄位簡單排版(無分組) @@ -473,60 +408,59 @@ const dialogSubtitle = computed(() => { if (!editingId.value) return '' return `${form.value.studentId || '未填學號'}・${form.value.name || '未填姓名'}` }) -const isEditMode = computed(() => dialogMode.value === 'edit') -const isViewMode = computed(() => dialogMode.value === 'view') // 是否有修改(用於啟用儲存與提示) -const isDirty = computed( - () => JSON.stringify(form.value) !== JSON.stringify(initialForm.value), -) // 載入/儲存時鎖定、檢視模式 readonly const isFormLocked = computed(() => isLoading.value || isSaving.value) +const { + closeDialog, + confirmClose, + confirmCloseVisible, + confirmDelete, + confirmDeleteVisible, + confirmNavigate, + confirmNavigateVisible, + confirmSaveVisible, + confirmSwitch, + confirmSwitchVisible, + currentEditingRecord, + handleDialogVisibility, + hasNextRecord, + hasPrevRecord, + isEditMode, + isViewMode, + openAdjacentRecord, + openEdgeRecord, + pendingDeleteLabel, + requestCloseDialog, + requestDeleteConfirmation, + requestDeleteCurrent, + switchToEditMode, + switchToViewMode, +} = useMaintenanceCrudFlow({ + records: students, + editingId, + dialogMode, + dialogVisible, + isLoading, + isSaving, + isDirty, + clearAllErrors, + resetForm, + openEditDialog, + openViewDialog, + removeRecord: (id) => { + studentStore.removeStudent(id) + semesterStore.removeByStudentId(id) + }, + describeRecord: (student) => `${student.studentId} ${student.name}`, + onCloseReset: () => { + selectedSemesterId.value = null + activeMobilePanel.value = 'master' + isDetailEditing.value = false + detailForm.value = null + }, +}) const isFormReadonly = computed(() => isViewMode.value) -// 目前筆數索引(用於導覽) -const currentRecordIndex = computed(() => - students.value.findIndex((item) => item.id === editingId.value), -) -const currentEditingRecord = computed( - () => students.value.find((item) => item.id === editingId.value) || null, -) -const hasPrevRecord = computed(() => currentRecordIndex.value > 0) -const hasNextRecord = computed( - () => currentRecordIndex.value >= 0 && currentRecordIndex.value < students.value.length - 1, -) -const pendingDeleteLabel = computed(() => { - if (!pendingDelete.value) return '這筆資料' - return `${pendingDelete.value.studentId} ${pendingDelete.value.name}` -}) - -const saveSummary = computed(() => { - const labels: Record = { - studentId: '學號', - name: '姓名', - department: '系所', - grade: '年級', - enrollYear: '入學年度', - credits: '已修學分', - advisor: '指導老師', - email: 'Email', - phone: '電話', - status: '狀態', - } - const before = initialForm.value - const after = form.value - const entries = Object.keys(labels).map((key) => { - const beforeValue = before[key as keyof typeof before] - const afterValue = after[key as keyof typeof after] - return { - key, - label: labels[key], - before: editingId.value ? formatSummaryValue(key, beforeValue) : null, - after: formatSummaryValue(key, afterValue), - } - }) - if (!editingId.value) return entries - return entries.filter((entry) => entry.before !== entry.after) -}) - // 重設查詢條件 function resetSearch () { search.value = { @@ -538,25 +472,6 @@ function resetSearch () { } } -// 重設表單與錯誤狀態 -function resetForm () { - form.value = { - studentId: '', - name: '', - department: departments[0] ?? '', - grade: gradeOptions[0]?.value ?? 1, - enrollYear: enrollYears[0] ?? 2024, - credits: 0, - advisor: '', - email: '', - phone: '', - status: statuses[0] ?? '', - } - initialForm.value = { ...form.value } - isDetailEditing.value = false - clearAllErrors() -} - // 新增:開啟彈窗,使用預設值 function openAddDialog () { loadSequence.value += 1 @@ -566,6 +481,7 @@ function openAddDialog () { selectedSemesterId.value = null activeMobilePanel.value = 'master' resetForm() + isDetailEditing.value = false isLoading.value = false dialogVisible.value = true } @@ -584,7 +500,7 @@ function openEditDialog (student: StudentRecord) { clearAllErrors() setTimeout(() => { if (sequence !== loadSequence.value || !dialogVisible.value) return - form.value = { + setForm({ studentId: student.studentId, name: student.name, department: student.department, @@ -595,8 +511,8 @@ function openEditDialog (student: StudentRecord) { email: student.email, phone: student.phone, status: student.status, - } - initialForm.value = { ...form.value } + }) + syncInitialForm() isLoading.value = false }, 350) } @@ -615,7 +531,7 @@ function openViewDialog (student: StudentRecord) { clearAllErrors() setTimeout(() => { if (sequence !== loadSequence.value || !dialogVisible.value) return - form.value = { + setForm({ studentId: student.studentId, name: student.name, department: student.department, @@ -626,89 +542,12 @@ function openViewDialog (student: StudentRecord) { email: student.email, phone: student.phone, status: student.status, - } - initialForm.value = { ...form.value } + }) + syncInitialForm() isLoading.value = false }, 350) } -// 依方向切換上一筆/下一筆 -function openAdjacentRecord (direction: 'prev' | 'next') { - if (!isViewMode.value && !isEditMode.value) return - const index = currentRecordIndex.value - if (index < 0) return - const targetIndex = direction === 'prev' ? index - 1 : index + 1 - const target = students.value[targetIndex] - if (!target) return - if (isEditMode.value) { - if (isDirty.value) { - pendingNavigateTarget.value = target - confirmNavigateVisible.value = true - return - } - openEditDialog(target) - return - } - openViewDialog(target) -} - -// 切換到第一筆/最後一筆 -function openEdgeRecord (position: 'first' | 'last') { - if (!isViewMode.value && !isEditMode.value) return - if (students.value.length === 0) return - const target = position === 'first' ? students.value[0] : students.value.at(-1) - if (!target) return - if (isEditMode.value) { - if (isDirty.value) { - pendingNavigateTarget.value = target - confirmNavigateVisible.value = true - return - } - openEditDialog(target) - return - } - openViewDialog(target) -} - -// 檢視 → 編輯(直接切換) -function switchToEditMode () { - if (!isViewMode.value) return - const current = students.value.find((item) => item.id === editingId.value) - if (!current) return - openEditDialog(current) -} - -// 編輯 → 檢視(若 dirty 需確認) -function switchToViewMode () { - if (!isEditMode.value) return - const current = students.value.find((item) => item.id === editingId.value) - if (!current) return - if (isDirty.value) { - pendingSwitchTarget.value = current - confirmSwitchVisible.value = true - return - } - openViewDialog(current) -} - -// 確認切換為檢視模式 -function confirmSwitch () { - const target = pendingSwitchTarget.value - pendingSwitchTarget.value = null - confirmSwitchVisible.value = false - if (!target) return - openViewDialog(target) -} - -// 確認切換到其他資料 -function confirmNavigate () { - const target = pendingNavigateTarget.value - pendingNavigateTarget.value = null - confirmNavigateVisible.value = false - if (!target) return - openEditDialog(target) -} - // 先檢核再提示儲存確認 async function requestSaveConfirmation () { if (isSaving.value || isLoading.value || !isDirty.value || isViewMode.value) return @@ -761,7 +600,7 @@ async function saveStudent () { highlightedId.value = createdId } - initialForm.value = { ...form.value } + syncInitialForm() dialogVisible.value = false snackbarVisible.value = true isSaving.value = false @@ -770,163 +609,12 @@ async function saveStudent () { }, 1600) } -// 刪除:先提示確認 -function requestDeleteConfirmation (student: StudentRecord) { - pendingDelete.value = student - confirmDeleteVisible.value = true -} - -// 編輯模式刪除目前資料 -function requestDeleteCurrent () { - const current = currentEditingRecord.value - if (!current) return - requestDeleteConfirmation(current) -} - -// 確認刪除後,若正在編輯該筆則關閉彈窗 -function confirmDelete () { - if (!pendingDelete.value) return - const deletedId = pendingDelete.value.id - studentStore.removeStudent(deletedId) - semesterStore.removeByStudentId(deletedId) - pendingDelete.value = null - confirmDeleteVisible.value = false - if (editingId.value === deletedId) { - closeDialog() - } -} - -// 狀態顏色映射 -function statusColor (status: string) { - if (status === '在學') return 'success' - if (status === '休學') return 'warning' - if (status === '畢業') return 'secondary' - return 'default' -} - -// 年級顯示字串 -function gradeLabel (grade: number) { - return gradeOptions.find((option) => option.value === grade)?.title ?? `年級 ${grade}` -} - -// 列高亮(儲存後提示位置) -function rowProps (data: { item: StudentRecord }) { - return { - class: data.item.id === highlightedId.value ? 'is-highlighted' : '', -} -} - -function clearAllErrors () { - fieldErrors.value = { - studentId: [], - name: [], - department: [], - grade: [], - enrollYear: [], - credits: [], - advisor: [], - email: [], - phone: [], - status: [], - } -} - -function clearFieldError (field: string) { - if (!fieldErrors.value[field]?.length) return - fieldErrors.value[field] = [] -} - -function validateForm () { - const errors: Array<{ field: string; message: string }> = [] - const studentId = form.value.studentId.trim() - const name = form.value.name.trim() - const email = form.value.email.trim() - const credits = form.value.credits - - if (!studentId) errors.push({ field: 'studentId', message: '請輸入學號' }) - if (!name) errors.push({ field: 'name', message: '請輸入姓名' }) - if (!form.value.department) errors.push({ field: 'department', message: '請選擇系所' }) - if (!form.value.grade) errors.push({ field: 'grade', message: '請選擇年級' }) - if (!form.value.enrollYear) errors.push({ field: 'enrollYear', message: '請選擇入學年度' }) - if (credits === null || Number.isNaN(credits)) { - errors.push({ field: 'credits', message: '請輸入已修學分' }) - } else if (credits < 0) { - errors.push({ field: 'credits', message: '已修學分不可小於 0' }) - } - if (!form.value.status) errors.push({ field: 'status', message: '請選擇狀態' }) - if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { - errors.push({ field: 'email', message: 'Email 格式不正確' }) - } - - const duplicate = students.value.find( - (item) => item.studentId === studentId && item.id !== editingId.value, - ) - if (studentId && duplicate) { - errors.push({ field: 'studentId', message: '學號已存在,請確認是否重複' }) - } - - return errors -} - -const errorSummary = computed(() => { - const entries = Object.entries(fieldErrors.value).flatMap(([field, messages]) => - messages.map((message) => ({ field, message })), - ) - return entries.slice(0, 3) -}) - function scrollToField (field: string) { const target = document.getElementById(`field-${field}`) if (!target) return target.scrollIntoView({ behavior: 'smooth', block: 'center' }) } -function requestCloseDialog () { - if (isDirty.value && !isSaving.value) { - confirmCloseVisible.value = true - return - } - closeDialog() -} - -function confirmClose () { - confirmCloseVisible.value = false - closeDialog() -} - -function closeDialog () { - dialogVisible.value = false - isLoading.value = false - isSaving.value = false - confirmSaveVisible.value = false - confirmSwitchVisible.value = false - confirmNavigateVisible.value = false - dialogMode.value = 'create' - editingId.value = null - selectedSemesterId.value = null - activeMobilePanel.value = 'master' - isDetailEditing.value = false - clearAllErrors() - resetForm() -} - -function handleDialogVisibility (nextValue: boolean) { - if (nextValue) { - dialogVisible.value = true - return - } - requestCloseDialog() -} - - -const isPlaceholderValue = (value: string | null) => value === '—' - -function formatSummaryValue (key: string, value: string | number | null | undefined) { - if (value === null || value === undefined || value === '') return '—' - if (key === 'grade') return gradeLabel(Number(value)) - return String(value) -} - function handleSemesterSelect (id: number) { if (isMobile.value) { selectedSemesterId.value = id diff --git a/src/views/maint/MasterDetailMntB.vue b/src/views/maint/MasterDetailMntB.vue index 26ea9a0..fd2b18c 100644 --- a/src/views/maint/MasterDetailMntB.vue +++ b/src/views/maint/MasterDetailMntB.vue @@ -113,54 +113,17 @@ v-else :class="[ { 'form-readonly': isFormReadonly }, isMobile ? '' : 'd-flex flex-column h-100', ]" @submit.prevent="requestSaveConfirmation"> - - - - - - - - - - - - - - - - - - - - - - - + @@ -206,44 +169,26 @@ v-if="!isViewMode" color="primary" :disabled="!isDirty || isLoading" :loading="i - - - -
-
-
{{ item.label }}
-
- 原: - - {{ item.before }} - -
-
- 新: - - {{ item.after }} - -
-
-
-
目前沒有可儲存的變更。
-
- - - - - - + (null) const dialogMode = ref<'create' | 'edit' | 'view'>('create') const isLoading = ref(false) const isSaving = ref(false) -// 各種確認彈窗 -const confirmCloseVisible = ref(false) -const confirmSaveVisible = ref(false) -const confirmDeleteVisible = ref(false) -const confirmSwitchVisible = ref(false) -const confirmNavigateVisible = ref(false) // UI 回饋 const snackbarVisible = ref(false) const highlightedId = ref(null) // 防止快速切換導致的異步覆蓋 const loadSequence = ref(0) -// 暫存待處理的目標 -const pendingDelete = ref(null) -const pendingSwitchTarget = ref(null) -const pendingNavigateTarget = ref(null) // Master-Detail 擴充 const studentSemesters = ref([]) @@ -495,32 +433,29 @@ function handleUpdateCourse (semesterId: number, courseIndex: number, payload: P } -// 表單資料與初始快照(用於 dirty 判斷) -const form = ref({ - studentId: '', - name: '', - department: departments[0] ?? '', - grade: gradeOptions[0]?.value ?? 1, - enrollYear: enrollYears[0] ?? 2024, - credits: 0, - advisor: '', - email: '', - phone: '', - status: statuses[0] ?? '', -}) -const initialForm = ref({ ...form.value }) -// 欄位錯誤訊息(僅保存一則) -const fieldErrors = ref>({ - studentId: [], - name: [], - department: [], - grade: [], - enrollYear: [], - credits: [], - advisor: [], - email: [], - phone: [], - status: [], +const { + errorSummary, + fieldErrors, + form, + isDirty, + saveSummary, + clearAllErrors, + clearFieldError, + gradeLabel, + resetForm, + rowProps, + setForm, + statusColor, + syncInitialForm, + validateForm, +} = useStudentMaintenanceForm({ + departments, + gradeOptions, + enrollYears, + statuses, + students, + editingId, + highlightedId, }) // 表單欄位簡單排版(無分組) @@ -535,60 +470,58 @@ const dialogSubtitle = computed(() => { if (!editingId.value) return '' return `${form.value.studentId || '未填學號'}・${form.value.name || '未填姓名'}` }) -const isEditMode = computed(() => dialogMode.value === 'edit') -const isViewMode = computed(() => dialogMode.value === 'view') // 是否有修改(用於啟用儲存與提示) -const isDirty = computed( - () => JSON.stringify(form.value) !== JSON.stringify(initialForm.value), -) // 載入/儲存時鎖定、檢視模式 readonly const isFormLocked = computed(() => isLoading.value || isSaving.value) +const { + closeDialog, + confirmClose, + confirmCloseVisible, + confirmDelete, + confirmDeleteVisible, + confirmNavigate, + confirmNavigateVisible, + confirmSaveVisible, + confirmSwitch, + confirmSwitchVisible, + currentEditingRecord, + handleDialogVisibility, + hasNextRecord, + hasPrevRecord, + isEditMode, + isViewMode, + openAdjacentRecord, + openEdgeRecord, + pendingDeleteLabel, + requestCloseDialog, + requestDeleteConfirmation, + requestDeleteCurrent, + switchToEditMode, + switchToViewMode, +} = useMaintenanceCrudFlow({ + records: students, + editingId, + dialogMode, + dialogVisible, + isLoading, + isSaving, + isDirty, + clearAllErrors, + resetForm, + openEditDialog, + openViewDialog, + removeRecord: (id) => { + studentStore.removeStudent(id) + semesterStore.removeByStudentId(id) + }, + describeRecord: (student) => `${student.studentId} ${student.name}`, + onCloseReset: () => { + selectedSemesterId.value = null + activeMobilePanel.value = 'master' + studentSemesters.value = [] + }, +}) const isFormReadonly = computed(() => isViewMode.value) -// 目前筆數索引(用於導覽) -const currentRecordIndex = computed(() => - students.value.findIndex((item) => item.id === editingId.value), -) -const currentEditingRecord = computed( - () => students.value.find((item) => item.id === editingId.value) || null, -) -const hasPrevRecord = computed(() => currentRecordIndex.value > 0) -const hasNextRecord = computed( - () => currentRecordIndex.value >= 0 && currentRecordIndex.value < students.value.length - 1, -) -const pendingDeleteLabel = computed(() => { - if (!pendingDelete.value) return '這筆資料' - return `${pendingDelete.value.studentId} ${pendingDelete.value.name}` -}) - -const saveSummary = computed(() => { - const labels: Record = { - studentId: '學號', - name: '姓名', - department: '系所', - grade: '年級', - enrollYear: '入學年度', - credits: '已修學分', - advisor: '指導老師', - email: 'Email', - phone: '電話', - status: '狀態', - } - const before = initialForm.value - const after = form.value - const entries = Object.keys(labels).map((key) => { - const beforeValue = before[key as keyof typeof before] - const afterValue = after[key as keyof typeof after] - return { - key, - label: labels[key], - before: editingId.value ? formatSummaryValue(key, beforeValue) : null, - after: formatSummaryValue(key, afterValue), - } - }) - if (!editingId.value) return entries - return entries.filter((entry) => entry.before !== entry.after) -}) - // 重設查詢條件 function resetSearch () { search.value = { @@ -600,24 +533,6 @@ function resetSearch () { } } -// 重設表單與錯誤狀態 -function resetForm () { - form.value = { - studentId: '', - name: '', - department: departments[0] ?? '', - grade: gradeOptions[0]?.value ?? 1, - enrollYear: enrollYears[0] ?? 2024, - credits: 0, - advisor: '', - email: '', - phone: '', - status: statuses[0] ?? '', - } - initialForm.value = { ...form.value } - clearAllErrors() -} - // 新增:開啟彈窗,使用預設值 function openAddDialog () { loadSequence.value += 1 @@ -645,7 +560,7 @@ function openEditDialog (student: StudentRecord) { clearAllErrors() setTimeout(() => { if (sequence !== loadSequence.value || !dialogVisible.value) return - form.value = { + setForm({ studentId: student.studentId, name: student.name, department: student.department, @@ -656,8 +571,8 @@ function openEditDialog (student: StudentRecord) { email: student.email, phone: student.phone, status: student.status, - } - initialForm.value = { ...form.value } + }) + syncInitialForm() isLoading.value = false }, 350) } @@ -676,7 +591,7 @@ function openViewDialog (student: StudentRecord) { clearAllErrors() setTimeout(() => { if (sequence !== loadSequence.value || !dialogVisible.value) return - form.value = { + setForm({ studentId: student.studentId, name: student.name, department: student.department, @@ -687,89 +602,12 @@ function openViewDialog (student: StudentRecord) { email: student.email, phone: student.phone, status: student.status, - } - initialForm.value = { ...form.value } + }) + syncInitialForm() isLoading.value = false }, 350) } -// 依方向切換上一筆/下一筆 -function openAdjacentRecord (direction: 'prev' | 'next') { - if (!isViewMode.value && !isEditMode.value) return - const index = currentRecordIndex.value - if (index < 0) return - const targetIndex = direction === 'prev' ? index - 1 : index + 1 - const target = students.value[targetIndex] - if (!target) return - if (isEditMode.value) { - if (isDirty.value) { - pendingNavigateTarget.value = target - confirmNavigateVisible.value = true - return - } - openEditDialog(target) - return - } - openViewDialog(target) -} - -// 切換到第一筆/最後一筆 -function openEdgeRecord (position: 'first' | 'last') { - if (!isViewMode.value && !isEditMode.value) return - if (students.value.length === 0) return - const target = position === 'first' ? students.value[0] : students.value.at(-1) - if (!target) return - if (isEditMode.value) { - if (isDirty.value) { - pendingNavigateTarget.value = target - confirmNavigateVisible.value = true - return - } - openEditDialog(target) - return - } - openViewDialog(target) -} - -// 檢視 → 編輯(直接切換) -function switchToEditMode () { - if (!isViewMode.value) return - const current = students.value.find((item) => item.id === editingId.value) - if (!current) return - openEditDialog(current) -} - -// 編輯 → 檢視(若 dirty 需確認) -function switchToViewMode () { - if (!isEditMode.value) return - const current = students.value.find((item) => item.id === editingId.value) - if (!current) return - if (isDirty.value) { - pendingSwitchTarget.value = current - confirmSwitchVisible.value = true - return - } - openViewDialog(current) -} - -// 確認切換為檢視模式 -function confirmSwitch () { - const target = pendingSwitchTarget.value - pendingSwitchTarget.value = null - confirmSwitchVisible.value = false - if (!target) return - openViewDialog(target) -} - -// 確認切換到其他資料 -function confirmNavigate () { - const target = pendingNavigateTarget.value - pendingNavigateTarget.value = null - confirmNavigateVisible.value = false - if (!target) return - openEditDialog(target) -} - // 先檢核再提示儲存確認 async function requestSaveConfirmation () { if (isSaving.value || isLoading.value || !isDirty.value || isViewMode.value) return @@ -822,7 +660,7 @@ async function saveStudent () { highlightedId.value = createdId } - initialForm.value = { ...form.value } + syncInitialForm() dialogVisible.value = false snackbarVisible.value = true isSaving.value = false @@ -831,163 +669,11 @@ async function saveStudent () { }, 1600) } -// 刪除:先提示確認 -function requestDeleteConfirmation (student: StudentRecord) { - pendingDelete.value = student - confirmDeleteVisible.value = true -} - -// 編輯模式刪除目前資料 -function requestDeleteCurrent () { - const current = currentEditingRecord.value - if (!current) return - requestDeleteConfirmation(current) -} - -// 確認刪除後,若正在編輯該筆則關閉彈窗 -function confirmDelete () { - if (!pendingDelete.value) return - const deletedId = pendingDelete.value.id - studentStore.removeStudent(deletedId) - semesterStore.removeByStudentId(deletedId) - pendingDelete.value = null - confirmDeleteVisible.value = false - if (editingId.value === deletedId) { - closeDialog() - } -} - -// 狀態顏色映射 -function statusColor (status: string) { - if (status === '在學') return 'success' - if (status === '休學') return 'warning' - if (status === '畢業') return 'secondary' - return 'default' -} - -// 年級顯示字串 -function gradeLabel (grade: number) { - return gradeOptions.find((option) => option.value === grade)?.title ?? `年級 ${grade}` -} - -// 列高亮(儲存後提示位置) -function rowProps (data: { item: StudentRecord }) { - return { - class: data.item.id === highlightedId.value ? 'is-highlighted' : '', -} -} - -function clearAllErrors () { - fieldErrors.value = { - studentId: [], - name: [], - department: [], - grade: [], - enrollYear: [], - credits: [], - advisor: [], - email: [], - phone: [], - status: [], - } -} - -function clearFieldError (field: string) { - if (!fieldErrors.value[field]?.length) return - fieldErrors.value[field] = [] -} - -function validateForm () { - const errors: Array<{ field: string; message: string }> = [] - const studentId = form.value.studentId.trim() - const name = form.value.name.trim() - const email = form.value.email.trim() - const credits = form.value.credits - - if (!studentId) errors.push({ field: 'studentId', message: '請輸入學號' }) - if (!name) errors.push({ field: 'name', message: '請輸入姓名' }) - if (!form.value.department) errors.push({ field: 'department', message: '請選擇系所' }) - if (!form.value.grade) errors.push({ field: 'grade', message: '請選擇年級' }) - if (!form.value.enrollYear) errors.push({ field: 'enrollYear', message: '請選擇入學年度' }) - if (credits === null || Number.isNaN(credits)) { - errors.push({ field: 'credits', message: '請輸入已修學分' }) - } else if (credits < 0) { - errors.push({ field: 'credits', message: '已修學分不可小於 0' }) - } - if (!form.value.status) errors.push({ field: 'status', message: '請選擇狀態' }) - if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { - errors.push({ field: 'email', message: 'Email 格式不正確' }) - } - - const duplicate = students.value.find( - (item) => item.studentId === studentId && item.id !== editingId.value, - ) - if (studentId && duplicate) { - errors.push({ field: 'studentId', message: '學號已存在,請確認是否重複' }) - } - - return errors -} - -const errorSummary = computed(() => { - const entries = Object.entries(fieldErrors.value).flatMap(([field, messages]) => - messages.map((message) => ({ field, message })), - ) - return entries.slice(0, 3) -}) - function scrollToField (field: string) { const target = document.getElementById(`field-${field}`) if (!target) return target.scrollIntoView({ behavior: 'smooth', block: 'center' }) } - -function requestCloseDialog () { - if (isDirty.value && !isSaving.value) { - confirmCloseVisible.value = true - return - } - closeDialog() -} - -function confirmClose () { - confirmCloseVisible.value = false - closeDialog() -} - -function closeDialog () { - dialogVisible.value = false - isLoading.value = false - isSaving.value = false - confirmSaveVisible.value = false - confirmSwitchVisible.value = false - confirmNavigateVisible.value = false - dialogMode.value = 'create' - editingId.value = null - selectedSemesterId.value = null - activeMobilePanel.value = 'master' - studentSemesters.value = [] - clearAllErrors() - resetForm() -} - -function handleDialogVisibility (nextValue: boolean) { - if (nextValue) { - dialogVisible.value = true - return - } - requestCloseDialog() -} - - -const isPlaceholderValue = (value: string | null) => value === '—' - -function formatSummaryValue (key: string, value: string | number | null | undefined) { - if (value === null || value === undefined || value === '') return '—' - if (key === 'grade') return gradeLabel(Number(value)) - return String(value) -} -