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:
skytek_xinliang
2026-05-19 14:35:28 +08:00
parent 96b96bcaaa
commit 2b780a12c2
16 changed files with 3319 additions and 3285 deletions
+10 -1
View File
@@ -2,7 +2,7 @@
<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>
<span class="text-h6">{{ title }}</span>
<v-chip :color="hasAnyChange ? 'warning' : 'success'" variant="tonal">
{{ hasAnyChange ? '有未儲存變更' : '已同步' }}
</v-chip>
@@ -377,6 +377,15 @@ 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,
@@ -0,0 +1,12 @@
<script setup lang="ts">
import EditableStudentGrid from '@/components/maint/EditableGrid.vue'
import type { MaintenancePageModel } from '@/models/page'
defineProps<{
page: MaintenancePageModel
}>()
</script>
<template>
<EditableStudentGrid :title="page.title" />
</template>
@@ -0,0 +1,478 @@
<script setup lang="ts">
import ConfirmDialog from '@/components/maint/CommonConfirmDialog.vue'
import DetailNavigation from '@/components/maint/master-detail/DetailNavigation.vue'
import DetailSidePanel from '@/components/maint/master-detail/DetailSidePanel.vue'
import MasterFileFormFields from '@/components/maint/MasterFileFormFields.vue'
import MntDialogCard from '@/components/maint/MntDialogCard.vue'
import MntRecordNavToolbar from '@/components/maint/MntRecordNavToolbar.vue'
import PageMaintenance from '@/components/pages/PageMaintenance.vue'
import SectionDataTable from '@/components/sections/SectionDataTable.vue'
import SectionSearchPanel from '@/components/sections/SectionSearchPanel.vue'
import type {
SaveSummaryItem,
StudentFormState,
} from '@/composables/maint/useStudentMaintenanceForm'
import type { MaintenancePageModel } from '@/models/page'
import type { SemesterRecord } from '@/stores/semesters'
import type { StudentRecord } from '@/stores/students'
interface FieldErrorItem {
field: string
message: string
}
interface GradeOption {
title: string
value: number
}
defineProps<{
activeMobilePanel: 'master' | 'detail'
confirmCloseVisible: boolean
confirmDeleteVisible: boolean
confirmNavigateVisible: boolean
confirmSaveVisible: boolean
confirmSwitchVisible: boolean
currentPage: number
departments: string[]
detailForm: SemesterRecord | null
dialogSubtitle: string
dialogTitle: string
dialogVisible: boolean
enrollYears: number[]
errorSummary: FieldErrorItem[]
fieldErrors: Record<keyof StudentFormState, string[]>
gradeLabel: (grade: number) => string
gradeOptions: GradeOption[]
hasNextRecord: boolean
hasPrevRecord: boolean
headers: any[]
isDetailEditing: boolean
isDirty: boolean
isEditMode: boolean
isFormLocked: boolean
isFormReadonly: boolean
isLoading: boolean
isMobile: boolean
isSaving: boolean
isViewMode: boolean
items: StudentRecord[]
itemsPerPage: number
page: MaintenancePageModel
pageCount: number
pageSummary: string
pendingDeleteLabel: string
rowProps: (data: { item: StudentRecord }) => Record<string, string>
saveSummary: SaveSummaryItem[]
selectedSemester: SemesterRecord | null
selectedSemesterId: number | null
semesters: SemesterRecord[]
statusColor: (status: string) => string
statuses: string[]
}>()
const form = defineModel<StudentFormState>('form', { required: true })
const detailFormModel = defineModel<SemesterRecord | null>('detailForm', { required: true })
const search = defineModel<{
studentId: string
name: string
department: string
grade: number | null
status: string
}>('search', { required: true })
const searchPanelOpen = defineModel<boolean>('searchPanelOpen', { required: true })
const emit = defineEmits<{
(e: 'add-semester'): void
(e: 'cancel-detail-edit'): void
(e: 'clear-field-error', field: keyof StudentFormState): void
(e: 'close'): void
(e: 'close-detail-panel'): void
(e: 'confirm-close'): void
(e: 'confirm-delete'): void
(e: 'confirm-navigate'): void
(e: 'confirm-save'): void
(e: 'confirm-switch'): void
(e: 'create'): void
(e: 'delete', record: StudentRecord): void
(e: 'delete-current'): void
(e: 'delete-semester', id: number): void
(e: 'dialog-visible-change', value: boolean): void
(e: 'edit', record: StudentRecord): void
(e: 'first'): void
(e: 'last'): void
(e: 'next'): void
(e: 'prev'): void
(e: 'reset-search'): void
(e: 'save'): void
(e: 'save-detail-edit'): void
(e: 'scroll-to-field', field: string): void
(e: 'select-semester', id: number): void
(e: 'start-detail-edit'): void
(e: 'switch-to-edit'): void
(e: 'switch-to-view'): void
(e: 'update:confirmCloseVisible', value: boolean): void
(e: 'update:confirmDeleteVisible', value: boolean): void
(e: 'update:confirmNavigateVisible', value: boolean): void
(e: 'update:confirmSaveVisible', value: boolean): void
(e: 'update:confirmSwitchVisible', value: boolean): void
(e: 'update:currentPage', page: number): void
(e: 'view', record: StudentRecord): void
}>()
</script>
<template>
<PageMaintenance
v-model:search-panel-open="searchPanelOpen"
:page="page"
@create="emit('create')"
>
<template #search-fields>
<SectionSearchPanel
v-model="search"
:departments="departments"
:grade-options="gradeOptions"
:statuses="statuses"
@reset="emit('reset-search')"
/>
</template>
<template #table>
<SectionDataTable
:current-page="currentPage"
:grade-label="gradeLabel"
:headers="headers"
:items="items"
:items-per-page="itemsPerPage"
:page-count="pageCount"
:page-summary="pageSummary"
:row-props="rowProps"
:status-color="statusColor"
@delete="emit('delete', $event)"
@edit="emit('edit', $event)"
@update:current-page="emit('update:currentPage', $event)"
@view="emit('view', $event)"
/>
</template>
</PageMaintenance>
<teleport to="body">
<v-overlay
class="dialog-overlay"
:close-on-content-click="false"
:model-value="dialogVisible"
scrim="rgba(0, 0, 0, 0.45)"
scroll-strategy="block"
@update:model-value="emit('dialog-visible-change', $event)"
>
<div class="dialog-panel" :class="{ 'is-mobile': isMobile }">
<div
v-if="!isMobile || activeMobilePanel === 'detail'"
class="detail-panel-wrapper"
:class="{ 'is-active': !!selectedSemesterId, 'is-mobile': isMobile }"
>
<DetailSidePanel
v-model:detail-form="detailFormModel"
:is-detail-editing="isDetailEditing"
:is-mobile="isMobile"
:is-view-mode="isViewMode"
:selected-semester="selectedSemester"
@cancel-edit="emit('cancel-detail-edit')"
@close="emit('close-detail-panel')"
@delete="emit('delete-semester', $event)"
@save-edit="emit('save-detail-edit')"
@start-edit="emit('start-detail-edit')"
/>
</div>
<MntDialogCard
v-if="!isMobile || activeMobilePanel === 'master'"
:dialog-subtitle="dialogSubtitle"
:dialog-title="dialogTitle"
:is-edit-mode="isEditMode"
:is-view-mode="isViewMode"
:width="isMobile ? '100%' : 760"
>
<template #toolbar>
<MntRecordNavToolbar
:has-next-record="hasNextRecord"
:has-prev-record="hasPrevRecord"
:is-edit-mode="isEditMode"
:is-view-mode="isViewMode"
:mobile="isMobile"
@first="emit('first')"
@last="emit('last')"
@next="emit('next')"
@prev="emit('prev')"
@switch-to-edit="emit('switch-to-edit')"
@switch-to-view="emit('switch-to-view')"
/>
</template>
<template #content>
<v-alert
v-if="errorSummary.length > 0 && !isLoading"
class="mb-4"
type="error"
variant="tonal"
>
<div class="text-subtitle-2 mb-2">請先修正以下問題</div>
<div class="d-flex flex-column ga-1">
<v-btn
v-for="error in errorSummary"
:key="error.field"
color="error"
size="small"
variant="text"
@click="emit('scroll-to-field', error.field)"
>
{{ error.message }}
</v-btn>
</div>
</v-alert>
<v-skeleton-loader
v-if="isLoading"
class="mt-4"
type="subtitle,paragraph"
width="100%"
/>
<v-form
v-else
:class="{ 'form-readonly': isFormReadonly }"
@submit.prevent="emit('save')"
>
<MasterFileFormFields
:departments="departments"
:enroll-years="enrollYears"
:field-errors="fieldErrors"
:form="form"
:grade-options="gradeOptions"
:is-form-locked="isFormLocked"
:is-form-readonly="isFormReadonly"
:statuses="statuses"
@clear-field="emit('clear-field-error', $event)"
/>
<v-divider />
<DetailNavigation
:is-mobile="isMobile"
:is-view-mode="isViewMode"
:selected-semester-id="selectedSemesterId"
:semesters="semesters"
@add="emit('add-semester')"
@select="emit('select-semester', $event)"
/>
</v-form>
</template>
<template #actions>
<template v-if="isMobile">
<v-btn :disabled="isSaving" variant="text" @click="emit('close')">取消</v-btn>
<v-btn
v-if="isEditMode"
color="error"
:disabled="isSaving"
variant="tonal"
@click="emit('delete-current')"
>
刪除
</v-btn>
<v-btn
v-if="!isViewMode"
color="primary"
:disabled="!isDirty || isLoading"
:loading="isSaving"
variant="flat"
@click="emit('save')"
>
儲存
</v-btn>
<v-btn v-else color="primary" variant="flat" @click="emit('close')">關閉</v-btn>
</template>
<template v-else>
<v-spacer />
<v-btn :disabled="isSaving" variant="text" @click="emit('close')">取消</v-btn>
<v-btn
v-if="isEditMode"
color="error"
:disabled="isSaving"
variant="tonal"
@click="emit('delete-current')"
>
刪除
</v-btn>
<v-btn
v-if="!isViewMode"
color="primary"
:disabled="!isDirty || isLoading"
:loading="isSaving"
variant="flat"
@click="emit('save')"
>
儲存
</v-btn>
<v-btn v-else color="primary" variant="flat" @click="emit('close')">關閉</v-btn>
</template>
</template>
</MntDialogCard>
</div>
</v-overlay>
</teleport>
<ConfirmDialog
:model-value="confirmCloseVisible"
confirm-color="error"
confirm-text="關閉不儲存"
message="目前有尚未儲存的內容,確定要關閉嗎?"
title="未儲存變更"
@confirm="emit('confirm-close')"
@update:model-value="emit('update:confirmCloseVisible', $event)"
/>
<ConfirmDialog
:model-value="confirmSaveVisible"
:confirm-loading="isSaving"
confirm-text="確認儲存"
max-width="520"
title="確認儲存變更"
@confirm="emit('confirm-save')"
@update:model-value="emit('update:confirmSaveVisible', $event)"
>
<div v-if="saveSummary.length > 0" class="d-flex flex-column ga-2">
<div v-for="item in saveSummary" :key="item.label" class="d-flex flex-column">
<div class="text-caption text-medium-emphasis">{{ item.label }}</div>
<div v-if="item.before !== null" class="text-body-2">
<span class="text-medium-emphasis"></span>
<span :class="{ 'text-medium-emphasis': item.before === '—' }">
{{ item.before }}
</span>
</div>
<div class="text-body-2">
<span class="text-medium-emphasis"></span>
<span :class="{ 'text-medium-emphasis': item.after === '—' }">
{{ item.after }}
</span>
</div>
</div>
</div>
<div v-else class="text-body-2">目前沒有可儲存的變更</div>
</ConfirmDialog>
<ConfirmDialog
:model-value="confirmDeleteVisible"
confirm-color="error"
confirm-text="確定刪除"
:message="`確定要刪除 ${pendingDeleteLabel} 嗎?此操作無法復原。`"
title="確認刪除"
@confirm="emit('confirm-delete')"
@update:model-value="emit('update:confirmDeleteVisible', $event)"
/>
<ConfirmDialog
:model-value="confirmSwitchVisible"
confirm-text="確定切換"
max-width="480"
message="目前有尚未儲存的內容,切換為檢視模式將會捨棄變更,確定要切換嗎?"
title="未儲存變更"
@confirm="emit('confirm-switch')"
@update:model-value="emit('update:confirmSwitchVisible', $event)"
/>
<ConfirmDialog
:model-value="confirmNavigateVisible"
confirm-text="確定切換"
max-width="480"
message="目前有尚未儲存的內容,切換到其他資料將會捨棄變更,確定要切換嗎?"
title="未儲存變更"
@confirm="emit('confirm-navigate')"
@update:model-value="emit('update:confirmNavigateVisible', $event)"
/>
</template>
<style scoped>
.dialog-overlay :deep(.v-overlay__content) {
height: 100vh;
display: flex;
justify-content: flex-end;
width: 100vw;
max-width: 100vw;
}
.dialog-panel {
width: auto;
max-width: 100%;
height: 100vh;
background: transparent;
padding: 0;
display: flex;
}
.dialog-panel > .v-card {
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.12);
}
.detail-panel-wrapper {
width: 0;
opacity: 0;
overflow: hidden;
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
margin-right: 0;
}
.detail-panel-wrapper.is-active {
width: 600px;
opacity: 1;
margin-right: 0;
}
.dialog-panel.is-mobile {
width: 100%;
}
.dialog-panel.is-mobile :deep(.dialog-title) {
padding: 16px 20px 12px;
}
.dialog-panel.is-mobile :deep(.dialog-toolbar) {
padding: 8px 12px;
}
.dialog-panel.is-mobile :deep(.dialog-actions) {
gap: 8px;
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
}
.dialog-panel.is-mobile :deep(.dialog-actions .v-btn) {
flex: 1 1 0;
min-width: 0;
}
.dialog-panel.is-mobile :deep(.v-card-text) {
padding-bottom: 88px;
}
.detail-panel-wrapper.is-mobile {
width: 100%;
opacity: 1;
overflow: visible;
}
.detail-panel-wrapper.is-mobile.is-active {
width: 100%;
}
.form-readonly :deep(.v-field) {
pointer-events: none;
}
@media (max-width: 600px) {
.dialog-panel {
width: 100%;
}
.dialog-panel > .v-card {
width: 100%;
box-shadow: none;
}
}
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,19 @@
import { computed } from 'vue'
import type { MaintenancePageModel } from '@/models/page'
import { useStudentStore } from '@/stores/students'
export function useEditableGridMaintenancePage() {
const studentStore = useStudentStore()
const pageModel = computed<MaintenancePageModel>(() => ({
type: 'maintenance',
title: '可編輯表格維護示範',
records: studentStore.students,
loading: false,
error: null,
}))
return {
pageModel,
}
}
@@ -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,
}
}
@@ -0,0 +1,20 @@
import { computed } from 'vue'
import type { MaintenancePageModel } from '@/models/page'
import { useStudentStore } from '@/stores/students'
export function useMasterDetailBMaintenancePage() {
const studentStore = useStudentStore()
const pageModel = computed<MaintenancePageModel>(() => ({
type: 'maintenance',
title: '主從資料維護示範B',
records: studentStore.students,
loading: false,
error: null,
}))
return {
pageModel,
}
}
@@ -0,0 +1,20 @@
import { computed } from 'vue'
import type { MaintenancePageModel } from '@/models/page'
import { useStudentStore } from '@/stores/students'
export function useMasterDetailCMaintenancePage() {
const studentStore = useStudentStore()
const pageModel = computed<MaintenancePageModel>(() => ({
type: 'maintenance',
title: '主從資料維護示範C',
records: studentStore.students,
loading: false,
error: null,
}))
return {
pageModel,
}
}
+8 -5
View File
@@ -1,7 +1,10 @@
<template>
<EditableStudentGrid />
</template>
<script setup lang="ts">
import EditableStudentGrid from '@/components/maint/EditableGrid.vue'
import PageEditableGridMaintenance from '@/components/pages/PageEditableGridMaintenance.vue'
import { useEditableGridMaintenancePage } from '@/composables/page-drivers/useEditableGridMaintenancePage'
const page = useEditableGridMaintenancePage()
</script>
<template>
<PageEditableGridMaintenance :page="page.pageModel.value" />
</template>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff