feat: 阅卷质量

This commit is contained in:
小柚 2025-08-19 20:46:10 +08:00
parent ef1acb07d1
commit 613606716c
5 changed files with 233 additions and 294 deletions

View File

@ -1,12 +1,19 @@
<!-- 题目选择组件 -->
<script lang="ts" setup>
import { DictCode, useDict } from '@/composables/useDict'
import { computed } from 'vue'
import { DictCode, useDict } from '@/composables/useDict'
defineOptions({
name: 'QuestionTabs',
})
const props = withDefaults(defineProps<Props>(), {
loading: false,
questionTabs: () => [],
})
const emit = defineEmits<Emits>()
//
export interface QuestionTabItem {
/** 题目ID */
@ -14,7 +21,7 @@ export interface QuestionTabItem {
/** 题目名称 */
name: string
/** 评价方法 */
type: string
evaluate_method: string
/** 大题号 */
questionMajor?: string
/** 小题号 */
@ -39,24 +46,14 @@ interface Props {
loading?: boolean
/** 错误状态 */
error?: any
/** 是否显示题目详情 */
showDetails?: boolean
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
questionTabs: () => [],
showDetails: true,
})
// Emits
interface Emits {
(e: 'change', questionId: number): void
(e: 'retry'): void
}
const emit = defineEmits<Emits>()
const { getDictOptionsAndGetLabel } = useDict()
const [, getEvaluateMethodLabel] = getDictOptionsAndGetLabel(DictCode.EVALUATE_METHOD)
@ -109,47 +106,8 @@ function handleTabChange(tabInfo: { index: number, name: string }) {
v-for="question in questionTabs"
:key="question.id"
:name="`question-${question.id}`"
:title="`${question.name} (${getEvaluateMethodLabel(question.type)})`"
>
<!-- Tab 内容显示当前选中题目的详情 -->
<view v-if="showDetails && currentQuestion" class="p-4">
<!-- 题目详情 -->
<view class="mb-4">
<text class="mb-2 block text-lg font-semibold">题目详情</text>
<!-- 分数统计 -->
<view class="grid grid-cols-4 gap-3">
<view class="rounded-xl bg-blue-50 p-3 text-center">
<text class="block text-lg font-semibold text-blue-600">
{{ currentQuestion.totalScore || 0 }}
</text>
<text class="block text-xs text-gray-500">总分</text>
</view>
<view class="rounded-xl bg-green-50 p-3 text-center">
<text class="block text-lg font-semibold text-green-600">
{{ currentQuestion.averageScore?.toFixed(1) || 0 }}
</text>
<text class="block text-xs text-gray-500">平均分</text>
</view>
<view class="rounded-xl bg-orange-50 p-3 text-center">
<text class="block text-lg font-semibold text-orange-600">
{{ currentQuestion.maxScore || 0 }}
</text>
<text class="block text-xs text-gray-500">最高分</text>
</view>
<view class="rounded-xl bg-red-50 p-3 text-center">
<text class="block text-lg font-semibold text-red-600">
{{ currentQuestion.minScore || 0 }}
</text>
<text class="block text-xs text-gray-500">最低分</text>
</view>
</view>
</view>
</view>
</wd-tab>
:title="`${question.name} (${getEvaluateMethodLabel(question.evaluate_method)})`"
/>
</wd-tabs>
</view>

View File

@ -92,10 +92,7 @@
"path": "pages/marking/grading",
"type": "page",
"style": {
"navigationStyle": "custom",
"enablePullDownRefresh": false,
"disableScroll": true,
"bounce": "none"
"navigationStyle": "custom"
}
},
{

View File

@ -27,9 +27,6 @@ const questionId = ref<number>()
// ID
const expandedSchools = ref<Set<number>>(new Set())
const { getDictOptionsAndGetLabel } = useDict()
const [, getEvaluateMethodLabel] = getDictOptionsAndGetLabel(DictCode.EVALUATE_METHOD)
//
onLoad((options) => {
if (options.examSubjectId) {
@ -68,8 +65,8 @@ const questionTabs = computed(() => {
const questions = allProgressResponse.value?.questions || []
return questions.map(question => ({
id: question.question_id!,
name: `题目${question.question_major}${question.question_minor ? `.${question.question_minor}` : ''}`,
type: question.evaluate_method || '单评',
name: `${question.question_major}${question.question_minor ? `.${question.question_minor}` : ''}`,
evaluate_method: question.evaluate_method,
questionMajor: question.question_major,
questionMinor: question.question_minor,
}))
@ -89,7 +86,7 @@ const {
error: errorQuestion,
refetch: refetchQuestion,
} = useQuery({
queryKey: ['question-marking-progress', questionId],
queryKey: computed(() => ['question-marking-progress', questionId.value]),
queryFn: () => markingProgressQuestionUsingGet({
params: {
question_id: questionId.value!,
@ -145,6 +142,9 @@ function getTaskTypeDisplayName(taskType: string) {
}
}
const { getDictOptionsAndGetLabel } = useDict()
const [, getTaskTypeLabel] = getDictOptionsAndGetLabel(DictCode.TASK_TYPE)
//
function handleRefresh() {
refetchAll()
@ -170,7 +170,6 @@ const error = computed(() => errorAll.value || errorQuestion.value)
:active-question-id="questionId"
:loading="isLoadingAll"
:error="errorAll"
:show-details="false"
@change="handleQuestionChange"
@retry="handleRetry"
/>
@ -200,32 +199,35 @@ const error = computed(() => errorAll.value || errorQuestion.value)
class="space-y-3"
>
<!-- 任务类型标题 -->
<view class="mb-4">
<text class="mb-2 block text-lg font-semibold">
{{ getTaskTypeDisplayName(taskProgress.task_type || '') }}
</text>
<!-- 整体进度 -->
<view class="mb-2 flex items-center justify-between">
<text class="text-sm text-gray-600">
已阅{{ taskProgress.marked_quantity || 0 }}
待阅{{ (taskProgress.total_quantity || 0) - (taskProgress.marked_quantity || 0) }}
</text>
<text class="text-sm text-blue-600 font-medium">
{{ getProgressPercentage(taskProgress.marked_quantity, taskProgress.total_quantity) }}%
<view class="mb-4 rounded-md bg-white p-4 shadow-sm">
<view class="mb-4 flex items-center justify-between">
<text class="block text-base font-semibold">
{{ `${getTaskTypeLabel(taskProgress.task_type || '')}进度` }}
</text>
<view class="flex items-center gap-2">
<view
class="rounded-full bg-blue-100 px-2 py-1 text-xs"
>
已阅{{ taskProgress.marked_quantity || 0 }}
</view>
<view
class="rounded-full bg-orange-100 px-2 py-1 text-xs"
>
待阅{{ (taskProgress.total_quantity || 0) - (taskProgress.marked_quantity || 0) }}
</view>
</view>
</view>
<!-- 进度条 -->
<view class="h-2 w-full rounded-full bg-gray-200">
<view
class="h-2 rounded-full bg-blue-500 transition-all duration-300"
:style="{ width: `${getProgressPercentage(taskProgress.marked_quantity, taskProgress.total_quantity)}%` }"
/>
</view>
<view class="mt-1 flex justify-center">
<text class="text-xs text-gray-500">
{{ taskProgress.marked_quantity || 0 }}/{{ taskProgress.total_quantity || 0 }}
<view class="flex items-center justify-between gap-2">
<view class="w-full flex items-center justify-between rounded-full bg-gray-200">
<view
class="h-4 rounded-full bg-blue-500 transition-all duration-300"
:style="{ width: `${getProgressPercentage(taskProgress.marked_quantity, taskProgress.total_quantity)}%` }"
/>
</view>
<text class="text-sm text-blue-600 font-medium">
{{ getProgressPercentage(taskProgress.marked_quantity, taskProgress.total_quantity) }}%
</text>
</view>
</view>

View File

@ -8,18 +8,12 @@
</route>
<script lang="ts" setup>
import type {
GetQuestionMarkingQualityResponse,
MarkingQualityResponse,
QuestionMarkingQualitySchool,
QuestionMarkingQualityTeacher,
} from '@/service/types'
import type { GetQuestionMarkingQualityResponse, MarkingQualityResponse, QuestionMarkingQualityTeacher } from '@/service/types'
import { onLoad } from '@dcloudio/uni-app'
import { useQuery } from '@tanstack/vue-query'
import { computed, ref, watch } from 'vue'
import QuestionTabs from '@/components/marking/QuestionTabs.vue'
import { DictCode, useDict } from '@/composables/useDict'
import { markingQualityQuestionQualityUsingGet, markingQualityUsingGet } from '@/service/yuejuanzhiliang'
import { markingQualityQuestionUsingGet, markingQualityUsingGet } from '@/service/yuejuanzhiliang'
defineOptions({
name: 'MarkingQualityPage',
@ -29,11 +23,8 @@ defineOptions({
const examSubjectId = ref<number>()
const questionId = ref<number>()
//
const activeTeacherType = ref<'first' | 'final'>('first')
const { getDictOptionsAndGetLabel } = useDict()
const [, getEvaluateMethodLabel] = getDictOptionsAndGetLabel(DictCode.EVALUATE_METHOD)
// /
const activeEvaluateType = ref<'initial' | 'final'>('initial')
//
onLoad((options) => {
@ -45,12 +36,12 @@ onLoad((options) => {
}
})
//
//
const {
data: qualityData,
isLoading: isLoadingQuality,
error: errorQuality,
refetch: refetchQuality,
data: allQualityData,
isLoading: isLoadingAll,
error: errorAll,
refetch: refetchAll,
} = useQuery({
queryKey: ['marking-quality', examSubjectId],
queryFn: () => markingQualityUsingGet({
@ -63,135 +54,129 @@ const {
gcTime: 1000 * 60 * 30,
})
//
const qualityResponse = computed<MarkingQualityResponse | undefined>(() => {
return qualityData.value
//
const allQualityResponse = computed<MarkingQualityResponse>(() => {
return allQualityData.value
})
//
//
const questionTabs = computed(() => {
const questions = allQualityResponse.value?.subjective_item?.question_items || []
return questions.map(question => ({
id: question.question_id!,
name: `${question.question_major}${question.question_minor ? `.${question.question_minor}` : ''}`,
evaluate_method: question.evaluate_method,
questionMajor: question.question_major,
questionMinor: question.question_minor,
averageScore: question.average_score,
totalScore: question.total_score,
}))
})
//
watch(questionTabs, (newTabs) => {
if (newTabs.length > 0 && !questionId.value) {
questionId.value = newTabs[0].id
}
}, { immediate: true })
//
const {
data: allQuestionsData,
isLoading: isLoadingQuestions,
error: errorQuestions,
refetch: refetchQuestions,
data: questionQualityData,
isLoading: isLoadingQuestion,
error: errorQuestion,
refetch: refetchQuestion,
} = useQuery({
queryKey: ['marking-quality-questions', examSubjectId],
queryFn: async () => {
if (!qualityResponse.value?.subjective_item?.question_items)
return []
//
const promises = qualityResponse.value.subjective_item.question_items.map(item =>
markingQualityQuestionQualityUsingGet({
params: {
question_id: item.question_id!,
},
}),
)
return Promise.all(promises)
},
enabled: computed(() => !!qualityResponse.value?.subjective_item?.question_items?.length),
queryKey: computed(() => ['question-marking-quality', questionId.value]),
queryFn: () => markingQualityQuestionUsingGet({
params: {
question_id: questionId.value!,
},
}),
enabled: computed(() => !!questionId.value),
staleTime: 1000 * 60 * 5,
gcTime: 1000 * 60 * 30,
})
//
const questionsData = computed<GetQuestionMarkingQualityResponse[]>(() => {
return allQuestionsData.value || []
//
const questionQualityResponse = computed<GetQuestionMarkingQualityResponse | undefined>(() => {
return questionQualityData.value
})
// QuestionTabs
const questionTabsData = computed(() => {
return questionsData.value.map(question => ({
id: question.question_id!,
name: `题目${question.question_major}${question.question_minor ? `.${question.question_minor}` : ''}`,
type: question.evaluate_method || '单评',
questionMajor: question.question_major,
questionMinor: question.question_minor,
totalScore: question.total_score,
averageScore: question.average_score,
maxScore: question.max_score,
minScore: question.min_score,
}))
//
const currentQuestion = computed(() => {
return questionTabs.value.find(q => q.id === questionId.value)
})
//
const currentQuestionData = computed<GetQuestionMarkingQualityResponse | undefined>(() => {
if (!questionsData.value.length)
return undefined
if (questionId.value) {
return questionsData.value.find(q => q.question_id === questionId.value)
}
//
return questionsData.value[0]
//
const initialTeachers = computed<QuestionMarkingQualityTeacher[]>(() => {
return questionQualityResponse.value?.initial_teachers || []
})
//
watch(questionsData, (newData) => {
if (newData.length > 0 && !questionId.value) {
questionId.value = newData[0].question_id
}
}, { immediate: true })
//
const schoolGroups = computed<QuestionMarkingQualitySchool[]>(() => {
return currentQuestionData.value?.school_groups || []
//
const finalTeachers = computed<QuestionMarkingQualityTeacher[]>(() => {
return questionQualityResponse.value?.final_teachers || []
})
//
const shouldShowFinalTeacher = computed(() => {
const evaluateMethod = currentQuestionData.value?.evaluate_method
return evaluateMethod === '双评' || evaluateMethod === '多评'
//
const currentTeachers = computed<QuestionMarkingQualityTeacher[]>(() => {
return activeEvaluateType.value === 'initial' ? initialTeachers.value : finalTeachers.value
})
//
function getCurrentTypeTeachers(school: QuestionMarkingQualitySchool): QuestionMarkingQualityTeacher[] {
if (!school.teachers)
return []
// API
// API
//
return school.teachers
}
//
const hasDoubleEvaluation = computed(() => {
return initialTeachers.value.length > 0 && finalTeachers.value.length > 0
})
//
function handleQuestionChange(newQuestionId: number) {
questionId.value = newQuestionId
// useQuery queryKey
}
//
function switchTeacherType(type: 'first' | 'final') {
activeTeacherType.value = type
//
function switchEvaluateType(type: 'initial' | 'final') {
activeEvaluateType.value = type
}
//
function handleRefresh() {
refetchQuality()
refetchQuestions()
refetchAll()
refetchQuestion()
}
function handleRetry() {
refetchAll()
}
//
const isLoading = computed(() => isLoadingQuality.value || isLoadingQuestions.value)
const isLoading = computed(() => isLoadingAll.value || isLoadingQuestion.value)
//
const error = computed(() => errorQuality.value || errorQuestions.value)
const error = computed(() => errorAll.value || errorQuestion.value)
//
function formatAverageTime(seconds?: number): string {
if (!seconds)
return '-'
//
function getTeacherLevelColor(score: number = 0, maxScore: number = 100) {
const percentage = (score / maxScore) * 100
if (percentage >= 90)
return 'text-green-600'
if (percentage >= 80)
return 'text-blue-600'
if (percentage >= 70)
return 'text-orange-600'
return 'text-red-600'
}
//
function formatScore(score: number = 0) {
return score.toFixed(1)
}
//
function formatTime(seconds: number = 0) {
const minutes = Math.floor(seconds / 60)
const remainingSeconds = Math.floor(seconds % 60)
if (minutes > 0) {
return `${minutes}${remainingSeconds}`
}
return `${remainingSeconds}`
const remainingSeconds = seconds % 60
return `${minutes}${remainingSeconds}`
}
</script>
@ -199,15 +184,15 @@ function formatAverageTime(seconds?: number): string {
<view class="marking-quality-page">
<!-- 题目选择组件 -->
<QuestionTabs
:question-tabs="questionTabsData"
:question-tabs="questionTabs"
:active-question-id="questionId"
:loading="isLoadingQuestions"
:error="errorQuestions"
:loading="isLoadingAll"
:error="errorAll"
@change="handleQuestionChange"
@retry="refetchQuestions"
@retry="handleRetry"
/>
<!-- 主体内容区域 -->
<!-- 质量内容区域 -->
<view class="p-4">
<!-- 加载状态 -->
<view v-if="isLoading" class="py-12 text-center">
@ -225,110 +210,107 @@ function formatAverageTime(seconds?: number): string {
<!-- 质量数据 -->
<view v-else class="space-y-4">
<!-- 初评老师/终评老师切换 -->
<view v-if="shouldShowFinalTeacher" class="rounded-2xl bg-white p-4">
<view class="flex">
<wd-button
:type="activeTeacherType === 'first' ? 'primary' : 'default'"
size="small"
class="mr-2"
@click="switchTeacherType('first')"
<!-- 题目详情 -->
<view class="mb-4 rounded-md bg-white p-4 shadow-sm">
<text class="mb-2 block text-base font-semibold">题目详情</text>
<view class="flex items-center justify-between gap-2">
<view
class="rounded-full bg-blue-100 px-3 py-1 text-xs"
>
初评老师
</wd-button>
<wd-button
:type="activeTeacherType === 'final' ? 'primary' : 'default'"
size="small"
@click="switchTeacherType('final')"
总分{{ questionQualityResponse?.total_score || 0 }}
</view>
<view
class="rounded-full bg-orange-100 px-3 py-1 text-xs"
>
终评老师
</wd-button>
平均分{{ questionQualityResponse?.average_score || 0 }}
</view>
<view
class="rounded-full bg-red-100 px-3 py-1 text-xs"
>
最高分{{ questionQualityResponse?.max_score || 0 }}
</view>
<view
class="rounded-full bg-green-100 px-3 py-1 text-xs"
>
最低分{{ questionQualityResponse?.min_score || 0 }}
</view>
</view>
</view>
<!-- 阅卷质量表格 -->
<view class="rounded-2xl bg-white p-4">
<!-- 表头 -->
<view class="mb-4">
<!-- 评选类型切换仅在有双评时显示 -->
<view v-if="hasDoubleEvaluation" class="border border-gray-100 rounded-2xl bg-white p-4 shadow-sm">
<view class="flex space-x-4">
<view
class="flex-1 cursor-pointer rounded-lg py-2 text-center transition-colors"
:class="activeEvaluateType === 'initial' ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 text-gray-600'"
@click="switchEvaluateType('initial')"
>
<text class="font-medium">初评老师</text>
</view>
<view
class="flex-1 cursor-pointer rounded-lg py-2 text-center transition-colors"
:class="activeEvaluateType === 'final' ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 text-gray-600'"
@click="switchEvaluateType('final')"
>
<text class="font-medium">终评老师</text>
</view>
</view>
</view>
<!-- 老师质量列表 -->
<view v-if="currentTeachers.length > 0" class="space-y-3">
<view class="mb-3">
<text class="text-lg font-semibold">
{{ shouldShowFinalTeacher && activeTeacherType === 'final' ? '终评老师' : '初评老师' }}
{{ hasDoubleEvaluation ? (activeEvaluateType === 'initial' ? '初评老师' : '终评老师') : '阅卷老师' }}
</text>
</view>
<!-- 表头行 -->
<view class="grid grid-cols-6 mb-3 gap-2 border-b border-gray-100 pb-3">
<text class="text-sm text-gray-600 font-medium">姓名</text>
<text class="text-sm text-gray-600 font-medium">阅卷量</text>
<text class="text-sm text-gray-600 font-medium">问题卷</text>
<text class="text-sm text-gray-600 font-medium">平均分</text>
<text class="text-sm text-gray-600 font-medium">平均用时</text>
<text class="text-sm text-gray-600 font-medium">标准差</text>
</view>
<!-- 学校列表 -->
<view class="space-y-4">
<view
v-for="school in schoolGroups"
:key="school.school_id"
class="space-y-2"
>
<!-- 学校名称 -->
<view class="py-2">
<text class="text-base text-gray-800 font-medium">
{{ school.school_name }}
</text>
</view>
<!-- 老师列表 -->
<view class="space-y-2">
<view
v-for="teacher in getCurrentTypeTeachers(school)"
:key="teacher.teacher_id"
class="grid grid-cols-6 gap-2 rounded-lg bg-gray-50 p-3"
>
<!-- 姓名 -->
<view class="flex items-center">
<text class="text-sm">{{ teacher.teacher_name }}</text>
</view>
<!-- 阅卷量 -->
<view class="flex items-center">
<text class="text-sm">{{ teacher.marking_count || 0 }}</text>
</view>
<!-- 问题卷 -->
<view class="flex items-center">
<text class="text-sm">{{ teacher.problem_num || 0 }}</text>
</view>
<!-- 平均分 -->
<view class="flex items-center">
<text class="text-sm">{{ teacher.average_score?.toFixed(1) || '-' }}</text>
</view>
<!-- 平均用时 -->
<view class="flex items-center">
<text class="text-sm">{{ formatAverageTime(teacher.average_time) }}</text>
</view>
<!-- 标准差 -->
<view class="flex items-center">
<text class="text-sm">{{ teacher.standard_dev?.toFixed(2) || '-' }}</text>
</view>
<view
v-for="(teacher, index) in currentTeachers"
:key="`${teacher.teacher_id}-${index}`"
class="border border-gray-100 rounded-2xl bg-white p-4 shadow-sm"
>
<!-- 老师基本信息 -->
<view class="mb-3 flex items-center justify-between">
<view class="flex items-center">
<view class="mr-3 h-3 w-3 rounded-full" :class="getTeacherLevelColor(teacher.average_score || 0, currentQuestion?.totalScore || 100)" />
<view>
<text class="text-base font-medium">{{ teacher.teacher_name || '未知老师' }}</text>
<text v-if="teacher.phone" class="block text-sm text-gray-500">{{ teacher.phone }}</text>
</view>
</view>
<view class="text-right">
<text class="block text-lg font-bold" :class="getTeacherLevelColor(teacher.average_score || 0, currentQuestion?.totalScore || 100)">
{{ formatScore(teacher.average_score || 0) }}
</text>
<text class="text-sm text-gray-500">平均分</text>
</view>
</view>
<!-- 没有老师数据 -->
<view v-if="getCurrentTypeTeachers(school).length === 0" class="py-4 text-center">
<text class="text-sm text-gray-500">暂无老师数据</text>
<!-- 老师详细统计 -->
<view class="grid grid-cols-3 gap-4 border-t border-gray-100 pt-3">
<view class="text-center">
<text class="block text-base text-gray-700 font-semibold">{{ teacher.marking_count || 0 }}</text>
<text class="text-xs text-gray-500">阅卷数</text>
</view>
<view class="text-center">
<text class="block text-base text-gray-700 font-semibold">{{ formatTime(teacher.average_time || 0) }}</text>
<text class="text-xs text-gray-500">平均用时</text>
</view>
<view class="text-center">
<text class="block text-base text-red-600 font-semibold">{{ teacher.problem_num || 0 }}</text>
<text class="text-xs text-gray-500">问题卷</text>
</view>
</view>
</view>
</view>
<!-- 没有学校数据 -->
<view v-if="schoolGroups.length === 0" class="py-8 text-center">
<text class="text-sm text-gray-500">暂无阅卷质量数据</text>
</view>
<!-- 暂无数据 -->
<view v-else class="py-12 text-center">
<text class="text-sm text-gray-500">
{{ hasDoubleEvaluation ? `暂无${activeEvaluateType === 'initial' ? '初评' : '终评'}老师数据` : '暂无老师数据' }}
</text>
</view>
</view>
</view>

View File

@ -115,20 +115,20 @@ export async function markingQualityExportTeacherDetailUsingGet({
});
}
/** 获取题目阅卷质量 根据question_id获取指定题目的阅卷质量统计包含大题、小题、评价方法、总分数、平均分数等详细信息 GET /marking-quality/question-quality */
export async function markingQualityQuestionQualityUsingGet({
/** 获取题目阅卷质量 根据question_id获取指定题目的阅卷质量统计包含大题、小题、评价方法、总分数、平均分数等详细信息 GET /marking-quality/question */
export async function markingQualityQuestionUsingGet({
params,
options,
}: {
// 叠加生成的Param类型 (非body参数openapi默认没有生成对象)
params: API.markingQualityQuestionQualityUsingGetParams;
params: API.markingQualityQuestionUsingGetParams;
options?: CustomRequestOptions;
}) {
return request<
API.Response & {
data?: API.GetQuestionMarkingQualityResponse;
}
>('/marking-quality/question-quality', {
>('/marking-quality/question', {
method: 'GET',
params: {
...params,