已完成“成绩管理与学业档案”模块:
教师批量录入平时、期中、期末成绩。 自动计算总评、绩点和及格状态。 支持缺考、缓考、免修。 教师提交 → 学院审核/退回 → 校级发布。 学生只能查看正式发布成绩。 自动统计平均分、完成度、及格率和加权 GPA。 SQLite 本地增量升级和 MySQL 正式迁移均已完成。 分级权限在前后端同时校验。
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
||||
Tickets,
|
||||
Calendar,
|
||||
CircleCheck,
|
||||
DocumentChecked,
|
||||
User,
|
||||
UserFilled,
|
||||
} from '@element-plus/icons-vue'
|
||||
@@ -41,6 +42,7 @@ const pageTitle = computed(() => {
|
||||
'teaching-tasks': '教学任务',
|
||||
schedules: '排课与课表',
|
||||
'course-selections': '选课与教学班',
|
||||
grades: '成绩与学业档案',
|
||||
users: '用户与权限',
|
||||
}
|
||||
return titles[String(route.name)] ?? '教务管理'
|
||||
@@ -129,6 +131,13 @@ onMounted(() => auth.refresh().catch(() => undefined))
|
||||
<el-icon><CircleCheck /></el-icon>
|
||||
<template #title>{{ auth.user?.roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(role)) ? '选课管理' : '学生选课' }}</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item
|
||||
v-if="auth.user?.roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher', 'Student'].includes(role))"
|
||||
index="/grades"
|
||||
>
|
||||
<el-icon><DocumentChecked /></el-icon>
|
||||
<template #title>{{ auth.user?.roles.includes('Student') ? '学业成绩' : '成绩管理' }}</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item v-if="auth.isSuperAdmin" index="/users">
|
||||
<el-icon><User /></el-icon>
|
||||
<template #title>用户与权限</template>
|
||||
@@ -137,7 +146,7 @@ onMounted(() => auth.refresh().catch(() => undefined))
|
||||
|
||||
<div v-if="!collapsed" class="phase-note">
|
||||
<span>第一阶段 · 核心可用版</span>
|
||||
<p>教学运行、排课与学生选课已就绪</p>
|
||||
<p>教学运行、选课与成绩管理已就绪</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
|
||||
@@ -66,6 +66,14 @@ const router = createRouter({
|
||||
roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Student'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'grades',
|
||||
name: 'grades',
|
||||
component: () => import('../views/GradesView.vue'),
|
||||
meta: {
|
||||
roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher', 'Student'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
name: 'users',
|
||||
|
||||
@@ -378,6 +378,138 @@ button { cursor: pointer; }
|
||||
.roster-summary b { margin-top: 5px; font-size: 14px; }
|
||||
.roster-summary small { margin-top: 4px; color: #bec8e2; font-size: 10px; }
|
||||
|
||||
.grade-page { min-width: 0; }
|
||||
.grade-toolbar {
|
||||
min-height: 68px; padding: 13px 16px; display: flex; align-items: center; gap: 10px;
|
||||
border: 1px solid var(--line); background: white;
|
||||
}
|
||||
.grade-toolbar .el-select { width: 210px; }
|
||||
.grade-toolbar > span { margin-left: auto; color: var(--muted); font-size: 10px; }
|
||||
.grade-workspace {
|
||||
min-height: 650px; display: grid; grid-template-columns: 285px minmax(0, 1fr);
|
||||
border: 1px solid var(--line); background: white;
|
||||
}
|
||||
.grade-sheet-list {
|
||||
min-width: 0; border-right: 1px solid var(--line); background: #fafbfc;
|
||||
}
|
||||
.grade-sheet-list > button {
|
||||
width: 100%; min-height: 100px; padding: 16px 17px; display: grid;
|
||||
grid-template-columns: 1fr auto; gap: 6px 10px; text-align: left;
|
||||
border: none; border-bottom: 1px solid var(--line); border-left: 3px solid transparent;
|
||||
color: var(--ink); background: transparent;
|
||||
}
|
||||
.grade-sheet-list > button:hover { background: white; }
|
||||
.grade-sheet-list > button.active { border-left-color: var(--teal); background: white; }
|
||||
.grade-sheet-list span, .grade-sheet-list small {
|
||||
overflow: hidden; color: var(--muted); font-size: 9px; white-space: nowrap; text-overflow: ellipsis;
|
||||
}
|
||||
.grade-sheet-list b {
|
||||
grid-column: 1 / -1; overflow: hidden; font-family: "STZhongsong", "Songti SC", serif;
|
||||
font-size: 14px; white-space: nowrap; text-overflow: ellipsis;
|
||||
}
|
||||
.grade-sheet-list i {
|
||||
padding: 3px 7px; border-radius: 10px; color: #777f8c;
|
||||
background: #eceff3; font-size: 9px; font-style: normal;
|
||||
}
|
||||
.grade-sheet-list i.submitted { color: #a66b16; background: #fbefd9; }
|
||||
.grade-sheet-list i.approved, .grade-sheet-list i.published { color: var(--teal); background: #e4f4f0; }
|
||||
.grade-sheet-list i.returned { color: #b05252; background: #f8e7e7; }
|
||||
.grade-register { min-width: 0; }
|
||||
.grade-create-empty {
|
||||
min-height: 620px; display: grid; place-content: center; justify-items: center;
|
||||
padding: 30px; text-align: center;
|
||||
}
|
||||
.grade-create-empty > .el-icon { font-size: 50px; color: #98a5be; }
|
||||
.grade-create-empty h3 { margin: 18px 0 8px; font-family: "STZhongsong", "Songti SC", serif; font-size: 23px; }
|
||||
.grade-create-empty p { max-width: 440px; margin: 0 0 20px; color: var(--muted); font-size: 11px; line-height: 1.7; }
|
||||
.grade-register-head {
|
||||
min-height: 108px; padding: 21px 24px; display: flex; align-items: center;
|
||||
justify-content: space-between; gap: 20px; border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.grade-register-head span { color: var(--teal); font: 700 9px/1 Consolas, monospace; letter-spacing: .1em; }
|
||||
.grade-register-head h3 { margin: 8px 0 5px; font-family: "STZhongsong", "Songti SC", serif; font-size: 22px; }
|
||||
.grade-register-head p { margin: 0; color: var(--muted); font-size: 10px; }
|
||||
.grade-register-head > i {
|
||||
padding: 7px 12px; border-radius: 15px; color: #657083; background: #edf0f4;
|
||||
font-size: 10px; font-style: normal;
|
||||
}
|
||||
.grade-register-head > i.submitted { color: #a66b16; background: #fbefd9; }
|
||||
.grade-register-head > i.approved, .grade-register-head > i.published { color: var(--teal); background: #e4f4f0; }
|
||||
.grade-register-head > i.returned { color: #ad4e4e; background: #f7e5e5; }
|
||||
.grade-register-ruler {
|
||||
min-height: 92px; display: grid; grid-template-columns: repeat(4, 120px) 1fr;
|
||||
color: white; background:
|
||||
repeating-linear-gradient(90deg, transparent 0, transparent 23px, rgba(255,255,255,.07) 24px),
|
||||
linear-gradient(105deg, #17295a, #263f80);
|
||||
}
|
||||
.grade-register-ruler > div { padding: 17px 19px; border-right: 1px solid rgba(255,255,255,.14); }
|
||||
.grade-register-ruler span, .grade-register-ruler b { display: block; }
|
||||
.grade-register-ruler span { color: #b6c0dc; font-size: 9px; }
|
||||
.grade-register-ruler b { margin-top: 7px; color: #5bd4c3; font: 700 24px/1 Consolas, monospace; }
|
||||
.grade-register-ruler p { margin: 0; padding: 20px 22px; align-self: center; color: #cbd3e8; font-size: 10px; text-align: right; }
|
||||
.grade-register > .el-alert { border-radius: 0; }
|
||||
.grade-table-scroll { max-width: 100%; overflow-x: auto; }
|
||||
.grade-entry-table { min-width: 800px; }
|
||||
.grade-entry-table .el-input-number { width: 92px; }
|
||||
.grade-entry-table .el-input-number .el-input__inner { text-align: center; }
|
||||
.total-score { color: var(--indigo); font: 700 15px/1 Consolas, monospace; }
|
||||
.total-score.excellent { color: var(--teal); }
|
||||
.total-score.failed { color: #b44c4c; }
|
||||
.total-score.pending { color: #a0a6b0; }
|
||||
.grade-actions {
|
||||
min-height: 78px; padding: 14px 20px; display: flex; align-items: center;
|
||||
justify-content: flex-end; gap: 8px; border-top: 1px solid var(--line); background: #fafbfc;
|
||||
}
|
||||
.grade-actions > div { margin-right: auto; }
|
||||
.grade-actions span, .grade-actions b { display: block; }
|
||||
.grade-actions span { color: var(--muted); font-size: 9px; }
|
||||
.grade-actions b { margin-top: 4px; font-size: 13px; }
|
||||
.grade-actions .el-button + .el-button { margin-left: 0; }
|
||||
.grade-weight-form { margin-top: 18px; }
|
||||
.transcript-identity {
|
||||
min-height: 135px; padding: 24px 28px; display: flex; align-items: center;
|
||||
justify-content: space-between; gap: 25px; color: white;
|
||||
background:
|
||||
linear-gradient(rgba(255,255,255,.035) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,.035) 1px, transparent 1px),
|
||||
linear-gradient(112deg, #162858, #243d7d 72%, #176b70);
|
||||
background-size: 28px 28px, 28px 28px, auto;
|
||||
}
|
||||
.transcript-identity span { color: #5bd4c3; font: 700 9px/1 Consolas, monospace; letter-spacing: .15em; }
|
||||
.transcript-identity h3 { margin: 9px 0 6px; font-family: "STZhongsong", "Songti SC", serif; font-size: 25px; }
|
||||
.transcript-identity p { margin: 0; color: #c2cce5; font-size: 10px; }
|
||||
.transcript-identity label { width: 265px; }
|
||||
.transcript-identity label > span { display: block; margin-bottom: 7px; }
|
||||
.transcript-identity .el-select { width: 100%; }
|
||||
.transcript-ribbon { display: grid; grid-template-columns: repeat(3, 1fr); border: 1px solid var(--line); background: white; }
|
||||
.transcript-ribbon > div { min-height: 85px; padding: 17px 22px; display: flex; align-items: baseline; gap: 7px; border-right: 1px solid var(--line); }
|
||||
.transcript-ribbon > div:last-child { border-right: none; }
|
||||
.transcript-ribbon span { color: var(--muted); font-size: 10px; }
|
||||
.transcript-ribbon b { margin-left: auto; color: var(--indigo); font: 700 27px/1 Consolas, monospace; }
|
||||
.transcript-ribbon small { color: var(--muted); font-size: 9px; }
|
||||
.transcript-list { display: grid; gap: 10px; }
|
||||
.transcript-list > article {
|
||||
min-height: 114px; padding: 18px 21px; display: grid;
|
||||
grid-template-columns: minmax(230px, 1fr) minmax(200px, 1.4fr) 105px;
|
||||
align-items: center; gap: 28px; border: 1px solid var(--line); background: white;
|
||||
}
|
||||
.transcript-course span { color: var(--teal); font-size: 9px; }
|
||||
.transcript-course h3 { margin: 7px 0 5px; font-family: "STZhongsong", "Songti SC", serif; font-size: 18px; }
|
||||
.transcript-course p { margin: 0; color: var(--muted); font-size: 10px; }
|
||||
.grade-scale { height: 22px; display: grid; grid-template-columns: repeat(11, 1fr); position: relative; border-bottom: 2px solid #cfd5de; }
|
||||
.grade-scale > i { border-left: 1px solid #cfd5de; }
|
||||
.grade-scale > span {
|
||||
position: absolute; bottom: -12px; transform: translateX(-50%); padding: 4px 6px;
|
||||
border-radius: 9px; color: white; background: var(--indigo); font: 700 10px Consolas, monospace;
|
||||
}
|
||||
.grade-scale > span.excellent { background: var(--teal); }
|
||||
.grade-scale > span.failed { background: #b44c4c; }
|
||||
.transcript-result { padding-left: 20px; border-left: 1px solid var(--line); text-align: right; }
|
||||
.transcript-result span, .transcript-result b, .transcript-result small { display: block; }
|
||||
.transcript-result span { color: var(--muted); font-size: 9px; }
|
||||
.transcript-result b { margin: 5px 0 3px; color: var(--indigo); font: 700 25px/1 Consolas, monospace; }
|
||||
.transcript-result small { color: var(--muted); font-size: 9px; }
|
||||
|
||||
.login-page { min-height: 100vh; display: grid; grid-template-columns: minmax(440px, 1.2fr) minmax(420px, .8fr); background: white; }
|
||||
.login-story { min-height: 100vh; padding: 54px clamp(45px, 6vw, 90px); display: flex; flex-direction: column; color: white; background: linear-gradient(142deg, #13224d, #243a77 62%, #176b71); overflow: hidden; position: relative; }
|
||||
.login-story::before { content: ""; position: absolute; inset: 0; opacity: .28; background-image: linear-gradient(rgba(255,255,255,.06) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.06) 1px, transparent 1px); background-size: 46px 46px; }
|
||||
@@ -475,6 +607,14 @@ button { cursor: pointer; }
|
||||
}
|
||||
.round-actions { justify-content: flex-start; }
|
||||
.offering-grid { grid-template-columns: 1fr; }
|
||||
.grade-workspace { display: block; }
|
||||
.grade-sheet-list { max-height: 300px; overflow-y: auto; border-right: none; border-bottom: 1px solid var(--line); }
|
||||
.grade-register-ruler { grid-template-columns: repeat(4, 1fr); }
|
||||
.grade-register-ruler > div { padding: 14px; }
|
||||
.grade-register-ruler p { grid-column: 1 / -1; padding: 10px 14px; text-align: left; border-top: 1px solid rgba(255,255,255,.14); }
|
||||
.transcript-list > article { grid-template-columns: 1fr 120px; }
|
||||
.grade-scale { grid-column: 1 / -1; grid-row: 2; }
|
||||
.transcript-result { grid-column: 2; grid-row: 1; }
|
||||
.role-editor-summary { align-items: flex-start; flex-direction: column; gap: 4px; }
|
||||
.form-grid, .form-grid.three { grid-template-columns: 1fr; gap: 0; }
|
||||
.el-dialog { width: calc(100vw - 24px) !important; }
|
||||
@@ -498,6 +638,22 @@ button { cursor: pointer; }
|
||||
.selection-ledger-head { align-items: flex-start; flex-direction: column; }
|
||||
.offering-ticket > footer { align-items: stretch; flex-direction: column; }
|
||||
.offering-ticket > footer .el-button { width: 100%; }
|
||||
.grade-toolbar { flex-wrap: wrap; }
|
||||
.grade-toolbar .el-select { flex: 1 1 140px; width: auto; }
|
||||
.grade-toolbar > span { width: 100%; margin-left: 0; }
|
||||
.grade-register-head { align-items: flex-start; }
|
||||
.grade-register-ruler { grid-template-columns: 1fr 1fr; }
|
||||
.grade-register-ruler > div:nth-child(2) { border-right: none; }
|
||||
.grade-actions { align-items: stretch; flex-direction: column; }
|
||||
.grade-actions > div { margin: 0 0 5px; }
|
||||
.grade-actions .el-button { width: 100%; }
|
||||
.transcript-identity { align-items: stretch; flex-direction: column; }
|
||||
.transcript-identity label { width: 100%; }
|
||||
.transcript-ribbon { grid-template-columns: 1fr; }
|
||||
.transcript-ribbon > div { min-height: 65px; border-right: none; border-bottom: 1px solid var(--line); }
|
||||
.transcript-list > article { grid-template-columns: 1fr; gap: 17px; }
|
||||
.transcript-result { grid-column: auto; grid-row: auto; padding: 12px 0 0; border-left: none; border-top: 1px solid var(--line); text-align: left; }
|
||||
.grade-scale { grid-column: auto; grid-row: auto; }
|
||||
.el-drawer { width: 100% !important; }
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user