Refactor MasterDetailMntC.vue for improved readability and consistency

This commit is contained in:
skytek_xinliang
2026-03-30 09:18:55 +08:00
parent 7591ecd062
commit 16b58fbf7a
66 changed files with 2071 additions and 777 deletions
+2 -2
View File
@@ -2,14 +2,14 @@ import { computed } from 'vue'
import { useTheme } from 'vuetify'
import { getNextThemeName } from '@/utils/theme'
export function useThemeToggle () {
export function useThemeToggle() {
const theme = useTheme()
const availableThemeNames = computed(() =>
Object.keys(theme.themes.value ?? {}).filter((name) => name.startsWith('theme'))
)
function toggleTheme () {
function toggleTheme() {
const names = availableThemeNames.value
if (names.length === 0) return null
@@ -16,23 +16,100 @@ const statuses = ['在學', '休學', '畢業']
const tableHeaders = [
{ title: '', key: 'select', sortable: false, nowrap: true },
{ title: '學號', key: 'studentId', sortable: true, minWidth: 120, nowrap: true, cellProps: { class: 'px-1' } },
{ title: '姓名', key: 'name', sortable: true, minWidth: 120, nowrap: true, cellProps: { class: 'px-1' } },
{ title: '系所', key: 'department', sortable: true, minWidth: 140, nowrap: true, cellProps: { class: 'px-1' } },
{ title: '年級', key: 'grade', sortable: true, minWidth: 140, nowrap: true, cellProps: { class: 'px-1' } },
{ title: '入學年度', key: 'enrollYear', sortable: true, minWidth: 120, nowrap: true, cellProps: { class: 'px-1' } },
{ title: '已修學分', key: 'credits', sortable: true, minWidth: 120, nowrap: true, cellProps: { class: 'px-1' } },
{ title: '指導老師', key: 'advisor', sortable: true, minWidth: 120, nowrap: true, cellProps: { class: 'px-1' } },
{ title: 'Email', key: 'email', sortable: true, minWidth: 200, nowrap: true, cellProps: { class: 'px-1' } },
{ title: '電話', key: 'phone', sortable: true, minWidth: 150, nowrap: true, cellProps: { class: 'px-1' } },
{ title: '狀態', key: 'status', sortable: true, minWidth: 120, nowrap: true, cellProps: { class: 'px-1' } },
{ title: '操作', key: 'actions', sortable: false, width: 'auto', nowrap: true, cellProps: { class: 'bg-background' } },
{
title: '學號',
key: 'studentId',
sortable: true,
minWidth: 120,
nowrap: true,
cellProps: { class: 'px-1' },
},
{
title: '姓名',
key: 'name',
sortable: true,
minWidth: 120,
nowrap: true,
cellProps: { class: 'px-1' },
},
{
title: '系所',
key: 'department',
sortable: true,
minWidth: 140,
nowrap: true,
cellProps: { class: 'px-1' },
},
{
title: '年級',
key: 'grade',
sortable: true,
minWidth: 140,
nowrap: true,
cellProps: { class: 'px-1' },
},
{
title: '入學年度',
key: 'enrollYear',
sortable: true,
minWidth: 120,
nowrap: true,
cellProps: { class: 'px-1' },
},
{
title: '已修學分',
key: 'credits',
sortable: true,
minWidth: 120,
nowrap: true,
cellProps: { class: 'px-1' },
},
{
title: '指導老師',
key: 'advisor',
sortable: true,
minWidth: 120,
nowrap: true,
cellProps: { class: 'px-1' },
},
{
title: 'Email',
key: 'email',
sortable: true,
minWidth: 200,
nowrap: true,
cellProps: { class: 'px-1' },
},
{
title: '電話',
key: 'phone',
sortable: true,
minWidth: 150,
nowrap: true,
cellProps: { class: 'px-1' },
},
{
title: '狀態',
key: 'status',
sortable: true,
minWidth: 120,
nowrap: true,
cellProps: { class: 'px-1' },
},
{
title: '操作',
key: 'actions',
sortable: false,
width: 'auto',
nowrap: true,
cellProps: { class: 'bg-background' },
},
] as const
const TABLE_BOTTOM_GAP = 64
const TABLE_MIN_HEIGHT = 240
export function useEditableStudentGrid () {
export function useEditableStudentGrid() {
const studentStore = useStudentStore()
const students = computed(() => studentStore.students)
const search = ref({
@@ -49,7 +126,7 @@ export function useEditableStudentGrid () {
const draftRows = ref<Record<number, StudentPayload>>({})
const selectedRowIds = ref<number[]>([])
function toPayload (student: StudentRecord): StudentPayload {
function toPayload(student: StudentRecord): StudentPayload {
return {
studentId: student.studentId,
name: student.name,
@@ -64,17 +141,17 @@ export function useEditableStudentGrid () {
}
}
function rebuildDraftRows () {
function rebuildDraftRows() {
draftRows.value = Object.fromEntries(students.value.map((item) => [item.id, toPayload(item)]))
}
function getDraftRow (id: number) {
function getDraftRow(id: number) {
return draftRows.value[id] ?? null
}
const getSourceRow = (id: number) => students.value.find((item) => item.id === id) || null
function isRowDirty (id: number) {
function isRowDirty(id: number) {
const source = getSourceRow(id)
const draft = getDraftRow(id)
if (!source || !draft) return false
@@ -112,34 +189,42 @@ export function useEditableStudentGrid () {
})
const visibleStudentIds = computed(() => filteredStudents.value.map((item) => item.id))
const isAllVisibleSelected = computed(() =>
visibleStudentIds.value.length > 0 &&
visibleStudentIds.value.every((id) => selectedRowIds.value.includes(id))
const isAllVisibleSelected = computed(
() =>
visibleStudentIds.value.length > 0 &&
visibleStudentIds.value.every((id) => selectedRowIds.value.includes(id))
)
const isPartiallyVisibleSelected = computed(() =>
visibleStudentIds.value.some((id) => selectedRowIds.value.includes(id)) &&
!isAllVisibleSelected.value
const isPartiallyVisibleSelected = computed(
() =>
visibleStudentIds.value.some((id) => selectedRowIds.value.includes(id)) &&
!isAllVisibleSelected.value
)
const hasAnyChange = computed(() =>
students.value.some((item) => isRowDirty(item.id)) || studentStore.deletedIds.size > 0
const hasAnyChange = computed(
() => students.value.some((item) => isRowDirty(item.id)) || studentStore.deletedIds.size > 0
)
const hasSelectedRows = computed(() => selectedRowIds.value.length > 0)
function toggleSelectAll (checked: boolean | null) {
function toggleSelectAll(checked: boolean | null) {
if (isPartiallyVisibleSelected.value) {
selectedRowIds.value = selectedRowIds.value.filter((id) => !visibleStudentIds.value.includes(id))
selectedRowIds.value = selectedRowIds.value.filter(
(id) => !visibleStudentIds.value.includes(id)
)
return
}
if (!checked) {
selectedRowIds.value = selectedRowIds.value.filter((id) => !visibleStudentIds.value.includes(id))
selectedRowIds.value = selectedRowIds.value.filter(
(id) => !visibleStudentIds.value.includes(id)
)
return
}
selectedRowIds.value = Array.from(new Set([...selectedRowIds.value, ...visibleStudentIds.value]))
selectedRowIds.value = Array.from(
new Set([...selectedRowIds.value, ...visibleStudentIds.value])
)
}
function toggleSingleRowSelection (id: number, checked: boolean | null) {
function toggleSingleRowSelection(id: number, checked: boolean | null) {
if (checked) {
if (!selectedRowIds.value.includes(id)) {
selectedRowIds.value = [...selectedRowIds.value, id]
@@ -150,13 +235,13 @@ export function useEditableStudentGrid () {
selectedRowIds.value = selectedRowIds.value.filter((selectedId) => selectedId !== id)
}
function deleteSingleRow (id: number) {
function deleteSingleRow(id: number) {
selectedRowIds.value = selectedRowIds.value.filter((selectedId) => selectedId !== id)
studentStore.markAsDeleted(id)
rebuildDraftRows()
}
function deleteSelectedRows () {
function deleteSelectedRows() {
if (selectedRowIds.value.length === 0) return
for (const id of selectedRowIds.value) {
@@ -167,7 +252,7 @@ export function useEditableStudentGrid () {
rebuildDraftRows()
}
function saveAllRows () {
function saveAllRows() {
for (const item of students.value) {
const draft = getDraftRow(item.id)
if (!draft) continue
@@ -179,17 +264,18 @@ export function useEditableStudentGrid () {
rebuildDraftRows()
}
function resetAllRows () {
function resetAllRows() {
studentStore.restoreDeleted()
selectedRowIds.value = []
rebuildDraftRows()
}
function recalculateTableHeight () {
function recalculateTableHeight() {
const container = tableContainerRef.value
if (!container) return
const scrollParent = tableScrollParentRef.value || (container.closest('.overflow-auto') as HTMLElement | null)
const scrollParent =
tableScrollParentRef.value || (container.closest('.overflow-auto') as HTMLElement | null)
tableScrollParentRef.value = scrollParent
const parentBottom = scrollParent
@@ -49,8 +49,8 @@ interface UseMaintenanceCrudFlowResult<T extends { id: number }> {
handleDialogVisibility: (nextValue: boolean) => void
}
export function useMaintenanceCrudFlow<T extends { id: number }> (
options: UseMaintenanceCrudFlowOptions<T>,
export function useMaintenanceCrudFlow<T extends { id: number }>(
options: UseMaintenanceCrudFlowOptions<T>
): UseMaintenanceCrudFlowResult<T> {
const confirmCloseVisible = ref(false)
const confirmSaveVisible = ref(false)
@@ -64,23 +64,22 @@ export function useMaintenanceCrudFlow<T extends { id: number }> (
const isEditMode = computed(() => options.dialogMode.value === 'edit')
const isViewMode = computed(() => options.dialogMode.value === 'view')
const currentRecordIndex = computed(() =>
options.records.value.findIndex((item) => item.id === options.editingId.value),
options.records.value.findIndex((item) => item.id === options.editingId.value)
)
const currentEditingRecord = computed(
() => options.records.value.find((item) => item.id === options.editingId.value) || null,
() => options.records.value.find((item) => item.id === options.editingId.value) || null
)
const hasPrevRecord = computed(() => currentRecordIndex.value > 0)
const hasNextRecord = computed(
() =>
currentRecordIndex.value >= 0
&& currentRecordIndex.value < options.records.value.length - 1,
currentRecordIndex.value >= 0 && currentRecordIndex.value < options.records.value.length - 1
)
const pendingDeleteLabel = computed(() => {
if (!pendingDelete.value) return '這筆資料'
return options.describeRecord(pendingDelete.value)
})
function openAdjacentRecord (direction: 'prev' | 'next') {
function openAdjacentRecord(direction: 'prev' | 'next') {
if (!isViewMode.value && !isEditMode.value) return
const index = currentRecordIndex.value
if (index < 0) return
@@ -99,7 +98,7 @@ export function useMaintenanceCrudFlow<T extends { id: number }> (
options.openViewDialog(target)
}
function openEdgeRecord (position: 'first' | 'last') {
function openEdgeRecord(position: 'first' | 'last') {
if (!isViewMode.value && !isEditMode.value) return
if (options.records.value.length === 0) return
const target = position === 'first' ? options.records.value[0] : options.records.value.at(-1)
@@ -116,14 +115,14 @@ export function useMaintenanceCrudFlow<T extends { id: number }> (
options.openViewDialog(target)
}
function switchToEditMode () {
function switchToEditMode() {
if (!isViewMode.value) return
const current = currentEditingRecord.value
if (!current) return
options.openEditDialog(current)
}
function switchToViewMode () {
function switchToViewMode() {
if (!isEditMode.value) return
const current = currentEditingRecord.value
if (!current) return
@@ -135,7 +134,7 @@ export function useMaintenanceCrudFlow<T extends { id: number }> (
options.openViewDialog(current)
}
function confirmSwitch () {
function confirmSwitch() {
const target = pendingSwitchTarget.value
pendingSwitchTarget.value = null
confirmSwitchVisible.value = false
@@ -143,7 +142,7 @@ export function useMaintenanceCrudFlow<T extends { id: number }> (
options.openViewDialog(target)
}
function confirmNavigate () {
function confirmNavigate() {
const target = pendingNavigateTarget.value
pendingNavigateTarget.value = null
confirmNavigateVisible.value = false
@@ -151,18 +150,18 @@ export function useMaintenanceCrudFlow<T extends { id: number }> (
options.openEditDialog(target)
}
function requestDeleteConfirmation (record: T) {
function requestDeleteConfirmation(record: T) {
pendingDelete.value = record
confirmDeleteVisible.value = true
}
function requestDeleteCurrent () {
function requestDeleteCurrent() {
const current = currentEditingRecord.value
if (!current) return
requestDeleteConfirmation(current)
}
function closeDialog () {
function closeDialog() {
options.dialogVisible.value = false
options.isLoading.value = false
options.isSaving.value = false
@@ -181,7 +180,7 @@ export function useMaintenanceCrudFlow<T extends { id: number }> (
options.onCloseReset?.()
}
function confirmDelete () {
function confirmDelete() {
if (!pendingDelete.value) return
const deletedId = pendingDelete.value.id
options.removeRecord(deletedId)
@@ -193,7 +192,7 @@ export function useMaintenanceCrudFlow<T extends { id: number }> (
}
}
function requestCloseDialog () {
function requestCloseDialog() {
if (options.isDirty.value && !options.isSaving.value) {
confirmCloseVisible.value = true
return
@@ -201,12 +200,12 @@ export function useMaintenanceCrudFlow<T extends { id: number }> (
closeDialog()
}
function confirmClose () {
function confirmClose() {
confirmCloseVisible.value = false
closeDialog()
}
function handleDialogVisibility (nextValue: boolean) {
function handleDialogVisibility(nextValue: boolean) {
if (nextValue) {
options.dialogVisible.value = true
return
@@ -49,11 +49,11 @@ const fieldLabels: Record<keyof StudentFormState, string> = {
status: '狀態',
}
function createDefaultForm (
function createDefaultForm(
departments: string[],
gradeOptions: GradeOption[],
enrollYears: number[],
statuses: string[],
statuses: string[]
): StudentFormState {
return {
studentId: '',
@@ -69,7 +69,7 @@ function createDefaultForm (
}
}
function createEmptyFieldErrors () {
function createEmptyFieldErrors() {
return {
studentId: [],
name: [],
@@ -84,22 +84,25 @@ function createEmptyFieldErrors () {
} as Record<keyof StudentFormState, string[]>
}
export function useStudentMaintenanceForm (options: UseStudentMaintenanceFormOptions) {
export function useStudentMaintenanceForm(options: UseStudentMaintenanceFormOptions) {
const form = ref<StudentFormState>(
createDefaultForm(options.departments, options.gradeOptions, options.enrollYears, options.statuses),
createDefaultForm(
options.departments,
options.gradeOptions,
options.enrollYears,
options.statuses
)
)
const initialForm = ref<StudentFormState>({ ...form.value })
const fieldErrors = ref(createEmptyFieldErrors())
const isDirty = computed(
() => JSON.stringify(form.value) !== JSON.stringify(initialForm.value),
)
const isDirty = computed(() => JSON.stringify(form.value) !== JSON.stringify(initialForm.value))
function gradeLabel (grade: number) {
function gradeLabel(grade: number) {
return options.gradeOptions.find((option) => option.value === grade)?.title ?? `年級 ${grade}`
}
function formatSummaryValue (key: string, value: string | number | null | undefined) {
function formatSummaryValue(key: string, value: string | number | null | undefined) {
if (value === null || value === undefined || value === '') return '—'
if (key === 'grade') return gradeLabel(Number(value))
return String(value)
@@ -124,40 +127,40 @@ export function useStudentMaintenanceForm (options: UseStudentMaintenanceFormOpt
const errorSummary = computed(() => {
const entries = Object.entries(fieldErrors.value).flatMap(([field, messages]) =>
messages.map((message) => ({ field, message })),
messages.map((message) => ({ field, message }))
)
return entries.slice(0, 3)
})
function setForm (nextForm: StudentFormState) {
function setForm(nextForm: StudentFormState) {
form.value = { ...nextForm }
}
function syncInitialForm () {
function syncInitialForm() {
initialForm.value = { ...form.value }
}
function resetForm () {
function resetForm() {
form.value = createDefaultForm(
options.departments,
options.gradeOptions,
options.enrollYears,
options.statuses,
options.statuses
)
syncInitialForm()
clearAllErrors()
}
function clearAllErrors () {
function clearAllErrors() {
fieldErrors.value = createEmptyFieldErrors()
}
function clearFieldError (field: keyof StudentFormState | string) {
function clearFieldError(field: keyof StudentFormState | string) {
if (!fieldErrors.value[field as keyof StudentFormState]?.length) return
fieldErrors.value[field as keyof StudentFormState] = []
}
function validateForm () {
function validateForm() {
const errors: Array<{ field: keyof StudentFormState; message: string }> = []
const studentId = form.value.studentId.trim()
const name = form.value.name.trim()
@@ -180,7 +183,7 @@ export function useStudentMaintenanceForm (options: UseStudentMaintenanceFormOpt
}
const duplicate = options.students.value.find(
(item) => item.studentId === studentId && item.id !== options.editingId.value,
(item) => item.studentId === studentId && item.id !== options.editingId.value
)
if (studentId && duplicate) {
errors.push({ field: 'studentId', message: '學號已存在,請確認是否重複' })
@@ -189,14 +192,14 @@ export function useStudentMaintenanceForm (options: UseStudentMaintenanceFormOpt
return errors
}
function statusColor (status: string) {
function statusColor(status: string) {
if (status === '在學') return 'success'
if (status === '休學') return 'warning'
if (status === '畢業') return 'secondary'
return 'default'
}
function rowProps (data: { item: StudentRecord }) {
function rowProps(data: { item: StudentRecord }) {
return {
class: data.item.id === options.highlightedId.value ? 'is-highlighted' : '',
}
+6 -4
View File
@@ -18,19 +18,21 @@ interface UseApiCallResult<TResult, TArgs extends unknown[]> {
reset: () => void
}
function getDefaultToastLevel (error: ApiRequestError): ToastLevel {
function getDefaultToastLevel(error: ApiRequestError): ToastLevel {
if (typeof error.status === 'number' && error.status >= 500) return 'error'
return 'warning'
}
function levelToColor (level: ToastLevel): string {
function levelToColor(level: ToastLevel): string {
if (level === 'error') return 'error'
if (level === 'warning') return 'warning'
return 'info'
}
export function useApiCall <TResult, TArgs extends unknown[]>(action: (...args: TArgs) => Promise<TResult>,
options?: Options): UseApiCallResult<TResult, TArgs> {
export function useApiCall<TResult, TArgs extends unknown[]>(
action: (...args: TArgs) => Promise<TResult>,
options?: Options
): UseApiCallResult<TResult, TArgs> {
const loading = ref(false)
const data = ref<TResult | null>(null) as Ref<TResult | null>
const error = ref<ApiRequestError | null>(null)