feat: add SingleRecordMnt component for student record maintenance with search, add, edit, view, and delete functionalities

This commit is contained in:
skytek_xinliang
2026-03-26 11:24:37 +08:00
parent 507afcc99c
commit 069141794e
116 changed files with 15247 additions and 107 deletions
@@ -0,0 +1,250 @@
<template>
<div class="text-subtitle-1 font-weight-bold my-3 d-flex align-center">
<v-icon start>mdi-school</v-icon>
子檔資料示範
</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">mdi-chevron-right</v-icon>
</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="mdi-plus"
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">{{ getSortIcon(semester.id, 'name') }}</v-icon>
</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">{{ getSortIcon(semester.id, 'credits') }}</v-icon>
</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">{{ getSortIcon(semester.id, 'score') }}</v-icon>
</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
density="compact"
:disabled="isFormLocked"
hide-details
hide-spin-buttons
:model-value="course.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
density="compact"
:disabled="isFormLocked"
hide-details
hide-spin-buttons
:model-value="course.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="mdi-delete-outline"
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">mdi-book-open-outline</v-icon>
<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 { 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 'mdi-swap-vertical'
return current.order === 'asc' ? 'mdi-arrow-up' : 'mdi-arrow-down'
}
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>