feat: add SingleRecord component for student maintenance with CRUD functionality
This commit is contained in:
@@ -0,0 +1,985 @@
|
||||
<template>
|
||||
<page-maint
|
||||
:search-panel-open="searchPanelOpen"
|
||||
:title="`主從資料維護示範A`"
|
||||
@create="openAddDialog"
|
||||
@toggle-search="searchPanelOpen = !searchPanelOpen"
|
||||
>
|
||||
<template #search-fields>
|
||||
<v-col cols="12" md="2">
|
||||
<div id="search-student-id-label" class="text-body-2 text-medium-emphasis pl-2">學號</div>
|
||||
<v-text-field
|
||||
id="search-student-id"
|
||||
v-model="search.studentId"
|
||||
aria-labelledby="search-student-id-label"
|
||||
density="compact"
|
||||
hide-details
|
||||
name="searchStudentId"
|
||||
placeholder="例如:S2024001"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="2">
|
||||
<div id="search-name-label" class="text-body-2 text-medium-emphasis pl-2">姓名</div>
|
||||
<v-text-field
|
||||
id="search-name"
|
||||
v-model="search.name"
|
||||
aria-labelledby="search-name-label"
|
||||
density="compact"
|
||||
hide-details
|
||||
name="searchName"
|
||||
placeholder="例如:王小明"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="2">
|
||||
<div id="search-department-label" class="text-body-2 text-medium-emphasis pl-2">系所</div>
|
||||
<v-select
|
||||
id="search-department"
|
||||
v-model="search.department"
|
||||
aria-labelledby="search-department-label"
|
||||
density="compact"
|
||||
hide-details
|
||||
:items="departments"
|
||||
name="searchDepartment"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="2">
|
||||
<div id="search-grade-label" class="text-body-2 text-medium-emphasis pl-2">年級</div>
|
||||
<v-select
|
||||
id="search-grade"
|
||||
v-model="search.grade"
|
||||
aria-labelledby="search-grade-label"
|
||||
density="compact"
|
||||
hide-details
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
:items="gradeOptions"
|
||||
name="searchGrade"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="2">
|
||||
<div id="search-status-label" class="text-body-2 text-medium-emphasis pl-2">狀態</div>
|
||||
<v-select
|
||||
id="search-status"
|
||||
v-model="search.status"
|
||||
aria-labelledby="search-status-label"
|
||||
density="compact"
|
||||
hide-details
|
||||
:items="statuses"
|
||||
name="searchStatus"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col class="d-flex justify-end align-end flex-grow-1 ga-2" cols="12" md="auto">
|
||||
<v-btn :prepend-icon="mdiBroom" variant="text" @click="resetSearch">清除</v-btn>
|
||||
<v-btn color="primary" disabled :prepend-icon="mdiMagnify" variant="tonal">查詢</v-btn>
|
||||
</v-col>
|
||||
</template>
|
||||
<template #table>
|
||||
<v-data-table
|
||||
v-model:page="currentPage"
|
||||
class="student-table"
|
||||
density="compact"
|
||||
fixed-header
|
||||
:headers="tableHeaders"
|
||||
height="100%"
|
||||
hide-default-footer
|
||||
:items="students"
|
||||
:items-per-page="itemsPerPage"
|
||||
:row-props="rowProps"
|
||||
>
|
||||
<template #[`item.grade`]="{ item }">
|
||||
{{ gradeLabel(item.grade) }}
|
||||
</template>
|
||||
<template #[`item.status`]="{ item }">
|
||||
<v-chip :color="statusColor(item.status)" size="small" variant="tonal">
|
||||
{{ item.status }}
|
||||
</v-chip>
|
||||
</template>
|
||||
<template #[`item.actions`]="{ item }">
|
||||
<div class="d-flex ga-1">
|
||||
<v-btn
|
||||
color="info"
|
||||
:prepend-icon="mdiEye"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="openViewDialog(item)"
|
||||
>
|
||||
檢視
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
:prepend-icon="mdiPencil"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="openEditDialog(item)"
|
||||
>
|
||||
修改
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="error"
|
||||
:prepend-icon="mdiDelete"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="requestDeleteConfirmation(item)"
|
||||
>
|
||||
刪除
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
<template #bottom>
|
||||
<div class="d-flex align-center justify-space-between px-4 py-3">
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
{{ pageSummary }}
|
||||
</div>
|
||||
<div class="d-flex align-center ga-2">
|
||||
<v-btn
|
||||
:disabled="currentPage <= 1"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="currentPage = 1"
|
||||
>第一頁</v-btn
|
||||
>
|
||||
<v-btn
|
||||
:disabled="currentPage <= 1"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="currentPage -= 1"
|
||||
>上一頁</v-btn
|
||||
>
|
||||
<span class="text-body-2">{{ currentPage }} / {{ pageCount }}</span>
|
||||
<v-btn
|
||||
:disabled="currentPage >= pageCount"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="currentPage += 1"
|
||||
>下一頁</v-btn
|
||||
>
|
||||
<v-btn
|
||||
:disabled="currentPage >= pageCount"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="currentPage = pageCount"
|
||||
>最後頁</v-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</template>
|
||||
</page-maint>
|
||||
|
||||
<!-- 主從式維護視窗 -->
|
||||
<!-- 說明:包含主檔 (學生資料) 與明細檔 (學期成績) 的維護介面 -->
|
||||
<teleport to="body">
|
||||
<!-- 包成元件需要傳高度寬度給dialog-panel -->
|
||||
<v-overlay
|
||||
class="dialog-overlay"
|
||||
:close-on-content-click="false"
|
||||
:model-value="dialogVisible"
|
||||
scrim="rgba(0, 0, 0, 0.45)"
|
||||
scroll-strategy="block"
|
||||
@update:model-value="handleDialogVisibility"
|
||||
>
|
||||
<div class="dialog-panel" :class="{ 'is-mobile': isMobile }">
|
||||
<!-- 子檔區塊 (Detail Card):學期成績明細 -->
|
||||
<!-- 說明:點選主檔的學期後,會從左側滑出顯示該學期的詳細課程成績 -->
|
||||
<div
|
||||
v-if="!isMobile || activeMobilePanel === 'detail'"
|
||||
class="detail-panel-wrapper"
|
||||
:class="{ 'is-active': !!selectedSemesterId, 'is-mobile': isMobile }"
|
||||
>
|
||||
<master-detail-semester-panel
|
||||
v-model:detail-form="detailForm"
|
||||
:is-detail-editing="isDetailEditing"
|
||||
:is-mobile="isMobile"
|
||||
:is-view-mode="isViewMode"
|
||||
:selected-semester="selectedSemester"
|
||||
@cancel-edit="cancelDetailEdit"
|
||||
@close="closeDetailPanel"
|
||||
@delete="handleDeleteSemester"
|
||||
@save-edit="saveDetailEdit"
|
||||
@start-edit="startDetailEdit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 主檔區塊 (Master Card):學生基本資料與學期列表 -->
|
||||
<!-- 說明:固定在視窗右側,包含學生表單與學期清單 -->
|
||||
<mnt-dialog-card
|
||||
v-if="!isMobile || activeMobilePanel === 'master'"
|
||||
:dialog-subtitle="dialogSubtitle"
|
||||
:dialog-title="dialogTitle"
|
||||
:is-edit-mode="isEditMode"
|
||||
:is-view-mode="isViewMode"
|
||||
:width="isMobile ? '100%' : 760"
|
||||
>
|
||||
<template #toolbar>
|
||||
<mnt-record-nav-toolbar
|
||||
:has-next-record="hasNextRecord"
|
||||
:has-prev-record="hasPrevRecord"
|
||||
:is-edit-mode="isEditMode"
|
||||
:is-view-mode="isViewMode"
|
||||
:mobile="isMobile"
|
||||
@first="openEdgeRecord('first')"
|
||||
@last="openEdgeRecord('last')"
|
||||
@next="openAdjacentRecord('next')"
|
||||
@prev="openAdjacentRecord('prev')"
|
||||
@switch-to-edit="switchToEditMode"
|
||||
@switch-to-view="switchToViewMode"
|
||||
/>
|
||||
</template>
|
||||
<template #content>
|
||||
<!-- 錯誤提示:當表單驗證未通過時顯示 -->
|
||||
<v-alert
|
||||
v-if="errorSummary.length > 0 && !isLoading"
|
||||
class="mb-4"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
>
|
||||
<div class="text-subtitle-2 mb-2">請先修正以下問題</div>
|
||||
<div class="d-flex flex-column ga-1">
|
||||
<v-btn
|
||||
v-for="error in errorSummary"
|
||||
:key="error.field"
|
||||
color="error"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="scrollToField(error.field)"
|
||||
>
|
||||
{{ error.message }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-alert>
|
||||
|
||||
<!-- 載入中骨架畫面 -->
|
||||
<v-skeleton-loader
|
||||
v-if="isLoading"
|
||||
class="mt-4"
|
||||
type="subtitle,paragraph"
|
||||
width="100%"
|
||||
/>
|
||||
|
||||
<!-- 學生主檔表單:檢視模式時會自動套用 readonly 樣式 -->
|
||||
<v-form
|
||||
v-else
|
||||
:class="{ 'form-readonly': isFormReadonly }"
|
||||
@submit.prevent="requestSaveConfirmation"
|
||||
>
|
||||
<maintenance-student-form-fields
|
||||
:departments="departments"
|
||||
:enroll-years="enrollYears"
|
||||
:field-errors="fieldErrors"
|
||||
:form="form"
|
||||
:grade-options="gradeOptions"
|
||||
:is-form-locked="isFormLocked"
|
||||
:is-form-readonly="isFormReadonly"
|
||||
:statuses="statuses"
|
||||
@clear-field="clearFieldError"
|
||||
/>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<!-- 學期成績紀錄區塊 -->
|
||||
<!-- 說明:顯示該學生的所有學期紀錄,並提供新增與選取功能 -->
|
||||
<master-detail-semester-list
|
||||
:is-mobile="isMobile"
|
||||
:is-view-mode="isViewMode"
|
||||
:selected-semester-id="selectedSemesterId"
|
||||
:semesters="studentSemesters"
|
||||
@add="handleAddSemester"
|
||||
@select="handleSemesterSelect"
|
||||
/>
|
||||
</v-form>
|
||||
</template>
|
||||
<template #actions>
|
||||
<template v-if="isMobile">
|
||||
<v-btn :disabled="isSaving" variant="text" @click="requestCloseDialog">取消</v-btn>
|
||||
<v-btn
|
||||
v-if="isEditMode"
|
||||
color="error"
|
||||
:disabled="isSaving"
|
||||
variant="tonal"
|
||||
@click="requestDeleteCurrent"
|
||||
>
|
||||
刪除
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="!isViewMode"
|
||||
color="primary"
|
||||
:disabled="!isDirty || isLoading"
|
||||
:loading="isSaving"
|
||||
variant="flat"
|
||||
@click="requestSaveConfirmation"
|
||||
>
|
||||
儲存
|
||||
</v-btn>
|
||||
<v-btn v-else color="primary" variant="flat" @click="requestCloseDialog">關閉</v-btn>
|
||||
</template>
|
||||
<template v-else>
|
||||
<v-spacer />
|
||||
<v-btn :disabled="isSaving" variant="text" @click="requestCloseDialog">取消</v-btn>
|
||||
<v-btn
|
||||
v-if="isEditMode"
|
||||
color="error"
|
||||
:disabled="isSaving"
|
||||
variant="tonal"
|
||||
@click="requestDeleteCurrent"
|
||||
>
|
||||
刪除
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="!isViewMode"
|
||||
color="primary"
|
||||
:disabled="!isDirty || isLoading"
|
||||
:loading="isSaving"
|
||||
variant="flat"
|
||||
@click="requestSaveConfirmation"
|
||||
>
|
||||
儲存
|
||||
</v-btn>
|
||||
<v-btn v-else color="primary" variant="flat" @click="requestCloseDialog">關閉</v-btn>
|
||||
</template>
|
||||
</template>
|
||||
</mnt-dialog-card>
|
||||
</div>
|
||||
</v-overlay>
|
||||
</teleport>
|
||||
|
||||
<maintenance-crud-dialogs
|
||||
:close-visible="confirmCloseVisible"
|
||||
:delete-visible="confirmDeleteVisible"
|
||||
:is-saving="isSaving"
|
||||
:navigate-visible="confirmNavigateVisible"
|
||||
:pending-delete-label="pendingDeleteLabel"
|
||||
:save-summary="saveSummary"
|
||||
:save-visible="confirmSaveVisible"
|
||||
:switch-visible="confirmSwitchVisible"
|
||||
@confirm-close="confirmClose"
|
||||
@confirm-delete="confirmDelete"
|
||||
@confirm-navigate="confirmNavigate"
|
||||
@confirm-save="confirmSave"
|
||||
@confirm-switch="confirmSwitch"
|
||||
@update:close-visible="confirmCloseVisible = $event"
|
||||
@update:delete-visible="confirmDeleteVisible = $event"
|
||||
@update:navigate-visible="confirmNavigateVisible = $event"
|
||||
@update:save-visible="confirmSaveVisible = $event"
|
||||
@update:switch-visible="confirmSwitchVisible = $event"
|
||||
/>
|
||||
|
||||
<!-- 成功提示 -->
|
||||
<v-snackbar v-model="snackbarVisible" color="success" location="bottom right" :timeout="2200">
|
||||
儲存成功
|
||||
</v-snackbar>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { mdiBroom, mdiDelete, mdiEye, mdiMagnify, mdiPencil } from '@mdi/js'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
import MaintenanceCrudDialogs from '@/components/maint/MaintenanceCrudDialogs.vue'
|
||||
import MaintenanceStudentFormFields from '@/components/maint/MaintenanceStudentFormFields.vue'
|
||||
import MasterDetailSemesterList from '@/components/maint/master-detail/SemesterList.vue'
|
||||
import MasterDetailSemesterPanel from '@/components/maint/master-detail/SemesterPanel.vue'
|
||||
import MntDialogCard from '@/components/maint/MntDialogCard.vue'
|
||||
import MntRecordNavToolbar from '@/components/maint/MntRecordNavToolbar.vue'
|
||||
import PageMaint from '@/components/maint/PageMaint.vue'
|
||||
import { useMaintenanceCrudFlow } from '@/composables/maint/useMaintenanceCrudFlow'
|
||||
import { useStudentMaintenanceForm } from '@/composables/maint/useStudentMaintenanceForm'
|
||||
import { type SemesterRecord, useSemesterStore } from '@/stores/semesters'
|
||||
import { type StudentRecord, useStudentStore } from '@/stores/students'
|
||||
|
||||
// 下拉選項:系所/年級/入學年度/狀態
|
||||
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 { smAndUp } = useDisplay()
|
||||
const isMobile = computed(() => !smAndUp.value)
|
||||
|
||||
// 表格欄位設定(含固定欄與排序)
|
||||
const tableHeaders = [
|
||||
{
|
||||
title: '學號',
|
||||
key: 'studentId',
|
||||
sortable: true,
|
||||
fixed: smAndUp.value && ('start' as const),
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '姓名',
|
||||
key: 'name',
|
||||
sortable: true,
|
||||
fixed: smAndUp.value && ('start' as const),
|
||||
width: 100,
|
||||
},
|
||||
{ title: '系所', key: 'department', sortable: true, width: 140 },
|
||||
{ title: '年級', key: 'grade', sortable: true, width: 90 },
|
||||
{ title: '入學年度', key: 'enrollYear', sortable: true, width: 110 },
|
||||
{ title: '已修學分', key: 'credits', sortable: true, width: 110 },
|
||||
{ title: 'Email', key: 'email', sortable: true, width: 200 },
|
||||
{ title: '電話', key: 'phone', sortable: true, width: 140 },
|
||||
{ title: '指導老師', key: 'advisor', sortable: true, width: 110 },
|
||||
{ title: '狀態', key: 'status', sortable: true, width: 90 },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
sortable: false,
|
||||
fixed: smAndUp.value && ('end' as const),
|
||||
width: 'auto',
|
||||
cellProps: { class: 'px-0 bg-background' },
|
||||
},
|
||||
]
|
||||
|
||||
// 查詢條件(示意用,未接 API)
|
||||
const search = ref({
|
||||
studentId: '',
|
||||
name: '',
|
||||
department: '',
|
||||
grade: null as number | null,
|
||||
status: '',
|
||||
})
|
||||
// 查詢區塊是否展開
|
||||
const searchPanelOpen = ref(false)
|
||||
|
||||
// 透過 store 管理 Demo 資料與 CRUD
|
||||
const studentStore = useStudentStore()
|
||||
const semesterStore = useSemesterStore()
|
||||
const students = computed(() => studentStore.students)
|
||||
type StudentPayload = Omit<StudentRecord, 'id'>
|
||||
const itemsPerPage = 10
|
||||
const currentPage = ref(1)
|
||||
const pageCount = computed(() => Math.max(1, Math.ceil(students.value.length / itemsPerPage)))
|
||||
const pageSummary = computed(() => {
|
||||
const total = students.value.length
|
||||
if (total === 0) return '第 0-0 筆 / 共 0 筆'
|
||||
|
||||
const start = (currentPage.value - 1) * itemsPerPage + 1
|
||||
const end = Math.min(currentPage.value * itemsPerPage, total)
|
||||
return `第 ${start}-${end} 筆 / 共 ${total} 筆`
|
||||
})
|
||||
|
||||
// 彈窗狀態與流程控制
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
const dialogMode = ref<'create' | 'edit' | 'view'>('create')
|
||||
const isLoading = ref(false)
|
||||
const isSaving = ref(false)
|
||||
// UI 回饋
|
||||
const snackbarVisible = ref(false)
|
||||
const highlightedId = ref<number | null>(null)
|
||||
// 防止快速切換導致的異步覆蓋
|
||||
const loadSequence = ref(0)
|
||||
|
||||
// Master-Detail 擴充
|
||||
const studentSemesters = ref<SemesterRecord[]>([])
|
||||
const selectedSemesterId = ref<number | null>(null)
|
||||
const activeMobilePanel = ref<'master' | 'detail'>('master')
|
||||
const selectedSemester = computed(
|
||||
() => studentSemesters.value.find((s) => s.id === selectedSemesterId.value) || null
|
||||
)
|
||||
|
||||
// Detail Editing State (子檔編輯狀態管理)
|
||||
const isDetailEditing = ref(false)
|
||||
const detailForm = ref<SemesterRecord | null>(null)
|
||||
|
||||
// 輔助函式:重新載入當前學生的學期資料
|
||||
function refreshSemesters() {
|
||||
if (editingId.value) {
|
||||
studentSemesters.value = semesterStore.getStudentSemesters(editingId.value)
|
||||
}
|
||||
}
|
||||
|
||||
// 處理新增學期
|
||||
function handleAddSemester() {
|
||||
if (!editingId.value) return
|
||||
const newSem = semesterStore.addSemester(editingId.value)
|
||||
refreshSemesters()
|
||||
selectedSemesterId.value = newSem.id
|
||||
activeMobilePanel.value = 'detail'
|
||||
startDetailEdit() // 新增後直接進入編輯模式
|
||||
}
|
||||
|
||||
// 處理刪除學期
|
||||
function handleDeleteSemester(id: number) {
|
||||
// 簡單確認對話框 (實作時可改用更美觀的 Modal)
|
||||
if (!confirm('確定要刪除此學期紀錄嗎?')) return
|
||||
|
||||
semesterStore.removeSemester(id)
|
||||
refreshSemesters()
|
||||
// 若刪除的是當前選取的學期,則取消選取並關閉編輯模式
|
||||
if (selectedSemesterId.value === id) {
|
||||
selectedSemesterId.value = null
|
||||
isDetailEditing.value = false
|
||||
activeMobilePanel.value = 'master'
|
||||
}
|
||||
}
|
||||
|
||||
// 開始編輯子檔 (複製資料到暫存表單)
|
||||
function startDetailEdit() {
|
||||
if (!selectedSemester.value) return
|
||||
// 需要深拷貝來避免直接改到原始資料,且保留巢狀結構的語意
|
||||
detailForm.value = structuredClone(selectedSemester.value)
|
||||
isDetailEditing.value = true
|
||||
}
|
||||
|
||||
// 取消編輯子檔
|
||||
function cancelDetailEdit() {
|
||||
isDetailEditing.value = false
|
||||
detailForm.value = null
|
||||
if (isMobile.value && selectedSemesterId.value === null) {
|
||||
activeMobilePanel.value = 'master'
|
||||
}
|
||||
}
|
||||
|
||||
// 儲存子檔變更
|
||||
function saveDetailEdit() {
|
||||
if (!detailForm.value || !detailForm.value.id) return
|
||||
semesterStore.updateSemester(detailForm.value.id, detailForm.value)
|
||||
refreshSemesters()
|
||||
isDetailEditing.value = false
|
||||
detailForm.value = null
|
||||
}
|
||||
|
||||
const {
|
||||
errorSummary,
|
||||
fieldErrors,
|
||||
form,
|
||||
isDirty,
|
||||
saveSummary,
|
||||
clearAllErrors,
|
||||
clearFieldError,
|
||||
gradeLabel,
|
||||
resetForm,
|
||||
rowProps,
|
||||
setForm,
|
||||
statusColor,
|
||||
syncInitialForm,
|
||||
validateForm,
|
||||
} = useStudentMaintenanceForm({
|
||||
departments,
|
||||
gradeOptions,
|
||||
enrollYears,
|
||||
statuses,
|
||||
students,
|
||||
editingId,
|
||||
highlightedId,
|
||||
})
|
||||
|
||||
// 表單欄位簡單排版(無分組)
|
||||
|
||||
// 彈窗標題/副標題
|
||||
const dialogTitle = computed(() => {
|
||||
if (dialogMode.value === 'view') return '檢視主檔資料示範'
|
||||
if (dialogMode.value === 'edit') return '修改主檔資料示範'
|
||||
return '新增主檔資料示範'
|
||||
})
|
||||
const dialogSubtitle = computed(() => {
|
||||
if (!editingId.value) return ''
|
||||
return `${form.value.studentId || '未填學號'}・${form.value.name || '未填姓名'}`
|
||||
})
|
||||
// 是否有修改(用於啟用儲存與提示)
|
||||
// 載入/儲存時鎖定、檢視模式 readonly
|
||||
const isFormLocked = computed(() => isLoading.value || isSaving.value)
|
||||
const {
|
||||
confirmClose,
|
||||
confirmCloseVisible,
|
||||
confirmDelete,
|
||||
confirmDeleteVisible,
|
||||
confirmNavigate,
|
||||
confirmNavigateVisible,
|
||||
confirmSaveVisible,
|
||||
confirmSwitch,
|
||||
confirmSwitchVisible,
|
||||
handleDialogVisibility,
|
||||
hasNextRecord,
|
||||
hasPrevRecord,
|
||||
isEditMode,
|
||||
isViewMode,
|
||||
openAdjacentRecord,
|
||||
openEdgeRecord,
|
||||
pendingDeleteLabel,
|
||||
requestCloseDialog,
|
||||
requestDeleteConfirmation,
|
||||
requestDeleteCurrent,
|
||||
switchToEditMode,
|
||||
switchToViewMode,
|
||||
} = useMaintenanceCrudFlow<StudentRecord>({
|
||||
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)
|
||||
// 重設查詢條件
|
||||
function resetSearch() {
|
||||
search.value = {
|
||||
studentId: '',
|
||||
name: '',
|
||||
department: '',
|
||||
grade: null,
|
||||
status: '',
|
||||
}
|
||||
}
|
||||
|
||||
watch(pageCount, (value) => {
|
||||
if (currentPage.value > value) {
|
||||
currentPage.value = value
|
||||
}
|
||||
})
|
||||
|
||||
// 新增:開啟彈窗,使用預設值
|
||||
function openAddDialog() {
|
||||
loadSequence.value += 1
|
||||
dialogMode.value = 'create'
|
||||
editingId.value = null
|
||||
studentSemesters.value = []
|
||||
selectedSemesterId.value = null
|
||||
activeMobilePanel.value = 'master'
|
||||
resetForm()
|
||||
isDetailEditing.value = false
|
||||
isLoading.value = false
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 編輯:先開彈窗,資料載入後填入
|
||||
function openEditDialog(student: StudentRecord) {
|
||||
loadSequence.value += 1
|
||||
const sequence = loadSequence.value
|
||||
dialogMode.value = 'edit'
|
||||
editingId.value = student.id
|
||||
studentSemesters.value = semesterStore.getStudentSemesters(student.id)
|
||||
selectedSemesterId.value = null
|
||||
activeMobilePanel.value = 'master'
|
||||
dialogVisible.value = true
|
||||
isLoading.value = true
|
||||
clearAllErrors()
|
||||
setTimeout(() => {
|
||||
if (sequence !== loadSequence.value || !dialogVisible.value) return
|
||||
setForm({
|
||||
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,
|
||||
})
|
||||
syncInitialForm()
|
||||
isLoading.value = false
|
||||
}, 350)
|
||||
}
|
||||
|
||||
// 檢視:只讀模式並預設展開所有分組
|
||||
function openViewDialog(student: StudentRecord) {
|
||||
loadSequence.value += 1
|
||||
const sequence = loadSequence.value
|
||||
dialogMode.value = 'view'
|
||||
editingId.value = student.id
|
||||
studentSemesters.value = semesterStore.getStudentSemesters(student.id)
|
||||
selectedSemesterId.value = null
|
||||
activeMobilePanel.value = 'master'
|
||||
dialogVisible.value = true
|
||||
isLoading.value = true
|
||||
clearAllErrors()
|
||||
setTimeout(() => {
|
||||
if (sequence !== loadSequence.value || !dialogVisible.value) return
|
||||
setForm({
|
||||
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,
|
||||
})
|
||||
syncInitialForm()
|
||||
isLoading.value = false
|
||||
}, 350)
|
||||
}
|
||||
|
||||
// 先檢核再提示儲存確認
|
||||
async function requestSaveConfirmation() {
|
||||
if (isSaving.value || isLoading.value || !isDirty.value || isViewMode.value) return
|
||||
clearAllErrors()
|
||||
|
||||
const errors = validateForm()
|
||||
if (errors.length > 0) {
|
||||
for (const error of errors) {
|
||||
fieldErrors.value[error.field] = [error.message]
|
||||
}
|
||||
await nextTick()
|
||||
const firstError = errors[0]
|
||||
if (firstError) scrollToField(firstError.field)
|
||||
return
|
||||
}
|
||||
|
||||
confirmSaveVisible.value = true
|
||||
}
|
||||
|
||||
// 儲存確認後才真正送出
|
||||
function confirmSave() {
|
||||
confirmSaveVisible.value = false
|
||||
saveStudent()
|
||||
}
|
||||
|
||||
// 寫入資料(Demo:直接更新列表)
|
||||
async function saveStudent() {
|
||||
if (isSaving.value || isLoading.value) return
|
||||
isSaving.value = true
|
||||
await new Promise((resolve) => setTimeout(resolve, 450))
|
||||
const payload = {
|
||||
studentId: form.value.studentId.trim(),
|
||||
name: form.value.name.trim(),
|
||||
department: form.value.department,
|
||||
grade: form.value.grade,
|
||||
enrollYear: form.value.enrollYear,
|
||||
credits: form.value.credits,
|
||||
advisor: form.value.advisor.trim(),
|
||||
email: form.value.email.trim(),
|
||||
phone: form.value.phone.trim(),
|
||||
status: form.value.status,
|
||||
} as StudentPayload
|
||||
|
||||
if (editingId.value) {
|
||||
const updated = studentStore.updateStudent(editingId.value, payload)
|
||||
if (updated) highlightedId.value = editingId.value
|
||||
} else {
|
||||
const createdId = studentStore.addStudent(payload)
|
||||
semesterStore.generateForStudent(createdId)
|
||||
highlightedId.value = createdId
|
||||
}
|
||||
|
||||
syncInitialForm()
|
||||
dialogVisible.value = false
|
||||
snackbarVisible.value = true
|
||||
isSaving.value = false
|
||||
window.setTimeout(() => {
|
||||
highlightedId.value = null
|
||||
}, 1600)
|
||||
}
|
||||
|
||||
function scrollToField(field: string) {
|
||||
const target = document.getElementById(`field-${field}`)
|
||||
if (!target) return
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
|
||||
function handleSemesterSelect(id: number) {
|
||||
if (isMobile.value) {
|
||||
selectedSemesterId.value = id
|
||||
activeMobilePanel.value = 'detail'
|
||||
return
|
||||
}
|
||||
|
||||
selectedSemesterId.value = selectedSemesterId.value === id ? null : id
|
||||
}
|
||||
|
||||
function closeDetailPanel() {
|
||||
isDetailEditing.value = false
|
||||
detailForm.value = null
|
||||
if (isMobile.value) {
|
||||
activeMobilePanel.value = 'master'
|
||||
return
|
||||
}
|
||||
selectedSemesterId.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dialog-overlay :deep(.v-overlay__content) {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
.dialog-panel {
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
height: 100vh;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.dialog-panel > .v-card {
|
||||
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.detail-panel-wrapper {
|
||||
width: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.detail-panel-wrapper.is-active {
|
||||
width: 600px;
|
||||
opacity: 1;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.dialog-panel.is-mobile {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dialog-panel.is-mobile :deep(.dialog-title) {
|
||||
padding: 16px 20px 12px;
|
||||
}
|
||||
|
||||
.dialog-panel.is-mobile :deep(.dialog-toolbar) {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.dialog-panel.is-mobile :deep(.dialog-actions) {
|
||||
gap: 8px;
|
||||
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.dialog-panel.is-mobile :deep(.dialog-actions .v-btn) {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dialog-panel.is-mobile :deep(.v-card-text) {
|
||||
padding-bottom: 88px;
|
||||
}
|
||||
|
||||
.detail-panel-wrapper.is-mobile {
|
||||
width: 100%;
|
||||
opacity: 1;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.detail-panel-wrapper.is-mobile.is-active {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.dialog-panel {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dialog-panel > .v-card {
|
||||
width: 100%;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.student-table :deep(table) {
|
||||
min-width: 960px;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background: rgba(var(--v-theme-surface), 0.95);
|
||||
border-top: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.dialog-title {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: rgba(var(--v-theme-surface), 0.95);
|
||||
border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.student-table {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.student-table :deep(table) {
|
||||
min-width: 1400px;
|
||||
}
|
||||
|
||||
.student-table :deep(th),
|
||||
.student-table :deep(td) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.student-table :deep(.v-data-table-column--fixed),
|
||||
.student-table :deep(.v-data-table-column--fixed-end) {
|
||||
background: rgb(var(--v-theme-surface));
|
||||
}
|
||||
|
||||
.student-table :deep(.v-data-table-column--fixed-last-start)::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -5px;
|
||||
bottom: 0;
|
||||
width: 5px;
|
||||
box-shadow: inset 10px 0 8px -8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.student-table :deep(.v-data-table-footer) {
|
||||
padding: 4px 0 0;
|
||||
}
|
||||
|
||||
/* 直線 */
|
||||
/* .student-table :deep(.v-data-table-column--first-fixed-end),
|
||||
.student-table :deep(.v-data-table-column--last-fixed) {
|
||||
border-left: none !important;
|
||||
border-right: none !important;
|
||||
} */
|
||||
|
||||
.form-readonly :deep(.v-field) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
tbody tr.is-highlighted {
|
||||
animation: row-highlight 1.6s ease-out;
|
||||
}
|
||||
|
||||
@keyframes row-highlight {
|
||||
0% {
|
||||
background-color: rgba(var(--v-theme-primary), 0.18);
|
||||
}
|
||||
|
||||
100% {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user