Files
skt-vuetify-templates/src/components/maint/EditableGrid.vue
T
skytek_xinliang 2b780a12c2 docs: document template naming and maintenance refactor
Update agent and LLM guidance to reference the architecture strategy and
add a template naming rule that keeps reusable abstractions domain-neutral.

Mark maintenance Phase 3 as complete and document the page driver/page
component refactors for EditableGrid and MasterDetail variants.docs: document template naming and maintenance refactor

Update agent and LLM guidance to reference the architecture strategy and
add a template naming rule that keeps reusable abstractions domain-neutral.

Mark maintenance Phase 3 as complete and document the page driver/page
component refactors for EditableGrid and MasterDetail variants.
2026-05-19 14:35:28 +08:00

501 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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">{{ title }}</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
v-model:page="currentPage"
class="student-table"
density="comfortable"
fixed-header
:headers="tableHeaders"
:height="tableHeight"
hide-default-footer
item-value="id"
:items="filteredStudents"
:items-per-page="itemsPerPage"
>
<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>
<template #bottom>
<div class="d-flex align-center justify-space-between px-4 py-3">
<div class="text-body-2 text-medium-emphasis">
{{ 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>
<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>
</div>
</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, watch } from 'vue'
import ConfirmDialog from '@/components/maint/CommonConfirmDialog.vue'
import { useEditableStudentGrid } from '@/composables/maint/useEditableStudentGrid'
withDefaults(
defineProps<{
title?: string
}>(),
{
title: '可編輯表格維護示範',
}
)
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 itemsPerPage = 10
const currentPage = ref(1)
const pageCount = computed(() => Math.max(1, Math.ceil(filteredStudents.value.length / itemsPerPage)))
const pageSummary = computed(() => {
const total = filteredStudents.value.length
if (total === 0) return '第 0-0 筆 / 共 0 筆'
const start = (currentPage.value - 1) * itemsPerPage + 1
const end = Math.min(currentPage.value * itemsPerPage, total)
return `${start}-${end} 筆 / 共 ${total}`
})
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} 筆資料嗎?此操作會在儲存後正式生效。`
)
watch(pageCount, (value) => {
if (currentPage.value > value) {
currentPage.value = value
}
})
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;
}
</style>