2b780a12c2
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.
501 lines
16 KiB
Vue
501 lines
16 KiB
Vue
<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>
|