Refactor MasterDetailMntC.vue for improved readability and consistency

This commit is contained in:
skytek_xinliang
2026-03-30 09:18:55 +08:00
parent 7591ecd062
commit 16b58fbf7a
66 changed files with 2071 additions and 777 deletions
+246 -75
View File
@@ -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;
+342 -98
View File
@@ -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);
}
+315 -92
View File
@@ -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);
}