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
Reference in New Issue
Block a user