refactor: 阅卷一堆优化
continuous-integration/drone/push Build is passing Details

This commit is contained in:
AfyerCu 2025-11-04 22:29:47 +08:00
parent bcacba59a1
commit 7103b3a04b
16 changed files with 1651 additions and 326 deletions

View File

@ -256,10 +256,6 @@ importers:
specifier: ^2.2.10
version: 2.2.12(typescript@5.9.2)
src/uni_modules/uni-icons: {}
src/uni_modules/uni-scss: {}
packages:
'@alova/adapter-uniapp@2.0.14':

View File

@ -1,17 +1,22 @@
<!-- 阅卷布局组件 -->
<script lang="ts" setup>
import type { ExamQuestionWithTasksResponse } from '@/api'
import { computed } from 'vue'
import { computed, ref, watch } from 'vue'
import { useMarkingHistory } from '@/composables/marking/useMarkingHistory'
interface Props {
isLandscape: boolean
isFullscreen: boolean
currentQuestionIndex: number
totalQuestions: number
currentTaskSubmit: number
questions: ExamQuestionWithTasksResponse[] // any[]
questions: ExamQuestionWithTasksResponse[]
myScore?: number
avgScore?: number
isViewingHistory?: boolean
canGoPrev?: boolean
canGoNext?: boolean
historyModeText?: string
taskId?: number
}
interface Emits {
@ -22,15 +27,36 @@ interface Emits {
(e: 'viewAnswer'): void
(e: 'toggleOrientation'): void
(e: 'toggleFullscreen'): void
(e: 'prevQuestion'): void
(e: 'nextQuestion'): void
}
const props = withDefaults(defineProps<Props>(), {
myScore: 0,
avgScore: 0,
isViewingHistory: false,
canGoPrev: false,
canGoNext: false,
historyModeText: '',
taskId: 0,
})
const emit = defineEmits<Emits>()
//
const markingHistory = useMarkingHistory()
const currentHistoryPage = ref(1)
const { data: historyPageData, isLoading: isLoadingHistory } = markingHistory.useHistoryPage(
computed(() => currentHistoryPage.value),
)
// taskId
watch(() => props.taskId, () => {
if (props.taskId) {
currentHistoryPage.value = 1
}
})
//
const currentQuestion = computed(() => props.questions[props.currentQuestionIndex])
@ -53,6 +79,34 @@ function formatScore(score: number): string {
const formatted = score.toFixed(2)
return formatted.replace(/\.?0+$/, '')
}
//
const historyList = computed(() => {
if (!historyPageData.value?.list)
return []
// 使
return historyPageData.value.list
})
//
async function goToHistoryIndex(index: number) {
//
// index 0 total - 1
// index n-1 0
const total = markingHistory.totalHistoryCount.value
const globalIndex = total - 1 - index
await markingHistory.goToHistoryIndex(globalIndex)
}
//
const currentHistoryIndexInList = computed(() => {
if (!props.isViewingHistory)
return -1
const currentIndex = markingHistory.currentHistoryIndex.value
const total = markingHistory.totalHistoryCount.value
// n-1 0
return total - 1 - currentIndex
})
</script>
<template>
@ -75,7 +129,7 @@ function formatScore(score: number): string {
<!-- 题号信息 -->
<view class="mr-16px flex items-center">
<text class="text-16px text-gray-800 font-medium">
{{ currentTaskSubmit + 1 }}/{{ totalQuestions }}
{{ isViewingHistory ? historyModeText : `${currentTaskSubmit + 1}/${totalQuestions}` }}
</text>
</view>
@ -110,8 +164,7 @@ function formatScore(score: number): string {
@click="emit('toggleFullscreen')"
>
<view
:class="isFullscreen ? 'i-carbon-minimize' : 'i-carbon-maximize'"
class="size-18px text-gray-700"
class="i-carbon-image-search size-18px text-gray-700"
/>
</view>
</view>
@ -120,25 +173,46 @@ function formatScore(score: number): string {
<!-- 内容区域 -->
<view class="h-0 flex flex-1">
<!-- 左侧题目选择器 -->
<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 overflow-auto border-r border-gray-200 bg-white">
<!-- 历史记录列表 -->
<view
v-for="(question, index) in questions"
:key="question.question_id"
class="flex cursor-pointer items-center justify-center rounded py-6px text-12px transition-colors"
v-for="(record, index) in historyList"
:key="record.id"
class="flex flex-col cursor-pointer items-center justify-center border-b border-gray-100 px-4px py-8px text-10px transition-colors"
:class="[
index === currentQuestionIndex
currentHistoryIndexInList === index
? 'bg-blue-500 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200',
: 'bg-white text-gray-700 hover:bg-gray-50',
]"
@click="emit('selectQuestion', index)"
@click="goToHistoryIndex(index)"
>
{{ question.question_major }}.{{ question.question_minor }}
<view class="mb-2px font-medium">
{{ record.question_major }}.{{ record.question_minor }}
</view>
<view class="text-12px font-semibold">
{{ formatScore(record.score) }}/{{ formatScore(record.full_score) }}
</view>
</view>
</view>
<!-- 右侧作答区域 -->
<view class="relative h-full flex-1 overflow-auto bg-green-200">
<!-- 左右箭头按钮 -->
<view
class="absolute left-2 top-1/2 size-32px flex cursor-pointer items-center justify-center rounded transition-colors -translate-y-1/2"
:class="canGoPrev ? 'bg-gray-100 hover:bg-gray-200' : 'bg-gray-50 opacity-50 cursor-not-allowed'"
@click="canGoPrev && emit('prevQuestion')"
>
<view class="i-carbon-chevron-left size-16px text-gray-700" />
</view>
<view
class="absolute right-2 top-1/2 size-32px flex cursor-pointer items-center justify-center rounded transition-colors -translate-y-1/2"
:class="canGoNext ? 'bg-gray-100 hover:bg-gray-200' : 'bg-gray-50 opacity-50 cursor-not-allowed'"
@click="canGoNext && emit('nextQuestion')"
>
<view class="i-carbon-chevron-right size-16px text-gray-700" />
</view>
<slot name="content" :current-question="currentQuestion" />
</view>
</view>
@ -179,8 +253,11 @@ function formatScore(score: number): string {
<view class="h-12 flex items-center border-t border-gray-200 bg-white px-4 py-2">
<!-- 题号信息 -->
<view class="flex items-center gap-4">
<text class="text-base text-gray-800 font-medium">
{{ currentTaskSubmit + 1 }}/{{ totalQuestions }}
<text v-if="!isViewingHistory" class="text-base text-gray-800 font-medium">
{{ Math.min(totalQuestions, currentTaskSubmit + 1) }}/{{ totalQuestions }}
</text>
<text v-else class="text-base text-gray-800 font-medium">
{{ historyModeText }}
</text>
</view>
@ -215,8 +292,7 @@ function formatScore(score: number): string {
@click="emit('toggleFullscreen')"
>
<view
:class="isFullscreen ? 'i-carbon-minimize' : 'i-carbon-maximize'"
class="size-16px text-gray-700"
class="i-carbon-image-search size-16px text-gray-700"
/>
</view>
</view>
@ -224,6 +300,22 @@ function formatScore(score: number): string {
<!-- 作答区域 -->
<view class="relative h-0 flex-1 bg-green-200">
<!-- 左右箭头按钮 -->
<view
class="absolute left-2 top-1/2 size-28px flex cursor-pointer items-center justify-center rounded transition-colors -translate-y-1/2"
:class="canGoPrev ? 'bg-gray-100 hover:bg-gray-200' : 'bg-gray-50 opacity-50 cursor-not-allowed'"
@click="canGoPrev && emit('prevQuestion')"
>
<view class="i-carbon-chevron-left size-16px text-gray-700" />
</view>
<view
class="absolute right-2 top-1/2 size-28px flex cursor-pointer items-center justify-center rounded transition-colors -translate-y-1/2"
:class="canGoNext ? 'bg-gray-100 hover:bg-gray-200' : 'bg-gray-50 opacity-50 cursor-not-allowed'"
@click="canGoNext && emit('nextQuestion')"
>
<view class="i-carbon-chevron-right size-16px text-gray-700" />
</view>
<slot name="content" :current-question="currentQuestion" />
</view>
</view>

View File

@ -72,8 +72,8 @@ const displayItems = computed(() => {
}
})
//
const landscapeLayout = computed(() => {
// 使
const twoColumnLayout = computed(() => {
const items = displayItems.value
const layout: Array<Array<typeof items[0]>> = []
@ -190,33 +190,33 @@ async function submitCurrentScore() {
'h-[calc(100vh-80px)] overflow-auto': isLandscape && !isCollapsed,
}"
>
<!-- 按钮内容 -->
<div v-if="!isLandscape || !isCollapsed" class="flex flex-col gap-2px">
<!-- 竖屏一列布局 -->
<template v-if="!isLandscape">
<!-- 按钮内容 - 始终展示 -->
<div class="flex flex-col gap-2px">
<!-- 收起时一列布局 -->
<template v-if="isCollapsed">
<div
v-for="item in displayItems"
:key="item.label"
class="flex cursor-pointer items-center justify-center border-2 rounded-8px transition-all active:scale-95"
:class="[
item.class,
scoreMode === 'onekey' ? 'size-10' : 'h-10 w-16',
scoreMode === 'onekey' ? 'size-48rpx lg:size-12' : 'h-48rpx w-80rpx lg:h-12 lg:w-20',
]"
@click="item.action"
>
<text
class="font-medium"
:class="scoreMode === 'onekey' ? 'text-18px' : 'text-14px'"
:class="scoreMode === 'onekey' ? 'text-18rpx lg:text-base' : 'text-14rpx lg:text-sm'"
>
{{ item.label }}
</text>
</div>
</template>
<!-- 横屏两列布局 -->
<!-- 展开时两列布局 -->
<template v-else>
<div
v-for="(row, index) in landscapeLayout"
v-for="(row, index) in twoColumnLayout"
:key="index"
class="flex gap-2px"
>
@ -246,12 +246,11 @@ async function submitCurrentScore() {
</template>
</div>
<!-- 操作按钮 -->
<!-- 收起/展开按钮 - 始终显示 -->
<div
v-if="scoreMode === 'onekey' && isLandscape"
class="flex cursor-pointer items-center justify-center border-2 border-blue-200 rounded-8px bg-blue-50 transition-all active:scale-95"
:class="isLandscape ? 'size-48rpx lg:size-12' : 'size-10'"
@click="isLandscape ? toggleCollapse() : undefined"
@click="toggleCollapse"
>
<div
class="text-blue-500 transition-transform"

View File

@ -0,0 +1,120 @@
<script setup lang="ts">
interface Props {
imageUrls: string[]
}
const props = defineProps<Props>()
const visible = defineModel<boolean>({ required: true })
/**
* 扩大阿里云OSS图片裁剪范围
* 正则匹配阿里云OSS裁剪参数如果存在则扩大范围
* 例如/crop,w_500,h_500,x_100,y_100 -> /crop,w_750,h_750,x_25,y_25
*/
function expandCropArea(url: string): string {
// OSScrop/crop,w_,h_,x_,y_
const cropRegex = /crop,w_(\d+),h_(\d+),x_(\d+),y_(\d+)/
const match = url.match(cropRegex)
if (match) {
const [, w, h, x, y] = match
const xNum = Number.parseInt(x)
const yNum = Number.parseInt(y)
const wNum = Number.parseInt(w)
const hNum = Number.parseInt(h)
// 50%
const expandRatio = 0.5
const expandX = Math.floor((wNum * expandRatio) / 2)
const expandY = Math.floor((hNum * expandRatio) / 2)
const newX = Math.max(0, xNum - expandX)
const newY = Math.max(0, yNum - expandY)
const newW = wNum + expandX * 2
const newH = hNum + expandY * 2
const newCrop = `crop,w_${newW},h_${newH},x_${newX},y_${newY}`
return url.replace(cropRegex, newCrop)
}
return url
}
// URL
const expandedImageUrls = computed(() => {
return props.imageUrls.map(url => expandCropArea(url))
})
</script>
<template>
<wd-popup
v-model="visible"
position="center"
:close-on-click-modal="false"
custom-class="fullscreen-dialog"
>
<view class="fullscreen-container h-100vh w-100vw flex flex-col bg-black">
<!-- 顶部工具栏 -->
<view class="flex items-center justify-between bg-black/80 px-4 py-3">
<text class="text-base text-white font-medium">全屏查看</text>
<view
class="size-8 flex cursor-pointer items-center justify-center rounded transition-colors hover:bg-white/20"
@click="visible = false"
>
<view class="i-carbon-close size-5 text-white" />
</view>
</view>
<!-- 图片展示区 -->
<view class="flex-1 overflow-auto">
<!-- 单张图片 -->
<view
v-if="expandedImageUrls.length === 1"
class="h-full flex items-center justify-center"
>
<image
:src="expandedImageUrls[0]"
mode="aspectFit"
class="h-full w-full"
:show-menu-by-longpress="true"
/>
</view>
<!-- 多张图片 -->
<view v-else class="flex flex-col gap-4 p-4">
<view
v-for="(imageUrl, index) in expandedImageUrls"
:key="index"
class="flex items-center justify-center rounded bg-gray-900"
style="min-height: 300px"
>
<image
:src="imageUrl"
mode="aspectFit"
class="h-full w-full"
:show-menu-by-longpress="true"
/>
</view>
</view>
</view>
<!-- 底部提示 -->
<view class="bg-black/80 px-4 py-2 text-center">
<text class="text-sm text-white/70">长按图片可保存</text>
</view>
</view>
</wd-popup>
</template>
<style>
/* 全局样式,确保弹窗全屏 */
.fullscreen-dialog {
width: 100vw !important;
height: 100vh !important;
max-width: 100vw !important;
max-height: 100vh !important;
border-radius: 0 !important;
}
</style>

View File

@ -318,25 +318,6 @@ function resetQuickScoreScores() {
<!-- 快捷打分设置 -->
<div v-if="activeTab === 'quick'" class="space-y-24px">
<!-- 缩放面板设置 -->
<div>
<div class="mb-12px text-14px text-gray-700">
缩放面板设置
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-12px">
<text class="text-13px text-gray-600">显示缩放面板</text>
<wd-switch v-model="settings.showScalePanel" size="small" />
</div>
<div v-if="settings.showScalePanel" class="flex items-center gap-12px">
<text class="text-13px text-gray-600">默认收起</text>
<wd-switch v-model="settings.scalePanelCollapsed" size="small" />
</div>
</div>
<div class="mt-8px text-12px text-gray-500">
缩放面板可以帮助您快速调整图片大小和适应屏幕
</div>
</div>
<!-- 设置加分/扣分步长 -->
<div>
<div class="mb-12px flex items-center justify-between text-14px text-gray-700">
@ -437,25 +418,6 @@ function resetQuickScoreScores() {
<!-- 一键打分设置 -->
<div v-if="activeTab === 'onekey'" class="space-y-24px">
<!-- 缩放面板设置 -->
<div>
<div class="mb-12px text-14px text-gray-700">
缩放面板设置
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-12px">
<text class="text-13px text-gray-600">显示缩放面板</text>
<wd-switch v-model="settings.showScalePanel" size="small" />
</div>
<div v-if="settings.showScalePanel" class="flex items-center gap-12px">
<text class="text-13px text-gray-600">默认收起</text>
<wd-switch v-model="settings.scalePanelCollapsed" size="small" />
</div>
</div>
<div class="mt-8px text-12px text-gray-500">
缩放面板可以帮助您快速调整图片大小和适应屏幕
</div>
</div>
<!-- 设置步长 -->
<div>
<div class="mb-12px flex items-center justify-between text-14px text-gray-700">

View File

@ -2,11 +2,11 @@
import type { KonvaMarkingData } from '../../composables/renderer/useMarkingKonva'
import type { ExamStudentMarkingQuestionResponse } from '@/api'
import { markingSettings } from '../../composables/useMarkingSettings'
import { useSmartScale } from '../../composables/useSmartScale'
import QuestionRenderer from './QuestionRenderer.vue'
interface Props {
imageSize: number
questionData: ExamStudentMarkingQuestionResponse[]
}
const props = defineProps<Props>()
@ -15,16 +15,43 @@ const emit = defineEmits<{
'marking-change': [questionIndex: number, imageIndex: number, data: KonvaMarkingData]
}>()
//
const smartScale = useSmartScale(props.imageSize)
const scale = defineModel<number>('scale', { default: 1.0 })
const questionData = defineModel<ExamStudentMarkingQuestionResponse[]>('questionData')
// -
// 1.0 = 100%0.5 = 50%2.0 = 200%
const userScaleFactor = ref(scale.value || 1.0)
// - 100%
const finalScale = computed(() => {
return userScaleFactor.value
})
//
function setScale(newScale: number) {
// 0.15
const clampedScale = Math.max(0.1, Math.min(5.0, newScale))
userScaleFactor.value = clampedScale
scale.value = clampedScale
}
//
const scaleText = computed(() => {
const percentage = Math.round(userScaleFactor.value * 100)
return `${percentage}%`
})
// scale
watch(scale, (newScale) => {
if (newScale !== undefined && newScale !== userScaleFactor.value) {
userScaleFactor.value = newScale
}
})
//
const containerRef = ref<HTMLElement>()
const initialDistance = ref(0)
const initialScale = ref(1)
const isGesturing = ref(false)
const initialScale = ref(1.0)
/**
* 计算两点之间的距离
@ -43,7 +70,7 @@ function handleTouchStart(e: TouchEvent) {
//
isGesturing.value = true
initialDistance.value = getDistance(e.touches[0], e.touches[1])
initialScale.value = smartScale.userScaleFactor.value
initialScale.value = userScaleFactor.value
e.preventDefault()
}
}
@ -61,7 +88,7 @@ function handleTouchMove(e: TouchEvent) {
//
const newScale = initialScale.value * scaleChange
smartScale.setScale(newScale)
setScale(newScale)
e.preventDefault()
}
@ -86,10 +113,10 @@ const questionsLayoutClass = computed(() => ({
//
const questionDataList = computed(() => {
if (!questionData.value?.length)
if (!props.questionData?.length)
return []
return questionData.value.map((item, index) => ({
return props.questionData.map((item, index) => ({
id: `question_${item.tpl_question_id || index}_${item.scan_info_id}_${index}`,
title: `${item.question_major || ''}${item.question_minor || ''}`,
fullScore: item.full_score || 0,
@ -109,8 +136,8 @@ function handleMarkingChange(questionIndex: number, imageIndex: number, data: Ko
<div
ref="containerRef"
class="multi-question-renderer"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchstart="handleTouchStart"
@touchend="handleTouchEnd"
>
<!-- 缩放提示 -->
@ -118,7 +145,7 @@ function handleMarkingChange(questionIndex: number, imageIndex: number, data: Ko
v-if="isGesturing"
class="fixed left-1/2 top-1/2 z-50 rounded-lg bg-black/70 px-4 py-2 text-white -translate-x-1/2 -translate-y-1/2"
>
{{ smartScale.scaleText.value }}
{{ scaleText }}
</div>
<div class="flex flex-col flex-nowrap" :class="questionsLayoutClass">
@ -127,7 +154,7 @@ function handleMarkingChange(questionIndex: number, imageIndex: number, data: Ko
:key="question.id"
:question="question"
:question-index="questionIndex"
:scale="smartScale.finalScale.value"
:scale="finalScale"
:image-layout="markingSettings.imageLayout"
:show-toolbar="markingSettings.showTraceToolbar"
class="question-item w-fit"
@ -140,6 +167,7 @@ function handleMarkingChange(questionIndex: number, imageIndex: number, data: Ko
<style scoped>
.multi-question-renderer {
width: 100%;
height: 100%;
overflow: auto;
padding: 8rpx 16rpx;
padding-bottom: 96rpx;

View File

@ -1,5 +1,6 @@
<script setup lang="ts">
import type { KonvaMarkingData, useSimpleKonvaLayer } from '../../composables/renderer/useMarkingKonva'
import { MarkingTool } from '../../composables/renderer/useMarkingKonva'
import { DictCode, useDict } from '@/composables/useDict'
import { useMarkingData } from '../../composables/useMarkingData'
@ -144,6 +145,15 @@ function undo() {
* 处理快捷打分
*/
function handleQuickScore(value: number) {
//
if (currentTool.value !== MarkingTool.SELECT) {
return false
}
if (!markingSettings.value.quickScoreClickMode) {
return false
}
if (currentMarkingData.value) {
// -1
const currentScore =

View File

@ -160,35 +160,28 @@ function getSpecialButtonClass(type: 'excellent' | 'typical' | 'problem') {
}
/**
* 切换工具
* 切换工具支持取消激活
*/
function switchTool(tool: MarkingTool) {
currentTool.value = tool
currentTool.value = currentTool.value === tool ? MarkingTool.SELECT : tool
pendingMarkType.value = null
// /
settings.value.showToolOptions = tool === 'pen' || tool === 'text'
settings.value.showToolOptions = currentTool.value === 'pen' || currentTool.value === 'text'
}
/**
* 处理正确标记
* 处理标记工具切换正确/错误/半对
*/
function handleCorrectMark() {
currentTool.value = MarkingTool.CORRECT
function handleMarkTool(type: 'correct' | 'wrong' | 'half') {
const toolMap = {
correct: MarkingTool.CORRECT,
wrong: MarkingTool.WRONG,
half: MarkingTool.HALF,
}
/**
* 处理错误标记
*/
function handleWrongMark() {
currentTool.value = MarkingTool.WRONG
}
/**
* 处理半对标记
*/
function handleHalfMark() {
currentTool.value = MarkingTool.HALF
const tool = toolMap[type]
currentTool.value = currentTool.value === tool ? MarkingTool.SELECT : tool
pendingMarkType.value = null
}
/**
@ -489,19 +482,19 @@ defineExpose({
<div :class="separatorClass" />
<!-- 对错半对标记 -->
<wd-tooltip content="正确标记" placement="top">
<button :class="getMarkButtonClass('correct')" @click="handleCorrectMark">
<button :class="getMarkButtonClass('correct')" @click="handleMarkTool('correct')">
<div :class="iconClass" class="i-fluent:checkmark-24-regular" />
</button>
</wd-tooltip>
<wd-tooltip content="错误标记" placement="top">
<button :class="getMarkButtonClass('wrong')" @click="handleWrongMark">
<button :class="getMarkButtonClass('wrong')" @click="handleMarkTool('wrong')">
<div :class="iconClass" class="i-fluent:dismiss-24-regular" />
</button>
</wd-tooltip>
<wd-tooltip content="半对标记" placement="top">
<button :class="getMarkButtonClass('half')" @click="handleHalfMark">
<button :class="getMarkButtonClass('half')" @click="handleMarkTool('half')">
<svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<!-- 对勾的第一段短的向下斜线 -->
<line

View File

@ -146,12 +146,16 @@ export function useSimpleKonvaLayer({
const target = e.target
const clickedOnExistingShape = target !== (layer as any) && target.getClassName() !== 'Image'
// 如果快捷打分点击模式激活,优先处理
if (markingSettings.value.quickScoreClickMode) {
// 如果快捷打分点击模式激活,且当前没有选择任何工具,优先处理(仅左键)
if (markingSettings.value.quickScoreClickMode && currentTool.value === MarkingTool.SELECT) {
const mouseEvent = e.evt as MouseEvent
// 只处理左键点击button === 0
if (mouseEvent.button === 0) {
const pos = getRelativePosition()
if (pos) {
handleQuickScoreClick(pos)
}
}
return
}
@ -411,7 +415,7 @@ export function useSimpleKonvaLayer({
x: pos.x,
y: pos.y,
text,
fontSize: markingSettings.value.textSize,
fontSize: 48,
color: scoreMode === 'add' ? '#ff4d4f' : '#1890ff',
}

View File

@ -1,9 +1,10 @@
import type { Ref } from 'vue'
import type { ExamBatchCreateMarkingTaskRecordRequest, ExamCreateMarkingTaskRecordRequest, ExamQuestionAverageScoreComparisonResponse, ExamQuestionWithTasksResponse, ExamSetProblemRecordRequest, ExamStudentMarkingQuestionResponse } from '@/api'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { useDebounceFn, useThrottleFn } from '@vueuse/core'
import type { ExamBatchCreateMarkingTaskRecordRequest, ExamCreateMarkingTaskRecordRequest, ExamQuestionWithTasksResponse, ExamSetProblemRecordRequest, ExamStudentMarkingQuestionResponse } from '@/api'
import { useQuery } from '@tanstack/vue-query'
import { computed, inject, provide, readonly, ref, watch } from 'vue'
import { examMarkingTaskApi } from '@/api'
import { getMarkingContext } from '@/composables/marking/MarkingContext'
import { useMarkingHistory } from '@/composables/marking/useMarkingHistory'
import { useUserId } from '@/composables/useUserId'
export interface MarkingSubmitData {
@ -30,6 +31,10 @@ export interface UseMarkingDataOptions {
function createMarkingData(options: UseMarkingDataOptions) {
const { taskId, questionId, examId, subjectId, isLandscape, taskType } = options
// 获取阅卷上下文和历史管理
const markingContext = getMarkingContext()
const markingHistory = useMarkingHistory()
// 基础数据
const questionData = ref<ExamStudentMarkingQuestionResponse[]>([])
const currentMarkingSubmitData = ref<MarkingSubmitData[]>([])
@ -38,8 +43,6 @@ function createMarkingData(options: UseMarkingDataOptions) {
const markingStartTime = ref<number>(0)
const mode = ref<'single' | 'multi'>('single')
const queryClient = useQueryClient()
// 当前分数
const firstNotScoredIndex = computed(() => {
return 0
@ -56,7 +59,7 @@ function createMarkingData(options: UseMarkingDataOptions) {
},
})
// 获取题目数据
// 获取题目数据(使用上下文提供者)
const {
data: questionResponse,
isLoading: isQuestionLoading,
@ -64,7 +67,27 @@ function createMarkingData(options: UseMarkingDataOptions) {
refetch: refetchQuestion,
} = useQuery({
queryKey: ['marking-question', taskId, useUserId()],
queryFn: async () => examMarkingTaskApi.questionDetail(taskId.value),
queryFn: async () => {
// 如果在历史查看模式,返回历史记录
if (markingHistory.isViewingHistory.value && markingHistory.currentHistoryRecord.value) {
const record = markingHistory.currentHistoryRecord.value
// 将历史记录转换为题目数据格式
return {
id: record.id,
task_id: record.task_id,
question_id: record.question_id,
scan_info_id: record.scan_info_id,
score: record.score,
full_score: record.full_score,
question_major: record.question_major,
question_minor: record.question_minor,
image_urls: record.image_urls,
} as ExamStudentMarkingQuestionResponse
}
// 否则使用上下文提供者获取当前题目
return markingContext.dataProvider.getSingleQuestion(taskId.value)
},
enabled: computed(() => !!taskId.value),
gcTime: 0,
})
@ -160,6 +183,7 @@ function createMarkingData(options: UseMarkingDataOptions) {
throw new Error('题目数据不存在')
const currentData = data || currentMarkingSubmitData.value[index]
const isHistoryMode = markingHistory.isViewingHistory.value
// 如果是问题卷,只提交问题卷记录,不提交正常阅卷记录
if (currentData.isProblem) {
@ -169,15 +193,24 @@ function createMarkingData(options: UseMarkingDataOptions) {
problem_type: currentData.problemType! as any,
problem_addition: currentData.problemRemark,
}
const response = await examMarkingTaskApi.problemRecordCreate(problemRequest)
if (response) {
if (markingContext.dataProvider.createProblemRecord) {
await markingContext.dataProvider.createProblemRecord(problemRequest as any)
}
isSubmitted.value = true
// 如果是历史模式,刷新历史记录
if (isHistoryMode) {
await markingHistory.forceRefreshHistory()
}
else {
// 重新获取下一题
questionData.value = []
await refetchQuestion()
processQuestionData()
return response
}
return
}
@ -186,6 +219,7 @@ function createMarkingData(options: UseMarkingDataOptions) {
id: question.id,
scan_info_id: question.scan_info_id!,
question_id: questionId.value,
record_id: 0,
task_id: taskId.value,
duration: getMarkingTime(),
score: currentData.score,
@ -200,15 +234,33 @@ function createMarkingData(options: UseMarkingDataOptions) {
batch_data: [submitData],
}
const response = await examMarkingTaskApi.batchCreate(batchRequest)
// 使用上下文提供者提交
const response = await markingContext.dataProvider.submitRecords(batchRequest)
if (response) {
isSubmitted.value = true
currentTaskInfo.value!.marked_quantity = (currentTaskInfo.value!.marked_quantity || 0) + 1
// 更新历史记录总数
if (!isHistoryMode && response.success_count > 0) {
markingHistory.incrementHistoryCount(response.success_count)
}
// 更新已阅数量
if (currentTaskInfo.value) {
currentTaskInfo.value.marked_quantity = (currentTaskInfo.value.marked_quantity || 0) + 1
}
// 如果是历史模式,刷新历史记录
if (isHistoryMode) {
await markingHistory.forceRefreshHistory()
}
else {
// 重新获取下一题
questionData.value = []
await refetchQuestion()
processQuestionData()
}
return response
}
}

View File

@ -1,55 +0,0 @@
import { computed, ref } from 'vue'
/**
* -
*/
export function useSmartScale(imageSize: number = 100) {
// 用户手动调节的缩放因子1.0 = 默认100%0.5 = 缩小50%2.0 = 放大200%
const userScaleFactor = ref(1.0)
// 最终的缩放比例 - 默认100%
const finalScale = computed(() => {
return userScaleFactor.value
})
// 设置缩放比例(用于手势缩放)
const setScale = (scale: number) => {
// 限制缩放范围0.3倍到5倍
userScaleFactor.value = Math.max(0.3, Math.min(5.0, scale))
}
// 缩放控制方法(保留用于可能的按钮控制)
const zoomIn = () => {
userScaleFactor.value = Math.min(userScaleFactor.value + 0.2, 5.0)
}
const zoomOut = () => {
userScaleFactor.value = Math.max(userScaleFactor.value - 0.2, 0.3)
}
const resetZoom = () => {
userScaleFactor.value = 1.0
}
// 缩放级别文本
const scaleText = computed(() => {
const percentage = Math.round(userScaleFactor.value * 100)
return `${percentage}%`
})
// 是否可以放大/缩小
const canZoomIn = computed(() => userScaleFactor.value < 5.0)
const canZoomOut = computed(() => userScaleFactor.value > 0.3)
return {
finalScale,
userScaleFactor,
scaleText,
canZoomIn,
canZoomOut,
zoomIn,
zoomOut,
resetZoom,
setScale,
}
}

View File

@ -0,0 +1,302 @@
/**
*
*
* - 使 TanStack Query
*/
import type {
ExamBatchCreateMarkingTaskRecordRequest,
ExamStudentMarkingQuestionResponse,
ExamStudentMarkingQuestionsResponse,
} from '@/api'
import { examMarkingTaskApi } from '@/api'
// 分页响应接口
export interface PaginatedResponse<T> {
list: T[]
page: number
page_size: number
total: number
has_more?: boolean
}
// 提交响应接口
export interface SubmitResponse {
success_count: number
fail_data?: Array<{ message: string }>
success_data?: Array<{
id: number
question_id: number
score: number
remark?: string
is_excellent: number
is_model: number
is_problem: number
}>
is_end: boolean
}
// 历史记录项
export interface MarkingHistoryRecord {
id: number
task_id: number
question_id: number
scan_info_id: number
score: number
full_score: number
question_major: string
question_minor: string
image_urls?: string[]
created_time: string
is_excellent: number
is_model: number
is_problem: number
}
// 阅卷数据提供者接口
export interface MarkingDataProvider {
/**
*
*/
getSingleQuestion: (taskId: number) => Promise<ExamStudentMarkingQuestionResponse | null>
/**
*
*/
getMultipleQuestions: (
taskId: number,
options: { count: number },
) => Promise<{
questions: ExamStudentMarkingQuestionResponse[]
} | null>
/**
*
*/
submitRecords: (data: ExamBatchCreateMarkingTaskRecordRequest) => Promise<SubmitResponse | null>
/**
*
*/
createProblemRecord?: (data: {
id: number
task_id: number
problem_type: string
problem_addition?: string
}) => Promise<void>
}
// 阅卷历史提供者接口
export interface MarkingHistoryProvider {
/**
*
*/
getHistoryPage: (
taskId: number,
options: { page: number, page_size: number },
) => Promise<PaginatedResponse<MarkingHistoryRecord>>
}
// 完整的阅卷上下文接口
export interface MarkingContext {
dataProvider: MarkingDataProvider
historyProvider: MarkingHistoryProvider
isHistory: boolean
defaultPosition?: 'first' | 'last' // 默认定位first-第一个last-最后一个
}
/**
*
*/
async function preloadImages(urls: string[]): Promise<void> {
if (!urls || urls.length === 0)
return
// 移动端使用 uni.getImageInfo 预加载
const imagePromises = urls.map((url) => {
return new Promise<void>((resolve) => {
uni.getImageInfo({
src: url,
success: () => resolve(),
fail: () => resolve(), // 预加载失败不影响主流程
})
})
})
await Promise.all(imagePromises)
console.log(`🖼️ 已预加载 ${urls.length} 张图片`)
}
/**
* 使API
*
*/
export class DefaultMarkingDataProvider implements MarkingDataProvider {
// 滑动窗口缓存
private cachedQuestion: ExamStudentMarkingQuestionsResponse | null = null
private lastTaskId: number | null = null
private hasSubmitted = false
private fetchingPromise: Promise<ExamStudentMarkingQuestionsResponse | null> | null = null
async getSingleQuestion(taskId: number): Promise<ExamStudentMarkingQuestionResponse | null> {
// 如果任务ID变化清空缓存
if (this.lastTaskId !== taskId) {
this.cachedQuestion = null
this.lastTaskId = taskId
this.hasSubmitted = false
this.fetchingPromise = null
}
// 如果有缓存且已提交过,快速返回缓存并立即预加载下一批数据
if (this.cachedQuestion && this.hasSubmitted) {
const result = this.cachedQuestion
this.cachedQuestion = null // 使用后清空缓存
this.hasSubmitted = false // 重置提交标记
// 立即预加载下一批数据(不等待)
this.fetchingPromise = this.fetchAndCacheQuestions(taskId)
return result?.questions?.[0] || null
}
// 如果正在预加载,等待预加载完成
if (this.fetchingPromise) {
await this.fetchingPromise
this.fetchingPromise = null
// 预加载完成后,返回缓存的数据
if (this.cachedQuestion) {
const result = this.cachedQuestion
this.cachedQuestion = null
return result?.questions?.[0] || null
}
}
// 否则调用接口获取数据
const result = await this.fetchAndCacheQuestions(taskId)
return result?.questions?.[0] || null
}
private async fetchAndCacheQuestions(
taskId: number,
): Promise<ExamStudentMarkingQuestionsResponse | null> {
const response = await examMarkingTaskApi.questionsDetail(taskId, {
count: 2,
})
if (!response) {
return null
}
// 如果有多个问题,缓存第二个作为滑动窗口
if (response.questions && response.questions.length > 1) {
this.cachedQuestion = {
...response,
questions: [response.questions[1]],
}
// 预加载图片资源
if (this.cachedQuestion.questions?.[0]?.image_urls) {
preloadImages(this.cachedQuestion.questions[0].image_urls)
}
}
else {
this.cachedQuestion = null
}
// 返回第一个问题
return {
...response,
questions: response.questions ? [response.questions[0]] : [],
}
}
async getMultipleQuestions(
taskId: number,
_options: { count: number },
): Promise<{ questions: ExamStudentMarkingQuestionResponse[] } | null> {
// 移动端暂不支持多题模式
const response = await this.getSingleQuestion(taskId)
return response ? { questions: [response] } : null
}
async submitRecords(data: ExamBatchCreateMarkingTaskRecordRequest): Promise<SubmitResponse | null> {
const response = await examMarkingTaskApi.batchCreate(data)
// 标记已提交,下次获取时可以使用缓存
this.hasSubmitted = true
return response
? {
success_count: response.success_count || 0,
fail_data: response.fail_data?.map(item => ({ message: item.message || '' })),
success_data: response.success_data?.map(item => ({
id: item.id || 0,
question_id: item.question_id || 0,
score: item.score || 0,
remark: item.remark,
is_excellent: item.is_excellent || 0,
is_model: item.is_model || 0,
is_problem: item.is_problem || 0,
})),
is_end: response.is_end || false,
}
: null
}
async createProblemRecord(data: {
id: number
task_id: number
problem_type: string
problem_addition?: string
}): Promise<void> {
await examMarkingTaskApi.problemRecordCreate(data as any)
}
}
/**
* 使API
*/
export class DefaultMarkingHistoryProvider implements MarkingHistoryProvider {
async getHistoryPage(
taskId: number,
options: { page: number, page_size: number },
): Promise<PaginatedResponse<MarkingHistoryRecord>> {
return await examMarkingTaskApi.historyDetail(taskId, options)
}
}
/**
*
*/
export const defaultMarkingContext: MarkingContext = {
dataProvider: new DefaultMarkingDataProvider(),
historyProvider: new DefaultMarkingHistoryProvider(),
isHistory: false,
defaultPosition: 'last', // 默认定位到最后一个
}
/**
* Symbol
*/
export const MarkingContextSymbol = Symbol('MarkingContext')
/**
*
*/
export function provideMarkingContext(context: MarkingContext) {
provide(MarkingContextSymbol, context)
}
/**
* 使
*/
export function useMarkingContext(): MarkingContext | null {
return inject<MarkingContext>(MarkingContextSymbol) || null
}
/**
*
*/
export function getMarkingContext(): MarkingContext {
return useMarkingContext() || defaultMarkingContext
}

View File

@ -0,0 +1,453 @@
/**
*
* - 使 TanStack Query
*/
import type { Ref } from 'vue'
import type { MarkingHistoryRecord } from './MarkingContext'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { injectLocal, provideLocal, useDebounceFn } from '@vueuse/core'
import { computed, readonly, ref, watch } from 'vue'
import { getMarkingContext } from './MarkingContext'
export interface MarkingHistoryProps {
taskId: Ref<number>
}
// 统一的分页大小常量
const PAGE_SIZE = 20
/**
*
*
*
* 1. 使0 = n-1 =
* 2.
* 3. 使 TanStack Query
*/
function createMarkingHistory({ taskId }: MarkingHistoryProps) {
console.log('createMarkingHistory', taskId)
const queryClient = useQueryClient()
// 获取阅卷上下文
const markingContext = getMarkingContext()
// ==================== 状态管理 ====================
// 历史记录总数
const totalHistoryCount = ref(0)
// 来自历史记录的总分
const historyTotalScore = ref(0)
// 当前查看的历史记录索引从0开始0=最旧n-1=最新)
const currentHistoryIndex = ref(-1)
// 是否处于历史查看模式
const isViewingHistory = ref(false)
// ==================== 数据获取 ====================
/**
*
*
*
* - 0-19 1
* - 20-39 2
* -
*
*
* 25
* - 03
* - 241
*/
const getPageInfoByIndex = (index: number) => {
const total = totalHistoryCount.value
if (total === 0 || index < 0 || index >= total) {
return { page: 1, indexInPage: 0 }
}
// 反转索引:将"从旧到新"转换为"从新到旧"
const reverseIndex = total - 1 - index
// 计算页码和页内索引
const page = Math.floor(reverseIndex / PAGE_SIZE) + 1
const indexInPage = reverseIndex % PAGE_SIZE
return { page, indexInPage }
}
/**
*
*/
const useHistoryPage = (page: Ref<number>) => {
return useQuery({
queryKey: computed(() => ['marking-history', taskId.value, toValue(page)]),
queryFn: async () => {
const currentPage = toValue(page)
console.log(`📚 获取历史记录第 ${currentPage}`)
const response = await markingContext.historyProvider.getHistoryPage(taskId.value, {
page: currentPage,
page_size: PAGE_SIZE,
})
return {
list: response?.list || [],
page: response?.page || currentPage,
pageSize: response?.page_size || PAGE_SIZE,
total: response?.total || 0,
hasMore: response?.has_more || false,
}
},
select(data) {
// 第一页时更新总数
if (page.value === 1) {
totalHistoryCount.value = data.total
// 获取总分(从第一条记录)
if (data.list.length > 0) {
historyTotalScore.value = data.list[0].full_score || 0
}
}
return data
},
enabled: computed(() => !!taskId.value),
staleTime: 5 * 60 * 1000, // 5分钟内认为数据是新鲜的
gcTime: 10 * 60 * 1000, // 10分钟后清理缓存
})
}
/**
*
*/
const currentHistoryRecord = computed(() => {
if (!isViewingHistory.value || currentHistoryIndex.value < 0) {
return null
}
const { page, indexInPage } = getPageInfoByIndex(currentHistoryIndex.value)
const pageData = queryClient.getQueryData([
'marking-history',
taskId.value,
page,
]) as any
return (pageData?.list?.[indexInPage] as MarkingHistoryRecord) || null
})
// ==================== 导航控制 ====================
/**
*
*/
const preloadHistoryRecord = async (
index: number,
): Promise<MarkingHistoryRecord | null> => {
if (index < 0 || index >= totalHistoryCount.value) {
return null
}
const { page, indexInPage } = getPageInfoByIndex(index)
// 检查是否已缓存
let pageData = queryClient.getQueryData([
'marking-history',
taskId.value,
page,
]) as any
if (!pageData) {
console.log(`📥 预加载历史记录第 ${page}`)
// 手动触发查询
await queryClient.fetchQuery({
queryKey: ['marking-history', taskId.value, page],
queryFn: async () => {
return await markingContext.historyProvider.getHistoryPage(taskId.value, {
page,
page_size: PAGE_SIZE,
})
},
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
})
pageData = queryClient.getQueryData(['marking-history', taskId.value, page]) as any
}
return pageData?.list?.[indexInPage] || null
}
/**
*
*/
const goToHistoryIndex = async (index: number): Promise<boolean> => {
if (index < 0 || index >= totalHistoryCount.value) {
console.warn(`⚠️ 历史记录索引超出范围: ${index}, 总数: ${totalHistoryCount.value}`)
return false
}
const { page, indexInPage } = getPageInfoByIndex(index)
console.log(
`🎯 跳转到历史记录 - 全局索引: ${index + 1}/${totalHistoryCount.value}, `
+ `页码: ${page}, 页内索引: ${indexInPage}`,
)
try {
// 预加载目标记录
const record = await preloadHistoryRecord(index)
if (!record) {
console.error(`❌ 无法加载历史记录索引: ${index}`)
return false
}
// 更新状态
currentHistoryIndex.value = index
isViewingHistory.value = true
console.log(
`✅ 成功跳转到历史记录: ${record.question_major}-${record.question_minor}, `
+ `ID: ${record.id}`,
)
return true
}
catch (error) {
console.error(`❌ 跳转历史记录失败:`, error)
return false
}
}
/**
*
*/
const goToPrevHistory = async (): Promise<boolean> => {
if (currentHistoryIndex.value <= 0) {
return false
}
return await goToHistoryIndex(currentHistoryIndex.value - 1)
}
/**
*
*/
const goToNextHistory = async (): Promise<boolean> => {
if (currentHistoryIndex.value >= totalHistoryCount.value - 1) {
return false
}
return await goToHistoryIndex(currentHistoryIndex.value + 1)
}
/**
* 退
*/
const exitHistoryMode = () => {
console.log('🚪 退出历史查看模式')
isViewingHistory.value = false
currentHistoryIndex.value = -1
}
// ==================== 数据更新 ====================
/**
*
*/
const forceRefreshHistory = async () => {
console.log('🔄 强制刷新历史记录')
// 清除所有相关的查询缓存
queryClient.removeQueries({ queryKey: ['marking-history', taskId.value] })
// 重置状态
totalHistoryCount.value = 0
currentHistoryIndex.value = -1
isViewingHistory.value = false
// 重新初始化
// eslint-disable-next-line ts/no-use-before-define
await initHistory()
console.log('✅ 历史记录强制刷新完成')
}
/**
*
*/
const incrementHistoryCount = (count = 1) => {
const oldTotal = totalHistoryCount.value
console.log(` 增加历史记录总数: +${count},从 ${oldTotal}${oldTotal + count}`)
totalHistoryCount.value += count
// 新记录会插入到后端第1页的开头清理所有缓存
console.log('🗑️ 清理所有历史记录缓存')
queryClient.removeQueries({ queryKey: ['marking-history', taskId.value] })
console.log(`✅ 历史记录总数已更新为: ${totalHistoryCount.value}`)
}
// ==================== 计算属性 ====================
// 是否可以上一条
const canGoPrev = computed(() => {
return isViewingHistory.value && currentHistoryIndex.value > 0
})
// 是否可以下一条
const canGoNext = computed(() => {
return isViewingHistory.value && currentHistoryIndex.value < totalHistoryCount.value - 1
})
// 当前进度信息
const progressInfo = computed(() => {
if (!isViewingHistory.value) {
return {
current: totalHistoryCount.value + 1,
total: totalHistoryCount.value + 1,
percentage: 100,
}
}
const current = currentHistoryIndex.value + 1
return {
current,
total: totalHistoryCount.value,
percentage:
totalHistoryCount.value > 0 ? Math.round((current / totalHistoryCount.value) * 100) : 0,
}
})
// ==================== 模式切换 ====================
/**
*
*/
const switchToHistoryMode = async () => {
console.log('🔄 切换到历史查看模式')
if (totalHistoryCount.value > 0) {
const context = markingContext
const defaultPosition = context.defaultPosition || 'last'
if (defaultPosition === 'first') {
await goToHistoryIndex(0)
}
else {
await goToHistoryIndex(totalHistoryCount.value - 1)
}
}
}
/**
*
*/
const switchToCurrentMode = () => {
console.log('🔄 切换到当前阅卷模式')
exitHistoryMode()
}
// ==================== 初始化 ====================
/**
*
*/
const initHistory = useDebounceFn(async () => {
console.log('🚀 初始化历史记录管理')
if (!taskId.value) {
console.warn('⚠️ taskId 为空,跳过历史记录初始化')
return
}
try {
// 获取第一页数据来获取总数
const response = await queryClient.fetchQuery({
queryKey: ['marking-history', taskId.value, 1],
queryFn: async () => {
const response = await markingContext.historyProvider.getHistoryPage(taskId.value, {
page: 1,
page_size: PAGE_SIZE,
})
return response
},
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
})
historyTotalScore.value = response?.list?.[0]?.full_score || 0
totalHistoryCount.value = response?.total || 0
console.log(`✅ 历史记录初始化完成,总数: ${totalHistoryCount.value}`)
}
catch (error) {
console.error('❌ 历史记录初始化失败:', error)
totalHistoryCount.value = 0
}
}, 100)
// 监听 taskId 变化,重新初始化
watch(
taskId,
async (newTaskId, oldTaskId) => {
if (newTaskId && newTaskId !== oldTaskId) {
// 重置状态
totalHistoryCount.value = 0
currentHistoryIndex.value = -1
isViewingHistory.value = false
// 重新初始化
await initHistory()
}
},
{ immediate: true },
)
return {
// 状态
totalHistoryCount: readonly(totalHistoryCount),
currentHistoryIndex: readonly(currentHistoryIndex),
isViewingHistory: readonly(isViewingHistory),
historyTotalScore: readonly(historyTotalScore),
currentHistoryRecord,
// 计算属性
canGoPrev,
canGoNext,
progressInfo,
// 导航方法
goToHistoryIndex,
goToPrevHistory,
goToNextHistory,
exitHistoryMode,
// 模式切换
switchToHistoryMode,
switchToCurrentMode,
// 数据管理
incrementHistoryCount,
forceRefreshHistory,
preloadHistoryRecord,
// 初始化
initHistory,
// 工具方法
useHistoryPage,
}
}
type MarkingHistory = ReturnType<typeof createMarkingHistory>
const MarkingHistorySymbol = Symbol('MarkingHistory')
export function provideMarkingHistory(props: MarkingHistoryProps) {
const markingHistory = createMarkingHistory(props)
provideLocal(MarkingHistorySymbol, markingHistory)
return markingHistory
}
export function useMarkingHistory() {
const markingHistory = injectLocal<MarkingHistory>(MarkingHistorySymbol)
if (!markingHistory) {
throw new Error('MarkingHistory not found. Make sure to call provideMarkingHistory first.')
}
return markingHistory
}

View File

@ -0,0 +1,279 @@
/**
*
* -
*/
import type { Ref } from 'vue'
import { injectLocal, provideLocal } from '@vueuse/core'
import { computed } from 'vue'
import { useMarkingHistory } from './useMarkingHistory'
interface MarkingNavigationProps {
taskId: Ref<number>
}
/**
*
*/
export enum SwipeDirection {
Left = 'left',
Right = 'right',
Up = 'up',
Down = 'down',
}
function createMarkingNavigation(_props: MarkingNavigationProps) {
// 获取历史记录管理
const {
totalHistoryCount,
currentHistoryIndex,
isViewingHistory,
currentHistoryRecord,
canGoPrev,
canGoNext,
progressInfo,
goToHistoryIndex,
goToPrevHistory,
goToNextHistory,
switchToHistoryMode,
switchToCurrentMode,
} = useMarkingHistory()
// ==================== 导航状态 ====================
// 当前索引(历史查看时显示历史序号,否则显示当前进度)
const currentIndex = computed(() => {
return progressInfo.value.current
})
// 总数
const totalCount = computed(() => {
return progressInfo.value.total
})
// 当前进度百分比
const progressPercentage = computed(() => {
return progressInfo.value.percentage
})
// 历史记录题目信息
const currentHistoryQuestionInfo = computed(() => {
if (!isViewingHistory.value || !currentHistoryRecord.value) {
return null
}
const record = currentHistoryRecord.value
return {
questionId: record.question_id || 0,
taskId: record.task_id || 0,
recordId: record.id || 0,
sequence: currentHistoryIndex.value + 1,
score: record.score || 0,
createdTime: record.created_time || '',
isExcellent: record.is_excellent === 1,
isModel: record.is_model === 1,
isProblem: record.is_problem === 1,
}
})
// ==================== 导航方法 ====================
/**
*
*/
const goToPrevQuestion = async () => {
console.log('🤚 上一题', {
canGoPrev: canGoPrev.value,
isViewingHistory: isViewingHistory.value,
totalHistoryCount: totalHistoryCount.value,
})
if (canGoPrev.value) {
return await goToPrevHistory()
}
// 如果当前不在历史模式,且有历史记录,则进入历史模式查看最新记录
if (!isViewingHistory.value && totalHistoryCount.value > 0) {
return await goToHistoryIndex(totalHistoryCount.value - 1)
}
return false
}
/**
*
*/
const goToNextQuestion = async () => {
if (canGoNext.value) {
return await goToNextHistory()
}
// 如果是历史记录的最后一题,则跳转到当前阅卷
if (isViewingHistory.value && currentHistoryIndex.value === totalHistoryCount.value - 1) {
switchToCurrentMode()
return true
}
return false
}
// ==================== 手势支持 ====================
/**
*
*/
const swipeConfig = {
threshold: 50, // 最小滑动距离px
timeout: 100, // 最大滑动时间ms
}
/**
*
*/
const handleSwipe = async (direction: SwipeDirection): Promise<boolean> => {
console.log('🤚 检测到滑动手势:', direction)
switch (direction) {
case SwipeDirection.Left:
// 左滑:下一题
return await goToNextQuestion()
case SwipeDirection.Right:
// 右滑:上一题
return await goToPrevQuestion()
default:
return false
}
}
/**
*
*
*/
const createSwipeHandler = () => {
let startX = 0
let startY = 0
let startTime = 0
const onTouchStart = (event: TouchEvent) => {
const touch = event.touches[0]
startX = touch.clientX
startY = touch.clientY
startTime = Date.now()
}
const onTouchEnd = async (event: TouchEvent) => {
const touch = event.changedTouches[0]
const endX = touch.clientX
const endY = touch.clientY
const endTime = Date.now()
const deltaX = endX - startX
const deltaY = endY - startY
const deltaTime = endTime - startTime
// 检查是否超时
if (deltaTime > swipeConfig.timeout) {
return
}
// 计算滑动距离
const absX = Math.abs(deltaX)
const absY = Math.abs(deltaY)
// 检查是否达到阈值
if (absX < swipeConfig.threshold && absY < swipeConfig.threshold) {
return
}
// 判断滑动方向(优先水平方向)
if (absX > absY) {
// 水平滑动
if (deltaX > 0) {
await handleSwipe(SwipeDirection.Right)
}
else {
await handleSwipe(SwipeDirection.Left)
}
}
else {
// 垂直滑动
if (deltaY > 0) {
await handleSwipe(SwipeDirection.Down)
}
else {
await handleSwipe(SwipeDirection.Up)
}
}
}
return {
onTouchStart,
onTouchEnd,
}
}
// ==================== 初始化 ====================
/**
*
*/
const initNavigation = async () => {
console.log('🚀 初始化导航状态')
// 默认切换到当前阅卷模式
switchToCurrentMode()
}
return {
// 基础状态
currentIndex,
totalCount,
currentHistoryIndex,
isViewingHistory: readonly(isViewingHistory),
currentHistoryQuestionInfo,
currentHistoryQuestion: currentHistoryRecord,
totalHistoryCount,
// 计算属性
canGoPrev: computed(
() => canGoPrev.value || (!isViewingHistory.value && totalHistoryCount.value > 0),
),
canGoNext: computed(
() =>
canGoNext.value
|| (currentHistoryIndex.value === totalHistoryCount.value - 1 && isViewingHistory.value),
),
progressPercentage,
// 导航方法
initNavigation,
goToPrevQuestion,
goToNextQuestion,
goToHistoryIndex,
// 模式切换
switchToHistoryMode,
switchToCurrentMode,
// 手势支持
handleSwipe,
createSwipeHandler,
swipeConfig,
}
}
type MarkingNavigation = ReturnType<typeof createMarkingNavigation>
const MarkingNavigationSymbol = Symbol('MarkingNavigation')
export function provideMarkingNavigation(props: MarkingNavigationProps) {
const markingNavigation = createMarkingNavigation(props)
provideLocal(MarkingNavigationSymbol, markingNavigation)
return markingNavigation
}
export function useMarkingNavigation() {
const markingNavigation = injectLocal<MarkingNavigation>(MarkingNavigationSymbol)
if (!markingNavigation) {
throw new Error('MarkingNavigation not found. Make sure to call provideMarkingNavigation first.')
}
return markingNavigation
}

View File

@ -22,6 +22,7 @@ defineOptions({
//
const { getDictOptionsAndGetLabel } = useDict()
const [, getTaskTypeName] = getDictOptionsAndGetLabel(DictCode.TASK_TYPE)
const [, getEvaluateMethodName] = getDictOptionsAndGetLabel(DictCode.EVALUATE_METHOD)
//
const examId = ref<number>()
@ -52,25 +53,81 @@ const {
enabled: enableQuery,
})
// - 使flatMap
const allTasks = computed(() => {
//
interface GroupedQuestion {
questionKey: string
questionInfo: {
question_major: string
question_minor: string
full_score: number
evaluate_method: string
question_id: number
}
tasks: Array<{
id: number
question_id: number
task_type: string
task_quantity: number
marked_quantity: number
not_mark_quantity: number
[key: string]: any
}>
}
const groupedTasks = computed(() => {
const data = questionsData.value
if (!data || !Array.isArray(data))
return []
return {}
return data.flatMap((question) => {
const tasks = Object.values(question.tasks || {})
return tasks.map(task => ({
...task,
// evaluate_method
const groups: Record<string, Record<string, GroupedQuestion>> = {}
data.forEach((question) => {
const evaluateMethod = Object.values(question.tasks || {})[0]?.evaluate_method || 'other'
const questionKey = `${question.question_major || ''}.${question.question_minor || ''}`
//
if (!groups[evaluateMethod]) {
groups[evaluateMethod] = {}
}
//
if (!groups[evaluateMethod][questionKey]) {
groups[evaluateMethod][questionKey] = {
questionKey,
questionInfo: {
question_major: question.question_major || '',
question_minor: question.question_minor || '',
full_score: question.full_score || 0,
evaluate_method: question.evaluate_method || '',
//
evaluate_method: evaluateMethod,
question_id: question.question_id || 0,
},
tasks: [],
}
}
//
const tasks = Object.values(question.tasks || {})
tasks.forEach((task) => {
groups[evaluateMethod][questionKey].tasks.push({
...task,
question_id: question.question_id || 0,
not_mark_quantity: Math.max(0, (task.task_quantity || 0) - (task.marked_quantity || 0)),
}))
})
})
})
return groups
})
//
const groupedTasksList = computed(() => {
return Object.entries(groupedTasks.value).map(([evaluateMethod, questions]) => ({
evaluateMethod,
evaluateMethodName: getEvaluateMethodName(evaluateMethod),
questions: Object.values(questions),
}))
})
//
function getTaskButtonType(task: any) {
@ -145,57 +202,54 @@ function handleRefresh() {
</wd-button>
</view>
<!-- 任务列表 -->
<view v-else class="flex flex-col gap-3">
<!-- 任务列表 - 按类型分组 -->
<view v-else class="flex flex-col gap-4">
<view
v-for="task in allTasks"
:key="task.id"
v-for="group in groupedTasksList"
:key="group.evaluateMethod"
class="flex flex-col gap-3"
>
<!-- 分组标题 -->
<view class="flex items-center gap-2">
<text class="text-base text-gray-800 font-semibold">{{ group.evaluateMethodName }}</text>
</view>
<!-- 该组下的题目卡片 -->
<view
v-for="questionGroup in group.questions"
:key="questionGroup.questionKey"
class="relative rounded-3 bg-white p-4 shadow-sm"
>
<!-- 左上角任务类型标签 -->
<view
class="absolute left-0 top-0 rounded-br-2 rounded-tl-3 px-2 py-1 text-xs text-white"
:class="{
'bg-blue-500': task.task_type === 'initial',
'bg-green-500': task.task_type === 'final',
'bg-orange-500': task.task_type === 'arbitration',
'bg-gray-500': !['initial', 'final', 'arbitration'].includes(task.task_type),
}"
>
{{ getTaskTypeName(task.task_type) }}
</view>
<!-- 题目信息 -->
<view class="mb-3 mt-2 flex items-start justify-between">
<view class="mb-3 flex items-start justify-between">
<view class="flex items-baseline gap-2">
<text class="text-lg text-gray-800 font-semibold">
{{ task.question_major }}.{{ task.question_minor }}
{{ questionGroup.questionInfo.question_major }}.{{ questionGroup.questionInfo.question_minor }}
</text>
<text class="text-sm text-gray-500">满分: {{ task.full_score }}</text>
<text class="text-xs text-gray-400">{{ task.evaluate_method }}</text>
</view>
<view class="text-xs text-gray-500">
任务ID: {{ task.id }}
<!-- <text class="text-sm text-blue-500">效率优先</text> -->
<text class="text-xs text-gray-500">满分: {{ questionGroup.questionInfo.full_score }}</text>
</view>
</view>
<!-- 统计信息和操作按钮 -->
<view class="flex items-center justify-between">
<!-- 该题目的多个任务 - 竖着展示 -->
<view class="flex flex-col gap-3">
<view
v-for="(task, index) in questionGroup.tasks"
:key="task.id"
class="flex items-center justify-between pt-3"
:class="{ 'border-t border-gray-100': index > 0 }"
>
<!-- 任务统计信息 -->
<view class="flex gap-4">
<view class="flex flex-col items-center gap-1">
<text class="text-xs text-gray-500">总任务</text>
<text class="text-base text-blue-500 font-semibold">{{ task.task_quantity || 0 }}</text>
</view>
<view class="flex flex-col items-center gap-1">
<text class="text-xs text-gray-500">已阅</text>
<text class="text-base text-green-500 font-semibold">{{ task.marked_quantity || 0 }}</text>
</view>
<view class="flex flex-col items-center gap-1">
<text class="text-xs text-gray-500">待阅</text>
<text class="text-base text-orange-500 font-semibold">{{ task.not_mark_quantity }}</text>
</view>
<text class="text-sm text-gray-600">
已阅<text class="text-gray-800 font-semibold">{{ task.marked_quantity || 0 }}</text>
</text>
<text class="text-sm text-gray-600">
待阅<text class="text-orange-500 font-semibold">{{ task.not_mark_quantity }}</text>
</text>
</view>
<!-- 操作按钮 -->
<view class="flex gap-2">
<!-- 查看回评按钮 -->
<wd-button
@ -219,9 +273,11 @@ function handleRefresh() {
</view>
</view>
</view>
</view>
</view>
<!-- 空状态 -->
<view v-if="allTasks.length === 0" class="h-50 flex items-center justify-center">
<view v-if="groupedTasksList.length === 0" class="h-50 flex items-center justify-center">
<text class="text-sm text-gray-500">暂无任务数据</text>
</view>
</view>

View File

@ -13,11 +13,15 @@ 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'
import FullscreenImageDialog from '@/components/marking/components/dialog/FullscreenImageDialog.vue'
import ScoreSettingsDialog from '@/components/marking/components/dialog/ScoreSettingsDialog.vue'
import QuickScorePanel from '@/components/marking/components/QuickScorePanel.vue'
import MarkingImageViewerNew from '@/components/marking/components/renderer/MarkingImageViewerNew.vue'
import { provideMarkingData } from '@/components/marking/composables/useMarkingData'
import MarkingLayout from '@/components/marking/MarkingLayout.vue'
import { DefaultMarkingDataProvider, DefaultMarkingHistoryProvider, provideMarkingContext } from '@/composables/marking/MarkingContext'
import { provideMarkingHistory } from '@/composables/marking/useMarkingHistory'
import { provideMarkingNavigation } from '@/composables/marking/useMarkingNavigation'
defineOptions({
name: 'GradingPage',
@ -34,6 +38,35 @@ const taskType = ref<'initial' | 'final' | 'arbitration'>('initial')
//
const isLandscape = ref(false)
//
const imageScale = ref(1.0)
// 使
provideMarkingContext({
dataProvider: new DefaultMarkingDataProvider(),
historyProvider: new DefaultMarkingHistoryProvider(),
isHistory: false,
defaultPosition: 'last',
})
//
provideMarkingHistory({ taskId })
//
const markingNavigation = provideMarkingNavigation({ taskId })
const {
currentIndex: navCurrentIndex,
totalCount: navTotalCount,
isViewingHistory,
canGoPrev,
canGoNext,
goToPrevQuestion,
goToNextQuestion,
initNavigation,
createSwipeHandler,
} = markingNavigation
//
const markingData = provideMarkingData({
taskId,
questionId,
@ -44,8 +77,8 @@ const markingData = provideMarkingData({
})
const { questionData: questions, questionsList, totalQuestions: totalQuestionsCount } = markingData
//
const isFullscreen = ref(false)
//
const showFullscreenImage = ref(false)
const currentQuestionIndex = ref(0)
const totalQuestions = computed(() => totalQuestionsCount.value)
@ -82,43 +115,24 @@ function handleOrientationChange() {
// #endif
}
//
function handleFullscreenChange() {
// #ifdef H5
isFullscreen.value = !!document.fullscreenElement
// #endif
}
onMounted(() => {
onMounted(async () => {
// #ifdef H5
window.addEventListener('orientationchange', handleOrientationChange)
document.addEventListener('fullscreenchange', handleFullscreenChange)
handleOrientationChange()
// #endif
//
await initNavigation()
})
onUnmounted(() => {
// #ifdef H5
window.removeEventListener('orientationchange', handleOrientationChange)
document.removeEventListener('fullscreenchange', handleFullscreenChange)
// #endif
})
//
function toggleOrientation() {
// #ifdef H5
if (document.fullscreenElement) {
if (screen.orientation && 'lock' in screen.orientation) {
if (isLandscape.value) {
(screen.orientation as any).lock('portrait-primary')
}
else {
(screen.orientation as any).lock('landscape-primary')
}
}
}
// #endif
// #ifdef APP-PLUS
if (isLandscape.value) {
plus.screen.lockOrientation('portrait-primary')
@ -131,39 +145,9 @@ function toggleOrientation() {
isLandscape.value = !isLandscape.value
}
//
//
function toggleFullscreen() {
// #ifdef H5
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().then(() => {
isFullscreen.value = true
}).catch((err) => {
console.error('进入全屏失败:', err)
uni.showToast({
title: '全屏功能不支持',
icon: 'none',
})
})
}
else {
document.exitFullscreen().then(() => {
isFullscreen.value = false
})
}
// #endif
// #ifdef APP-PLUS
// App
if (isFullscreen.value) {
plus.navigator.setStatusBarStyle('dark')
plus.navigator.setStatusBarBackground('#000000')
}
else {
plus.navigator.setStatusBarStyle('light')
plus.navigator.setStatusBarBackground('#ffffff')
}
isFullscreen.value = !isFullscreen.value
// #endif
showFullscreenImage.value = true
}
//
@ -208,9 +192,20 @@ function viewAnswer() {
//
const currentQuestions = computed(() => questionsList.value[currentQuestionIndex.value])
const currentQuestion = computed(() => questions.value[0])
// 使使
const currentQuestion = computed(() => {
if (isViewingHistory.value && markingNavigation.currentHistoryQuestion.value) {
//
return markingNavigation.currentHistoryQuestion.value
}
//
return questions.value[0]
})
const currentTask = computed(() => currentQuestions.value?.tasks?.[taskType.value])
//
const currentImageUrls = computed(() => currentQuestion.value?.image_urls || [])
let isFirst = true
whenever(questionsList, () => {
if (isFirst) {
@ -232,19 +227,49 @@ whenever(currentTask, (task, oldTask) => {
function handleQuickScoreSelect(score: number) {
console.log('选择分数:', score)
}
//
const swipeHandler = createSwipeHandler()
//
const historyModeText = computed(() => {
if (isViewingHistory.value) {
return `${navCurrentIndex.value}/${totalQuestionsCount.value}`
}
return ''
})
//
async function handlePrevQuestion() {
await goToPrevQuestion()
}
//
async function handleNextQuestion() {
await goToNextQuestion()
}
</script>
<template>
<div class="relative h-screen w-100vw flex flex-col touch-pan-x touch-pan-y overflow-hidden overscroll-none pt-safe">
<div
class="relative h-screen w-100vw flex flex-col touch-pan-x touch-pan-y overflow-hidden overscroll-none pt-safe"
@touchstart="swipeHandler.onTouchStart"
@touchend="swipeHandler.onTouchEnd"
>
<MarkingLayout
:is-landscape="isLandscape"
:is-fullscreen="isFullscreen"
:is-fullscreen="false"
:current-question-index="currentQuestionIndex"
:current-task-submit="currentTask?.marked_quantity || 0"
:total-questions="totalQuestions"
:questions="questionsList"
:my-score="myScore"
:avg-score="avgScore"
:is-viewing-history="isViewingHistory"
:can-go-prev="canGoPrev"
:can-go-next="canGoNext"
:history-mode-text="historyModeText"
:task-id="taskId"
@go-back="goBack"
@select-question="selectQuestion"
@open-score-settings="openScoreSettings"
@ -252,17 +277,20 @@ function handleQuickScoreSelect(score: number) {
@view-answer="viewAnswer"
@toggle-orientation="toggleOrientation"
@toggle-fullscreen="toggleFullscreen"
@prev-question="handlePrevQuestion"
@next-question="handleNextQuestion"
>
<template #content>
<MarkingImageViewerNew
v-if="questions[0]?.image_urls?.length"
v-model:question-data="questions"
v-model:scale="imageScale"
:question-data="[currentQuestion]"
:image-size="100"
/>
<!-- 快捷打分面板 - 固定定位在右侧 -->
<QuickScorePanel
v-if="currentQuestion"
v-if="currentQuestion && !isViewingHistory"
:is-landscape="isLandscape"
:full-score="currentQuestion.full_score"
@score-selected="handleQuickScoreSelect"
@ -293,7 +321,13 @@ function handleQuickScoreSelect(score: number) {
v-model="showAnswer"
:question-title="`${currentQuestion?.question_major}.${currentQuestion?.question_minor}`"
:full-score="currentQuestion?.full_score"
:standard-answer="currentQuestion?.standard_answer"
:standard-answer="currentQuestion?.standard_answer || ''"
/>
<!-- 全屏图片弹窗 -->
<FullscreenImageDialog
v-model="showFullscreenImage"
:image-urls="currentImageUrls"
/>
</div>
</template>