feat: Implement editable student grid and refactor maintenance dialogs
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user