Files
skt-vuetify-templates/src/views/maint/MasterDetailC.vue
T
skytek_xinliang f61432ad8a fixing adn docing
2026-06-01 14:44:39 +08:00

1126 lines
33 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>
<!-- Page component 組合 MaintShell 外殼主檔表格子檔區與 dialog流程狀態集中在本頁 script -->
<maint-shell
:search-panel-open="searchPanelOpen"
:title="pageModel.title"
@create="openAddDialog"
@toggle-search="searchPanelOpen = !searchPanelOpen"
>
<!-- 搜尋欄位放在 MaintShell search-fields slot讓外殼固定欄位由頁面決定 -->
<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>
<!-- table slot 放主檔表格與列操作操作事件再交給頁面流程函式處理 -->
<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-2">
<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>
</maint-shell>
<!-- 主從式維護視窗 -->
<!-- 說明包含主檔 (學生資料) 與明細檔 (學期成績) 的維護介面 -->
<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 }">
<master-detail-c-course-mobile-panel
v-if="isMobile && activeMobilePanel === 'detail'"
:is-form-locked="isFormLocked"
:is-view-mode="isViewMode"
:semester="selectedSemester"
@add-course="openAddCourseDialog"
@close="closeDetailPanel"
@delete-course="removeCourseFromSemester"
@update-course="handleUpdateCourse"
/>
<!-- 主檔區塊 (Master Card)學生基本資料與學期列表 -->
<!-- 說明固定在視窗右側包含學生表單與學期清單 -->
<mnt-dialog-card
v-else
:content-class="
isMobile
? 'pa-3 flex-grow-1 overflow-y-auto pb-16'
: 'pa-2 flex-grow-1 overflow-hidden d-flex flex-column'
"
:dialog-subtitle="dialogSubtitle"
:dialog-title="dialogTitle"
:is-edit-mode="isEditMode"
:is-view-mode="isViewMode"
width="100%"
>
<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 },
isMobile ? '' : 'd-flex flex-column h-100',
]"
@submit.prevent="requestSaveConfirmation"
>
<MasterFileFormFields
: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 />
<DetailSimpleList
:is-form-locked="isFormLocked"
:is-form-readonly="isFormReadonly"
:is-mobile="isMobile"
:selected-semester-id="selectedSemesterId"
:semesters="studentSemesters"
@add-course="openAddCourseDialog"
@delete-course="removeCourseFromSemester"
@select-semester="handleSemesterSelect"
@update-course="handleUpdateCourse"
/>
</v-form>
</template>
<template #actions>
<template v-if="isMobile">
<v-btn
class="flex-grow-1"
:disabled="isSaving"
variant="text"
@click="requestCloseDialog"
>取消</v-btn
>
<v-btn
v-if="isEditMode"
class="flex-grow-1"
color="error"
:disabled="isSaving"
variant="tonal"
@click="requestDeleteCurrent"
>
刪除
</v-btn>
<v-btn
v-if="!isViewMode"
class="flex-grow-1"
color="primary"
:disabled="!isDirty || isLoading"
:loading="isSaving"
variant="flat"
@click="requestSaveConfirmation"
>
儲存
</v-btn>
<v-btn
v-else
class="flex-grow-1"
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>
<ConfirmDialog
v-model="confirmCloseVisible"
confirm-color="error"
confirm-text="關閉不儲存"
message="目前有尚未儲存的內容,確定要關閉嗎?"
title="未儲存變更"
@confirm="confirmClose"
/>
<ConfirmDialog
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': item.before === '—' }">
{{ item.before }}
</span>
</div>
<div class="text-body-2">
<span class="text-medium-emphasis"></span>
<span :class="{ 'text-medium-emphasis': item.after === '—' }">
{{ item.after }}
</span>
</div>
</div>
</div>
<div v-else class="text-body-2">目前沒有可儲存的變更</div>
</ConfirmDialog>
<ConfirmDialog
v-model="confirmDeleteVisible"
confirm-color="error"
confirm-text="確定刪除"
:message="`確定要刪除 ${pendingDeleteLabel} 嗎?此操作無法復原。`"
title="確認刪除"
@confirm="confirmDelete"
/>
<ConfirmDialog
v-model="confirmSwitchVisible"
confirm-text="確定切換"
max-width="480"
message="目前有尚未儲存的內容,切換為檢視模式將會捨棄變更,確定要切換嗎?"
title="未儲存變更"
@confirm="confirmSwitch"
/>
<ConfirmDialog
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>
<!-- 新增成績對話框 -->
<v-dialog v-model="addCourseDialogVisible" max-width="480" persistent>
<v-card>
<v-card-title class="d-flex align-center">
<v-icon color="primary" start :icon="mdiSchool" />
新增成績
</v-card-title>
<v-card-text>
<v-select
v-model="addCourseForm.semesterId"
class="mb-3"
density="comfortable"
item-title="label"
item-value="value"
:items="semesterOptions"
label="學期"
variant="outlined"
/>
<v-select
v-model="addCourseForm.courseName"
class="mb-3"
density="comfortable"
:items="availableCourses"
label="課程名稱"
variant="outlined"
@update:model-value="handleCourseSelect"
/>
<v-text-field
v-model.number="addCourseForm.credits"
class="mb-3"
density="comfortable"
label="學分"
type="number"
variant="outlined"
/>
<v-text-field
v-model.number="addCourseForm.score"
density="comfortable"
label="分數"
type="number"
variant="outlined"
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="addCourseDialogVisible = false">取消</v-btn>
<v-btn color="primary" :disabled="!canAddCourse" variant="flat" @click="confirmAddCourse"
>新增</v-btn
>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { mdiBroom, mdiDelete, mdiEye, mdiMagnify, mdiPencil, mdiSchool } from '@mdi/js'
import { computed, nextTick, ref, watch } from 'vue'
import { useDisplay } from 'vuetify'
import ConfirmDialog from '@/components/maint/CommonConfirmDialog.vue'
import MasterDetailCCourseMobilePanel from '@/components/maint/master-detail/CourseMobilePanel.vue'
import DetailSimpleList from '@/components/maint/master-detail/DetailSimpleList.vue'
import MasterFileFormFields from '@/components/maint/MasterFileFormFields.vue'
import MntDialogCard from '@/components/maint/MntDialogCard.vue'
import MntRecordNavToolbar from '@/components/maint/MntRecordNavToolbar.vue'
import MaintShell from '@/components/maint/MaintShell.vue'
import { useMaintenanceCrudFlow } from '@/composables/maint/useMaintenanceCrudFlow'
import { useStudentMaintenanceForm } from '@/composables/maint/useStudentMaintenanceForm'
import { type CourseRecord, type SemesterRecord, useSemesterStore } from '@/stores/semesters'
import { type StudentRecord, useStudentStore } from '@/stores/students'
import type { MaintenancePageModel } from '@/models/page'
// 下拉選項:系所/年級/入學年度/狀態
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)
const pageModel = computed<MaintenancePageModel>(() => ({
type: 'maintenance',
title: '主從資料維護示範C',
records: students.value,
loading: false,
error: null,
}))
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((item) => item.id === selectedSemesterId.value) ?? null
)
// 新增成績對話框狀態
const addCourseDialogVisible = ref(false)
const addCourseForm = ref({
semesterId: null as number | null,
courseName: '',
credits: 3,
score: 0,
})
// 可選課程清單(與 store 內的 subjects 一致)
const availableCourses = [
'資料結構',
'演算法',
'作業系統',
'計算機組織',
'線性代數',
'機率與統計',
'資料庫系統',
'人工智慧導論',
'網頁程式設計',
'計算機網路',
]
// 學期下拉選項
const semesterOptions = computed(() =>
studentSemesters.value.map((sem) => ({
value: sem.id,
label: sem.semesterName,
}))
)
// 是否可以新增
const canAddCourse = computed(
() =>
addCourseForm.value.semesterId !== null &&
addCourseForm.value.courseName !== '' &&
addCourseForm.value.credits > 0
)
// 開啟新增成績對話框
function openAddCourseDialog(semesterId?: number) {
addCourseForm.value = {
semesterId: semesterId ?? selectedSemesterId.value ?? studentSemesters.value[0]?.id ?? null,
courseName: '',
credits: 3,
score: 0,
}
addCourseDialogVisible.value = true
}
// 選擇課程時自動帶入學分
function handleCourseSelect(courseName: string) {
const creditsMap: Record<string, number> = {
資料結構: 3,
演算法: 3,
作業系統: 3,
計算機組織: 3,
線性代數: 3,
機率與統計: 3,
資料庫系統: 3,
人工智慧導論: 3,
網頁程式設計: 3,
計算機網路: 3,
}
const credits = creditsMap[courseName]
if (credits !== undefined) {
addCourseForm.value.credits = credits
}
}
// 確認新增成績
function confirmAddCourse() {
if (!addCourseForm.value.semesterId || !addCourseForm.value.courseName) return
const semester = studentSemesters.value.find((sem) => sem.id === addCourseForm.value.semesterId)
if (!semester) return
semesterStore.updateSemester(addCourseForm.value.semesterId, {
courses: [
...semester.courses,
{
code: `CS${Date.now()}`,
name: addCourseForm.value.courseName,
credits: addCourseForm.value.credits,
score: addCourseForm.value.score,
},
],
})
refreshSemesters()
addCourseDialogVisible.value = false
}
// 輔助函式:重新載入當前學生的學期資料
function refreshSemesters() {
if (editingId.value) {
studentSemesters.value = semesterStore.getStudentSemesters(editingId.value)
}
}
function handleSemesterSelect(semesterId: number) {
selectedSemesterId.value = semesterId
if (isMobile.value) {
activeMobilePanel.value = 'detail'
}
}
function closeDetailPanel() {
if (isMobile.value) {
activeMobilePanel.value = 'master'
return
}
selectedSemesterId.value = null
}
function handleUpdateCourse(
semesterId: number,
courseIndex: number,
payload: Partial<CourseRecord>
) {
const semester = studentSemesters.value.find((item) => item.id === semesterId)
if (!semester) return
const nextCourses = semester.courses.map((course, idx) =>
idx === courseIndex ? { ...course, ...payload } : course
)
semesterStore.updateSemester(semesterId, { courses: nextCourses })
}
function removeCourseFromSemester(semesterId: number, courseIndex: number) {
const semester = studentSemesters.value.find((item) => item.id === semesterId)
if (!semester) return
semesterStore.updateSemester(semesterId, {
courses: semester.courses.filter((_, idx) => idx !== courseIndex),
})
}
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'
studentSemesters.value = []
},
})
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()
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' })
}
</script>
<style scoped>
:deep(.v-table__wrapper .v-field__input) {
padding-top: 0 !important;
padding-bottom: 0 !important;
min-height: 32px;
}
.dialog-overlay :deep(.v-overlay__content) {
height: 100vh;
display: flex;
justify-content: flex-end;
width: 100vw;
max-width: 100vw;
}
.dialog-panel {
width: 65%;
max-width: 100%;
height: 100vh;
background: transparent;
padding: 0;
display: flex;
}
.dialog-panel > .v-card {
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.12);
}
.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;
}
}
.dialog-panel.is-mobile {
width: 100%;
}
.dialog-panel.is-mobile :deep(.v-card) {
width: 100%;
box-shadow: none;
}
.dialog-panel.is-mobile :deep(.dialog-title) {
padding-inline: 16px;
}
.dialog-panel.is-mobile :deep(.dialog-toolbar) {
padding-inline: 12px;
padding-top: 8px;
padding-bottom: 8px;
}
.dialog-panel.is-mobile :deep(.dialog-actions) {
gap: 8px;
padding: 12px 16px calc(12px + env(safe-area-inset-bottom, 0px));
}
@media (max-width: 600px) {
.dialog-overlay :deep(.v-overlay__content) {
justify-content: stretch;
}
}
</style>