考试排考

This commit is contained in:
2026-07-25 14:51:17 +08:00 Unverified
parent 36049af692
commit 4e7da6709e
12 changed files with 1306 additions and 118 deletions
+197 -36
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Plus, Promotion, Refresh, UserFilled } from '@element-plus/icons-vue'
import { Plus, Promotion, Refresh, UserFilled, Setting } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth'
@@ -15,9 +15,13 @@ const terms = ref<any[]>([])
const tasks = ref<any[]>([])
const rooms = ref<any[]>([])
const teachers = ref<any[]>([])
const buildings = ref<any[]>([])
const timeSlots = ref<any[]>([])
const loading = ref(false)
const arrangeLoading = ref(false)
const planDialog = ref(false)
const sessionDialog = ref(false)
const editingSession = ref<any | null>(null)
const rosterDrawer = ref(false)
const roster = ref<any | null>(null)
const planForm = reactive<Record<string, any>>({})
@@ -32,7 +36,20 @@ function dateText(value: string) {
hour: '2-digit', minute: '2-digit', hour12: false,
}).format(new Date(value))
}
function toIso(value: string) { return new Date(value.replace(' ', 'T')).toISOString() }
function timeText(startsAt: string) {
return new Date(startsAt).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })
}
function dateOnlyText(value: string) {
if (!value) return ''
return new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', weekday: 'short' }).format(new Date(value))
}
function periodLabel(session: any) {
const end = session.startPeriod + session.periodCount - 1
const startSlot = timeSlots.value.find((s: any) => s.periodNumber === session.startPeriod)
const endSlot = timeSlots.value.find((s: any) => s.periodNumber === end)
const timeRange = startSlot && endSlot ? `${startSlot.startsAt}${endSlot.endsAt}` : ''
return `${session.startPeriod}-${end}${timeRange ? ' · ' + timeRange : ''}`
}
async function load() {
loading.value = true
@@ -64,33 +81,57 @@ async function savePlan() {
await load()
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
}
function openSession() {
function openSession(existing?: any) {
editingSession.value = existing ?? null
const firstSlot = timeSlots.value[0]
Object.assign(sessionForm, {
teachingTaskId: undefined, classroomId: undefined,
startsAt: '', endsAt: '', invigilatorIds: [], notes: '',
teachingTaskId: existing?.teachingTaskId ?? undefined,
classroomId: existing?.classroomId ?? undefined,
examDate: existing?.examDate ?? '',
startPeriod: existing?.startPeriod ?? (firstSlot?.periodNumber ?? 1),
periodCount: existing?.periodCount ?? 2,
requiredBuildingId: existing?.requiredBuildingId ?? undefined,
requiredInvigilatorCount: existing?.requiredInvigilatorCount ?? 2,
invigilatorIds: existing?.invigilatorIds ?? [],
notes: existing?.notes ?? '',
})
sessionDialog.value = true
}
async function saveSession() {
try {
await http.post(`/exams/plans/${selected.value.id}/sessions`, {
...sessionForm,
startsAt: toIso(sessionForm.startsAt),
endsAt: toIso(sessionForm.endsAt),
})
const payload = { ...sessionForm }
if (editingSession.value) {
await http.put(`/exams/plans/${selected.value.id}/sessions/${editingSession.value.id}`, payload)
} else {
await http.post(`/exams/plans/${selected.value.id}/sessions`, payload)
}
sessionDialog.value = false
editingSession.value = null
await selectPlan(selected.value.id)
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
}
async function removeSession(row: any) {
try {
await ElMessageBox.confirm(`移除${row.courseName}考试场次?`, '移除场次', { type: 'warning' })
await ElMessageBox.confirm(`移除"${row.courseName}"考试场次?`, '移除场次', { type: 'warning' })
await http.delete(`/exams/plans/${selected.value.id}/sessions/${row.id}`)
await selectPlan(selected.value.id)
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
async function autoArrange() {
try {
await ElMessageBox.confirm(
'系统将为未分配考场的场次自动匹配教室,为未满监考的场次自动分配教师。',
'自动编排', { type: 'info', confirmButtonText: '开始编排' })
arrangeLoading.value = true
const res = await http.post(`/exams/plans/${selected.value.id}/auto-arrange`)
ElMessage.success(res.data.message)
await selectPlan(selected.value.id)
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
} finally { arrangeLoading.value = false }
}
async function publishPlan() {
try {
await ElMessageBox.confirm('发布后考试时间、考场与监考安排将锁定。', '发布考试计划', {
@@ -108,20 +149,39 @@ async function showRoster(row: any) {
rosterDrawer.value = true
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
}
function periodOptions() {
const max = timeSlots.value.length > 0 ? timeSlots.value[timeSlots.value.length - 1].periodNumber : 12
return Array.from({ length: max }, (_, i) => ({ value: i + 1, label: `${i + 1}` }))
}
function periodCountOptions() {
return [1, 2, 3, 4].map(n => ({ value: n, label: `${n} 小节` }))
}
function classroomLabel(room: any) {
return `${room.name} · ${room.capacity}座 · ${room.buildingName}`
}
function filteredRooms() {
if (!sessionForm.requiredBuildingId) return rooms.value
return rooms.value.filter((r: any) => r.buildingId === sessionForm.requiredBuildingId)
}
onMounted(async () => {
try {
if (isManager.value) {
const [termRes, taskRes, roomRes, teacherRes] = await Promise.all([
const currentTermId = (await http.get('/base-data/terms')).data.find((t: any) => t.isCurrent)?.id
const [termRes, taskRes, roomRes, teacherRes, buildingRes, slotRes] = await Promise.all([
http.get('/base-data/terms'),
http.get('/teaching-tasks', { params: { page: 1, pageSize: 100 } }),
http.get('/teaching-tasks', { params: { page: 1, pageSize: 200 } }),
http.get('/base-data/classrooms'),
http.get('/personnel/teachers', { params: { page: 1, pageSize: 100, teacherStatus: 'Active' } }),
http.get('/personnel/teachers', { params: { page: 1, pageSize: 200, teacherStatus: 'Active' } }),
http.get('/base-data/buildings'),
currentTermId ? http.get('/exams/time-slots-for-term', { params: { academicTermId: currentTermId } }) : Promise.resolve({ data: [] }),
])
terms.value = termRes.data
tasks.value = taskRes.data.items
rooms.value = roomRes.data
teachers.value = teacherRes.data.items
buildings.value = buildingRes.data
timeSlots.value = slotRes.data
}
await load()
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
@@ -134,7 +194,7 @@ onMounted(async () => {
<div>
<span class="section-kicker">EXAMINATION OFFICE</span>
<h2>{{ isManager ? '考试安排与考场' : isTeacher ? '我的监考' : '我的考试' }}</h2>
<p>{{ isManager ? '集中安排考试时间、考场与监考教师,并在发布前消除冲突。' : '查看学校已经正式发布的考试日程。' }}</p>
<p>{{ isManager ? '基于课表节次安排考试,自动匹配考场与监考教师。' : '查看学校已经正式发布的考试日程。' }}</p>
</div>
<el-button v-if="isManager" type="primary" :icon="Plus" @click="openPlan">新建考试计划</el-button>
<el-button v-else :icon="Refresh" @click="load">刷新日程</el-button>
@@ -149,55 +209,156 @@ onMounted(async () => {
</section>
<section v-if="selected" class="exam-board" v-loading="loading">
<header>
<div><span>EXAM TIMELINE</span><h3>{{ selected.name }}</h3><p>{{ selected.termName }} · {{ selected.sessions.length }} 个考试场次</p></div>
<div>
<el-button v-if="selected.status === 'Draft'" :icon="Plus" @click="openSession">安排场次</el-button>
<span>EXAM TIMELINE</span>
<h3>{{ selected.name }}</h3>
<p>{{ selected.termName }} · {{ selected.sessions.length }} 个考试场次</p>
</div>
<div class="exam-actions">
<el-button v-if="selected.status === 'Draft'" :icon="Setting" @click="autoArrange" :loading="arrangeLoading">自动编排</el-button>
<el-button v-if="selected.status === 'Draft'" :icon="Plus" @click="openSession()">安排场次</el-button>
<el-button v-if="selected.status === 'Draft'" type="primary" :icon="Promotion" @click="publishPlan">发布计划</el-button>
</div>
</header>
<div class="exam-timeline">
<article v-for="session in selected.sessions" :key="session.id">
<time><b>{{ dateText(session.startsAt).split(' ')[0] }}</b><span>{{ dateText(session.startsAt).split(' ').slice(1).join(' ') }}</span></time>
<div><span>{{ session.courseCode }} · {{ session.taskNumber }}</span><h4>{{ session.courseName }}</h4><p>{{ session.buildingName }} · {{ session.classroomName }} · {{ session.studentCount }} </p></div>
<div class="exam-staff"><span>监考</span><b>{{ session.invigilatorNames.join('、') }}</b><small>{{ dateText(session.startsAt) }}{{ dateText(session.endsAt).split(' ').slice(-1)[0] }}</small></div>
<article v-for="session in selected.sessions" :key="session.id" :class="{ unassigned: !session.classroomId }">
<time>
<b>{{ dateOnlyText(session.examDate) }}</b>
<span>{{ periodLabel(session) }}</span>
</time>
<div>
<span>{{ session.courseCode }} · {{ session.taskNumber }}</span>
<h4>{{ session.courseName }}</h4>
<p>
<template v-if="session.classroomId">{{ session.buildingName }} · {{ session.classroomName }} · {{ session.classroomCapacity }}</template>
<template v-else><el-tag size="small" type="warning">待分配考场</el-tag></template>
· {{ session.studentCount }}
<template v-if="session.requiredBuildingName"> · {{ session.requiredBuildingName }}</template>
<template v-if="session.requiredInvigilatorCount > 1"> · {{ session.requiredInvigilatorCount }}名监考</template>
</p>
</div>
<div class="exam-staff">
<span>监考</span>
<b>{{ session.invigilatorNames.length ? session.invigilatorNames.join('、') : '待分配' }}</b>
<small>{{ timeText(session.startsAt) }}{{ timeText(session.endsAt) }}</small>
</div>
<div class="exam-row-actions">
<el-button link type="primary" @click="showRoster(session)">考生名单</el-button>
<el-button v-if="selected.status === 'Draft'" link type="primary" @click="openSession(session)">编辑</el-button>
<el-button v-if="selected.status === 'Draft'" link type="danger" @click="removeSession(session)">移除</el-button>
</div>
</article>
<el-empty v-if="!selected.sessions.length" description="尚未安排考试场次" />
<el-empty v-if="!selected.sessions.length" description="尚未安排考试场次,点击「安排场次」开始。" />
</div>
</section>
</template>
<section v-else class="exam-ticket-grid" v-loading="loading">
<article v-for="item in personal" :key="item.id">
<div class="exam-ticket-date"><b>{{ dateText(item.startsAt).split(' ')[0] }}</b><span>{{ dateText(item.startsAt).split(' ').slice(1).join(' ') }}</span></div>
<div><span>{{ item.courseCode }} · {{ item.taskNumber }}</span><h3>{{ item.courseName }}</h3><p>{{ item.buildingName }} · {{ item.classroomName }}</p></div>
<footer><el-icon><UserFilled /></el-icon>{{ isTeacher ? `${item.studentCount} 名考生` : `监考:${item.invigilatorNames.join('、')}` }}</footer>
<div class="exam-ticket-date">
<b>{{ dateOnlyText(item.examDate) }}</b>
<span>{{ timeText(item.startsAt) }}{{ timeText(item.endsAt) }}</span>
</div>
<div>
<span>{{ item.courseCode }} · {{ item.taskNumber }}</span>
<h3>{{ item.courseName }}</h3>
<p>{{ item.buildingName ? `${item.buildingName} · ${item.classroomName}` : '考场待定' }}</p>
</div>
<footer>
<el-icon><UserFilled /></el-icon>
{{ isTeacher ? `${item.studentCount} 名考生` : `监考:${item.invigilatorNames.join('、') || '待定'}` }}
</footer>
</article>
<el-empty v-if="!personal.length" description="暂无已发布考试安排" />
</section>
<!-- Plan Dialog -->
<el-dialog v-model="planDialog" title="新建考试计划" width="600px">
<el-form label-position="top">
<el-form-item label="学期"><el-select v-model="planForm.academicTermId"><el-option v-for="x in terms" :key="x.id" :label="x.name" :value="x.id" /></el-select></el-form-item>
<el-form-item label="计划名称"><el-input v-model="planForm.name" /></el-form-item>
<el-form-item label="说明"><el-input v-model="planForm.notes" type="textarea" /></el-form-item>
<el-form-item label="学期">
<el-select v-model="planForm.academicTermId">
<el-option v-for="x in terms" :key="x.id" :label="x.name" :value="x.id" />
</el-select>
</el-form-item>
<el-form-item label="计划名称"><el-input v-model="planForm.name" maxlength="120" /></el-form-item>
<el-form-item label="说明"><el-input v-model="planForm.notes" type="textarea" maxlength="500" /></el-form-item>
</el-form>
<template #footer><el-button @click="planDialog=false">取消</el-button><el-button type="primary" @click="savePlan">保存草稿</el-button></template>
<template #footer>
<el-button @click="planDialog = false">取消</el-button>
<el-button type="primary" @click="savePlan">保存草稿</el-button>
</template>
</el-dialog>
<el-dialog v-model="sessionDialog" title="安排考试场次" width="680px">
<!-- Session Dialog -->
<el-dialog v-model="sessionDialog" :title="editingSession ? '编辑考试场次' : '安排考试场次'" width="720px" top="5vh">
<el-form label-position="top">
<el-form-item label="教学班"><el-select v-model="sessionForm.teachingTaskId" filterable><el-option v-for="x in tasks" :key="x.id" :label="`${x.taskNumber} · ${x.courseName}`" :value="x.id" /></el-select></el-form-item>
<el-form-item label="考场"><el-select v-model="sessionForm.classroomId"><el-option v-for="x in rooms" :key="x.id" :label="`${x.name} · ${x.capacity} 座`" :value="x.id" /></el-select></el-form-item>
<div class="form-grid"><el-form-item label="开始时间"><el-date-picker v-model="sessionForm.startsAt" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" /></el-form-item><el-form-item label="结束时间"><el-date-picker v-model="sessionForm.endsAt" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" /></el-form-item></div>
<el-form-item label="监考教师"><el-select v-model="sessionForm.invigilatorIds" multiple filterable><el-option v-for="x in teachers" :key="x.id" :label="`${x.teacherNumber} · ${x.name}`" :value="x.id" /></el-select></el-form-item>
<el-form-item label="教学班">
<el-select v-model="sessionForm.teachingTaskId" filterable placeholder="选择教学班">
<el-option v-for="x in tasks" :key="x.id" :label="`${x.taskNumber} · ${x.courseName}`" :value="x.id" />
</el-select>
</el-form-item>
<div class="form-grid">
<el-form-item label="考试日期">
<el-date-picker v-model="sessionForm.examDate" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" />
</el-form-item>
<el-form-item label="起始节次">
<el-select v-model="sessionForm.startPeriod">
<el-option v-for="opt in periodOptions()" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
<el-form-item label="持续节数">
<el-select v-model="sessionForm.periodCount">
<el-option v-for="opt in periodCountOptions()" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
</div>
<div class="form-grid">
<el-form-item label="教学楼限制">
<el-select v-model="sessionForm.requiredBuildingId" clearable placeholder="不限教学楼">
<el-option v-for="x in buildings" :key="x.id" :label="x.name" :value="x.id" />
</el-select>
</el-form-item>
<el-form-item label="监考人数">
<el-input-number v-model="sessionForm.requiredInvigilatorCount" :min="1" :max="10" />
</el-form-item>
</div>
<el-form-item label="考场(可留空,由自动编排分配)">
<el-select v-model="sessionForm.classroomId" clearable filterable placeholder="留空由自动编排分配">
<el-option v-for="x in filteredRooms()" :key="x.id" :label="classroomLabel(x)" :value="x.id" />
</el-select>
</el-form-item>
<el-form-item label="监考教师(可留空,由自动编排分配)">
<el-select v-model="sessionForm.invigilatorIds" multiple filterable clearable placeholder="留空由自动编排分配">
<el-option v-for="x in teachers" :key="x.id" :label="`${x.teacherNumber} · ${x.name}`" :value="x.id" />
</el-select>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="sessionForm.notes" maxlength="500" placeholder="可选" />
</el-form-item>
</el-form>
<template #footer><el-button @click="sessionDialog=false">取消</el-button><el-button type="primary" @click="saveSession">保存场次</el-button></template>
<template #footer>
<el-button @click="sessionDialog = false">取消</el-button>
<el-button type="primary" @click="saveSession">{{ editingSession ? '保存修改' : '保存场次' }}</el-button>
</template>
</el-dialog>
<!-- Roster Drawer -->
<el-drawer v-model="rosterDrawer" title="考生名单" size="560px">
<el-table v-if="roster" :data="roster.students"><el-table-column prop="studentNumber" label="学号" width="130" /><el-table-column prop="name" label="姓名" width="90" /><el-table-column prop="className" label="行政班" /></el-table>
<el-table v-if="roster" :data="roster.students">
<el-table-column prop="studentNumber" label="学号" width="130" />
<el-table-column prop="name" label="姓名" width="90" />
<el-table-column prop="className" label="行政班" />
</el-table>
</el-drawer>
</div>
</template>
<style scoped>
.exam-actions {
display: flex;
gap: 8px;
}
.exam-timeline article.unassigned {
border-left-color: #e6a23c;
}
</style>
+77 -11
View File
@@ -45,6 +45,13 @@ const statusLabels: Record<string, string> = {
Published: '已发布',
Returned: '已退回',
}
const statusMeta: Record<string, { icon: string; description: string }> = {
Draft: { icon: '📝', description: '教师正在录入成绩' },
Submitted: { icon: '📤', description: '已提交,等待开课学院审核' },
Approved: { icon: '✅', description: '学院审核通过,待校级发布' },
Published: { icon: '📢', description: '成绩已向学生发布' },
Returned: { icon: '↩️', description: '学院退回,需教师修改后重新提交' },
}
const examStatusLabels: Record<string, string> = {
Normal: '正常',
Absent: '缺考',
@@ -281,6 +288,32 @@ function itemScore(record: any, gradeItemId: string) {
return record.itemScores?.find((itemScore: any) => itemScore.gradeItemId === gradeItemId)
}
/** Client-side preview calculation — mirrors backend GradeCalculator.CalculateTotal */
function calcPreviewTotal(record: any): number | null {
if (!detail.value) return null
if (record.examStatus !== 'Normal') return null
const { regularWeight, finalWeight, items } = detail.value
const itemWeightById: Record<string, number> = {}
for (const item of items ?? []) itemWeightById[item.id] = item.weight
// Check required scores are present
if (regularWeight > 0 && (record.regularScore == null || record.regularScore === '')) return null
if (finalWeight > 0 && (record.finalScore == null || record.finalScore === '')) return null
for (const itemScore of record.itemScores ?? []) {
const weight = itemWeightById[itemScore.gradeItemId]
if (weight > 0 && (itemScore.score == null || itemScore.score === '')) return null
}
let total = (Number(record.regularScore) || 0) * regularWeight / 100
for (const itemScore of record.itemScores ?? []) {
const weight = itemWeightById[itemScore.gradeItemId] || 0
total += (Number(itemScore.score) || 0) * weight / 100
}
total += (Number(record.finalScore) || 0) * finalWeight / 100
return Math.round(total * 10) / 10
}
onMounted(async () => {
try {
terms.value = (await http.get('/base-data/terms')).data
@@ -395,8 +428,14 @@ onMounted(async () => {
<span>{{ detail.courseCode }} · {{ detail.taskNumber }}</span>
<h3>{{ detail.courseName }}成绩登记册</h3>
<p>{{ detail.termName }} · {{ detail.teacherNames.join('、') }} · {{ detail.classNames.join('、') }}</p>
<p v-if="detail.courseCollegeName" class="college-meta">开课学院{{ detail.courseCollegeName }}</p>
</div>
<div class="status-badge-group">
<i :class="detail.status.toLowerCase()">{{ statusLabels[detail.status] }}</i>
<small v-if="detail.status === 'Submitted' && detail.needsCollegeReview">
{{ detail.needsCollegeReview }} 审核
</small>
</div>
<i :class="detail.status.toLowerCase()">{{ statusLabels[detail.status] }}</i>
</header>
<section class="grade-register-ruler">
@@ -465,9 +504,14 @@ onMounted(async () => {
<span v-else>{{ row.finalScore ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="总评" width="80">
<el-table-column label="总评" width="110">
<template #default="{ row }">
<b class="total-score" :class="scoreClass(row.totalScore)">{{ row.totalScore ?? '—' }}</b>
<div class="total-score-cell">
<b class="total-score" :class="scoreClass(row.totalScore)">{{ row.totalScore ?? '—' }}</b>
<span v-if="detail.canEdit && row.examStatus === 'Normal'" class="preview-score" :class="scoreClass(calcPreviewTotal(row))">
参考 {{ calcPreviewTotal(row) ?? '—' }}
</span>
</div>
</template>
</el-table-column>
<el-table-column label="考试状态" width="115">
@@ -488,15 +532,20 @@ onMounted(async () => {
</div>
<footer class="grade-actions">
<div>
<span>当前流程</span>
<b>{{ statusLabels[detail.status] }}</b>
<div class="workflow-status">
<span>{{ statusMeta[detail.status]?.icon }} {{ statusLabels[detail.status] }}</span>
<small>{{ statusMeta[detail.status]?.description }}</small>
<small v-if="detail.needsCollegeReview && (detail.status === 'Submitted' || detail.status === 'Approved')" class="college-hint">
开课学院:{{ detail.needsCollegeReview }}
</small>
</div>
<div class="grade-actions-buttons">
<el-button v-if="detail.canEdit" :icon="EditPen" @click="saveRecords">保存成绩</el-button>
<el-button v-if="detail.canEdit" type="primary" :icon="Promotion" @click="submitSheet">提交审核</el-button>
<el-button v-if="detail.canReview" type="danger" plain @click="openReturn">退回修改</el-button>
<el-button v-if="detail.canReview" type="success" :icon="Check" @click="approveSheet">审核通过</el-button>
<el-button v-if="detail.canPublish" type="primary" :icon="Promotion" @click="publishSheet">发布成绩</el-button>
</div>
<el-button v-if="detail.canEdit" :icon="EditPen" @click="saveRecords">保存成绩</el-button>
<el-button v-if="detail.canEdit" type="primary" :icon="Promotion" @click="submitSheet">提交审核</el-button>
<el-button v-if="detail.canReview" type="danger" plain @click="openReturn">退回修改</el-button>
<el-button v-if="detail.canReview" type="success" :icon="Check" @click="approveSheet">审核通过</el-button>
<el-button v-if="detail.canPublish" type="primary" :icon="Promotion" @click="publishSheet">发布成绩</el-button>
</footer>
</template>
</main>
@@ -581,4 +630,21 @@ onMounted(async () => {
.item-row { display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: #eef5f0; border-radius: 4px; }
.item-row > span:first-child { flex: 1; font-size: 13px; font-weight: 650; }
.add-item-row { display: flex; align-items: center; gap: 8px; }
/* Preview score */
.total-score-cell { display: flex; flex-direction: column; align-items: center; gap: 2px; }
.preview-score { font-size: 10px; opacity: .7; white-space: nowrap; }
.preview-score.failed { color: #b34e48; }
.preview-score.excellent { color: #2d8975; }
/* College review */
.college-meta { color: var(--muted); font-size: 12px; margin-top: 2px; }
.status-badge-group { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; }
.status-badge-group small { font-size: 11px; color: var(--muted); }
.workflow-status { display: flex; flex-direction: column; gap: 2px; }
.workflow-status span { font-weight: 650; font-size: 13px; }
.workflow-status small { color: var(--muted); font-size: 11px; }
.workflow-status .college-hint { color: #6b4e16; font-weight: 600; }
.grade-actions { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px; padding: 12px 16px; border-top: 1px solid #e4e7ed; background: #fafbfc; }
.grade-actions-buttons { display: flex; gap: 8px; flex-wrap: wrap; }
</style>