Files
Academic-Affairs-System/web/src/views/ExperimentGradesView.vue
T
biss 8f56548b54 已按“实验成绩独立管理”完成修改。
每个实验项目独立建成绩单,支持评分项、参与状态、补做次数、安全违规、报告地址和教师评语。
流程为:录入 → 学院审核 → 教务发布;学生只能查看已发布成绩。
自主预约实验支持同步有效预约名单,已评分记录不会被误删。
课程成绩可将已发布实验成绩汇总为快照导入指定分项;导入后锁定,不能手工篡改。
实验项目成绩不完整时,对应课程分项保持空白,不按 0 分处理。
一级“实验管理”菜单下增加“项目与场次”“实验成绩”;学生端增加“实验安排”“实验成绩”。
已生成 MySQL 正式迁移及 SQLite 开发迁移,尚未操作生产数据库。
界面采用“实验项目 → 独立评分 → 审核发布”工作台设计。
2026-07-28 22:21:18 +08:00

835 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,
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>