feat: Implement editable student grid and refactor maintenance dialogs

This commit is contained in:
skytek_xinliang
2026-03-30 11:44:04 +08:00
parent 20b093ff73
commit edf664fbb8
9 changed files with 372 additions and 237 deletions
+434
View File
@@ -0,0 +1,434 @@
<template>
<div class="d-flex flex-column">
<v-card variant="flat">
<v-card-title class="d-flex flex-wrap align-center py-0 ga-2">
<span class="text-h6">可編輯表格維護示範</span>
<v-chip :color="hasAnyChange ? 'warning' : 'success'" variant="tonal">
{{ hasAnyChange ? '有未儲存變更' : '已同步' }}
</v-chip>
<v-chip color="info" variant="tonal">筆數 {{ filteredStudents.length }}</v-chip>
<v-spacer />
<v-btn flat :prepend-icon="mdiMagnify" @click="isSearchVisible = !isSearchVisible"
>條件搜尋</v-btn
>
<v-switch v-model="isBulkEditEnabled" color="primary" hide-details label="啟用編輯" />
</v-card-title>
<v-divider />
<v-card-text class="pb-0 pt-2">
<v-row v-if="isSearchVisible" class="mb-2" density="compact">
<v-col cols="12" md="3">
<div class="text-body-1 text-medium-emphasis pl-2">學號</div>
<v-text-field
v-model="search.studentId"
clearable
density="compact"
hide-details
placeholder="例如:S2024001"
variant="outlined"
/>
</v-col>
<v-col cols="12" md="3">
<div class="text-body-1 text-medium-emphasis pl-2">姓名</div>
<v-text-field
v-model="search.name"
clearable
density="compact"
hide-details
placeholder="例如:王小明"
variant="outlined"
/>
</v-col>
<v-col cols="12" md="3">
<div class="text-body-1 text-medium-emphasis pl-2">系所</div>
<v-select
v-model="search.department"
:class="{ 'select-hide-arrow': !isBulkEditEnabled }"
clearable
density="compact"
hide-details
:items="departments"
variant="outlined"
/>
</v-col>
</v-row>
<v-row v-if="isBulkEditEnabled" align="center" class="mb-2 ga-1" density="compact">
<v-btn
:disabled="!hasSelectedRows"
:prepend-icon="mdiDelete"
variant="outlined"
@click="requestDeleteSelectedRows"
>
批次刪除
</v-btn>
<v-spacer />
<v-btn
color="primary"
:disabled="!hasAnyChange"
:prepend-icon="mdiContentSave"
variant="outlined"
@click="requestSaveAllRows"
>
儲存變更
</v-btn>
<v-btn
:disabled="!hasAnyChange"
:prepend-icon="mdiRestore"
variant="text"
@click="resetAllRows"
>
取消變更
</v-btn>
</v-row>
<div ref="tableContainerRef">
<v-data-table
density="comfortable"
fixed-header
:headers="tableHeaders"
:height="tableHeight"
item-value="id"
:items="filteredStudents"
:items-per-page="10"
items-per-page-text="每頁筆數"
page-text=" {0}-{1} / {2} "
>
<template #[`header.select`]>
<v-checkbox-btn
:disabled="!isBulkEditEnabled"
:indeterminate="isPartiallyVisibleSelected"
:model-value="isAllVisibleSelected"
@update:model-value="toggleSelectAll"
/>
</template>
<template #[`item.select`]="{ item }">
<v-checkbox-btn
:disabled="!isBulkEditEnabled"
:model-value="selectedRowIds.includes(item.id)"
@update:model-value="(checked) => toggleSingleRowSelection(item.id, checked)"
/>
</template>
<template #[`item.studentId`]="{ item }">
<v-text-field
:model-value="getDraftRow(item.id)?.studentId ?? ''"
density="compact"
flat
hide-details
:readonly="!isBulkEditEnabled"
:variant="isBulkEditEnabled ? 'outlined' : 'solo'"
@update:model-value="
(value) => {
const row = getDraftRow(item.id)
if (row) row.studentId = String(value)
}
"
/>
</template>
<template #[`item.name`]="{ item }">
<v-text-field
:model-value="getDraftRow(item.id)?.name ?? ''"
density="compact"
flat
hide-details
:readonly="!isBulkEditEnabled"
:variant="isBulkEditEnabled ? 'outlined' : 'solo'"
@update:model-value="
(value) => {
const row = getDraftRow(item.id)
if (row) row.name = String(value)
}
"
/>
</template>
<template #[`item.department`]="{ item }">
<v-select
:model-value="getDraftRow(item.id)?.department ?? ''"
:class="{ 'select-hide-arrow': !isBulkEditEnabled }"
density="compact"
flat
hide-details
:items="departments"
:readonly="!isBulkEditEnabled"
:variant="isBulkEditEnabled ? 'outlined' : 'solo'"
@update:model-value="
(value) => {
const row = getDraftRow(item.id)
if (row) row.department = String(value)
}
"
/>
</template>
<template #[`item.grade`]="{ item }">
<v-select
:model-value="getDraftRow(item.id)?.grade ?? null"
:class="{ 'select-hide-arrow': !isBulkEditEnabled }"
density="compact"
flat
hide-details
item-title="title"
item-value="value"
:items="gradeOptions"
:readonly="!isBulkEditEnabled"
:variant="isBulkEditEnabled ? 'outlined' : 'solo'"
@update:model-value="
(value) => {
const row = getDraftRow(item.id)
if (row) row.grade = Number(value) || 0
}
"
/>
</template>
<template #[`item.enrollYear`]="{ item }">
<v-select
:model-value="getDraftRow(item.id)?.enrollYear ?? null"
:class="{ 'select-hide-arrow': !isBulkEditEnabled }"
density="compact"
flat
hide-details
:items="enrollYears"
:readonly="!isBulkEditEnabled"
:variant="isBulkEditEnabled ? 'outlined' : 'solo'"
@update:model-value="
(value) => {
const row = getDraftRow(item.id)
if (row) row.enrollYear = Number(value) || 0
}
"
/>
</template>
<template #[`item.credits`]="{ item }">
<v-text-field
:model-value="getDraftRow(item.id)?.credits ?? 0"
density="compact"
flat
hide-details
min="0"
:readonly="!isBulkEditEnabled"
type="number"
:variant="isBulkEditEnabled ? 'outlined' : 'solo'"
@update:model-value="
(value) => {
const row = getDraftRow(item.id)
if (row) row.credits = Number(value) || 0
}
"
/>
</template>
<template #[`item.advisor`]="{ item }">
<v-text-field
:model-value="getDraftRow(item.id)?.advisor ?? ''"
density="compact"
flat
hide-details
:readonly="!isBulkEditEnabled"
:variant="isBulkEditEnabled ? 'outlined' : 'solo'"
@update:model-value="
(value) => {
const row = getDraftRow(item.id)
if (row) row.advisor = String(value)
}
"
/>
</template>
<template #[`item.email`]="{ item }">
<v-text-field
:model-value="getDraftRow(item.id)?.email ?? ''"
density="compact"
flat
hide-details
:readonly="!isBulkEditEnabled"
:variant="isBulkEditEnabled ? 'outlined' : 'solo'"
@update:model-value="
(value) => {
const row = getDraftRow(item.id)
if (row) row.email = String(value)
}
"
/>
</template>
<template #[`item.phone`]="{ item }">
<v-text-field
:model-value="getDraftRow(item.id)?.phone ?? ''"
density="compact"
flat
hide-details
:readonly="!isBulkEditEnabled"
:variant="isBulkEditEnabled ? 'outlined' : 'solo'"
@update:model-value="
(value) => {
const row = getDraftRow(item.id)
if (row) row.phone = String(value)
}
"
/>
</template>
<template #[`item.status`]="{ item }">
<v-select
:model-value="getDraftRow(item.id)?.status ?? ''"
:class="{ 'select-hide-arrow': !isBulkEditEnabled }"
density="compact"
flat
hide-details
:items="statuses"
:readonly="!isBulkEditEnabled"
:variant="isBulkEditEnabled ? 'outlined' : 'solo'"
@update:model-value="
(value) => {
const row = getDraftRow(item.id)
if (row) row.status = String(value)
}
"
/>
</template>
<template #[`item.actions`]="{ item }">
<div class="d-flex ga-1 justify-end">
<v-btn
color="error"
:disabled="!isBulkEditEnabled"
size="small"
variant="text"
@click="requestDeleteSingleRow(item.id)"
>
刪除
</v-btn>
</div>
</template>
</v-data-table>
</div>
</v-card-text>
</v-card>
</div>
<ConfirmDialog
v-model="confirmDeleteSingleVisible"
confirm-color="error"
confirm-text="確定刪除"
:message="singleDeleteMessage"
title="確認刪除"
@confirm="confirmDeleteSingleRow"
/>
<ConfirmDialog
v-model="confirmDeleteSelectedVisible"
confirm-color="error"
confirm-text="確定批次刪除"
:message="selectedDeleteMessage"
title="確認批次刪除"
@confirm="confirmDeleteSelectedRows"
/>
<ConfirmDialog
v-model="confirmSaveVisible"
confirm-text="確認儲存"
message="確定要儲存目前所有變更嗎"
title="確認儲存變更"
@confirm="confirmSaveAllRows"
/>
</template>
<script setup lang="ts">
import { mdiContentSave, mdiDelete, mdiMagnify, mdiRestore } from '@mdi/js'
import { computed, ref } from 'vue'
import ConfirmDialog from '@/components/maint/CommonConfirmDialog.vue'
import { useEditableStudentGrid } from '@/composables/maint/useEditableStudentGrid'
const {
departments,
enrollYears,
filteredStudents,
getDraftRow,
gradeOptions,
hasAnyChange,
hasSelectedRows,
isAllVisibleSelected,
isBulkEditEnabled,
isPartiallyVisibleSelected,
isSearchVisible,
saveAllRows,
search,
selectedRowIds,
statuses,
tableContainerRef,
tableHeaders,
tableHeight,
toggleSelectAll,
toggleSingleRowSelection,
deleteSelectedRows,
deleteSingleRow,
resetAllRows,
} = useEditableStudentGrid()
const confirmDeleteSingleVisible = ref(false)
const confirmDeleteSelectedVisible = ref(false)
const confirmSaveVisible = ref(false)
const pendingDeleteRowId = ref<number | null>(null)
const pendingDeleteRow = computed(() =>
pendingDeleteRowId.value === null ? null : getDraftRow(pendingDeleteRowId.value)
)
const singleDeleteMessage = computed(() => {
const row = pendingDeleteRow.value
if (!row) {
return '確定要刪除此筆資料嗎?此操作會在儲存後正式生效。'
}
return `確定要刪除 ${row.name}${row.studentId})嗎?此操作會在儲存後正式生效。`
})
const selectedDeleteMessage = computed(
() =>
`確定要刪除目前選取的 ${selectedRowIds.value.length} 筆資料嗎?此操作會在儲存後正式生效。`
)
function requestDeleteSingleRow(id: number) {
pendingDeleteRowId.value = id
confirmDeleteSingleVisible.value = true
}
function confirmDeleteSingleRow() {
if (pendingDeleteRowId.value === null) return
deleteSingleRow(pendingDeleteRowId.value)
pendingDeleteRowId.value = null
confirmDeleteSingleVisible.value = false
}
function requestDeleteSelectedRows() {
if (!hasSelectedRows.value) return
confirmDeleteSelectedVisible.value = true
}
function confirmDeleteSelectedRows() {
deleteSelectedRows()
confirmDeleteSelectedVisible.value = false
}
function requestSaveAllRows() {
if (!hasAnyChange.value) return
confirmSaveVisible.value = true
}
function confirmSaveAllRows() {
saveAllRows()
confirmSaveVisible.value = false
}
</script>
<style scoped>
:deep(.select-hide-arrow .v-field__append-inner) {
display: none;
}
:deep(.v-table__wrapper .v-field__input) {
padding-top: 0 !important;
padding-bottom: 0 !important;
min-height: 32px;
}
:deep(.v-data-table-footer) {
padding: 4px 0 0;
}
</style>