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