Files
Academic-Affairs-System/web/src/views/GradesView.vue
T
biss 15a34a306c ---
成绩册 Excel 导入/导出 — 已完成

  后端 (GradesController.cs)

  GET /api/grades/sheets/{id}/template — 下载成绩导入模板
  - 根据成绩单的动态分项(GradeItems)生成对应的 Excel 列:学号 | 姓名 | 班级 | 平时成绩 | [分项1] | [分项2] | ... | 期末成绩 | 考试状态 | 备注
  - 预填已有学生信息和已有成绩
  - 附带填写说明工作表

  POST /api/grades/sheets/{id}/import — 从 Excel 导入成绩
  - 按学号匹配学生,批量更新平时成绩、分项成绩、期末成绩、考试状态、备注
  - 支持留空(不覆盖已有成绩)、0-100 范围校验、考试状态校验(正常/缺考/缓考/免修)
  - 导入后自动重新计算总评成绩和绩点
  - 仅限 Draft/Returned 状态的成绩单,仅限有编辑权限的用户
  - 错误时整批回滚,最多显示 50 条错误详情

  前端 (GradesView.vue)

  在成绩登记册底部操作栏新增两个按钮(仅在 canEdit 时显示):
  - 下载模板 — 调用 downloadApiFile 下载当前成绩单的 Excel 模板
  - Excel导入 — 通过隐藏 file input 选择 .xlsx 文件,调用 importExcel 上传

  工作流程

  1. 教师打开成绩单 → 点击「下载模板」
  2. 在 Excel 中填写/修改学生成绩
  3. 点击「Excel导入」选择文件上传
  4. 系统自动校验、合并成绩、重新计算总评 → 提示更新条数
  5. 成绩表刷新显示最新数据

✻ Crunched for 5m 50s
2026-07-25 17:57:56 +08:00

