Files
skt-vuetify-templates/src/views/maint/MasterDetailMnt.vue
T

1116 lines
36 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<mnt-page-cards
:search-panel-open="searchPanelOpen" :title="`主從資料維護示範A`"
@create="openAddDialog" @toggle-search="searchPanelOpen = !searchPanelOpen">
<template #search-fields>
<v-col cols="12" md="2">
<div class="text-body-2 text-medium-emphasis pl-2">學號</div>
<v-text-field
v-model="search.studentId" density="compact" hide-details placeholder="例如:S2024001"
variant="outlined" />
</v-col>
<v-col cols="12" md="2">
<div class="text-body-2 text-medium-emphasis pl-2">姓名</div>
<v-text-field v-model="search.name" density="compact" hide-details placeholder="例如:王小明" variant="outlined" />
</v-col>
<v-col cols="12" md="2">
<div class="text-body-2 text-medium-emphasis pl-2">系所</div>
<v-select v-model="search.department" density="compact" hide-details :items="departments" variant="outlined" />
</v-col>
<v-col cols="12" md="2">
<div class="text-body-2 text-medium-emphasis pl-2">年級</div>
<v-select
v-model="search.grade" density="compact" hide-details item-title="title" item-value="value"
:items="gradeOptions" variant="outlined" />
</v-col>
<v-col cols="12" md="2">
<div class="text-body-2 text-medium-emphasis pl-2">狀態</div>
<v-select v-model="search.status" density="compact" hide-details :items="statuses" 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
class="student-table" density="compact" fixed-header :headers="tableHeaders" height="100%"
:items="students" :items-per-page="10" items-per-page-text="每頁筆數" page-text=" {0}-{1} / {2} "
: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>
</v-data-table>
</template>
</mnt-page-cards>
<!-- 主從式維護視窗 -->
<!-- 說明包含主檔 (學生資料) 與明細檔 (學期成績) 的維護介面 -->
<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">
<v-row dense>
<v-col cols="12" md="6">
<v-text-field
id="field-studentId" v-model="form.studentId" density="comfortable" :disabled="isFormLocked"
:error-messages="fieldErrors.studentId" label="學號" placeholder="例如:S2024008"
:readonly="isFormReadonly" variant="outlined"
@update:model-value="clearFieldError('studentId')" />
</v-col>
<v-col cols="12" md="6">
<v-text-field
id="field-name" v-model="form.name" density="comfortable" :disabled="isFormLocked" :error-messages="fieldErrors.name"
label="姓名" placeholder="例如:陳怡君" :readonly="isFormReadonly"
variant="outlined" @update:model-value="clearFieldError('name')" />
</v-col>
<v-col cols="12" md="6">
<v-select
id="field-department" v-model="form.department" density="comfortable" :disabled="isFormLocked"
:error-messages="fieldErrors.department" :items="departments" label="系所"
:readonly="isFormReadonly" variant="outlined"
@update:model-value="clearFieldError('department')" />
</v-col>
<v-col cols="12" md="6">
<v-select
id="field-grade" v-model="form.grade" density="comfortable" :disabled="isFormLocked" :error-messages="fieldErrors.grade"
item-title="title" item-value="value" :items="gradeOptions" label="年級"
:readonly="isFormReadonly" variant="outlined"
@update:model-value="clearFieldError('grade')" />
</v-col>
<v-col cols="12" md="6">
<v-select
id="field-enrollYear" v-model="form.enrollYear" density="comfortable" :disabled="isFormLocked"
:error-messages="fieldErrors.enrollYear" :items="enrollYears" label="入學年度"
:readonly="isFormReadonly" variant="outlined"
@update:model-value="clearFieldError('enrollYear')" />
</v-col>
<v-col cols="12" md="6">
<v-select
id="field-status" v-model="form.status" density="comfortable" :disabled="isFormLocked" :error-messages="fieldErrors.status"
:items="statuses" label="狀態" :readonly="isFormReadonly"
variant="outlined" @update:model-value="clearFieldError('status')" />
</v-col>
<v-col cols="12">
<v-text-field
id="field-email" v-model="form.email" density="comfortable" :disabled="isFormLocked"
:error-messages="fieldErrors.email" label="Email" :readonly="isFormReadonly"
variant="outlined" @update:model-value="clearFieldError('email')" />
</v-col>
</v-row>
<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>
<common-confirm-dialog
v-model="confirmCloseVisible" confirm-color="error" confirm-text="關閉不儲存" message="目前有尚未儲存的內容,確定要關閉嗎?"
title="未儲存變更" @confirm="confirmClose" />
<common-confirm-dialog
v-model="confirmSaveVisible" :confirm-loading="isSaving" confirm-text="確認儲存" max-width="520"
title="確認儲存變更" @confirm="confirmSave">
<div v-if="saveSummary.length > 0" class="d-flex flex-column ga-2">
<div v-for="item in saveSummary" :key="item.label" class="d-flex flex-column">
<div class="text-caption text-medium-emphasis">{{ item.label }}</div>
<div v-if="item.before !== null" class="text-body-2">
<span class="text-medium-emphasis"></span>
<span :class="{ 'text-medium-emphasis': isPlaceholderValue(item.before) }">
{{ item.before }}
</span>
</div>
<div class="text-body-2">
<span class="text-medium-emphasis"></span>
<span :class="{ 'text-medium-emphasis': isPlaceholderValue(item.after) }">
{{ item.after }}
</span>
</div>
</div>
</div>
<div v-else class="text-body-2">目前沒有可儲存的變更</div>
</common-confirm-dialog>
<common-confirm-dialog
v-model="confirmDeleteVisible" confirm-color="error" confirm-text="確定刪除"
:message="`確定要刪除 ${pendingDeleteLabel} 嗎?此操作無法復原。`" title="確認刪除" @confirm="confirmDelete" />
<common-confirm-dialog
v-model="confirmSwitchVisible" confirm-text="確定切換" max-width="480"
message="目前有尚未儲存的內容,切換為檢視模式將會捨棄變更,確定要切換嗎?" title="未儲存變更" @confirm="confirmSwitch" />
<common-confirm-dialog
v-model="confirmNavigateVisible" confirm-text="確定切換" max-width="480"
message="目前有尚未儲存的內容,切換到其他資料將會捨棄變更,確定要切換嗎?" title="未儲存變更" @confirm="confirmNavigate" />
<!-- 成功提示 -->
<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 } from 'vue'
import { useDisplay } from 'vuetify'
import CommonConfirmDialog from '@/components/maintenance/CommonConfirmDialog.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 { 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 dialogVisible = ref(false)
const editingId = ref<number | null>(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<number | null>(null)
// 防止快速切換導致的異步覆蓋
const loadSequence = ref(0)
// 暫存待處理的目標
const pendingDelete = ref<StudentRecord | null>(null)
const pendingSwitchTarget = ref<StudentRecord | null>(null)
const pendingNavigateTarget = ref<StudentRecord | null>(null)
// 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
// Deep copy 以避免直接修改原始資料 (Vue 的響應式特性)
detailForm.value = JSON.parse(JSON.stringify(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
}
// 編輯模式:新增課程項目
function addCourseToDetail () {
if (!detailForm.value) return
detailForm.value.courses.push({
code: '',
name: '',
credits: 3,
score: 0
})
}
// 編輯模式:移除課程項目
function removeCourseFromDetail (index: number) {
if (!detailForm.value) return
detailForm.value.courses.splice(index, 1)
}
// 表單資料與初始快照(用於 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<Record<string, string[]>>({
studentId: [],
name: [],
department: [],
grade: [],
enrollYear: [],
credits: [],
advisor: [],
email: [],
phone: [],
status: [],
})
// 表單欄位簡單排版(無分組)
// 彈窗標題/副標題
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 || '未填姓名'}`
})
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 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<string, string> = {
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 = {
studentId: '',
name: '',
department: '',
grade: null,
status: '',
}
}
// 重設表單與錯誤狀態
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
dialogMode.value = 'create'
editingId.value = null
studentSemesters.value = []
selectedSemesterId.value = null
activeMobilePanel.value = 'master'
resetForm()
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
form.value = {
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,
}
initialForm.value = { ...form.value }
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
form.value = {
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,
}
initialForm.value = { ...form.value }
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
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
}
initialForm.value = { ...form.value }
dialogVisible.value = false
snackbarVisible.value = true
isSaving.value = false
window.setTimeout(() => {
highlightedId.value = null
}, 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
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>