成绩录入页面优化

This commit is contained in:
biss committed 2026-08-15 18:57:39 +08:00
1 parent 53bee58a84
commit 7124011da9
3 files changed
+202 -22

No files matched your search

+67
View File
@@ -0,0 +1,67 @@
import { nextTick } from 'vue'
type Direction = 'up' | 'down' | 'left' | 'right'
/**
* Makes an editable score table behave like a spreadsheet without changing
* the normal keyboard behaviour of selects, switches, or free-text fields.
*/
export function useScoreGridNavigation(grid: string) {
function handleScoreGridKeydown(event: KeyboardEvent) {
const directionByKey: Record<string, Direction | undefined> = {
Enter: 'down',
ArrowUp: 'up',
ArrowDown: 'down',
ArrowLeft: 'left',
ArrowRight: 'right',
}
const direction = directionByKey[event.key]
if (!direction) return
const current = event.currentTarget as HTMLElement
const row = Number(current.dataset.scoreRow)
const column = Number(current.dataset.scoreColumn)
if (!Number.isFinite(row) || !Number.isFinite(column)) return
const cells = Array.from(
document.querySelectorAll<HTMLElement>(`[data-score-grid="${grid}"]`),
).filter((cell) => {
const input = cell.querySelector<HTMLInputElement>('input')
return input && !input.disabled && !input.readOnly
})
const findCell = (targetRow: number, targetColumn: number) =>
cells.find((cell) =>
Number(cell.dataset.scoreRow) === targetRow
&& Number(cell.dataset.scoreColumn) === targetColumn)
let target: HTMLElement | undefined
if (direction === 'left' || direction === 'right') {
const rowCells = cells
.filter((cell) => Number(cell.dataset.scoreRow) === row)
.sort((left, right) => Number(left.dataset.scoreColumn) - Number(right.dataset.scoreColumn))
const currentIndex = rowCells.indexOf(current)
target = rowCells[currentIndex + (direction === 'left' ? -1 : 1)]
} else {
const columnCells = cells
.filter((cell) => Number(cell.dataset.scoreColumn) === column)
.sort((top, bottom) => Number(top.dataset.scoreRow) - Number(bottom.dataset.scoreRow))
const currentIndex = columnCells.indexOf(current)
target = columnCells[currentIndex + (direction === 'up' ? -1 : 1)]
?? findCell(row + (direction === 'up' ? -1 : 1), column)
}
// Keep arrow keys from changing an input-number value; navigation is the
// expected spreadsheet action even at an edge where no next cell exists.
event.preventDefault()
if (!target) return
void nextTick(() => {
const input = target?.querySelector<HTMLInputElement>('input')
input?.focus()
input?.select()
})
}
return { handleScoreGridKeydown }
}
+59 -11
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref, watch } from 'vue'
import { import {
Check, Check,
Download, Download,
@@ -19,8 +19,10 @@ import {
academicTermOptionClass, academicTermOptionClass,
defaultAcademicTermId, defaultAcademicTermId,
} from '../utils/academicTerms' } from '../utils/academicTerms'
import { useScoreGridNavigation } from '../utils/scoreGridNavigation'
const auth = useAuthStore() const auth = useAuthStore()
const { handleScoreGridKeydown } = useScoreGridNavigation('experiment-grade-entry')
const isStudent = computed(() => auth.user?.roles.includes('Student') ?? false) const isStudent = computed(() => auth.user?.roles.includes('Student') ?? false)
const canViewAllColleges = computed(() => const canViewAllColleges = computed(() =>
auth.user?.roles.some((role) => ['SuperAdmin', 'AcademicAdmin'].includes(role)) ?? false, auth.user?.roles.some((role) => ['SuperAdmin', 'AcademicAdmin'].includes(role)) ?? false,
@@ -28,6 +30,10 @@ const canViewAllColleges = computed(() =>
const loading = ref(false) const loading = ref(false)
const detailLoading = ref(false) const detailLoading = ref(false)
const saving = ref(false) const saving = ref(false)
const autoSaveState = ref<'idle' | 'saving' | 'saved' | 'error'>('idle')
let autoSaveTimer: ReturnType<typeof setTimeout> | undefined
let savedRecordSnapshot = ''
let autoSaving = false
const terms = ref<any[]>([]) const terms = ref<any[]>([])
const colleges = ref<any[]>([]) const colleges = ref<any[]>([])
const termId = ref('') const termId = ref('')
@@ -252,6 +258,7 @@ async function loadDetail() {
detail.value.canEdit = response.canEdit detail.value.canEdit = response.canEdit
detail.value.canReview = response.canReview detail.value.canReview = response.canReview
detail.value.canPublish = response.canPublish detail.value.canPublish = response.canPublish
savedRecordSnapshot = experimentRecordSnapshot()
} catch (error) { } catch (error) {
ElMessage.error(apiErrorMessage(error)) ElMessage.error(apiErrorMessage(error))
} finally { } finally {
@@ -276,12 +283,8 @@ function previewTotal(record: any): number | null {
return Math.round(total * 10) / 10 return Math.round(total * 10) / 10
} }
async function saveRecords() { function experimentRecordPayload() {
if (!detail.value) return return detail.value.records.map((record: any) => ({
saving.value = true
try {
await http.put(`/experiment-grades/sheets/${detail.value.id}/records`, {
records: detail.value.records.map((record: any) => ({
id: record.id, id: record.id,
participationStatus: record.participationStatus, participationStatus: record.participationStatus,
safetyViolation: record.safetyViolation, safetyViolation: record.safetyViolation,
@@ -295,17 +298,52 @@ async function saveRecords() {
score: score.score, score: score.score,
comment: score.comment, comment: score.comment,
})), })),
})), }))
}
function experimentRecordSnapshot() {
return detail.value ? JSON.stringify(experimentRecordPayload()) : ''
}
function scheduleAutoSave() {
if (!detail.value?.canEdit || experimentRecordSnapshot() === savedRecordSnapshot) return
autoSaveState.value = 'idle'
clearTimeout(autoSaveTimer)
autoSaveTimer = setTimeout(() => { void saveRecords(true) }, 60_000)
}
async function saveRecords(automatic = false) {
if (!detail.value) return
if (automatic && autoSaving) return
const records = experimentRecordPayload()
const snapshot = JSON.stringify(records)
if (automatic) {
autoSaving = true
autoSaveState.value = 'saving'
} else saving.value = true
try {
await http.put(`/experiment-grades/sheets/${detail.value.id}/records`, {
records,
}) })
savedRecordSnapshot = snapshot
if (automatic) autoSaveState.value = 'saved'
else {
ElMessage.success('本页实验成绩已保存') ElMessage.success('本页实验成绩已保存')
await loadDetail() await loadDetail()
}
} catch (error) { } catch (error) {
ElMessage.error(apiErrorMessage(error)) if (automatic) autoSaveState.value = 'error'
else ElMessage.error(apiErrorMessage(error))
} finally { } finally {
saving.value = false if (automatic) {
autoSaving = false
if (experimentRecordSnapshot() !== savedRecordSnapshot) scheduleAutoSave()
} else saving.value = false
} }
} }
watch(() => detail.value?.records, scheduleAutoSave, { deep: true })
function downloadTemplate() { function downloadTemplate() {
if (!detail.value) return if (!detail.value) return
downloadApiFile( downloadApiFile(
@@ -788,6 +826,12 @@ onMounted(async () => {
width="125" width="125"
> >
<template #default="{ row }"> <template #default="{ row }">
<div
data-score-grid="experiment-grade-entry"
:data-score-row="detail.records.indexOf(row)"
:data-score-column="detail.items.indexOf(item)"
@keydown="handleScoreGridKeydown"
>
<el-input-number <el-input-number
v-if="detail.canEdit && ['Completed', 'Makeup'].includes(row.participationStatus)" v-if="detail.canEdit && ['Completed', 'Makeup'].includes(row.participationStatus)"
:model-value="itemScore(row, item.id)?.score" :model-value="itemScore(row, item.id)?.score"
@@ -799,6 +843,7 @@ onMounted(async () => {
@update:model-value="(value: number | undefined) => { const target = itemScore(row, item.id); if (target) target.score = value }" @update:model-value="(value: number | undefined) => { const target = itemScore(row, item.id); if (target) target.score = value }"
/> />
<span v-else>{{ itemScore(row, item.id)?.score ?? '—' }}</span> <span v-else>{{ itemScore(row, item.id)?.score ?? '—' }}</span>
</div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="安全违规" width="105"> <el-table-column label="安全违规" width="105">
@@ -839,11 +884,14 @@ onMounted(async () => {
<div> <div>
<b v-if="detail.reviewComment">审核意见:{{ detail.reviewComment }}</b> <b v-if="detail.reviewComment">审核意见:{{ detail.reviewComment }}</b>
<span>开课学院:{{ detail.courseCollegeName }}</span> <span>开课学院:{{ detail.courseCollegeName }}</span>
<small v-if="detail.canEdit" class="autosave-state">
{{ autoSaveState === 'saving' ? '正在自动保存…' : autoSaveState === 'saved' ? '已自动保存' : autoSaveState === 'error' ? '自动保存失败,请手动保存' : '停止编辑 1 分钟后自动保存' }}
</small>
</div> </div>
<div> <div>
<el-button v-if="detail.canEdit" :icon="Download" @click="downloadTemplate">下载模板</el-button> <el-button v-if="detail.canEdit" :icon="Download" @click="downloadTemplate">下载模板</el-button>
<el-button v-if="detail.canEdit" :icon="Upload" :loading="saving" @click="chooseImportFile">Excel 导入</el-button> <el-button v-if="detail.canEdit" :icon="Upload" :loading="saving" @click="chooseImportFile">Excel 导入</el-button>
<el-button v-if="detail.canEdit" :icon="EditPen" :loading="saving" @click="saveRecords">保存本页</el-button> <el-button v-if="detail.canEdit" :icon="EditPen" :loading="saving" @click="saveRecords()">保存本页</el-button>
<el-button v-if="detail.canEdit" type="primary" :icon="Promotion" @click="submitSheet">提交审核</el-button> <el-button v-if="detail.canEdit" type="primary" :icon="Promotion" @click="submitSheet">提交审核</el-button>
<el-button v-if="detail.canReview" type="danger" plain @click="returnSheet">退回修改</el-button> <el-button v-if="detail.canReview" type="danger" plain @click="returnSheet">退回修改</el-button>
<el-button v-if="detail.canReview" type="success" :icon="Check" @click="approveSheet">审核通过</el-button> <el-button v-if="detail.canReview" type="success" :icon="Check" @click="approveSheet">审核通过</el-button>
+76 -11
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref, watch } from 'vue'
import { import {
Check, Check,
ArrowRight, ArrowRight,
@@ -18,9 +18,11 @@ import { downloadApiFile, importExcel } from '../api/excel'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms' import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
import { useScoreGridNavigation } from '../utils/scoreGridNavigation'
const auth = useAuthStore() const auth = useAuthStore()
const router = useRouter() const router = useRouter()
const { handleScoreGridKeydown } = useScoreGridNavigation('course-grade-entry')
const isStudent = computed(() => auth.user?.roles.includes('Student') && const isStudent = computed(() => auth.user?.roles.includes('Student') &&
!auth.user.roles.some((role) => !auth.user.roles.some((role) =>
['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher'].includes(role))) ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher'].includes(role)))
@@ -53,6 +55,10 @@ const createForm = reactive({
const newItemName = ref('') const newItemName = ref('')
const newItemWeight = ref(0) const newItemWeight = ref(0)
const returnComment = ref('') const returnComment = ref('')
const autoSaveState = ref<'idle' | 'saving' | 'saved' | 'error'>('idle')
let autoSaveTimer: ReturnType<typeof setTimeout> | undefined
let savedRecordSnapshot = ''
let autoSaving = false
const presetItems = ['实验', '实习', '课程设计', '作业', '课堂表现', '期中'] const presetItems = ['实验', '实习', '课程设计', '作业', '课堂表现', '期中']
@@ -199,6 +205,7 @@ async function loadDetail() {
detail.value.canEdit = response.canEdit detail.value.canEdit = response.canEdit
detail.value.canReview = response.canReview detail.value.canReview = response.canReview
detail.value.canPublish = response.canPublish detail.value.canPublish = response.canPublish
savedRecordSnapshot = gradeRecordSnapshot()
if (!detail.value.items?.some((item: any) => item.id === experimentGradeItemId.value)) { if (!detail.value.items?.some((item: any) => item.id === experimentGradeItemId.value)) {
experimentGradeItemId.value = experimentGradeItemId.value =
detail.value.items?.find((item: any) => item.name.includes('实验'))?.id detail.value.items?.find((item: any) => item.name.includes('实验'))?.id
@@ -260,10 +267,8 @@ async function createSheet() {
} }
} }
async function saveRecords() { function gradeRecordPayload() {
try { return detail.value.records.map((record: any) => ({
await http.put(`/grades/sheets/${detail.value.id}/records`, {
records: detail.value.records.map((record: any) => ({
id: record.id, id: record.id,
regularScore: record.regularScore, regularScore: record.regularScore,
finalScore: record.finalScore, finalScore: record.finalScore,
@@ -273,15 +278,51 @@ async function saveRecords() {
})), })),
examStatus: record.examStatus, examStatus: record.examStatus,
notes: record.notes, notes: record.notes,
})), }))
}
function gradeRecordSnapshot() {
return detail.value ? JSON.stringify(gradeRecordPayload()) : ''
}
function scheduleAutoSave() {
if (!detail.value?.canEdit || gradeRecordSnapshot() === savedRecordSnapshot) return
autoSaveState.value = 'idle'
clearTimeout(autoSaveTimer)
autoSaveTimer = setTimeout(() => { void saveRecords(true) }, 60_000)
}
async function saveRecords(automatic = false) {
if (!detail.value || (automatic && autoSaving)) return
const records = gradeRecordPayload()
const snapshot = JSON.stringify(records)
if (automatic) {
autoSaving = true
autoSaveState.value = 'saving'
}
try {
await http.put(`/grades/sheets/${detail.value.id}/records`, {
records,
}) })
savedRecordSnapshot = snapshot
if (automatic) autoSaveState.value = 'saved'
else {
ElMessage.success('成绩已保存并重新计算总评') ElMessage.success('成绩已保存并重新计算总评')
await loadDetail() await loadDetail()
}
} catch (error) { } catch (error) {
ElMessage.error(apiErrorMessage(error)) if (automatic) autoSaveState.value = 'error'
else ElMessage.error(apiErrorMessage(error))
} finally {
if (automatic) {
autoSaving = false
if (gradeRecordSnapshot() !== savedRecordSnapshot) scheduleAutoSave()
}
} }
} }
watch(() => detail.value?.records, scheduleAutoSave, { deep: true })
async function importExperimentScores() { async function importExperimentScores() {
if (!experimentGradeItemId.value) { if (!experimentGradeItemId.value) {
ElMessage.warning('请先选择用于承接实验成绩的课程成绩项。') ElMessage.warning('请先选择用于承接实验成绩的课程成绩项。')
@@ -636,7 +677,14 @@ onMounted(async () => {
</el-table-column> </el-table-column>
<el-table-column :label="`平时 ${detail.regularWeight}%`" width="110"> <el-table-column :label="`平时 ${detail.regularWeight}%`" width="110">
<template #default="{ row }"> <template #default="{ row }">
<div class="score-cell" :class="{ 'is-failed': isFailingScore(row.regularScore) }"> <div
class="score-cell"
:class="{ 'is-failed': isFailingScore(row.regularScore) }"
data-score-grid="course-grade-entry"
:data-score-row="detail.records.indexOf(row)"
data-score-column="0"
@keydown="handleScoreGridKeydown"
>
<el-input-number <el-input-number
v-if="detail.canEdit && ['Normal', 'Deferred'].includes(row.examStatus)" v-if="detail.canEdit && ['Normal', 'Deferred'].includes(row.examStatus)"
v-model="row.regularScore" v-model="row.regularScore"
@@ -653,7 +701,14 @@ onMounted(async () => {
:width="item.sourceType === 'ExperimentSummary' ? 150 : 110" :width="item.sourceType === 'ExperimentSummary' ? 150 : 110"
> >
<template #default="{ row }"> <template #default="{ row }">
<div class="score-cell" :class="{ 'is-failed': isFailingScore(itemScore(row, item.id)?.score) }"> <div
class="score-cell"
:class="{ 'is-failed': isFailingScore(itemScore(row, item.id)?.score) }"
data-score-grid="course-grade-entry"
:data-score-row="detail.records.indexOf(row)"
:data-score-column="detail.items.indexOf(item) + 1"
@keydown="handleScoreGridKeydown"
>
<el-input-number <el-input-number
v-if="detail.canEdit && ['Normal', 'Deferred'].includes(row.examStatus) && item.sourceType !== 'ExperimentSummary'" v-if="detail.canEdit && ['Normal', 'Deferred'].includes(row.examStatus) && item.sourceType !== 'ExperimentSummary'"
:model-value="itemScore(row, item.id)?.score" :model-value="itemScore(row, item.id)?.score"
@@ -669,7 +724,14 @@ onMounted(async () => {
</el-table-column> </el-table-column>
<el-table-column :label="`期末 ${detail.finalWeight}%`" width="110"> <el-table-column :label="`期末 ${detail.finalWeight}%`" width="110">
<template #default="{ row }"> <template #default="{ row }">
<div class="score-cell" :class="{ 'is-failed': isFailingScore(row.finalScore) }"> <div
class="score-cell"
:class="{ 'is-failed': isFailingScore(row.finalScore) }"
data-score-grid="course-grade-entry"
:data-score-row="detail.records.indexOf(row)"
:data-score-column="detail.items.length + 1"
@keydown="handleScoreGridKeydown"
>
<el-input-number <el-input-number
v-if="detail.canEdit && row.examStatus === 'Normal'" v-if="detail.canEdit && row.examStatus === 'Normal'"
v-model="row.finalScore" v-model="row.finalScore"
@@ -730,6 +792,9 @@ onMounted(async () => {
<small v-if="detail.needsCollegeReview && (detail.status === 'Submitted' || detail.status === 'Approved')" class="college-hint"> <small v-if="detail.needsCollegeReview && (detail.status === 'Submitted' || detail.status === 'Approved')" class="college-hint">
开课学院:{{ detail.needsCollegeReview }} 开课学院:{{ detail.needsCollegeReview }}
</small> </small>
<small v-if="detail.canEdit" class="autosave-state">
{{ autoSaveState === 'saving' ? '正在自动保存…' : autoSaveState === 'saved' ? '已自动保存' : autoSaveState === 'error' ? '自动保存失败,请手动保存' : '停止编辑 1 分钟后自动保存' }}
</small>
</div> </div>
<div class="grade-actions-buttons"> <div class="grade-actions-buttons">
<div v-if="detail.canEdit && detail.items?.length" class="experiment-import"> <div v-if="detail.canEdit && detail.items?.length" class="experiment-import">
@@ -749,7 +814,7 @@ onMounted(async () => {
</div> </div>
<el-button v-if="detail.canEdit" :icon="Download" @click="downloadTemplate">下载模板</el-button> <el-button v-if="detail.canEdit" :icon="Download" @click="downloadTemplate">下载模板</el-button>
<el-button v-if="detail.canEdit" :icon="Upload" @click="chooseImportFile">Excel导入</el-button> <el-button v-if="detail.canEdit" :icon="Upload" @click="chooseImportFile">Excel导入</el-button>
<el-button v-if="detail.canEdit" :icon="EditPen" @click="saveRecords">保存成绩</el-button> <el-button v-if="detail.canEdit" :icon="EditPen" @click="saveRecords()">保存成绩</el-button>
<el-button v-if="detail.canEdit" type="primary" :icon="Promotion" @click="submitSheet">提交审核</el-button> <el-button v-if="detail.canEdit" type="primary" :icon="Promotion" @click="submitSheet">提交审核</el-button>
<el-button v-if="detail.canReview" type="danger" plain @click="openReturn">退回修改</el-button> <el-button v-if="detail.canReview" type="danger" plain @click="openReturn">退回修改</el-button>
<el-button v-if="detail.canReview" type="success" :icon="Check" @click="approveSheet">审核通过</el-button> <el-button v-if="detail.canReview" type="success" :icon="Check" @click="approveSheet">审核通过</el-button>