691 lines
28 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,
Delete,
DocumentChecked,
Download,
EditPen,
Plus,
Promotion,
Refresh,
Upload,
} from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
import { downloadApiFile, importExcel } from '../api/excel'
import { useAuthStore } from '../stores/auth'
const auth = useAuthStore()
const isStudent = computed(() => auth.user?.roles.includes('Student') &&
!auth.user.roles.some((role) =>
['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher'].includes(role)))
const terms = ref<any[]>([])
const tasks = ref<any[]>([])
const selectedTask = ref<any | null>(null)
const detail = ref<any | null>(null)
const transcript = ref<any>({ student: null, records: [] })
const loading = ref(false)
const detailLoading = ref(false)
const createDialog = ref(false)
const returnDialog = ref(false)
const termId = ref<string | undefined>()
const status = ref<string | undefined>()
const createForm = reactive({
regularWeight: 30,
finalWeight: 70,
items: [] as { name: string; weight: number }[],
})
const newItemName = ref('')
const newItemWeight = ref(0)
const returnComment = ref('')
const presetItems = ['实验', '实习', '课程设计', '作业', '课堂表现', '期中']
const statusLabels: Record<string, string> = {
Draft: '录入中',
Submitted: '待审核',
Approved: '审核通过',
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: '缺考',
Deferred: '缓考',
Exempt: '免修',
}
const itemsWeight = computed(() =>
createForm.items.reduce((sum, item) => sum + item.weight, 0))
const weightSum = computed(() =>
createForm.regularWeight + createForm.finalWeight + itemsWeight.value)
const completedCount = computed(() =>
detail.value?.records.filter((record: any) =>
record.totalScore != null || record.examStatus !== 'Normal').length ?? 0)
const averageScore = computed(() => {
const scores = (detail.value?.records ?? [])
.map((record: any) => Number(record.totalScore))
.filter((value: number) => Number.isFinite(value))
if (!scores.length) return '—'
return (scores.reduce((sum: number, value: number) => sum + value, 0) / scores.length)
.toFixed(1)
})
const passRate = computed(() => {
const scores = (detail.value?.records ?? [])
.map((record: any) => Number(record.totalScore))
.filter((value: number) => Number.isFinite(value))
if (!scores.length) return '—'
return `${Math.round(scores.filter((value: number) => value >= 60).length / scores.length * 100)}%`
})
const transcriptGpa = computed(() => {
const numeric = transcript.value.records.filter((record: any) => record.gradePoint != null)
const credits = numeric.reduce((sum: number, record: any) => sum + Number(record.credits), 0)
if (!credits) return '—'
const points = numeric.reduce(
(sum: number, record: any) => sum + Number(record.gradePoint) * Number(record.credits), 0,
)
return (points / credits).toFixed(2)
})
const earnedCredits = computed(() =>
transcript.value.records
.filter((record: any) => Number(record.totalScore) >= 60)
.reduce((sum: number, record: any) => sum + Number(record.credits), 0))
function addItem() {
const name = newItemName.value.trim() || '自定义分项'
if (createForm.items.some(item => item.name === name)) {
ElMessage.warning('分项名称不能重复。')
return
}
createForm.items.push({ name, weight: newItemWeight.value || 0 })
newItemName.value = ''
newItemWeight.value = 0
}
function addPresetItem(name: string) {
if (createForm.items.some(item => item.name === name)) {
ElMessage.warning('该分项已存在。')
return
}
createForm.items.push({ name, weight: 0 })
}
function removeItem(index: number) {
createForm.items.splice(index, 1)
}
async function load() {
loading.value = true
try {
if (isStudent.value) {
transcript.value = (await http.get('/grades/student/transcript', {
params: { academicTermId: termId.value },
})).data
return
}
tasks.value = (await http.get('/grades/sheets', {
params: {
academicTermId: termId.value,
status: status.value,
},
})).data
const preferred = tasks.value.find((item) => item.id === selectedTask.value?.id)
?? tasks.value[0]
if (preferred) await selectTask(preferred)
else {
selectedTask.value = null
detail.value = null
}
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
loading.value = false
}
}
async function selectTask(task: any) {
selectedTask.value = task
detail.value = null
if (!task.sheet) return
detailLoading.value = true
try {
const response = (await http.get(`/grades/sheets/${task.sheet.id}`)).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 openCreate() {
createForm.regularWeight = 30
createForm.finalWeight = 70
createForm.items = []
newItemName.value = ''
newItemWeight.value = 0
createDialog.value = true
}
async function createSheet() {
if (weightSum.value !== 100) {
ElMessage.warning(`平时、期末与所有分项的比例必须合计 100%(当前 ${weightSum.value}%)。`)
return
}
try {
await http.post('/grades/sheets', {
teachingTaskId: selectedTask.value.id,
regularWeight: createForm.regularWeight,
finalWeight: createForm.finalWeight,
items: createForm.items.map(item => ({ name: item.name, weight: item.weight })),
})
createDialog.value = false
ElMessage.success('成绩登记册已建立')
await load()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function saveRecords() {
try {
await http.put(`/grades/sheets/${detail.value.id}/records`, {
records: detail.value.records.map((record: any) => ({
id: record.id,
regularScore: record.regularScore,
finalScore: record.finalScore,
itemScores: record.itemScores?.map((itemScore: any) => ({
gradeItemId: itemScore.gradeItemId,
score: itemScore.score,
})),
examStatus: record.examStatus,
notes: record.notes,
})),
})
ElMessage.success('成绩已保存并重新计算总评')
await selectTask(selectedTask.value)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function submitSheet() {
try {
await ElMessageBox.confirm(
'提交后成绩将锁定并进入学院审核,确认所有学生成绩已经核对?',
'提交成绩',
{ type: 'warning', confirmButtonText: '确认提交', cancelButtonText: '继续检查' },
)
await http.post(`/grades/sheets/${detail.value.id}/submit`)
ElMessage.success('成绩单已提交审核')
await load()
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
const importFileInput = ref<HTMLInputElement>()
function downloadTemplate() {
downloadApiFile(
`/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) return
try {
const result = await importExcel(
`/grades/sheets/${detail.value.id}/import`,
file,
)
ElMessage.success(`导入完成:已更新 ${result.data.updated} 条成绩记录`)
await selectTask(selectedTask.value)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
input.value = ''
}
}
async function approveSheet() {
try {
await http.post(`/grades/sheets/${detail.value.id}/approve`)
ElMessage.success('成绩单已通过审核')
await load()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
function openReturn() {
returnComment.value = ''
returnDialog.value = true
}
async function returnSheet() {
if (!returnComment.value.trim()) {
ElMessage.warning('请填写需要教师修改的具体内容。')
return
}
try {
await http.post(`/grades/sheets/${detail.value.id}/return`, {
comment: returnComment.value,
})
returnDialog.value = false
ElMessage.success('成绩单已退回教师修改')
await load()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function publishSheet() {
try {
await ElMessageBox.confirm(
'发布后学生即可查看成绩,当前版本将锁定。确定发布?',
'发布成绩',
{ type: 'warning', confirmButtonText: '确认发布', cancelButtonText: '取消' },
)
await http.post(`/grades/sheets/${detail.value.id}/publish`)
ElMessage.success('成绩已向学生发布')
await load()
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
function scoreClass(score: number | null) {
if (score == null) return 'pending'
if (score < 60) return 'failed'
if (score >= 90) return 'excellent'
return ''
}
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
termId.value = terms.value.find((item) => item.isCurrent)?.id
await load()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
})
</script>
<template>
<div class="page-stack grade-page">
<section class="page-intro">
<div>
<span class="section-kicker">ACADEMIC RECORD</span>
<h2>{{ isStudent ? '学业成绩单' : '成绩管理' }}</h2>
<p v-if="isStudent">查看学校已正式发布的课程成绩学分与绩点</p>
<p v-else>从教师登记学院复核到校级发布支持自定义平时实验实习等分项</p>
</div>
<el-button :icon="Refresh" @click="load">刷新</el-button>
</section>
<template v-if="isStudent">
<section class="transcript-identity">
<div>
<span>STUDENT TRANSCRIPT</span>
<h3>{{ transcript.student?.name ?? '学生' }}</h3>
<p>{{ transcript.student?.studentNumber }} · {{ transcript.student?.majorName }} · {{ transcript.student?.className }}</p>
</div>
<label>
<span>查看学期</span>
<el-select v-model="termId" clearable placeholder="全部学期" @change="load">
<el-option v-for="term in terms" :key="term.id" :label="term.name" :value="term.id" />
</el-select>
</label>
</section>
<section class="transcript-ribbon">
<div><span>已发布课程</span><b>{{ transcript.records.length }}</b><small></small></div>
<div><span>获得学分</span><b>{{ earnedCredits }}</b><small></small></div>
<div><span>加权平均绩点</span><b>{{ transcriptGpa }}</b><small>GPA</small></div>
</section>
<section class="transcript-list" v-loading="loading">
<article v-for="record in transcript.records" :key="record.id">
<div class="transcript-course">
<span>{{ record.termName }}</span>
<h3>{{ record.courseName }}</h3>
<p>{{ record.courseCode }} · {{ record.taskNumber }} · {{ record.credits }} 学分</p>
</div>
<div class="grade-scale">
<i v-for="tick in 11" :key="tick" />
<span
v-if="record.totalScore != null"
:class="scoreClass(record.totalScore)"
:style="{ left: `${record.totalScore}%` }"
>{{ record.totalScore }}</span>
</div>
<div class="transcript-result">
<span>{{ examStatusLabels[record.examStatus] }}</span>
<b v-if="record.totalScore != null">{{ record.totalScore }}</b>
<b v-else></b>
<small>绩点 {{ record.gradePoint ?? '—' }}</small>
</div>
</article>
<el-empty v-if="!transcript.records.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="term.name" :value="term.id" />
</el-select>
<el-select v-model="status" clearable placeholder="全部状态" @change="load">
<el-option v-for="(label, value) in statusLabels" :key="value" :label="label" :value="value" />
</el-select>
<span> {{ tasks.length }} 个可管理教学班</span>
</section>
<section class="grade-workspace" v-loading="loading">
<aside class="grade-sheet-list">
<button
v-for="task in tasks"
:key="task.id"
type="button"
:class="{ active: selectedTask?.id === task.id }"
@click="selectTask(task)"
>
<span>{{ task.courseCode }} · {{ task.taskNumber }}</span>
<b>{{ task.courseName }}</b>
<small>{{ task.teacherNames.join('、') }} · {{ task.classNames.join('、') }}</small>
<i :class="task.sheet?.status?.toLowerCase()">
{{ task.sheet ? statusLabels[task.sheet.status] : '未建册' }}
</i>
</button>
<el-empty v-if="!tasks.length" description="没有可管理的教学班" />
</aside>
<main class="grade-register" v-loading="detailLoading">
<el-empty v-if="!selectedTask" description="请选择教学班" />
<section v-else-if="!selectedTask.sheet" class="grade-create-empty">
<el-icon><DocumentChecked /></el-icon>
<h3>{{ selectedTask.courseName }}</h3>
<p>{{ selectedTask.taskNumber }} 尚未建立成绩登记册系统会从有效选课名单生成学生行</p>
<el-button type="primary" :icon="Plus" @click="openCreate">建立成绩登记册</el-button>
</section>
<template v-else-if="detail">
<header class="grade-register-head">
<div>
<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>
</header>
<section class="grade-register-ruler">
<div><span>学生数</span><b>{{ detail.records.length }}</b></div>
<div><span>已完成</span><b>{{ completedCount }}</b></div>
<div><span>平均分</span><b>{{ averageScore }}</b></div>
<div><span>及格率</span><b>{{ passRate }}</b></div>
<p>
平时 {{ detail.regularWeight }}%
<template v-if="detail.items?.length">{{ detail.items.map((item: any) => ` · ${item.name} ${item.weight}%`).join('') }}</template>
· 期末 {{ detail.finalWeight }}%
</p>
</section>
<el-alert
v-if="detail.status === 'Returned'"
:title="`审核退回:${detail.reviewComment}`"
type="warning"
:closable="false"
show-icon
/>
<div class="grade-table-scroll">
<el-table :data="detail.records" class="data-table grade-entry-table">
<el-table-column label="学生" fixed min-width="165">
<template #default="{ row }">
<div class="record-name">
<b>{{ row.name }}</b>
<span>{{ row.studentNumber }} · {{ row.className }}</span>
</div>
</template>
</el-table-column>
<el-table-column :label="`平时 ${detail.regularWeight}%`" width="110">
<template #default="{ row }">
<el-input-number
v-if="detail.canEdit && row.examStatus === 'Normal'"
v-model="row.regularScore"
:min="0" :max="100" :controls="false" size="small"
/>
<span v-else>{{ row.regularScore ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column
v-for="item in detail.items"
:key="item.id"
:label="`${item.name} ${item.weight}%`"
width="110"
>
<template #default="{ row }">
<el-input-number
v-if="detail.canEdit && row.examStatus === 'Normal'"
:model-value="itemScore(row, item.id)?.score"
@update:model-value="(val: number | undefined) => { const found = itemScore(row, item.id); if (found) found.score = val }"
:min="0" :max="100" :controls="false" size="small"
/>
<span v-else>{{ itemScore(row, item.id)?.score ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column :label="`期末 ${detail.finalWeight}%`" width="110">
<template #default="{ row }">
<el-input-number
v-if="detail.canEdit && row.examStatus === 'Normal'"
v-model="row.finalScore"
:min="0" :max="100" :controls="false" size="small"
/>
<span v-else>{{ row.finalScore ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="总评" width="90">
<template #default="{ row }">
<template v-if="detail.canEdit && row.examStatus === 'Normal'">
<b class="total-score preview" :class="scoreClass(calcPreviewTotal(row))">
{{ calcPreviewTotal(row) ?? '—' }}
</b>
</template>
<b v-else class="total-score" :class="scoreClass(row.totalScore)">{{ row.totalScore ?? '—' }}</b>
</template>
</el-table-column>
<el-table-column label="考试状态" width="115">
<template #default="{ row }">
<el-select v-if="detail.canEdit" v-model="row.examStatus" size="small">
<el-option v-for="(label, value) in examStatusLabels" :key="value" :label="label" :value="value" />
</el-select>
<span v-else>{{ examStatusLabels[row.examStatus] }}</span>
</template>
</el-table-column>
<el-table-column label="备注" min-width="140">
<template #default="{ row }">
<el-input v-if="detail.canEdit" v-model="row.notes" maxlength="300" size="small" />
<span v-else>{{ row.notes || '—' }}</span>
</template>
</el-table-column>
</el-table>
</div>
<input ref="importFileInput" type="file" accept=".xlsx" style="display:none" @change="handleImport" />
<footer class="grade-actions">
<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="Download" @click="downloadTemplate">下载模板</el-button>
<el-button v-if="detail.canEdit" :icon="Upload" @click="chooseImportFile">Excel导入</el-button>
<el-button v-if="detail.canEdit" :icon="EditPen" @click="saveRecords">保存成绩</el-button>
<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>
</footer>
</template>
</main>
</section>
</template>
<el-dialog v-model="createDialog" title="建立成绩登记册" width="620px">
<el-alert title="平时期末与所有分项的比例必须合计 100%" type="info" :closable="false" />
<el-form label-position="top" class="grade-weight-form">
<div class="form-grid three">
<el-form-item label="平时成绩 %">
<el-input-number v-model="createForm.regularWeight" :min="0" :max="100" />
</el-form-item>
<el-form-item label="期末成绩 %">
<el-input-number v-model="createForm.finalWeight" :min="0" :max="100" />
</el-form-item>
<el-form-item label="当前合计">
<div class="weight-sum" :class="{ invalid: weightSum !== 100 }">
<b>{{ weightSum }}%</b>
<span v-if="weightSum !== 100">需为 100%</span>
<span v-else class="valid">✓</span>
</div>
</el-form-item>
</div>
<el-divider>成绩分项(实验、实习、课程设计等)</el-divider>
<div class="preset-items">
<span>快速添加:</span>
<el-button
v-for="name in presetItems" :key="name"
size="small" @click="addPresetItem(name)"
>{{ name }}</el-button>
</div>
<div v-if="createForm.items.length" class="item-list">
<div v-for="(item, index) in createForm.items" :key="index" class="item-row">
<span>{{ item.name }}</span>
<el-input-number v-model="item.weight" :min="0" :max="100" size="small" />
<span>%</span>
<el-button :icon="Delete" link type="danger" @click="removeItem(index)" />
</div>
</div>
<div class="add-item-row">
<el-input v-model="newItemName" placeholder="自定义名称" size="small" style="width:160px" />
<el-input-number v-model="newItemWeight" :min="0" :max="100" size="small" />
<span>%</span>
<el-button size="small" :icon="Plus" @click="addItem">添加</el-button>
</div>
</el-form>
<template #footer>
<el-button @click="createDialog = false">取消</el-button>
<el-button type="primary" :disabled="weightSum !== 100" @click="createSheet">建立登记册</el-button>
</template>
</el-dialog>
<el-dialog v-model="returnDialog" title="退回成绩单" width="560px">
<el-form label-position="top">
<el-form-item label="修改意见" required>
<el-input v-model="returnComment" type="textarea" :rows="4" maxlength="500" show-word-limit />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="returnDialog = false">取消</el-button>
<el-button type="danger" @click="returnSheet">确认退回</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.grade-weight-form { margin-top: 16px; }
.weight-sum { display: flex; align-items: baseline; gap: 8px; padding: 12px; border-radius: 6px; background: #f5f7fa; }
.weight-sum b { font: 700 22px/1 Consolas, monospace; color: var(--indigo); }
.weight-sum.invalid b { color: #b34e48; }
.weight-sum span { font-size: 11px; color: var(--muted); }
.weight-sum .valid { color: #2d8975; font-weight: 700; }
.preset-items { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
.preset-items > span { font-size: 11px; color: var(--muted); }
.item-list { display: grid; gap: 6px; margin-bottom: 12px; }
.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 total score */
.total-score.preview {
text-decoration: underline dashed;
text-underline-offset: 3px;
text-decoration-color: var(--muted);
cursor: help;
}
/* 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>