feat: 回评
continuous-integration/drone/push Build is passing
Details
continuous-integration/drone/push Build is passing
Details
This commit is contained in:
parent
fb6a63d7a5
commit
52f7cac53a
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"path": "."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../xlx_client/art-design-pro"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<!-- 阅卷布局组件 -->
|
<!-- 阅卷布局组件 -->
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { ExamStudentMarkingQuestionResponse } from '@/api'
|
import type { ExamQuestionWithTasksResponse } from '@/api'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -8,7 +8,8 @@ interface Props {
|
||||||
isFullscreen: boolean
|
isFullscreen: boolean
|
||||||
currentQuestionIndex: number
|
currentQuestionIndex: number
|
||||||
totalQuestions: number
|
totalQuestions: number
|
||||||
questions: ExamStudentMarkingQuestionResponse[]
|
currentTaskSubmit: number
|
||||||
|
questions: ExamQuestionWithTasksResponse[] // 改为 any[] 以支持不同的数据结构
|
||||||
myScore?: number
|
myScore?: number
|
||||||
avgScore?: number
|
avgScore?: number
|
||||||
}
|
}
|
||||||
|
|
@ -40,6 +41,18 @@ const questionPickerColumns = computed(() => [
|
||||||
value: index,
|
value: index,
|
||||||
})),
|
})),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
// 格式化分数,保留最少的小数位
|
||||||
|
function formatScore(score: number): string {
|
||||||
|
if (score === 0)
|
||||||
|
return '0'
|
||||||
|
if (score % 1 === 0)
|
||||||
|
return score.toString() // 整数不显示小数
|
||||||
|
|
||||||
|
// 最多保留2位小数,去掉尾部的0
|
||||||
|
const formatted = score.toFixed(2)
|
||||||
|
return formatted.replace(/\.?0+$/, '')
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -62,7 +75,7 @@ const questionPickerColumns = computed(() => [
|
||||||
<!-- 题号信息 -->
|
<!-- 题号信息 -->
|
||||||
<view class="mr-16px flex items-center">
|
<view class="mr-16px flex items-center">
|
||||||
<text class="text-16px text-gray-800 font-medium">
|
<text class="text-16px text-gray-800 font-medium">
|
||||||
{{ currentQuestionIndex + 1 }}/{{ totalQuestions }}
|
{{ currentTaskSubmit + 1 }}/{{ totalQuestions }}
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
|
@ -111,7 +124,7 @@ const questionPickerColumns = computed(() => [
|
||||||
<view class="w-80px flex flex-col gap-8px border-r border-gray-200 bg-white p-8px">
|
<view class="w-80px flex flex-col gap-8px border-r border-gray-200 bg-white p-8px">
|
||||||
<view
|
<view
|
||||||
v-for="(question, index) in questions"
|
v-for="(question, index) in questions"
|
||||||
:key="question.scan_info_id"
|
:key="question.question_id"
|
||||||
class="flex cursor-pointer items-center justify-center rounded py-6px text-12px transition-colors"
|
class="flex cursor-pointer items-center justify-center rounded py-6px text-12px transition-colors"
|
||||||
:class="[
|
:class="[
|
||||||
index === currentQuestionIndex
|
index === currentQuestionIndex
|
||||||
|
|
@ -150,14 +163,14 @@ const questionPickerColumns = computed(() => [
|
||||||
:columns="questionPickerColumns"
|
:columns="questionPickerColumns"
|
||||||
:value="String(currentQuestionIndex)"
|
:value="String(currentQuestionIndex)"
|
||||||
:title="`${currentQuestion?.question_major}.${currentQuestion?.question_minor}`"
|
:title="`${currentQuestion?.question_major}.${currentQuestion?.question_minor}`"
|
||||||
@confirm="(value) => emit('selectQuestion', Number(value))"
|
@confirm="({ value }) => emit('selectQuestion', Number(value))"
|
||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 右侧分数信息 -->
|
<!-- 右侧分数信息 -->
|
||||||
<view class="flex flex-1 justify-end">
|
<view class="flex flex-1 justify-end">
|
||||||
<text class="text-sm text-gray-600">
|
<text class="text-sm text-gray-600">
|
||||||
我的均分/本题均分: {{ myScore }}/{{ avgScore }}
|
我的均分/本题均分: {{ formatScore(myScore) }}/{{ formatScore(avgScore) }}
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
@ -167,7 +180,7 @@ const questionPickerColumns = computed(() => [
|
||||||
<!-- 题号信息 -->
|
<!-- 题号信息 -->
|
||||||
<view class="flex items-center gap-4">
|
<view class="flex items-center gap-4">
|
||||||
<text class="text-base text-gray-800 font-medium">
|
<text class="text-base text-gray-800 font-medium">
|
||||||
{{ currentQuestionIndex + 1 }}/{{ totalQuestions }}
|
{{ currentTaskSubmit + 1 }}/{{ totalQuestions }}
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,15 @@ interface Props {
|
||||||
backgroundColor?: string
|
backgroundColor?: string
|
||||||
currentTool: MarkingTool
|
currentTool: MarkingTool
|
||||||
readOnly?: boolean
|
readOnly?: boolean
|
||||||
|
adaptiveWidth?: boolean // 是否启用自适应宽度模式
|
||||||
|
onQuickScore?: (value: number) => boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
scale: 1,
|
scale: 1,
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#ffffff',
|
||||||
readOnly: false,
|
readOnly: false,
|
||||||
|
adaptiveWidth: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|
@ -45,30 +48,67 @@ let layer: Konva.Layer | null = null
|
||||||
const naturalWidth = ref(0)
|
const naturalWidth = ref(0)
|
||||||
const naturalHeight = ref(0)
|
const naturalHeight = ref(0)
|
||||||
|
|
||||||
|
// 容器尺寸
|
||||||
|
const containerWidth = ref(0)
|
||||||
|
const containerHeight = ref(0)
|
||||||
|
|
||||||
|
// 计算实际使用的缩放比例
|
||||||
|
const actualScale = computed(() => {
|
||||||
|
if (!props.adaptiveWidth || !naturalWidth.value || !containerWidth.value) {
|
||||||
|
return props.scale
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自适应宽度模式:计算适合容器宽度的缩放比例
|
||||||
|
const adaptiveScale = containerWidth.value / naturalWidth.value
|
||||||
|
return Math.min(adaptiveScale, props.scale) // 不超过原始设定的缩放比例
|
||||||
|
})
|
||||||
|
|
||||||
// 计算容器样式
|
// 计算容器样式
|
||||||
const containerStyle = computed(() => {
|
const containerStyle = computed(() => {
|
||||||
if (!naturalWidth.value || !naturalHeight.value) {
|
if (!naturalWidth.value || !naturalHeight.value) {
|
||||||
return {
|
return {
|
||||||
width: '100%',
|
width: props.adaptiveWidth ? '100%' : '100%',
|
||||||
height: '400px',
|
height: '400px',
|
||||||
backgroundColor: props.backgroundColor,
|
backgroundColor: props.backgroundColor,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const scaledWidth = naturalWidth.value * props.scale
|
if (props.adaptiveWidth) {
|
||||||
const scaledHeight = naturalHeight.value * props.scale
|
// 自适应模式:容器宽度100%,高度根据比例计算
|
||||||
|
const scaledHeight = naturalHeight.value * actualScale.value
|
||||||
|
return {
|
||||||
|
width: '100%',
|
||||||
|
height: `${scaledHeight}px`,
|
||||||
|
backgroundColor: props.backgroundColor,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// 固定缩放模式:根据缩放比例设置固定尺寸
|
||||||
|
const scaledWidth = naturalWidth.value * actualScale.value
|
||||||
|
const scaledHeight = naturalHeight.value * actualScale.value
|
||||||
return {
|
return {
|
||||||
width: `${scaledWidth}px`,
|
width: `${scaledWidth}px`,
|
||||||
height: `${scaledHeight}px`,
|
height: `${scaledHeight}px`,
|
||||||
backgroundColor: props.backgroundColor,
|
backgroundColor: props.backgroundColor,
|
||||||
}
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Layer管理器
|
// Layer管理器
|
||||||
let layerManager: ReturnType<typeof useSimpleKonvaLayer> | null = null
|
let layerManager: ReturnType<typeof useSimpleKonvaLayer> | null = null
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新容器尺寸
|
||||||
|
*/
|
||||||
|
function updateContainerSize() {
|
||||||
|
if (!konvaContainer.value)
|
||||||
|
return
|
||||||
|
|
||||||
|
containerWidth.value = konvaContainer.value.offsetWidth
|
||||||
|
containerHeight.value = konvaContainer.value.offsetHeight
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 初始化Konva画布
|
* 初始化Konva画布
|
||||||
*/
|
*/
|
||||||
|
|
@ -79,14 +119,17 @@ async function initKonva() {
|
||||||
// 等待容器尺寸稳定
|
// 等待容器尺寸稳定
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
const containerWidth = konvaContainer.value.offsetWidth
|
// 更新容器尺寸
|
||||||
const containerHeight = konvaContainer.value.offsetHeight
|
updateContainerSize()
|
||||||
|
|
||||||
|
const currentContainerWidth = containerWidth.value
|
||||||
|
const currentContainerHeight = containerHeight.value
|
||||||
|
|
||||||
// 创建Stage
|
// 创建Stage
|
||||||
const newStage = new Konva.Stage({
|
const newStage = new Konva.Stage({
|
||||||
container: konvaContainer.value,
|
container: konvaContainer.value,
|
||||||
width: containerWidth,
|
width: currentContainerWidth,
|
||||||
height: containerHeight,
|
height: currentContainerHeight,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 创建Layer
|
// 创建Layer
|
||||||
|
|
@ -102,10 +145,10 @@ async function initKonva() {
|
||||||
// 创建layer管理器
|
// 创建layer管理器
|
||||||
layerManager = useSimpleKonvaLayer({
|
layerManager = useSimpleKonvaLayer({
|
||||||
layer: newLayer,
|
layer: newLayer,
|
||||||
scale,
|
scale: actualScale,
|
||||||
currentTool,
|
currentTool,
|
||||||
initialData: props.markingData,
|
initialData: props.markingData,
|
||||||
onQuickScore: (value: number) => emit('quick-score', value),
|
onQuickScore: (value: number) => props.onQuickScore?.(value),
|
||||||
readOnly: props.readOnly,
|
readOnly: props.readOnly,
|
||||||
message,
|
message,
|
||||||
})
|
})
|
||||||
|
|
@ -176,19 +219,31 @@ function applyScale() {
|
||||||
if (!layer || !stage)
|
if (!layer || !stage)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
const currentScale = actualScale.value
|
||||||
|
|
||||||
// 重置layer的变换
|
// 重置layer的变换
|
||||||
layer.scale({ x: props.scale, y: props.scale })
|
layer.scale({ x: currentScale, y: currentScale })
|
||||||
layer.offset({ x: 0, y: 0 })
|
layer.offset({ x: 0, y: 0 })
|
||||||
layer.position({ x: 0, y: 0 })
|
layer.position({ x: 0, y: 0 })
|
||||||
|
|
||||||
// 更新stage尺寸以适应缩放后的图片
|
// 更新stage尺寸
|
||||||
const scaledWidth = naturalWidth.value * props.scale
|
if (props.adaptiveWidth && containerWidth.value) {
|
||||||
const scaledHeight = naturalHeight.value * props.scale
|
// 自适应模式:stage宽度等于容器宽度
|
||||||
|
const scaledHeight = naturalHeight.value * currentScale
|
||||||
|
stage.size({
|
||||||
|
width: containerWidth.value,
|
||||||
|
height: scaledHeight,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// 固定缩放模式:根据缩放比例设置尺寸
|
||||||
|
const scaledWidth = naturalWidth.value * currentScale
|
||||||
|
const scaledHeight = naturalHeight.value * currentScale
|
||||||
stage.size({
|
stage.size({
|
||||||
width: scaledWidth,
|
width: scaledWidth,
|
||||||
height: scaledHeight,
|
height: scaledHeight,
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
layer.draw()
|
layer.draw()
|
||||||
}
|
}
|
||||||
|
|
@ -210,28 +265,115 @@ watch(
|
||||||
)
|
)
|
||||||
|
|
||||||
// 监听缩放变化
|
// 监听缩放变化
|
||||||
watch(
|
watch([() => props.scale, actualScale], () => {
|
||||||
() => props.scale,
|
|
||||||
() => {
|
|
||||||
applyScale()
|
applyScale()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听容器尺寸变化(自适应模式)
|
||||||
|
watch(
|
||||||
|
() => props.adaptiveWidth,
|
||||||
|
(newAdaptiveWidth) => {
|
||||||
|
if (newAdaptiveWidth && konvaContainer.value) {
|
||||||
|
// 开启自适应模式时重新计算容器尺寸
|
||||||
|
nextTick(() => {
|
||||||
|
updateContainerSize()
|
||||||
|
applyScale()
|
||||||
|
})
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ResizeObserver实例 - 用于小程序环境的兼容性
|
||||||
|
let resizeObserver: any = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置尺寸监听器(适配小程序)
|
||||||
|
*/
|
||||||
|
function setupResizeObserver() {
|
||||||
|
if (!props.adaptiveWidth || !containerRef.value)
|
||||||
|
return
|
||||||
|
|
||||||
|
// 在小程序环境中,ResizeObserver 可能不可用,使用定时器作为备选方案
|
||||||
|
if (typeof ResizeObserver !== 'undefined') {
|
||||||
|
resizeObserver = new ResizeObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
const { width, height } = entry.contentRect
|
||||||
|
containerWidth.value = width
|
||||||
|
containerHeight.value = height
|
||||||
|
|
||||||
|
// 延迟应用缩放以确保DOM更新完成
|
||||||
|
nextTick(() => {
|
||||||
|
applyScale()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
resizeObserver.observe(containerRef.value)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// 小程序环境备选方案:使用定时器监听尺寸变化
|
||||||
|
let lastWidth = 0
|
||||||
|
let lastHeight = 0
|
||||||
|
|
||||||
|
const checkSizeChange = () => {
|
||||||
|
if (!containerRef.value)
|
||||||
|
return
|
||||||
|
|
||||||
|
const newWidth = containerRef.value.offsetWidth || 0
|
||||||
|
const newHeight = containerRef.value.offsetHeight || 0
|
||||||
|
|
||||||
|
if (newWidth !== lastWidth || newHeight !== lastHeight) {
|
||||||
|
lastWidth = newWidth
|
||||||
|
lastHeight = newHeight
|
||||||
|
containerWidth.value = newWidth
|
||||||
|
containerHeight.value = newHeight
|
||||||
|
|
||||||
|
nextTick(() => {
|
||||||
|
applyScale()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resizeObserver = setInterval(checkSizeChange, 500) // 每500ms检查一次
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理尺寸监听器
|
||||||
|
*/
|
||||||
|
function cleanupResizeObserver() {
|
||||||
|
if (resizeObserver) {
|
||||||
|
if (typeof ResizeObserver !== 'undefined' && resizeObserver.disconnect) {
|
||||||
|
resizeObserver.disconnect()
|
||||||
|
}
|
||||||
|
else if (typeof resizeObserver === 'number') {
|
||||||
|
clearInterval(resizeObserver)
|
||||||
|
}
|
||||||
|
resizeObserver = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 暴露方法给父组件
|
// 暴露方法给父组件
|
||||||
defineExpose({
|
defineExpose({
|
||||||
getLayerManager,
|
getLayerManager,
|
||||||
getStage: () => stage,
|
getStage: () => stage,
|
||||||
getLayer: () => layer,
|
getLayer: () => layer,
|
||||||
|
updateContainerSize,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 组件挂载时初始化
|
// 组件挂载时初始化
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await initKonva()
|
await initKonva()
|
||||||
|
// 在自适应模式下设置尺寸监听器
|
||||||
|
if (props.adaptiveWidth) {
|
||||||
|
setupResizeObserver()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 组件卸载时清理
|
// 组件卸载时清理
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
layerManager?.cleanupEvents()
|
layerManager?.cleanupEvents()
|
||||||
|
cleanupResizeObserver()
|
||||||
stage?.destroy()
|
stage?.destroy()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -207,7 +207,13 @@ async function toggleSpecialMark(type: 'excellent' | 'typical' | 'problem') {
|
||||||
isProblem: true,
|
isProblem: true,
|
||||||
problemType: result.problemType,
|
problemType: result.problemType,
|
||||||
problemRemark: result.problemRemark,
|
problemRemark: result.problemRemark,
|
||||||
|
score: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 在单题模式下自动提交问题卷
|
||||||
|
if (markingData.mode.value === 'single') {
|
||||||
|
markingData.submitRecord()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
|
|
@ -268,11 +274,6 @@ defineExpose({
|
||||||
&& markingData.firstNotScoredIndex.value === questionIndex,
|
&& markingData.firstNotScoredIndex.value === questionIndex,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<div v-if="question.title" class="question-header">
|
|
||||||
<div class="question-title">
|
|
||||||
<span class="question-title-text">{{ question.title }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="images-container" :class="imagesLayoutClass">
|
<div class="images-container" :class="imagesLayoutClass">
|
||||||
<div
|
<div
|
||||||
v-for="(imageUrl, imageIndex) in question.imageUrls"
|
v-for="(imageUrl, imageIndex) in question.imageUrls"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { Ref } from 'vue'
|
import type { Ref } from 'vue'
|
||||||
import type { ExamBatchCreateMarkingTaskRecordRequest, ExamCreateMarkingTaskRecordRequest, ExamQuestionAverageScoreComparisonResponse, ExamSetProblemRecordRequest, ExamStudentMarkingQuestionResponse } from '@/api'
|
import type { ExamBatchCreateMarkingTaskRecordRequest, ExamCreateMarkingTaskRecordRequest, ExamQuestionAverageScoreComparisonResponse, ExamQuestionWithTasksResponse, ExamSetProblemRecordRequest, ExamStudentMarkingQuestionResponse } from '@/api'
|
||||||
import { useQuery } from '@tanstack/vue-query'
|
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||||
|
import { useDebounceFn, useThrottleFn } from '@vueuse/core'
|
||||||
import { computed, inject, provide, readonly, ref, watch } from 'vue'
|
import { computed, inject, provide, readonly, ref, watch } from 'vue'
|
||||||
import { examMarkingTaskApi } from '@/api'
|
import { examMarkingTaskApi } from '@/api'
|
||||||
|
|
||||||
|
|
@ -19,11 +20,14 @@ export interface MarkingSubmitData {
|
||||||
export interface UseMarkingDataOptions {
|
export interface UseMarkingDataOptions {
|
||||||
taskId: Ref<number>
|
taskId: Ref<number>
|
||||||
questionId: Ref<number>
|
questionId: Ref<number>
|
||||||
|
examId?: Ref<number>
|
||||||
|
subjectId?: Ref<number>
|
||||||
isLandscape?: Ref<boolean>
|
isLandscape?: Ref<boolean>
|
||||||
|
taskType?: Ref<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
function createMarkingData(options: UseMarkingDataOptions) {
|
function createMarkingData(options: UseMarkingDataOptions) {
|
||||||
const { taskId, questionId, isLandscape } = options
|
const { taskId, questionId, examId, subjectId, isLandscape, taskType } = options
|
||||||
|
|
||||||
// 基础数据
|
// 基础数据
|
||||||
const questionData = ref<ExamStudentMarkingQuestionResponse[]>([])
|
const questionData = ref<ExamStudentMarkingQuestionResponse[]>([])
|
||||||
|
|
@ -33,6 +37,8 @@ function createMarkingData(options: UseMarkingDataOptions) {
|
||||||
const markingStartTime = ref<number>(0)
|
const markingStartTime = ref<number>(0)
|
||||||
const mode = ref<'single' | 'multi'>('single')
|
const mode = ref<'single' | 'multi'>('single')
|
||||||
|
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
// 当前分数
|
// 当前分数
|
||||||
const firstNotScoredIndex = computed(() => {
|
const firstNotScoredIndex = computed(() => {
|
||||||
return 0
|
return 0
|
||||||
|
|
@ -59,7 +65,23 @@ function createMarkingData(options: UseMarkingDataOptions) {
|
||||||
queryKey: computed(() => ['marking-question', taskId.value]),
|
queryKey: computed(() => ['marking-question', taskId.value]),
|
||||||
queryFn: async () => examMarkingTaskApi.questionDetail(taskId.value),
|
queryFn: async () => examMarkingTaskApi.questionDetail(taskId.value),
|
||||||
enabled: computed(() => !!taskId.value),
|
enabled: computed(() => !!taskId.value),
|
||||||
|
gcTime: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 获取题目列表数据(用于Layout显示)
|
||||||
|
const {
|
||||||
|
data: questionsListData,
|
||||||
|
refetch: refetchQuestionsList,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: computed(() => ['marking-questions-list', examId?.value, subjectId?.value]),
|
||||||
|
queryFn: async () => examMarkingTaskApi.byQuestionList({
|
||||||
|
exam_id: examId!.value!,
|
||||||
|
exam_subject_id: subjectId!.value!,
|
||||||
|
}) as unknown as Promise<ExamQuestionWithTasksResponse[]>,
|
||||||
|
enabled: computed(() => !!examId?.value && !!subjectId?.value),
|
||||||
|
placeholderData: [],
|
||||||
|
})
|
||||||
|
|
||||||
// 获取平均分对比
|
// 获取平均分对比
|
||||||
const {
|
const {
|
||||||
data: avgScoreData,
|
data: avgScoreData,
|
||||||
|
|
@ -101,6 +123,24 @@ function createMarkingData(options: UseMarkingDataOptions) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 当前任务信息(用于获取总数)
|
||||||
|
const currentTaskInfo = computed(() => {
|
||||||
|
if (!questionId.value || !taskId.value || !questionsListData.value.length)
|
||||||
|
return null
|
||||||
|
|
||||||
|
// 找到当前题目
|
||||||
|
const currentQuestion = questionsListData.value.find(q => q.question_id === questionId.value)
|
||||||
|
if (!currentQuestion)
|
||||||
|
return null
|
||||||
|
|
||||||
|
return currentQuestion.tasks?.[taskType.value]
|
||||||
|
})
|
||||||
|
|
||||||
|
// 总题目数量(从任务中获取)
|
||||||
|
const totalQuestions = computed(() => {
|
||||||
|
return currentTaskInfo.value?.task_quantity || 0
|
||||||
|
})
|
||||||
|
|
||||||
// 监听数据变化
|
// 监听数据变化
|
||||||
watch(() => questionResponse.value, processQuestionData, { immediate: true })
|
watch(() => questionResponse.value, processQuestionData, { immediate: true })
|
||||||
|
|
||||||
|
|
@ -132,6 +172,7 @@ function createMarkingData(options: UseMarkingDataOptions) {
|
||||||
if (response) {
|
if (response) {
|
||||||
isSubmitted.value = true
|
isSubmitted.value = true
|
||||||
// 重新获取下一题
|
// 重新获取下一题
|
||||||
|
questionData.value = []
|
||||||
await refetchQuestion()
|
await refetchQuestion()
|
||||||
processQuestionData()
|
processQuestionData()
|
||||||
return response
|
return response
|
||||||
|
|
@ -162,7 +203,9 @@ function createMarkingData(options: UseMarkingDataOptions) {
|
||||||
|
|
||||||
if (response) {
|
if (response) {
|
||||||
isSubmitted.value = true
|
isSubmitted.value = true
|
||||||
|
currentTaskInfo.value!.marked_quantity = (currentTaskInfo.value!.marked_quantity || 0) + 1
|
||||||
// 重新获取下一题
|
// 重新获取下一题
|
||||||
|
questionData.value = []
|
||||||
await refetchQuestion()
|
await refetchQuestion()
|
||||||
processQuestionData()
|
processQuestionData()
|
||||||
return response
|
return response
|
||||||
|
|
@ -184,15 +227,19 @@ function createMarkingData(options: UseMarkingDataOptions) {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
refetchQuestion(),
|
refetchQuestion(),
|
||||||
refetchAvgScore(),
|
refetchAvgScore(),
|
||||||
|
refetchQuestionsList(),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// 数据状态
|
// 数据状态
|
||||||
questionData,
|
questionData,
|
||||||
|
questionsList: computed(() => questionsListData.value || []), // 题目列表数据
|
||||||
currentMarkingSubmitData,
|
currentMarkingSubmitData,
|
||||||
currentScore,
|
currentScore,
|
||||||
firstNotScoredIndex,
|
firstNotScoredIndex,
|
||||||
|
totalQuestions, // 总题目数量
|
||||||
|
currentTaskInfo,
|
||||||
isLoading: computed(() => isQuestionLoading.value || isLoading.value),
|
isLoading: computed(() => isQuestionLoading.value || isLoading.value),
|
||||||
isSubmitted: readonly(isSubmitted),
|
isSubmitted: readonly(isSubmitted),
|
||||||
mode,
|
mode,
|
||||||
|
|
@ -211,6 +258,7 @@ function createMarkingData(options: UseMarkingDataOptions) {
|
||||||
reload,
|
reload,
|
||||||
refetchQuestion,
|
refetchQuestion,
|
||||||
refetchAvgScore,
|
refetchAvgScore,
|
||||||
|
refetchQuestionsList,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -124,7 +124,7 @@ export function generateOnekeyScores(stepSize: number, fullScore: number): numbe
|
||||||
export function getSelectedOnekeyScores(
|
export function getSelectedOnekeyScores(
|
||||||
stepSize: number,
|
stepSize: number,
|
||||||
fullScore: number,
|
fullScore: number,
|
||||||
selections: Record<string, boolean>,
|
selections: Record<string, boolean> = {},
|
||||||
): number[] {
|
): number[] {
|
||||||
const allScores = generateOnekeyScores(stepSize, fullScore)
|
const allScores = generateOnekeyScores(stepSize, fullScore)
|
||||||
const selectionKey = `${stepSize}_${fullScore}`
|
const selectionKey = `${stepSize}_${fullScore}`
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,168 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
historyScoreOptions?: number[]
|
||||||
|
isLoadingOptions?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'score-change': [score?: number]
|
||||||
|
'order-change': [orderBy: number]
|
||||||
|
'go-back': []
|
||||||
|
'refresh': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// 筛选条件
|
||||||
|
const selectedScore = ref<number | undefined>()
|
||||||
|
const selectedOrderBy = ref<number>(1) // 默认按打分从小到大
|
||||||
|
|
||||||
|
// 排序选项
|
||||||
|
const orderOptions = [
|
||||||
|
{ label: '按打分从小到大', value: 1 },
|
||||||
|
{ label: '按打分从大到小', value: 2 },
|
||||||
|
{ label: '按时间从早到晚', value: 3 },
|
||||||
|
{ label: '按时间从晚到早', value: 4 },
|
||||||
|
]
|
||||||
|
|
||||||
|
// 处理分数筛选变化
|
||||||
|
function handleScoreChange(score?: number) {
|
||||||
|
selectedScore.value = score
|
||||||
|
emit('score-change', score)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理排序变化
|
||||||
|
function handleOrderChange(orderBy: number) {
|
||||||
|
selectedOrderBy.value = orderBy
|
||||||
|
emit('order-change', orderBy)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回上一页
|
||||||
|
function goBack() {
|
||||||
|
emit('go-back')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新
|
||||||
|
function handleRefresh() {
|
||||||
|
emit('refresh')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示排序选择器
|
||||||
|
const showOrderPicker = ref(false)
|
||||||
|
|
||||||
|
function handleOrderPickerConfirm(value: number) {
|
||||||
|
handleOrderChange(value)
|
||||||
|
showOrderPicker.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="review-header border-b border-gray-200 bg-white">
|
||||||
|
<!-- 筛选条件 -->
|
||||||
|
<view class="filter-section p-4 pt-2">
|
||||||
|
<!-- 历史评分筛选 -->
|
||||||
|
<view class="filter-group mb-3">
|
||||||
|
<text class="filter-label mb-2 block text-sm text-gray-600">历史评分:</text>
|
||||||
|
<view class="score-filters flex flex-wrap gap-2">
|
||||||
|
<view
|
||||||
|
class="score-tag"
|
||||||
|
:class="{ active: selectedScore === undefined }"
|
||||||
|
@click="handleScoreChange(undefined)"
|
||||||
|
>
|
||||||
|
全部
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
v-for="score in historyScoreOptions"
|
||||||
|
:key="score"
|
||||||
|
class="score-tag"
|
||||||
|
:class="{ active: selectedScore === score }"
|
||||||
|
@click="handleScoreChange(score)"
|
||||||
|
>
|
||||||
|
{{ score }}分
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 加载状态 -->
|
||||||
|
<view v-if="isLoadingOptions" class="score-tag loading">
|
||||||
|
<wd-loading size="12" />
|
||||||
|
<text class="ml-1">加载中</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 试卷排序 -->
|
||||||
|
<view class="filter-group">
|
||||||
|
<text class="filter-label mb-2 block text-sm text-gray-600">试卷排序:</text>
|
||||||
|
<view
|
||||||
|
class="order-selector flex items-center justify-between rounded-2 bg-gray-50 px-3 py-2"
|
||||||
|
@click="showOrderPicker = true"
|
||||||
|
>
|
||||||
|
<text class="text-sm text-gray-700">
|
||||||
|
{{ orderOptions.find(item => item.value === selectedOrderBy)?.label }}
|
||||||
|
</text>
|
||||||
|
<view class="i-fluent:chevron-down-20-filled size-4 text-gray-400" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 排序选择器 -->
|
||||||
|
<wd-action-sheet
|
||||||
|
v-model="showOrderPicker"
|
||||||
|
:actions="orderOptions.map(item => ({ name: item.label, value: item.value }))"
|
||||||
|
cancel-text="取消"
|
||||||
|
@select="({ item }) => handleOrderPickerConfirm(item.value)"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.review-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn:active,
|
||||||
|
.refresh-btn:active {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-tag {
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 12px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-tag.active {
|
||||||
|
background-color: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-tag.loading {
|
||||||
|
background-color: #f9fafb;
|
||||||
|
color: #9ca3af;
|
||||||
|
cursor: not-allowed;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-tag:not(.loading):not(.active):active {
|
||||||
|
background-color: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-selector {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-selector:active {
|
||||||
|
background-color: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-label {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,171 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import KonvaImageRenderer from '../components/renderer/KonvaImageRenderer.vue'
|
||||||
|
import { MarkingTool } from '../composables/renderer/useMarkingKonva'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
imageUrls: string[]
|
||||||
|
markingData?: string
|
||||||
|
score?: number
|
||||||
|
fullScore?: number
|
||||||
|
scale?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
scale: 0.5, // 默认缩小显示
|
||||||
|
})
|
||||||
|
|
||||||
|
// 处理标记数据,确保每个图片都有对应的数据
|
||||||
|
const processedMarkingData = computed(() => {
|
||||||
|
if (!props.markingData || typeof props.markingData !== 'string') {
|
||||||
|
// 如果没有标记数据,为每个图片提供空的默认数据
|
||||||
|
return props.imageUrls.map(() => ({
|
||||||
|
version: '1.0',
|
||||||
|
shapes: [],
|
||||||
|
specialMarks: [],
|
||||||
|
annotations: [],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
let markingData = []
|
||||||
|
try {
|
||||||
|
markingData = JSON.parse(props.markingData)
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('解析标记数据失败:', error)
|
||||||
|
markingData = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保标记数据数组长度与图片数组长度一致
|
||||||
|
return props.imageUrls.map((_, index) => {
|
||||||
|
const data = markingData?.[index]
|
||||||
|
if (data && typeof data === 'object') {
|
||||||
|
return {
|
||||||
|
version: data.version || '1.0',
|
||||||
|
shapes: data.shapes || [],
|
||||||
|
specialMarks: data.specialMarks || [],
|
||||||
|
annotations: data.annotations || [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
version: '1.0',
|
||||||
|
shapes: [],
|
||||||
|
specialMarks: [],
|
||||||
|
annotations: [],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 处理layer准备就绪(只读模式不需要处理)
|
||||||
|
function handleLayerReady() {
|
||||||
|
// 只读模式,不需要处理layer事件
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="review-image-renderer">
|
||||||
|
<!-- 图片列表 -->
|
||||||
|
<view class="image-list flex flex-col gap-3">
|
||||||
|
<view
|
||||||
|
v-for="(imageUrl, index) in imageUrls"
|
||||||
|
:key="`review_${index}`"
|
||||||
|
class="image-item relative"
|
||||||
|
>
|
||||||
|
<!-- 图片渲染器 -->
|
||||||
|
<view class="image-wrapper relative overflow-hidden rounded-2 shadow-sm">
|
||||||
|
<KonvaImageRenderer
|
||||||
|
:image-url="imageUrl"
|
||||||
|
:scale="scale"
|
||||||
|
:marking-data="processedMarkingData[index]"
|
||||||
|
:current-tool="MarkingTool.SELECT"
|
||||||
|
:read-only="true"
|
||||||
|
:adaptive-width="true"
|
||||||
|
@layer-ready="handleLayerReady"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 点击编辑提示 -->
|
||||||
|
<view class="edit-hint absolute bottom-2 right-2 z-1">
|
||||||
|
<view class="hint-badge rounded-1 bg-black bg-opacity-60 px-2 py-1 text-white">
|
||||||
|
<view class="i-fluent:edit-20-filled mr-1 inline-block size-3" />
|
||||||
|
<text class="hint-text text-xs">点击编辑</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<view v-if="imageUrls.length === 0" class="empty-state flex-center h-32 rounded-2 bg-gray-100">
|
||||||
|
<view class="text-center">
|
||||||
|
<view class="i-fluent:image-20-filled mb-2 size-8 text-gray-400" />
|
||||||
|
<text class="text-sm text-gray-400">暂无图片</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.review-image-renderer {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-item {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-wrapper {
|
||||||
|
position: relative;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-overlay {
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 1px;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-text {
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator {
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.full-score-text {
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-hint {
|
||||||
|
opacity: 0.8;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-wrapper:active .edit-hint {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-text {
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-center {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,499 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { ExamBatchCreateMarkingTaskRecordRequest, ExamMarkingTaskHistoryRecord } from '@/api'
|
||||||
|
import { useMutation } from '@tanstack/vue-query'
|
||||||
|
import { examMarkingTaskApi } from '@/api'
|
||||||
|
|
||||||
|
interface EditData {
|
||||||
|
currentScore: number
|
||||||
|
recordTask: ExamMarkingTaskHistoryRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对话框状态
|
||||||
|
const show = ref(false)
|
||||||
|
const editScore = ref(0)
|
||||||
|
const fullScore = ref(0)
|
||||||
|
const recordTask = ref<ExamMarkingTaskHistoryRecord>()
|
||||||
|
|
||||||
|
// 解析器
|
||||||
|
let resolvePromise: ((value: any) => void) | null = null
|
||||||
|
let rejectPromise: ((reason?: any) => void) | null = null
|
||||||
|
|
||||||
|
// 修改分数的mutation
|
||||||
|
const { mutate: updateScore, isPending } = useMutation({
|
||||||
|
mutationFn: async (params: { recordTask: ExamMarkingTaskHistoryRecord, score: number }) => {
|
||||||
|
// 调用批量更新阅卷记录接口
|
||||||
|
const request: ExamBatchCreateMarkingTaskRecordRequest = {
|
||||||
|
batch_data: [{
|
||||||
|
id: params.recordTask.id!,
|
||||||
|
scan_info_id: params.recordTask.scan_info_id!,
|
||||||
|
question_id: params.recordTask.question_id!,
|
||||||
|
record_id: params.recordTask.id!,
|
||||||
|
task_id: params.recordTask.task_id!,
|
||||||
|
duration: params.recordTask.duration || 0,
|
||||||
|
remark: params.recordTask.remark || '',
|
||||||
|
page_mode: 'single',
|
||||||
|
is_excellent: params.recordTask.is_excellent || 0,
|
||||||
|
is_model: params.recordTask.is_model || 0,
|
||||||
|
is_problem: params.recordTask.is_problem || 0,
|
||||||
|
score: params.score,
|
||||||
|
}],
|
||||||
|
is_review: true, // 标记为回评操作
|
||||||
|
}
|
||||||
|
return examMarkingTaskApi.batchCreate(request)
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
uni.showToast({
|
||||||
|
title: '分数修改成功',
|
||||||
|
icon: 'success',
|
||||||
|
})
|
||||||
|
handleConfirm(editScore.value)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 打开对话框
|
||||||
|
function open(data: EditData): Promise<number | null> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
editScore.value = data.currentScore
|
||||||
|
fullScore.value = data.recordTask.full_score
|
||||||
|
recordTask.value = data.recordTask
|
||||||
|
show.value = true
|
||||||
|
|
||||||
|
resolvePromise = resolve
|
||||||
|
rejectPromise = reject
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确认修改
|
||||||
|
function handleConfirm(score: number) {
|
||||||
|
show.value = false
|
||||||
|
resolvePromise?.(score)
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取消修改
|
||||||
|
function handleCancel() {
|
||||||
|
show.value = false
|
||||||
|
rejectPromise?.('cancel')
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理
|
||||||
|
function cleanup() {
|
||||||
|
resolvePromise = null
|
||||||
|
rejectPromise = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交分数修改
|
||||||
|
function handleSubmit() {
|
||||||
|
if (editScore.value < 0 || editScore.value > fullScore.value) {
|
||||||
|
uni.showToast({
|
||||||
|
title: `分数必须在0-${fullScore.value}之间`,
|
||||||
|
icon: 'none',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updateScore({
|
||||||
|
recordTask: recordTask.value!,
|
||||||
|
score: editScore.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 快捷分数按钮
|
||||||
|
const quickScoreButtons = computed(() => {
|
||||||
|
const buttons = []
|
||||||
|
const step = fullScore.value <= 10 ? 1 : Math.ceil(fullScore.value / 10)
|
||||||
|
|
||||||
|
for (let i = 0; i <= fullScore.value; i += step) {
|
||||||
|
if (i <= fullScore.value) {
|
||||||
|
buttons.push(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保包含满分
|
||||||
|
if (buttons[buttons.length - 1] !== fullScore.value) {
|
||||||
|
buttons.push(fullScore.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buttons
|
||||||
|
})
|
||||||
|
|
||||||
|
// 分数调整
|
||||||
|
function adjustScore(delta: number) {
|
||||||
|
const newScore = editScore.value + delta
|
||||||
|
if (newScore >= 0 && newScore <= fullScore.value) {
|
||||||
|
editScore.value = newScore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听对话框关闭
|
||||||
|
watch(show, (newValue) => {
|
||||||
|
if (!newValue && rejectPromise) {
|
||||||
|
handleCancel()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 暴露方法
|
||||||
|
defineExpose({
|
||||||
|
open,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<wd-popup
|
||||||
|
v-model="show"
|
||||||
|
position="bottom"
|
||||||
|
round
|
||||||
|
closable
|
||||||
|
safe-area-inset-bottom
|
||||||
|
@close="handleCancel"
|
||||||
|
>
|
||||||
|
<view class="score-edit-dialog">
|
||||||
|
<!-- 拖拽指示器 -->
|
||||||
|
<view class="drag-indicator" />
|
||||||
|
|
||||||
|
<!-- 标题区域 -->
|
||||||
|
<view class="dialog-header">
|
||||||
|
<text class="title">修改分数</text>
|
||||||
|
<text class="subtitle">请为当前题目设置分数</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 当前分数显示 -->
|
||||||
|
<view class="current-score">
|
||||||
|
<view class="score-container">
|
||||||
|
<view class="score-display">
|
||||||
|
<text class="score">{{ editScore }}</text>
|
||||||
|
<text class="separator">/</text>
|
||||||
|
<text class="total">{{ fullScore }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="score-label">当前分数</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 分数调整器 -->
|
||||||
|
<view class="score-adjuster">
|
||||||
|
<wd-button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
round
|
||||||
|
:disabled="editScore <= 0"
|
||||||
|
custom-class="adjust-btn minus-btn"
|
||||||
|
@click="adjustScore(-1)"
|
||||||
|
>
|
||||||
|
-1
|
||||||
|
</wd-button>
|
||||||
|
|
||||||
|
<view class="score-input-container">
|
||||||
|
<wd-input
|
||||||
|
v-model.number="editScore"
|
||||||
|
:min="0"
|
||||||
|
:max="fullScore"
|
||||||
|
:precision="0"
|
||||||
|
size="large"
|
||||||
|
custom-class="score-input"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<wd-button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
round
|
||||||
|
:disabled="editScore >= fullScore"
|
||||||
|
custom-class="adjust-btn plus-btn"
|
||||||
|
@click="adjustScore(1)"
|
||||||
|
>
|
||||||
|
+1
|
||||||
|
</wd-button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 快捷分数按钮 -->
|
||||||
|
<view class="quick-scores">
|
||||||
|
<text class="section-title">快捷分数</text>
|
||||||
|
<view class="quick-buttons">
|
||||||
|
<wd-button
|
||||||
|
v-for="score in quickScoreButtons"
|
||||||
|
:key="score"
|
||||||
|
size="small"
|
||||||
|
round
|
||||||
|
:type="editScore === score ? 'primary' : 'default'"
|
||||||
|
:custom-class="editScore === score ? 'quick-btn active' : 'quick-btn'"
|
||||||
|
@click="editScore = score"
|
||||||
|
>
|
||||||
|
{{ score }}
|
||||||
|
</wd-button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 操作按钮 -->
|
||||||
|
<view class="dialog-actions">
|
||||||
|
<wd-button
|
||||||
|
size="large"
|
||||||
|
round
|
||||||
|
:disabled="isPending"
|
||||||
|
custom-class="cancel-btn"
|
||||||
|
@click="handleCancel"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</wd-button>
|
||||||
|
<wd-button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
round
|
||||||
|
:loading="isPending"
|
||||||
|
custom-class="confirm-btn"
|
||||||
|
@click="handleSubmit"
|
||||||
|
>
|
||||||
|
确定修改
|
||||||
|
</wd-button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</wd-popup>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.score-edit-dialog {
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #f8f9fa 100%);
|
||||||
|
border-radius: 20px 20px 0 0;
|
||||||
|
padding: 0;
|
||||||
|
position: relative;
|
||||||
|
max-height: 85vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 拖拽指示器 */
|
||||||
|
.drag-indicator {
|
||||||
|
width: 40px;
|
||||||
|
height: 4px;
|
||||||
|
background: #e1e5e9;
|
||||||
|
border-radius: 2px;
|
||||||
|
margin: 12px auto 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 标题区域 */
|
||||||
|
.dialog-header {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px 24px 16px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
display: block;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 当前分数显示 */
|
||||||
|
.current-score {
|
||||||
|
padding: 32px 24px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-container {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-display {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score {
|
||||||
|
font-size: 48px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #dc2626;
|
||||||
|
line-height: 1;
|
||||||
|
text-shadow: 0 2px 4px rgba(220, 38, 38, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #4b5563;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6b7280;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 分数调整器 */
|
||||||
|
.score-adjuster {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 24px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-input-container {
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 4px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 快捷分数按钮区域 */
|
||||||
|
.quick-scores {
|
||||||
|
padding: 24px;
|
||||||
|
background: #ffffff;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
max-height: 120px;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #d1d5db transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-buttons::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-buttons::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-buttons::-webkit-scrollbar-thumb {
|
||||||
|
background: #d1d5db;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 操作按钮 */
|
||||||
|
.dialog-actions {
|
||||||
|
padding: 24px;
|
||||||
|
background: #ffffff;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 自定义按钮样式 */
|
||||||
|
:deep(.adjust-btn) {
|
||||||
|
min-width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.2);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.adjust-btn:active) {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.score-input) {
|
||||||
|
border: none !important;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
width: 120px;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.quick-btn) {
|
||||||
|
min-width: 44px;
|
||||||
|
height: 36px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.quick-btn.active) {
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.cancel-btn) {
|
||||||
|
flex: 1;
|
||||||
|
height: 48px;
|
||||||
|
background: #f3f4f6 !important;
|
||||||
|
color: #374151 !important;
|
||||||
|
border: 1px solid #e5e7eb !important;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.confirm-btn) {
|
||||||
|
flex: 1;
|
||||||
|
height: 48px;
|
||||||
|
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%) !important;
|
||||||
|
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.confirm-btn:active) {
|
||||||
|
transform: translateY(1px);
|
||||||
|
box-shadow: 0 2px 6px rgba(59, 130, 246, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 动画效果 */
|
||||||
|
.score-edit-dialog {
|
||||||
|
animation: slideUp 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
transform: translateY(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 分数变化动画 */
|
||||||
|
.score {
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式设计 */
|
||||||
|
@media (max-height: 600px) {
|
||||||
|
.current-score {
|
||||||
|
padding: 20px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score {
|
||||||
|
font-size: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-buttons {
|
||||||
|
max-height: 80px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -154,6 +154,13 @@
|
||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "阅卷监控"
|
"navigationBarTitleText": "阅卷监控"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/marking/review",
|
||||||
|
"type": "page",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "回评"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"subPackages": []
|
"subPackages": []
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,7 @@ async function handleStartMarking(task: any) {
|
||||||
|
|
||||||
// 跳转到阅卷界面
|
// 跳转到阅卷界面
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
url: `/pages/marking/grading?examId=${examId.value}&subjectId=${subjectId.value}&questionId=${task.question_id}&taskId=${task.id}&type=${evaluationType}`,
|
url: `/pages/marking/grading?examId=${examId.value}&subjectId=${subjectId.value}&questionId=${task.question_id}&taskId=${task.id}&type=${evaluationType}&taskType=${task.task_type}`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
|
|
@ -115,32 +115,11 @@ async function handleStartMarking(task: any) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 回评
|
// 查看回评
|
||||||
async function handleRemark(task: any) {
|
async function handleViewReview(task: any) {
|
||||||
try {
|
uni.navigateTo({
|
||||||
await uni.showModal({
|
url: `/pages/marking/review?examId=${examId.value}&subjectId=${subjectId.value}&questionId=${task.question_id}&taskId=${task.id}`,
|
||||||
title: '确认回评',
|
|
||||||
content: '回评将清除已阅记录,是否继续?',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
uni.showToast({
|
|
||||||
title: '回评功能开发中',
|
|
||||||
icon: 'none',
|
|
||||||
})
|
|
||||||
|
|
||||||
// TODO: 实现回评接口调用
|
|
||||||
// 刷新数据
|
|
||||||
refetch()
|
|
||||||
}
|
|
||||||
catch (error: any) {
|
|
||||||
if (error.errMsg !== 'showModal:fail cancel') {
|
|
||||||
console.error('回评失败:', error)
|
|
||||||
uni.showToast({
|
|
||||||
title: '回评失败',
|
|
||||||
icon: 'none',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 刷新数据
|
// 刷新数据
|
||||||
|
|
@ -217,12 +196,12 @@ function handleRefresh() {
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="flex gap-2">
|
<view class="flex gap-2">
|
||||||
<!-- 回评按钮 -->
|
<!-- 查看回评按钮 -->
|
||||||
<wd-button
|
<wd-button
|
||||||
v-if="(task.marked_quantity || 0) > 0"
|
v-if="(task.marked_quantity || 0) > 0"
|
||||||
size="small"
|
size="small"
|
||||||
type="warning"
|
type="info"
|
||||||
@click="handleRemark(task)"
|
@click="handleViewReview(task)"
|
||||||
>
|
>
|
||||||
回评
|
回评
|
||||||
</wd-button>
|
</wd-button>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@
|
||||||
</route>
|
</route>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import { useQueryClient } from '@tanstack/vue-query'
|
||||||
|
import { whenever } from '@vueuse/core'
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import AnswerDialog from '@/components/marking/components/dialog/AnswerDialog.vue'
|
import AnswerDialog from '@/components/marking/components/dialog/AnswerDialog.vue'
|
||||||
import AvgScoreDialog from '@/components/marking/components/dialog/AvgScoreDialog.vue'
|
import AvgScoreDialog from '@/components/marking/components/dialog/AvgScoreDialog.vue'
|
||||||
|
|
@ -27,6 +29,7 @@ const subjectId = ref<number>()
|
||||||
const questionId = ref<number>()
|
const questionId = ref<number>()
|
||||||
const taskId = ref<number>()
|
const taskId = ref<number>()
|
||||||
const evaluationType = ref<'single' | 'double'>('single')
|
const evaluationType = ref<'single' | 'double'>('single')
|
||||||
|
const taskType = ref<'initial' | 'final' | 'arbitration'>('initial')
|
||||||
|
|
||||||
// 屏幕方向状态
|
// 屏幕方向状态
|
||||||
const isLandscape = ref(false)
|
const isLandscape = ref(false)
|
||||||
|
|
@ -34,15 +37,18 @@ const isLandscape = ref(false)
|
||||||
const markingData = provideMarkingData({
|
const markingData = provideMarkingData({
|
||||||
taskId,
|
taskId,
|
||||||
questionId,
|
questionId,
|
||||||
|
examId,
|
||||||
|
subjectId,
|
||||||
isLandscape,
|
isLandscape,
|
||||||
|
taskType,
|
||||||
})
|
})
|
||||||
const { questionData: questions } = markingData
|
const { questionData: questions, questionsList, totalQuestions: totalQuestionsCount } = markingData
|
||||||
|
|
||||||
// 全屏状态
|
// 全屏状态
|
||||||
const isFullscreen = ref(false)
|
const isFullscreen = ref(false)
|
||||||
|
|
||||||
const currentQuestionIndex = ref(0)
|
const currentQuestionIndex = ref(0)
|
||||||
const totalQuestions = computed(() => questions.value.length)
|
const totalQuestions = computed(() => totalQuestionsCount.value)
|
||||||
|
|
||||||
// 分数数据
|
// 分数数据
|
||||||
const myScore = computed(() => {
|
const myScore = computed(() => {
|
||||||
|
|
@ -61,6 +67,7 @@ onLoad((options) => {
|
||||||
questionId.value = Number(options.questionId)
|
questionId.value = Number(options.questionId)
|
||||||
taskId.value = Number(options.taskId) // 从路由参数获取taskId
|
taskId.value = Number(options.taskId) // 从路由参数获取taskId
|
||||||
evaluationType.value = options.type as 'single' | 'double' || 'single'
|
evaluationType.value = options.type as 'single' | 'double' || 'single'
|
||||||
|
taskType.value = options.taskType
|
||||||
})
|
})
|
||||||
|
|
||||||
// 监听屏幕方向变化
|
// 监听屏幕方向变化
|
||||||
|
|
@ -167,6 +174,7 @@ function goBack() {
|
||||||
// 题目选择
|
// 题目选择
|
||||||
function selectQuestion(index: number) {
|
function selectQuestion(index: number) {
|
||||||
currentQuestionIndex.value = index
|
currentQuestionIndex.value = index
|
||||||
|
questions.value = []
|
||||||
}
|
}
|
||||||
|
|
||||||
// 弹窗状态
|
// 弹窗状态
|
||||||
|
|
@ -199,7 +207,18 @@ function viewAnswer() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 当前题目信息
|
// 当前题目信息
|
||||||
const currentQuestion = computed(() => questions.value[currentQuestionIndex.value])
|
const currentQuestions = computed(() => questionsList.value[currentQuestionIndex.value])
|
||||||
|
const currentQuestion = computed(() => questions.value[0])
|
||||||
|
const currentTask = computed(() => currentQuestions.value?.tasks?.[taskType.value])
|
||||||
|
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
whenever(currentTask, (task, oldTask) => {
|
||||||
|
taskId.value = task?.id
|
||||||
|
questionId.value = task?.question_id
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['marking-question', oldTask?.id] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['marking-question', task.id] })
|
||||||
|
})
|
||||||
|
|
||||||
// 快捷打分选择
|
// 快捷打分选择
|
||||||
function handleQuickScoreSelect(score: number) {
|
function handleQuickScoreSelect(score: number) {
|
||||||
|
|
@ -213,8 +232,9 @@ function handleQuickScoreSelect(score: number) {
|
||||||
:is-landscape="isLandscape"
|
:is-landscape="isLandscape"
|
||||||
:is-fullscreen="isFullscreen"
|
:is-fullscreen="isFullscreen"
|
||||||
:current-question-index="currentQuestionIndex"
|
:current-question-index="currentQuestionIndex"
|
||||||
|
:current-task-submit="currentTask?.marked_quantity || 0"
|
||||||
:total-questions="totalQuestions"
|
:total-questions="totalQuestions"
|
||||||
:questions="questions"
|
:questions="questionsList"
|
||||||
:my-score="myScore"
|
:my-score="myScore"
|
||||||
:avg-score="avgScore"
|
:avg-score="avgScore"
|
||||||
@go-back="goBack"
|
@go-back="goBack"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,275 @@
|
||||||
|
<!-- 回评页面 -->
|
||||||
|
<route lang="jsonc">
|
||||||
|
{
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "回评"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</route>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { useQuery } from '@tanstack/vue-query'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { examMarkingTaskApi } from '@/api'
|
||||||
|
import ReviewHeader from '@/components/marking/review/ReviewHeader.vue'
|
||||||
|
import ReviewImageRenderer from '@/components/marking/review/ReviewImageRenderer.vue'
|
||||||
|
import ScoreEditDialog from '@/components/marking/review/ScoreEditDialog.vue'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'MarkingReviewPage',
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取路由参数
|
||||||
|
const taskId = ref<number>()
|
||||||
|
const questionId = ref<number>()
|
||||||
|
const examId = ref<number>()
|
||||||
|
const subjectId = ref<number>()
|
||||||
|
|
||||||
|
const enableQuery = computed(() => !!taskId.value)
|
||||||
|
|
||||||
|
// 页面加载时获取参数
|
||||||
|
onLoad((options) => {
|
||||||
|
taskId.value = Number(options.taskId)
|
||||||
|
questionId.value = Number(options.questionId)
|
||||||
|
examId.value = Number(options.examId)
|
||||||
|
subjectId.value = Number(options.subjectId)
|
||||||
|
console.log('回评页面参数:', { taskId: taskId.value, questionId: questionId.value })
|
||||||
|
})
|
||||||
|
|
||||||
|
// 筛选和排序状态
|
||||||
|
const selectedScore = ref<number>()
|
||||||
|
const selectedOrderBy = ref<number>(1) // 默认按打分从小到大
|
||||||
|
|
||||||
|
// 获取历史分数选项
|
||||||
|
const {
|
||||||
|
data: historyScoreOptions,
|
||||||
|
isLoading: isLoadingScoreOptions,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['review-history-score', taskId],
|
||||||
|
queryFn: () => examMarkingTaskApi.reviewHistoryScoreCreate({
|
||||||
|
task_id: taskId.value!,
|
||||||
|
}),
|
||||||
|
enabled: enableQuery,
|
||||||
|
select: data => data?.history_scores || [],
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取历史记录数据
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = ref(20)
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: historyData,
|
||||||
|
isLoading: isLoadingHistory,
|
||||||
|
error: historyError,
|
||||||
|
refetch: refetchHistory,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['review-history', taskId, currentPage, selectedScore, selectedOrderBy],
|
||||||
|
queryFn: () => examMarkingTaskApi.historyDetail(
|
||||||
|
taskId.value!,
|
||||||
|
{
|
||||||
|
page: currentPage.value,
|
||||||
|
page_size: pageSize.value,
|
||||||
|
is_review: true,
|
||||||
|
history_score: selectedScore.value,
|
||||||
|
order_by: selectedOrderBy.value,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
enabled: enableQuery,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 历史记录列表
|
||||||
|
const historyList = computed(() => historyData.value?.list || [])
|
||||||
|
const totalCount = computed(() => historyData.value?.total || 0)
|
||||||
|
|
||||||
|
// 处理分数筛选变化
|
||||||
|
function handleScoreChange(score?: number) {
|
||||||
|
console.log('分数筛选变化:', score)
|
||||||
|
selectedScore.value = score
|
||||||
|
currentPage.value = 1 // 重置到第一页
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理排序变化
|
||||||
|
function handleOrderChange(orderBy: number) {
|
||||||
|
console.log('排序变化:', orderBy)
|
||||||
|
selectedOrderBy.value = orderBy
|
||||||
|
currentPage.value = 1 // 重置到第一页
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回上一页
|
||||||
|
function goBack() {
|
||||||
|
uni.navigateBack()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分数编辑对话框
|
||||||
|
const scoreEditDialogRef = ref<InstanceType<typeof ScoreEditDialog>>()
|
||||||
|
|
||||||
|
// 处理图片点击编辑分数
|
||||||
|
async function handleImageClick(record: any) {
|
||||||
|
try {
|
||||||
|
const result = await scoreEditDialogRef.value?.open({
|
||||||
|
currentScore: record.score,
|
||||||
|
recordTask: record,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
// 刷新历史记录
|
||||||
|
await refetchHistory()
|
||||||
|
uni.showToast({
|
||||||
|
title: '分数修改成功',
|
||||||
|
icon: 'success',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
console.error('修改分数失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载更多
|
||||||
|
function loadMore() {
|
||||||
|
if (historyData.value?.has_more) {
|
||||||
|
currentPage.value += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新数据
|
||||||
|
function handleRefresh() {
|
||||||
|
currentPage.value = 1
|
||||||
|
refetchHistory()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="review-page bg-gray-50">
|
||||||
|
<!-- 顶部筛选栏 -->
|
||||||
|
<ReviewHeader
|
||||||
|
:history-score-options="historyScoreOptions || []"
|
||||||
|
:is-loading-options="isLoadingScoreOptions"
|
||||||
|
@score-change="handleScoreChange"
|
||||||
|
@order-change="handleOrderChange"
|
||||||
|
@go-back="goBack"
|
||||||
|
@refresh="handleRefresh"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 主体内容区 -->
|
||||||
|
<view class="content-area flex-1 p-4">
|
||||||
|
<!-- 加载状态 -->
|
||||||
|
<view v-if="isLoadingHistory" class="loading-container flex-center h-50">
|
||||||
|
<wd-loading size="24" />
|
||||||
|
<text class="ml-2 text-sm text-gray-600">加载中...</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 错误状态 -->
|
||||||
|
<view v-else-if="historyError" class="error-container flex-center h-50">
|
||||||
|
<view class="text-center">
|
||||||
|
<text class="mb-3 block text-sm text-gray-600">加载失败</text>
|
||||||
|
<wd-button type="primary" size="small" @click="handleRefresh">
|
||||||
|
重试
|
||||||
|
</wd-button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 历史记录列表 -->
|
||||||
|
<view v-else-if="historyList.length > 0" class="history-list">
|
||||||
|
<view
|
||||||
|
v-for="record in historyList"
|
||||||
|
:key="record.id"
|
||||||
|
class="history-item mb-4 rounded-3 bg-white p-4 shadow-sm"
|
||||||
|
@click="handleImageClick(record)"
|
||||||
|
>
|
||||||
|
<!-- 头部信息 -->
|
||||||
|
<view class="item-header mb-3 flex items-center justify-between">
|
||||||
|
<view class="score-info">
|
||||||
|
<text class="score text-2xl text-red-600 font-bold">{{ record.score }}</text>
|
||||||
|
<text class="total-score text-base text-gray-500">/ {{ record.full_score }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="time-info text-right">
|
||||||
|
<text class="time text-sm text-gray-500">{{ dayjs(record.created_time).format('YYYY-MM-DD HH:mm:ss') }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 图片展示区 -->
|
||||||
|
<view class="image-container">
|
||||||
|
<ReviewImageRenderer
|
||||||
|
v-if="record.image_urls?.length"
|
||||||
|
:image-urls="record.image_urls"
|
||||||
|
:marking-data="record.remark"
|
||||||
|
:score="record.score"
|
||||||
|
:full-score="record.full_score"
|
||||||
|
/>
|
||||||
|
<view v-else class="no-image flex-center h-32 rounded-2 bg-gray-100">
|
||||||
|
<text class="text-gray-400">暂无图片</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 加载更多 -->
|
||||||
|
<view v-if="historyData?.has_more" class="load-more py-4">
|
||||||
|
<wd-button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
block
|
||||||
|
@click="loadMore"
|
||||||
|
>
|
||||||
|
加载更多
|
||||||
|
</wd-button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 没有更多数据 -->
|
||||||
|
<view v-else-if="historyList.length > 0" class="no-more py-4 text-center">
|
||||||
|
<text class="text-sm text-gray-400">没有更多数据了</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<view v-else class="empty-state flex-center h-50">
|
||||||
|
<view class="text-center">
|
||||||
|
<view class="i-fluent:document-search-20-filled mb-4 size-20 text-gray-400" />
|
||||||
|
<text class="text-sm text-gray-500">暂无历史记录</text>
|
||||||
|
<text class="mt-2 block text-xs text-gray-400">请调整筛选条件</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 分数编辑对话框 -->
|
||||||
|
<ScoreEditDialog ref="scoreEditDialogRef" />
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.review-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-area {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-center {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score {
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-score {
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
/* eslint-disable style/operator-linebreak */
|
||||||
/**
|
/**
|
||||||
* 桌面端 HTTP 客户端
|
* 桌面端 HTTP 客户端
|
||||||
* 基于现有的 uni.request 架构,自动注入 baseURL: api.xianglexue.com
|
* 基于现有的 uni.request 架构,自动注入 baseURL: api.xianglexue.com
|
||||||
|
|
@ -10,12 +11,44 @@ import { http, httpDelete, httpGet, httpPost, httpPut } from '@/http/http'
|
||||||
// 桌面端 baseURL
|
// 桌面端 baseURL
|
||||||
const DESKTOP_BASE_URL = 'http://api.xianglexue.com/api/v1'
|
const DESKTOP_BASE_URL = 'http://api.xianglexue.com/api/v1'
|
||||||
|
|
||||||
|
interface ControllerResponse<T> {
|
||||||
|
code?: number
|
||||||
|
data?: T
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重写 ModelPageResponse 以支持泛型,避免 any[] 覆盖问题
|
||||||
|
interface TypedModelPageResponse<T = any> {
|
||||||
|
/** 是否有更多数据 */
|
||||||
|
has_more?: boolean
|
||||||
|
/** 当前页数据列表 */
|
||||||
|
list?: T[]
|
||||||
|
/** 当前页 */
|
||||||
|
page?: number
|
||||||
|
/** 每页数量 */
|
||||||
|
page_size?: number
|
||||||
|
/** 总记录数 */
|
||||||
|
total?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测是否为分页响应类型
|
||||||
|
type IsPageResponse<T> = T extends { list?: any[], page?: number, total?: number } ? true : false
|
||||||
|
|
||||||
|
// 优化的类型解压,智能处理分页响应
|
||||||
|
type ExtractData<T> = T extends ControllerResponse<infer U>
|
||||||
|
? IsPageResponse<U> extends true
|
||||||
|
? // 如果是分页响应,保持原始结构但替换 ModelPageResponse 为 TypedModelPageResponse
|
||||||
|
U extends { list?: infer L }
|
||||||
|
? Omit<U, 'list'> & { list?: L }
|
||||||
|
: U
|
||||||
|
: U
|
||||||
|
: T
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 桌面端 GET 请求
|
* 桌面端 GET 请求
|
||||||
*/
|
*/
|
||||||
function desktopHttpGet<T>(url: string, query?: Record<string, any>, header?: Record<string, any>, options?: Partial<CustomRequestOptions>) {
|
function desktopHttpGet<T>(url: string, query?: Record<string, any>, header?: Record<string, any>, options?: Partial<CustomRequestOptions>) {
|
||||||
const desktopUrl = url.startsWith('http') ? url : `${DESKTOP_BASE_URL}${url}`
|
const desktopUrl = url.startsWith('http') ? url : `${DESKTOP_BASE_URL}${url}`
|
||||||
return httpGet<T>(desktopUrl, query, header, options)
|
return httpGet<ExtractData<T>>(desktopUrl, query, header, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -23,7 +56,7 @@ function desktopHttpGet<T>(url: string, query?: Record<string, any>, header?: Re
|
||||||
*/
|
*/
|
||||||
function desktopHttpPost<T>(url: string, data?: Record<string, any>, query?: Record<string, any>, header?: Record<string, any>, options?: Partial<CustomRequestOptions>) {
|
function desktopHttpPost<T>(url: string, data?: Record<string, any>, query?: Record<string, any>, header?: Record<string, any>, options?: Partial<CustomRequestOptions>) {
|
||||||
const desktopUrl = url.startsWith('http') ? url : `${DESKTOP_BASE_URL}${url}`
|
const desktopUrl = url.startsWith('http') ? url : `${DESKTOP_BASE_URL}${url}`
|
||||||
return httpPost<T>(desktopUrl, data, query, header, options)
|
return httpPost<ExtractData<T>>(desktopUrl, data, query, header, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -31,7 +64,7 @@ function desktopHttpPost<T>(url: string, data?: Record<string, any>, query?: Rec
|
||||||
*/
|
*/
|
||||||
function desktopHttpPut<T>(url: string, data?: Record<string, any>, query?: Record<string, any>, header?: Record<string, any>, options?: Partial<CustomRequestOptions>) {
|
function desktopHttpPut<T>(url: string, data?: Record<string, any>, query?: Record<string, any>, header?: Record<string, any>, options?: Partial<CustomRequestOptions>) {
|
||||||
const desktopUrl = url.startsWith('http') ? url : `${DESKTOP_BASE_URL}${url}`
|
const desktopUrl = url.startsWith('http') ? url : `${DESKTOP_BASE_URL}${url}`
|
||||||
return httpPut<T>(desktopUrl, data, query, header, options)
|
return httpPut<ExtractData<T>>(desktopUrl, data, query, header, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -39,7 +72,7 @@ function desktopHttpPut<T>(url: string, data?: Record<string, any>, query?: Reco
|
||||||
*/
|
*/
|
||||||
function desktopHttpDelete<T>(url: string, query?: Record<string, any>, header?: Record<string, any>, options?: Partial<CustomRequestOptions>) {
|
function desktopHttpDelete<T>(url: string, query?: Record<string, any>, header?: Record<string, any>, options?: Partial<CustomRequestOptions>) {
|
||||||
const desktopUrl = url.startsWith('http') ? url : `${DESKTOP_BASE_URL}${url}`
|
const desktopUrl = url.startsWith('http') ? url : `${DESKTOP_BASE_URL}${url}`
|
||||||
return httpDelete<T>(desktopUrl, query, header, options)
|
return httpDelete<ExtractData<T>>(desktopUrl, query, header, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -189,4 +222,7 @@ class DesktopHttpClient {
|
||||||
// 创建桌面端 HTTP 客户端实例
|
// 创建桌面端 HTTP 客户端实例
|
||||||
const httpClient = new DesktopHttpClient()
|
const httpClient = new DesktopHttpClient()
|
||||||
|
|
||||||
|
// 导出类型和客户端
|
||||||
|
export { TypedModelPageResponse }
|
||||||
|
export type { ExtractData }
|
||||||
export default httpClient
|
export default httpClient
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue