Files
Academic-Affairs-System/web/src/views/GradesView.vue
T
2026-08-09 10:13:12 +08:00

899 lines
36 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import {
Check,
ArrowRight,
Delete,
DocumentChecked,
Download,
EditPen,
Plus,
Promotion,
Refresh,
Search,
Upload,
} from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
import { downloadApiFile, importExcel } from '../api/excel'
import { useAuthStore } from '../stores/auth'
import { useRouter } from 'vue-router'
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
const auth = useAuthStore()
const router = useRouter()
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 taskKeyword = ref('')
const taskPage = ref(1)
const taskPageSize = 12
const taskTotal = ref(0)
const recordKeyword = ref('')
const recordPage = ref(1)
const recordPageSize = 50
const experimentGradeItemId = ref('')
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(() =>
selectedTask.value?.sheet?.completedCount ?? 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 completed = Number(selectedTask.value?.sheet?.completedCount ?? 0)
if (!completed) return '—'
return `${Math.round(Number(selectedTask.value?.sheet?.passedCount ?? 0) / completed * 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)
}
function openStatistics(record: any) {
router.push({ name: 'course-grade-statistics', params: { sheetId: record.gradeSheetId } })
}
async function load(resetPage = false) {
loading.value = true
try {
if (isStudent.value) {
transcript.value = (await http.get('/grades/student/transcript', {
params: { academicTermId: termId.value },
})).data
return
}
if (resetPage) taskPage.value = 1
const response = (await http.get('/grades/sheets', {
params: {
academicTermId: termId.value,
status: status.value,
keyword: taskKeyword.value.trim() || undefined,
page: taskPage.value,
pageSize: taskPageSize,
},
})).data
tasks.value = response.items
taskTotal.value = response.total
const preferred = tasks.value.find((item) => item.id === selectedTask.value?.id)
if (preferred) {
selectedTask.value = preferred
if (preferred.sheet) await loadDetail()
} 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
recordPage.value = 1
recordKeyword.value = ''
detail.value = null
if (!task.sheet) return
await loadDetail()
}
async function loadDetail() {
if (!selectedTask.value?.sheet) return
detailLoading.value = true
try {
const response = (await http.get(`/grades/sheets/${selectedTask.value.sheet.id}`, {
params: {
recordPage: recordPage.value,
recordPageSize,
studentKeyword: recordKeyword.value.trim() || undefined,
},
})).data
detail.value = response.sheet
detail.value.canEdit = response.canEdit
detail.value.canReview = response.canReview
detail.value.canPublish = response.canPublish
if (!detail.value.items?.some((item: any) => item.id === experimentGradeItemId.value)) {
experimentGradeItemId.value =
detail.value.items?.find((item: any) => item.name.includes('实验'))?.id
?? detail.value.items?.[0]?.id
?? ''
}
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
detailLoading.value = false
}
}
async function changeTaskPage(page: number) {
taskPage.value = page
await load()
}
async function searchRecords() {
recordPage.value = 1
await loadDetail()
}
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 loadDetail()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function importExperimentScores() {
if (!experimentGradeItemId.value) {
ElMessage.warning('请先选择用于承接实验成绩的课程成绩项。')
return
}
try {
const result = (await http.post(
`/grades/sheets/${detail.value.id}/import-experiment-scores`,
{ gradeItemId: experimentGradeItemId.value },
)).data
const missingHint = result.missingCount > 0
? `${result.missingCount} 名学生因实验成绩尚未完整发布而保持空白`
: ''
ElMessage.success(
`已从 ${result.publishedProjectCount} 个实验项目导入 ${result.importedCount} 名学生的成绩快照${missingHint}`,
)
await loadDetail()
} 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 loadDetail()
} 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 isFailingScore(score: number | string | null | undefined) {
return score != null && score !== '' && Number(score) < 60
}
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 = defaultAcademicTermId(terms.value)
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="academicTermLabel(term)" :value="term.id" :class="academicTermOptionClass(term)" />
</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"
class="transcript-course-card"
role="button"
tabindex="0"
:aria-label="`查看${record.courseName}成绩分析`"
@click="openStatistics(record)"
@keydown.enter="openStatistics(record)"
@keydown.space.prevent="openStatistics(record)"
>
<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" :class="{ 'is-failed': isFailingScore(record.totalScore) }">
<span>{{ examStatusLabels[record.examStatus] }}</span>
<b v-if="record.totalScore != null">{{ record.totalScore }}</b>
<b v-else></b>
<small>绩点 {{ record.gradePoint ?? '—' }}</small>
<span class="card-entry">查看成绩分析 <el-icon><ArrowRight /></el-icon></span>
</div>
</article>
<el-empty v-if="!transcript.records.length" description="当前范围内暂无已发布成绩" />
</section>
</template>
<template v-else>
<section class="grade-toolbar">
<el-input
v-model="taskKeyword"
clearable
:prefix-icon="Search"
placeholder="搜索课程、课程号或教学班号"
@keyup.enter="load(true)"
@clear="load(true)"
/>
<el-select v-model="termId" clearable placeholder="全部学期" @change="load(true)">
<el-option v-for="term in terms" :key="term.id" :label="academicTermLabel(term)" :value="term.id" :class="academicTermOptionClass(term)" />
</el-select>
<el-select v-model="status" clearable placeholder="全部状态" @change="load(true)">
<el-option v-for="(label, value) in statusLabels" :key="value" :label="label" :value="value" />
</el-select>
<el-button :icon="Search" @click="load(true)">查询</el-button>
<span> {{ taskTotal }} 个可管理教学班</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="没有可管理的教学班" />
<el-pagination
v-if="taskTotal > taskPageSize"
class="grade-task-pagination"
small
layout="prev, pager, next"
:current-page="taskPage"
:page-size="taskPageSize"
:total="taskTotal"
@current-change="changeTaskPage"
/>
</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.recordTotal }}</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
/>
<section class="grade-record-toolbar">
<el-input
v-model="recordKeyword"
clearable
:prefix-icon="Search"
placeholder="搜索姓名、学号或行政班"
@keyup.enter="searchRecords"
@clear="searchRecords"
/>
<el-button :icon="Search" @click="searchRecords">查找学生</el-button>
<span>每页最多显示 {{ recordPageSize }} 当前第 {{ detail.recordPage }} </span>
</section>
<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 }">
<div class="score-cell" :class="{ 'is-failed': isFailingScore(row.regularScore) }">
<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>
</div>
</template>
</el-table-column>
<el-table-column
v-for="item in detail.items"
:key="item.id"
:label="`${item.name} ${item.weight}%${item.sourceType === 'ExperimentSummary' ? ' · 实验快照' : ''}`"
:width="item.sourceType === 'ExperimentSummary' ? 150 : 110"
>
<template #default="{ row }">
<div class="score-cell" :class="{ 'is-failed': isFailingScore(itemScore(row, item.id)?.score) }">
<el-input-number
v-if="detail.canEdit && row.examStatus === 'Normal' && item.sourceType !== 'ExperimentSummary'"
: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 class="item-score-readonly">
{{ itemScore(row, item.id)?.score ?? '—' }}
<small v-if="item.sourceType === 'ExperimentSummary'">已锁定</small>
</span>
</div>
</template>
</el-table-column>
<el-table-column :label="`期末 ${detail.finalWeight}%`" width="110">
<template #default="{ row }">
<div class="score-cell" :class="{ 'is-failed': isFailingScore(row.finalScore) }">
<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>
</div>
</template>
</el-table-column>
<el-table-column label="总评" width="90">
<template #default="{ row }">
<div
class="score-cell"
:class="{ 'is-failed': isFailingScore(detail.canEdit && row.examStatus === 'Normal' ? calcPreviewTotal(row) : row.totalScore) }"
>
<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>
</div>
</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>
<el-pagination
v-if="detail.recordTotal > recordPageSize"
class="grade-record-pagination"
background
layout="total, prev, pager, next"
:current-page="recordPage"
:page-size="recordPageSize"
:total="detail.recordTotal"
@current-change="(page: number) => { recordPage = page; loadDetail() }"
/>
<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">
<div v-if="detail.canEdit && detail.items?.length" class="experiment-import">
<el-select
v-model="experimentGradeItemId"
placeholder="选择课程成绩项"
aria-label="实验成绩导入目标"
>
<el-option
v-for="item in detail.items"
:key="item.id"
:label="`${item.name} ${item.weight}%`"
:value="item.id"
/>
</el-select>
<el-button :icon="Refresh" @click="importExperimentScores">导入实验成绩</el-button>
</div>
<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; }
.score-cell {
min-height: 30px;
margin: -5px;
padding: 5px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid transparent;
border-radius: 4px;
}
.score-cell.is-failed {
color: #b42318;
background: #fdecec;
border-color: #f4b8b2;
font-weight: 700;
}
.score-cell.is-failed :deep(input) { color: #b42318; font-weight: 700; }
.transcript-result.is-failed {
color: #b42318;
background: #fdecec;
border: 1px solid #f4b8b2;
border-radius: 4px;
padding: 8px;
}
.transcript-result.is-failed b { color: #b42318; }
.transcript-course-card { cursor: pointer; transition: transform .18s ease, border-color .18s ease, box-shadow .18s ease; }
.transcript-course-card:hover { transform: translateY(-2px); border-color: #91a8c8; box-shadow: 0 10px 24px rgb(31 66 112 / 9%); }
.transcript-course-card:focus-visible { outline: 2px solid #315a9b; outline-offset: 3px; }
.card-entry { display: inline-flex !important; align-items: center; justify-content: flex-end; gap: 4px; margin-top: 8px; color: #315a9b !important; font-size: 10px !important; font-weight: 700; }
/* 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; }
.experiment-import { display: flex; gap: 6px; align-items: center; padding-right: 8px; border-right: 1px solid var(--line); }
.experiment-import .el-select { width: 165px; }
.item-score-readonly { display: inline-flex; flex-direction: column; align-items: center; line-height: 1.1; }
.item-score-readonly small { margin-top: 3px; color: #2d8975; font-size: 10px; font-weight: 650; }
.grade-toolbar > .el-input { width: min(320px, 100%); }
.grade-task-pagination { justify-content: center; padding: 14px 8px; border-top: 1px solid var(--line); }
.grade-record-toolbar {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
padding: 12px 16px; border-bottom: 1px solid var(--line); background: #f7f9fb;
}
.grade-record-toolbar .el-input { width: min(300px, 100%); }
.grade-record-toolbar > span { margin-left: auto; color: var(--muted); font-size: 11px; }
.grade-record-pagination { justify-content: flex-end; padding: 14px 16px; border-top: 1px solid var(--line); }
@media (max-width: 760px) {
.grade-record-toolbar > span { width: 100%; margin-left: 0; }
.grade-record-pagination { justify-content: center; }
.experiment-import { width: 100%; padding-right: 0; padding-bottom: 8px; border-right: 0; border-bottom: 1px solid var(--line); }
.experiment-import .el-select { flex: 1; width: auto; }
}
</style>