已完成“成绩管理与学业档案”模块:

教师批量录入平时、期中、期末成绩。
自动计算总评、绩点和及格状态。
支持缺考、缓考、免修。
教师提交 → 学院审核/退回 → 校级发布。
学生只能查看正式发布成绩。
自动统计平均分、完成度、及格率和加权 GPA。
SQLite 本地增量升级和 MySQL 正式迁移均已完成。
分级权限在前后端同时校验。
This commit is contained in:
2026-07-24 15:33:47 +08:00 Unverified
parent 151a22554f
commit af52e27a3a
18 changed files with 3686 additions and 22 deletions
+7 -1
View File
@@ -112,19 +112,25 @@ onMounted(async () => {
<b>{{ data.counts.courseSelectionRounds ?? 0 }} 个批次 · {{ data.counts.courseEnrollments ?? 0 }} 条有效选课</b>
<i class="done">已建立</i>
</div>
<div>
<span>成绩管理</span>
<b>{{ data.counts.gradeSheets ?? 0 }} 张登记册 · {{ data.counts.publishedGradeSheets ?? 0 }} 张已发布</b>
<i class="done">已建立</i>
</div>
</div>
</article>
<article class="work-card phase-card">
<span class="section-kicker">NEXT MILESTONE</span>
<h3>下一段业务链</h3>
<p>选课批次容量校验和教学班名单已就绪下一步进入成绩录入审核与学业档案</p>
<p>成绩录入审核发布与学生成绩单已就绪下一步进入考试安排与考场管理</p>
<div class="phase-line">
<span class="active">基础底座</span>
<span class="active">人员档案</span>
<span class="active">培养方案</span>
<span class="active">排课课表</span>
<span class="active">学生选课</span>
<span class="active">成绩管理</span>
</div>
</article>
</section>
+499
View File
@@ -0,0 +1,499 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import {
Check,
DocumentChecked,
EditPen,
Plus,
Promotion,
Refresh,
} from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
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,
midtermWeight: 0,
finalWeight: 70,
})
const returnComment = ref('')
const statusLabels: Record<string, string> = {
Draft: '录入中',
Submitted: '待审核',
Approved: '审核通过',
Published: '已发布',
Returned: '已退回',
}
const examStatusLabels: Record<string, string> = {
Normal: '正常',
Absent: '缺考',
Deferred: '缓考',
Exempt: '免修',
}
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))
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() {
Object.assign(createForm, {
regularWeight: 30,
midtermWeight: 0,
finalWeight: 70,
})
createDialog.value = true
}
async function createSheet() {
if (createForm.regularWeight + createForm.midtermWeight + createForm.finalWeight !== 100) {
ElMessage.warning('三个成绩分项的比例必须合计 100%。')
return
}
try {
await http.post('/grades/sheets', {
teachingTaskId: selectedTask.value.id,
...createForm,
})
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,
midtermScore: record.midtermScore,
finalScore: record.finalScore,
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))
}
}
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 ''
}
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>
</div>
<i :class="detail.status.toLowerCase()">{{ statusLabels[detail.status] }}</i>
</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.midtermWeight"> · 期中 {{ detail.midtermWeight }}%</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="125">
<template #default="{ row }">
<el-input-number
v-if="detail.canEdit && row.examStatus === 'Normal'"
v-model="row.regularScore"
:min="0"
:max="100"
:controls="false"
/>
<span v-else>{{ row.regularScore ?? '' }}</span>
</template>
</el-table-column>
<el-table-column v-if="detail.midtermWeight" :label="`期中 ${detail.midtermWeight}%`" width="125">
<template #default="{ row }">
<el-input-number
v-if="detail.canEdit && row.examStatus === 'Normal'"
v-model="row.midtermScore"
:min="0"
:max="100"
:controls="false"
/>
<span v-else>{{ row.midtermScore ?? '' }}</span>
</template>
</el-table-column>
<el-table-column :label="`期末 ${detail.finalWeight}%`" width="125">
<template #default="{ row }">
<el-input-number
v-if="detail.canEdit && row.examStatus === 'Normal'"
v-model="row.finalScore"
:min="0"
:max="100"
:controls="false"
/>
<span v-else>{{ row.finalScore ?? '' }}</span>
</template>
</el-table-column>
<el-table-column label="总评" width="90">
<template #default="{ row }">
<b class="total-score" :class="scoreClass(row.totalScore)">{{ row.totalScore ?? '—' }}</b>
</template>
</el-table-column>
<el-table-column label="考试状态" width="125">
<template #default="{ row }">
<el-select v-if="detail.canEdit" v-model="row.examStatus">
<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="150">
<template #default="{ row }">
<el-input v-if="detail.canEdit" v-model="row.notes" maxlength="300" />
<span v-else>{{ row.notes || '' }}</span>
</template>
</el-table-column>
</el-table>
</div>
<footer class="grade-actions">
<div>
<span>当前流程</span>
<b>{{ statusLabels[detail.status] }}</b>
</div>
<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>
</footer>
</template>
</main>
</section>
</template>
<el-dialog v-model="createDialog" title="建立成绩登记册" width="560px">
<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.midtermWeight" :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>
</div>
</el-form>
<template #footer>
<el-button @click="createDialog = false">取消</el-button>
<el-button type="primary" @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>