Refactor MasterDetailMntC.vue for improved readability and consistency
This commit is contained in:
@@ -1,31 +1,77 @@
|
||||
<template>
|
||||
<mnt-page-cards
|
||||
:search-panel-open="searchPanelOpen" :title="`主從資料維護示範A`"
|
||||
@create="openAddDialog" @toggle-search="searchPanelOpen = !searchPanelOpen">
|
||||
: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" />
|
||||
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-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-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" />
|
||||
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-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>
|
||||
@@ -34,9 +80,17 @@ id="search-grade" v-model="search.grade" aria-labelledby="search-grade-label" de
|
||||
</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">
|
||||
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>
|
||||
@@ -47,15 +101,31 @@ v-model:page="currentPage" class="student-table" density="compact" fixed-header
|
||||
</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
|
||||
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
|
||||
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)">
|
||||
color="error"
|
||||
:prepend-icon="mdiDelete"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="requestDeleteConfirmation(item)"
|
||||
>
|
||||
刪除
|
||||
</v-btn>
|
||||
</div>
|
||||
@@ -66,11 +136,35 @@ color="error" :prepend-icon="mdiDelete" size="small" variant="text"
|
||||
{{ 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>
|
||||
<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>
|
||||
<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>
|
||||
@@ -83,15 +177,21 @@ color="error" :prepend-icon="mdiDelete" size="small" variant="text"
|
||||
<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">
|
||||
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 }">
|
||||
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"
|
||||
@@ -109,34 +209,65 @@ v-if="!isMobile || activeMobilePanel === 'detail'" class="detail-panel-wrapper"
|
||||
<!-- 主檔區塊 (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">
|
||||
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" />
|
||||
: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">
|
||||
<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)">
|
||||
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%" />
|
||||
<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-form
|
||||
v-else
|
||||
:class="{ 'form-readonly': isFormReadonly }"
|
||||
@submit.prevent="requestSaveConfirmation"
|
||||
>
|
||||
<maintenance-student-form-fields
|
||||
:departments="departments"
|
||||
:enroll-years="enrollYears"
|
||||
@@ -154,21 +285,35 @@ v-for="error in errorSummary" :key="error.field" color="error" size="small" vari
|
||||
<!-- 學期成績紀錄區塊 -->
|
||||
<!-- 說明:顯示該學生的所有學期紀錄,並提供新增與選取功能 -->
|
||||
<master-detail-semester-list
|
||||
:is-mobile="isMobile" :is-view-mode="isViewMode"
|
||||
:selected-semester-id="selectedSemesterId" :semesters="studentSemesters" @add="handleAddSemester"
|
||||
@select="handleSemesterSelect" />
|
||||
|
||||
: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-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-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>
|
||||
@@ -176,20 +321,29 @@ v-if="!isViewMode" color="primary" :disabled="!isDirty || isLoading" :loading="i
|
||||
<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-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-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>
|
||||
@@ -255,8 +409,20 @@ 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: '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 },
|
||||
@@ -265,7 +431,14 @@ const tableHeaders = [
|
||||
{ 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' } },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
sortable: false,
|
||||
fixed: smAndUp.value && ('end' as const),
|
||||
width: 'auto',
|
||||
cellProps: { class: 'px-0 bg-background' },
|
||||
},
|
||||
]
|
||||
|
||||
// 查詢條件(示意用,未接 API)
|
||||
@@ -312,8 +485,8 @@ const loadSequence = ref(0)
|
||||
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
|
||||
const selectedSemester = computed(
|
||||
() => studentSemesters.value.find((s) => s.id === selectedSemesterId.value) || null
|
||||
)
|
||||
|
||||
// Detail Editing State (子檔編輯狀態管理)
|
||||
@@ -321,14 +494,14 @@ const isDetailEditing = ref(false)
|
||||
const detailForm = ref<SemesterRecord | null>(null)
|
||||
|
||||
// 輔助函式:重新載入當前學生的學期資料
|
||||
function refreshSemesters () {
|
||||
function refreshSemesters() {
|
||||
if (editingId.value) {
|
||||
studentSemesters.value = semesterStore.getStudentSemesters(editingId.value)
|
||||
}
|
||||
}
|
||||
|
||||
// 處理新增學期
|
||||
function handleAddSemester () {
|
||||
function handleAddSemester() {
|
||||
if (!editingId.value) return
|
||||
const newSem = semesterStore.addSemester(editingId.value)
|
||||
refreshSemesters()
|
||||
@@ -338,7 +511,7 @@ function handleAddSemester () {
|
||||
}
|
||||
|
||||
// 處理刪除學期
|
||||
function handleDeleteSemester (id: number) {
|
||||
function handleDeleteSemester(id: number) {
|
||||
// 簡單確認對話框 (實作時可改用更美觀的 Modal)
|
||||
if (!confirm('確定要刪除此學期紀錄嗎?')) return
|
||||
|
||||
@@ -353,7 +526,7 @@ function handleDeleteSemester (id: number) {
|
||||
}
|
||||
|
||||
// 開始編輯子檔 (複製資料到暫存表單)
|
||||
function startDetailEdit () {
|
||||
function startDetailEdit() {
|
||||
if (!selectedSemester.value) return
|
||||
// 需要深拷貝來避免直接改到原始資料,且保留巢狀結構的語意
|
||||
detailForm.value = structuredClone(selectedSemester.value)
|
||||
@@ -361,7 +534,7 @@ function startDetailEdit () {
|
||||
}
|
||||
|
||||
// 取消編輯子檔
|
||||
function cancelDetailEdit () {
|
||||
function cancelDetailEdit() {
|
||||
isDetailEditing.value = false
|
||||
detailForm.value = null
|
||||
if (isMobile.value && selectedSemesterId.value === null) {
|
||||
@@ -370,7 +543,7 @@ function cancelDetailEdit () {
|
||||
}
|
||||
|
||||
// 儲存子檔變更
|
||||
function saveDetailEdit () {
|
||||
function saveDetailEdit() {
|
||||
if (!detailForm.value || !detailForm.value.id) return
|
||||
semesterStore.updateSemester(detailForm.value.id, detailForm.value)
|
||||
refreshSemesters()
|
||||
@@ -467,7 +640,7 @@ const {
|
||||
})
|
||||
const isFormReadonly = computed(() => isViewMode.value)
|
||||
// 重設查詢條件
|
||||
function resetSearch () {
|
||||
function resetSearch() {
|
||||
search.value = {
|
||||
studentId: '',
|
||||
name: '',
|
||||
@@ -484,7 +657,7 @@ watch(pageCount, (value) => {
|
||||
})
|
||||
|
||||
// 新增:開啟彈窗,使用預設值
|
||||
function openAddDialog () {
|
||||
function openAddDialog() {
|
||||
loadSequence.value += 1
|
||||
dialogMode.value = 'create'
|
||||
editingId.value = null
|
||||
@@ -498,7 +671,7 @@ function openAddDialog () {
|
||||
}
|
||||
|
||||
// 編輯:先開彈窗,資料載入後填入
|
||||
function openEditDialog (student: StudentRecord) {
|
||||
function openEditDialog(student: StudentRecord) {
|
||||
loadSequence.value += 1
|
||||
const sequence = loadSequence.value
|
||||
dialogMode.value = 'edit'
|
||||
@@ -529,7 +702,7 @@ function openEditDialog (student: StudentRecord) {
|
||||
}
|
||||
|
||||
// 檢視:只讀模式並預設展開所有分組
|
||||
function openViewDialog (student: StudentRecord) {
|
||||
function openViewDialog(student: StudentRecord) {
|
||||
loadSequence.value += 1
|
||||
const sequence = loadSequence.value
|
||||
dialogMode.value = 'view'
|
||||
@@ -560,7 +733,7 @@ function openViewDialog (student: StudentRecord) {
|
||||
}
|
||||
|
||||
// 先檢核再提示儲存確認
|
||||
async function requestSaveConfirmation () {
|
||||
async function requestSaveConfirmation() {
|
||||
if (isSaving.value || isLoading.value || !isDirty.value || isViewMode.value) return
|
||||
clearAllErrors()
|
||||
|
||||
@@ -579,13 +752,13 @@ async function requestSaveConfirmation () {
|
||||
}
|
||||
|
||||
// 儲存確認後才真正送出
|
||||
function confirmSave () {
|
||||
function confirmSave() {
|
||||
confirmSaveVisible.value = false
|
||||
saveStudent()
|
||||
}
|
||||
|
||||
// 寫入資料(Demo:直接更新列表)
|
||||
async function saveStudent () {
|
||||
async function saveStudent() {
|
||||
if (isSaving.value || isLoading.value) return
|
||||
isSaving.value = true
|
||||
await new Promise((resolve) => setTimeout(resolve, 450))
|
||||
@@ -620,23 +793,23 @@ async function saveStudent () {
|
||||
}, 1600)
|
||||
}
|
||||
|
||||
function scrollToField (field: string) {
|
||||
function scrollToField(field: string) {
|
||||
const target = document.getElementById(`field-${field}`)
|
||||
if (!target) return
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
|
||||
function handleSemesterSelect (id: number) {
|
||||
function handleSemesterSelect(id: number) {
|
||||
if (isMobile.value) {
|
||||
selectedSemesterId.value = id
|
||||
activeMobilePanel.value = 'detail'
|
||||
return
|
||||
}
|
||||
|
||||
selectedSemesterId.value = selectedSemesterId.value === id ? null : id;
|
||||
selectedSemesterId.value = selectedSemesterId.value === id ? null : id
|
||||
}
|
||||
|
||||
function closeDetailPanel () {
|
||||
function closeDetailPanel() {
|
||||
isDetailEditing.value = false
|
||||
detailForm.value = null
|
||||
if (isMobile.value) {
|
||||
@@ -668,7 +841,7 @@ function closeDetailPanel () {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.dialog-panel>.v-card {
|
||||
.dialog-panel > .v-card {
|
||||
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
@@ -727,7 +900,7 @@ function closeDetailPanel () {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dialog-panel>.v-card {
|
||||
.dialog-panel > .v-card {
|
||||
width: 100%;
|
||||
box-shadow: none;
|
||||
}
|
||||
@@ -737,8 +910,6 @@ function closeDetailPanel () {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
.dialog-actions {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
|
||||
@@ -1,31 +1,77 @@
|
||||
<template>
|
||||
<mnt-page-cards
|
||||
:search-panel-open="searchPanelOpen" :title="`主從資料維護示範B`"
|
||||
@create="openAddDialog" @toggle-search="searchPanelOpen = !searchPanelOpen">
|
||||
:search-panel-open="searchPanelOpen"
|
||||
:title="`主從資料維護示範B`"
|
||||
@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" />
|
||||
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-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-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" />
|
||||
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-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>
|
||||
@@ -34,9 +80,17 @@ id="search-grade" v-model="search.grade" aria-labelledby="search-grade-label" de
|
||||
</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">
|
||||
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>
|
||||
@@ -47,15 +101,31 @@ v-model:page="currentPage" class="student-table" density="compact" fixed-header
|
||||
</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
|
||||
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
|
||||
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)">
|
||||
color="error"
|
||||
:prepend-icon="mdiDelete"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="requestDeleteConfirmation(item)"
|
||||
>
|
||||
刪除
|
||||
</v-btn>
|
||||
</div>
|
||||
@@ -66,11 +136,35 @@ color="error" :prepend-icon="mdiDelete" size="small" variant="text"
|
||||
{{ 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>
|
||||
<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>
|
||||
<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>
|
||||
@@ -83,50 +177,96 @@ color="error" :prepend-icon="mdiDelete" size="small" variant="text"
|
||||
<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">
|
||||
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" />
|
||||
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%">
|
||||
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" />
|
||||
: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">
|
||||
<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)">
|
||||
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%" />
|
||||
<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">
|
||||
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"
|
||||
@@ -142,37 +282,77 @@ v-else :class="[
|
||||
<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" />
|
||||
: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">
|
||||
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-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>
|
||||
<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-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-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>
|
||||
@@ -206,9 +386,13 @@ v-if="!isViewMode" color="primary" :disabled="!isDirty || isLoading" :loading="i
|
||||
|
||||
<!-- 刪除課程確認 -->
|
||||
<common-confirm-dialog
|
||||
v-model="confirmDeleteCourseVisible" confirm-color="error"
|
||||
confirm-text="確定移除" :message="`確定要移除「${pendingDeleteCourseName}」嗎?`" :title="`刪除課程`"
|
||||
@confirm="confirmDeleteCourse" />
|
||||
v-model="confirmDeleteCourseVisible"
|
||||
confirm-color="error"
|
||||
confirm-text="確定移除"
|
||||
:message="`確定要移除「${pendingDeleteCourseName}」嗎?`"
|
||||
:title="`刪除課程`"
|
||||
@confirm="confirmDeleteCourse"
|
||||
/>
|
||||
|
||||
<!-- 加入課程對話框 -->
|
||||
<v-dialog v-model="addCourseDialogVisible" max-width="420" persistent>
|
||||
@@ -219,25 +403,47 @@ v-model="confirmDeleteCourseVisible" confirm-color="error"
|
||||
</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-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-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-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-btn
|
||||
color="primary"
|
||||
:disabled="!addCourseForm.name"
|
||||
variant="flat"
|
||||
@click="confirmAddCourse"
|
||||
>加入</v-btn
|
||||
>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
@@ -283,8 +489,20 @@ 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: '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 },
|
||||
@@ -293,7 +511,14 @@ const tableHeaders = [
|
||||
{ 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' } },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
sortable: false,
|
||||
fixed: smAndUp.value && ('end' as const),
|
||||
width: 'auto',
|
||||
cellProps: { class: 'px-0 bg-background' },
|
||||
},
|
||||
]
|
||||
|
||||
// 查詢條件(示意用,未接 API)
|
||||
@@ -340,8 +565,8 @@ const loadSequence = ref(0)
|
||||
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 selectedSemester = computed(
|
||||
() => studentSemesters.value.find((semester) => semester.id === selectedSemesterId.value) ?? null
|
||||
)
|
||||
|
||||
// 刪除課程確認狀態
|
||||
@@ -355,30 +580,46 @@ 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,
|
||||
資料結構: 3,
|
||||
演算法: 3,
|
||||
作業系統: 3,
|
||||
計算機組織: 3,
|
||||
線性代數: 3,
|
||||
機率與統計: 3,
|
||||
資料庫系統: 3,
|
||||
人工智慧導論: 3,
|
||||
網頁程式設計: 3,
|
||||
計算機網路: 3,
|
||||
}
|
||||
|
||||
// 輔助函式:重新載入當前學生的學期資料
|
||||
function refreshSemesters () {
|
||||
function refreshSemesters() {
|
||||
if (editingId.value) {
|
||||
studentSemesters.value = semesterStore.getStudentSemesters(editingId.value)
|
||||
}
|
||||
}
|
||||
|
||||
function handleSemesterSelect (semesterId: number) {
|
||||
function handleSemesterSelect(semesterId: number) {
|
||||
selectedSemesterId.value = semesterId
|
||||
if (isMobile.value) {
|
||||
activeMobilePanel.value = 'detail'
|
||||
}
|
||||
}
|
||||
|
||||
function closeDetailPanel () {
|
||||
function closeDetailPanel() {
|
||||
if (isMobile.value) {
|
||||
activeMobilePanel.value = 'master'
|
||||
return
|
||||
@@ -386,21 +627,19 @@ function closeDetailPanel () {
|
||||
selectedSemesterId.value = null
|
||||
}
|
||||
|
||||
function handleUpdateSemester (semesterId: number, payload: Partial<SemesterRecord>) {
|
||||
function handleUpdateSemester(semesterId: number, payload: Partial<SemesterRecord>) {
|
||||
semesterStore.updateSemester(semesterId, payload)
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 請求刪除課程(開啟確認對話框)
|
||||
function requestDeleteCourse (semesterId: number, courseIndex: number, courseName: string) {
|
||||
function requestDeleteCourse(semesterId: number, courseIndex: number, courseName: string) {
|
||||
pendingDeleteCourseKey.value = { semesterId, courseIndex }
|
||||
pendingDeleteCourseName.value = courseName
|
||||
confirmDeleteCourseVisible.value = true
|
||||
}
|
||||
|
||||
// 確認刪除課程
|
||||
function confirmDeleteCourse () {
|
||||
function confirmDeleteCourse() {
|
||||
if (!pendingDeleteCourseKey.value) return
|
||||
const { semesterId, courseIndex } = pendingDeleteCourseKey.value
|
||||
const semester = studentSemesters.value.find((item) => item.id === semesterId)
|
||||
@@ -415,14 +654,14 @@ function confirmDeleteCourse () {
|
||||
}
|
||||
|
||||
// 開啟加入課程對話框
|
||||
function openAddCourseDialog (semesterId: number) {
|
||||
function openAddCourseDialog(semesterId: number) {
|
||||
addCourseTargetSemesterId.value = semesterId
|
||||
addCourseForm.value = { name: '', credits: 3, score: 0 }
|
||||
addCourseDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 選擇課程名稱時自動帶入預設學分
|
||||
function handleAddCourseNameSelect (name: string) {
|
||||
function handleAddCourseNameSelect(name: string) {
|
||||
const credits = creditsMap[name]
|
||||
if (credits !== undefined) {
|
||||
addCourseForm.value.credits = credits
|
||||
@@ -430,9 +669,11 @@ function handleAddCourseNameSelect (name: string) {
|
||||
}
|
||||
|
||||
// 確認加入課程
|
||||
function confirmAddCourse () {
|
||||
function confirmAddCourse() {
|
||||
if (!addCourseTargetSemesterId.value || !addCourseForm.value.name) return
|
||||
const semester = studentSemesters.value.find((item) => item.id === addCourseTargetSemesterId.value)
|
||||
const semester = studentSemesters.value.find(
|
||||
(item) => item.id === addCourseTargetSemesterId.value
|
||||
)
|
||||
if (!semester) return
|
||||
semesterStore.updateSemester(addCourseTargetSemesterId.value, {
|
||||
courses: [
|
||||
@@ -449,16 +690,19 @@ function confirmAddCourse () {
|
||||
addCourseDialogVisible.value = false
|
||||
}
|
||||
|
||||
function handleUpdateCourse (semesterId: number, courseIndex: number, payload: Partial<CourseRecord>) {
|
||||
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,
|
||||
idx === courseIndex ? { ...course, ...payload } : course
|
||||
)
|
||||
semesterStore.updateSemester(semesterId, { courses: nextCourses })
|
||||
}
|
||||
|
||||
|
||||
const {
|
||||
errorSummary,
|
||||
fieldErrors,
|
||||
@@ -547,7 +791,7 @@ const {
|
||||
})
|
||||
const isFormReadonly = computed(() => isViewMode.value)
|
||||
// 重設查詢條件
|
||||
function resetSearch () {
|
||||
function resetSearch() {
|
||||
search.value = {
|
||||
studentId: '',
|
||||
name: '',
|
||||
@@ -564,7 +808,7 @@ watch(pageCount, (value) => {
|
||||
})
|
||||
|
||||
// 新增:開啟彈窗,使用預設值
|
||||
function openAddDialog () {
|
||||
function openAddDialog() {
|
||||
loadSequence.value += 1
|
||||
dialogMode.value = 'create'
|
||||
editingId.value = null
|
||||
@@ -577,7 +821,7 @@ function openAddDialog () {
|
||||
}
|
||||
|
||||
// 編輯:先開彈窗,資料載入後填入
|
||||
function openEditDialog (student: StudentRecord) {
|
||||
function openEditDialog(student: StudentRecord) {
|
||||
loadSequence.value += 1
|
||||
const sequence = loadSequence.value
|
||||
dialogMode.value = 'edit'
|
||||
@@ -608,7 +852,7 @@ function openEditDialog (student: StudentRecord) {
|
||||
}
|
||||
|
||||
// 檢視:只讀模式並預設展開所有分組
|
||||
function openViewDialog (student: StudentRecord) {
|
||||
function openViewDialog(student: StudentRecord) {
|
||||
loadSequence.value += 1
|
||||
const sequence = loadSequence.value
|
||||
dialogMode.value = 'view'
|
||||
@@ -639,7 +883,7 @@ function openViewDialog (student: StudentRecord) {
|
||||
}
|
||||
|
||||
// 先檢核再提示儲存確認
|
||||
async function requestSaveConfirmation () {
|
||||
async function requestSaveConfirmation() {
|
||||
if (isSaving.value || isLoading.value || !isDirty.value || isViewMode.value) return
|
||||
clearAllErrors()
|
||||
|
||||
@@ -658,13 +902,13 @@ async function requestSaveConfirmation () {
|
||||
}
|
||||
|
||||
// 儲存確認後才真正送出
|
||||
function confirmSave () {
|
||||
function confirmSave() {
|
||||
confirmSaveVisible.value = false
|
||||
saveStudent()
|
||||
}
|
||||
|
||||
// 寫入資料(Demo:直接更新列表)
|
||||
async function saveStudent () {
|
||||
async function saveStudent() {
|
||||
if (isSaving.value || isLoading.value) return
|
||||
isSaving.value = true
|
||||
await new Promise((resolve) => setTimeout(resolve, 450))
|
||||
@@ -699,7 +943,7 @@ async function saveStudent () {
|
||||
}, 1600)
|
||||
}
|
||||
|
||||
function scrollToField (field: string) {
|
||||
function scrollToField(field: string) {
|
||||
const target = document.getElementById(`field-${field}`)
|
||||
if (!target) return
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
@@ -737,7 +981,7 @@ function scrollToField (field: string) {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.dialog-panel>.v-card {
|
||||
.dialog-panel > .v-card {
|
||||
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,31 +1,77 @@
|
||||
<template>
|
||||
<mnt-page-cards
|
||||
:search-panel-open="searchPanelOpen" :title="`主從資料維護示範C`"
|
||||
@create="openAddDialog" @toggle-search="searchPanelOpen = !searchPanelOpen">
|
||||
:search-panel-open="searchPanelOpen"
|
||||
:title="`主從資料維護示範C`"
|
||||
@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" />
|
||||
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-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-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" />
|
||||
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-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>
|
||||
@@ -34,9 +80,17 @@ id="search-grade" v-model="search.grade" aria-labelledby="search-grade-label" de
|
||||
</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">
|
||||
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>
|
||||
@@ -47,15 +101,31 @@ v-model:page="currentPage" class="student-table" density="compact" fixed-header
|
||||
</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
|
||||
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
|
||||
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)">
|
||||
color="error"
|
||||
:prepend-icon="mdiDelete"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="requestDeleteConfirmation(item)"
|
||||
>
|
||||
刪除
|
||||
</v-btn>
|
||||
</div>
|
||||
@@ -66,11 +136,35 @@ color="error" :prepend-icon="mdiDelete" size="small" variant="text"
|
||||
{{ 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>
|
||||
<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>
|
||||
<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>
|
||||
@@ -83,50 +177,95 @@ color="error" :prepend-icon="mdiDelete" size="small" variant="text"
|
||||
<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">
|
||||
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" />
|
||||
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%">
|
||||
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" />
|
||||
: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">
|
||||
<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)">
|
||||
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%" />
|
||||
<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">
|
||||
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"
|
||||
@@ -141,37 +280,77 @@ v-else :class="[
|
||||
|
||||
<v-divider />
|
||||
<master-detail-c-course-section
|
||||
: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" />
|
||||
: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">
|
||||
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-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>
|
||||
<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-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-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>
|
||||
@@ -217,22 +396,46 @@ v-if="!isViewMode" color="primary" :disabled="!isDirty || isLoading" :loading="i
|
||||
</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-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-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-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-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-btn color="primary" :disabled="!canAddCourse" variant="flat" @click="confirmAddCourse"
|
||||
>新增</v-btn
|
||||
>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
@@ -272,8 +475,20 @@ 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: '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 },
|
||||
@@ -282,7 +497,14 @@ const tableHeaders = [
|
||||
{ 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' } },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
sortable: false,
|
||||
fixed: smAndUp.value && ('end' as const),
|
||||
width: 'auto',
|
||||
cellProps: { class: 'px-0 bg-background' },
|
||||
},
|
||||
]
|
||||
|
||||
// 查詢條件(示意用,未接 API)
|
||||
@@ -330,7 +552,7 @@ 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,
|
||||
() => studentSemesters.value.find((item) => item.id === selectedSemesterId.value) ?? null
|
||||
)
|
||||
|
||||
// 新增成績對話框狀態
|
||||
@@ -361,7 +583,7 @@ const semesterOptions = computed(() =>
|
||||
studentSemesters.value.map((sem) => ({
|
||||
value: sem.id,
|
||||
label: sem.semesterName,
|
||||
})),
|
||||
}))
|
||||
)
|
||||
|
||||
// 是否可以新增
|
||||
@@ -369,11 +591,11 @@ const canAddCourse = computed(
|
||||
() =>
|
||||
addCourseForm.value.semesterId !== null &&
|
||||
addCourseForm.value.courseName !== '' &&
|
||||
addCourseForm.value.credits > 0,
|
||||
addCourseForm.value.credits > 0
|
||||
)
|
||||
|
||||
// 開啟新增成績對話框
|
||||
function openAddCourseDialog (semesterId?: number) {
|
||||
function openAddCourseDialog(semesterId?: number) {
|
||||
addCourseForm.value = {
|
||||
semesterId: semesterId ?? selectedSemesterId.value ?? studentSemesters.value[0]?.id ?? null,
|
||||
courseName: '',
|
||||
@@ -384,7 +606,7 @@ function openAddCourseDialog (semesterId?: number) {
|
||||
}
|
||||
|
||||
// 選擇課程時自動帶入學分
|
||||
function handleCourseSelect (courseName: string) {
|
||||
function handleCourseSelect(courseName: string) {
|
||||
const creditsMap: Record<string, number> = {
|
||||
資料結構: 3,
|
||||
演算法: 3,
|
||||
@@ -404,12 +626,10 @@ function handleCourseSelect (courseName: string) {
|
||||
}
|
||||
|
||||
// 確認新增成績
|
||||
function confirmAddCourse () {
|
||||
function confirmAddCourse() {
|
||||
if (!addCourseForm.value.semesterId || !addCourseForm.value.courseName) return
|
||||
|
||||
const semester = studentSemesters.value.find(
|
||||
(sem) => sem.id === addCourseForm.value.semesterId,
|
||||
)
|
||||
const semester = studentSemesters.value.find((sem) => sem.id === addCourseForm.value.semesterId)
|
||||
if (!semester) return
|
||||
|
||||
semesterStore.updateSemester(addCourseForm.value.semesterId, {
|
||||
@@ -429,20 +649,20 @@ function confirmAddCourse () {
|
||||
}
|
||||
|
||||
// 輔助函式:重新載入當前學生的學期資料
|
||||
function refreshSemesters () {
|
||||
function refreshSemesters() {
|
||||
if (editingId.value) {
|
||||
studentSemesters.value = semesterStore.getStudentSemesters(editingId.value)
|
||||
}
|
||||
}
|
||||
|
||||
function handleSemesterSelect (semesterId: number) {
|
||||
function handleSemesterSelect(semesterId: number) {
|
||||
selectedSemesterId.value = semesterId
|
||||
if (isMobile.value) {
|
||||
activeMobilePanel.value = 'detail'
|
||||
}
|
||||
}
|
||||
|
||||
function closeDetailPanel () {
|
||||
function closeDetailPanel() {
|
||||
if (isMobile.value) {
|
||||
activeMobilePanel.value = 'master'
|
||||
return
|
||||
@@ -450,16 +670,20 @@ function closeDetailPanel () {
|
||||
selectedSemesterId.value = null
|
||||
}
|
||||
|
||||
function handleUpdateCourse (semesterId: number, courseIndex: number, payload: Partial<CourseRecord>) {
|
||||
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,
|
||||
idx === courseIndex ? { ...course, ...payload } : course
|
||||
)
|
||||
semesterStore.updateSemester(semesterId, { courses: nextCourses })
|
||||
}
|
||||
|
||||
function removeCourseFromSemester (semesterId: number, courseIndex: number) {
|
||||
function removeCourseFromSemester(semesterId: number, courseIndex: number) {
|
||||
const semester = studentSemesters.value.find((item) => item.id === semesterId)
|
||||
if (!semester) return
|
||||
semesterStore.updateSemester(semesterId, {
|
||||
@@ -467,7 +691,6 @@ function removeCourseFromSemester (semesterId: number, courseIndex: number) {
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
const {
|
||||
errorSummary,
|
||||
fieldErrors,
|
||||
@@ -556,7 +779,7 @@ const {
|
||||
})
|
||||
const isFormReadonly = computed(() => isViewMode.value)
|
||||
// 重設查詢條件
|
||||
function resetSearch () {
|
||||
function resetSearch() {
|
||||
search.value = {
|
||||
studentId: '',
|
||||
name: '',
|
||||
@@ -573,7 +796,7 @@ watch(pageCount, (value) => {
|
||||
})
|
||||
|
||||
// 新增:開啟彈窗,使用預設值
|
||||
function openAddDialog () {
|
||||
function openAddDialog() {
|
||||
loadSequence.value += 1
|
||||
dialogMode.value = 'create'
|
||||
editingId.value = null
|
||||
@@ -586,7 +809,7 @@ function openAddDialog () {
|
||||
}
|
||||
|
||||
// 編輯:先開彈窗,資料載入後填入
|
||||
function openEditDialog (student: StudentRecord) {
|
||||
function openEditDialog(student: StudentRecord) {
|
||||
loadSequence.value += 1
|
||||
const sequence = loadSequence.value
|
||||
dialogMode.value = 'edit'
|
||||
@@ -617,7 +840,7 @@ function openEditDialog (student: StudentRecord) {
|
||||
}
|
||||
|
||||
// 檢視:只讀模式並預設展開所有分組
|
||||
function openViewDialog (student: StudentRecord) {
|
||||
function openViewDialog(student: StudentRecord) {
|
||||
loadSequence.value += 1
|
||||
const sequence = loadSequence.value
|
||||
dialogMode.value = 'view'
|
||||
@@ -648,7 +871,7 @@ function openViewDialog (student: StudentRecord) {
|
||||
}
|
||||
|
||||
// 先檢核再提示儲存確認
|
||||
async function requestSaveConfirmation () {
|
||||
async function requestSaveConfirmation() {
|
||||
if (isSaving.value || isLoading.value || !isDirty.value || isViewMode.value) return
|
||||
clearAllErrors()
|
||||
|
||||
@@ -667,13 +890,13 @@ async function requestSaveConfirmation () {
|
||||
}
|
||||
|
||||
// 儲存確認後才真正送出
|
||||
function confirmSave () {
|
||||
function confirmSave() {
|
||||
confirmSaveVisible.value = false
|
||||
saveStudent()
|
||||
}
|
||||
|
||||
// 寫入資料(Demo:直接更新列表)
|
||||
async function saveStudent () {
|
||||
async function saveStudent() {
|
||||
if (isSaving.value || isLoading.value) return
|
||||
isSaving.value = true
|
||||
await new Promise((resolve) => setTimeout(resolve, 450))
|
||||
@@ -708,7 +931,7 @@ async function saveStudent () {
|
||||
}, 1600)
|
||||
}
|
||||
|
||||
function scrollToField (field: string) {
|
||||
function scrollToField(field: string) {
|
||||
const target = document.getElementById(`field-${field}`)
|
||||
if (!target) return
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
@@ -739,7 +962,7 @@ function scrollToField (field: string) {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.dialog-panel>.v-card {
|
||||
.dialog-panel > .v-card {
|
||||
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user