830 lines
27 KiB
Vue
830 lines
27 KiB
Vue
<template>
|
||
<mnt-page-cards
|
||
:search-panel-open="searchPanelOpen" :title="`主從資料維護示範B`"
|
||
@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-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>
|
||
</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 }">
|
||
<master-detail-b-semester-mobile-panel
|
||
v-if="isMobile && activeMobilePanel === 'detail'"
|
||
:is-form-locked="isFormLocked" :is-view-mode="isViewMode" :semester="selectedSemester"
|
||
@add-course="openAddCourseDialog" @close="closeDetailPanel" @delete-course="requestDeleteCourse"
|
||
@update-course="handleUpdateCourse" @update-semester="handleUpdateSemester" />
|
||
|
||
<!-- 主檔區塊 (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">
|
||
<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-b-semester-section
|
||
:is-form-locked="isFormLocked" :is-form-readonly="isFormReadonly"
|
||
:is-mobile="isMobile" :selected-semester-id="selectedSemesterId"
|
||
:semesters="studentSemesters" @add-course="openAddCourseDialog"
|
||
@delete-course="requestDeleteCourse" @select="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>
|
||
|
||
<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"
|
||
/>
|
||
|
||
<!-- 刪除課程確認 -->
|
||
<common-confirm-dialog
|
||
v-model="confirmDeleteCourseVisible" confirm-color="error"
|
||
confirm-text="確定移除" :message="`確定要移除「${pendingDeleteCourseName}」嗎?`" :title="`刪除課程`"
|
||
@confirm="confirmDeleteCourse" />
|
||
|
||
<!-- 加入課程對話框 -->
|
||
<v-dialog v-model="addCourseDialogVisible" max-width="420" persistent>
|
||
<v-card>
|
||
<v-card-title class="d-flex align-center">
|
||
<v-icon color="primary" start :icon="mdiBookPlus" />
|
||
加入課程
|
||
</v-card-title>
|
||
<v-card-text>
|
||
<v-select
|
||
v-model="addCourseForm.name" class="mb-3" density="comfortable" :items="availableCourses"
|
||
label="課程名稱" variant="outlined" @update:model-value="handleAddCourseNameSelect" />
|
||
<v-row density="compact">
|
||
<v-col cols="6">
|
||
<v-text-field
|
||
v-model.number="addCourseForm.credits" density="comfortable" hide-spin-buttons label="學分"
|
||
type="number" variant="outlined" />
|
||
</v-col>
|
||
<v-col cols="6">
|
||
<v-text-field
|
||
v-model.number="addCourseForm.score" density="comfortable" hide-spin-buttons label="分數"
|
||
type="number" variant="outlined" />
|
||
</v-col>
|
||
</v-row>
|
||
</v-card-text>
|
||
<v-card-actions>
|
||
<v-spacer />
|
||
<v-btn variant="text" @click="addCourseDialogVisible = false">取消</v-btn>
|
||
<v-btn color="primary" :disabled="!addCourseForm.name" variant="flat" @click="confirmAddCourse">加入</v-btn>
|
||
</v-card-actions>
|
||
</v-card>
|
||
</v-dialog>
|
||
|
||
<!-- 成功提示 -->
|
||
<v-snackbar v-model="snackbarVisible" color="success" location="bottom right" :timeout="2200">
|
||
儲存成功
|
||
</v-snackbar>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { mdiBookPlus, mdiBroom, mdiDelete, mdiEye, mdiMagnify, mdiPencil } from '@mdi/js'
|
||
import { computed, nextTick, ref } from 'vue'
|
||
import { useDisplay } from 'vuetify'
|
||
|
||
import MaintenanceCrudDialogs from '@/components/maintenance/MaintenanceCrudDialogs.vue'
|
||
import MaintenanceStudentFormFields from '@/components/maintenance/MaintenanceStudentFormFields.vue'
|
||
import MasterDetailBSemesterMobilePanel from '@/components/maintenance/master-detail-b/MasterDetailBSemesterMobilePanel.vue'
|
||
import MasterDetailBSemesterSection from '@/components/maintenance/master-detail-b/MasterDetailBSemesterSection.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 CourseRecord, 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)
|
||
// 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((semester) => semester.id === selectedSemesterId.value) ?? null,
|
||
)
|
||
|
||
// 刪除課程確認狀態
|
||
const confirmDeleteCourseVisible = ref(false)
|
||
const pendingDeleteCourseKey = ref<{ semesterId: number; courseIndex: number } | null>(null)
|
||
const pendingDeleteCourseName = ref('')
|
||
|
||
// 加入課程對話框狀態
|
||
const addCourseDialogVisible = ref(false)
|
||
const addCourseTargetSemesterId = ref<number | null>(null)
|
||
const addCourseForm = ref({ name: '', credits: 3, score: 0 })
|
||
|
||
const availableCourses = [
|
||
'資料結構', '演算法', '作業系統', '計算機組織', '線性代數',
|
||
'機率與統計', '資料庫系統', '人工智慧導論', '網頁程式設計', '計算機網路',
|
||
]
|
||
|
||
const creditsMap: Record<string, number> = {
|
||
資料結構: 3, 演算法: 3, 作業系統: 3, 計算機組織: 3, 線性代數: 3,
|
||
機率與統計: 3, 資料庫系統: 3, 人工智慧導論: 3, 網頁程式設計: 3, 計算機網路: 3,
|
||
}
|
||
|
||
// 輔助函式:重新載入當前學生的學期資料
|
||
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 handleUpdateSemester (semesterId: number, payload: Partial<SemesterRecord>) {
|
||
semesterStore.updateSemester(semesterId, payload)
|
||
}
|
||
|
||
|
||
|
||
// 請求刪除課程(開啟確認對話框)
|
||
function requestDeleteCourse (semesterId: number, courseIndex: number, courseName: string) {
|
||
pendingDeleteCourseKey.value = { semesterId, courseIndex }
|
||
pendingDeleteCourseName.value = courseName
|
||
confirmDeleteCourseVisible.value = true
|
||
}
|
||
|
||
// 確認刪除課程
|
||
function confirmDeleteCourse () {
|
||
if (!pendingDeleteCourseKey.value) return
|
||
const { semesterId, courseIndex } = pendingDeleteCourseKey.value
|
||
const semester = studentSemesters.value.find((item) => item.id === semesterId)
|
||
if (semester) {
|
||
semesterStore.updateSemester(semesterId, {
|
||
courses: semester.courses.filter((_, idx) => idx !== courseIndex),
|
||
})
|
||
refreshSemesters()
|
||
}
|
||
pendingDeleteCourseKey.value = null
|
||
confirmDeleteCourseVisible.value = false
|
||
}
|
||
|
||
// 開啟加入課程對話框
|
||
function openAddCourseDialog (semesterId: number) {
|
||
addCourseTargetSemesterId.value = semesterId
|
||
addCourseForm.value = { name: '', credits: 3, score: 0 }
|
||
addCourseDialogVisible.value = true
|
||
}
|
||
|
||
// 選擇課程名稱時自動帶入預設學分
|
||
function handleAddCourseNameSelect (name: string) {
|
||
const credits = creditsMap[name]
|
||
if (credits !== undefined) {
|
||
addCourseForm.value.credits = credits
|
||
}
|
||
}
|
||
|
||
// 確認加入課程
|
||
function confirmAddCourse () {
|
||
if (!addCourseTargetSemesterId.value || !addCourseForm.value.name) return
|
||
const semester = studentSemesters.value.find((item) => item.id === addCourseTargetSemesterId.value)
|
||
if (!semester) return
|
||
semesterStore.updateSemester(addCourseTargetSemesterId.value, {
|
||
courses: [
|
||
...semester.courses,
|
||
{
|
||
code: `CS${Date.now()}`,
|
||
name: addCourseForm.value.name,
|
||
credits: addCourseForm.value.credits,
|
||
score: addCourseForm.value.score,
|
||
},
|
||
],
|
||
})
|
||
refreshSemesters()
|
||
addCourseDialogVisible.value = false
|
||
}
|
||
|
||
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 })
|
||
}
|
||
|
||
|
||
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 {
|
||
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<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: '',
|
||
}
|
||
}
|
||
|
||
// 新增:開啟彈窗,使用預設值
|
||
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;
|
||
}
|
||
|
||
:deep(.v-expansion-panel-text__wrapper) {
|
||
padding: 8px;
|
||
}
|
||
|
||
.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;
|
||
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;
|
||
}
|
||
|
||
.dialog-toolbar {
|
||
position: sticky;
|
||
top: 73px;
|
||
background: rgba(var(--v-theme-surface), 0.95);
|
||
z-index: 1;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.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;
|
||
min-height: 72px;
|
||
}
|
||
|
||
.dialog-panel.is-mobile :deep(.dialog-toolbar) {
|
||
top: 72px;
|
||
padding-inline: 12px;
|
||
min-height: 52px;
|
||
}
|
||
|
||
.dialog-panel.is-mobile :deep(.dialog-actions) {
|
||
display: flex;
|
||
gap: 8px;
|
||
padding: 12px 16px calc(12px + env(safe-area-inset-bottom, 0px));
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 600px) {
|
||
.dialog-overlay :deep(.v-overlay__content) {
|
||
justify-content: stretch;
|
||
}
|
||
|
||
.dialog-panel {
|
||
width: 100%;
|
||
}
|
||
}
|
||
</style>
|