<script lang="ts" setup>
|
import type { GradeExamDetail, GradeExamItem } from './types';
|
|
import { computed, onMounted, reactive, ref } from 'vue';
|
import { useRoute, useRouter } from 'vue-router';
|
|
import { useMessage } from '@jnpf/hooks';
|
|
import { getGradeExamDetail, submitGrade } from '#/api/x/tms/examGrade';
|
import { labelOfType } from '#/views/x/tms/question/constants';
|
|
import { colorOfGradeStatus, labelOfGradeStatus } from './constants';
|
|
defineOptions({ name: 'TmsExamGradeMark' });
|
|
const route = useRoute();
|
const router = useRouter();
|
const { createMessage } = useMessage();
|
|
const loading = ref(false);
|
const saving = ref(false);
|
const detail = ref<GradeExamDetail | null>(null);
|
const scoreMap = reactive<Record<string, number | undefined>>({});
|
|
const readonly = computed(() => detail.value?.gradeStatus === 'graded');
|
|
const subjectiveTotal = computed(() => {
|
if (!detail.value) return 0;
|
return detail.value.items
|
.filter((x) => x.isSubjective === '1')
|
.reduce((sum, x) => sum + Number(scoreMap[x.id] ?? 0), 0);
|
});
|
|
const previewTotal = computed(() => {
|
if (!detail.value) return 0;
|
return Number(detail.value.objectiveScore || 0) + subjectiveTotal.value;
|
});
|
|
onMounted(() => {
|
loadDetail();
|
});
|
|
async function loadDetail() {
|
const id = String(route.params.id || '');
|
if (!id) {
|
router.replace('/tms/examGrade');
|
return;
|
}
|
loading.value = true;
|
try {
|
detail.value = await getGradeExamDetail(id);
|
detail.value.items.forEach((it) => {
|
if (it.isSubjective === '1') {
|
scoreMap[it.id] = it.gotScore;
|
}
|
});
|
} catch (e: any) {
|
createMessage.error(e?.message || '加载答卷失败');
|
router.back();
|
} finally {
|
loading.value = false;
|
}
|
}
|
|
function stripHtml(html?: string) {
|
if (!html) return '';
|
return html.replace(/<[^>]+>/g, '').replace(/ /g, ' ').trim();
|
}
|
|
function goBack() {
|
if (detail.value?.sessionId) {
|
router.push(`/tms/examGrade/session/${detail.value.sessionId}`);
|
} else {
|
router.push('/tms/examGrade');
|
}
|
}
|
|
function validateScores(): string | null {
|
if (!detail.value) return '答卷不存在';
|
for (const it of detail.value.items) {
|
if (it.isSubjective !== '1') continue;
|
const v = scoreMap[it.id];
|
if (v === undefined || v === null || Number.isNaN(Number(v))) {
|
return '请为所有主观题打分';
|
}
|
if (Number(v) < 0 || Number(v) > Number(it.score)) {
|
return `主观题得分需在 0 ~ ${it.score} 之间`;
|
}
|
}
|
return null;
|
}
|
|
async function handleSubmit() {
|
if (!detail.value || readonly.value) return;
|
const err = validateScores();
|
if (err) {
|
createMessage.warning(err);
|
return;
|
}
|
saving.value = true;
|
try {
|
const items = detail.value.items
|
.filter((x) => x.isSubjective === '1')
|
.map((x) => ({ id: x.id, gotScore: Number(scoreMap[x.id] || 0) }));
|
await submitGrade({ examId: detail.value.examId, items });
|
createMessage.success(`阅卷完成,总分 ${previewTotal.value}`);
|
goBack();
|
} catch (e: any) {
|
createMessage.error(e?.message || '提交失败');
|
} finally {
|
saving.value = false;
|
}
|
}
|
|
function itemClass(it: GradeExamItem) {
|
return it.isSubjective === '1' ? 'grade-item subjective' : 'grade-item';
|
}
|
</script>
|
|
<template>
|
<div class="jnpf-content-wrapper tms-grade-page">
|
<div class="jnpf-content-wrapper-center tms-grade-center">
|
<div class="jnpf-content-wrapper-content tms-grade-mark-wrap">
|
<div class="mark-top">
|
<div class="mark-header">
|
<div>
|
<div class="text-base font-medium">
|
{{ detail?.paperName || '阅卷' }} · 阅卷
|
</div>
|
<div v-if="detail" class="mt-1 text-gray-400 text-sm">
|
考生:{{ detail.userName }}
|
<span v-if="detail.deptName">({{ detail.deptName }})</span>
|
<span class="ml-3">交卷:{{ detail.submitTime || '-' }}</span>
|
<span class="ml-3" :style="{ color: colorOfGradeStatus(detail.gradeStatus) }">
|
{{ labelOfGradeStatus(detail.gradeStatus) }}
|
</span>
|
</div>
|
</div>
|
<a-space>
|
<a-button @click="goBack">返回</a-button>
|
<a-button v-if="detail && !readonly" type="primary" :loading="saving" @click="handleSubmit">
|
提交阅卷
|
</a-button>
|
</a-space>
|
</div>
|
|
<div v-if="detail" class="score-bar">
|
<span>客观题 {{ detail.objectiveScore }} 分</span>
|
<span class="mx-3">主观题 {{ subjectiveTotal }} 分</span>
|
<span>
|
合计
|
<b class="text-primary">{{ previewTotal }}</b>
|
/ {{ detail.totalScore }}(合格 {{ detail.passScore }})
|
</span>
|
</div>
|
</div>
|
|
<div class="mark-body">
|
<a-spin :spinning="loading">
|
<template v-if="detail">
|
<div
|
v-for="(it, idx) in detail.items"
|
:key="it.id"
|
:class="itemClass(it)"
|
>
|
<div class="mb-2 text-sm text-gray-500">
|
第 {{ idx + 1 }} 题 · {{ labelOfType(it.questionType) }}
|
({{ it.score }} 分)
|
<span v-if="it.isSubjective === '1'" class="text-orange-500 ml-2">主观题</span>
|
</div>
|
<div class="stem mb-3">{{ stripHtml(it.stem) }}</div>
|
|
<div v-if="it.options?.length" class="mb-2 text-sm text-gray-600">
|
<div v-for="opt in it.options" :key="opt.optionLabel">
|
{{ opt.optionLabel }}. {{ opt.optionContent }}
|
</div>
|
</div>
|
|
<div class="answer-row text-sm">
|
<div>考生答案:{{ it.userAnswer || '(未作答)' }}</div>
|
<div v-if="it.isSubjective !== '1'">正确答案:{{ it.correctAnswer || '-' }}</div>
|
<div v-if="it.analysis" class="text-gray-400">解析:{{ it.analysis }}</div>
|
</div>
|
|
<div class="mt-3 flex items-center gap-2">
|
<template v-if="it.isSubjective === '1'">
|
<span>得分</span>
|
<a-input-number
|
v-model:value="scoreMap[it.id]"
|
:min="0"
|
:max="it.score"
|
:precision="1"
|
:disabled="readonly"
|
class="!w-[120px]"
|
/>
|
<span class="text-gray-400">/ {{ it.score }}</span>
|
</template>
|
<template v-else>
|
<span class="text-gray-500">自动得分:{{ it.gotScore ?? 0 }}</span>
|
</template>
|
</div>
|
</div>
|
</template>
|
</a-spin>
|
</div>
|
</div>
|
</div>
|
</div>
|
</template>
|
|
<style scoped>
|
/* 全局 jnpf-content-wrapper* 为 overflow:hidden 且无 min-height:0,必须整条链补齐 */
|
.tms-grade-page {
|
min-height: 0;
|
}
|
|
.tms-grade-center {
|
min-height: 0 !important;
|
}
|
|
.tms-grade-mark-wrap {
|
display: flex !important;
|
flex-direction: column;
|
flex: 1 1 0 !important;
|
min-height: 0 !important;
|
height: auto !important;
|
overflow: hidden !important;
|
background: #fff;
|
padding: 0;
|
}
|
|
.mark-top {
|
flex-shrink: 0;
|
padding: 16px 20px 0;
|
}
|
|
.mark-header {
|
display: flex;
|
justify-content: space-between;
|
align-items: center;
|
padding-bottom: 12px;
|
margin-bottom: 12px;
|
border-bottom: 1px solid #f0f0f0;
|
}
|
|
.score-bar {
|
background: #fafafa;
|
border-radius: 6px;
|
padding: 10px 14px;
|
margin-bottom: 12px;
|
font-size: 14px;
|
}
|
|
.mark-body {
|
flex: 1 1 0;
|
min-height: 0;
|
overflow-y: auto !important;
|
overflow-x: hidden;
|
padding: 0 20px 32px;
|
-webkit-overflow-scrolling: touch;
|
}
|
|
.grade-item {
|
border: 1px solid #f0f0f0;
|
border-radius: 8px;
|
padding: 14px 16px;
|
margin-bottom: 12px;
|
}
|
|
.grade-item.subjective {
|
border-color: #ffd591;
|
background: #fffbe6;
|
}
|
|
.stem {
|
font-size: 15px;
|
line-height: 1.7;
|
}
|
|
.answer-row {
|
display: flex;
|
flex-direction: column;
|
gap: 4px;
|
color: rgba(0, 0, 0, 0.75);
|
}
|
</style>
|