已按“实验成绩独立管理”完成修改。

每个实验项目独立建成绩单,支持评分项、参与状态、补做次数、安全违规、报告地址和教师评语。
流程为:录入 → 学院审核 → 教务发布;学生只能查看已发布成绩。
自主预约实验支持同步有效预约名单,已评分记录不会被误删。
课程成绩可将已发布实验成绩汇总为快照导入指定分项;导入后锁定,不能手工篡改。
实验项目成绩不完整时,对应课程分项保持空白,不按 0 分处理。
一级“实验管理”菜单下增加“项目与场次”“实验成绩”;学生端增加“实验安排”“实验成绩”。
已生成 MySQL 正式迁移及 SQLite 开发迁移,尚未操作生产数据库。
界面采用“实验项目 → 独立评分 → 审核发布”工作台设计。
This commit is contained in:
2026-07-28 22:21:18 +08:00 Unverified
parent 51c3b81e27
commit 8f56548b54
17 changed files with 9239 additions and 14 deletions
+834
View File
@@ -0,0 +1,834 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import {
Check,
EditPen,
Plus,
Promotion,
Refresh,
Setting,
} from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth'
import {
academicTermLabel,
academicTermOptionClass,
defaultAcademicTermId,
} from '../utils/academicTerms'
const auth = useAuthStore()
const isStudent = computed(() => auth.user?.roles.includes('Student') ?? false)
const loading = ref(false)
const detailLoading = ref(false)
const saving = ref(false)
const terms = ref<any[]>([])
const termId = ref('')
const statusFilter = ref('')
const projects = 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 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 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 {
projects.value = (await http.get('/experiment-grades/management', {
params: {
academicTermId: termId.value || undefined,
status: statusFilter.value || undefined,
},
})).data
}
} 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
} 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
}
async function saveRecords() {
if (!detail.value) return
saving.value = true
try {
await http.put(`/experiment-grades/sheets/${detail.value.id}/records`, {
records: 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,
})),
})),
})
ElMessage.success('本页实验成绩已保存')
await loadDetail()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
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 scoreTone(result: any) {
if (result.totalScore == null) return 'is-pending'
return result.isPassed ? 'is-passed' : 'is-failed'
}
onMounted(async () => {
if (!isStudent.value) {
try {
terms.value = (await http.get('/base-data/terms')).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 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-result-grid">
<article
v-for="result in studentResults"
:key="result.id"
class="student-result-card"
:class="scoreTone(result)"
>
<header>
<div>
<span>{{ result.projectCode }} · {{ modeLabels[result.arrangementMode] }}</span>
<h3>{{ result.projectName }}</h3>
<p>{{ result.courseCode }} · {{ result.courseName }} · {{ result.termName }}</p>
</div>
<div class="score-seal">
<strong>{{ result.totalScore ?? '—' }}</strong>
<small>{{ result.totalScore == null ? participationLabels[result.participationStatus] : result.isPassed ? '通过' : '未通过' }}</small>
</div>
</header>
<div class="result-facts">
<span>参与状态<b>{{ participationLabels[result.participationStatus] }}</b></span>
<span>合格线<b>{{ result.passScore }}</b></span>
<span>实验次数<b>{{ result.attemptNumber }}</b></span>
<span>提交情况<b>{{ result.isLate ? '迟交' : result.submittedAt ? '已提交' : '未登记' }}</b></span>
</div>
<div class="result-rubric">
<div v-for="item in result.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="result.safetyViolation" class="safety-alert">已登记安全违规本次实验按规则判定为 0 </p>
<p v-if="result.teacherComment" class="teacher-comment">{{ result.teacherComment }}</p>
</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="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="load">
<el-option v-for="(label, value) in statusLabels" :key="value" :label="label" :value="value" />
</el-select>
<span>每个实验项目独立建单课程只读取已发布的汇总快照</span>
</section>
<section v-loading="loading" class="grade-project-list">
<article v-for="project in projects" :key="project.id" class="grade-project-card">
<div class="project-mark">
<span>{{ project.code }}</span>
<b>{{ modeLabels[project.arrangementMode] }}</b>
</div>
<div class="project-summary">
<h3>{{ project.name }}</h3>
<p>{{ project.courseCode }} · {{ project.courseName }}</p>
<small>{{ project.taskNumber }} · {{ project.termName }} · {{ project.teacherNames.join('、') || '教师待定' }}</small>
</div>
<template v-if="project.sheet">
<div class="sheet-progress">
<el-tag :type="statusTypes[project.sheet.status] as any" effect="plain">
{{ statusLabels[project.sheet.status] }}
</el-tag>
<span><b>{{ project.sheet.scoredCount }}</b> / {{ project.sheet.studentCount }} 已计分</span>
<span>评分项 {{ project.sheet.itemCount }} · 合格线 {{ project.sheet.passScore }}</span>
</div>
<el-button type="primary" plain :icon="EditPen" @click="openDetail(project)">
打开成绩单
</el-button>
</template>
<template v-else>
<div class="sheet-empty">
<b>尚未建立实验成绩单</b>
<span>建立时会按集中名单或有效预约生成评分对象</span>
</div>
<el-button type="primary" :icon="Plus" @click="openCreate(project)">
建立成绩单
</el-button>
</template>
</article>
<el-empty v-if="!loading && !projects.length" description="当前筛选条件下没有可评分实验项目" />
</section>
</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>
</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 }">
<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>
</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()"
/>
<footer class="workbench-actions">
<div>
<b v-if="detail.reviewComment">审核意见:{{ detail.reviewComment }}</b>
<span>开课学院:{{ detail.courseCollegeName }}</span>
</div>
<div>
<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 > span { margin-left: auto; color: var(--muted); font-size: 12px; }
.grade-project-list { display: grid; gap: 12px; min-height: 180px; }
.grade-project-card { min-width: 0; padding: 16px 18px; display: grid; grid-template-columns: 130px minmax(220px, 1.4fr) minmax(220px, 1fr) auto; align-items: center; gap: 18px; border: 1px solid #d9e4e8; border-left: 5px solid var(--grade-blue); background: #fff; box-shadow: 0 6px 18px rgb(30 68 86 / 5%); }
.project-mark { display: grid; gap: 7px; }
.project-mark span { overflow: hidden; color: #607b88; font: 700 10px/1.3 Consolas, monospace; text-overflow: ellipsis; }
.project-mark b { color: var(--grade-blue); font-size: 12px; }
.project-summary { min-width: 0; }
.project-summary h3 { margin: 0 0 5px; color: var(--grade-ink); font-size: 17px; }
.project-summary p { margin: 0 0 4px; color: #365f73; font-size: 13px; }
.project-summary small { display: block; overflow: hidden; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; }
.sheet-progress, .sheet-empty { display: grid; gap: 5px; color: #607681; font-size: 12px; }
.sheet-progress b { color: var(--grade-teal); }
.sheet-empty b { color: #5d7480; }
.student-result-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); gap: 15px; min-height: 180px; }
.student-result-card { overflow: hidden; border: 1px solid #d8e4e6; border-top: 5px solid #80939b; background: #fff; }
.student-result-card.is-passed { border-top-color: var(--grade-teal); }
.student-result-card.is-failed { border-top-color: #b5543d; }
.student-result-card > header { padding: 18px; display: flex; justify-content: space-between; gap: 16px; border-bottom: 1px solid #e4eaed; }
.student-result-card header span { color: #607d89; font: 700 10px/1.3 Consolas, monospace; }
.student-result-card h3 { margin: 5px 0; color: var(--grade-ink); font-size: 18px; }
.student-result-card header p { margin: 0; color: var(--muted); font-size: 12px; }
.score-seal { width: 72px; flex: 0 0 72px; display: grid; place-content: center; border-left: 1px solid #dce6e8; text-align: center; }
.score-seal strong { color: var(--grade-teal); font: 750 28px/1 Consolas, monospace; }
.is-failed .score-seal strong { color: #b5543d; }
.score-seal small { margin-top: 5px; color: var(--muted); }
.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-project-card { grid-template-columns: 110px minmax(0, 1fr) auto; }
.sheet-progress, .sheet-empty { grid-column: 2; }
.grade-project-card > .el-button { grid-column: 3; grid-row: 1 / span 2; }
}
@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 { width: 100%; }
.grade-toolbar > span { margin-left: 0; }
.grade-project-card { grid-template-columns: 1fr; }
.sheet-progress, .sheet-empty, .grade-project-card > .el-button { grid-column: 1; grid-row: auto; width: 100%; }
.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>
+58 -4
View File
@@ -39,6 +39,7 @@ const taskTotal = ref(0)
const recordKeyword = ref('')
const recordPage = ref(1)
const recordPageSize = 50
const experimentGradeItemId = ref('')
const createForm = reactive({
regularWeight: 30,
finalWeight: 70,
@@ -186,6 +187,12 @@ async function loadDetail() {
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 {
@@ -254,6 +261,29 @@ async function saveRecords() {
}
}
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(
@@ -583,17 +613,20 @@ onMounted(async () => {
<el-table-column
v-for="item in detail.items"
:key="item.id"
:label="`${item.name} ${item.weight}%`"
width="110"
:label="`${item.name} ${item.weight}%${item.sourceType === 'ExperimentSummary' ? ' · 实验快照' : ''}`"
:width="item.sourceType === 'ExperimentSummary' ? 150 : 110"
>
<template #default="{ row }">
<el-input-number
v-if="detail.canEdit && row.examStatus === 'Normal'"
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>{{ itemScore(row, item.id)?.score ?? '—' }}</span>
<span v-else class="item-score-readonly">
{{ itemScore(row, item.id)?.score ?? '—' }}
<small v-if="item.sourceType === 'ExperimentSummary'">已锁定</small>
</span>
</template>
</el-table-column>
<el-table-column :label="`期末 ${detail.finalWeight}%`" width="110">
@@ -654,6 +687,21 @@ onMounted(async () => {
</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>
@@ -765,6 +813,10 @@ onMounted(async () => {
.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 {
@@ -777,5 +829,7 @@ onMounted(async () => {
@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>