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.
This commit is contained in:
@@ -0,0 +1,407 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user