1069 lines
47 KiB
Vue
1069 lines
47 KiB
Vue
<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>
|