feat: add SingleRecord component for student maintenance with CRUD functionality

This commit is contained in:
skytek_xinliang
2026-03-30 11:03:01 +08:00
parent 00a7150757
commit 20b093ff73
22 changed files with 51 additions and 51 deletions
@@ -0,0 +1,74 @@
<template>
<v-dialog
:max-width="maxWidth"
:model-value="modelValue"
:persistent="persistent"
@update:model-value="$emit('update:modelValue', $event)"
>
<v-card>
<v-card-title class="text-h6">{{ title }}</v-card-title>
<v-card-text>
<slot>{{ message }}</slot>
</v-card-text>
<v-card-actions class="justify-end">
<v-btn variant="text" @click="$emit('update:modelValue', false)">取消</v-btn>
<v-btn
:color="confirmColor"
:loading="confirmLoading"
:variant="confirmVariant"
@click="$emit('confirm')"
>
{{ confirmText }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import type { PropType } from 'vue'
defineProps({
modelValue: {
type: Boolean,
required: true,
},
title: {
type: String,
required: true,
},
message: {
type: String,
default: '',
},
confirmText: {
type: String,
default: '確認',
},
confirmColor: {
type: String,
default: 'primary',
},
confirmVariant: {
type: String as PropType<'text' | 'flat' | 'outlined' | 'plain' | 'elevated' | 'tonal'>,
default: 'flat',
},
confirmLoading: {
type: Boolean,
default: false,
},
maxWidth: {
type: [String, Number],
default: 420,
},
persistent: {
type: Boolean,
default: true,
},
})
defineEmits<{
(event: 'update:modelValue', value: boolean): void
(event: 'confirm'): void
}>()
</script>
@@ -0,0 +1,350 @@
<template>
<div class="d-flex flex-column">
<v-card variant="flat">
<v-card-title class="d-flex flex-wrap align-center py-0 ga-2">
<span class="text-h6">可編輯表格維護示範</span>
<v-chip :color="hasAnyChange ? 'warning' : 'success'" variant="tonal">
{{ hasAnyChange ? '有未儲存變更' : '已同步' }}
</v-chip>
<v-chip color="info" variant="tonal">筆數 {{ filteredStudents.length }}</v-chip>
<v-spacer />
<v-btn flat :prepend-icon="mdiMagnify" @click="isSearchVisible = !isSearchVisible"
>條件搜尋</v-btn
>
<v-switch v-model="isBulkEditEnabled" color="primary" hide-details label="啟用編輯" />
</v-card-title>
<v-divider />
<v-card-text class="pb-0 pt-2">
<v-row v-if="isSearchVisible" class="mb-2" density="compact">
<v-col cols="12" md="3">
<div class="text-body-1 text-medium-emphasis pl-2">學號</div>
<v-text-field
v-model="search.studentId"
clearable
density="compact"
hide-details
placeholder="例如:S2024001"
variant="outlined"
/>
</v-col>
<v-col cols="12" md="3">
<div class="text-body-1 text-medium-emphasis pl-2">姓名</div>
<v-text-field
v-model="search.name"
clearable
density="compact"
hide-details
placeholder="例如:王小明"
variant="outlined"
/>
</v-col>
<v-col cols="12" md="3">
<div class="text-body-1 text-medium-emphasis pl-2">系所</div>
<v-select
v-model="search.department"
:class="{ 'select-hide-arrow': !isBulkEditEnabled }"
clearable
density="compact"
hide-details
:items="departments"
variant="outlined"
/>
</v-col>
</v-row>
<v-row v-if="isBulkEditEnabled" align="center" class="mb-2 ga-1" density="compact">
<v-btn
:disabled="!hasSelectedRows"
:prepend-icon="mdiDelete"
variant="outlined"
@click="deleteSelectedRows"
>
批次刪除
</v-btn>
<v-spacer />
<v-btn
color="primary"
:disabled="!hasAnyChange"
:prepend-icon="mdiContentSave"
variant="outlined"
@click="saveAllRows"
>
儲存變更
</v-btn>
<v-btn
:disabled="!hasAnyChange"
:prepend-icon="mdiRestore"
variant="text"
@click="resetAllRows"
>
取消變更
</v-btn>
</v-row>
<div ref="tableContainerRef">
<v-data-table
density="comfortable"
fixed-header
:headers="tableHeaders"
:height="tableHeight"
item-value="id"
:items="filteredStudents"
:items-per-page="10"
items-per-page-text="每頁筆數"
page-text=" {0}-{1} / {2} "
>
<template #[`header.select`]>
<v-checkbox-btn
:disabled="!isBulkEditEnabled"
:indeterminate="isPartiallyVisibleSelected"
:model-value="isAllVisibleSelected"
@update:model-value="toggleSelectAll"
/>
</template>
<template #[`item.select`]="{ item }">
<v-checkbox-btn
:disabled="!isBulkEditEnabled"
:model-value="selectedRowIds.includes(item.id)"
@update:model-value="(checked) => toggleSingleRowSelection(item.id, checked)"
/>
</template>
<template #[`item.studentId`]="{ item }">
<v-text-field
:model-value="getDraftRow(item.id)?.studentId ?? ''"
density="compact"
flat
hide-details
:readonly="!isBulkEditEnabled"
:variant="isBulkEditEnabled ? 'outlined' : 'solo'"
@update:model-value="
(value) => {
const row = getDraftRow(item.id)
if (row) row.studentId = String(value)
}
"
/>
</template>
<template #[`item.name`]="{ item }">
<v-text-field
:model-value="getDraftRow(item.id)?.name ?? ''"
density="compact"
flat
hide-details
:readonly="!isBulkEditEnabled"
:variant="isBulkEditEnabled ? 'outlined' : 'solo'"
@update:model-value="
(value) => {
const row = getDraftRow(item.id)
if (row) row.name = String(value)
}
"
/>
</template>
<template #[`item.department`]="{ item }">
<v-select
:model-value="getDraftRow(item.id)?.department ?? ''"
:class="{ 'select-hide-arrow': !isBulkEditEnabled }"
density="compact"
flat
hide-details
:items="departments"
:readonly="!isBulkEditEnabled"
:variant="isBulkEditEnabled ? 'outlined' : 'solo'"
@update:model-value="
(value) => {
const row = getDraftRow(item.id)
if (row) row.department = String(value)
}
"
/>
</template>
<template #[`item.grade`]="{ item }">
<v-select
:model-value="getDraftRow(item.id)?.grade ?? null"
:class="{ 'select-hide-arrow': !isBulkEditEnabled }"
density="compact"
flat
hide-details
item-title="title"
item-value="value"
:items="gradeOptions"
:readonly="!isBulkEditEnabled"
:variant="isBulkEditEnabled ? 'outlined' : 'solo'"
@update:model-value="
(value) => {
const row = getDraftRow(item.id)
if (row) row.grade = Number(value) || 0
}
"
/>
</template>
<template #[`item.enrollYear`]="{ item }">
<v-select
:model-value="getDraftRow(item.id)?.enrollYear ?? null"
:class="{ 'select-hide-arrow': !isBulkEditEnabled }"
density="compact"
flat
hide-details
:items="enrollYears"
:readonly="!isBulkEditEnabled"
:variant="isBulkEditEnabled ? 'outlined' : 'solo'"
@update:model-value="
(value) => {
const row = getDraftRow(item.id)
if (row) row.enrollYear = Number(value) || 0
}
"
/>
</template>
<template #[`item.credits`]="{ item }">
<v-text-field
:model-value="getDraftRow(item.id)?.credits ?? 0"
density="compact"
flat
hide-details
min="0"
:readonly="!isBulkEditEnabled"
type="number"
:variant="isBulkEditEnabled ? 'outlined' : 'solo'"
@update:model-value="
(value) => {
const row = getDraftRow(item.id)
if (row) row.credits = Number(value) || 0
}
"
/>
</template>
<template #[`item.advisor`]="{ item }">
<v-text-field
:model-value="getDraftRow(item.id)?.advisor ?? ''"
density="compact"
flat
hide-details
:readonly="!isBulkEditEnabled"
:variant="isBulkEditEnabled ? 'outlined' : 'solo'"
@update:model-value="
(value) => {
const row = getDraftRow(item.id)
if (row) row.advisor = String(value)
}
"
/>
</template>
<template #[`item.email`]="{ item }">
<v-text-field
:model-value="getDraftRow(item.id)?.email ?? ''"
density="compact"
flat
hide-details
:readonly="!isBulkEditEnabled"
:variant="isBulkEditEnabled ? 'outlined' : 'solo'"
@update:model-value="
(value) => {
const row = getDraftRow(item.id)
if (row) row.email = String(value)
}
"
/>
</template>
<template #[`item.phone`]="{ item }">
<v-text-field
:model-value="getDraftRow(item.id)?.phone ?? ''"
density="compact"
flat
hide-details
:readonly="!isBulkEditEnabled"
:variant="isBulkEditEnabled ? 'outlined' : 'solo'"
@update:model-value="
(value) => {
const row = getDraftRow(item.id)
if (row) row.phone = String(value)
}
"
/>
</template>
<template #[`item.status`]="{ item }">
<v-select
:model-value="getDraftRow(item.id)?.status ?? ''"
:class="{ 'select-hide-arrow': !isBulkEditEnabled }"
density="compact"
flat
hide-details
:items="statuses"
:readonly="!isBulkEditEnabled"
:variant="isBulkEditEnabled ? 'outlined' : 'solo'"
@update:model-value="
(value) => {
const row = getDraftRow(item.id)
if (row) row.status = String(value)
}
"
/>
</template>
<template #[`item.actions`]="{ item }">
<div class="d-flex ga-1 justify-end">
<v-btn
color="error"
:disabled="!isBulkEditEnabled"
size="small"
variant="text"
@click="deleteSingleRow(item.id)"
>
刪除
</v-btn>
</div>
</template>
</v-data-table>
</div>
</v-card-text>
</v-card>
</div>
</template>
<script setup lang="ts">
import { mdiContentSave, mdiDelete, mdiMagnify, mdiRestore } from '@mdi/js'
import { useEditableStudentGrid } from '@/composables/maint/useEditableStudentGrid'
const {
departments,
enrollYears,
filteredStudents,
getDraftRow,
gradeOptions,
hasAnyChange,
hasSelectedRows,
isAllVisibleSelected,
isBulkEditEnabled,
isPartiallyVisibleSelected,
isSearchVisible,
saveAllRows,
search,
selectedRowIds,
statuses,
tableContainerRef,
tableHeaders,
tableHeight,
toggleSelectAll,
toggleSingleRowSelection,
deleteSelectedRows,
deleteSingleRow,
resetAllRows,
} = useEditableStudentGrid()
</script>
<style scoped>
:deep(.select-hide-arrow .v-field__append-inner) {
display: none;
}
:deep(.v-table__wrapper .v-field__input) {
padding-top: 0 !important;
padding-bottom: 0 !important;
min-height: 32px;
}
:deep(.v-data-table-footer) {
padding: 4px 0 0;
}
</style>
@@ -0,0 +1,120 @@
<template>
<common-confirm-dialog
v-model="closeVisibleModel"
confirm-color="error"
confirm-text="關閉不儲存"
message="目前有尚未儲存的內容,確定要關閉嗎?"
title="未儲存變更"
@confirm="emit('confirm-close')"
/>
<common-confirm-dialog
v-model="saveVisibleModel"
:confirm-loading="props.isSaving"
confirm-text="確認儲存"
max-width="520"
title="確認儲存變更"
@confirm="emit('confirm-save')"
>
<div v-if="props.saveSummary.length > 0" class="d-flex flex-column ga-2">
<div v-for="item in props.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>
</common-confirm-dialog>
<common-confirm-dialog
v-model="deleteVisibleModel"
confirm-color="error"
confirm-text="確定刪除"
:message="`確定要刪除 ${props.pendingDeleteLabel} 嗎?此操作無法復原。`"
title="確認刪除"
@confirm="emit('confirm-delete')"
/>
<common-confirm-dialog
v-model="switchVisibleModel"
confirm-text="確定切換"
max-width="480"
message="目前有尚未儲存的內容,切換為檢視模式將會捨棄變更,確定要切換嗎?"
title="未儲存變更"
@confirm="emit('confirm-switch')"
/>
<common-confirm-dialog
v-model="navigateVisibleModel"
confirm-text="確定切換"
max-width="480"
message="目前有尚未儲存的內容,切換到其他資料將會捨棄變更,確定要切換嗎?"
title="未儲存變更"
@confirm="emit('confirm-navigate')"
/>
</template>
<script setup lang="ts">
import type { SaveSummaryItem } from '@/composables/maint/useStudentMaintenanceForm'
import { computed } from 'vue'
import CommonConfirmDialog from './CommonConfirmDialog.vue'
const props = defineProps<{
closeVisible: boolean
deleteVisible: boolean
isSaving: boolean
navigateVisible: boolean
pendingDeleteLabel: string
saveSummary: SaveSummaryItem[]
saveVisible: boolean
switchVisible: boolean
}>()
const emit = defineEmits<{
(event: 'confirm-close'): void
(event: 'confirm-delete'): void
(event: 'confirm-navigate'): void
(event: 'confirm-save'): void
(event: 'confirm-switch'): void
(event: 'update:close-visible', value: boolean): void
(event: 'update:delete-visible', value: boolean): void
(event: 'update:navigate-visible', value: boolean): void
(event: 'update:save-visible', value: boolean): void
(event: 'update:switch-visible', value: boolean): void
}>()
const closeVisibleModel = computed({
get: () => props.closeVisible,
set: (value: boolean) => emit('update:close-visible', value),
})
const saveVisibleModel = computed({
get: () => props.saveVisible,
set: (value: boolean) => emit('update:save-visible', value),
})
const deleteVisibleModel = computed({
get: () => props.deleteVisible,
set: (value: boolean) => emit('update:delete-visible', value),
})
const switchVisibleModel = computed({
get: () => props.switchVisible,
set: (value: boolean) => emit('update:switch-visible', value),
})
const navigateVisibleModel = computed({
get: () => props.navigateVisible,
set: (value: boolean) => emit('update:navigate-visible', value),
})
</script>
@@ -0,0 +1,130 @@
<template>
<v-row density="compact">
<v-col cols="12" md="3">
<v-text-field
id="field-studentId"
v-model="form.studentId"
density="comfortable"
:disabled="props.isFormLocked"
:error-messages="props.fieldErrors.studentId"
label="學號"
placeholder="例如:S2024008"
:readonly="props.isFormReadonly"
variant="outlined"
@update:model-value="emit('clear-field', 'studentId')"
/>
</v-col>
<v-col cols="12" md="3">
<v-text-field
id="field-name"
v-model="form.name"
density="comfortable"
:disabled="props.isFormLocked"
:error-messages="props.fieldErrors.name"
label="姓名"
placeholder="例如:陳怡君"
:readonly="props.isFormReadonly"
variant="outlined"
@update:model-value="emit('clear-field', 'name')"
/>
</v-col>
<v-col cols="12" md="3">
<v-select
id="field-department"
v-model="form.department"
density="comfortable"
:disabled="props.isFormLocked"
:error-messages="props.fieldErrors.department"
:items="props.departments"
label="系所"
:readonly="props.isFormReadonly"
variant="outlined"
@update:model-value="emit('clear-field', 'department')"
/>
</v-col>
<v-col cols="12" md="3">
<v-select
id="field-grade"
v-model="form.grade"
density="comfortable"
:disabled="props.isFormLocked"
:error-messages="props.fieldErrors.grade"
item-title="title"
item-value="value"
:items="props.gradeOptions"
label="年級"
:readonly="props.isFormReadonly"
variant="outlined"
@update:model-value="emit('clear-field', 'grade')"
/>
</v-col>
<v-col cols="12" md="3">
<v-select
id="field-enrollYear"
v-model="form.enrollYear"
density="comfortable"
:disabled="props.isFormLocked"
:error-messages="props.fieldErrors.enrollYear"
:items="props.enrollYears"
label="入學年度"
:readonly="props.isFormReadonly"
variant="outlined"
@update:model-value="emit('clear-field', 'enrollYear')"
/>
</v-col>
<v-col cols="12" md="3">
<v-select
id="field-status"
v-model="form.status"
density="comfortable"
:disabled="props.isFormLocked"
:error-messages="props.fieldErrors.status"
:items="props.statuses"
label="狀態"
:readonly="props.isFormReadonly"
variant="outlined"
@update:model-value="emit('clear-field', 'status')"
/>
</v-col>
<v-col cols="12" md="3">
<v-text-field
id="field-email"
v-model="form.email"
density="comfortable"
:disabled="props.isFormLocked"
:error-messages="props.fieldErrors.email"
label="Email"
:readonly="props.isFormReadonly"
variant="outlined"
@update:model-value="emit('clear-field', 'email')"
/>
</v-col>
</v-row>
</template>
<script setup lang="ts">
import type { StudentFormState } from '@/composables/maint/useStudentMaintenanceForm'
import { toRef } from 'vue'
interface GradeOption {
title: string
value: number
}
const props = defineProps<{
departments: string[]
enrollYears: number[]
fieldErrors: Record<string, string[]>
form: StudentFormState
gradeOptions: GradeOption[]
isFormLocked: boolean
isFormReadonly: boolean
statuses: string[]
}>()
const form = toRef(props, 'form')
const emit = defineEmits<{
(event: 'clear-field', field: keyof StudentFormState): void
}>()
</script>
+65
View File
@@ -0,0 +1,65 @@
<template>
<v-card border class="d-flex flex-column h-100 rounded-0" :class="cardClass" flat :width="width">
<v-card-title class="dialog-title d-flex align-center ga-2">
<div>
<div class="text-h6">{{ dialogTitle }}</div>
<div v-if="dialogSubtitle" class="text-body-2 text-medium-emphasis">
{{ dialogSubtitle }}
</div>
</div>
<v-spacer />
<v-chip v-if="isViewMode" color="info" size="small" variant="tonal">檢視中</v-chip>
<v-chip v-else-if="isEditMode" color="primary" size="small" variant="tonal">編輯中</v-chip>
<v-chip v-else color="secondary" size="small" variant="tonal">新增中</v-chip>
</v-card-title>
<v-card-subtitle class="dialog-toolbar d-flex align-center py-2 ga-2">
<slot name="toolbar" />
</v-card-subtitle>
<v-divider />
<v-card-text :class="contentClass">
<slot name="content" />
</v-card-text>
<v-divider />
<v-card-actions class="dialog-actions">
<slot name="actions" />
</v-card-actions>
</v-card>
</template>
<script setup lang="ts">
defineProps({
dialogTitle: {
type: String,
required: true,
},
dialogSubtitle: {
type: String,
default: '',
},
isViewMode: {
type: Boolean,
required: true,
},
isEditMode: {
type: Boolean,
required: true,
},
width: {
type: [String, Number],
default: '100%',
},
cardClass: {
type: String,
default: '',
},
contentClass: {
type: String,
default: 'pa-2 flex-grow-1 overflow-y-auto',
},
})
</script>
@@ -0,0 +1,173 @@
<template>
<div v-if="mobile" class="d-flex align-center flex-wrap ga-2 w-100">
<div class="d-flex align-center ga-1">
<v-btn
v-if="isViewMode || isEditMode"
:disabled="!hasPrevRecord"
:icon="mdiChevronLeft"
size="small"
variant="text"
@click="$emit('prev')"
/>
<v-btn
v-if="isViewMode || isEditMode"
:disabled="!hasNextRecord"
:icon="mdiChevronRight"
size="small"
variant="text"
@click="$emit('next')"
/>
</div>
<v-spacer />
<v-btn
v-if="isViewMode"
color="primary"
:prepend-icon="mdiPencil"
size="small"
variant="tonal"
@click="$emit('switch-to-edit')"
>
{{ editLabel }}
</v-btn>
<v-btn
v-if="isEditMode"
color="primary"
:prepend-icon="mdiEye"
size="small"
variant="tonal"
@click="$emit('switch-to-view')"
>
{{ viewLabel }}
</v-btn>
</div>
<template v-else>
<v-btn
v-if="isViewMode || isEditMode"
:disabled="!hasPrevRecord"
:prepend-icon="mdiSkipPrevious"
size="small"
variant="text"
@click="$emit('first')"
>
{{ firstLabel }}
</v-btn>
<v-btn
v-if="isViewMode || isEditMode"
:disabled="!hasPrevRecord"
:prepend-icon="mdiChevronLeft"
size="small"
variant="text"
@click="$emit('prev')"
>
{{ prevLabel }}
</v-btn>
<v-btn
v-if="isViewMode || isEditMode"
:append-icon="mdiChevronRight"
:disabled="!hasNextRecord"
size="small"
variant="text"
@click="$emit('next')"
>
{{ nextLabel }}
</v-btn>
<v-btn
v-if="isViewMode || isEditMode"
:append-icon="mdiSkipNext"
:disabled="!hasNextRecord"
size="small"
variant="text"
@click="$emit('last')"
>
{{ lastLabel }}
</v-btn>
<v-spacer />
<v-btn
v-if="isViewMode"
color="primary"
:prepend-icon="mdiPencil"
size="small"
variant="tonal"
@click="$emit('switch-to-edit')"
>
{{ editLabel }}
</v-btn>
<v-btn
v-if="isEditMode"
color="primary"
:prepend-icon="mdiEye"
size="small"
variant="tonal"
@click="$emit('switch-to-view')"
>
{{ viewLabel }}
</v-btn>
</template>
</template>
<script setup lang="ts">
import {
mdiChevronLeft,
mdiChevronRight,
mdiEye,
mdiPencil,
mdiSkipNext,
mdiSkipPrevious,
} from '@mdi/js'
defineProps({
isViewMode: {
type: Boolean,
required: true,
},
isEditMode: {
type: Boolean,
required: true,
},
hasPrevRecord: {
type: Boolean,
required: true,
},
hasNextRecord: {
type: Boolean,
required: true,
},
mobile: {
type: Boolean,
default: false,
},
firstLabel: {
type: String,
default: '首筆',
},
prevLabel: {
type: String,
default: '上一筆',
},
nextLabel: {
type: String,
default: '下一筆',
},
lastLabel: {
type: String,
default: '末筆',
},
editLabel: {
type: String,
default: '編輯',
},
viewLabel: {
type: String,
default: '檢視',
},
})
defineEmits<{
(event: 'first'): void
(event: 'prev'): void
(event: 'next'): void
(event: 'last'): void
(event: 'switch-to-edit'): void
(event: 'switch-to-view'): void
}>()
</script>
+88
View File
@@ -0,0 +1,88 @@
<template>
<v-sheet class="d-flex flex-column ga-2 h-100">
<v-card border class="flex-shrink-0" variant="flat">
<v-card-title class="d-flex align-center ga-3">
<span class="text-h6">{{ title }}</span>
<v-spacer />
<v-btn
:icon="mdAndUp ? false : mdiMagnify"
:prepend-icon="mdAndUp ? mdiMagnify : undefined"
size="small"
:text="mdAndUp ? '搜尋條件' : false"
variant="text"
@click="$emit('toggle-search')"
>
</v-btn>
<v-btn
color="primary"
:icon="mdAndUp ? false : mdiPlus"
:prepend-icon="mdAndUp ? mdiPlus : undefined"
size="small"
:text="mdAndUp ? createLabel : false"
@click="$emit('create')"
>
</v-btn>
</v-card-title>
<!-- Desktopinline 展開 -->
<template v-if="mdAndUp">
<v-divider />
<v-card-text v-show="searchPanelOpen" class="px-2 py-1">
<v-row density="compact">
<slot name="search-fields" />
</v-row>
</v-card-text>
</template>
</v-card>
<slot name="table" />
</v-sheet>
<!-- 手機 / TabletBottom Sheet -->
<v-bottom-sheet
v-if="!mdAndUp"
:model-value="searchPanelOpen"
@update:model-value="$emit('toggle-search')"
>
<v-card rounded="t-xl">
<v-card-title class="d-flex align-center py-3 px-4">
<span class="text-subtitle-1 font-weight-medium">搜尋條件</span>
<v-spacer />
<v-btn :icon="mdiClose" size="small" variant="text" @click="$emit('toggle-search')" />
</v-card-title>
<v-divider />
<v-card-text class="px-2 py-1">
<v-row density="compact">
<slot name="search-fields" />
</v-row>
</v-card-text>
</v-card>
</v-bottom-sheet>
</template>
<script setup lang="ts">
import { mdiClose, mdiMagnify, mdiPlus } from '@mdi/js'
import { useDisplay } from 'vuetify'
const { mdAndUp } = useDisplay()
defineProps({
title: {
type: String,
default: '學生資料維護',
},
createLabel: {
type: String,
default: '新增資料',
},
searchPanelOpen: {
type: Boolean,
required: true,
},
})
defineEmits<{
(event: 'toggle-search'): void
(event: 'create'): void
}>()
</script>
@@ -0,0 +1,175 @@
<template>
<v-card border class="d-flex flex-column h-100 rounded-0" flat width="100%">
<v-toolbar color="transparent" density="compact" flat>
<v-btn :icon="mdiArrowLeft" size="small" variant="text" @click="$emit('close')" />
<v-toolbar-title class="text-subtitle-1 font-weight-bold">
{{ semester?.semesterName || '課程明細' }}
</v-toolbar-title>
</v-toolbar>
<v-divider />
<v-card-text class="pa-0 flex-grow-1 overflow-y-auto bg-grey-lighten-5">
<div v-if="semester" class="pa-4 d-flex flex-column ga-4">
<div class="d-flex flex-column ga-3">
<v-card class="py-2 px-3" variant="outlined">
<div class="text-caption text-medium-emphasis">學期平均</div>
<div class="text-h6 font-weight-bold text-primary">{{ semester.average }}</div>
</v-card>
<v-card class="py-2 px-3" variant="outlined">
<div class="text-caption text-medium-emphasis">班級排名</div>
<div class="text-h6 font-weight-bold">{{ semester.rank }}</div>
</v-card>
<v-card class="py-2 px-3" variant="outlined">
<div class="text-caption text-medium-emphasis">總學分</div>
<div class="text-h6 font-weight-bold">{{ totalCredits }}</div>
</v-card>
</div>
<div class="d-flex align-center">
<div>
<div class="text-subtitle-2 font-weight-bold">課程列表</div>
<div class="text-caption text-medium-emphasis">
手機版改用卡片式維護不使用扁平表格
</div>
</div>
<v-spacer />
<v-btn
v-if="!isViewMode"
color="primary"
:disabled="isFormLocked"
:prepend-icon="mdiPlus"
size="small"
variant="text"
@click="$emit('add-course', semester.id)"
>
加入課程
</v-btn>
</div>
<template v-if="semester.courses.length > 0">
<v-card
v-for="(course, idx) in semester.courses"
:key="`${course.code}-${idx}`"
class="pa-3"
variant="outlined"
>
<template v-if="isViewMode">
<div class="d-flex align-start justify-space-between ga-3">
<div>
<div class="text-body-1 font-weight-medium">{{ course.name }}</div>
<div class="text-caption text-medium-emphasis">{{ course.code }}</div>
</div>
<v-chip
:color="course.score < 60 ? 'error' : 'success'"
size="small"
variant="tonal"
>
{{ course.score }}
</v-chip>
</div>
<div class="d-flex ga-4 mt-3 text-body-2">
<div>學分 {{ course.credits }}</div>
</div>
</template>
<template v-else>
<div class="d-flex align-center mb-3">
<div class="text-subtitle-2 font-weight-bold">課程 {{ idx + 1 }}</div>
<v-spacer />
<v-btn
color="error"
:disabled="isFormLocked"
:icon="mdiDelete"
size="small"
variant="text"
@click="$emit('delete-course', semester.id, idx)"
/>
</div>
<div class="d-flex flex-column ga-3">
<v-text-field
density="comfortable"
:disabled="isFormLocked"
hide-details
label="課程名稱"
:model-value="course.name"
variant="outlined"
@update:model-value="(value) => updateCourse(idx, { name: String(value) })"
/>
<v-text-field
density="comfortable"
:disabled="isFormLocked"
hide-details
label="代碼"
:model-value="course.code"
variant="outlined"
@update:model-value="(value) => updateCourse(idx, { code: String(value) })"
/>
<v-text-field
density="comfortable"
:disabled="isFormLocked"
hide-details
label="學分"
:model-value="course.credits"
type="number"
variant="outlined"
@update:model-value="
(value) => updateCourse(idx, { credits: Number(value) || 0 })
"
/>
<v-text-field
density="comfortable"
:disabled="isFormLocked"
hide-details
label="分數"
:model-value="course.score"
type="number"
variant="outlined"
@update:model-value="(value) => updateCourse(idx, { score: Number(value) || 0 })"
/>
</div>
</template>
</v-card>
</template>
<div v-else class="text-center text-medium-emphasis py-6 border border-dashed rounded">
尚無課程資料
</div>
</div>
</v-card-text>
</v-card>
</template>
<script setup lang="ts">
import type { CourseRecord, SemesterRecord } from '@/stores/semesters'
import { mdiArrowLeft, mdiDelete, mdiPlus } from '@mdi/js'
import { computed } from 'vue'
const props = defineProps<{
semester: SemesterRecord | null
isViewMode: boolean
isFormLocked: boolean
}>()
const emit = defineEmits<{
(event: 'close'): void
(event: 'add-course', semesterId: number): void
(
event: 'update-course',
semesterId: number,
courseIndex: number,
payload: Partial<CourseRecord>
): void
(event: 'delete-course', semesterId: number, courseIndex: number): void
}>()
const totalCredits = computed(
() => props.semester?.courses.reduce((sum, course) => sum + course.credits, 0) ?? 0
)
function updateCourse(courseIndex: number, payload: Partial<CourseRecord>) {
if (!props.semester) return
emit('update-course', props.semester.id, courseIndex, payload)
}
</script>
@@ -0,0 +1,197 @@
<template>
<div class="text-subtitle-1 font-weight-bold my-3 d-flex align-center">
<v-icon start :icon="mdiSchool" />
子檔資料示範
<v-spacer />
<v-btn
v-if="!isMobile && !isFormReadonly && !isFormLocked"
color="primary"
:prepend-icon="mdiPlus"
size="small"
variant="tonal"
@click="$emit('add-course')"
>
新增成績
</v-btn>
</div>
<div v-if="isMobile" class="d-flex flex-column ga-3">
<v-card
v-for="semester in semesters"
:key="semester.id"
class="cursor-pointer"
:class="{ 'border-opacity-100': selectedSemesterId === semester.id }"
:color="selectedSemesterId === semester.id ? 'primary' : undefined"
variant="outlined"
@click="$emit('select-semester', semester.id)"
>
<v-card-text class="pa-4">
<div class="d-flex align-start justify-space-between ga-3">
<div>
<div class="text-body-1 font-weight-bold">{{ semester.semesterName }}</div>
<div class="text-caption text-medium-emphasis">點擊查看課程與成績</div>
</div>
<v-icon size="small" :icon="mdiChevronRight" />
</div>
<div class="d-flex flex-wrap ga-2 mt-3">
<v-chip color="primary" size="small" variant="tonal">平均 {{ semester.average }}</v-chip>
<v-chip size="small" variant="tonal">排名 {{ semester.rank }}</v-chip>
<v-chip size="small" variant="tonal">課程 {{ semester.courses.length }}</v-chip>
</div>
</v-card-text>
</v-card>
</div>
<div v-else class="flex-grow-1" style="min-height: 0">
<div class="d-flex flex-column ga-3 overflow-y-auto pr-1 h-100">
<v-card class="flex-shrink-0" variant="flat">
<v-card-text class="pa-2">
<v-data-table
class="border rounded"
density="compact"
:headers="headers"
hide-default-footer
:items="flattenedCourses"
:items-per-page="-1"
>
<template #[`item.semesterName`]="slotProps">
{{ slotProps.item.semesterName }}
</template>
<template #[`item.name`]="slotProps">
{{ slotProps.item.name }}
</template>
<template #[`item.credits`]="slotProps">
<span v-if="isFormReadonly">{{ slotProps.item.credits }}</span>
<v-text-field
v-else
:aria-label="`${slotProps.item.semesterName} ${slotProps.item.name} 學分`"
density="compact"
:disabled="isFormLocked"
hide-details
hide-spin-buttons
:model-value="slotProps.item.credits"
:name="`semester-${slotProps.item.semesterId}-course-${slotProps.item.courseIndex}-credits`"
type="number"
variant="outlined"
@update:model-value="
(value) =>
$emit('update-course', slotProps.item.semesterId, slotProps.item.courseIndex, {
credits: Number(value) || 0,
})
"
/>
</template>
<template #[`item.score`]="slotProps">
<span v-if="isFormReadonly">{{ slotProps.item.score }}</span>
<v-text-field
v-else
:aria-label="`${slotProps.item.semesterName} ${slotProps.item.name} 分數`"
density="compact"
:disabled="isFormLocked"
hide-details
hide-spin-buttons
:model-value="slotProps.item.score"
:name="`semester-${slotProps.item.semesterId}-course-${slotProps.item.courseIndex}-score`"
type="number"
variant="outlined"
@update:model-value="
(value) =>
$emit('update-course', slotProps.item.semesterId, slotProps.item.courseIndex, {
score: Number(value) || 0,
})
"
/>
</template>
<template #[`item.actions`]="slotProps">
<v-btn
color="error"
:disabled="isFormLocked"
:icon="mdiDelete"
size="small"
variant="text"
@click="
$emit('delete-course', slotProps.item.semesterId, slotProps.item.courseIndex)
"
/>
</template>
</v-data-table>
</v-card-text>
</v-card>
</div>
</div>
<div
v-if="semesters.length === 0"
class="text-caption text-center py-6 text-medium-emphasis border border-dashed rounded"
>
尚無成績資料
</div>
</template>
<script setup lang="ts">
import type { CourseRecord, SemesterRecord } from '@/stores/semesters'
import { mdiChevronRight, mdiDelete, mdiPlus, mdiSchool } from '@mdi/js'
import { computed } from 'vue'
const props = defineProps<{
semesters: SemesterRecord[]
isMobile: boolean
isFormReadonly: boolean
isFormLocked: boolean
selectedSemesterId: number | null
}>()
defineEmits<{
(event: 'select-semester', semesterId: number): void
(event: 'add-course'): void
(
event: 'update-course',
semesterId: number,
courseIndex: number,
payload: Partial<CourseRecord>
): void
(event: 'delete-course', semesterId: number, courseIndex: number): void
}>()
const headers = computed(() => {
const baseHeaders: Array<{ title: string; key: string; sortable: boolean; width?: string }> = [
{ title: '學年學期', key: 'semesterName', sortable: true },
{ title: '課程名稱', key: 'name', sortable: true },
{ title: '學分', key: 'credits', sortable: true, width: '100' },
{ title: '分數', key: 'score', sortable: true, width: '100' },
]
if (!props.isFormReadonly) {
baseHeaders.push({ title: '操作', key: 'actions', sortable: false, width: '60' })
}
return baseHeaders
})
const flattenedCourses = computed(() => {
const result: Array<{
semesterId: number
courseIndex: number
semesterName: string
name: string
credits: number
score: number
}> = []
for (const semester of props.semesters) {
for (const [courseIndex, course] of semester.courses.entries()) {
result.push({
semesterId: semester.id,
courseIndex,
semesterName: semester.semesterName,
name: course.name,
credits: course.credits,
score: course.score,
})
}
}
return result
})
</script>
@@ -0,0 +1,65 @@
<template>
<div class="text-subtitle-1 font-weight-bold mb-3 d-flex align-center">
<v-icon start :icon="mdiSchool" />
子檔資料示範 ({{ semesters.length }})
<v-spacer />
<v-btn
v-if="!isViewMode"
color="primary"
:prepend-icon="mdiPlus"
size="small"
variant="text"
@click="$emit('add')"
>
新增學期
</v-btn>
</div>
<v-row density="compact">
<v-col v-for="semester in semesters" :key="semester.id" cols="12" :md="isMobile ? 12 : 6">
<v-card
class="cursor-pointer mb-2"
:class="{ 'border-opacity-100': selectedSemesterId === semester.id }"
:color="selectedSemesterId === semester.id ? 'primary' : undefined"
variant="outlined"
@click="$emit('select', semester.id)"
>
<v-list-item density="compact">
<v-list-item-title class="text-body-2 font-weight-bold">{{
semester.semesterName
}}</v-list-item-title>
<v-list-item-subtitle class="text-caption">
平均: {{ semester.average }} 排名: {{ semester.rank }}
</v-list-item-subtitle>
<template #append>
<v-icon size="small" :icon="mdiChevronRight" />
</template>
</v-list-item>
</v-card>
</v-col>
</v-row>
<div
v-if="semesters.length === 0"
class="text-caption text-center py-6 text-medium-emphasis border border-dashed rounded"
>
尚無學期資料
</div>
</template>
<script setup lang="ts">
import type { SemesterRecord } from '@/stores/semesters'
import { mdiChevronRight, mdiPlus, mdiSchool } from '@mdi/js'
defineProps<{
semesters: SemesterRecord[]
selectedSemesterId: number | null
isViewMode: boolean
isMobile: boolean
}>()
defineEmits<{
(event: 'select', id: number): void
(event: 'add'): void
}>()
</script>
@@ -0,0 +1,214 @@
<template>
<v-card border class="d-flex flex-column h-100 rounded-0" flat width="100%">
<v-toolbar color="transparent" density="compact" flat>
<v-btn :icon="mdiArrowLeft" size="small" variant="text" @click="$emit('close')" />
<v-toolbar-title class="text-subtitle-1 font-weight-bold">
{{ semester?.semesterName || '學期明細' }}
</v-toolbar-title>
</v-toolbar>
<v-divider />
<v-card-text class="pa-0 flex-grow-1 overflow-y-auto bg-grey-lighten-5">
<div v-if="semester" class="pa-4 d-flex flex-column ga-4">
<div class="d-flex flex-column ga-3">
<v-card class="py-2 px-3" variant="outlined">
<div class="text-caption text-medium-emphasis">學期平均</div>
<div class="text-h6 font-weight-bold text-primary">{{ semester.average }}</div>
</v-card>
<v-card class="py-2 px-3" variant="outlined">
<div class="text-caption text-medium-emphasis">班級排名</div>
<div class="text-h6 font-weight-bold">{{ semester.rank }}</div>
</v-card>
<v-card class="py-2 px-3" variant="outlined">
<div class="text-caption text-medium-emphasis">總學分</div>
<div class="text-h6 font-weight-bold">{{ totalCredits }}</div>
</v-card>
</div>
<template v-if="isViewMode">
<v-card
v-for="course in semester.courses"
:key="course.code"
class="pa-3"
variant="outlined"
>
<div class="d-flex align-start justify-space-between ga-3">
<div>
<div class="text-body-1 font-weight-medium">{{ course.name }}</div>
<div class="text-caption text-medium-emphasis">{{ course.code }}</div>
</div>
<v-chip :color="course.score < 60 ? 'error' : 'success'" size="small" variant="tonal">
{{ course.score }}
</v-chip>
</div>
<div class="d-flex ga-4 mt-3 text-body-2">
<div>學分 {{ course.credits }}</div>
</div>
</v-card>
<div
v-if="semester.courses.length === 0"
class="text-center text-medium-emphasis py-6 border border-dashed rounded"
>
尚無課程資料
</div>
</template>
<template v-else>
<v-text-field
density="comfortable"
:disabled="isFormLocked"
hide-details="auto"
label="學期名稱"
:model-value="semester.semesterName"
variant="outlined"
@update:model-value="(value) => updateSemester({ semesterName: String(value) })"
/>
<v-text-field
density="comfortable"
:disabled="isFormLocked"
hide-details="auto"
label="班級排名"
:model-value="semester.rank"
type="number"
variant="outlined"
@update:model-value="(value) => updateSemester({ rank: Number(value) || 0 })"
/>
<v-text-field
density="comfortable"
hide-details="auto"
label="平均分數"
:model-value="semester.average"
readonly
variant="filled"
/>
<div class="d-flex align-center">
<div class="text-subtitle-2 font-weight-bold">課程列表</div>
<v-spacer />
<v-btn
color="primary"
:disabled="isFormLocked"
:prepend-icon="mdiPlus"
size="small"
variant="text"
@click="$emit('add-course', semester.id)"
>
加入課程
</v-btn>
</div>
<v-card
v-for="(course, idx) in semester.courses"
:key="`${course.code}-${idx}`"
class="pa-3"
variant="outlined"
>
<div class="d-flex align-center mb-3">
<div class="text-subtitle-2 font-weight-bold">課程 {{ idx + 1 }}</div>
<v-spacer />
<v-btn
color="error"
:disabled="isFormLocked"
:icon="mdiDelete"
size="small"
variant="text"
@click="$emit('delete-course', semester.id, idx, course.name)"
/>
</div>
<div class="d-flex flex-column ga-3">
<v-text-field
density="comfortable"
:disabled="isFormLocked"
hide-details
label="課程名稱"
:model-value="course.name"
variant="outlined"
@update:model-value="(value) => updateCourse(idx, { name: String(value) })"
/>
<v-text-field
density="comfortable"
:disabled="isFormLocked"
hide-details
label="代碼"
:model-value="course.code"
variant="outlined"
@update:model-value="(value) => updateCourse(idx, { code: String(value) })"
/>
<v-text-field
density="comfortable"
:disabled="isFormLocked"
hide-details
label="學分"
:model-value="course.credits"
type="number"
variant="outlined"
@update:model-value="(value) => updateCourse(idx, { credits: Number(value) || 0 })"
/>
<v-text-field
density="comfortable"
:disabled="isFormLocked"
hide-details
label="分數"
:model-value="course.score"
type="number"
variant="outlined"
@update:model-value="(value) => updateCourse(idx, { score: Number(value) || 0 })"
/>
</div>
</v-card>
<div
v-if="semester.courses.length === 0"
class="text-center text-medium-emphasis py-6 border border-dashed rounded"
>
尚無課程資料
</div>
</template>
</div>
</v-card-text>
</v-card>
</template>
<script setup lang="ts">
import type { CourseRecord, SemesterRecord } from '@/stores/semesters'
import { mdiArrowLeft, mdiDelete, mdiPlus } from '@mdi/js'
import { computed } from 'vue'
const props = defineProps<{
semester: SemesterRecord | null
isViewMode: boolean
isFormLocked: boolean
}>()
const emit = defineEmits<{
(event: 'close'): void
(event: 'add-course', semesterId: number): void
(event: 'update-semester', semesterId: number, payload: Partial<SemesterRecord>): void
(
event: 'update-course',
semesterId: number,
courseIndex: number,
payload: Partial<CourseRecord>
): void
(event: 'delete-course', semesterId: number, courseIndex: number, courseName: string): void
}>()
const totalCredits = computed(
() => props.semester?.courses.reduce((sum, course) => sum + course.credits, 0) ?? 0
)
function updateSemester(payload: Partial<SemesterRecord>) {
if (!props.semester) return
emit('update-semester', props.semester.id, payload)
}
function updateCourse(courseIndex: number, payload: Partial<CourseRecord>) {
if (!props.semester) return
emit('update-course', props.semester.id, courseIndex, payload)
}
</script>
@@ -0,0 +1,398 @@
<template>
<v-card border class="d-flex flex-column h-100 rounded-0" flat width="100%">
<v-toolbar v-if="!isDetailEditing" color="transparent" density="compact" flat>
<v-btn
:icon="isMobile ? mdiArrowLeft : mdiClose"
size="small"
variant="text"
@click="$emit('close')"
/>
<v-toolbar-title class="text-subtitle-1 font-weight-bold">
{{ selectedSemester ? selectedSemester.semesterName : '學期明細' }}
</v-toolbar-title>
<v-spacer />
<v-btn
v-if="selectedSemester && !isViewMode"
color="error"
:prepend-icon="mdiDelete"
size="small"
variant="text"
@click="$emit('delete', selectedSemester.id)"
>
刪除
</v-btn>
<v-btn
v-if="selectedSemester && !isViewMode"
color="primary"
:prepend-icon="mdiPencil"
size="small"
variant="text"
@click="$emit('start-edit')"
>
編輯
</v-btn>
</v-toolbar>
<v-toolbar v-else density="compact" flat>
<v-btn
:icon="isMobile ? mdiArrowLeft : mdiClose"
size="small"
variant="text"
@click="$emit('cancel-edit')"
/>
<v-toolbar-title class="text-subtitle-1 font-weight-bold"> 編輯學期 </v-toolbar-title>
<v-spacer />
<v-btn variant="text" @click="$emit('save-edit')">儲存</v-btn>
</v-toolbar>
<v-divider />
<v-card-text v-if="!isDetailEditing" class="pa-0 flex-grow-1 overflow-y-auto bg-grey-lighten-5">
<div v-if="selectedSemester" class="h-100 d-flex flex-column">
<div :class="statsClass">
<v-card class="py-2 px-3" :class="statCardClass" variant="outlined">
<div class="text-caption text-medium-emphasis">學期平均</div>
<div class="text-h6 font-weight-bold text-primary">{{ selectedSemester.average }}</div>
</v-card>
<v-card class="py-2 px-3" :class="statCardClass" variant="outlined">
<div class="text-caption text-medium-emphasis">班級排名</div>
<div class="text-h6 font-weight-bold">{{ selectedSemester.rank }}</div>
</v-card>
<v-card class="py-2 px-3" :class="statCardClass" variant="outlined">
<div class="text-caption text-medium-emphasis">總學分</div>
<div class="text-h6 font-weight-bold">{{ totalCredits }}</div>
</v-card>
</div>
<v-divider />
<div v-if="isMobile" class="pa-3 d-flex flex-column ga-3">
<v-card
v-for="course in selectedSemester.courses"
:key="course.code"
class="pa-3"
variant="outlined"
>
<div class="d-flex align-start justify-space-between ga-3">
<div>
<div class="text-body-1 font-weight-medium">{{ course.name }}</div>
<div class="text-caption text-medium-emphasis">{{ course.code }}</div>
</div>
<v-chip :color="course.score < 60 ? 'error' : 'success'" size="small" variant="tonal">
{{ course.score }}
</v-chip>
</div>
<div class="d-flex ga-4 mt-3 text-body-2">
<div>學分 {{ course.credits }}</div>
</div>
</v-card>
<div
v-if="selectedSemester.courses.length === 0"
class="text-center text-medium-emphasis py-6"
>
尚無課程資料
</div>
</div>
<v-table v-else class="flex-grow-1">
<thead>
<tr>
<th class="text-left bg-grey-lighten-4">課程名稱</th>
<th class="text-center bg-grey-lighten-4" width="80">學分</th>
<th class="text-right bg-grey-lighten-4" width="80">分數</th>
</tr>
</thead>
<tbody>
<tr v-for="course in selectedSemester.courses" :key="course.code">
<td>
<div class="text-body-2 font-weight-medium">{{ course.name }}</div>
<div class="text-caption text-medium-emphasis">{{ course.code }}</div>
</td>
<td class="text-center">{{ course.credits }}</td>
<td
class="text-right font-weight-bold"
:class="course.score < 60 ? 'text-error' : 'text-success'"
>
{{ course.score }}
</td>
</tr>
</tbody>
</v-table>
</div>
</v-card-text>
<v-card-text v-else class="pa-0 flex-grow-1 overflow-y-auto bg-surface">
<div v-if="detailForm" class="pa-4 d-flex flex-column ga-4">
<v-row density="compact">
<v-col cols="12">
<v-text-field
:model-value="detailForm.semesterName"
density="compact"
hide-details="auto"
label="學期名稱"
variant="outlined"
@update:model-value="updateDetailFormField('semesterName', $event)"
/>
</v-col>
<v-col :cols="isMobile ? 12 : 6">
<v-text-field
:model-value="detailForm.rank"
density="compact"
hide-details="auto"
label="班級排名"
type="number"
variant="outlined"
@update:model-value="updateDetailFormField('rank', toNumber($event))"
/>
</v-col>
<v-col :cols="isMobile ? 12 : 6">
<v-text-field
density="compact"
hide-details="auto"
label="平均分數 (自動計算)"
:model-value="detailForm.average"
readonly
variant="filled"
/>
</v-col>
</v-row>
<div class="d-flex align-center mt-2 mb-1">
<div class="text-subtitle-2 font-weight-bold">課程列表</div>
<v-spacer />
<v-btn
color="primary"
:prepend-icon="mdiPlus"
size="small"
variant="text"
@click="addCourse"
>
加入課程
</v-btn>
</div>
<div v-if="isMobile" class="d-flex flex-column ga-3">
<v-card
v-for="(course, idx) in detailForm.courses"
:key="idx"
class="pa-3"
variant="outlined"
>
<div class="d-flex align-center mb-3">
<div class="text-subtitle-2 font-weight-bold">課程 {{ idx + 1 }}</div>
<v-spacer />
<v-btn
color="error"
:icon="mdiDelete"
size="small"
variant="text"
@click="removeCourse(idx)"
/>
</div>
<div class="d-flex flex-column ga-3">
<v-text-field
:model-value="course.name"
density="compact"
hide-details
label="課程名稱"
variant="outlined"
@update:model-value="updateCourseField(idx, 'name', $event)"
/>
<v-text-field
:model-value="course.code"
density="compact"
hide-details
label="代碼"
variant="outlined"
@update:model-value="updateCourseField(idx, 'code', $event)"
/>
<v-text-field
:model-value="course.credits"
density="compact"
hide-details
label="學分"
type="number"
variant="outlined"
@update:model-value="updateCourseField(idx, 'credits', toNumber($event))"
/>
<v-text-field
:model-value="course.score"
density="compact"
hide-details
label="分數"
type="number"
variant="outlined"
@update:model-value="updateCourseField(idx, 'score', toNumber($event))"
/>
</div>
</v-card>
<div
v-if="detailForm.courses.length === 0"
class="text-center text-medium-emphasis py-4 border border-dashed rounded"
>
暫無課程請點擊上方按鈕新增
</div>
</div>
<v-table v-else class="border rounded">
<thead>
<tr>
<th width="40"></th>
<th>課程資訊</th>
<th width="90">學分</th>
<th width="90">分數</th>
</tr>
</thead>
<tbody>
<tr v-for="(course, idx) in detailForm.courses" :key="idx">
<td class="px-0 text-center">
<v-btn
color="error"
:icon="mdiDelete"
size="small"
variant="text"
@click="removeCourse(idx)"
/>
</td>
<td class="py-2">
<v-text-field
:model-value="course.name"
class="mb-1"
density="compact"
hide-details
label="課程名稱"
variant="underlined"
@update:model-value="updateCourseField(idx, 'name', $event)"
/>
<v-text-field
:model-value="course.code"
density="compact"
hide-details
label="代碼"
style="font-size: 0.85em"
variant="underlined"
@update:model-value="updateCourseField(idx, 'code', $event)"
/>
</td>
<td class="align-top py-2">
<v-text-field
:model-value="course.credits"
density="compact"
hide-details
type="number"
variant="outlined"
@update:model-value="updateCourseField(idx, 'credits', toNumber($event))"
/>
</td>
<td class="align-top py-2">
<v-text-field
:model-value="course.score"
density="compact"
hide-details
type="number"
variant="outlined"
@update:model-value="updateCourseField(idx, 'score', toNumber($event))"
/>
</td>
</tr>
<tr v-if="detailForm.courses.length === 0">
<td class="text-center text-medium-emphasis py-4" colspan="4">
暫無課程請點擊上方按鈕新增
</td>
</tr>
</tbody>
</v-table>
</div>
</v-card-text>
</v-card>
</template>
<script setup lang="ts">
import type { SemesterRecord } from '@/stores/semesters'
import { mdiArrowLeft, mdiClose, mdiDelete, mdiPencil, mdiPlus } from '@mdi/js'
import { computed } from 'vue'
const props = defineProps<{
selectedSemester: SemesterRecord | null
detailForm: SemesterRecord | null
isViewMode: boolean
isDetailEditing: boolean
isMobile: boolean
}>()
const emit = defineEmits<{
(event: 'close'): void
(event: 'start-edit'): void
(event: 'delete', id: number): void
(event: 'cancel-edit'): void
(event: 'save-edit'): void
(event: 'add-course'): void
(event: 'remove-course', index: number): void
(event: 'update:detail-form', value: SemesterRecord | null): void
}>()
function cloneDetailForm(form: SemesterRecord): SemesterRecord {
return {
...form,
courses: form.courses.map((course) => ({ ...course })),
}
}
function emitDetailFormUpdate(updater: (draft: SemesterRecord) => void) {
if (!props.detailForm) return
const nextForm = cloneDetailForm(props.detailForm)
updater(nextForm)
emit('update:detail-form', nextForm)
}
function updateDetailFormField<K extends keyof SemesterRecord>(key: K, value: SemesterRecord[K]) {
emitDetailFormUpdate((draft) => {
draft[key] = value
})
}
function updateCourseField<K extends keyof SemesterRecord['courses'][number]>(
index: number,
key: K,
value: SemesterRecord['courses'][number][K]
) {
emitDetailFormUpdate((draft) => {
const course = draft.courses[index]
if (!course) return
course[key] = value
})
}
function toNumber(value: unknown): number {
if (typeof value === 'number') return value
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : 0
}
function addCourse() {
emitDetailFormUpdate((draft) => {
draft.courses.push({
code: '',
name: '',
credits: 3,
score: 0,
})
})
}
function removeCourse(index: number) {
emitDetailFormUpdate((draft) => {
draft.courses.splice(index, 1)
})
}
const totalCredits = computed(
() => props.selectedSemester?.courses.reduce((sum, course) => sum + course.credits, 0) ?? 0
)
const statsClass = computed(() =>
props.isMobile ? 'pa-3 d-flex flex-column ga-3 bg-surface' : 'pa-3 d-flex ga-3 bg-surface'
)
const statCardClass = computed(() => (props.isMobile ? '' : 'flex-grow-1'))
</script>
@@ -0,0 +1,264 @@
<template>
<div class="text-subtitle-1 font-weight-bold my-3 d-flex align-center">
<v-icon start :icon="mdiSchool" />
子檔資料示範
</div>
<div v-if="isMobile" class="d-flex flex-column ga-3">
<v-card
v-for="semester in semesters"
:key="semester.id"
class="cursor-pointer"
:class="{ 'border-opacity-100': selectedSemesterId === semester.id }"
:color="selectedSemesterId === semester.id ? 'primary' : undefined"
variant="outlined"
@click="$emit('select', semester.id)"
>
<v-card-text class="pa-4">
<div class="d-flex align-start justify-space-between ga-3">
<div>
<div class="text-body-1 font-weight-bold">{{ semester.semesterName }}</div>
<div class="text-caption text-medium-emphasis">點擊查看課程與成績</div>
</div>
<v-icon size="small" :icon="mdiChevronRight" />
</div>
<div class="d-flex flex-wrap ga-2 mt-3">
<v-chip color="primary" size="small" variant="tonal">平均 {{ semester.average }}</v-chip>
<v-chip size="small" variant="tonal">排名 {{ semester.rank }}</v-chip>
<v-chip size="small" variant="tonal">課程 {{ semester.courses.length }}</v-chip>
</div>
</v-card-text>
</v-card>
</div>
<div v-else class="flex-grow-1" style="min-height: 0">
<div class="d-flex flex-column ga-3 overflow-y-auto pr-1 h-100">
<v-expansion-panels>
<v-expansion-panel v-for="semester in semesters" :key="semester.id">
<v-expansion-panel-title color="background">
<div class="d-flex align-center ga-3 w-100 pr-2">
<span class="font-weight-medium">{{ semester.semesterName }}</span>
</div>
</v-expansion-panel-title>
<v-expansion-panel-text>
<div class="d-flex align-center mb-2">
<span class="text-subtitle-1 font-weight-bold text-medium-emphasis">課程列表</span>
<v-spacer />
<v-btn
v-if="!isFormReadonly"
color="primary"
:disabled="isFormLocked"
:prepend-icon="mdiPlus"
size="small"
variant="tonal"
@click="$emit('add-course', semester.id)"
>
加入課程
</v-btn>
</div>
<v-table class="border rounded" density="compact" fixed-header>
<thead>
<tr>
<th class="cursor-pointer" width="50%" @click="toggleSort(semester.id, 'name')">
<div class="d-flex align-center ga-1">
<span>課程名稱</span>
<v-icon size="small" :icon="getSortIcon(semester.id, 'name')" />
</div>
</th>
<th class="cursor-pointer" @click="toggleSort(semester.id, 'credits')">
<div class="d-flex align-center ga-1">
<span>學分</span>
<v-icon size="small" :icon="getSortIcon(semester.id, 'credits')" />
</div>
</th>
<th class="cursor-pointer" @click="toggleSort(semester.id, 'score')">
<div class="d-flex align-center ga-1">
<span>分數</span>
<v-icon size="small" :icon="getSortIcon(semester.id, 'score')" />
</div>
</th>
<th v-if="!isFormReadonly" width="52"></th>
</tr>
</thead>
<tbody>
<tr
v-for="{ course, originalIndex } in getSortedCourses(semester)"
:key="`${semester.id}-${originalIndex}`"
>
<td class="py-0">{{ course.name }}</td>
<td class="align-top py-0">
<span v-if="isFormReadonly">{{ course.credits }}</span>
<v-text-field
v-else
:aria-label="`${semester.semesterName} ${course.name} 學分`"
density="compact"
:disabled="isFormLocked"
hide-details
hide-spin-buttons
:model-value="course.credits"
:name="`semester-${semester.id}-course-${originalIndex}-credits`"
type="number"
variant="outlined"
@update:model-value="
(value) =>
$emit('update-course', semester.id, originalIndex, {
credits: Number(value) || 0,
})
"
/>
</td>
<td class="align-top py-0">
<span v-if="isFormReadonly">{{ course.score }}</span>
<v-text-field
v-else
:aria-label="`${semester.semesterName} ${course.name} 分數`"
density="compact"
:disabled="isFormLocked"
hide-details
hide-spin-buttons
:model-value="course.score"
:name="`semester-${semester.id}-course-${originalIndex}-score`"
type="number"
variant="outlined"
@update:model-value="
(value) =>
$emit('update-course', semester.id, originalIndex, {
score: Number(value) || 0,
})
"
/>
</td>
<td v-if="!isFormReadonly" class="px-1 text-center">
<v-btn
color="error"
:disabled="isFormLocked"
:icon="mdiDeleteOutline"
size="small"
variant="text"
@click="$emit('delete-course', semester.id, originalIndex, course.name)"
/>
</td>
</tr>
<tr v-if="semester.courses.length === 0">
<td
class="text-center text-medium-emphasis py-6"
:colspan="isFormReadonly ? 3 : 4"
>
<div class="d-flex flex-column align-center ga-2">
<v-icon color="medium-emphasis" size="24" :icon="mdiBookOpenOutline" />
<span class="text-caption">尚無課程,點擊「加入課程」新增</span>
</div>
</td>
</tr>
</tbody>
</v-table>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</div>
</div>
<div
v-if="semesters.length === 0"
class="text-caption text-center py-6 text-medium-emphasis border border-dashed rounded"
>
尚無學期資料
</div>
</template>
<script setup lang="ts">
import type { CourseRecord, SemesterRecord } from '@/stores/semesters'
import {
mdiArrowDown,
mdiArrowUp,
mdiBookOpenOutline,
mdiChevronRight,
mdiDeleteOutline,
mdiPlus,
mdiSchool,
mdiSwapVertical,
} from '@mdi/js'
import { ref } from 'vue'
type CourseSortKey = 'name' | 'credits' | 'score'
interface CourseSortState {
key: CourseSortKey
order: 'asc' | 'desc'
}
interface SortedCourseRow {
course: CourseRecord
originalIndex: number
}
defineProps<{
semesters: SemesterRecord[]
isMobile: boolean
isFormReadonly: boolean
isFormLocked: boolean
selectedSemesterId: number | null
}>()
defineEmits<{
(event: 'select', semesterId: number): void
(event: 'add-course', semesterId: number): void
(
event: 'update-course',
semesterId: number,
courseIndex: number,
payload: Partial<CourseRecord>
): void
(event: 'delete-course', semesterId: number, courseIndex: number, courseName: string): void
}>()
const semesterSortStates = ref<Record<number, CourseSortState>>({})
const getSortState = (semesterId: number) => semesterSortStates.value[semesterId]
function toggleSort(semesterId: number, key: CourseSortKey) {
const current = getSortState(semesterId)
semesterSortStates.value[semesterId] =
current?.key === key
? {
key,
order: current.order === 'asc' ? 'desc' : 'asc',
}
: {
key,
order: 'asc',
}
}
function getSortIcon(semesterId: number, key: CourseSortKey) {
const current = getSortState(semesterId)
if (current?.key !== key) return mdiSwapVertical
return current.order === 'asc' ? mdiArrowUp : mdiArrowDown
}
function compareCourseValue(left: CourseRecord, right: CourseRecord, key: CourseSortKey) {
if (key === 'name') return left.name.localeCompare(right.name, 'zh-Hant')
return left[key] - right[key]
}
function getSortedCourses(semester: SemesterRecord): SortedCourseRow[] {
const rows = semester.courses.map((course, originalIndex) => ({
course,
originalIndex,
}))
const sortState = getSortState(semester.id)
if (!sortState) return rows
return [...rows].sort((left, right) => {
const result = compareCourseValue(left.course, right.course, sortState.key)
if (result === 0) return left.originalIndex - right.originalIndex
return sortState.order === 'asc' ? result : -result
})
}
</script>