Files
Academic-Affairs-System/web/src/views/ExperimentGradesView.vue
T

1069 lines
47 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, watch } from 'vue'
import {
Check,
Download,
EditPen,
Plus,
Promotion,
Refresh,
Setting,
Upload,
} from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import http, { apiErrorMessage } from '../api/http'
import { downloadApiFile, importExcel } from '../api/excel'
import { useAuthStore } from '../stores/auth'
import { useRoute } from 'vue-router'
import {
academicTermLabel,
academicTermOptionClass,
defaultAcademicTermId,
} from '../utils/academicTerms'
import { useScoreGridNavigation } from '../utils/scoreGridNavigation'
const auth = useAuthStore()
const route = useRoute()
const { handleScoreGridKeydown } = useScoreGridNavigation('experiment-grade-entry')
const isStudent = computed(() => auth.user?.roles.includes('Student') ?? false)
const canViewAllColleges = computed(() =>
auth.user?.roles.some((role) => ['SuperAdmin', 'AcademicAdmin'].includes(role)) ?? false,
)
const loading = ref(false)
const detailLoading = ref(false)
const saving = ref(false)
const autoSaveState = ref<'idle' | 'saving' | 'saved' | 'error'>('idle')
let autoSaveTimer: ReturnType<typeof setTimeout> | undefined
let savedRecordSnapshot = ''
let autoSaving = false
const openedTargetSheetId = ref<string | undefined>()
const terms = ref<any[]>([])
const colleges = ref<any[]>([])
const termId = ref('')
const statusFilter = ref('')
const collegeId = ref('')
const projectKeyword = ref('')
const projectPage = ref(1)
const projectPageSize = ref(20)
const courseTotal = ref(0)
const courses = ref<any[]>([])
const studentResults = ref<any[]>([])
const detailDrawer = ref(false)
const detail = ref<any | null>(null)
const recordPage = ref(1)
const recordPageSize = ref(50)
const recordKeyword = ref('')
const schemeDialog = ref(false)
const editingScheme = ref(false)
const schemeProject = ref<any | null>(null)
const importFileInput = ref<HTMLInputElement>()
const targetSheetId = computed(() => {
const value = route.query.sheetId
return typeof value === 'string' ? value : undefined
})
const schemeForm = reactive({
contributionWeight: 1,
passScore: 60,
items: [] as Array<{ name: string; kind: string; weight: number }>,
})
const statusLabels: Record<string, string> = {
Draft: '录入中',
Submitted: '待审核',
Approved: '审核通过',
Published: '已发布',
Returned: '已退回',
}
const statusTypes: Record<string, string> = {
Draft: 'info',
Submitted: 'warning',
Approved: 'success',
Published: 'primary',
Returned: 'danger',
}
const participationLabels: Record<string, string> = {
Pending: '待登记',
Completed: '已完成',
Absent: '缺席',
Excused: '请假',
Makeup: '补做',
Exempt: '免做',
}
const kindLabels: Record<string, string> = {
Operation: '实验操作',
Report: '实验报告',
Result: '实验结果',
Defense: '答辩',
Attendance: '到场情况',
Safety: '安全规范',
Other: '其他',
}
const modeLabels: Record<string, string> = {
Centralized: '集中安排',
SelfScheduled: '自主预约',
}
const weekdayLabels = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日']
function projectScheduleLabel(project: any) {
const entry = project.scheduleEntry
if (!entry) return modeLabels[project.arrangementMode] ?? '待安排'
const week = project.scheduleWeek ?? '—'
const endPeriod = entry.startPeriod + entry.periodCount - 1
const periods = entry.periodCount === 1
? `第 ${entry.startPeriod} 节`
: `第 ${entry.startPeriod}—${endPeriod} 节`
return `第 ${week} 周 · ${weekdayLabels[entry.dayOfWeek] ?? `周${entry.dayOfWeek}`} · ${periods}`
}
const schemeWeightTotal = computed(() =>
schemeForm.items.reduce((sum, item) => sum + Number(item.weight || 0), 0),
)
const detailAverage = computed(() => {
const values = (detail.value?.records ?? [])
.map((record: any) => previewTotal(record))
.filter((score: number | null) => score != null) as number[]
if (!values.length) return '—'
return (values.reduce((sum, score) => sum + score, 0) / values.length).toFixed(1)
})
const detailPassed = computed(() =>
(detail.value?.records ?? []).filter((record: any) => {
const score = previewTotal(record)
return score != null && score >= Number(detail.value.passScore) && !record.safetyViolation
}).length,
)
function resetScheme() {
schemeForm.contributionWeight = 1
schemeForm.passScore = 60
schemeForm.items = [
{ name: '实验操作', kind: 'Operation', weight: 35 },
{ name: '实验结果', kind: 'Result', weight: 20 },
{ name: '实验报告', kind: 'Report', weight: 35 },
{ name: '到场与规范', kind: 'Attendance', weight: 10 },
]
}
function addSchemeItem() {
schemeForm.items.push({ name: '', kind: 'Other', weight: 0 })
}
function openCreate(project: any) {
editingScheme.value = false
schemeProject.value = project
resetScheme()
schemeDialog.value = true
}
function openSettings() {
if (!detail.value) return
editingScheme.value = true
schemeProject.value = {
id: detail.value.experimentProjectId,
name: detail.value.projectName,
sheet: { id: detail.value.id },
}
schemeForm.contributionWeight = Number(detail.value.contributionWeight)
schemeForm.passScore = Number(detail.value.passScore)
schemeForm.items = detail.value.items.map((item: any) => ({
name: item.name,
kind: item.kind,
weight: Number(item.weight),
}))
schemeDialog.value = true
}
async function saveScheme() {
if (!schemeProject.value) return
if (!schemeForm.items.length ||
schemeForm.items.some((item) => !item.name.trim() || Number(item.weight) <= 0) ||
schemeWeightTotal.value !== 100) {
ElMessage.warning('请完整填写评分项,且权重合计必须为 100%')
return
}
try {
const payload = {
contributionWeight: schemeForm.contributionWeight,
passScore: schemeForm.passScore,
items: schemeForm.items.map((item) => ({
name: item.name.trim(),
kind: item.kind,
weight: item.weight,
})),
}
if (editingScheme.value) {
await http.put(`/experiment-grades/sheets/${schemeProject.value.sheet.id}/settings`, payload)
ElMessage.success('实验评分方案已更新,原分项成绩已清空')
await loadDetail()
} else {
await http.post('/experiment-grades/sheets', {
experimentProjectId: schemeProject.value.id,
...payload,
})
ElMessage.success('实验成绩单已建立')
await load()
}
schemeDialog.value = false
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function load() {
loading.value = true
try {
if (isStudent.value) {
studentResults.value = (await http.get('/experiment-grades/mine')).data
} else {
const locatingSheet = targetSheetId.value
const data = (await http.get('/experiment-grades/management', {
params: {
academicTermId: locatingSheet ? undefined : termId.value || undefined,
status: locatingSheet ? undefined : statusFilter.value || undefined,
collegeId: locatingSheet ? undefined : collegeId.value || undefined,
keyword: locatingSheet ? undefined : projectKeyword.value.trim() || undefined,
page: projectPage.value,
pageSize: projectPageSize.value,
sheetId: locatingSheet,
},
})).data
courses.value = data.items
courseTotal.value = data.total
projectPage.value = data.page
projectPageSize.value = data.pageSize
const targetProject = targetSheetId.value && courses.value
.flatMap((course: any) => course.projects)
.find((project: any) => project.sheet?.id === targetSheetId.value)
if (targetProject && openedTargetSheetId.value !== targetSheetId.value) {
openedTargetSheetId.value = targetSheetId.value
await openDetail(targetProject)
}
}
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
loading.value = false
}
}
async function openDetail(project: any) {
detailDrawer.value = true
detail.value = {
id: project.sheet.id,
items: [],
records: [],
recordTotal: 0,
}
recordPage.value = 1
recordKeyword.value = ''
await loadDetail()
}
async function loadDetail() {
if (!detail.value?.id) return
detailLoading.value = true
try {
const response = (await http.get(`/experiment-grades/sheets/${detail.value.id}`, {
params: {
recordPage: recordPage.value,
recordPageSize: recordPageSize.value,
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
savedRecordSnapshot = experimentRecordSnapshot()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
detailLoading.value = false
}
}
function itemScore(record: any, itemId: string) {
return record.itemScores?.find((score: any) =>
score.experimentGradeItemId === itemId)
}
function previewTotal(record: any): number | null {
if (record.safetyViolation || record.participationStatus === 'Absent') return 0
if (!['Completed', 'Makeup'].includes(record.participationStatus)) return null
let total = 0
for (const item of detail.value?.items ?? []) {
const score = itemScore(record, item.id)?.score
if (score == null || score === '') return null
total += Number(score) * Number(item.weight) / 100
}
return Math.round(total * 10) / 10
}
function experimentRecordPayload() {
return detail.value.records.map((record: any) => ({
id: record.id,
participationStatus: record.participationStatus,
safetyViolation: record.safetyViolation,
attemptNumber: record.attemptNumber,
submissionReference: record.submissionReference,
submittedAt: record.submittedAt,
isLate: record.isLate,
teacherComment: record.teacherComment,
itemScores: record.itemScores.map((score: any) => ({
experimentGradeItemId: score.experimentGradeItemId,
score: score.score,
comment: score.comment,
})),
}))
}
function experimentRecordSnapshot() {
return detail.value ? JSON.stringify(experimentRecordPayload()) : ''
}
function scheduleAutoSave() {
if (!detail.value?.canEdit || experimentRecordSnapshot() === savedRecordSnapshot) return
autoSaveState.value = 'idle'
clearTimeout(autoSaveTimer)
autoSaveTimer = setTimeout(() => { void saveRecords(true) }, 60_000)
}
async function saveRecords(automatic = false) {
if (!detail.value) return
if (automatic && autoSaving) return
const records = experimentRecordPayload()
const snapshot = JSON.stringify(records)
if (automatic) {
autoSaving = true
autoSaveState.value = 'saving'
} else saving.value = true
try {
await http.put(`/experiment-grades/sheets/${detail.value.id}/records`, {
records,
})
savedRecordSnapshot = snapshot
if (automatic) autoSaveState.value = 'saved'
else {
ElMessage.success('本页实验成绩已保存')
await loadDetail()
}
} catch (error) {
if (automatic) autoSaveState.value = 'error'
else ElMessage.error(apiErrorMessage(error))
} finally {
if (automatic) {
autoSaving = false
if (experimentRecordSnapshot() !== savedRecordSnapshot) scheduleAutoSave()
} else saving.value = false
}
}
watch(() => detail.value?.records, scheduleAutoSave, { deep: true })
watch(targetSheetId, async () => {
openedTargetSheetId.value = undefined
if (!isStudent.value) {
projectPage.value = 1
await load()
}
})
function downloadTemplate() {
if (!detail.value) return
downloadApiFile(
`/experiment-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 || !detail.value) return
saving.value = true
try {
const result = await importExcel(
`/experiment-grades/sheets/${detail.value.id}/import`,
file,
)
ElMessage.success(`导入完成:已更新 ${result.data.updated} 条实验成绩记录`)
await loadDetail()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
input.value = ''
saving.value = false
}
}
async function syncParticipants() {
if (!detail.value) return
try {
const result = (await http.post(
`/experiment-grades/sheets/${detail.value.id}/sync-participants`,
)).data
ElMessage.success(
`名单已同步:新增 ${result.added} 人,移除 ${result.removed} 人,当前 ${result.total} 人`,
)
recordPage.value = 1
await loadDetail()
await load()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function submitSheet() {
try {
await ElMessageBox.confirm(
'提交后教师不能继续修改,学院审核退回后方可重新录入。',
'提交实验成绩审核',
{ confirmButtonText: '确认提交', cancelButtonText: '继续检查', type: 'warning' },
)
await http.post(`/experiment-grades/sheets/${detail.value.id}/submit`)
ElMessage.success('实验成绩已提交审核')
await loadDetail()
await load()
} catch (error: any) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(apiErrorMessage(error))
}
}
async function approveSheet() {
try {
await http.post(`/experiment-grades/sheets/${detail.value.id}/approve`)
ElMessage.success('实验成绩审核通过')
await loadDetail()
await load()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function returnSheet() {
try {
const { value } = await ElMessageBox.prompt(
'请填写教师需要修正的具体内容。',
'退回实验成绩',
{
confirmButtonText: '确认退回',
cancelButtonText: '取消',
inputType: 'textarea',
inputValidator: (value) => value.trim() ? true : '必须填写退回意见',
},
)
await http.post(`/experiment-grades/sheets/${detail.value.id}/return`, {
comment: value,
})
ElMessage.success('实验成绩已退回')
await loadDetail()
await load()
} catch (error: any) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(apiErrorMessage(error))
}
}
async function publishSheet() {
try {
await ElMessageBox.confirm(
'发布后学生可查看各评分项和实验总评,课程成绩也可以导入这份结果。',
'发布实验成绩',
{ confirmButtonText: '确认发布', cancelButtonText: '暂不发布', type: 'warning' },
)
await http.post(`/experiment-grades/sheets/${detail.value.id}/publish`)
ElMessage.success('实验成绩已向学生发布')
await loadDetail()
await load()
} catch (error: any) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(apiErrorMessage(error))
}
}
function projectScoreTone(result: any) {
if (result.totalScore == null) return 'is-pending'
return result.isPassed ? 'is-passed' : 'is-failed'
}
onMounted(async () => {
if (!isStudent.value) {
try {
const requests: Promise<any>[] = [http.get('/base-data/terms')]
if (canViewAllColleges.value) requests.push(http.get('/base-data/colleges'))
const [termResponse, collegeResponse] = await Promise.all(requests)
terms.value = termResponse.data
colleges.value = collegeResponse?.data ?? []
termId.value = defaultAcademicTermId(terms.value) ?? ''
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
await load()
})
</script>
<template>
<main class="experiment-grade-page">
<section class="page-intro grade-intro">
<div>
<span class="section-kicker">LAB ASSESSMENT LEDGER</span>
<h2>{{ isStudent ? '我的实验成绩' : '实验成绩管理' }}</h2>
<p v-if="isStudent">按课程查看各实验项目;实验课总分为项目贡献权重加权平均,也是课程成绩引用的实验部分分数。</p>
<p v-else>按实验项目建立独立评分方案,审核发布后可选择汇总到课程成绩单。</p>
</div>
<el-button :icon="Refresh" @click="load">刷新</el-button>
</section>
<section v-if="!isStudent" class="assessment-flow" aria-label="实验成绩流程">
<div><span>PROJECT</span><b>实验项目</b><small>集中安排 / 自主预约</small></div>
<i>→</i>
<div><span>RUBRIC</span><b>独立评分</b><small>操作 · 结果 · 报告 · 规范</small></div>
<i>→</i>
<div><span>PUBLISH</span><b>审核发布</b><small>学生查看 / 课程可选导入</small></div>
</section>
<template v-if="isStudent">
<section v-loading="loading" class="student-course-results">
<article v-for="course in studentResults" :key="course.teachingTaskId" class="student-course-result">
<header class="student-course-head">
<div>
<span>{{ course.termName }} · {{ course.taskNumber }}</span>
<h3>{{ course.courseCode }} · {{ course.courseName }}</h3>
<p>{{ course.publishedProjectCount }} 个已发布实验项目 · 按项目贡献权重计算</p>
</div>
<div class="course-score-seal" :class="{ 'has-score': course.experimentCourseScore != null }">
<span>实验课总分</span>
<strong>{{ course.experimentCourseScore ?? '—' }}</strong>
<small>{{ course.experimentCourseScore == null ? '成绩尚未完整' : '加权平均分' }}</small>
</div>
</header>
<el-table :data="course.projects" class="student-grade-table" size="small">
<el-table-column type="expand" width="44">
<template #default="{ row }">
<div class="student-grade-detail" :class="projectScoreTone(row)">
<div class="result-facts">
<span>参与状态<b>{{ participationLabels[row.participationStatus] }}</b></span>
<span>合格线<b>{{ row.passScore }}</b></span>
<span>实验次数<b>{{ row.attemptNumber }}</b></span>
<span>提交情况<b>{{ row.isLate ? '迟交' : row.submittedAt ? '已提交' : '未登记' }}</b></span>
</div>
<div class="result-rubric">
<div v-for="item in row.items" :key="item.experimentGradeItemId">
<span>{{ item.name }} · {{ item.weight }}%</span>
<b>{{ item.score ?? '—' }}</b>
<small v-if="item.comment">{{ item.comment }}</small>
</div>
</div>
<p v-if="row.safetyViolation" class="safety-alert">已登记安全违规,本次实验按规则判定为 0 分。</p>
<p v-if="row.teacherComment" class="teacher-comment">{{ row.teacherComment }}</p>
</div>
</template>
</el-table-column>
<el-table-column label="实验项目" min-width="220">
<template #default="{ row }">
<div class="student-grade-project">
<b>{{ row.projectCode }} · {{ row.projectName }}</b>
<small>{{ modeLabels[row.arrangementMode] }} · 贡献权重 {{ row.contributionWeight }}</small>
</div>
</template>
</el-table-column>
<el-table-column label="分项成绩" min-width="300">
<template #default="{ row }">
<div class="rubric-inline">
<span v-for="item in row.items" :key="item.experimentGradeItemId">
{{ item.name }} <b>{{ item.score ?? '—' }}</b>
</span>
</div>
</template>
</el-table-column>
<el-table-column label="参与状态" width="100" align="center">
<template #default="{ row }">{{ participationLabels[row.participationStatus] }}</template>
</el-table-column>
<el-table-column label="项目成绩" width="100" align="center">
<template #default="{ row }">
<strong class="project-total" :class="projectScoreTone(row)">{{ row.totalScore ?? '—' }}</strong>
</template>
</el-table-column>
<el-table-column label="结果" width="88" align="center">
<template #default="{ row }">
<el-tag v-if="row.totalScore != null" :type="row.isPassed ? 'success' : 'danger'" size="small">
{{ row.isPassed ? '通过' : '未通过' }}
</el-tag>
<span v-else class="table-muted">待完整</span>
</template>
</el-table-column>
</el-table>
</article>
<el-empty v-if="!loading && !studentResults.length" description="暂无已发布的实验成绩" />
</section>
</template>
<template v-else>
<section class="grade-toolbar">
<el-select v-model="termId" clearable placeholder="全部学期" @change="projectPage = 1; load()">
<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="statusFilter" clearable placeholder="全部成绩状态" @change="projectPage = 1; load()">
<el-option v-for="(label, value) in statusLabels" :key="value" :label="label" :value="value" />
</el-select>
<el-select
v-if="canViewAllColleges"
v-model="collegeId"
clearable
filterable
placeholder="全部开课学院"
@change="projectPage = 1; load()"
>
<el-option v-for="college in colleges" :key="college.id" :label="college.name" :value="college.id" />
</el-select>
<el-input
v-model="projectKeyword"
clearable
placeholder="项目、课程、教学班或教师"
@keyup.enter="projectPage = 1; load()"
@clear="projectPage = 1; load()"
/>
<el-button type="primary" @click="projectPage = 1; load()">查询</el-button>
</section>
<section v-loading="loading" class="grade-course-list">
<article v-for="course in courses" :key="course.teachingTaskId" class="grade-course-card">
<header class="grade-course-head">
<div>
<span>{{ course.termName }} · {{ course.taskNumber }}</span>
<h3>{{ course.courseCode }} · {{ course.courseName }}</h3>
<small>{{ course.teacherNames.join('、') || '教师待定' }} · {{ course.collegeName }}</small>
</div>
<div class="course-rollup">
<b>{{ course.projectCount }}</b><span>个实验项目</span>
<small v-if="course.sheetCount">{{ course.sheetCount }} 份成绩单 · {{ course.scoredCount }}/{{ course.studentCount }} 已计分</small>
<small v-else>尚未建立成绩单</small>
</div>
</header>
<el-table :data="course.projects" size="small" class="course-project-table">
<el-table-column label="实验项目" min-width="260">
<template #default="{ row }">
<div class="course-project-name">
<b>{{ row.code }} · {{ row.name }}</b>
<small>{{ projectScheduleLabel(row) }}</small>
</div>
</template>
</el-table-column>
<el-table-column label="成绩进度" min-width="190">
<template #default="{ row }">
<div v-if="row.sheet" class="course-project-progress">
<el-tag :type="statusTypes[row.sheet.status] as any" size="small" effect="plain">{{ statusLabels[row.sheet.status] }}</el-tag>
<span>{{ row.sheet.scoredCount }}/{{ row.sheet.studentCount }} 已计分 · {{ row.sheet.itemCount }} 项</span>
</div>
<span v-else class="table-muted">尚未建立成绩单</span>
</template>
</el-table-column>
<el-table-column label="操作" width="130" align="right">
<template #default="{ row }">
<el-button v-if="row.sheet" type="primary" text :icon="EditPen" @click="openDetail(row)">打开成绩单</el-button>
<el-button v-else type="primary" text :icon="Plus" @click="openCreate(row)">建立成绩单</el-button>
</template>
</el-table-column>
</el-table>
</article>
<el-empty v-if="!loading && !courses.length" description="当前筛选条件下没有可评分实验课程" />
</section>
<el-pagination
v-model:current-page="projectPage"
v-model:page-size="projectPageSize"
:total="courseTotal"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@current-change="load"
@size-change="projectPage = 1; load()"
/>
</template>
<el-dialog
v-model="schemeDialog"
:title="`${editingScheme ? '调整评分方案' : '建立实验成绩单'} · ${schemeProject?.name ?? ''}`"
width="720px"
top="5vh"
>
<div class="scheme-form">
<el-alert
v-if="editingScheme"
title="调整评分方案会清空原有分项得分,参与状态和教师评语保留。"
type="warning"
show-icon
:closable="false"
/>
<div class="scheme-basics">
<label>
<span>项目贡献权重</span>
<el-input-number v-model="schemeForm.contributionWeight" :min="0.1" :max="100" :step="0.5" />
<small>多个实验项目汇总到课程时按此相对权重计算</small>
</label>
<label>
<span>实验合格线</span>
<el-input-number v-model="schemeForm.passScore" :min="0" :max="100" :step="0.5" />
<small>仅决定该实验是否通过,不直接计算课程绩点</small>
</label>
</div>
<div class="rubric-editor">
<header>
<div><span>RUBRIC</span><b>实验评分项</b></div>
<strong :class="{ invalid: schemeWeightTotal !== 100 }">合计 {{ schemeWeightTotal }}%</strong>
</header>
<div v-for="(item, index) in schemeForm.items" :key="index" class="rubric-row">
<el-input v-model="item.name" maxlength="60" placeholder="评分项名称" />
<el-select v-model="item.kind">
<el-option v-for="(label, value) in kindLabels" :key="value" :label="label" :value="value" />
</el-select>
<el-input-number v-model="item.weight" :min="0" :max="100" :controls="false" />
<span>%</span>
<el-button text type="danger" :disabled="schemeForm.items.length === 1" @click="schemeForm.items.splice(index, 1)">
删除
</el-button>
</div>
<el-button text type="primary" :icon="Plus" @click="addSchemeItem">增加评分项</el-button>
</div>
</div>
<template #footer>
<el-button @click="schemeDialog = false">取消</el-button>
<el-button type="primary" @click="saveScheme">{{ editingScheme ? '保存并重置分项' : '建立成绩单' }}</el-button>
</template>
</el-dialog>
<el-drawer
v-model="detailDrawer"
title="实验评分工作台"
size="94%"
class="experiment-grade-drawer"
>
<div v-if="detail" v-loading="detailLoading" class="grade-workbench">
<header class="workbench-head">
<div>
<span>{{ detail.projectCode }} · {{ modeLabels[detail.arrangementMode] }}</span>
<h3>{{ detail.projectName }}</h3>
<p>{{ detail.courseCode }} · {{ detail.courseName }} · {{ detail.termName }}</p>
<p class="project-schedule">{{ projectScheduleLabel(detail) }}</p>
</div>
<div class="workbench-status">
<el-tag :type="statusTypes[detail.status] as any" effect="dark">{{ statusLabels[detail.status] }}</el-tag>
<small>项目贡献权重 {{ detail.contributionWeight }} · 合格线 {{ detail.passScore }}</small>
</div>
</header>
<section class="workbench-metrics">
<div><span>学生数</span><b>{{ detail.recordTotal }}</b></div>
<div><span>本页平均</span><b>{{ detailAverage }}</b></div>
<div><span>本页通过</span><b>{{ detailPassed }}</b></div>
<div><span>评分项</span><b>{{ detail.items.length }}</b></div>
</section>
<div class="record-toolbar">
<el-input
v-model="recordKeyword"
clearable
placeholder="搜索学号、姓名或班级"
@keyup.enter="recordPage = 1; loadDetail()"
@clear="recordPage = 1; loadDetail()"
/>
<el-button @click="recordPage = 1; loadDetail()">查询</el-button>
<el-button v-if="detail.canEdit" :icon="Refresh" @click="syncParticipants">同步参与名单</el-button>
<el-button v-if="detail.canEdit" :icon="Setting" @click="openSettings">评分方案</el-button>
</div>
<el-table :data="detail.records" row-key="id" border class="grade-record-table">
<el-table-column type="expand" width="48">
<template #default="{ row }">
<div class="record-evidence">
<label>
<span>提交物或报告地址</span>
<el-input v-if="detail.canEdit" v-model="row.submissionReference" maxlength="500" />
<b v-else>{{ row.submissionReference || '未登记' }}</b>
</label>
<label>
<span>提交时间</span>
<el-date-picker
v-if="detail.canEdit"
v-model="row.submittedAt"
type="datetime"
value-format="YYYY-MM-DDTHH:mm:ss"
placeholder="未登记"
/>
<b v-else>{{ row.submittedAt ? new Date(row.submittedAt).toLocaleString('zh-CN') : '未登记' }}</b>
</label>
<label>
<span>迟交</span>
<el-switch v-if="detail.canEdit" v-model="row.isLate" />
<b v-else>{{ row.isLate ? '是' : '否' }}</b>
</label>
<label class="comment-field">
<span>教师总评</span>
<el-input v-if="detail.canEdit" v-model="row.teacherComment" type="textarea" maxlength="500" show-word-limit />
<b v-else>{{ row.teacherComment || '无' }}</b>
</label>
<label
v-for="item in detail.items"
:key="item.id"
class="comment-field"
>
<span>{{ item.name }}评语</span>
<el-input
v-if="detail.canEdit"
:model-value="itemScore(row, item.id)?.comment"
maxlength="300"
@update:model-value="(value: string) => { const target = itemScore(row, item.id); if (target) target.comment = value }"
/>
<b v-else>{{ itemScore(row, item.id)?.comment || '无' }}</b>
</label>
</div>
</template>
</el-table-column>
<el-table-column label="学生" fixed min-width="180">
<template #default="{ row }">
<b>{{ row.name }}</b>
<small class="student-line">{{ row.studentNumber }} · {{ row.className }}</small>
</template>
</el-table-column>
<el-table-column label="参与状态" width="125">
<template #default="{ row }">
<el-select v-if="detail.canEdit" v-model="row.participationStatus" size="small">
<el-option v-for="(label, value) in participationLabels" :key="value" :label="label" :value="value" />
</el-select>
<span v-else>{{ participationLabels[row.participationStatus] }}</span>
</template>
</el-table-column>
<el-table-column
v-for="item in detail.items"
:key="item.id"
:label="`${item.name} ${item.weight}%`"
width="125"
>
<template #default="{ row }">
<div
data-score-grid="experiment-grade-entry"
:data-score-row="detail.records.indexOf(row)"
:data-score-column="detail.items.indexOf(item)"
@keydown="handleScoreGridKeydown"
>
<el-input-number
v-if="detail.canEdit && ['Completed', 'Makeup'].includes(row.participationStatus)"
:model-value="itemScore(row, item.id)?.score"
:min="0"
:max="100"
:step="0.5"
:controls="false"
size="small"
@update:model-value="(value: number | undefined) => { const target = itemScore(row, item.id); if (target) target.score = value }"
/>
<span v-else>{{ itemScore(row, item.id)?.score ?? '—' }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="安全违规" width="105">
<template #default="{ row }">
<el-switch v-if="detail.canEdit" v-model="row.safetyViolation" />
<el-tag v-else-if="row.safetyViolation" type="danger" size="small">是</el-tag>
<span v-else>否</span>
</template>
</el-table-column>
<el-table-column label="次数" width="90">
<template #default="{ row }">
<el-input-number v-if="detail.canEdit" v-model="row.attemptNumber" :min="1" :max="20" :controls="false" size="small" />
<span v-else>{{ row.attemptNumber }}</span>
</template>
</el-table-column>
<el-table-column label="实验总评" fixed="right" width="105">
<template #default="{ row }">
<b class="record-total" :class="{ failed: previewTotal(row) != null && previewTotal(row)! < detail.passScore }">
{{ previewTotal(row) ?? '—' }}
</b>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="recordPage"
v-model:page-size="recordPageSize"
:total="detail.recordTotal"
:page-sizes="[20, 50, 100]"
layout="total, sizes, prev, pager, next"
@current-change="loadDetail"
@size-change="recordPage = 1; loadDetail()"
/>
<input ref="importFileInput" type="file" accept=".xlsx" style="display:none" @change="handleImport" />
<footer class="workbench-actions">
<div>
<b v-if="detail.reviewComment">审核意见:{{ detail.reviewComment }}</b>
<span>开课学院:{{ detail.courseCollegeName }}</span>
<small v-if="detail.canEdit" class="autosave-state">
{{ autoSaveState === 'saving' ? '正在自动保存…' : autoSaveState === 'saved' ? '已自动保存' : autoSaveState === 'error' ? '自动保存失败,请手动保存' : '停止编辑 1 分钟后自动保存' }}
</small>
</div>
<div>
<el-button v-if="detail.canEdit" :icon="Download" @click="downloadTemplate">下载模板</el-button>
<el-button v-if="detail.canEdit" :icon="Upload" :loading="saving" @click="chooseImportFile">Excel 导入</el-button>
<el-button v-if="detail.canEdit" :icon="EditPen" :loading="saving" @click="saveRecords()">保存本页</el-button>
<el-button v-if="detail.canEdit" type="primary" :icon="Promotion" @click="submitSheet">提交审核</el-button>
<el-button v-if="detail.canReview" type="danger" plain @click="returnSheet">退回修改</el-button>
<el-button v-if="detail.canReview" type="success" :icon="Check" @click="approveSheet">审核通过</el-button>
<el-button v-if="detail.canPublish" type="primary" :icon="Promotion" @click="publishSheet">发布成绩</el-button>
</div>
</footer>
</div>
</el-drawer>
</main>
</template>
<style scoped>
.experiment-grade-page { --grade-blue: #245d82; --grade-teal: #168276; --grade-ink: #173041; display: grid; gap: 18px; }
.grade-intro { align-items: flex-start; }
.assessment-flow { display: grid; grid-template-columns: 1fr 48px 1fr 48px 1fr; align-items: stretch; border: 1px solid #d8e4e7; background: #fff; }
.assessment-flow > div { padding: 15px 18px; display: grid; gap: 4px; border-top: 4px solid var(--grade-blue); }
.assessment-flow > div:nth-of-type(2) { border-top-color: #4c778f; }
.assessment-flow > div:nth-of-type(3) { border-top-color: var(--grade-teal); }
.assessment-flow > div span { color: #66808c; font: 700 10px/1.2 Consolas, monospace; letter-spacing: .08em; }
.assessment-flow > div b { color: var(--grade-ink); font-size: 15px; }
.assessment-flow > div small { color: var(--muted); }
.assessment-flow > i { display: grid; place-items: center; color: #8aa0aa; font-style: normal; background: #f4f7f8; }
.grade-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
.grade-toolbar .el-select { width: 220px; }
.grade-toolbar .el-input { width: min(320px, 100%); }
.grade-toolbar > span { margin-left: auto; color: var(--muted); font-size: 12px; }
.grade-course-list { display: grid; gap: 10px; min-height: 180px; }
.grade-course-card { overflow: hidden; border: 1px solid #d9e4e8; border-left: 4px solid var(--grade-blue); background: #fff; box-shadow: 0 4px 13px rgb(30 68 86 / 4%); }
.grade-course-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 10px 14px; background: #f3f7f8; }
.grade-course-head > div:first-child { min-width: 0; }
.grade-course-head span { color: #607b88; font: 700 10px/1.3 Consolas, monospace; }
.grade-course-head h3 { margin: 3px 0; color: var(--grade-ink); font-size: 15px; }
.grade-course-head small { display: block; overflow: hidden; color: var(--muted); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.course-rollup { display: grid; grid-template-columns: auto auto; align-items: baseline; column-gap: 5px; flex: none; text-align: right; }
.course-rollup b { color: var(--grade-teal); font: 750 22px/1 Consolas, monospace; }
.course-rollup > span { color: #55707b; font: 600 11px/1.2 inherit; }
.course-rollup small { grid-column: 1 / -1; margin-top: 3px; color: #70858d; font-size: 10px; }
.course-project-table { width: 100%; --el-table-border-color: #e0e8ea; }
.course-project-table :deep(td.el-table__cell), .course-project-table :deep(th.el-table__cell) { padding: 6px 0; }
.course-project-table :deep(th.el-table__cell) { color: #71858d; font-size: 10px; }
.course-project-name { display: grid; gap: 2px; }
.course-project-name b { color: #294d5c; font-size: 12px; }
.course-project-name small, .course-project-progress { color: #6d838b; font-size: 10px; }
.course-project-progress { display: flex; align-items: center; gap: 7px; }
.student-course-results { display: grid; gap: 12px; min-height: 180px; }
.student-course-result { overflow: hidden; border: 1px solid #cfdee3; background: #fff; }
.student-course-head { min-height: 76px; padding: 11px 14px 11px 18px; display: flex; align-items: center; justify-content: space-between; gap: 18px; border-left: 5px solid var(--grade-blue); background: #edf4f6; }
.student-course-head > div:first-child { min-width: 0; }
.student-course-head span { color: #617b87; font: 700 10px/1.3 Consolas, monospace; letter-spacing: .05em; }
.student-course-head h3 { margin: 4px 0; color: var(--grade-ink); font-size: 16px; }
.student-course-head p { margin: 0; color: #647b85; font-size: 11px; }
.course-score-seal { min-width: 132px; display: grid; grid-template-columns: auto auto; align-items: center; gap: 2px 12px; padding: 7px 11px; border-left: 1px solid #cbdade; text-align: right; }
.course-score-seal span { grid-column: 1; color: #59717d; font: 600 11px/1.2 inherit; letter-spacing: 0; }
.course-score-seal strong { grid-column: 2; grid-row: 1 / span 2; color: #768b94; font: 750 28px/1 Consolas, monospace; }
.course-score-seal.has-score strong { color: var(--grade-teal); }
.course-score-seal small { grid-column: 1; color: var(--muted); font-size: 10px; }
.student-grade-table { --el-table-border-color: #dce5e8; --el-table-header-bg-color: #f7fafb; width: 100%; }
.student-grade-table :deep(th.el-table__cell) { padding: 7px 0; color: #637985; font-size: 11px; }
.student-grade-table :deep(td.el-table__cell) { padding: 8px 0; }
.student-grade-project { display: grid; gap: 3px; }
.student-grade-project b { color: var(--grade-ink); font-size: 12px; }
.student-grade-project small { color: #6a8089; font-size: 10px; }
.rubric-inline { display: flex; flex-wrap: wrap; gap: 4px 10px; }
.rubric-inline span { color: #6a7f88; font-size: 10px; }
.rubric-inline b { color: #294e5d; font-size: 11px; }
.project-total { font: 750 15px/1 Consolas, monospace; color: #74858c; }
.project-total.is-passed { color: var(--grade-teal); }
.project-total.is-failed { color: #b5543d; }
.student-grade-detail { padding: 12px 16px 14px; background: #f7faf9; }
.result-facts { padding: 12px 18px; display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; background: #f5f8f8; }
.result-facts span { display: grid; gap: 3px; color: #758991; font-size: 10px; }
.result-facts b { color: #365762; font-size: 12px; }
.result-rubric { padding: 14px 18px; display: grid; gap: 8px; }
.result-rubric > div { padding: 8px 10px; display: grid; grid-template-columns: 1fr auto; gap: 4px 10px; border-left: 3px solid #9cb6bb; background: #f7f9f9; }
.result-rubric span { color: #58727c; font-size: 12px; }
.result-rubric b { color: var(--grade-ink); }
.result-rubric small { grid-column: 1 / -1; color: var(--muted); }
.safety-alert, .teacher-comment { margin: 0 18px 15px; padding: 10px 12px; font-size: 12px; }
.safety-alert { color: #8e382b; background: #fff0ed; }
.teacher-comment { color: #49686e; background: #eef6f4; }
.scheme-form { display: grid; gap: 18px; }
.scheme-basics { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.scheme-basics label { padding: 14px; display: grid; gap: 8px; border: 1px solid #dce5e8; background: #f8fafb; }
.scheme-basics label > span { color: var(--grade-ink); font-weight: 700; }
.scheme-basics label small { color: var(--muted); line-height: 1.5; }
.rubric-editor { border: 1px solid #dce5e8; }
.rubric-editor header { padding: 12px 14px; display: flex; justify-content: space-between; border-bottom: 1px solid #dce5e8; background: #f3f7f7; }
.rubric-editor header div { display: flex; align-items: baseline; gap: 10px; }
.rubric-editor header span { color: var(--grade-teal); font: 700 10px/1 Consolas, monospace; }
.rubric-editor header b { color: var(--grade-ink); }
.rubric-editor header strong { color: var(--grade-teal); }
.rubric-editor header strong.invalid { color: #b5543d; }
.rubric-row { padding: 9px 12px; display: grid; grid-template-columns: 1.3fr 150px 100px 20px auto; align-items: center; gap: 8px; border-bottom: 1px solid #edf1f2; }
.rubric-editor > .el-button { margin: 6px 10px; }
:global(.experiment-grade-drawer .el-drawer__body) { padding: 0; }
.grade-workbench { min-height: 100%; display: grid; align-content: start; gap: 15px; background: #f4f7f8; }
.workbench-head { padding: 18px 22px; display: flex; justify-content: space-between; gap: 20px; border-bottom: 1px solid #d9e3e6; background: #fff; }
.workbench-head span { color: #5d7c89; font: 700 10px/1.3 Consolas, monospace; }
.workbench-head h3 { margin: 5px 0; color: var(--grade-ink); font-size: 21px; }
.workbench-head p { margin: 0; color: var(--muted); }
.workbench-status { display: grid; justify-items: end; align-content: center; gap: 7px; }
.workbench-status small { color: var(--muted); }
.workbench-metrics { margin: 0 18px; display: grid; grid-template-columns: repeat(4, 1fr); border: 1px solid #d9e4e6; background: #fff; }
.workbench-metrics div { padding: 12px 16px; display: grid; gap: 3px; border-right: 1px solid #e2e9eb; }
.workbench-metrics div:last-child { border-right: 0; }
.workbench-metrics span { color: var(--muted); font-size: 11px; }
.workbench-metrics b { color: var(--grade-ink); font-size: 20px; }
.record-toolbar { margin: 0 18px; display: flex; gap: 8px; }
.record-toolbar .el-input { max-width: 320px; }
.grade-record-table { width: calc(100% - 36px); margin: 0 18px; }
.student-line { display: block; margin-top: 3px; color: var(--muted); }
.record-total { color: var(--grade-teal); font: 750 16px/1 Consolas, monospace; }
.record-total.failed { color: #b5543d; }
.record-evidence { padding: 14px 18px; display: grid; grid-template-columns: repeat(3, minmax(180px, 1fr)); gap: 12px; background: #f7faf9; }
.record-evidence label { display: grid; align-content: start; gap: 6px; }
.record-evidence label > span { color: #536f78; font-size: 11px; font-weight: 700; }
.record-evidence label > b { color: #304e58; font-size: 12px; font-weight: 500; }
.record-evidence .comment-field { grid-column: span 2; }
.grade-workbench > .el-pagination { margin: 0 18px; justify-content: flex-end; }
.workbench-actions { padding: 15px 20px; display: flex; justify-content: space-between; gap: 14px; border-top: 1px solid #d9e3e6; background: #fff; }
.workbench-actions > div { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.workbench-actions b { color: #a24f36; font-size: 12px; }
.workbench-actions span { color: var(--muted); font-size: 12px; }
@media (max-width: 980px) {
.grade-course-head { align-items: flex-start; }
}
@media (max-width: 700px) {
.assessment-flow { grid-template-columns: 1fr; }
.assessment-flow > i { display: none; }
.grade-toolbar { align-items: stretch; flex-direction: column; }
.grade-toolbar .el-select, .grade-toolbar .el-input { width: 100%; }
.grade-toolbar > span { margin-left: 0; }
.grade-course-head { align-items: stretch; flex-direction: column; }
.course-rollup { align-self: flex-start; text-align: left; }
.student-result-grid { grid-template-columns: 1fr; }
.student-result-card > header { align-items: stretch; flex-direction: column; }
.score-seal { width: auto; padding-top: 12px; grid-template-columns: auto auto; gap: 8px; border-top: 1px solid #dce6e8; border-left: 0; }
.result-facts { grid-template-columns: repeat(2, 1fr); }
.scheme-basics { grid-template-columns: 1fr; }
.rubric-row { grid-template-columns: 1fr 120px 80px 20px; }
.rubric-row .el-button { grid-column: 1 / -1; justify-self: end; }
.workbench-head, .workbench-actions { align-items: stretch; flex-direction: column; }
.workbench-status { justify-items: start; }
.workbench-metrics { grid-template-columns: repeat(2, 1fr); }
.record-toolbar { flex-wrap: wrap; }
.record-toolbar .el-input { max-width: none; flex: 1 1 100%; }
.record-evidence { grid-template-columns: 1fr; }
.record-evidence .comment-field { grid-column: 1; }
}
</style>