Files
skt-vuetify-templates/src/composables/page-drivers/useMasterDetailAMaintenancePage.ts
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

408 lines
14 KiB
TypeScript

import { computed, nextTick, ref, watch } from 'vue'
import { useDisplay } from 'vuetify'
import { useMaintenanceCrudFlow } from '@/composables/maint/useMaintenanceCrudFlow'
import {
type StudentFormState,
useStudentMaintenanceForm,
} from '@/composables/maint/useStudentMaintenanceForm'
import type { MaintenancePageModel } from '@/models/page'
import { type SemesterRecord, useSemesterStore } from '@/stores/semesters'
import { type StudentRecord, useStudentStore } from '@/stores/students'
const departments = ['資訊工程', '企業管理', '應用外語', '視覺設計', '財務金融']
const gradeOptions = [
{ title: '大一', value: 1 },
{ title: '大二', value: 2 },
{ title: '大三', value: 3 },
{ title: '大四', value: 4 },
{ title: '研究所', value: 5 },
]
const enrollYears = [2024, 2023, 2022, 2021, 2020, 2019]
const statuses = ['在學', '休學', '畢業']
const itemsPerPage = 10
type StudentPayload = Omit<StudentRecord, 'id'>
function toFormPayload(student: StudentRecord): StudentFormState {
return {
studentId: student.studentId,
name: student.name,
department: student.department,
grade: student.grade,
enrollYear: student.enrollYear,
credits: student.credits,
advisor: student.advisor,
email: student.email,
phone: student.phone,
status: student.status,
}
}
function toSavePayload(form: StudentFormState): StudentPayload {
return {
studentId: form.studentId.trim(),
name: form.name.trim(),
department: form.department,
grade: form.grade,
enrollYear: form.enrollYear,
credits: form.credits,
advisor: form.advisor.trim(),
email: form.email.trim(),
phone: form.phone.trim(),
status: form.status,
}
}
export function useMasterDetailAMaintenancePage() {
const studentStore = useStudentStore()
const semesterStore = useSemesterStore()
const students = computed(() => studentStore.students)
const { smAndUp } = useDisplay()
const isMobile = computed(() => !smAndUp.value)
const pageModel = computed<MaintenancePageModel>(() => ({
type: 'maintenance',
title: '主從資料維護示範A',
records: students.value,
loading: false,
error: null,
}))
const search = ref({ studentId: '', name: '', department: '', grade: null as number | null, status: '' })
const searchPanelOpen = ref(false)
const currentPage = ref(1)
const pageCount = computed(() => Math.max(1, Math.ceil(students.value.length / itemsPerPage)))
const pageSummary = computed(() => {
const total = students.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 dialogVisible = ref(false)
const editingId = ref<number | null>(null)
const dialogMode = ref<'create' | 'edit' | 'view'>('create')
const isLoading = ref(false)
const isSaving = ref(false)
const snackbarVisible = ref(false)
const highlightedId = ref<number | null>(null)
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 isDetailEditing = ref(false)
const detailForm = ref<SemesterRecord | null>(null)
const formState = useStudentMaintenanceForm({
departments,
gradeOptions,
enrollYears,
statuses,
students,
editingId,
highlightedId,
})
const isFormLocked = computed(() => isLoading.value || isSaving.value)
const tableHeaders = computed(() => [
{ 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 },
{ title: '已修學分', key: 'credits', sortable: true, width: 110 },
{ title: 'Email', key: 'email', sortable: true, width: 200 },
{ 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' } },
])
function resetDetailState() {
selectedSemesterId.value = null
activeMobilePanel.value = 'master'
isDetailEditing.value = false
detailForm.value = null
}
function refreshSemesters() {
if (editingId.value) {
studentSemesters.value = semesterStore.getStudentSemesters(editingId.value)
}
}
function handleAddSemester() {
if (!editingId.value) return
const newSemester = semesterStore.addSemester(editingId.value)
refreshSemesters()
selectedSemesterId.value = newSemester.id
activeMobilePanel.value = 'detail'
startDetailEdit()
}
function handleDeleteSemester(id: number) {
if (!confirm('確定要刪除此學期紀錄嗎?')) return
semesterStore.removeSemester(id)
refreshSemesters()
if (selectedSemesterId.value === id) {
resetDetailState()
}
}
function startDetailEdit() {
if (!selectedSemester.value) return
detailForm.value = structuredClone(selectedSemester.value)
isDetailEditing.value = true
}
function cancelDetailEdit() {
isDetailEditing.value = false
detailForm.value = null
if (isMobile.value && selectedSemesterId.value === null) {
activeMobilePanel.value = 'master'
}
}
function saveDetailEdit() {
if (!detailForm.value?.id) return
semesterStore.updateSemester(detailForm.value.id, detailForm.value)
refreshSemesters()
isDetailEditing.value = false
detailForm.value = null
}
function openAddDialog() {
loadSequence.value += 1
dialogMode.value = 'create'
editingId.value = null
studentSemesters.value = []
resetDetailState()
formState.resetForm()
isLoading.value = false
dialogVisible.value = true
}
function loadRecord(student: StudentRecord, mode: 'edit' | 'view') {
loadSequence.value += 1
const sequence = loadSequence.value
dialogMode.value = mode
editingId.value = student.id
studentSemesters.value = semesterStore.getStudentSemesters(student.id)
resetDetailState()
dialogVisible.value = true
isLoading.value = true
formState.clearAllErrors()
window.setTimeout(() => {
if (sequence !== loadSequence.value || !dialogVisible.value) return
formState.setForm(toFormPayload(student))
formState.syncInitialForm()
isLoading.value = false
}, 350)
}
function openEditDialog(student: StudentRecord) {
loadRecord(student, 'edit')
}
function openViewDialog(student: StudentRecord) {
loadRecord(student, 'view')
}
const flow = useMaintenanceCrudFlow<StudentRecord>({
records: students,
editingId,
dialogMode,
dialogVisible,
isLoading,
isSaving,
isDirty: formState.isDirty,
clearAllErrors: formState.clearAllErrors,
resetForm: formState.resetForm,
openEditDialog,
openViewDialog,
removeRecord: (id) => {
studentStore.removeStudent(id)
semesterStore.removeByStudentId(id)
},
describeRecord: (student) => `${student.studentId} ${student.name}`,
onCloseReset: resetDetailState,
})
const dialogTitle = computed(() => {
if (dialogMode.value === 'view') return '檢視主檔資料示範'
if (dialogMode.value === 'edit') return '修改主檔資料示範'
return '新增主檔資料示範'
})
const dialogSubtitle = computed(() => {
if (!editingId.value) return ''
return `${formState.form.value.studentId || '未填學號'}${formState.form.value.name || '未填姓名'}`
})
watch(pageCount, (value) => {
if (currentPage.value > value) currentPage.value = value
})
function resetSearch() {
search.value = { studentId: '', name: '', department: '', grade: null, status: '' }
}
async function requestSaveConfirmation() {
if (isSaving.value || isLoading.value || !formState.isDirty.value || flow.isViewMode.value) return
formState.clearAllErrors()
const errors = formState.validateForm()
if (errors.length > 0) {
for (const error of errors) {
formState.fieldErrors.value[error.field] = [error.message]
}
await nextTick()
const firstError = errors[0]
if (firstError) scrollToField(firstError.field)
return
}
flow.confirmSaveVisible.value = true
}
async function confirmSave() {
flow.confirmSaveVisible.value = false
await saveStudent()
}
async function saveStudent() {
if (isSaving.value || isLoading.value) return
isSaving.value = true
await new Promise((resolve) => window.setTimeout(resolve, 450))
const payload = toSavePayload(formState.form.value)
if (editingId.value) {
const updated = studentStore.updateStudent(editingId.value, payload)
if (updated) highlightedId.value = editingId.value
} else {
const createdId = studentStore.addStudent(payload)
semesterStore.generateForStudent(createdId)
highlightedId.value = createdId
}
formState.syncInitialForm()
dialogVisible.value = false
snackbarVisible.value = true
isSaving.value = false
window.setTimeout(() => {
highlightedId.value = null
}, 1600)
}
function scrollToField(field: string) {
const target = document.getElementById(`field-${field}`)
if (!target) return
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
function handleSemesterSelect(id: number) {
if (isMobile.value) {
selectedSemesterId.value = id
activeMobilePanel.value = 'detail'
return
}
selectedSemesterId.value = selectedSemesterId.value === id ? null : id
}
function closeDetailPanel() {
isDetailEditing.value = false
detailForm.value = null
if (isMobile.value) {
activeMobilePanel.value = 'master'
return
}
selectedSemesterId.value = null
}
const masterDetailProps = computed(() => ({
activeMobilePanel: activeMobilePanel.value,
confirmCloseVisible: flow.confirmCloseVisible.value,
confirmDeleteVisible: flow.confirmDeleteVisible.value,
confirmNavigateVisible: flow.confirmNavigateVisible.value,
confirmSaveVisible: flow.confirmSaveVisible.value,
confirmSwitchVisible: flow.confirmSwitchVisible.value,
departments,
detailForm: detailForm.value,
dialogSubtitle: dialogSubtitle.value,
dialogTitle: dialogTitle.value,
dialogVisible: dialogVisible.value,
enrollYears,
errorSummary: formState.errorSummary.value,
fieldErrors: formState.fieldErrors.value,
form: formState.form.value,
gradeOptions,
hasNextRecord: flow.hasNextRecord.value,
hasPrevRecord: flow.hasPrevRecord.value,
isDetailEditing: isDetailEditing.value,
isDirty: formState.isDirty.value,
isEditMode: flow.isEditMode.value,
isFormLocked: isFormLocked.value,
isFormReadonly: flow.isViewMode.value,
isLoading: isLoading.value,
isMobile: isMobile.value,
isSaving: isSaving.value,
isViewMode: flow.isViewMode.value,
pendingDeleteLabel: flow.pendingDeleteLabel.value,
saveSummary: formState.saveSummary.value,
selectedSemester: selectedSemester.value,
selectedSemesterId: selectedSemesterId.value,
semesters: studentSemesters.value,
statuses,
}))
const masterDetailEvents = {
'add-semester': handleAddSemester,
'cancel-detail-edit': cancelDetailEdit,
'clear-field-error': formState.clearFieldError,
close: flow.requestCloseDialog,
'close-detail-panel': closeDetailPanel,
'confirm-close': flow.confirmClose,
'confirm-delete': flow.confirmDelete,
'confirm-navigate': flow.confirmNavigate,
'confirm-save': confirmSave,
'confirm-switch': flow.confirmSwitch,
delete: flow.requestDeleteConfirmation,
'delete-current': flow.requestDeleteCurrent,
'delete-semester': handleDeleteSemester,
'dialog-visible-change': flow.handleDialogVisibility,
first: () => flow.openEdgeRecord('first'),
last: () => flow.openEdgeRecord('last'),
next: () => flow.openAdjacentRecord('next'),
prev: () => flow.openAdjacentRecord('prev'),
save: requestSaveConfirmation,
'save-detail-edit': saveDetailEdit,
'scroll-to-field': scrollToField,
'select-semester': handleSemesterSelect,
'start-detail-edit': startDetailEdit,
'switch-to-edit': flow.switchToEditMode,
'switch-to-view': flow.switchToViewMode,
'update:confirmCloseVisible': (value: boolean) => (flow.confirmCloseVisible.value = value),
'update:confirmDeleteVisible': (value: boolean) => (flow.confirmDeleteVisible.value = value),
'update:confirmNavigateVisible': (value: boolean) => (flow.confirmNavigateVisible.value = value),
'update:confirmSaveVisible': (value: boolean) => (flow.confirmSaveVisible.value = value),
'update:confirmSwitchVisible': (value: boolean) => (flow.confirmSwitchVisible.value = value),
'update:detailForm': (value: SemesterRecord | null) => (detailForm.value = value),
'update:form': (value: StudentFormState) => (formState.form.value = value),
}
return {
currentPage,
departments,
formState,
gradeOptions,
itemsPerPage,
masterDetailEvents,
masterDetailProps,
openAddDialog,
openEditDialog,
openViewDialog,
pageCount,
pageModel,
pageSummary,
resetSearch,
search,
searchPanelOpen,
snackbarVisible,
statuses,
students,
tableHeaders,
}
}