阿梦 dd12737ee4
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Deploy Website on push / Rerun on failure (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
feat: 优化一些文字图片
2026-05-27 16:09:50 +08:00

1447 lines
36 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, ref } from 'vue'
import MdKatex from '@vscode/markdown-it-katex'
import { ElMessage, ElMessageBox } from 'element-plus'
import MarkdownIt from 'markdown-it'
import { uploadImgToPath } from '#/api/global'
import { taskMgrService } from '#/api/service/question-bank/task-mgr'
import ImageAnnotator from '#/components/image-annotator/index.vue'
import PaperQuestionItem from '#/components/paper-question-item/index.vue'
import 'katex/dist/katex.min.css'
const props = defineProps({
resourceId: {
type: String,
default: '',
},
classId: {
type: String,
default: '',
},
students: {
type: Array,
default: () => [],
},
})
const emit = defineEmits(['refresh'])
const studentDialogVisible = ref(false)
const paperRecordDetail = ref<any>({})
const aiReasonMarkdown = new MarkdownIt({
breaks: true,
html: false,
linkify: true,
})
aiReasonMarkdown.use(MdKatex)
function formatDuration(val: any) {
const ms = Number(val || 0)
const total = Math.max(0, Math.floor(ms / 1000))
const hh = String(Math.floor(total / 3600)).padStart(2, '0')
const mm = String(Math.floor((total % 3600) / 60)).padStart(2, '0')
const ss = String(total % 60).padStart(2, '0')
return `${hh}:${mm}:${ss}`
}
type StudentPerformanceLevel =
| 'unsubmitted'
| 'ungraded'
| 'excellent'
| 'good'
| 'pass'
| 'fail'
| 'default'
function resolvePerformanceLevel(item: any): StudentPerformanceLevel {
if (item.status === 0) return 'unsubmitted'
if (item.gradingStatus === 0) return 'ungraded'
const level = Number(item?.level ?? item?.scoreLevel ?? 0)
if (level === 1) return 'excellent'
if (level === 2) return 'good'
if (level === 3) return 'pass'
if (level === 4) return 'fail'
const rate = Number(item?.scoreRate ?? 0)
if (rate >= 90) return 'excellent'
if (rate >= 80) return 'good'
if (rate >= 60) return 'pass'
if (rate > 0) return 'fail'
return 'default'
}
function getGradingStatusText(item: any) {
const performanceLevel = resolvePerformanceLevel(item)
if (performanceLevel === 'unsubmitted') return '未提交'
if (performanceLevel === 'ungraded') return '未批改'
if (performanceLevel === 'excellent') return '优秀'
if (performanceLevel === 'good') return '良好'
if (performanceLevel === 'pass') return '及格'
if (performanceLevel === 'fail') return '不及格'
return '已批改'
}
function getTableRowClassName({ row }: { row: any }) {
return row.rowClass || ''
}
function getCorrectLabel(actualScore: any, score: any) {
if (typeof actualScore !== 'number') return '-'
if (actualScore === score) return '满分'
if (actualScore === 0) return '答错'
return '部分正确'
}
function getCorrectClass(actualScore: any, score: any) {
if (typeof actualScore !== 'number') return ''
if (actualScore === score) return 'mark-success'
if (actualScore === 0) return 'mark-error'
return 'mark-partial'
}
const finalScore = computed(() => {
const list = paperRecordDetail.value?.titleVoList
if (!Array.isArray(list)) return '-'
let total = 0
for (const item of list) {
total += Number(item.actualScore ?? 0)
}
return total
})
const rows = computed(() => {
const list = Array.isArray(props.students) ? props.students : []
return list.map((item: any, idx: number) => {
const score = Number(item?.score ?? 0)
const duration = formatDuration(item?.costTime)
const canView = item.status !== 0 && item.gradingStatus !== 0
const performanceLevel = resolvePerformanceLevel(item)
return {
index: idx + 1,
className: item?.className ?? item?.gradeClassName ?? '',
userName: item?.userName ?? item?.name ?? item?.studentName ?? '',
costTime: duration,
costTimeMs: Number(item?.costTime ?? 0),
score,
scoreRate: Number(item?.scoreRate ?? 0),
scoreRateText: `${item.scoreRate.toFixed(2)}%`,
gradingStatusText: getGradingStatusText(item),
performanceLevel,
rowClass: `student-row--${performanceLevel}`,
recordId: item?.recordId ?? '',
canView,
canRegrade: canView,
}
})
})
function isSubjectiveItem(item: any): boolean {
const typeName = item?.titleChannelTypeName || ''
const objectiveTypes = [
'单选题',
'多选题',
'选择题',
'判断题',
'填空题',
'短文还原',
]
if (objectiveTypes.some((t) => typeName.includes(t))) return false
if (item?.objective === 1) return false
const hasOptions =
Array.isArray(item?.optionList) && item.optionList.length > 0
if (hasOptions) return false
return true
}
function getAiVerdict(item: any) {
const actual = item.actualScore
const max = item.score
if (typeof actual !== 'number')
return { label: '未批改', type: 'info' as const, icon: '⏳' }
if (actual === max)
return { label: '正确', type: 'success' as const, icon: '✅' }
if (actual === 0)
return { label: '错误', type: 'danger' as const, icon: '❌' }
return {
label: '部分正确',
type: 'warning' as const,
icon: '⚖️',
}
}
function escapeRegExp(str: string): string {
// eslint-disable-next-line unicorn/prefer-string-replace-all
return str.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`)
}
function highlightKeywords(text: string, keywords: string[]): string {
if (!text || !keywords?.length) return text || ''
let result = text
for (const kw of keywords) {
if (!kw) continue
const pattern = new RegExp(escapeRegExp(kw), 'gi')
result = result.replace(pattern, '<mark class="kw-highlight">$&</mark>')
}
return result
}
function formatParagraphs(text: string): string {
if (!text) return ''
return text
.split(/\n+/)
.filter((p) => p.trim())
.map((p) => `<p class="answer-paragraph">${p.trim()}</p>`)
.join('')
}
function getAiKeywords(item: any): string[] {
const raw = item?.aiKeywords ?? item?.keywords ?? item?.ai_keywords ?? ''
if (Array.isArray(raw)) return raw.filter(Boolean)
if (typeof raw === 'string' && raw.trim()) {
return raw
.split(/[,;;、\s]+/)
.map((s: string) => s.trim())
.filter(Boolean)
}
return []
}
function getAiReason(item: any): string {
return (
item?.aiAnalyze ??
item?.aiReason ??
item?.ai_analyze ??
item?.analyze ??
item?.gradingRemark ??
''
)
}
function ensureLatexDelimiters(text: string): string {
if (!text || !text.includes('\\')) return text
const mathRegions: string[] = []
let processed = text.replace(/\$\$[\s\S]*?\$\$|\$[^$]+?\$/g, (match) => {
mathRegions.push(match)
return `\x00MATH${mathRegions.length - 1}\x00`
})
// Wrap undelimited \commands (with surrounding math context) in $...$
processed = processed.replace(
/((?:[0-9+\-*/=^_|(\[{. ]*\\[a-zA-Z]+(?:\[[^\]]*\])?(?:\{(?:[^{}]|\{[^{}]*\})*\})*)+[0-9+\-*/=^_|)\]}. ]*)/g,
(match) => `$${match.trim()}$`,
)
processed = processed.replace(
/\x00MATH(\d+)\x00/g,
(_, idx) => mathRegions[Number(idx)]!,
)
return processed
}
function renderAiReasonMarkdown(item: any): string {
const content = getAiReason(item)
if (!content) return ''
return aiReasonMarkdown.render(ensureLatexDelimiters(content))
}
const handleView = (row: any) => {
taskMgrService.getPaperRecordDetail(row.recordId).then((res: any) => {
const data = res?.data ?? res
paperRecordDetail.value = data || {}
paperRecordDetail.value.userName =
paperRecordDetail.value.userName || row.userName || row.name
studentDialogVisible.value = true
})
}
const regradingRecordId = ref('')
async function handleRegrade(row: any) {
if (!row.recordId) {
ElMessage.warning('缺少学生答卷记录ID')
return
}
try {
await ElMessageBox.confirm('确定重新批改该学生试卷吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
regradingRecordId.value = row.recordId
try {
await taskMgrService.regradePaper(row.recordId)
ElMessage.success('已发起重新批改')
emit('refresh')
} catch {
ElMessage.error('重新批改失败,请重试')
} finally {
regradingRecordId.value = ''
}
}
const correctionDialogVisible = ref(false)
const correctionForm = ref({
itemId: 0,
correctResult: 1,
actualScore: 0,
teacherAnalyze: '',
submitAnswerPic: '',
maxScore: 0,
})
const correctionLoading = ref(false)
let currentCorrectionIndex = -1
function openCorrectionDialog(item: any, idx: number) {
currentCorrectionIndex = idx
const actual = typeof item.actualScore === 'number' ? item.actualScore : 0
const max = Number(item.score ?? 0)
let result = 1
if (actual === 0) result = 0
else if (actual >= max) result = 1
correctionForm.value = {
itemId: item.itemId ?? item.id ?? 0,
correctResult: result,
actualScore: actual,
teacherAnalyze: item.teacherAnalyze ?? '',
submitAnswerPic: item.submitAnswerPic ?? '',
maxScore: max,
}
correctionDialogVisible.value = true
}
const correctionPicList = computed(() => {
const raw = correctionForm.value.submitAnswerPic
if (!raw) return []
return String(raw).split(',').filter(Boolean)
})
const annotatorVisible = ref(false)
const annotatorSrc = ref('')
let annotatingPicIndex = -1
const annotatorUploading = ref(false)
function closeAnnotator() {
annotatorVisible.value = false
annotatorSrc.value = ''
annotatingPicIndex = -1
}
function openAnnotator(pic: string, idx: number) {
annotatorSrc.value = pic
annotatingPicIndex = idx
annotatorVisible.value = true
}
function onAnnotatorCancel() {
closeAnnotator()
}
function dataURLtoFile(dataUrl: string, filename: string): File {
const arr = dataUrl.split(',')
const mime = arr[0]?.match(/:(.*?);/)?.[1] ?? 'image/png'
const bstr = atob(arr[1] ?? '')
let n = bstr.length
const u8arr = new Uint8Array(n)
while (n--) {
u8arr[n] = bstr.codePointAt(n) ?? 0
}
return new File([u8arr], filename, { type: mime })
}
function getUploadedFilePath(res: any): string {
if (typeof res?.data?.filePath === 'string') return res.data.filePath
if (typeof res?.filePath === 'string') return res.filePath
if (typeof res?.data?.url === 'string') return res.data.url
if (typeof res?.url === 'string') return res.url
if (typeof res?.path === 'string') return res.path
if (typeof res?.data === 'string') return res.data
if (typeof res === 'string') return res
return ''
}
async function onAnnotatorSave(dataUrl: string) {
annotatorUploading.value = true
try {
const file = dataURLtoFile(dataUrl, `annotated_${Date.now()}.png`)
const fd = new FormData()
fd.append('file', file)
const res: any = await uploadImgToPath(fd)
const url = getUploadedFilePath(res)
if (!url || typeof url !== 'string') {
ElMessage.error('图片上传失败')
return
}
const pics = [...correctionPicList.value]
if (annotatingPicIndex >= 0 && annotatingPicIndex < pics.length) {
pics[annotatingPicIndex] = url
} else {
pics.push(url)
}
correctionForm.value.submitAnswerPic = pics.join(',')
closeAnnotator()
ElMessage.success('涂鸦已保存')
} catch {
ElMessage.error('上传失败,请重试')
} finally {
annotatorUploading.value = false
}
}
function onCorrectionResultChange(val: any) {
if (val === 1) {
correctionForm.value.actualScore = correctionForm.value.maxScore
} else if (val === 0) {
correctionForm.value.actualScore = 0
}
}
async function submitCorrection() {
const { actualScore, maxScore } = correctionForm.value
if (actualScore < 0 || actualScore > maxScore) {
ElMessage.warning(`分数需在 0 ~ ${maxScore} 之间`)
return
}
correctionLoading.value = true
try {
await taskMgrService.teacherGradeItem({
itemId: correctionForm.value.itemId,
correctResult: correctionForm.value.correctResult,
actualScore: correctionForm.value.actualScore,
teacherAnalyze: correctionForm.value.teacherAnalyze,
submitAnswerPic: correctionForm.value.submitAnswerPic,
})
ElMessage.success('修正成功')
if (
currentCorrectionIndex >= 0 &&
Array.isArray(paperRecordDetail.value?.titleVoList)
) {
const target = paperRecordDetail.value.titleVoList[currentCorrectionIndex]
if (target) {
target.actualScore = correctionForm.value.actualScore
target.correctResult = correctionForm.value.correctResult
target.teacherAnalyze = correctionForm.value.teacherAnalyze
target.submitAnswerPic = correctionForm.value.submitAnswerPic
}
}
correctionDialogVisible.value = false
} catch {
ElMessage.error('修正失败,请重试')
} finally {
correctionLoading.value = false
}
}
</script>
<template>
<div class="student-wrapper">
<div class="student">
<div class="title">学生情况</div>
<el-table :data="rows" border :row-class-name="getTableRowClassName">
<el-table-column
prop="index"
label="序号"
width="100"
header-align="center"
align="center"
/>
<el-table-column
prop="className"
label="班级"
header-align="center"
align="center"
/>
<el-table-column
prop="userName"
label="姓名"
header-align="center"
align="center"
/>
<el-table-column
prop="costTimeMs"
label="答题时长"
sortable
header-align="center"
align="center"
>
<template #default="{ row }">
{{ row.costTime }}
</template>
</el-table-column>
<el-table-column
prop="scoreRate"
label="得分/得分率"
sortable
header-align="center"
align="center"
>
<template #default="{ row }">
{{ row.score }}/{{ row.scoreRateText }}
</template>
</el-table-column>
<el-table-column
prop="gradingStatusText"
label="状态"
header-align="center"
align="center"
>
<template #default="{ row }">
<span
class="student-status-text"
:class="`student-status-text--${row.performanceLevel}`"
>
{{ row.gradingStatusText }}
</span>
</template>
</el-table-column>
<el-table-column
label="操作"
header-align="center"
align="center"
width="240"
>
<template #default="{ row }">
<el-button
link
type="primary"
:disabled="!row.canView"
@click="handleView(row)"
>
<p style="color: #fff">查看</p>
</el-button>
<el-button
v-if="row.canRegrade"
type="warning"
:loading="regradingRecordId === row.recordId"
@click="handleRegrade(row)"
>
重新批改
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- 学生答卷详情弹框 -->
<el-dialog
v-model="studentDialogVisible"
title="学生答卷详情"
width="960px"
align-center
destroy-on-close
class="student-detail-dialog"
>
<div class="paper-info">
<div class="paper-title">
试卷:{{ paperRecordDetail?.paperName || '-' }}
</div>
<div class="paper-meta">
<span>作答人:{{ paperRecordDetail?.userName || '-' }}</span>
<span>最终总分:{{ finalScore }}</span>
<span>
作答时长:{{ formatDuration(paperRecordDetail?.costTime) ?? '-' }}
</span>
</div>
<div v-if="paperRecordDetail?.remark" class="paper-remark">
备注:{{ paperRecordDetail.remark }}
</div>
</div>
<div v-if="Array.isArray(paperRecordDetail?.titleVoList)">
<div
v-for="(item, idx) in paperRecordDetail.titleVoList"
:key="item.itemId ?? item.id ?? idx"
class="question-detail-block"
:class="{ 'is-subjective': isSubjectiveItem(item) }"
>
<!-- 1. 题目 -->
<PaperQuestionItem
:index="item.sort != null ? Number(item.sort) : Number(idx) + 1"
:question="{
...item,
title: item.title,
optionList: item.optionList,
titleAnalyze: item.titleAnalyze,
answer: item.answer,
}"
:preview="true"
/>
<!-- === 客观题:保持原有简洁展示 === -->
<template v-if="!isSubjectiveItem(item)">
<div class="student-answer">
<span class="label">学生答案:</span>
<span class="content">{{ item.submitAnswer || '-' }}</span>
</div>
<div class="system-mark">
<span class="label">系统批改结果:</span>
<span class="content">
<span :class="getCorrectClass(item.actualScore, item.score)">
{{ getCorrectLabel(item.actualScore, item.score) }}
</span>
</span>
</div>
<div class="final-score">
<span class="label">最终得分:</span>
<span class="content">
{{
typeof item.actualScore === 'number' ? item.actualScore : '-'
}}
</span>
</div>
</template>
<!-- === 主观题AI 批改 + 老师复核布局 === -->
<template v-else>
<!-- 2. 学生完整回答(高亮关键词) -->
<div class="subjective-answer-section">
<div class="section-label">
<span class="dot"></span>
学生回答
</div>
<!-- eslint-disable-next-line vue/no-v-html -->
<div
class="answer-body"
v-html="
highlightKeywords(
formatParagraphs(item.submitAnswer || '未作答'),
getAiKeywords(item),
)
"
></div>
</div>
<!-- 答案图片 -->
<div v-if="item.submitAnswerPic" class="student-answer-pics">
<span class="label">答案图片:</span>
<div class="pics">
<el-image
v-for="(pic, pidx) in String(item.submitAnswerPic)
.split(',')
.filter(Boolean)"
:key="pidx"
:src="pic"
fit="contain"
style="width: 120px; height: 90px; margin-right: 8px"
:preview-src-list="
String(item.submitAnswerPic).split(',').filter(Boolean)
"
/>
</div>
</div>
<!-- 3. AI 批改结论(卡片式突出显示) -->
<div
class="ai-grading-card"
:class="[`ai-card--${getAiVerdict(item).type}`]"
>
<div class="ai-card-header">
<span class="ai-card-icon">📝</span>
<span class="ai-card-title">AI 批改结果</span>
</div>
<div class="ai-card-body">
<div class="ai-card-row verdict-row">
<span class="ai-row-label">判定:</span>
<span
class="ai-verdict-badge"
:class="[`badge--${getAiVerdict(item).type}`]"
>
{{ getAiVerdict(item).icon }}
{{ getAiVerdict(item).label }}
</span>
</div>
<div class="ai-card-row score-row">
<span class="ai-row-label">得分:</span>
<span class="ai-score-display">
<span class="ai-actual-score">
{{
typeof item.actualScore === 'number'
? item.actualScore
: '-'
}}
</span>
<span class="ai-score-sep">/</span>
<span class="ai-max-score">
{{ item.score ?? '-' }}
</span>
</span>
</div>
<div v-if="getAiReason(item)" class="ai-card-row reason-row">
<span class="ai-row-label">AI 判据:</span>
<!-- eslint-disable-next-line vue/no-v-html -->
<div
class="ai-reason-markdown"
v-html="renderAiReasonMarkdown(item)"
></div>
</div>
<div
v-if="getAiKeywords(item).length"
class="ai-card-row keywords-row"
>
<span class="ai-row-label">AI 识别关键词:</span>
<span class="ai-keywords">
<el-tag
v-for="kw in getAiKeywords(item)"
:key="kw"
size="small"
effect="plain"
class="kw-tag"
>
{{ kw }}
</el-tag>
</span>
</div>
</div>
</div>
<!-- 老师评语(如果已修正) -->
<div v-if="item.teacherAnalyze" class="teacher-remark">
<span class="teacher-remark-label">🧑‍🏫 老师评语:</span>
<span class="teacher-remark-text">
{{ item.teacherAnalyze }}
</span>
</div>
<!-- 4. 修正按钮 -->
<div class="correction-action">
<el-button
type="warning"
plain
size="large"
class="correction-btn"
@click="openCorrectionDialog(item, Number(idx))"
>
✏️ 人工修正
</el-button>
</div>
</template>
<!-- 客观题图片 -->
<div
v-if="!isSubjectiveItem(item) && item.submitAnswerPic"
class="student-answer-pics"
>
<span class="label">答案图片:</span>
<div class="pics">
<el-image
v-for="(pic, pidx) in String(item.submitAnswerPic)
.split(',')
.filter(Boolean)"
:key="pidx"
:src="pic"
fit="contain"
style="width: 120px; height: 90px; margin-right: 8px"
:preview-src-list="
String(item.submitAnswerPic).split(',').filter(Boolean)
"
/>
</div>
</div>
</div>
</div>
<div v-else>暂无答题详情</div>
</el-dialog>
<!-- 人工修正弹框 -->
<el-dialog
v-model="correctionDialogVisible"
title="人工修正"
:width="correctionPicList.length ? '780px' : '520px'"
align-center
destroy-on-close
append-to-body
>
<el-form label-width="100px" label-position="top" class="correction-form">
<el-form-item label="判定结果">
<el-radio-group
v-model="correctionForm.correctResult"
size="large"
@change="onCorrectionResultChange"
>
<el-radio-button :value="1">✅ 正确</el-radio-button>
<el-radio-button :value="0"> 错误</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="修改分数">
<el-input-number
v-model="correctionForm.actualScore"
:min="0"
:max="correctionForm.maxScore"
:step="0.5"
:precision="1"
controls-position="right"
style="width: 200px"
/>
<span class="max-score-hint">
满分 {{ correctionForm.maxScore }}
</span>
</el-form-item>
<!-- 学生提交图片 · 涂鸦修改 -->
<el-form-item
v-if="correctionPicList.length"
label="学生提交图片(点击可涂鸦批注)"
>
<div class="pic-edit-grid">
<div
v-for="(pic, pidx) in correctionPicList"
:key="pidx"
class="pic-edit-item"
@click="openAnnotator(pic, pidx)"
>
<el-image :src="pic" fit="contain" class="pic-edit-thumb" />
<div class="pic-edit-overlay">
<span> 涂鸦批注</span>
</div>
</div>
</div>
</el-form-item>
<el-form-item label="填写 / 修改评语">
<el-input
v-model="correctionForm.teacherAnalyze"
type="textarea"
:rows="3"
placeholder="请输入评语(选填)"
maxlength="500"
show-word-limit
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="correctionDialogVisible = false"> </el-button>
<el-button
type="primary"
:loading="correctionLoading"
@click="submitCorrection"
>
保存修正
</el-button>
</template>
</el-dialog>
<!-- 图片涂鸦弹框 -->
<el-dialog
v-model="annotatorVisible"
title="图片涂鸦批注"
width="900px"
align-center
destroy-on-close
append-to-body
:close-on-click-modal="false"
@closed="closeAnnotator"
>
<ImageAnnotator
v-if="annotatorVisible && annotatorSrc"
:src="annotatorSrc"
@save="onAnnotatorSave"
@cancel="onAnnotatorCancel"
/>
<div v-if="annotatorUploading" class="annotator-uploading-mask">
<span class="uploading-spinner"></span>
<span>正在上传...</span>
</div>
</el-dialog>
</div>
</template>
<style scoped>
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.title {
width: 100%;
margin-top: 24px;
margin-bottom: 16px;
font-size: 20px;
font-weight: bold;
text-align: center;
}
.student-status-text {
font-weight: 600;
}
.student-status-text--unsubmitted,
.student-status-text--fail {
color: #f56c6c;
}
.student-status-text--ungraded {
color: #909399;
}
.student-status-text--excellent {
color: #67c23a;
}
.student-status-text--good {
color: #409eff;
}
.student-status-text--pass {
color: #e6a23c;
}
.student-status-text--default {
color: #606266;
}
:deep(.student-row--unsubmitted > td) {
color: #f56c6c;
background-color: #fef0f0 !important;
}
:deep(.student-row--ungraded > td) {
background-color: #f4f4f5 !important;
}
:deep(.student-row--excellent > td) {
background-color: #f0f9eb !important;
}
:deep(.student-row--good > td) {
background-color: #ecf5ff !important;
}
:deep(.student-row--pass > td) {
background-color: #fdf6ec !important;
}
:deep(.student-row--fail > td) {
background-color: #fef0f0 !important;
}
/* ========== 弹框基础 ========== */
.paper-info {
padding: 10px 12px;
margin: 0 0 12px;
background-color: #f8fafc;
border: 1px solid #e6e8eb;
border-radius: 10px;
}
.paper-title {
margin-bottom: 6px;
font-size: 18px;
font-weight: 700;
color: #1f2328;
}
.paper-meta {
display: flex;
gap: 20px;
font-size: 16px;
color: #344054;
}
.paper-remark {
margin-top: 6px;
font-size: 14px;
color: #667085;
}
/* ========== 每道题的大块 ========== */
.question-detail-block {
position: relative;
padding: 16px 20px;
margin: 16px 0 24px;
overflow: hidden;
background-color: #fff;
border: 1px solid #e6e8eb;
border-radius: 10px;
box-shadow: 0 4px 12px rgb(31 35 40 / 6%);
}
.question-detail-block::before {
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
content: '';
background: #246cff;
}
.question-detail-block.is-subjective::before {
background: linear-gradient(180deg, #246cff 0%, #7c3aed 100%);
}
/* ========== 客观题原始样式 ========== */
.student-answer {
display: flex;
gap: 8px;
align-items: flex-start;
padding: 12px 0 4px;
font-size: 16px;
line-height: 1.8;
border-top: 1px dashed #dcdcdc;
}
.student-answer .label {
min-width: 110px;
font-weight: 600;
color: #246cff;
}
.student-answer .content {
flex: 1;
color: #1f2328;
}
.system-mark,
.final-score {
display: flex;
gap: 8px;
align-items: center;
padding: 8px 0 0;
font-size: 16px;
line-height: 1.8;
}
.system-mark .label,
.final-score .label {
min-width: 110px;
font-weight: 600;
color: #246cff;
}
.mark-success {
font-size: 16px;
font-weight: 700;
color: #67c23a;
}
.mark-partial {
font-size: 16px;
font-weight: 700;
color: #e6a23c;
}
.mark-error {
font-size: 16px;
font-weight: 700;
color: #f56c6c;
}
.student-answer-pics {
display: flex;
gap: 8px;
align-items: center;
padding: 6px 0 0;
}
.student-answer-pics .label {
color: #246cff;
}
.student-answer-pics .pics {
display: flex;
align-items: center;
}
/* ========== 主观题 · 学生回答区 ========== */
.subjective-answer-section {
padding: 14px 0 6px;
margin-top: 8px;
border-top: 1px dashed #dcdcdc;
}
.section-label {
display: flex;
gap: 6px;
align-items: center;
margin-bottom: 8px;
font-size: 15px;
font-weight: 600;
color: #344054;
}
.section-label .dot {
display: inline-block;
width: 6px;
height: 6px;
background-color: #246cff;
border-radius: 50%;
}
.answer-body {
padding: 12px 16px;
font-size: 15px;
line-height: 1.9;
color: #1f2328;
background-color: #fafbfc;
border: 1px solid #eef0f2;
border-radius: 8px;
}
.answer-body :deep(.answer-paragraph) {
margin: 0 0 6px;
}
.answer-body :deep(.answer-paragraph:last-child) {
margin-bottom: 0;
}
.answer-body :deep(.kw-highlight) {
padding: 1px 3px;
font-weight: 600;
color: #92400e;
background-color: #fef3c7;
border-radius: 3px;
}
/* ========== AI 批改卡片 ========== */
.ai-grading-card {
margin: 14px 0 10px;
overflow: hidden;
border: 2px solid #e6e8eb;
border-radius: 12px;
transition: border-color 0.2s;
}
.ai-card--success {
border-color: #b7eb8f;
}
.ai-card--warning {
border-color: #ffe58f;
}
.ai-card--danger {
border-color: #ffa39e;
}
.ai-card--info {
border-color: #d9d9d9;
}
.ai-card-header {
display: flex;
gap: 6px;
align-items: center;
padding: 10px 16px;
font-size: 15px;
font-weight: 700;
color: #1f2328;
background-color: #f6f8fa;
border-bottom: 1px solid #eef0f2;
}
.ai-card--success .ai-card-header {
background-color: #f6ffed;
}
.ai-card--warning .ai-card-header {
background-color: #fffbe6;
}
.ai-card--danger .ai-card-header {
background-color: #fff2f0;
}
.ai-card-icon {
font-size: 18px;
}
.ai-card-body {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px 16px;
}
.ai-card-row {
display: flex;
gap: 8px;
align-items: center;
font-size: 15px;
}
.reason-row {
align-items: flex-start;
}
.ai-row-label {
min-width: 120px;
font-weight: 600;
color: #667085;
white-space: nowrap;
}
.ai-verdict-badge {
display: inline-flex;
align-items: center;
padding: 3px 14px;
font-size: 16px;
font-weight: 700;
border-radius: 6px;
}
.badge--success {
color: #389e0d;
background-color: #f6ffed;
border: 1px solid #b7eb8f;
}
.badge--warning {
color: #d48806;
background-color: #fffbe6;
border: 1px solid #ffe58f;
}
.badge--danger {
color: #cf1322;
background-color: #fff2f0;
border: 1px solid #ffa39e;
}
.badge--info {
color: #8c8c8c;
background-color: #fafafa;
border: 1px solid #d9d9d9;
}
.ai-score-display {
display: flex;
gap: 2px;
align-items: baseline;
font-size: 22px;
font-weight: 700;
}
.ai-actual-score {
color: #246cff;
}
.ai-score-sep {
color: #c0c4cc;
}
.ai-max-score {
font-size: 16px;
color: #909399;
}
.ai-reason-markdown {
flex: 1;
line-height: 1.8;
color: #1f2328;
overflow-wrap: anywhere;
}
.ai-reason-markdown :deep(p),
.ai-reason-markdown :deep(ul),
.ai-reason-markdown :deep(ol),
.ai-reason-markdown :deep(blockquote),
.ai-reason-markdown :deep(pre) {
margin: 0 0 8px;
}
.ai-reason-markdown :deep(p:last-child),
.ai-reason-markdown :deep(ul:last-child),
.ai-reason-markdown :deep(ol:last-child),
.ai-reason-markdown :deep(blockquote:last-child),
.ai-reason-markdown :deep(pre:last-child) {
margin-bottom: 0;
}
.ai-reason-markdown :deep(ul),
.ai-reason-markdown :deep(ol) {
padding-left: 20px;
}
.ai-reason-markdown :deep(li + li) {
margin-top: 4px;
}
.ai-reason-markdown :deep(blockquote) {
padding: 8px 12px;
color: #475467;
background-color: #f8fafc;
border-left: 4px solid #d0d5dd;
border-radius: 6px;
}
.ai-reason-markdown :deep(code) {
padding: 2px 6px;
font-size: 13px;
background-color: #f2f4f7;
border-radius: 4px;
}
.ai-reason-markdown :deep(pre) {
padding: 12px;
overflow-x: auto;
background-color: #101828;
border-radius: 8px;
}
.ai-reason-markdown :deep(pre code) {
padding: 0;
color: #f9fafb;
background-color: transparent;
}
.ai-reason-markdown :deep(a) {
color: #246cff;
text-decoration: none;
}
.ai-reason-markdown :deep(a:hover) {
text-decoration: underline;
}
.ai-keywords {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.kw-tag {
font-weight: 500;
border-color: #d9d9d9;
}
/* ========== 老师评语 ========== */
.teacher-remark {
display: flex;
gap: 8px;
align-items: flex-start;
padding: 10px 14px;
margin: 6px 0 10px;
font-size: 14px;
line-height: 1.7;
background-color: #f0f9ff;
border: 1px solid #bae0ff;
border-radius: 8px;
}
.teacher-remark-label {
font-weight: 600;
color: #0958d9;
white-space: nowrap;
}
.teacher-remark-text {
color: #1f2328;
}
/* ========== 修正按钮 ========== */
.correction-action {
display: flex;
justify-content: flex-end;
padding-top: 10px;
}
.correction-btn {
min-width: 130px;
font-size: 15px;
font-weight: 600;
}
/* ========== 修正弹框 ========== */
.correction-form :deep(.el-form-item__label) {
font-weight: 600;
color: #1f2328;
}
.max-score-hint {
margin-left: 12px;
font-size: 14px;
color: #909399;
}
/* ========== 图片涂鸦编辑 ========== */
.pic-edit-grid {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.pic-edit-item {
position: relative;
width: 160px;
height: 120px;
overflow: hidden;
cursor: pointer;
border: 2px solid #e6e8eb;
border-radius: 8px;
transition: all 0.2s;
}
.pic-edit-item:hover {
border-color: #246cff;
box-shadow: 0 2px 12px rgb(36 108 255 / 20%);
}
.pic-edit-item:hover .pic-edit-overlay {
opacity: 1;
}
.pic-edit-thumb {
width: 100%;
height: 100%;
}
.pic-edit-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 600;
color: #fff;
background-color: rgb(0 0 0 / 45%);
opacity: 0;
transition: opacity 0.2s;
}
.annotator-uploading-mask {
position: absolute;
inset: 0;
z-index: 10;
display: flex;
flex-direction: column;
gap: 10px;
align-items: center;
justify-content: center;
font-size: 16px;
color: #246cff;
background-color: rgb(255 255 255 / 80%);
}
.uploading-spinner {
display: inline-block;
width: 28px;
height: 28px;
border: 3px solid #e6e8eb;
border-top-color: #246cff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
/* ========== PaperQuestionItem 覆盖 ========== */
:deep(.paper-question-item .preview-toolbar) {
display: none !important;
}
:deep(.paper-question-item) {
padding: 6px 8px;
margin-top: 10px;
border: 1px dashed #e6e8eb;
border-radius: 6px;
}
</style>