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:
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
+28
-1022
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
Reference in New Issue
Block a user