refactor: replace common confirm dialogs with maintenance CRUD dialogs and streamline form handling in MasterDetailMntC.vue and SingleRecordMnt.vue
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch, type Ref } from 'vue'
|
||||
import type { AdminLayoutMenuItem } from '@/components/layouts/sk-admin-layout/types'
|
||||
|
||||
type ToggleSidebarPayload = {
|
||||
drawer: boolean
|
||||
rail: boolean
|
||||
}
|
||||
|
||||
type UseAdminLayoutStateOptions = {
|
||||
appBarRef: Ref<unknown>
|
||||
breadcrumbBarVisible: boolean | null
|
||||
emitUpdateBreadcrumbBarVisible: (value: boolean) => void
|
||||
emitUpdateFavoritesBarVisible: (value: boolean) => void
|
||||
emitUpdateIsRail: (value: boolean) => void
|
||||
favoritesBarVisible: boolean | null
|
||||
isMobile: Ref<boolean>
|
||||
isRail: boolean | null
|
||||
menuItems: AdminLayoutMenuItem[]
|
||||
onToggleSidebar: (payload: ToggleSidebarPayload) => void
|
||||
}
|
||||
|
||||
export function useAdminLayoutState (options: UseAdminLayoutStateOptions) {
|
||||
const drawer = ref(true)
|
||||
const mobileFavoritesPanel = ref(false)
|
||||
const mobileMenuPath = ref<AdminLayoutMenuItem[]>([])
|
||||
const localBreadcrumbBarVisible = ref(true)
|
||||
const localFavoritesBarVisible = ref(true)
|
||||
const localIsRail = ref(false)
|
||||
const opened = ref<string[]>([])
|
||||
const appBarHeight = ref(0)
|
||||
|
||||
const isRail = computed({
|
||||
get: () => (options.isRail ?? localIsRail.value),
|
||||
set: (value: boolean) => {
|
||||
if (options.isRail === null) {
|
||||
localIsRail.value = value
|
||||
return
|
||||
}
|
||||
|
||||
options.emitUpdateIsRail(value)
|
||||
},
|
||||
})
|
||||
|
||||
const showFavoritesBar = computed({
|
||||
get: () => (options.favoritesBarVisible ?? localFavoritesBarVisible.value),
|
||||
set: (value: boolean) => {
|
||||
if (options.favoritesBarVisible === null) {
|
||||
localFavoritesBarVisible.value = value
|
||||
return
|
||||
}
|
||||
|
||||
options.emitUpdateFavoritesBarVisible(value)
|
||||
},
|
||||
})
|
||||
|
||||
const showBreadcrumbBar = computed({
|
||||
get: () => (options.breadcrumbBarVisible ?? localBreadcrumbBarVisible.value),
|
||||
set: (value: boolean) => {
|
||||
if (options.breadcrumbBarVisible === null) {
|
||||
localBreadcrumbBarVisible.value = value
|
||||
return
|
||||
}
|
||||
|
||||
options.emitUpdateBreadcrumbBarVisible(value)
|
||||
},
|
||||
})
|
||||
|
||||
const mobileCurrentItems = computed(() =>
|
||||
mobileMenuPath.value.reduce(
|
||||
(items, currentItem) => currentItem?.subItems ?? [],
|
||||
options.menuItems || []
|
||||
)
|
||||
)
|
||||
|
||||
const mobileCurrentLevel = computed(() => mobileMenuPath.value.length + 1)
|
||||
|
||||
const mobileMenuLevels = computed(() =>
|
||||
Array.from({ length: mobileCurrentLevel.value }, (_, index) => ({
|
||||
level: index + 1,
|
||||
title: index === 0 ? '主選單' : (mobileMenuPath.value[index - 1]?.title ?? `第${index + 1}層`),
|
||||
}))
|
||||
)
|
||||
|
||||
function resetMobilePanels () {
|
||||
mobileFavoritesPanel.value = false
|
||||
mobileMenuPath.value = []
|
||||
}
|
||||
|
||||
function toggleSidebar () {
|
||||
if (options.isMobile.value) {
|
||||
drawer.value = !drawer.value
|
||||
} else {
|
||||
isRail.value = !isRail.value
|
||||
}
|
||||
|
||||
options.onToggleSidebar({
|
||||
drawer: drawer.value,
|
||||
rail: isRail.value,
|
||||
})
|
||||
}
|
||||
|
||||
function goToMobileLevel (level: number) {
|
||||
mobileFavoritesPanel.value = false
|
||||
mobileMenuPath.value = mobileMenuPath.value.slice(0, Math.max(0, level - 1))
|
||||
}
|
||||
|
||||
function openMobileFavoritesPanel () {
|
||||
mobileMenuPath.value = []
|
||||
mobileFavoritesPanel.value = true
|
||||
}
|
||||
|
||||
function handleMobileMenuClick (
|
||||
item: AdminLayoutMenuItem,
|
||||
onSelect: (selectedItem: AdminLayoutMenuItem) => void
|
||||
) {
|
||||
if (item?.subItems?.length) {
|
||||
mobileMenuPath.value = [...mobileMenuPath.value, item]
|
||||
return
|
||||
}
|
||||
|
||||
onSelect(item)
|
||||
}
|
||||
|
||||
function handleSelectFavorite (
|
||||
item: AdminLayoutMenuItem,
|
||||
onSelect: (selectedItem: AdminLayoutMenuItem) => void
|
||||
) {
|
||||
onSelect(item)
|
||||
mobileFavoritesPanel.value = false
|
||||
}
|
||||
|
||||
function toggleFavoritesBar (nextValue?: boolean) {
|
||||
showFavoritesBar.value =
|
||||
typeof nextValue === 'boolean' ? nextValue : !showFavoritesBar.value
|
||||
}
|
||||
|
||||
function handleUnshrink () {
|
||||
isRail.value = false
|
||||
}
|
||||
|
||||
let appBarObserver: ResizeObserver | null = null
|
||||
|
||||
function resolveObservedElement () {
|
||||
const target = options.appBarRef.value as HTMLElement | { $el?: HTMLElement } | null
|
||||
if (!target) return null
|
||||
if (target instanceof HTMLElement) return target
|
||||
return target.$el ?? null
|
||||
}
|
||||
|
||||
function updateAppBarHeight () {
|
||||
const el = resolveObservedElement()
|
||||
if (!el) return
|
||||
appBarHeight.value = Math.round(el.getBoundingClientRect().height || 0)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
updateAppBarHeight()
|
||||
if (typeof ResizeObserver === 'undefined') return
|
||||
|
||||
const el = resolveObservedElement()
|
||||
if (!el) return
|
||||
|
||||
appBarObserver = new ResizeObserver(() => updateAppBarHeight())
|
||||
appBarObserver.observe(el)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (!appBarObserver) return
|
||||
appBarObserver.disconnect()
|
||||
appBarObserver = null
|
||||
})
|
||||
|
||||
watch(options.isMobile, (value) => {
|
||||
if (!value) {
|
||||
resetMobilePanels()
|
||||
}
|
||||
})
|
||||
|
||||
watch(drawer, (value) => {
|
||||
if (!value) {
|
||||
resetMobilePanels()
|
||||
}
|
||||
})
|
||||
|
||||
const mainStyle = computed(() => ({
|
||||
paddingTop: appBarHeight.value ? `${appBarHeight.value}px` : undefined,
|
||||
height: '100vh',
|
||||
minHeight: 0,
|
||||
flex: '1 1 0',
|
||||
}))
|
||||
|
||||
return {
|
||||
drawer,
|
||||
goToMobileLevel,
|
||||
handleMobileMenuClick,
|
||||
handleSelectFavorite,
|
||||
handleUnshrink,
|
||||
isRail,
|
||||
mainStyle,
|
||||
mobileCurrentItems,
|
||||
mobileCurrentLevel,
|
||||
mobileFavoritesPanel,
|
||||
mobileMenuLevels,
|
||||
openMobileFavoritesPanel,
|
||||
opened,
|
||||
showBreadcrumbBar,
|
||||
showFavoritesBar,
|
||||
toggleFavoritesBar,
|
||||
toggleSidebar,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { computed } from 'vue'
|
||||
import { useTheme } from 'vuetify'
|
||||
import { getNextThemeName } from '@/utils/theme'
|
||||
|
||||
export function useThemeToggle () {
|
||||
const theme = useTheme()
|
||||
|
||||
const availableThemeNames = computed(() =>
|
||||
Object.keys(theme.themes.value ?? {}).filter((name) => name.startsWith('theme'))
|
||||
)
|
||||
|
||||
function toggleTheme () {
|
||||
const names = availableThemeNames.value
|
||||
if (names.length === 0) return null
|
||||
|
||||
const current = theme.global.name.value
|
||||
const next = getNextThemeName(names, current)
|
||||
if (!next) return null
|
||||
|
||||
theme.change(next)
|
||||
return next
|
||||
}
|
||||
|
||||
return {
|
||||
availableThemeNames,
|
||||
toggleTheme,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { type StudentRecord, useStudentStore } from '@/stores/students'
|
||||
|
||||
type StudentPayload = Omit<StudentRecord, 'id'>
|
||||
|
||||
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 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' } },
|
||||
] as const
|
||||
|
||||
const TABLE_BOTTOM_GAP = 64
|
||||
const TABLE_MIN_HEIGHT = 240
|
||||
|
||||
export function useEditableStudentGrid () {
|
||||
const studentStore = useStudentStore()
|
||||
const students = computed(() => studentStore.students)
|
||||
const search = ref({
|
||||
studentId: '',
|
||||
name: '',
|
||||
department: '',
|
||||
})
|
||||
const isBulkEditEnabled = ref(false)
|
||||
const isSearchVisible = ref(false)
|
||||
const tableContainerRef = ref<HTMLElement | null>(null)
|
||||
const tableScrollParentRef = ref<HTMLElement | null>(null)
|
||||
const tableResizeObserver = ref<ResizeObserver | null>(null)
|
||||
const tableHeight = ref(300)
|
||||
const draftRows = ref<Record<number, StudentPayload>>({})
|
||||
const selectedRowIds = ref<number[]>([])
|
||||
|
||||
function toPayload (student: StudentRecord): StudentPayload {
|
||||
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 rebuildDraftRows () {
|
||||
draftRows.value = Object.fromEntries(students.value.map((item) => [item.id, toPayload(item)]))
|
||||
}
|
||||
|
||||
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) {
|
||||
const source = getSourceRow(id)
|
||||
const draft = getDraftRow(id)
|
||||
if (!source || !draft) return false
|
||||
|
||||
return (
|
||||
source.studentId !== draft.studentId ||
|
||||
source.name !== draft.name ||
|
||||
source.department !== draft.department ||
|
||||
source.grade !== draft.grade ||
|
||||
source.enrollYear !== draft.enrollYear ||
|
||||
source.credits !== draft.credits ||
|
||||
source.advisor !== draft.advisor ||
|
||||
source.email !== draft.email ||
|
||||
source.phone !== draft.phone ||
|
||||
source.status !== draft.status
|
||||
)
|
||||
}
|
||||
|
||||
const filteredStudents = computed(() => {
|
||||
const keywordStudentId = search.value.studentId.trim().toLowerCase()
|
||||
const keywordName = search.value.name.trim().toLowerCase()
|
||||
const selectedDepartment = search.value.department
|
||||
|
||||
return students.value.filter((item) => {
|
||||
if (studentStore.isMarkedAsDeleted(item.id)) return false
|
||||
|
||||
const matchesStudentId = keywordStudentId
|
||||
? item.studentId.toLowerCase().includes(keywordStudentId)
|
||||
: true
|
||||
const matchesName = keywordName ? item.name.toLowerCase().includes(keywordName) : true
|
||||
const matchesDepartment = selectedDepartment ? item.department === selectedDepartment : true
|
||||
|
||||
return matchesStudentId && matchesName && matchesDepartment
|
||||
})
|
||||
})
|
||||
|
||||
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 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 hasSelectedRows = computed(() => selectedRowIds.value.length > 0)
|
||||
|
||||
function toggleSelectAll (checked: boolean | null) {
|
||||
if (isPartiallyVisibleSelected.value) {
|
||||
selectedRowIds.value = selectedRowIds.value.filter((id) => !visibleStudentIds.value.includes(id))
|
||||
return
|
||||
}
|
||||
|
||||
if (!checked) {
|
||||
selectedRowIds.value = selectedRowIds.value.filter((id) => !visibleStudentIds.value.includes(id))
|
||||
return
|
||||
}
|
||||
|
||||
selectedRowIds.value = Array.from(new Set([...selectedRowIds.value, ...visibleStudentIds.value]))
|
||||
}
|
||||
|
||||
function toggleSingleRowSelection (id: number, checked: boolean | null) {
|
||||
if (checked) {
|
||||
if (!selectedRowIds.value.includes(id)) {
|
||||
selectedRowIds.value = [...selectedRowIds.value, id]
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
selectedRowIds.value = selectedRowIds.value.filter((selectedId) => selectedId !== id)
|
||||
}
|
||||
|
||||
function deleteSingleRow (id: number) {
|
||||
selectedRowIds.value = selectedRowIds.value.filter((selectedId) => selectedId !== id)
|
||||
studentStore.markAsDeleted(id)
|
||||
rebuildDraftRows()
|
||||
}
|
||||
|
||||
function deleteSelectedRows () {
|
||||
if (selectedRowIds.value.length === 0) return
|
||||
|
||||
for (const id of selectedRowIds.value) {
|
||||
studentStore.markAsDeleted(id)
|
||||
}
|
||||
|
||||
selectedRowIds.value = []
|
||||
rebuildDraftRows()
|
||||
}
|
||||
|
||||
function saveAllRows () {
|
||||
for (const item of students.value) {
|
||||
const draft = getDraftRow(item.id)
|
||||
if (!draft) continue
|
||||
if (!isRowDirty(item.id)) continue
|
||||
studentStore.updateStudent(item.id, draft)
|
||||
}
|
||||
|
||||
studentStore.commitDeleted()
|
||||
rebuildDraftRows()
|
||||
}
|
||||
|
||||
function resetAllRows () {
|
||||
studentStore.restoreDeleted()
|
||||
selectedRowIds.value = []
|
||||
rebuildDraftRows()
|
||||
}
|
||||
|
||||
function recalculateTableHeight () {
|
||||
const container = tableContainerRef.value
|
||||
if (!container) return
|
||||
|
||||
const scrollParent = tableScrollParentRef.value || (container.closest('.overflow-auto') as HTMLElement | null)
|
||||
tableScrollParentRef.value = scrollParent
|
||||
|
||||
const parentBottom = scrollParent
|
||||
? scrollParent.getBoundingClientRect().bottom
|
||||
: window.innerHeight
|
||||
const top = container.getBoundingClientRect().top
|
||||
const availableHeight = parentBottom - top - TABLE_BOTTOM_GAP
|
||||
tableHeight.value = Math.max(TABLE_MIN_HEIGHT, Math.floor(availableHeight))
|
||||
}
|
||||
|
||||
watch(students, () => {
|
||||
const currentIds = new Set(students.value.map((item) => item.id))
|
||||
selectedRowIds.value = selectedRowIds.value.filter((id) => currentIds.has(id))
|
||||
})
|
||||
|
||||
watch(students, rebuildDraftRows, { immediate: true })
|
||||
|
||||
watch([isSearchVisible, isBulkEditEnabled], async () => {
|
||||
await nextTick()
|
||||
requestAnimationFrame(() => {
|
||||
recalculateTableHeight()
|
||||
})
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
const container = tableContainerRef.value
|
||||
if (container) {
|
||||
tableScrollParentRef.value = container.closest('.overflow-auto') as HTMLElement | null
|
||||
tableResizeObserver.value = new ResizeObserver(() => {
|
||||
recalculateTableHeight()
|
||||
})
|
||||
tableResizeObserver.value.observe(container)
|
||||
if (tableScrollParentRef.value) {
|
||||
tableResizeObserver.value.observe(tableScrollParentRef.value)
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
recalculateTableHeight()
|
||||
})
|
||||
window.addEventListener('resize', recalculateTableHeight)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
tableResizeObserver.value?.disconnect()
|
||||
window.removeEventListener('resize', recalculateTableHeight)
|
||||
})
|
||||
|
||||
return {
|
||||
departments,
|
||||
draftRows,
|
||||
enrollYears,
|
||||
filteredStudents,
|
||||
getDraftRow,
|
||||
gradeOptions,
|
||||
hasAnyChange,
|
||||
hasSelectedRows,
|
||||
isAllVisibleSelected,
|
||||
isBulkEditEnabled,
|
||||
isPartiallyVisibleSelected,
|
||||
isSearchVisible,
|
||||
saveAllRows,
|
||||
search,
|
||||
selectedRowIds,
|
||||
statuses,
|
||||
tableContainerRef,
|
||||
tableHeaders,
|
||||
tableHeight,
|
||||
toggleSelectAll,
|
||||
toggleSingleRowSelection,
|
||||
deleteSelectedRows,
|
||||
deleteSingleRow,
|
||||
resetAllRows,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { computed, ref, type ComputedRef, type Ref } from 'vue'
|
||||
|
||||
type DialogMode = 'create' | 'edit' | 'view'
|
||||
|
||||
interface UseMaintenanceCrudFlowOptions<T extends { id: number }> {
|
||||
records: ComputedRef<T[]>
|
||||
editingId: Ref<number | null>
|
||||
dialogMode: Ref<DialogMode>
|
||||
dialogVisible: Ref<boolean>
|
||||
isLoading: Ref<boolean>
|
||||
isSaving: Ref<boolean>
|
||||
isDirty: Readonly<Ref<boolean>> | ComputedRef<boolean>
|
||||
clearAllErrors: () => void
|
||||
resetForm: () => void
|
||||
openEditDialog: (record: T) => void
|
||||
openViewDialog: (record: T) => void
|
||||
removeRecord: (id: number) => void
|
||||
describeRecord: (record: T) => string
|
||||
onCloseReset?: () => void
|
||||
onAfterDelete?: (deletedId: number) => void
|
||||
}
|
||||
|
||||
interface UseMaintenanceCrudFlowResult<T extends { id: number }> {
|
||||
confirmCloseVisible: Ref<boolean>
|
||||
confirmSaveVisible: Ref<boolean>
|
||||
confirmDeleteVisible: Ref<boolean>
|
||||
confirmSwitchVisible: Ref<boolean>
|
||||
confirmNavigateVisible: Ref<boolean>
|
||||
pendingDelete: Ref<T | null>
|
||||
pendingDeleteLabel: ComputedRef<string>
|
||||
currentRecordIndex: ComputedRef<number>
|
||||
currentEditingRecord: ComputedRef<T | null>
|
||||
hasPrevRecord: ComputedRef<boolean>
|
||||
hasNextRecord: ComputedRef<boolean>
|
||||
isEditMode: ComputedRef<boolean>
|
||||
isViewMode: ComputedRef<boolean>
|
||||
openAdjacentRecord: (direction: 'prev' | 'next') => void
|
||||
openEdgeRecord: (position: 'first' | 'last') => void
|
||||
switchToEditMode: () => void
|
||||
switchToViewMode: () => void
|
||||
confirmSwitch: () => void
|
||||
confirmNavigate: () => void
|
||||
requestDeleteConfirmation: (record: T) => void
|
||||
requestDeleteCurrent: () => void
|
||||
confirmDelete: () => void
|
||||
requestCloseDialog: () => void
|
||||
confirmClose: () => void
|
||||
closeDialog: () => void
|
||||
handleDialogVisibility: (nextValue: boolean) => void
|
||||
}
|
||||
|
||||
export function useMaintenanceCrudFlow<T extends { id: number }> (
|
||||
options: UseMaintenanceCrudFlowOptions<T>,
|
||||
): UseMaintenanceCrudFlowResult<T> {
|
||||
const confirmCloseVisible = ref(false)
|
||||
const confirmSaveVisible = ref(false)
|
||||
const confirmDeleteVisible = ref(false)
|
||||
const confirmSwitchVisible = ref(false)
|
||||
const confirmNavigateVisible = ref(false)
|
||||
const pendingDelete = ref<T | null>(null) as Ref<T | null>
|
||||
const pendingSwitchTarget = ref<T | null>(null)
|
||||
const pendingNavigateTarget = ref<T | null>(null)
|
||||
|
||||
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),
|
||||
)
|
||||
const currentEditingRecord = computed(
|
||||
() => 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,
|
||||
)
|
||||
const pendingDeleteLabel = computed(() => {
|
||||
if (!pendingDelete.value) return '這筆資料'
|
||||
return options.describeRecord(pendingDelete.value)
|
||||
})
|
||||
|
||||
function openAdjacentRecord (direction: 'prev' | 'next') {
|
||||
if (!isViewMode.value && !isEditMode.value) return
|
||||
const index = currentRecordIndex.value
|
||||
if (index < 0) return
|
||||
const targetIndex = direction === 'prev' ? index - 1 : index + 1
|
||||
const target = options.records.value[targetIndex]
|
||||
if (!target) return
|
||||
if (isEditMode.value) {
|
||||
if (options.isDirty.value) {
|
||||
pendingNavigateTarget.value = target
|
||||
confirmNavigateVisible.value = true
|
||||
return
|
||||
}
|
||||
options.openEditDialog(target)
|
||||
return
|
||||
}
|
||||
options.openViewDialog(target)
|
||||
}
|
||||
|
||||
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)
|
||||
if (!target) return
|
||||
if (isEditMode.value) {
|
||||
if (options.isDirty.value) {
|
||||
pendingNavigateTarget.value = target
|
||||
confirmNavigateVisible.value = true
|
||||
return
|
||||
}
|
||||
options.openEditDialog(target)
|
||||
return
|
||||
}
|
||||
options.openViewDialog(target)
|
||||
}
|
||||
|
||||
function switchToEditMode () {
|
||||
if (!isViewMode.value) return
|
||||
const current = currentEditingRecord.value
|
||||
if (!current) return
|
||||
options.openEditDialog(current)
|
||||
}
|
||||
|
||||
function switchToViewMode () {
|
||||
if (!isEditMode.value) return
|
||||
const current = currentEditingRecord.value
|
||||
if (!current) return
|
||||
if (options.isDirty.value) {
|
||||
pendingSwitchTarget.value = current
|
||||
confirmSwitchVisible.value = true
|
||||
return
|
||||
}
|
||||
options.openViewDialog(current)
|
||||
}
|
||||
|
||||
function confirmSwitch () {
|
||||
const target = pendingSwitchTarget.value
|
||||
pendingSwitchTarget.value = null
|
||||
confirmSwitchVisible.value = false
|
||||
if (!target) return
|
||||
options.openViewDialog(target)
|
||||
}
|
||||
|
||||
function confirmNavigate () {
|
||||
const target = pendingNavigateTarget.value
|
||||
pendingNavigateTarget.value = null
|
||||
confirmNavigateVisible.value = false
|
||||
if (!target) return
|
||||
options.openEditDialog(target)
|
||||
}
|
||||
|
||||
function requestDeleteConfirmation (record: T) {
|
||||
pendingDelete.value = record
|
||||
confirmDeleteVisible.value = true
|
||||
}
|
||||
|
||||
function requestDeleteCurrent () {
|
||||
const current = currentEditingRecord.value
|
||||
if (!current) return
|
||||
requestDeleteConfirmation(current)
|
||||
}
|
||||
|
||||
function closeDialog () {
|
||||
options.dialogVisible.value = false
|
||||
options.isLoading.value = false
|
||||
options.isSaving.value = false
|
||||
confirmCloseVisible.value = false
|
||||
confirmSaveVisible.value = false
|
||||
confirmDeleteVisible.value = false
|
||||
confirmSwitchVisible.value = false
|
||||
confirmNavigateVisible.value = false
|
||||
pendingDelete.value = null
|
||||
pendingSwitchTarget.value = null
|
||||
pendingNavigateTarget.value = null
|
||||
options.dialogMode.value = 'create'
|
||||
options.editingId.value = null
|
||||
options.clearAllErrors()
|
||||
options.resetForm()
|
||||
options.onCloseReset?.()
|
||||
}
|
||||
|
||||
function confirmDelete () {
|
||||
if (!pendingDelete.value) return
|
||||
const deletedId = pendingDelete.value.id
|
||||
options.removeRecord(deletedId)
|
||||
pendingDelete.value = null
|
||||
confirmDeleteVisible.value = false
|
||||
options.onAfterDelete?.(deletedId)
|
||||
if (options.editingId.value === deletedId) {
|
||||
closeDialog()
|
||||
}
|
||||
}
|
||||
|
||||
function requestCloseDialog () {
|
||||
if (options.isDirty.value && !options.isSaving.value) {
|
||||
confirmCloseVisible.value = true
|
||||
return
|
||||
}
|
||||
closeDialog()
|
||||
}
|
||||
|
||||
function confirmClose () {
|
||||
confirmCloseVisible.value = false
|
||||
closeDialog()
|
||||
}
|
||||
|
||||
function handleDialogVisibility (nextValue: boolean) {
|
||||
if (nextValue) {
|
||||
options.dialogVisible.value = true
|
||||
return
|
||||
}
|
||||
requestCloseDialog()
|
||||
}
|
||||
|
||||
return {
|
||||
confirmCloseVisible,
|
||||
confirmSaveVisible,
|
||||
confirmDeleteVisible,
|
||||
confirmSwitchVisible,
|
||||
confirmNavigateVisible,
|
||||
pendingDelete,
|
||||
pendingDeleteLabel,
|
||||
currentRecordIndex,
|
||||
currentEditingRecord,
|
||||
hasPrevRecord,
|
||||
hasNextRecord,
|
||||
isEditMode,
|
||||
isViewMode,
|
||||
openAdjacentRecord,
|
||||
openEdgeRecord,
|
||||
switchToEditMode,
|
||||
switchToViewMode,
|
||||
confirmSwitch,
|
||||
confirmNavigate,
|
||||
requestDeleteConfirmation,
|
||||
requestDeleteCurrent,
|
||||
confirmDelete,
|
||||
requestCloseDialog,
|
||||
confirmClose,
|
||||
closeDialog,
|
||||
handleDialogVisibility,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import { computed, ref, type ComputedRef, type Ref } from 'vue'
|
||||
import type { StudentRecord } from '@/stores/students'
|
||||
|
||||
interface GradeOption {
|
||||
title: string
|
||||
value: number
|
||||
}
|
||||
|
||||
interface StudentFormState {
|
||||
studentId: string
|
||||
name: string
|
||||
department: string
|
||||
grade: number
|
||||
enrollYear: number
|
||||
credits: number
|
||||
advisor: string
|
||||
email: string
|
||||
phone: string
|
||||
status: string
|
||||
}
|
||||
|
||||
interface UseStudentMaintenanceFormOptions {
|
||||
departments: string[]
|
||||
gradeOptions: GradeOption[]
|
||||
enrollYears: number[]
|
||||
statuses: string[]
|
||||
students: ComputedRef<StudentRecord[]>
|
||||
editingId: Ref<number | null>
|
||||
highlightedId: Ref<number | null>
|
||||
}
|
||||
|
||||
type SaveSummaryItem = {
|
||||
key: string
|
||||
label: string
|
||||
before: string | null
|
||||
after: string
|
||||
}
|
||||
|
||||
const fieldLabels: Record<keyof StudentFormState, string> = {
|
||||
studentId: '學號',
|
||||
name: '姓名',
|
||||
department: '系所',
|
||||
grade: '年級',
|
||||
enrollYear: '入學年度',
|
||||
credits: '已修學分',
|
||||
advisor: '指導老師',
|
||||
email: 'Email',
|
||||
phone: '電話',
|
||||
status: '狀態',
|
||||
}
|
||||
|
||||
function createDefaultForm (
|
||||
departments: string[],
|
||||
gradeOptions: GradeOption[],
|
||||
enrollYears: number[],
|
||||
statuses: string[],
|
||||
): StudentFormState {
|
||||
return {
|
||||
studentId: '',
|
||||
name: '',
|
||||
department: departments[0] ?? '',
|
||||
grade: gradeOptions[0]?.value ?? 1,
|
||||
enrollYear: enrollYears[0] ?? 2024,
|
||||
credits: 0,
|
||||
advisor: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
status: statuses[0] ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
function createEmptyFieldErrors () {
|
||||
return {
|
||||
studentId: [],
|
||||
name: [],
|
||||
department: [],
|
||||
grade: [],
|
||||
enrollYear: [],
|
||||
credits: [],
|
||||
advisor: [],
|
||||
email: [],
|
||||
phone: [],
|
||||
status: [],
|
||||
} as Record<keyof StudentFormState, string[]>
|
||||
}
|
||||
|
||||
export function useStudentMaintenanceForm (options: UseStudentMaintenanceFormOptions) {
|
||||
const form = ref<StudentFormState>(
|
||||
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),
|
||||
)
|
||||
|
||||
function gradeLabel (grade: number) {
|
||||
return options.gradeOptions.find((option) => option.value === grade)?.title ?? `年級 ${grade}`
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
const saveSummary = computed<SaveSummaryItem[]>(() => {
|
||||
const before = initialForm.value
|
||||
const after = form.value
|
||||
const entries = Object.keys(fieldLabels).map((key) => {
|
||||
const fieldKey = key as keyof StudentFormState
|
||||
return {
|
||||
key,
|
||||
label: fieldLabels[fieldKey],
|
||||
before: options.editingId.value ? formatSummaryValue(key, before[fieldKey]) : null,
|
||||
after: formatSummaryValue(key, after[fieldKey]),
|
||||
}
|
||||
})
|
||||
|
||||
if (!options.editingId.value) return entries
|
||||
return entries.filter((entry) => entry.before !== entry.after)
|
||||
})
|
||||
|
||||
const errorSummary = computed(() => {
|
||||
const entries = Object.entries(fieldErrors.value).flatMap(([field, messages]) =>
|
||||
messages.map((message) => ({ field, message })),
|
||||
)
|
||||
return entries.slice(0, 3)
|
||||
})
|
||||
|
||||
function setForm (nextForm: StudentFormState) {
|
||||
form.value = { ...nextForm }
|
||||
}
|
||||
|
||||
function syncInitialForm () {
|
||||
initialForm.value = { ...form.value }
|
||||
}
|
||||
|
||||
function resetForm () {
|
||||
form.value = createDefaultForm(
|
||||
options.departments,
|
||||
options.gradeOptions,
|
||||
options.enrollYears,
|
||||
options.statuses,
|
||||
)
|
||||
syncInitialForm()
|
||||
clearAllErrors()
|
||||
}
|
||||
|
||||
function clearAllErrors () {
|
||||
fieldErrors.value = createEmptyFieldErrors()
|
||||
}
|
||||
|
||||
function clearFieldError (field: keyof StudentFormState | string) {
|
||||
if (!fieldErrors.value[field as keyof StudentFormState]?.length) return
|
||||
fieldErrors.value[field as keyof StudentFormState] = []
|
||||
}
|
||||
|
||||
function validateForm () {
|
||||
const errors: Array<{ field: keyof StudentFormState; message: string }> = []
|
||||
const studentId = form.value.studentId.trim()
|
||||
const name = form.value.name.trim()
|
||||
const email = form.value.email.trim()
|
||||
const credits = form.value.credits
|
||||
|
||||
if (!studentId) errors.push({ field: 'studentId', message: '請輸入學號' })
|
||||
if (!name) errors.push({ field: 'name', message: '請輸入姓名' })
|
||||
if (!form.value.department) errors.push({ field: 'department', message: '請選擇系所' })
|
||||
if (!form.value.grade) errors.push({ field: 'grade', message: '請選擇年級' })
|
||||
if (!form.value.enrollYear) errors.push({ field: 'enrollYear', message: '請選擇入學年度' })
|
||||
if (credits === null || Number.isNaN(credits)) {
|
||||
errors.push({ field: 'credits', message: '請輸入已修學分' })
|
||||
} else if (credits < 0) {
|
||||
errors.push({ field: 'credits', message: '已修學分不可小於 0' })
|
||||
}
|
||||
if (!form.value.status) errors.push({ field: 'status', message: '請選擇狀態' })
|
||||
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
errors.push({ field: 'email', message: 'Email 格式不正確' })
|
||||
}
|
||||
|
||||
const duplicate = options.students.value.find(
|
||||
(item) => item.studentId === studentId && item.id !== options.editingId.value,
|
||||
)
|
||||
if (studentId && duplicate) {
|
||||
errors.push({ field: 'studentId', message: '學號已存在,請確認是否重複' })
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
function statusColor (status: string) {
|
||||
if (status === '在學') return 'success'
|
||||
if (status === '休學') return 'warning'
|
||||
if (status === '畢業') return 'secondary'
|
||||
return 'default'
|
||||
}
|
||||
|
||||
function rowProps (data: { item: StudentRecord }) {
|
||||
return {
|
||||
class: data.item.id === options.highlightedId.value ? 'is-highlighted' : '',
|
||||
}
|
||||
}
|
||||
|
||||
const isPlaceholderValue = (value: string | null) => value === '—'
|
||||
|
||||
return {
|
||||
errorSummary,
|
||||
fieldErrors,
|
||||
form,
|
||||
initialForm,
|
||||
isDirty,
|
||||
isPlaceholderValue,
|
||||
saveSummary,
|
||||
clearAllErrors,
|
||||
clearFieldError,
|
||||
formatSummaryValue,
|
||||
gradeLabel,
|
||||
resetForm,
|
||||
rowProps,
|
||||
setForm,
|
||||
statusColor,
|
||||
syncInitialForm,
|
||||
validateForm,
|
||||
}
|
||||
}
|
||||
|
||||
export type { StudentFormState, SaveSummaryItem }
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ref } from 'vue'
|
||||
import { ref, type Ref } from 'vue'
|
||||
import { type ApiRequestError, normalizeError } from '@/services/error'
|
||||
import { useSnackbarStore } from '@/stores/snackbar'
|
||||
|
||||
@@ -9,6 +9,15 @@ type Options = {
|
||||
errorToastLevel?: (error: ApiRequestError) => ToastLevel
|
||||
}
|
||||
|
||||
interface UseApiCallResult<TResult, TArgs extends unknown[]> {
|
||||
loading: Ref<boolean>
|
||||
data: Ref<TResult | null>
|
||||
error: Ref<ApiRequestError | null>
|
||||
execute: (...args: TArgs) => Promise<TResult>
|
||||
executeSafe: (...args: TArgs) => Promise<TResult | null>
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
function getDefaultToastLevel (error: ApiRequestError): ToastLevel {
|
||||
if (typeof error.status === 'number' && error.status >= 500) return 'error'
|
||||
return 'warning'
|
||||
@@ -21,9 +30,9 @@ function levelToColor (level: ToastLevel): string {
|
||||
}
|
||||
|
||||
export function useApiCall <TResult, TArgs extends unknown[]>(action: (...args: TArgs) => Promise<TResult>,
|
||||
options?: Options) {
|
||||
options?: Options): UseApiCallResult<TResult, TArgs> {
|
||||
const loading = ref(false)
|
||||
const data = ref<TResult | null>(null)
|
||||
const data = ref<TResult | null>(null) as Ref<TResult | null>
|
||||
const error = ref<ApiRequestError | null>(null)
|
||||
|
||||
const snackbar = useSnackbarStore()
|
||||
@@ -80,4 +89,3 @@ export function useApiCall <TResult, TArgs extends unknown[]>(action: (...args:
|
||||
reset,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user