成绩册 Excel 导入/导出 — 已完成
后端 (GradesController.cs)
GET /api/grades/sheets/{id}/template — 下载成绩导入模板
- 根据成绩单的动态分项(GradeItems)生成对应的 Excel 列:学号 | 姓名 | 班级 | 平时成绩 | [分项1] | [分项2] | ... | 期末成绩 | 考试状态 | 备注
- 预填已有学生信息和已有成绩
- 附带填写说明工作表
POST /api/grades/sheets/{id}/import — 从 Excel 导入成绩
- 按学号匹配学生,批量更新平时成绩、分项成绩、期末成绩、考试状态、备注
- 支持留空(不覆盖已有成绩)、0-100 范围校验、考试状态校验(正常/缺考/缓考/免修)
- 导入后自动重新计算总评成绩和绩点
- 仅限 Draft/Returned 状态的成绩单,仅限有编辑权限的用户
- 错误时整批回滚,最多显示 50 条错误详情
前端 (GradesView.vue)
在成绩登记册底部操作栏新增两个按钮(仅在 canEdit 时显示):
- 下载模板 — 调用 downloadApiFile 下载当前成绩单的 Excel 模板
- Excel导入 — 通过隐藏 file input 选择 .xlsx 文件,调用 importExcel 上传
工作流程
1. 教师打开成绩单 → 点击「下载模板」
2. 在 Excel 中填写/修改学生成绩
3. 点击「Excel导入」选择文件上传
4. 系统自动校验、合并成绩、重新计算总评 → 提示更新条数
5. 成绩表刷新显示最新数据
✻ Crunched for 5m 50s
691 lines
28 KiB
Vue
691 lines
28 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, reactive, ref } from 'vue'
|
||
import {
|
||
Check,
|
||
Delete,
|
||
DocumentChecked,
|
||
Download,
|
||
EditPen,
|
||
Plus,
|
||
Promotion,
|
||
Refresh,
|
||
Upload,
|
||
} from '@element-plus/icons-vue'
|
||
import http, { apiErrorMessage } from '../api/http'
|
||
import { downloadApiFile, importExcel } from '../api/excel'
|
||
import { useAuthStore } from '../stores/auth'
|
||
|
||
const auth = useAuthStore()
|
||
const isStudent = computed(() => auth.user?.roles.includes('Student') &&
|
||
!auth.user.roles.some((role) =>
|
||
['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher'].includes(role)))
|
||
const terms = ref<any[]>([])
|
||
const tasks = ref<any[]>([])
|
||
const selectedTask = ref<any | null>(null)
|
||
const detail = ref<any | null>(null)
|
||
const transcript = ref<any>({ student: null, records: [] })
|
||
const loading = ref(false)
|
||
const detailLoading = ref(false)
|
||
const createDialog = ref(false)
|
||
const returnDialog = ref(false)
|
||
const termId = ref<string | undefined>()
|
||
const status = ref<string | undefined>()
|
||
const createForm = reactive({
|
||
regularWeight: 30,
|
||
finalWeight: 70,
|
||
items: [] as { name: string; weight: number }[],
|
||
})
|
||
const newItemName = ref('')
|
||
const newItemWeight = ref(0)
|
||
const returnComment = ref('')
|
||
|
||
const presetItems = ['实验', '实习', '课程设计', '作业', '课堂表现', '期中']
|
||
|
||
const statusLabels: Record<string, string> = {
|
||
Draft: '录入中',
|
||
Submitted: '待审核',
|
||
Approved: '审核通过',
|
||
Published: '已发布',
|
||
Returned: '已退回',
|
||
}
|
||
const statusMeta: Record<string, { icon: string; description: string }> = {
|
||
Draft: { icon: '📝', description: '教师正在录入成绩' },
|
||
Submitted: { icon: '📤', description: '已提交,等待开课学院审核' },
|
||
Approved: { icon: '✅', description: '学院审核通过,待校级发布' },
|
||
Published: { icon: '📢', description: '成绩已向学生发布' },
|
||
Returned: { icon: '↩️', description: '学院退回,需教师修改后重新提交' },
|
||
}
|
||
const examStatusLabels: Record<string, string> = {
|
||
Normal: '正常',
|
||
Absent: '缺考',
|
||
Deferred: '缓考',
|
||
Exempt: '免修',
|
||
}
|
||
const itemsWeight = computed(() =>
|
||
createForm.items.reduce((sum, item) => sum + item.weight, 0))
|
||
const weightSum = computed(() =>
|
||
createForm.regularWeight + createForm.finalWeight + itemsWeight.value)
|
||
const completedCount = computed(() =>
|
||
detail.value?.records.filter((record: any) =>
|
||
record.totalScore != null || record.examStatus !== 'Normal').length ?? 0)
|
||
const averageScore = computed(() => {
|
||
const scores = (detail.value?.records ?? [])
|
||
.map((record: any) => Number(record.totalScore))
|
||
.filter((value: number) => Number.isFinite(value))
|
||
if (!scores.length) return '—'
|
||
return (scores.reduce((sum: number, value: number) => sum + value, 0) / scores.length)
|
||
.toFixed(1)
|
||
})
|
||
const passRate = computed(() => {
|
||
const scores = (detail.value?.records ?? [])
|
||
.map((record: any) => Number(record.totalScore))
|
||
.filter((value: number) => Number.isFinite(value))
|
||
if (!scores.length) return '—'
|
||
return `${Math.round(scores.filter((value: number) => value >= 60).length / scores.length * 100)}%`
|
||
})
|
||
const transcriptGpa = computed(() => {
|
||
const numeric = transcript.value.records.filter((record: any) => record.gradePoint != null)
|
||
const credits = numeric.reduce((sum: number, record: any) => sum + Number(record.credits), 0)
|
||
if (!credits) return '—'
|
||
const points = numeric.reduce(
|
||
(sum: number, record: any) => sum + Number(record.gradePoint) * Number(record.credits), 0,
|
||
)
|
||
return (points / credits).toFixed(2)
|
||
})
|
||
const earnedCredits = computed(() =>
|
||
transcript.value.records
|
||
.filter((record: any) => Number(record.totalScore) >= 60)
|
||
.reduce((sum: number, record: any) => sum + Number(record.credits), 0))
|
||
|
||
function addItem() {
|
||
const name = newItemName.value.trim() || '自定义分项'
|
||
if (createForm.items.some(item => item.name === name)) {
|
||
ElMessage.warning('分项名称不能重复。')
|
||
return
|
||
}
|
||
createForm.items.push({ name, weight: newItemWeight.value || 0 })
|
||
newItemName.value = ''
|
||
newItemWeight.value = 0
|
||
}
|
||
|
||
function addPresetItem(name: string) {
|
||
if (createForm.items.some(item => item.name === name)) {
|
||
ElMessage.warning('该分项已存在。')
|
||
return
|
||
}
|
||
createForm.items.push({ name, weight: 0 })
|
||
}
|
||
|
||
function removeItem(index: number) {
|
||
createForm.items.splice(index, 1)
|
||
}
|
||
|
||
async function load() {
|
||
loading.value = true
|
||
try {
|
||
if (isStudent.value) {
|
||
transcript.value = (await http.get('/grades/student/transcript', {
|
||
params: { academicTermId: termId.value },
|
||
})).data
|
||
return
|
||
}
|
||
tasks.value = (await http.get('/grades/sheets', {
|
||
params: {
|
||
academicTermId: termId.value,
|
||
status: status.value,
|
||
},
|
||
})).data
|
||
const preferred = tasks.value.find((item) => item.id === selectedTask.value?.id)
|
||
?? tasks.value[0]
|
||
if (preferred) await selectTask(preferred)
|
||
else {
|
||
selectedTask.value = null
|
||
detail.value = null
|
||
}
|
||
} catch (error) {
|
||
ElMessage.error(apiErrorMessage(error))
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
async function selectTask(task: any) {
|
||
selectedTask.value = task
|
||
detail.value = null
|
||
if (!task.sheet) return
|
||
detailLoading.value = true
|
||
try {
|
||
const response = (await http.get(`/grades/sheets/${task.sheet.id}`)).data
|
||
detail.value = response.sheet
|
||
detail.value.canEdit = response.canEdit
|
||
detail.value.canReview = response.canReview
|
||
detail.value.canPublish = response.canPublish
|
||
} catch (error) {
|
||
ElMessage.error(apiErrorMessage(error))
|
||
} finally {
|
||
detailLoading.value = false
|
||
}
|
||
}
|
||
|
||
function openCreate() {
|
||
createForm.regularWeight = 30
|
||
createForm.finalWeight = 70
|
||
createForm.items = []
|
||
newItemName.value = ''
|
||
newItemWeight.value = 0
|
||
createDialog.value = true
|
||
}
|
||
|
||
async function createSheet() {
|
||
if (weightSum.value !== 100) {
|
||
ElMessage.warning(`平时、期末与所有分项的比例必须合计 100%(当前 ${weightSum.value}%)。`)
|
||
return
|
||
}
|
||
try {
|
||
await http.post('/grades/sheets', {
|
||
teachingTaskId: selectedTask.value.id,
|
||
regularWeight: createForm.regularWeight,
|
||
finalWeight: createForm.finalWeight,
|
||
items: createForm.items.map(item => ({ name: item.name, weight: item.weight })),
|
||
})
|
||
createDialog.value = false
|
||
ElMessage.success('成绩登记册已建立')
|
||
await load()
|
||
} catch (error) {
|
||
ElMessage.error(apiErrorMessage(error))
|
||
}
|
||
}
|
||
|
||
async function saveRecords() {
|
||
try {
|
||
await http.put(`/grades/sheets/${detail.value.id}/records`, {
|
||
records: detail.value.records.map((record: any) => ({
|
||
id: record.id,
|
||
regularScore: record.regularScore,
|
||
finalScore: record.finalScore,
|
||
itemScores: record.itemScores?.map((itemScore: any) => ({
|
||
gradeItemId: itemScore.gradeItemId,
|
||
score: itemScore.score,
|
||
})),
|
||
examStatus: record.examStatus,
|
||
notes: record.notes,
|
||
})),
|
||
})
|
||
ElMessage.success('成绩已保存并重新计算总评')
|
||
await selectTask(selectedTask.value)
|
||
} catch (error) {
|
||
ElMessage.error(apiErrorMessage(error))
|
||
}
|
||
}
|
||
|
||
async function submitSheet() {
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
'提交后成绩将锁定并进入学院审核,确认所有学生成绩已经核对?',
|
||
'提交成绩',
|
||
{ type: 'warning', confirmButtonText: '确认提交', cancelButtonText: '继续检查' },
|
||
)
|
||
await http.post(`/grades/sheets/${detail.value.id}/submit`)
|
||
ElMessage.success('成绩单已提交审核')
|
||
await load()
|
||
} catch (error: any) {
|
||
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
|
||
}
|
||
}
|
||
|
||
const importFileInput = ref<HTMLInputElement>()
|
||
|
||
function downloadTemplate() {
|
||
downloadApiFile(
|
||
`/grades/sheets/${detail.value.id}/template`,
|
||
'成绩导入模板.xlsx',
|
||
)
|
||
}
|
||
|
||
function chooseImportFile() {
|
||
importFileInput.value?.click()
|
||
}
|
||
|
||
async function handleImport(event: Event) {
|
||
const input = event.target as HTMLInputElement
|
||
const file = input.files?.[0]
|
||
if (!file) return
|
||
try {
|
||
const result = await importExcel(
|
||
`/grades/sheets/${detail.value.id}/import`,
|
||
file,
|
||
)
|
||
ElMessage.success(`导入完成:已更新 ${result.data.updated} 条成绩记录`)
|
||
await selectTask(selectedTask.value)
|
||
} catch (error) {
|
||
ElMessage.error(apiErrorMessage(error))
|
||
} finally {
|
||
input.value = ''
|
||
}
|
||
}
|
||
|
||
async function approveSheet() {
|
||
try {
|
||
await http.post(`/grades/sheets/${detail.value.id}/approve`)
|
||
ElMessage.success('成绩单已通过审核')
|
||
await load()
|
||
} catch (error) {
|
||
ElMessage.error(apiErrorMessage(error))
|
||
}
|
||
}
|
||
|
||
function openReturn() {
|
||
returnComment.value = ''
|
||
returnDialog.value = true
|
||
}
|
||
|
||
async function returnSheet() {
|
||
if (!returnComment.value.trim()) {
|
||
ElMessage.warning('请填写需要教师修改的具体内容。')
|
||
return
|
||
}
|
||
try {
|
||
await http.post(`/grades/sheets/${detail.value.id}/return`, {
|
||
comment: returnComment.value,
|
||
})
|
||
returnDialog.value = false
|
||
ElMessage.success('成绩单已退回教师修改')
|
||
await load()
|
||
} catch (error) {
|
||
ElMessage.error(apiErrorMessage(error))
|
||
}
|
||
}
|
||
|
||
async function publishSheet() {
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
'发布后学生即可查看成绩,当前版本将锁定。确定发布?',
|
||
'发布成绩',
|
||
{ type: 'warning', confirmButtonText: '确认发布', cancelButtonText: '取消' },
|
||
)
|
||
await http.post(`/grades/sheets/${detail.value.id}/publish`)
|
||
ElMessage.success('成绩已向学生发布')
|
||
await load()
|
||
} catch (error: any) {
|
||
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
|
||
}
|
||
}
|
||
|
||
function scoreClass(score: number | null) {
|
||
if (score == null) return 'pending'
|
||
if (score < 60) return 'failed'
|
||
if (score >= 90) return 'excellent'
|
||
return ''
|
||
}
|
||
|
||
function itemScore(record: any, gradeItemId: string) {
|
||
return record.itemScores?.find((itemScore: any) => itemScore.gradeItemId === gradeItemId)
|
||
}
|
||
|
||
/** Client-side preview calculation — mirrors backend GradeCalculator.CalculateTotal */
|
||
function calcPreviewTotal(record: any): number | null {
|
||
if (!detail.value) return null
|
||
if (record.examStatus !== 'Normal') return null
|
||
|
||
const { regularWeight, finalWeight, items } = detail.value
|
||
const itemWeightById: Record<string, number> = {}
|
||
for (const item of items ?? []) itemWeightById[item.id] = item.weight
|
||
|
||
// Check required scores are present
|
||
if (regularWeight > 0 && (record.regularScore == null || record.regularScore === '')) return null
|
||
if (finalWeight > 0 && (record.finalScore == null || record.finalScore === '')) return null
|
||
for (const itemScore of record.itemScores ?? []) {
|
||
const weight = itemWeightById[itemScore.gradeItemId]
|
||
if (weight > 0 && (itemScore.score == null || itemScore.score === '')) return null
|
||
}
|
||
|
||
let total = (Number(record.regularScore) || 0) * regularWeight / 100
|
||
for (const itemScore of record.itemScores ?? []) {
|
||
const weight = itemWeightById[itemScore.gradeItemId] || 0
|
||
total += (Number(itemScore.score) || 0) * weight / 100
|
||
}
|
||
total += (Number(record.finalScore) || 0) * finalWeight / 100
|
||
return Math.round(total * 10) / 10
|
||
}
|
||
|
||
onMounted(async () => {
|
||
try {
|
||
terms.value = (await http.get('/base-data/terms')).data
|
||
termId.value = terms.value.find((item) => item.isCurrent)?.id
|
||
await load()
|
||
} catch (error) {
|
||
ElMessage.error(apiErrorMessage(error))
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div class="page-stack grade-page">
|
||
<section class="page-intro">
|
||
<div>
|
||
<span class="section-kicker">ACADEMIC RECORD</span>
|
||
<h2>{{ isStudent ? '学业成绩单' : '成绩管理' }}</h2>
|
||
<p v-if="isStudent">查看学校已正式发布的课程成绩、学分与绩点。</p>
|
||
<p v-else>从教师登记、学院复核到校级发布,支持自定义平时、实验、实习等分项。</p>
|
||
</div>
|
||
<el-button :icon="Refresh" @click="load">刷新</el-button>
|
||
</section>
|
||
|
||
<template v-if="isStudent">
|
||
<section class="transcript-identity">
|
||
<div>
|
||
<span>STUDENT TRANSCRIPT</span>
|
||
<h3>{{ transcript.student?.name ?? '学生' }}</h3>
|
||
<p>{{ transcript.student?.studentNumber }} · {{ transcript.student?.majorName }} · {{ transcript.student?.className }}</p>
|
||
</div>
|
||
<label>
|
||
<span>查看学期</span>
|
||
<el-select v-model="termId" clearable placeholder="全部学期" @change="load">
|
||
<el-option v-for="term in terms" :key="term.id" :label="term.name" :value="term.id" />
|
||
</el-select>
|
||
</label>
|
||
</section>
|
||
|
||
<section class="transcript-ribbon">
|
||
<div><span>已发布课程</span><b>{{ transcript.records.length }}</b><small>门</small></div>
|
||
<div><span>获得学分</span><b>{{ earnedCredits }}</b><small>分</small></div>
|
||
<div><span>加权平均绩点</span><b>{{ transcriptGpa }}</b><small>GPA</small></div>
|
||
</section>
|
||
|
||
<section class="transcript-list" v-loading="loading">
|
||
<article v-for="record in transcript.records" :key="record.id">
|
||
<div class="transcript-course">
|
||
<span>{{ record.termName }}</span>
|
||
<h3>{{ record.courseName }}</h3>
|
||
<p>{{ record.courseCode }} · {{ record.taskNumber }} · {{ record.credits }} 学分</p>
|
||
</div>
|
||
<div class="grade-scale">
|
||
<i v-for="tick in 11" :key="tick" />
|
||
<span
|
||
v-if="record.totalScore != null"
|
||
:class="scoreClass(record.totalScore)"
|
||
:style="{ left: `${record.totalScore}%` }"
|
||
>{{ record.totalScore }}</span>
|
||
</div>
|
||
<div class="transcript-result">
|
||
<span>{{ examStatusLabels[record.examStatus] }}</span>
|
||
<b v-if="record.totalScore != null">{{ record.totalScore }}</b>
|
||
<b v-else>—</b>
|
||
<small>绩点 {{ record.gradePoint ?? '—' }}</small>
|
||
</div>
|
||
</article>
|
||
<el-empty v-if="!transcript.records.length" description="当前范围内暂无已发布成绩" />
|
||
</section>
|
||
</template>
|
||
|
||
<template v-else>
|
||
<section class="grade-toolbar">
|
||
<el-select v-model="termId" clearable placeholder="全部学期" @change="load">
|
||
<el-option v-for="term in terms" :key="term.id" :label="term.name" :value="term.id" />
|
||
</el-select>
|
||
<el-select v-model="status" clearable placeholder="全部状态" @change="load">
|
||
<el-option v-for="(label, value) in statusLabels" :key="value" :label="label" :value="value" />
|
||
</el-select>
|
||
<span>共 {{ tasks.length }} 个可管理教学班</span>
|
||
</section>
|
||
|
||
<section class="grade-workspace" v-loading="loading">
|
||
<aside class="grade-sheet-list">
|
||
<button
|
||
v-for="task in tasks"
|
||
:key="task.id"
|
||
type="button"
|
||
:class="{ active: selectedTask?.id === task.id }"
|
||
@click="selectTask(task)"
|
||
>
|
||
<span>{{ task.courseCode }} · {{ task.taskNumber }}</span>
|
||
<b>{{ task.courseName }}</b>
|
||
<small>{{ task.teacherNames.join('、') }} · {{ task.classNames.join('、') }}</small>
|
||
<i :class="task.sheet?.status?.toLowerCase()">
|
||
{{ task.sheet ? statusLabels[task.sheet.status] : '未建册' }}
|
||
</i>
|
||
</button>
|
||
<el-empty v-if="!tasks.length" description="没有可管理的教学班" />
|
||
</aside>
|
||
|
||
<main class="grade-register" v-loading="detailLoading">
|
||
<el-empty v-if="!selectedTask" description="请选择教学班" />
|
||
<section v-else-if="!selectedTask.sheet" class="grade-create-empty">
|
||
<el-icon><DocumentChecked /></el-icon>
|
||
<h3>{{ selectedTask.courseName }}</h3>
|
||
<p>{{ selectedTask.taskNumber }} 尚未建立成绩登记册。系统会从有效选课名单生成学生行。</p>
|
||
<el-button type="primary" :icon="Plus" @click="openCreate">建立成绩登记册</el-button>
|
||
</section>
|
||
<template v-else-if="detail">
|
||
<header class="grade-register-head">
|
||
<div>
|
||
<span>{{ detail.courseCode }} · {{ detail.taskNumber }}</span>
|
||
<h3>{{ detail.courseName }}成绩登记册</h3>
|
||
<p>{{ detail.termName }} · {{ detail.teacherNames.join('、') }} · {{ detail.classNames.join('、') }}</p>
|
||
<p v-if="detail.courseCollegeName" class="college-meta">开课学院:{{ detail.courseCollegeName }}</p>
|
||
</div>
|
||
<div class="status-badge-group">
|
||
<i :class="detail.status.toLowerCase()">{{ statusLabels[detail.status] }}</i>
|
||
<small v-if="detail.status === 'Submitted' && detail.needsCollegeReview">
|
||
需 {{ detail.needsCollegeReview }} 审核
|
||
</small>
|
||
</div>
|
||
</header>
|
||
|
||
<section class="grade-register-ruler">
|
||
<div><span>学生数</span><b>{{ detail.records.length }}</b></div>
|
||
<div><span>已完成</span><b>{{ completedCount }}</b></div>
|
||
<div><span>平均分</span><b>{{ averageScore }}</b></div>
|
||
<div><span>及格率</span><b>{{ passRate }}</b></div>
|
||
<p>
|
||
平时 {{ detail.regularWeight }}%
|
||
<template v-if="detail.items?.length">{{ detail.items.map((item: any) => ` · ${item.name} ${item.weight}%`).join('') }}</template>
|
||
· 期末 {{ detail.finalWeight }}%
|
||
</p>
|
||
</section>
|
||
|
||
<el-alert
|
||
v-if="detail.status === 'Returned'"
|
||
:title="`审核退回:${detail.reviewComment}`"
|
||
type="warning"
|
||
:closable="false"
|
||
show-icon
|
||
/>
|
||
|
||
<div class="grade-table-scroll">
|
||
<el-table :data="detail.records" class="data-table grade-entry-table">
|
||
<el-table-column label="学生" fixed min-width="165">
|
||
<template #default="{ row }">
|
||
<div class="record-name">
|
||
<b>{{ row.name }}</b>
|
||
<span>{{ row.studentNumber }} · {{ row.className }}</span>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column :label="`平时 ${detail.regularWeight}%`" width="110">
|
||
<template #default="{ row }">
|
||
<el-input-number
|
||
v-if="detail.canEdit && row.examStatus === 'Normal'"
|
||
v-model="row.regularScore"
|
||
:min="0" :max="100" :controls="false" size="small"
|
||
/>
|
||
<span v-else>{{ row.regularScore ?? '—' }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column
|
||
v-for="item in detail.items"
|
||
:key="item.id"
|
||
:label="`${item.name} ${item.weight}%`"
|
||
width="110"
|
||
>
|
||
<template #default="{ row }">
|
||
<el-input-number
|
||
v-if="detail.canEdit && row.examStatus === 'Normal'"
|
||
:model-value="itemScore(row, item.id)?.score"
|
||
@update:model-value="(val: number | undefined) => { const found = itemScore(row, item.id); if (found) found.score = val }"
|
||
:min="0" :max="100" :controls="false" size="small"
|
||
/>
|
||
<span v-else>{{ itemScore(row, item.id)?.score ?? '—' }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column :label="`期末 ${detail.finalWeight}%`" width="110">
|
||
<template #default="{ row }">
|
||
<el-input-number
|
||
v-if="detail.canEdit && row.examStatus === 'Normal'"
|
||
v-model="row.finalScore"
|
||
:min="0" :max="100" :controls="false" size="small"
|
||
/>
|
||
<span v-else>{{ row.finalScore ?? '—' }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="总评" width="90">
|
||
<template #default="{ row }">
|
||
<template v-if="detail.canEdit && row.examStatus === 'Normal'">
|
||
<b class="total-score preview" :class="scoreClass(calcPreviewTotal(row))">
|
||
{{ calcPreviewTotal(row) ?? '—' }}
|
||
</b>
|
||
</template>
|
||
<b v-else class="total-score" :class="scoreClass(row.totalScore)">{{ row.totalScore ?? '—' }}</b>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="考试状态" width="115">
|
||
<template #default="{ row }">
|
||
<el-select v-if="detail.canEdit" v-model="row.examStatus" size="small">
|
||
<el-option v-for="(label, value) in examStatusLabels" :key="value" :label="label" :value="value" />
|
||
</el-select>
|
||
<span v-else>{{ examStatusLabels[row.examStatus] }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="备注" min-width="140">
|
||
<template #default="{ row }">
|
||
<el-input v-if="detail.canEdit" v-model="row.notes" maxlength="300" size="small" />
|
||
<span v-else>{{ row.notes || '—' }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
|
||
<input ref="importFileInput" type="file" accept=".xlsx" style="display:none" @change="handleImport" />
|
||
|
||
<footer class="grade-actions">
|
||
<div class="workflow-status">
|
||
<span>{{ statusMeta[detail.status]?.icon }} {{ statusLabels[detail.status] }}</span>
|
||
<small>{{ statusMeta[detail.status]?.description }}</small>
|
||
<small v-if="detail.needsCollegeReview && (detail.status === 'Submitted' || detail.status === 'Approved')" class="college-hint">
|
||
开课学院:{{ detail.needsCollegeReview }}
|
||
</small>
|
||
</div>
|
||
<div class="grade-actions-buttons">
|
||
<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="EditPen" @click="saveRecords">保存成绩</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="success" :icon="Check" @click="approveSheet">审核通过</el-button>
|
||
<el-button v-if="detail.canPublish" type="primary" :icon="Promotion" @click="publishSheet">发布成绩</el-button>
|
||
</div>
|
||
</footer>
|
||
</template>
|
||
</main>
|
||
</section>
|
||
</template>
|
||
|
||
<el-dialog v-model="createDialog" title="建立成绩登记册" width="620px">
|
||
<el-alert title="平时、期末与所有分项的比例必须合计 100%" type="info" :closable="false" />
|
||
<el-form label-position="top" class="grade-weight-form">
|
||
<div class="form-grid three">
|
||
<el-form-item label="平时成绩 %">
|
||
<el-input-number v-model="createForm.regularWeight" :min="0" :max="100" />
|
||
</el-form-item>
|
||
<el-form-item label="期末成绩 %">
|
||
<el-input-number v-model="createForm.finalWeight" :min="0" :max="100" />
|
||
</el-form-item>
|
||
<el-form-item label="当前合计">
|
||
<div class="weight-sum" :class="{ invalid: weightSum !== 100 }">
|
||
<b>{{ weightSum }}%</b>
|
||
<span v-if="weightSum !== 100">需为 100%</span>
|
||
<span v-else class="valid">✓</span>
|
||
</div>
|
||
</el-form-item>
|
||
</div>
|
||
|
||
<el-divider>成绩分项(实验、实习、课程设计等)</el-divider>
|
||
|
||
<div class="preset-items">
|
||
<span>快速添加:</span>
|
||
<el-button
|
||
v-for="name in presetItems" :key="name"
|
||
size="small" @click="addPresetItem(name)"
|
||
>{{ name }}</el-button>
|
||
</div>
|
||
|
||
<div v-if="createForm.items.length" class="item-list">
|
||
<div v-for="(item, index) in createForm.items" :key="index" class="item-row">
|
||
<span>{{ item.name }}</span>
|
||
<el-input-number v-model="item.weight" :min="0" :max="100" size="small" />
|
||
<span>%</span>
|
||
<el-button :icon="Delete" link type="danger" @click="removeItem(index)" />
|
||
</div>
|
||
</div>
|
||
|
||
<div class="add-item-row">
|
||
<el-input v-model="newItemName" placeholder="自定义名称" size="small" style="width:160px" />
|
||
<el-input-number v-model="newItemWeight" :min="0" :max="100" size="small" />
|
||
<span>%</span>
|
||
<el-button size="small" :icon="Plus" @click="addItem">添加</el-button>
|
||
</div>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="createDialog = false">取消</el-button>
|
||
<el-button type="primary" :disabled="weightSum !== 100" @click="createSheet">建立登记册</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<el-dialog v-model="returnDialog" title="退回成绩单" width="560px">
|
||
<el-form label-position="top">
|
||
<el-form-item label="修改意见" required>
|
||
<el-input v-model="returnComment" type="textarea" :rows="4" maxlength="500" show-word-limit />
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="returnDialog = false">取消</el-button>
|
||
<el-button type="danger" @click="returnSheet">确认退回</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.grade-weight-form { margin-top: 16px; }
|
||
.weight-sum { display: flex; align-items: baseline; gap: 8px; padding: 12px; border-radius: 6px; background: #f5f7fa; }
|
||
.weight-sum b { font: 700 22px/1 Consolas, monospace; color: var(--indigo); }
|
||
.weight-sum.invalid b { color: #b34e48; }
|
||
.weight-sum span { font-size: 11px; color: var(--muted); }
|
||
.weight-sum .valid { color: #2d8975; font-weight: 700; }
|
||
.preset-items { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
|
||
.preset-items > span { font-size: 11px; color: var(--muted); }
|
||
.item-list { display: grid; gap: 6px; margin-bottom: 12px; }
|
||
.item-row { display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: #eef5f0; border-radius: 4px; }
|
||
.item-row > span:first-child { flex: 1; font-size: 13px; font-weight: 650; }
|
||
.add-item-row { display: flex; align-items: center; gap: 8px; }
|
||
|
||
/* Preview total score */
|
||
.total-score.preview {
|
||
text-decoration: underline dashed;
|
||
text-underline-offset: 3px;
|
||
text-decoration-color: var(--muted);
|
||
cursor: help;
|
||
}
|
||
|
||
/* College review */
|
||
.college-meta { color: var(--muted); font-size: 12px; margin-top: 2px; }
|
||
.status-badge-group { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; }
|
||
.status-badge-group small { font-size: 11px; color: var(--muted); }
|
||
.workflow-status { display: flex; flex-direction: column; gap: 2px; }
|
||
.workflow-status span { font-weight: 650; font-size: 13px; }
|
||
.workflow-status small { color: var(--muted); font-size: 11px; }
|
||
.workflow-status .college-hint { color: #6b4e16; font-weight: 600; }
|
||
.grade-actions { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px; padding: 12px 16px; border-top: 1px solid #e4e7ed; background: #fafbfc; }
|
||
.grade-actions-buttons { display: flex; gap: 8px; flex-wrap: wrap; }
|
||
</style>
|