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>
|
||||
import type { ExamStudentMarkingQuestionResponse } from '@/api'
|
||||
import type { ExamQuestionWithTasksResponse } from '@/api'
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
|
|
@ -8,7 +8,8 @@ interface Props {
|
|||
isFullscreen: boolean
|
||||
currentQuestionIndex: number
|
||||
totalQuestions: number
|
||||
questions: ExamStudentMarkingQuestionResponse[]
|
||||
currentTaskSubmit: number
|
||||
questions: ExamQuestionWithTasksResponse[] // 改为 any[] 以支持不同的数据结构
|
||||
myScore?: number
|
||||
avgScore?: number
|
||||
}
|
||||
|
|
@ -40,6 +41,18 @@ const questionPickerColumns = computed(() => [
|
|||
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>
|
||||
|
||||
<template>
|
||||
|
|
@ -62,7 +75,7 @@ const questionPickerColumns = computed(() => [
|
|||
<!-- 题号信息 -->
|
||||
<view class="mr-16px flex items-center">
|
||||
<text class="text-16px text-gray-800 font-medium">
|
||||
{{ currentQuestionIndex + 1 }}/{{ totalQuestions }}
|
||||
{{ currentTaskSubmit + 1 }}/{{ totalQuestions }}
|
||||
</text>
|
||||
</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
|
||||
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="[
|
||||
index === currentQuestionIndex
|
||||
|
|
@ -150,14 +163,14 @@ const questionPickerColumns = computed(() => [
|
|||
:columns="questionPickerColumns"
|
||||
:value="String(currentQuestionIndex)"
|
||||
:title="`${currentQuestion?.question_major}.${currentQuestion?.question_minor}`"
|
||||
@confirm="(value) => emit('selectQuestion', Number(value))"
|
||||
@confirm="({ value }) => emit('selectQuestion', Number(value))"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 右侧分数信息 -->
|
||||
<view class="flex flex-1 justify-end">
|
||||
<text class="text-sm text-gray-600">
|
||||
我的均分/本题均分: {{ myScore }}/{{ avgScore }}
|
||||
我的均分/本题均分: {{ formatScore(myScore) }}/{{ formatScore(avgScore) }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
|
@ -167,7 +180,7 @@ const questionPickerColumns = computed(() => [
|
|||
<!-- 题号信息 -->
|
||||
<view class="flex items-center gap-4">
|
||||
<text class="text-base text-gray-800 font-medium">
|
||||
{{ currentQuestionIndex + 1 }}/{{ totalQuestions }}
|
||||
{{ currentTaskSubmit + 1 }}/{{ totalQuestions }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
|
|
|
|||
|
|
@ -12,12 +12,15 @@ interface Props {
|
|||
backgroundColor?: string
|
||||
currentTool: MarkingTool
|
||||
readOnly?: boolean
|
||||
adaptiveWidth?: boolean // 是否启用自适应宽度模式
|
||||
onQuickScore?: (value: number) => boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
scale: 1,
|
||||
backgroundColor: '#ffffff',
|
||||
readOnly: false,
|
||||
adaptiveWidth: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
@ -45,23 +48,49 @@ let layer: Konva.Layer | null = null
|
|||
const naturalWidth = 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(() => {
|
||||
if (!naturalWidth.value || !naturalHeight.value) {
|
||||
return {
|
||||
width: '100%',
|
||||
width: props.adaptiveWidth ? '100%' : '100%',
|
||||
height: '400px',
|
||||
backgroundColor: props.backgroundColor,
|
||||
}
|
||||
}
|
||||
|
||||
const scaledWidth = naturalWidth.value * props.scale
|
||||
const scaledHeight = naturalHeight.value * props.scale
|
||||
|
||||
return {
|
||||
width: `${scaledWidth}px`,
|
||||
height: `${scaledHeight}px`,
|
||||
backgroundColor: props.backgroundColor,
|
||||
if (props.adaptiveWidth) {
|
||||
// 自适应模式:容器宽度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 {
|
||||
width: `${scaledWidth}px`,
|
||||
height: `${scaledHeight}px`,
|
||||
backgroundColor: props.backgroundColor,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -69,6 +98,17 @@ const containerStyle = computed(() => {
|
|||
let layerManager: ReturnType<typeof useSimpleKonvaLayer> | null = null
|
||||
const message = useMessage()
|
||||
|
||||
/**
|
||||
* 更新容器尺寸
|
||||
*/
|
||||
function updateContainerSize() {
|
||||
if (!konvaContainer.value)
|
||||
return
|
||||
|
||||
containerWidth.value = konvaContainer.value.offsetWidth
|
||||
containerHeight.value = konvaContainer.value.offsetHeight
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化Konva画布
|
||||
*/
|
||||
|
|
@ -79,14 +119,17 @@ async function initKonva() {
|
|||
// 等待容器尺寸稳定
|
||||
await nextTick()
|
||||
|
||||
const containerWidth = konvaContainer.value.offsetWidth
|
||||
const containerHeight = konvaContainer.value.offsetHeight
|
||||
// 更新容器尺寸
|
||||
updateContainerSize()
|
||||
|
||||
const currentContainerWidth = containerWidth.value
|
||||
const currentContainerHeight = containerHeight.value
|
||||
|
||||
// 创建Stage
|
||||
const newStage = new Konva.Stage({
|
||||
container: konvaContainer.value,
|
||||
width: containerWidth,
|
||||
height: containerHeight,
|
||||
width: currentContainerWidth,
|
||||
height: currentContainerHeight,
|
||||
})
|
||||
|
||||
// 创建Layer
|
||||
|
|
@ -102,10 +145,10 @@ async function initKonva() {
|
|||
// 创建layer管理器
|
||||
layerManager = useSimpleKonvaLayer({
|
||||
layer: newLayer,
|
||||
scale,
|
||||
scale: actualScale,
|
||||
currentTool,
|
||||
initialData: props.markingData,
|
||||
onQuickScore: (value: number) => emit('quick-score', value),
|
||||
onQuickScore: (value: number) => props.onQuickScore?.(value),
|
||||
readOnly: props.readOnly,
|
||||
message,
|
||||
})
|
||||
|
|
@ -176,19 +219,31 @@ function applyScale() {
|
|||
if (!layer || !stage)
|
||||
return
|
||||
|
||||
const currentScale = actualScale.value
|
||||
|
||||
// 重置layer的变换
|
||||
layer.scale({ x: props.scale, y: props.scale })
|
||||
layer.scale({ x: currentScale, y: currentScale })
|
||||
layer.offset({ x: 0, y: 0 })
|
||||
layer.position({ x: 0, y: 0 })
|
||||
|
||||
// 更新stage尺寸以适应缩放后的图片
|
||||
const scaledWidth = naturalWidth.value * props.scale
|
||||
const scaledHeight = naturalHeight.value * props.scale
|
||||
|
||||
stage.size({
|
||||
width: scaledWidth,
|
||||
height: scaledHeight,
|
||||
})
|
||||
// 更新stage尺寸
|
||||
if (props.adaptiveWidth && containerWidth.value) {
|
||||
// 自适应模式: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({
|
||||
width: scaledWidth,
|
||||
height: scaledHeight,
|
||||
})
|
||||
}
|
||||
|
||||
layer.draw()
|
||||
}
|
||||
|
|
@ -210,28 +265,115 @@ watch(
|
|||
)
|
||||
|
||||
// 监听缩放变化
|
||||
watch([() => props.scale, actualScale], () => {
|
||||
applyScale()
|
||||
})
|
||||
|
||||
// 监听容器尺寸变化(自适应模式)
|
||||
watch(
|
||||
() => props.scale,
|
||||
() => {
|
||||
applyScale()
|
||||
() => 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({
|
||||
getLayerManager,
|
||||
getStage: () => stage,
|
||||
getLayer: () => layer,
|
||||
updateContainerSize,
|
||||
})
|
||||
|
||||
// 组件挂载时初始化
|
||||
onMounted(async () => {
|
||||
await initKonva()
|
||||
// 在自适应模式下设置尺寸监听器
|
||||
if (props.adaptiveWidth) {
|
||||
setupResizeObserver()
|
||||
}
|
||||
})
|
||||
|
||||
// 组件卸载时清理
|
||||
onUnmounted(() => {
|
||||
layerManager?.cleanupEvents()
|
||||
cleanupResizeObserver()
|
||||
stage?.destroy()
|
||||
})
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -207,7 +207,13 @@ async function toggleSpecialMark(type: 'excellent' | 'typical' | 'problem') {
|
|||
isProblem: true,
|
||||
problemType: result.problemType,
|
||||
problemRemark: result.problemRemark,
|
||||
score: 0,
|
||||
})
|
||||
|
||||
// 在单题模式下自动提交问题卷
|
||||
if (markingData.mode.value === 'single') {
|
||||
markingData.submitRecord()
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
|
|
@ -268,11 +274,6 @@ defineExpose({
|
|||
&& 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
|
||||
v-for="(imageUrl, imageIndex) in question.imageUrls"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { Ref } from 'vue'
|
||||
import type { ExamBatchCreateMarkingTaskRecordRequest, ExamCreateMarkingTaskRecordRequest, ExamQuestionAverageScoreComparisonResponse, ExamSetProblemRecordRequest, ExamStudentMarkingQuestionResponse } from '@/api'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import type { ExamBatchCreateMarkingTaskRecordRequest, ExamCreateMarkingTaskRecordRequest, ExamQuestionAverageScoreComparisonResponse, ExamQuestionWithTasksResponse, ExamSetProblemRecordRequest, ExamStudentMarkingQuestionResponse } from '@/api'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { useDebounceFn, useThrottleFn } from '@vueuse/core'
|
||||
import { computed, inject, provide, readonly, ref, watch } from 'vue'
|
||||
import { examMarkingTaskApi } from '@/api'
|
||||
|
||||
|
|
@ -19,11 +20,14 @@ export interface MarkingSubmitData {
|
|||
export interface UseMarkingDataOptions {
|
||||
taskId: Ref<number>
|
||||
questionId: Ref<number>
|
||||
examId?: Ref<number>
|
||||
subjectId?: Ref<number>
|
||||
isLandscape?: Ref<boolean>
|
||||
taskType?: Ref<string>
|
||||
}
|
||||
|
||||
function createMarkingData(options: UseMarkingDataOptions) {
|
||||
const { taskId, questionId, isLandscape } = options
|
||||
const { taskId, questionId, examId, subjectId, isLandscape, taskType } = options
|
||||
|
||||
// 基础数据
|
||||
const questionData = ref<ExamStudentMarkingQuestionResponse[]>([])
|
||||
|
|
@ -33,6 +37,8 @@ function createMarkingData(options: UseMarkingDataOptions) {
|
|||
const markingStartTime = ref<number>(0)
|
||||
const mode = ref<'single' | 'multi'>('single')
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// 当前分数
|
||||
const firstNotScoredIndex = computed(() => {
|
||||
return 0
|
||||
|
|
@ -59,7 +65,23 @@ function createMarkingData(options: UseMarkingDataOptions) {
|
|||
queryKey: computed(() => ['marking-question', taskId.value]),
|
||||
queryFn: async () => examMarkingTaskApi.questionDetail(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 {
|
||||
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 })
|
||||
|
||||
|
|
@ -132,6 +172,7 @@ function createMarkingData(options: UseMarkingDataOptions) {
|
|||
if (response) {
|
||||
isSubmitted.value = true
|
||||
// 重新获取下一题
|
||||
questionData.value = []
|
||||
await refetchQuestion()
|
||||
processQuestionData()
|
||||
return response
|
||||
|
|
@ -162,7 +203,9 @@ function createMarkingData(options: UseMarkingDataOptions) {
|
|||
|
||||
if (response) {
|
||||
isSubmitted.value = true
|
||||
currentTaskInfo.value!.marked_quantity = (currentTaskInfo.value!.marked_quantity || 0) + 1
|
||||
// 重新获取下一题
|
||||
questionData.value = []
|
||||
await refetchQuestion()
|
||||
processQuestionData()
|
||||
return response
|
||||
|
|
@ -184,15 +227,19 @@ function createMarkingData(options: UseMarkingDataOptions) {
|
|||
await Promise.all([
|
||||
refetchQuestion(),
|
||||
refetchAvgScore(),
|
||||
refetchQuestionsList(),
|
||||
])
|
||||
}
|
||||
|
||||
return {
|
||||
// 数据状态
|
||||
questionData,
|
||||
questionsList: computed(() => questionsListData.value || []), // 题目列表数据
|
||||
currentMarkingSubmitData,
|
||||
currentScore,
|
||||
firstNotScoredIndex,
|
||||
totalQuestions, // 总题目数量
|
||||
currentTaskInfo,
|
||||
isLoading: computed(() => isQuestionLoading.value || isLoading.value),
|
||||
isSubmitted: readonly(isSubmitted),
|
||||
mode,
|
||||
|
|
@ -211,6 +258,7 @@ function createMarkingData(options: UseMarkingDataOptions) {
|
|||
reload,
|
||||
refetchQuestion,
|
||||
refetchAvgScore,
|
||||
refetchQuestionsList,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ export function generateOnekeyScores(stepSize: number, fullScore: number): numbe
|
|||
export function getSelectedOnekeyScores(
|
||||
stepSize: number,
|
||||
fullScore: number,
|
||||
selections: Record<string, boolean>,
|
||||
selections: Record<string, boolean> = {},
|
||||
): number[] {
|
||||
const allScores = generateOnekeyScores(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": {
|
||||
"navigationBarTitleText": "阅卷监控"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/marking/review",
|
||||
"type": "page",
|
||||
"style": {
|
||||
"navigationBarTitleText": "回评"
|
||||
}
|
||||
}
|
||||
],
|
||||
"subPackages": []
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ async function handleStartMarking(task: any) {
|
|||
|
||||
// 跳转到阅卷界面
|
||||
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) {
|
||||
|
|
@ -115,32 +115,11 @@ async function handleStartMarking(task: any) {
|
|||
}
|
||||
}
|
||||
|
||||
// 回评
|
||||
async function handleRemark(task: any) {
|
||||
try {
|
||||
await uni.showModal({
|
||||
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',
|
||||
})
|
||||
}
|
||||
}
|
||||
// 查看回评
|
||||
async function handleViewReview(task: any) {
|
||||
uni.navigateTo({
|
||||
url: `/pages/marking/review?examId=${examId.value}&subjectId=${subjectId.value}&questionId=${task.question_id}&taskId=${task.id}`,
|
||||
})
|
||||
}
|
||||
|
||||
// 刷新数据
|
||||
|
|
@ -217,12 +196,12 @@ function handleRefresh() {
|
|||
</view>
|
||||
|
||||
<view class="flex gap-2">
|
||||
<!-- 回评按钮 -->
|
||||
<!-- 查看回评按钮 -->
|
||||
<wd-button
|
||||
v-if="(task.marked_quantity || 0) > 0"
|
||||
size="small"
|
||||
type="warning"
|
||||
@click="handleRemark(task)"
|
||||
type="info"
|
||||
@click="handleViewReview(task)"
|
||||
>
|
||||
回评
|
||||
</wd-button>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@
|
|||
</route>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { whenever } from '@vueuse/core'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import AnswerDialog from '@/components/marking/components/dialog/AnswerDialog.vue'
|
||||
import AvgScoreDialog from '@/components/marking/components/dialog/AvgScoreDialog.vue'
|
||||
|
|
@ -27,6 +29,7 @@ const subjectId = ref<number>()
|
|||
const questionId = ref<number>()
|
||||
const taskId = ref<number>()
|
||||
const evaluationType = ref<'single' | 'double'>('single')
|
||||
const taskType = ref<'initial' | 'final' | 'arbitration'>('initial')
|
||||
|
||||
// 屏幕方向状态
|
||||
const isLandscape = ref(false)
|
||||
|
|
@ -34,15 +37,18 @@ const isLandscape = ref(false)
|
|||
const markingData = provideMarkingData({
|
||||
taskId,
|
||||
questionId,
|
||||
examId,
|
||||
subjectId,
|
||||
isLandscape,
|
||||
taskType,
|
||||
})
|
||||
const { questionData: questions } = markingData
|
||||
const { questionData: questions, questionsList, totalQuestions: totalQuestionsCount } = markingData
|
||||
|
||||
// 全屏状态
|
||||
const isFullscreen = ref(false)
|
||||
|
||||
const currentQuestionIndex = ref(0)
|
||||
const totalQuestions = computed(() => questions.value.length)
|
||||
const totalQuestions = computed(() => totalQuestionsCount.value)
|
||||
|
||||
// 分数数据
|
||||
const myScore = computed(() => {
|
||||
|
|
@ -61,6 +67,7 @@ onLoad((options) => {
|
|||
questionId.value = Number(options.questionId)
|
||||
taskId.value = Number(options.taskId) // 从路由参数获取taskId
|
||||
evaluationType.value = options.type as 'single' | 'double' || 'single'
|
||||
taskType.value = options.taskType
|
||||
})
|
||||
|
||||
// 监听屏幕方向变化
|
||||
|
|
@ -167,6 +174,7 @@ function goBack() {
|
|||
// 题目选择
|
||||
function selectQuestion(index: number) {
|
||||
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) {
|
||||
|
|
@ -213,8 +232,9 @@ function handleQuickScoreSelect(score: number) {
|
|||
:is-landscape="isLandscape"
|
||||
:is-fullscreen="isFullscreen"
|
||||
:current-question-index="currentQuestionIndex"
|
||||
:current-task-submit="currentTask?.marked_quantity || 0"
|
||||
:total-questions="totalQuestions"
|
||||
:questions="questions"
|
||||
:questions="questionsList"
|
||||
:my-score="myScore"
|
||||
:avg-score="avgScore"
|
||||
@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 客户端
|
||||
* 基于现有的 uni.request 架构,自动注入 baseURL: api.xianglexue.com
|
||||
|
|
@ -10,12 +11,44 @@ import { http, httpDelete, httpGet, httpPost, httpPut } from '@/http/http'
|
|||
// 桌面端 baseURL
|
||||
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 请求
|
||||
*/
|
||||
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}`
|
||||
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>) {
|
||||
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>) {
|
||||
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>) {
|
||||
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 客户端实例
|
||||
const httpClient = new DesktopHttpClient()
|
||||
|
||||
// 导出类型和客户端
|
||||
export { TypedModelPageResponse }
|
||||
export type { ExtractData }
|
||||
export default httpClient
|
||||
|
|
|
|||
Loading…
Reference in New Issue