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
1447 lines
36 KiB
Vue
1447 lines
36 KiB
Vue
<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>
|