docs: reorganize architecture strategy documentation
Split current project diagnostics into a dedicated analysis document and trim the main architecture strategy to focus on core guidance. This makes the documentation easier to navigate and separates observed issues from recommended architectural principles.docs: reorganize architecture strategy documentation Split current project diagnostics into a dedicated analysis document and trim the main architecture strategy to focus on core guidance. This makes the documentation easier to navigate and separates observed issues from recommended architectural principles.
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
<script setup lang="ts">
|
||||
import type { StudentFormState } from '@/composables/maint/useStudentMaintenanceForm'
|
||||
|
||||
interface GradeOption {
|
||||
title: string
|
||||
value: number
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
departments: string[]
|
||||
enrollYears: number[]
|
||||
fieldErrors: Record<keyof StudentFormState, string[]>
|
||||
gradeOptions: GradeOption[]
|
||||
isFormLocked: boolean
|
||||
isFormReadonly: boolean
|
||||
statuses: string[]
|
||||
}>()
|
||||
|
||||
const form = defineModel<StudentFormState>({ required: true })
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'clear-field-error', field: keyof StudentFormState): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-row density="compact">
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
id="field-studentId"
|
||||
v-model="form.studentId"
|
||||
density="comfortable"
|
||||
:disabled="isFormLocked"
|
||||
:error-messages="fieldErrors.studentId"
|
||||
label="學號"
|
||||
placeholder="例如:S2024008"
|
||||
:readonly="isFormReadonly"
|
||||
variant="outlined"
|
||||
@update:model-value="emit('clear-field-error', 'studentId')"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
id="field-name"
|
||||
v-model="form.name"
|
||||
density="comfortable"
|
||||
:disabled="isFormLocked"
|
||||
:error-messages="fieldErrors.name"
|
||||
label="姓名"
|
||||
placeholder="例如:陳怡君"
|
||||
:readonly="isFormReadonly"
|
||||
variant="outlined"
|
||||
@update:model-value="emit('clear-field-error', 'name')"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-select
|
||||
id="field-department"
|
||||
v-model="form.department"
|
||||
density="comfortable"
|
||||
:disabled="isFormLocked"
|
||||
:error-messages="fieldErrors.department"
|
||||
:items="departments"
|
||||
label="系所"
|
||||
:readonly="isFormReadonly"
|
||||
variant="outlined"
|
||||
@update:model-value="emit('clear-field-error', 'department')"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-select
|
||||
id="field-grade"
|
||||
v-model="form.grade"
|
||||
density="comfortable"
|
||||
:disabled="isFormLocked"
|
||||
:error-messages="fieldErrors.grade"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
:items="gradeOptions"
|
||||
label="年級"
|
||||
:readonly="isFormReadonly"
|
||||
variant="outlined"
|
||||
@update:model-value="emit('clear-field-error', 'grade')"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-select
|
||||
id="field-enrollYear"
|
||||
v-model="form.enrollYear"
|
||||
density="comfortable"
|
||||
:disabled="isFormLocked"
|
||||
:error-messages="fieldErrors.enrollYear"
|
||||
:items="enrollYears"
|
||||
label="入學年度"
|
||||
:readonly="isFormReadonly"
|
||||
variant="outlined"
|
||||
@update:model-value="emit('clear-field-error', 'enrollYear')"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
id="field-credits"
|
||||
v-model.number="form.credits"
|
||||
density="comfortable"
|
||||
:disabled="isFormLocked"
|
||||
:error-messages="fieldErrors.credits"
|
||||
label="已修學分"
|
||||
min="0"
|
||||
:readonly="isFormReadonly"
|
||||
type="number"
|
||||
variant="outlined"
|
||||
@update:model-value="emit('clear-field-error', 'credits')"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
id="field-advisor"
|
||||
v-model="form.advisor"
|
||||
density="comfortable"
|
||||
:disabled="isFormLocked"
|
||||
:error-messages="fieldErrors.advisor"
|
||||
label="指導老師"
|
||||
placeholder="例如:林教授"
|
||||
:readonly="isFormReadonly"
|
||||
variant="outlined"
|
||||
@update:model-value="emit('clear-field-error', 'advisor')"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
id="field-email"
|
||||
v-model="form.email"
|
||||
density="comfortable"
|
||||
:disabled="isFormLocked"
|
||||
:error-messages="fieldErrors.email"
|
||||
label="Email"
|
||||
placeholder="name@school.edu"
|
||||
:readonly="isFormReadonly"
|
||||
variant="outlined"
|
||||
@update:model-value="emit('clear-field-error', 'email')"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
id="field-phone"
|
||||
v-model="form.phone"
|
||||
density="comfortable"
|
||||
:disabled="isFormLocked"
|
||||
:error-messages="fieldErrors.phone"
|
||||
label="電話"
|
||||
placeholder="例如:02-2345-6789"
|
||||
:readonly="isFormReadonly"
|
||||
variant="outlined"
|
||||
@update:model-value="emit('clear-field-error', 'phone')"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-select
|
||||
id="field-status"
|
||||
v-model="form.status"
|
||||
density="comfortable"
|
||||
:disabled="isFormLocked"
|
||||
:error-messages="fieldErrors.status"
|
||||
:items="statuses"
|
||||
label="狀態"
|
||||
:readonly="isFormReadonly"
|
||||
variant="outlined"
|
||||
@update:model-value="emit('clear-field-error', 'status')"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
@@ -0,0 +1,169 @@
|
||||
<script setup lang="ts">
|
||||
import { mdiDelete, mdiEye, mdiPencil } from '@mdi/js'
|
||||
import type { StudentRecord } from '@/models/student'
|
||||
|
||||
defineProps<{
|
||||
currentPage: number
|
||||
gradeLabel: (grade: number) => string
|
||||
headers: any[]
|
||||
items: StudentRecord[]
|
||||
itemsPerPage: number
|
||||
pageCount: number
|
||||
pageSummary: string
|
||||
rowProps: (data: { item: StudentRecord }) => Record<string, string>
|
||||
statusColor: (status: string) => string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:currentPage', page: number): void
|
||||
(e: 'view', record: StudentRecord): void
|
||||
(e: 'edit', record: StudentRecord): void
|
||||
(e: 'delete', record: StudentRecord): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-data-table
|
||||
class="student-table"
|
||||
density="compact"
|
||||
fixed-header
|
||||
:headers="headers"
|
||||
height="100%"
|
||||
hide-default-footer
|
||||
:items="items"
|
||||
:items-per-page="itemsPerPage"
|
||||
:page="currentPage"
|
||||
:row-props="rowProps"
|
||||
@update:page="emit('update:currentPage', $event)"
|
||||
>
|
||||
<template #[`item.grade`]="{ item }">
|
||||
{{ gradeLabel(item.grade) }}
|
||||
</template>
|
||||
<template #[`item.status`]="{ item }">
|
||||
<v-chip :color="statusColor(item.status)" size="small" variant="tonal">
|
||||
{{ item.status }}
|
||||
</v-chip>
|
||||
</template>
|
||||
<template #[`item.actions`]="{ item }">
|
||||
<div class="d-flex ga-2">
|
||||
<v-btn
|
||||
color="info"
|
||||
:prepend-icon="mdiEye"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="emit('view', item)"
|
||||
>
|
||||
檢視
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
:prepend-icon="mdiPencil"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="emit('edit', item)"
|
||||
>
|
||||
修改
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="error"
|
||||
:prepend-icon="mdiDelete"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="emit('delete', item)"
|
||||
>
|
||||
刪除
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
<template #bottom>
|
||||
<div class="d-flex align-center justify-space-between px-4 py-3">
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
{{ pageSummary }}
|
||||
</div>
|
||||
<div class="d-flex align-center ga-2">
|
||||
<v-btn
|
||||
:disabled="currentPage <= 1"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="emit('update:currentPage', 1)"
|
||||
>
|
||||
第一頁
|
||||
</v-btn>
|
||||
<v-btn
|
||||
:disabled="currentPage <= 1"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="emit('update:currentPage', currentPage - 1)"
|
||||
>
|
||||
上一頁
|
||||
</v-btn>
|
||||
<span class="text-body-2">{{ currentPage }} / {{ pageCount }}</span>
|
||||
<v-btn
|
||||
:disabled="currentPage >= pageCount"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="emit('update:currentPage', currentPage + 1)"
|
||||
>
|
||||
下一頁
|
||||
</v-btn>
|
||||
<v-btn
|
||||
:disabled="currentPage >= pageCount"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="emit('update:currentPage', pageCount)"
|
||||
>
|
||||
最後頁
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.student-table {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.student-table :deep(table) {
|
||||
min-width: 1400px;
|
||||
}
|
||||
|
||||
.student-table :deep(th),
|
||||
.student-table :deep(td) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.student-table :deep(.v-data-table-column--fixed),
|
||||
.student-table :deep(.v-data-table-column--fixed-end) {
|
||||
background: rgb(var(--v-theme-surface));
|
||||
}
|
||||
|
||||
.student-table :deep(.v-data-table-column--fixed-last-start)::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -5px;
|
||||
bottom: 0;
|
||||
width: 5px;
|
||||
box-shadow: inset 10px 0 8px -8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.student-table :deep(.v-data-table-footer) {
|
||||
padding: 4px 0 0;
|
||||
}
|
||||
|
||||
tbody tr.is-highlighted {
|
||||
animation: row-highlight 1.6s ease-out;
|
||||
}
|
||||
|
||||
@keyframes row-highlight {
|
||||
0% {
|
||||
background-color: rgba(var(--v-theme-primary), 0.18);
|
||||
}
|
||||
|
||||
100% {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,282 @@
|
||||
<script setup lang="ts">
|
||||
import ConfirmDialog from '@/components/maint/CommonConfirmDialog.vue'
|
||||
import MntDialogCard from '@/components/maint/MntDialogCard.vue'
|
||||
import MntRecordNavToolbar from '@/components/maint/MntRecordNavToolbar.vue'
|
||||
import ItemFormFieldGroup from '@/components/items/ItemFormFieldGroup.vue'
|
||||
import type {
|
||||
SaveSummaryItem,
|
||||
StudentFormState,
|
||||
} from '@/composables/maint/useStudentMaintenanceForm'
|
||||
|
||||
interface FieldErrorItem {
|
||||
field: string
|
||||
message: string
|
||||
}
|
||||
|
||||
interface GradeOption {
|
||||
title: string
|
||||
value: number
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
confirmCloseVisible: boolean
|
||||
confirmDeleteVisible: boolean
|
||||
confirmNavigateVisible: boolean
|
||||
confirmSaveVisible: boolean
|
||||
confirmSwitchVisible: boolean
|
||||
departments: string[]
|
||||
dialogSubtitle: string
|
||||
dialogTitle: string
|
||||
dialogVisible: boolean
|
||||
enrollYears: number[]
|
||||
errorSummary: FieldErrorItem[]
|
||||
fieldErrors: Record<keyof StudentFormState, string[]>
|
||||
gradeOptions: GradeOption[]
|
||||
hasNextRecord: boolean
|
||||
hasPrevRecord: boolean
|
||||
isDirty: boolean
|
||||
isEditMode: boolean
|
||||
isFormLocked: boolean
|
||||
isFormReadonly: boolean
|
||||
isLoading: boolean
|
||||
isSaving: boolean
|
||||
isViewMode: boolean
|
||||
pendingDeleteLabel: string
|
||||
saveSummary: SaveSummaryItem[]
|
||||
statuses: string[]
|
||||
}>()
|
||||
|
||||
const form = defineModel<StudentFormState>('form', { required: true })
|
||||
|
||||
const emit = defineEmits<{
|
||||
(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: 'dialog-visible-change', value: boolean): void
|
||||
(e: 'clear-field-error', field: keyof StudentFormState): void
|
||||
(e: 'close'): void
|
||||
(e: 'confirm-close'): void
|
||||
(e: 'confirm-delete'): void
|
||||
(e: 'confirm-navigate'): void
|
||||
(e: 'confirm-save'): void
|
||||
(e: 'confirm-switch'): void
|
||||
(e: 'delete-current'): void
|
||||
(e: 'first'): void
|
||||
(e: 'last'): void
|
||||
(e: 'next'): void
|
||||
(e: 'prev'): void
|
||||
(e: 'save'): void
|
||||
(e: 'scroll-to-field', field: string): void
|
||||
(e: 'switch-to-edit'): void
|
||||
(e: 'switch-to-view'): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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">
|
||||
<MntDialogCard
|
||||
content-class="pa-2 flex-grow-1 overflow-y-auto"
|
||||
:dialog-subtitle="dialogSubtitle"
|
||||
:dialog-title="dialogTitle"
|
||||
:is-edit-mode="isEditMode"
|
||||
:is-view-mode="isViewMode"
|
||||
width="100%"
|
||||
>
|
||||
<template #toolbar>
|
||||
<MntRecordNavToolbar
|
||||
edit-label="進入編輯"
|
||||
first-label="第一筆"
|
||||
:has-next-record="hasNextRecord"
|
||||
:has-prev-record="hasPrevRecord"
|
||||
:is-edit-mode="isEditMode"
|
||||
:is-view-mode="isViewMode"
|
||||
last-label="最後一筆"
|
||||
view-label="回到檢視"
|
||||
@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')"
|
||||
>
|
||||
<ItemFormFieldGroup
|
||||
v-model="form"
|
||||
:departments="departments"
|
||||
:enroll-years="enrollYears"
|
||||
:field-errors="fieldErrors"
|
||||
:grade-options="gradeOptions"
|
||||
:is-form-locked="isFormLocked"
|
||||
:is-form-readonly="isFormReadonly"
|
||||
:statuses="statuses"
|
||||
@clear-field-error="emit('clear-field-error', $event)"
|
||||
/>
|
||||
</v-form>
|
||||
</template>
|
||||
<template #actions>
|
||||
<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>
|
||||
</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: 760px;
|
||||
max-width: 100%;
|
||||
height: 100vh;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
padding: 12px;
|
||||
box-shadow: -12px 0 24px rgba(0, 0, 0, 0.18);
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.form-readonly :deep(.v-field) {
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,102 @@
|
||||
<script setup lang="ts">
|
||||
import { mdiBroom, mdiMagnify } from '@mdi/js'
|
||||
|
||||
interface GradeOption {
|
||||
title: string
|
||||
value: number
|
||||
}
|
||||
|
||||
interface SearchState {
|
||||
studentId: string
|
||||
name: string
|
||||
department: string
|
||||
grade: number | null
|
||||
status: string
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
departments: string[]
|
||||
gradeOptions: GradeOption[]
|
||||
statuses: string[]
|
||||
}>()
|
||||
|
||||
const search = defineModel<SearchState>({ required: true })
|
||||
|
||||
defineEmits<{
|
||||
(e: 'reset'): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-col cols="12" md="2">
|
||||
<div id="search-student-id-label" class="text-body-2 text-medium-emphasis pl-2">學號</div>
|
||||
<v-text-field
|
||||
id="search-student-id"
|
||||
v-model="search.studentId"
|
||||
aria-labelledby="search-student-id-label"
|
||||
density="compact"
|
||||
hide-details
|
||||
name="searchStudentId"
|
||||
placeholder="例如:S2024001"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="2">
|
||||
<div id="search-name-label" class="text-body-2 text-medium-emphasis pl-2">姓名</div>
|
||||
<v-text-field
|
||||
id="search-name"
|
||||
v-model="search.name"
|
||||
aria-labelledby="search-name-label"
|
||||
density="compact"
|
||||
hide-details
|
||||
name="searchName"
|
||||
placeholder="例如:王小明"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="2">
|
||||
<div id="search-department-label" class="text-body-2 text-medium-emphasis pl-2">系所</div>
|
||||
<v-select
|
||||
id="search-department"
|
||||
v-model="search.department"
|
||||
aria-labelledby="search-department-label"
|
||||
density="compact"
|
||||
hide-details
|
||||
:items="departments"
|
||||
name="searchDepartment"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="2">
|
||||
<div id="search-grade-label" class="text-body-2 text-medium-emphasis pl-2">年級</div>
|
||||
<v-select
|
||||
id="search-grade"
|
||||
v-model="search.grade"
|
||||
aria-labelledby="search-grade-label"
|
||||
density="compact"
|
||||
hide-details
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
:items="gradeOptions"
|
||||
name="searchGrade"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="2">
|
||||
<div id="search-status-label" class="text-body-2 text-medium-emphasis pl-2">狀態</div>
|
||||
<v-select
|
||||
id="search-status"
|
||||
v-model="search.status"
|
||||
aria-labelledby="search-status-label"
|
||||
density="compact"
|
||||
hide-details
|
||||
:items="statuses"
|
||||
name="searchStatus"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col class="d-flex justify-end align-end flex-grow-1 ga-2" cols="12" md="auto">
|
||||
<v-btn :prepend-icon="mdiBroom" variant="text" @click="$emit('reset')">清除</v-btn>
|
||||
<v-btn color="primary" disabled :prepend-icon="mdiMagnify" variant="tonal">查詢</v-btn>
|
||||
</v-col>
|
||||
</template>
|
||||
@@ -0,0 +1,125 @@
|
||||
import { nextTick, type Ref } from 'vue'
|
||||
|
||||
interface UseCrudCommandsOptions<TRecord extends { id: number }, TPayload> {
|
||||
clearAllErrors: () => void
|
||||
dialogMode: Ref<'create' | 'edit' | 'view'>
|
||||
dialogVisible: Ref<boolean>
|
||||
editingId: Ref<number | null>
|
||||
fieldErrors: Ref<Record<string, string[]>>
|
||||
form: Ref<TPayload>
|
||||
highlightedId: Ref<number | null>
|
||||
isDirty: Readonly<Ref<boolean>>
|
||||
isLoading: Ref<boolean>
|
||||
isSaving: Ref<boolean>
|
||||
isViewMode: Readonly<Ref<boolean>>
|
||||
loadDelay?: number
|
||||
loadSequence: Ref<number>
|
||||
resetForm: () => void
|
||||
saveDelay?: number
|
||||
scrollToField: (field: string) => void
|
||||
setForm: (payload: TPayload) => void
|
||||
syncInitialForm: () => void
|
||||
toFormPayload: (record: TRecord) => TPayload
|
||||
toSavePayload: (form: TPayload) => TPayload
|
||||
updateRecord: (id: number, payload: TPayload) => unknown
|
||||
createRecord: (payload: TPayload) => number
|
||||
validateForm: () => Array<{ field: string; message: string }>
|
||||
}
|
||||
|
||||
export function useCrudCommands<TRecord extends { id: number }, TPayload>(
|
||||
options: UseCrudCommandsOptions<TRecord, TPayload>
|
||||
) {
|
||||
function openAddDialog() {
|
||||
options.loadSequence.value += 1
|
||||
options.dialogMode.value = 'create'
|
||||
options.editingId.value = null
|
||||
options.resetForm()
|
||||
options.isLoading.value = false
|
||||
options.dialogVisible.value = true
|
||||
}
|
||||
|
||||
function loadRecord(record: TRecord, mode: 'edit' | 'view') {
|
||||
options.loadSequence.value += 1
|
||||
const sequence = options.loadSequence.value
|
||||
options.dialogMode.value = mode
|
||||
options.editingId.value = record.id
|
||||
options.dialogVisible.value = true
|
||||
options.isLoading.value = true
|
||||
options.clearAllErrors()
|
||||
window.setTimeout(() => {
|
||||
if (sequence !== options.loadSequence.value || !options.dialogVisible.value) return
|
||||
options.setForm(options.toFormPayload(record))
|
||||
options.syncInitialForm()
|
||||
options.isLoading.value = false
|
||||
}, options.loadDelay ?? 350)
|
||||
}
|
||||
|
||||
function openEditDialog(record: TRecord) {
|
||||
loadRecord(record, 'edit')
|
||||
}
|
||||
|
||||
function openViewDialog(record: TRecord) {
|
||||
loadRecord(record, 'view')
|
||||
}
|
||||
|
||||
async function requestSaveConfirmation(confirmSaveVisible: Ref<boolean>) {
|
||||
if (
|
||||
options.isSaving.value ||
|
||||
options.isLoading.value ||
|
||||
!options.isDirty.value ||
|
||||
options.isViewMode.value
|
||||
) {
|
||||
return
|
||||
}
|
||||
options.clearAllErrors()
|
||||
|
||||
const errors = options.validateForm()
|
||||
if (errors.length > 0) {
|
||||
for (const error of errors) {
|
||||
options.fieldErrors.value[error.field] = [error.message]
|
||||
}
|
||||
await nextTick()
|
||||
const firstError = errors[0]
|
||||
if (firstError) options.scrollToField(firstError.field)
|
||||
return
|
||||
}
|
||||
|
||||
confirmSaveVisible.value = true
|
||||
}
|
||||
|
||||
function confirmSave(confirmSaveVisible: Ref<boolean>) {
|
||||
confirmSaveVisible.value = false
|
||||
return saveRecord()
|
||||
}
|
||||
|
||||
async function saveRecord() {
|
||||
if (options.isSaving.value || options.isLoading.value) return
|
||||
options.isSaving.value = true
|
||||
await new Promise((resolve) => window.setTimeout(resolve, options.saveDelay ?? 450))
|
||||
const payload = options.toSavePayload(options.form.value)
|
||||
|
||||
if (options.editingId.value) {
|
||||
const updated = options.updateRecord(options.editingId.value, payload)
|
||||
if (updated) options.highlightedId.value = options.editingId.value
|
||||
} else {
|
||||
const createdId = options.createRecord(payload)
|
||||
options.highlightedId.value = createdId
|
||||
}
|
||||
|
||||
options.syncInitialForm()
|
||||
options.dialogVisible.value = false
|
||||
options.isSaving.value = false
|
||||
window.setTimeout(() => {
|
||||
options.highlightedId.value = null
|
||||
}, 1600)
|
||||
}
|
||||
|
||||
return {
|
||||
confirmSave,
|
||||
openAddDialog,
|
||||
openEditDialog,
|
||||
openViewDialog,
|
||||
requestSaveConfirmation,
|
||||
saveRecord,
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ interface UseMaintenanceCrudFlowOptions<T extends { id: number }> {
|
||||
onAfterDelete?: (deletedId: number) => void
|
||||
}
|
||||
|
||||
interface UseMaintenanceCrudFlowResult<T extends { id: number }> {
|
||||
export interface UseMaintenanceCrudFlowResult<T extends { id: number }> {
|
||||
confirmCloseVisible: Ref<boolean>
|
||||
confirmSaveVisible: Ref<boolean>
|
||||
confirmDeleteVisible: Ref<boolean>
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useCrudCommands } from '@/composables/commands/useCrudCommands'
|
||||
import { useMaintenanceCrudFlow } from '@/composables/maint/useMaintenanceCrudFlow'
|
||||
import {
|
||||
type StudentFormState,
|
||||
useStudentMaintenanceForm,
|
||||
} from '@/composables/maint/useStudentMaintenanceForm'
|
||||
import type { MaintenancePageModel } from '@/models/page'
|
||||
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 useSingleRecordMaintenancePage() {
|
||||
const studentStore = useStudentStore()
|
||||
const students = computed(() => studentStore.students)
|
||||
const { smAndUp } = useDisplay()
|
||||
const pageModel = computed<MaintenancePageModel>(() => ({
|
||||
type: 'maintenance',
|
||||
title: '單筆資料維護示範',
|
||||
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 highlightedId = ref<number | null>(null)
|
||||
const loadSequence = ref(0)
|
||||
const snackbarVisible = ref(false)
|
||||
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 resetSearch() {
|
||||
search.value = { studentId: '', name: '', department: '', grade: null, status: '' }
|
||||
}
|
||||
|
||||
function scrollToField(field: string) {
|
||||
const target = document.getElementById(`field-${field}`)
|
||||
if (!target) return
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
|
||||
const commands = useCrudCommands<StudentRecord, StudentFormState>({
|
||||
...formState,
|
||||
dialogMode,
|
||||
dialogVisible,
|
||||
editingId,
|
||||
highlightedId,
|
||||
isLoading,
|
||||
isSaving,
|
||||
isViewMode: computed(() => dialogMode.value === 'view'),
|
||||
loadSequence,
|
||||
scrollToField,
|
||||
toFormPayload,
|
||||
toSavePayload,
|
||||
updateRecord: (id, payload) => studentStore.updateStudent(id, payload),
|
||||
createRecord: (payload) => studentStore.addStudent(payload),
|
||||
})
|
||||
const flow = useMaintenanceCrudFlow<StudentRecord>({
|
||||
records: students,
|
||||
editingId,
|
||||
dialogMode,
|
||||
dialogVisible,
|
||||
isLoading,
|
||||
isSaving,
|
||||
isDirty: formState.isDirty,
|
||||
clearAllErrors: formState.clearAllErrors,
|
||||
resetForm: formState.resetForm,
|
||||
openEditDialog: commands.openEditDialog,
|
||||
openViewDialog: commands.openViewDialog,
|
||||
removeRecord: (id) => studentStore.removeStudent(id),
|
||||
describeRecord: (student) => `${student.studentId} ${student.name}`,
|
||||
})
|
||||
const dialogTitle = computed(() => {
|
||||
if (dialogMode.value === 'view') return '檢視資料示範'
|
||||
if (dialogMode.value === 'edit') return '修改資料示範'
|
||||
return '新增資料示範'
|
||||
})
|
||||
const dialogSubtitle = computed(() => (!editingId.value ? '' : `${formState.form.value.studentId || '未填學號'}・${formState.form.value.name || '未填姓名'}`))
|
||||
|
||||
watch(pageCount, (value) => {
|
||||
if (currentPage.value > value) currentPage.value = value
|
||||
})
|
||||
|
||||
async function requestSaveConfirmation() {
|
||||
await commands.requestSaveConfirmation(flow.confirmSaveVisible)
|
||||
}
|
||||
|
||||
async function confirmSave() {
|
||||
await commands.confirmSave(flow.confirmSaveVisible)
|
||||
snackbarVisible.value = true
|
||||
}
|
||||
|
||||
const formPanelProps = computed(() => ({
|
||||
confirmCloseVisible: flow.confirmCloseVisible.value,
|
||||
confirmDeleteVisible: flow.confirmDeleteVisible.value,
|
||||
confirmNavigateVisible: flow.confirmNavigateVisible.value,
|
||||
confirmSaveVisible: flow.confirmSaveVisible.value,
|
||||
confirmSwitchVisible: flow.confirmSwitchVisible.value,
|
||||
departments,
|
||||
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,
|
||||
isDirty: formState.isDirty.value,
|
||||
isEditMode: flow.isEditMode.value,
|
||||
isFormLocked: isFormLocked.value,
|
||||
isFormReadonly: flow.isViewMode.value,
|
||||
isLoading: isLoading.value,
|
||||
isSaving: isSaving.value,
|
||||
isViewMode: flow.isViewMode.value,
|
||||
pendingDeleteLabel: flow.pendingDeleteLabel.value,
|
||||
saveSummary: formState.saveSummary.value,
|
||||
statuses,
|
||||
}))
|
||||
const formPanelEvents = {
|
||||
'clear-field-error': formState.clearFieldError,
|
||||
close: flow.requestCloseDialog,
|
||||
'confirm-close': flow.confirmClose,
|
||||
'confirm-delete': flow.confirmDelete,
|
||||
'confirm-navigate': flow.confirmNavigate,
|
||||
'confirm-save': confirmSave,
|
||||
'confirm-switch': flow.confirmSwitch,
|
||||
'delete-current': flow.requestDeleteCurrent,
|
||||
'dialog-visible-change': flow.handleDialogVisibility,
|
||||
first: () => flow.openEdgeRecord('first'),
|
||||
last: () => flow.openEdgeRecord('last'),
|
||||
next: () => flow.openAdjacentRecord('next'),
|
||||
prev: () => flow.openAdjacentRecord('prev'),
|
||||
save: requestSaveConfirmation,
|
||||
'scroll-to-field': scrollToField,
|
||||
'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:form': (value: StudentFormState) => (formState.form.value = value),
|
||||
}
|
||||
|
||||
return {
|
||||
commands,
|
||||
currentPage,
|
||||
departments,
|
||||
dialogMode,
|
||||
dialogSubtitle,
|
||||
dialogTitle,
|
||||
dialogVisible,
|
||||
enrollYears,
|
||||
flow,
|
||||
formState,
|
||||
formPanelEvents,
|
||||
formPanelProps,
|
||||
gradeOptions,
|
||||
isFormLocked,
|
||||
isFormReadonly: flow.isViewMode,
|
||||
isLoading,
|
||||
isSaving,
|
||||
itemsPerPage,
|
||||
pageCount,
|
||||
pageModel,
|
||||
pageSummary,
|
||||
requestSaveConfirmation,
|
||||
confirmSave,
|
||||
resetSearch,
|
||||
scrollToField,
|
||||
search,
|
||||
searchPanelOpen,
|
||||
snackbarVisible,
|
||||
statuses,
|
||||
students,
|
||||
tableHeaders,
|
||||
}
|
||||
}
|
||||
@@ -1,921 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import PageMaintenance from '@/components/pages/PageMaintenance.vue'
|
||||
import SectionDataTable from '@/components/sections/SectionDataTable.vue'
|
||||
import SectionFormPanel from '@/components/sections/SectionFormPanel.vue'
|
||||
import SectionSearchPanel from '@/components/sections/SectionSearchPanel.vue'
|
||||
import { useSingleRecordMaintenancePage } from '@/composables/page-drivers/useSingleRecordMaintenancePage'
|
||||
|
||||
const page = useSingleRecordMaintenancePage()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<page-maint
|
||||
:search-panel-open="searchPanelOpen"
|
||||
:title="`單筆資料維護示範`"
|
||||
@create="openAddDialog"
|
||||
@toggle-search="searchPanelOpen = !searchPanelOpen"
|
||||
<PageMaintenance
|
||||
v-model:search-panel-open="page.searchPanelOpen.value"
|
||||
:page="page.pageModel.value"
|
||||
@create="page.commands.openAddDialog"
|
||||
>
|
||||
<template #search-fields>
|
||||
<v-col cols="12" md="2">
|
||||
<div id="search-student-id-label" class="text-body-2 text-medium-emphasis pl-2">學號</div>
|
||||
<v-text-field
|
||||
id="search-student-id"
|
||||
v-model="search.studentId"
|
||||
aria-labelledby="search-student-id-label"
|
||||
density="compact"
|
||||
hide-details
|
||||
name="searchStudentId"
|
||||
placeholder="例如:S2024001"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="2">
|
||||
<div id="search-name-label" class="text-body-2 text-medium-emphasis pl-2">姓名</div>
|
||||
<v-text-field
|
||||
id="search-name"
|
||||
v-model="search.name"
|
||||
aria-labelledby="search-name-label"
|
||||
density="compact"
|
||||
hide-details
|
||||
name="searchName"
|
||||
placeholder="例如:王小明"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="2">
|
||||
<div id="search-department-label" class="text-body-2 text-medium-emphasis pl-2">系所</div>
|
||||
<v-select
|
||||
id="search-department"
|
||||
v-model="search.department"
|
||||
aria-labelledby="search-department-label"
|
||||
density="compact"
|
||||
hide-details
|
||||
:items="departments"
|
||||
name="searchDepartment"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="2">
|
||||
<div id="search-grade-label" class="text-body-2 text-medium-emphasis pl-2">年級</div>
|
||||
<v-select
|
||||
id="search-grade"
|
||||
v-model="search.grade"
|
||||
aria-labelledby="search-grade-label"
|
||||
density="compact"
|
||||
hide-details
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
:items="gradeOptions"
|
||||
name="searchGrade"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="2">
|
||||
<div id="search-status-label" class="text-body-2 text-medium-emphasis pl-2">狀態</div>
|
||||
<v-select
|
||||
id="search-status"
|
||||
v-model="search.status"
|
||||
aria-labelledby="search-status-label"
|
||||
density="compact"
|
||||
hide-details
|
||||
:items="statuses"
|
||||
name="searchStatus"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col class="d-flex justify-end align-end flex-grow-1 ga-2" cols="12" md="auto">
|
||||
<v-btn :prepend-icon="mdiBroom" variant="text" @click="resetSearch">清除</v-btn>
|
||||
<v-btn color="primary" disabled :prepend-icon="mdiMagnify" variant="tonal">查詢</v-btn>
|
||||
</v-col>
|
||||
<SectionSearchPanel
|
||||
v-model="page.search.value"
|
||||
:departments="page.departments"
|
||||
:grade-options="page.gradeOptions"
|
||||
:statuses="page.statuses"
|
||||
@reset="page.resetSearch"
|
||||
/>
|
||||
</template>
|
||||
<template #table>
|
||||
<v-data-table
|
||||
v-model:page="currentPage"
|
||||
class="student-table"
|
||||
density="compact"
|
||||
fixed-header
|
||||
:headers="tableHeaders"
|
||||
height="100%"
|
||||
hide-default-footer
|
||||
:items="students"
|
||||
:items-per-page="itemsPerPage"
|
||||
:row-props="rowProps"
|
||||
>
|
||||
<template #[`item.grade`]="{ item }">
|
||||
{{ gradeLabel(item.grade) }}
|
||||
</template>
|
||||
<template #[`item.status`]="{ item }">
|
||||
<v-chip :color="statusColor(item.status)" size="small" variant="tonal">
|
||||
{{ item.status }}
|
||||
</v-chip>
|
||||
</template>
|
||||
<template #[`item.actions`]="{ item }">
|
||||
<div class="d-flex ga-2">
|
||||
<v-btn
|
||||
color="info"
|
||||
:prepend-icon="mdiEye"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="openViewDialog(item)"
|
||||
>
|
||||
檢視
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
:prepend-icon="mdiPencil"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="openEditDialog(item)"
|
||||
>
|
||||
修改
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="error"
|
||||
:prepend-icon="mdiDelete"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="requestDeleteConfirmation(item)"
|
||||
>
|
||||
刪除
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
<template #bottom>
|
||||
<div class="d-flex align-center justify-space-between px-4 py-3">
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
{{ pageSummary }}
|
||||
</div>
|
||||
<div class="d-flex align-center ga-2">
|
||||
<v-btn
|
||||
:disabled="currentPage <= 1"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="currentPage = 1"
|
||||
>
|
||||
第一頁
|
||||
</v-btn>
|
||||
<v-btn
|
||||
:disabled="currentPage <= 1"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="currentPage -= 1"
|
||||
>
|
||||
上一頁
|
||||
</v-btn>
|
||||
<span class="text-body-2">{{ currentPage }} / {{ pageCount }}</span>
|
||||
<v-btn
|
||||
:disabled="currentPage >= pageCount"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="currentPage += 1"
|
||||
>
|
||||
下一頁
|
||||
</v-btn>
|
||||
<v-btn
|
||||
:disabled="currentPage >= pageCount"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="currentPage = pageCount"
|
||||
>
|
||||
最後頁
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</v-data-table>
|
||||
<SectionDataTable
|
||||
v-model:current-page="page.currentPage.value"
|
||||
:grade-label="page.formState.gradeLabel"
|
||||
:headers="page.tableHeaders.value"
|
||||
:items="page.students.value"
|
||||
:items-per-page="page.itemsPerPage"
|
||||
:page-count="page.pageCount.value"
|
||||
:page-summary="page.pageSummary.value"
|
||||
:row-props="page.formState.rowProps"
|
||||
:status-color="page.formState.statusColor"
|
||||
@delete="page.flow.requestDeleteConfirmation"
|
||||
@edit="page.commands.openEditDialog"
|
||||
@view="page.commands.openViewDialog"
|
||||
/>
|
||||
</template>
|
||||
</page-maint>
|
||||
</PageMaintenance>
|
||||
|
||||
<!-- 新增 / 編輯 / 檢視側邊欄 -->
|
||||
<teleport to="body">
|
||||
<!-- 包成元件需要傳高度寬度給dialog-panel -->
|
||||
<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="handleDialogVisibility"
|
||||
>
|
||||
<div class="dialog-panel">
|
||||
<mnt-dialog-card
|
||||
content-class="pa-2 flex-grow-1 overflow-y-auto"
|
||||
:dialog-subtitle="dialogSubtitle"
|
||||
:dialog-title="dialogTitle"
|
||||
:is-edit-mode="isEditMode"
|
||||
:is-view-mode="isViewMode"
|
||||
width="100%"
|
||||
>
|
||||
<template #toolbar>
|
||||
<mnt-record-nav-toolbar
|
||||
edit-label="進入編輯"
|
||||
first-label="第一筆"
|
||||
:has-next-record="hasNextRecord"
|
||||
:has-prev-record="hasPrevRecord"
|
||||
:is-edit-mode="isEditMode"
|
||||
:is-view-mode="isViewMode"
|
||||
last-label="最後一筆"
|
||||
view-label="回到檢視"
|
||||
@first="openEdgeRecord('first')"
|
||||
@last="openEdgeRecord('last')"
|
||||
@next="openAdjacentRecord('next')"
|
||||
@prev="openAdjacentRecord('prev')"
|
||||
@switch-to-edit="switchToEditMode"
|
||||
@switch-to-view="switchToViewMode"
|
||||
/>
|
||||
</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="scrollToField(error.field)"
|
||||
>
|
||||
{{ error.message }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-alert>
|
||||
|
||||
<!-- 編輯/檢視載入中骨架 -->
|
||||
<v-skeleton-loader
|
||||
v-if="isLoading"
|
||||
class="mt-4"
|
||||
type="subtitle,paragraph"
|
||||
width="100%"
|
||||
/>
|
||||
|
||||
<!-- 表單:檢視模式使用 readonly,避免 focus 狀態 -->
|
||||
<v-form
|
||||
v-else
|
||||
:class="{ 'form-readonly': isFormReadonly }"
|
||||
@submit.prevent="requestSaveConfirmation"
|
||||
>
|
||||
<v-row density="compact">
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
id="field-studentId"
|
||||
v-model="form.studentId"
|
||||
density="comfortable"
|
||||
:disabled="isFormLocked"
|
||||
:error-messages="fieldErrors.studentId"
|
||||
label="學號"
|
||||
placeholder="例如:S2024008"
|
||||
:readonly="isFormReadonly"
|
||||
variant="outlined"
|
||||
@update:model-value="clearFieldError('studentId')"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
id="field-name"
|
||||
v-model="form.name"
|
||||
density="comfortable"
|
||||
:disabled="isFormLocked"
|
||||
:error-messages="fieldErrors.name"
|
||||
label="姓名"
|
||||
placeholder="例如:陳怡君"
|
||||
:readonly="isFormReadonly"
|
||||
variant="outlined"
|
||||
@update:model-value="clearFieldError('name')"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-select
|
||||
id="field-department"
|
||||
v-model="form.department"
|
||||
density="comfortable"
|
||||
:disabled="isFormLocked"
|
||||
:error-messages="fieldErrors.department"
|
||||
:items="departments"
|
||||
label="系所"
|
||||
:readonly="isFormReadonly"
|
||||
variant="outlined"
|
||||
@update:model-value="clearFieldError('department')"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-select
|
||||
id="field-grade"
|
||||
v-model="form.grade"
|
||||
density="comfortable"
|
||||
:disabled="isFormLocked"
|
||||
:error-messages="fieldErrors.grade"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
:items="gradeOptions"
|
||||
label="年級"
|
||||
:readonly="isFormReadonly"
|
||||
variant="outlined"
|
||||
@update:model-value="clearFieldError('grade')"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-select
|
||||
id="field-enrollYear"
|
||||
v-model="form.enrollYear"
|
||||
density="comfortable"
|
||||
:disabled="isFormLocked"
|
||||
:error-messages="fieldErrors.enrollYear"
|
||||
:items="enrollYears"
|
||||
label="入學年度"
|
||||
:readonly="isFormReadonly"
|
||||
variant="outlined"
|
||||
@update:model-value="clearFieldError('enrollYear')"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
id="field-credits"
|
||||
v-model.number="form.credits"
|
||||
density="comfortable"
|
||||
:disabled="isFormLocked"
|
||||
:error-messages="fieldErrors.credits"
|
||||
label="已修學分"
|
||||
min="0"
|
||||
:readonly="isFormReadonly"
|
||||
type="number"
|
||||
variant="outlined"
|
||||
@update:model-value="clearFieldError('credits')"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
id="field-advisor"
|
||||
v-model="form.advisor"
|
||||
density="comfortable"
|
||||
:disabled="isFormLocked"
|
||||
:error-messages="fieldErrors.advisor"
|
||||
label="指導老師"
|
||||
placeholder="例如:林教授"
|
||||
:readonly="isFormReadonly"
|
||||
variant="outlined"
|
||||
@update:model-value="clearFieldError('advisor')"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
id="field-email"
|
||||
v-model="form.email"
|
||||
density="comfortable"
|
||||
:disabled="isFormLocked"
|
||||
:error-messages="fieldErrors.email"
|
||||
label="Email"
|
||||
placeholder="name@school.edu"
|
||||
:readonly="isFormReadonly"
|
||||
variant="outlined"
|
||||
@update:model-value="clearFieldError('email')"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
id="field-phone"
|
||||
v-model="form.phone"
|
||||
density="comfortable"
|
||||
:disabled="isFormLocked"
|
||||
:error-messages="fieldErrors.phone"
|
||||
label="電話"
|
||||
placeholder="例如:02-2345-6789"
|
||||
:readonly="isFormReadonly"
|
||||
variant="outlined"
|
||||
@update:model-value="clearFieldError('phone')"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-select
|
||||
id="field-status"
|
||||
v-model="form.status"
|
||||
density="comfortable"
|
||||
:disabled="isFormLocked"
|
||||
:error-messages="fieldErrors.status"
|
||||
:items="statuses"
|
||||
label="狀態"
|
||||
:readonly="isFormReadonly"
|
||||
variant="outlined"
|
||||
@update:model-value="clearFieldError('status')"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-form>
|
||||
</template>
|
||||
<template #actions>
|
||||
<v-spacer />
|
||||
<v-btn :disabled="isSaving" variant="text" @click="requestCloseDialog">取消</v-btn>
|
||||
<v-btn
|
||||
v-if="isEditMode"
|
||||
color="error"
|
||||
:disabled="isSaving"
|
||||
variant="tonal"
|
||||
@click="requestDeleteCurrent"
|
||||
>
|
||||
刪除
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="!isViewMode"
|
||||
color="primary"
|
||||
:disabled="!isDirty || isLoading"
|
||||
:loading="isSaving"
|
||||
variant="flat"
|
||||
@click="requestSaveConfirmation"
|
||||
>
|
||||
儲存
|
||||
</v-btn>
|
||||
<v-btn v-else color="primary" variant="flat" @click="requestCloseDialog">關閉</v-btn>
|
||||
</template>
|
||||
</mnt-dialog-card>
|
||||
</div>
|
||||
</v-overlay>
|
||||
</teleport>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model="confirmCloseVisible"
|
||||
confirm-color="error"
|
||||
confirm-text="關閉不儲存"
|
||||
message="目前有尚未儲存的內容,確定要關閉嗎?"
|
||||
title="未儲存變更"
|
||||
@confirm="confirmClose"
|
||||
<SectionFormPanel
|
||||
v-bind="page.formPanelProps.value"
|
||||
v-on="page.formPanelEvents"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model="confirmSaveVisible"
|
||||
:confirm-loading="isSaving"
|
||||
confirm-text="確認儲存"
|
||||
max-width="520"
|
||||
title="確認儲存變更"
|
||||
@confirm="confirmSave"
|
||||
>
|
||||
<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
|
||||
v-model="confirmDeleteVisible"
|
||||
confirm-color="error"
|
||||
confirm-text="確定刪除"
|
||||
:message="`確定要刪除 ${pendingDeleteLabel} 嗎?此操作無法復原。`"
|
||||
title="確認刪除"
|
||||
@confirm="confirmDelete"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model="confirmSwitchVisible"
|
||||
confirm-text="確定切換"
|
||||
max-width="480"
|
||||
message="目前有尚未儲存的內容,切換為檢視模式將會捨棄變更,確定要切換嗎?"
|
||||
title="未儲存變更"
|
||||
@confirm="confirmSwitch"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model="confirmNavigateVisible"
|
||||
confirm-text="確定切換"
|
||||
max-width="480"
|
||||
message="目前有尚未儲存的內容,切換到其他資料將會捨棄變更,確定要切換嗎?"
|
||||
title="未儲存變更"
|
||||
@confirm="confirmNavigate"
|
||||
/>
|
||||
|
||||
<!-- 成功提示 -->
|
||||
<v-snackbar v-model="snackbarVisible" color="success" location="bottom right" :timeout="2200">
|
||||
<v-snackbar v-model="page.snackbarVisible.value" color="success" location="bottom right" :timeout="2200">
|
||||
儲存成功
|
||||
</v-snackbar>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { mdiBroom, mdiDelete, mdiEye, mdiMagnify, mdiPencil } from '@mdi/js'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import ConfirmDialog from '@/components/maint/CommonConfirmDialog.vue'
|
||||
import MntDialogCard from '@/components/maint/MntDialogCard.vue'
|
||||
import MntRecordNavToolbar from '@/components/maint/MntRecordNavToolbar.vue'
|
||||
import PageMaint from '@/components/PageMaint.vue'
|
||||
import { useMaintenanceCrudFlow } from '@/composables/maint/useMaintenanceCrudFlow'
|
||||
import { useStudentMaintenanceForm } from '@/composables/maint/useStudentMaintenanceForm'
|
||||
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 { smAndUp } = useDisplay()
|
||||
|
||||
// 表格欄位設定(含固定欄與排序)
|
||||
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' },
|
||||
},
|
||||
])
|
||||
|
||||
// 查詢條件(示意用,未接 API)
|
||||
const search = ref({
|
||||
studentId: '',
|
||||
name: '',
|
||||
department: '',
|
||||
grade: null as number | null,
|
||||
status: '',
|
||||
})
|
||||
// 查詢區塊是否展開
|
||||
const searchPanelOpen = ref(false)
|
||||
|
||||
// 透過 store 管理 Demo 資料與 CRUD
|
||||
const studentStore = useStudentStore()
|
||||
const students = computed(() => studentStore.students)
|
||||
type StudentPayload = Omit<StudentRecord, 'id'>
|
||||
const itemsPerPage = 10
|
||||
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)
|
||||
// UI 回饋
|
||||
const snackbarVisible = ref(false)
|
||||
const highlightedId = ref<number | null>(null)
|
||||
// 防止快速切換導致的異步覆蓋
|
||||
const loadSequence = ref(0)
|
||||
const {
|
||||
errorSummary,
|
||||
fieldErrors,
|
||||
form,
|
||||
isDirty,
|
||||
saveSummary,
|
||||
clearAllErrors,
|
||||
clearFieldError,
|
||||
gradeLabel,
|
||||
resetForm,
|
||||
rowProps,
|
||||
setForm,
|
||||
statusColor,
|
||||
syncInitialForm,
|
||||
validateForm,
|
||||
} = useStudentMaintenanceForm({
|
||||
departments,
|
||||
gradeOptions,
|
||||
enrollYears,
|
||||
statuses,
|
||||
students,
|
||||
editingId,
|
||||
highlightedId,
|
||||
})
|
||||
|
||||
// 表單欄位簡單排版(無分組)
|
||||
|
||||
// 彈窗標題/副標題
|
||||
const dialogTitle = computed(() => {
|
||||
if (dialogMode.value === 'view') return '檢視資料示範'
|
||||
if (dialogMode.value === 'edit') return '修改資料示範'
|
||||
return '新增資料示範'
|
||||
})
|
||||
const dialogSubtitle = computed(() => {
|
||||
if (!editingId.value) return ''
|
||||
return `${form.value.studentId || '未填學號'}・${form.value.name || '未填姓名'}`
|
||||
})
|
||||
// 是否有修改(用於啟用儲存與提示)
|
||||
// 載入/儲存時鎖定、檢視模式 readonly
|
||||
const isFormLocked = computed(() => isLoading.value || isSaving.value)
|
||||
const {
|
||||
confirmClose,
|
||||
confirmCloseVisible,
|
||||
confirmDelete,
|
||||
confirmDeleteVisible,
|
||||
confirmNavigate,
|
||||
confirmNavigateVisible,
|
||||
confirmSaveVisible,
|
||||
confirmSwitch,
|
||||
confirmSwitchVisible,
|
||||
handleDialogVisibility,
|
||||
hasNextRecord,
|
||||
hasPrevRecord,
|
||||
isEditMode,
|
||||
isViewMode,
|
||||
openAdjacentRecord,
|
||||
openEdgeRecord,
|
||||
pendingDeleteLabel,
|
||||
requestCloseDialog,
|
||||
requestDeleteConfirmation,
|
||||
requestDeleteCurrent,
|
||||
switchToEditMode,
|
||||
switchToViewMode,
|
||||
} = useMaintenanceCrudFlow<StudentRecord>({
|
||||
records: students,
|
||||
editingId,
|
||||
dialogMode,
|
||||
dialogVisible,
|
||||
isLoading,
|
||||
isSaving,
|
||||
isDirty,
|
||||
clearAllErrors,
|
||||
resetForm,
|
||||
openEditDialog,
|
||||
openViewDialog,
|
||||
removeRecord: (id) => {
|
||||
studentStore.removeStudent(id)
|
||||
},
|
||||
describeRecord: (student) => `${student.studentId} ${student.name}`,
|
||||
})
|
||||
const isFormReadonly = computed(() => isViewMode.value)
|
||||
|
||||
// 重設查詢條件
|
||||
function resetSearch() {
|
||||
search.value = {
|
||||
studentId: '',
|
||||
name: '',
|
||||
department: '',
|
||||
grade: null,
|
||||
status: '',
|
||||
}
|
||||
}
|
||||
|
||||
watch(pageCount, (value) => {
|
||||
if (currentPage.value > value) {
|
||||
currentPage.value = value
|
||||
}
|
||||
})
|
||||
|
||||
// 新增:開啟彈窗,使用預設值
|
||||
function openAddDialog() {
|
||||
loadSequence.value += 1
|
||||
dialogMode.value = 'create'
|
||||
editingId.value = null
|
||||
resetForm()
|
||||
isLoading.value = false
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 編輯:先開彈窗,資料載入後填入
|
||||
function openEditDialog(student: StudentRecord) {
|
||||
loadSequence.value += 1
|
||||
const sequence = loadSequence.value
|
||||
dialogMode.value = 'edit'
|
||||
editingId.value = student.id
|
||||
dialogVisible.value = true
|
||||
isLoading.value = true
|
||||
clearAllErrors()
|
||||
setTimeout(() => {
|
||||
if (sequence !== loadSequence.value || !dialogVisible.value) return
|
||||
setForm({
|
||||
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,
|
||||
})
|
||||
syncInitialForm()
|
||||
isLoading.value = false
|
||||
}, 350)
|
||||
}
|
||||
|
||||
// 檢視:只讀模式並預設展開所有分組
|
||||
function openViewDialog(student: StudentRecord) {
|
||||
loadSequence.value += 1
|
||||
const sequence = loadSequence.value
|
||||
dialogMode.value = 'view'
|
||||
editingId.value = student.id
|
||||
dialogVisible.value = true
|
||||
isLoading.value = true
|
||||
clearAllErrors()
|
||||
setTimeout(() => {
|
||||
if (sequence !== loadSequence.value || !dialogVisible.value) return
|
||||
setForm({
|
||||
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,
|
||||
})
|
||||
syncInitialForm()
|
||||
isLoading.value = false
|
||||
}, 350)
|
||||
}
|
||||
|
||||
// 先檢核再提示儲存確認
|
||||
async function requestSaveConfirmation() {
|
||||
if (isSaving.value || isLoading.value || !isDirty.value || isViewMode.value) return
|
||||
clearAllErrors()
|
||||
|
||||
const errors = validateForm()
|
||||
if (errors.length > 0) {
|
||||
for (const error of errors) {
|
||||
fieldErrors.value[error.field] = [error.message]
|
||||
}
|
||||
await nextTick()
|
||||
const firstError = errors[0]
|
||||
if (firstError) scrollToField(firstError.field)
|
||||
return
|
||||
}
|
||||
|
||||
confirmSaveVisible.value = true
|
||||
}
|
||||
|
||||
// 儲存確認後才真正送出
|
||||
function confirmSave() {
|
||||
confirmSaveVisible.value = false
|
||||
saveStudent()
|
||||
}
|
||||
|
||||
// 寫入資料(Demo:直接更新列表)
|
||||
async function saveStudent() {
|
||||
if (isSaving.value || isLoading.value) return
|
||||
isSaving.value = true
|
||||
await new Promise((resolve) => setTimeout(resolve, 450))
|
||||
const payload = {
|
||||
studentId: form.value.studentId.trim(),
|
||||
name: form.value.name.trim(),
|
||||
department: form.value.department,
|
||||
grade: form.value.grade,
|
||||
enrollYear: form.value.enrollYear,
|
||||
credits: form.value.credits,
|
||||
advisor: form.value.advisor.trim(),
|
||||
email: form.value.email.trim(),
|
||||
phone: form.value.phone.trim(),
|
||||
status: form.value.status,
|
||||
} as StudentPayload
|
||||
|
||||
if (editingId.value) {
|
||||
const updated = studentStore.updateStudent(editingId.value, payload)
|
||||
if (updated) highlightedId.value = editingId.value
|
||||
} else {
|
||||
const createdId = studentStore.addStudent(payload)
|
||||
highlightedId.value = createdId
|
||||
}
|
||||
|
||||
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' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dialog-overlay :deep(.v-overlay__content) {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
.dialog-panel {
|
||||
width: 760px;
|
||||
max-width: 100%;
|
||||
height: 100vh;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
padding: 12px;
|
||||
box-shadow: -12px 0 24px rgba(0, 0, 0, 0.18);
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background: rgba(var(--v-theme-surface), 0.95);
|
||||
border-top: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.dialog-title {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: rgba(var(--v-theme-surface), 0.95);
|
||||
border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.student-table {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.student-table :deep(table) {
|
||||
min-width: 1400px;
|
||||
}
|
||||
|
||||
.student-table :deep(th),
|
||||
.student-table :deep(td) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.student-table :deep(.v-data-table-column--fixed),
|
||||
.student-table :deep(.v-data-table-column--fixed-end) {
|
||||
background: rgb(var(--v-theme-surface));
|
||||
}
|
||||
|
||||
.student-table :deep(.v-data-table-column--fixed-last-start)::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -5px;
|
||||
bottom: 0;
|
||||
width: 5px;
|
||||
box-shadow: inset 10px 0 8px -8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.student-table :deep(.v-data-table-footer) {
|
||||
padding: 4px 0 0;
|
||||
}
|
||||
|
||||
/* 直線 */
|
||||
/* .student-table :deep(.v-data-table-column--first-fixed-end),
|
||||
.student-table :deep(.v-data-table-column--last-fixed) {
|
||||
border-left: none !important;
|
||||
border-right: none !important;
|
||||
} */
|
||||
|
||||
.form-readonly :deep(.v-field) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
tbody tr.is-highlighted {
|
||||
animation: row-highlight 1.6s ease-out;
|
||||
}
|
||||
|
||||
@keyframes row-highlight {
|
||||
0% {
|
||||
background-color: rgba(var(--v-theme-primary), 0.18);
|
||||
}
|
||||
|
||||
100% {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user