refactor: 阅卷一堆优化
continuous-integration/drone/push Build is passing
Details
continuous-integration/drone/push Build is passing
Details
This commit is contained in:
parent
bcacba59a1
commit
7103b3a04b
|
|
@ -256,10 +256,6 @@ importers:
|
||||||
specifier: ^2.2.10
|
specifier: ^2.2.10
|
||||||
version: 2.2.12(typescript@5.9.2)
|
version: 2.2.12(typescript@5.9.2)
|
||||||
|
|
||||||
src/uni_modules/uni-icons: {}
|
|
||||||
|
|
||||||
src/uni_modules/uni-scss: {}
|
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
'@alova/adapter-uniapp@2.0.14':
|
'@alova/adapter-uniapp@2.0.14':
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,22 @@
|
||||||
<!-- 阅卷布局组件 -->
|
<!-- 阅卷布局组件 -->
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { ExamQuestionWithTasksResponse } from '@/api'
|
import type { ExamQuestionWithTasksResponse } from '@/api'
|
||||||
import { computed } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { useMarkingHistory } from '@/composables/marking/useMarkingHistory'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isLandscape: boolean
|
isLandscape: boolean
|
||||||
isFullscreen: boolean
|
|
||||||
currentQuestionIndex: number
|
currentQuestionIndex: number
|
||||||
totalQuestions: number
|
totalQuestions: number
|
||||||
currentTaskSubmit: number
|
currentTaskSubmit: number
|
||||||
questions: ExamQuestionWithTasksResponse[] // 改为 any[] 以支持不同的数据结构
|
questions: ExamQuestionWithTasksResponse[]
|
||||||
myScore?: number
|
myScore?: number
|
||||||
avgScore?: number
|
avgScore?: number
|
||||||
|
isViewingHistory?: boolean
|
||||||
|
canGoPrev?: boolean
|
||||||
|
canGoNext?: boolean
|
||||||
|
historyModeText?: string
|
||||||
|
taskId?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Emits {
|
interface Emits {
|
||||||
|
|
@ -22,15 +27,36 @@ interface Emits {
|
||||||
(e: 'viewAnswer'): void
|
(e: 'viewAnswer'): void
|
||||||
(e: 'toggleOrientation'): void
|
(e: 'toggleOrientation'): void
|
||||||
(e: 'toggleFullscreen'): void
|
(e: 'toggleFullscreen'): void
|
||||||
|
(e: 'prevQuestion'): void
|
||||||
|
(e: 'nextQuestion'): void
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
myScore: 0,
|
myScore: 0,
|
||||||
avgScore: 0,
|
avgScore: 0,
|
||||||
|
isViewingHistory: false,
|
||||||
|
canGoPrev: false,
|
||||||
|
canGoNext: false,
|
||||||
|
historyModeText: '',
|
||||||
|
taskId: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<Emits>()
|
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])
|
const currentQuestion = computed(() => props.questions[props.currentQuestionIndex])
|
||||||
|
|
||||||
|
|
@ -53,6 +79,34 @@ function formatScore(score: number): string {
|
||||||
const formatted = score.toFixed(2)
|
const formatted = score.toFixed(2)
|
||||||
return formatted.replace(/\.?0+$/, '')
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -75,7 +129,7 @@ function formatScore(score: number): string {
|
||||||
<!-- 题号信息 -->
|
<!-- 题号信息 -->
|
||||||
<view class="mr-16px flex items-center">
|
<view class="mr-16px flex items-center">
|
||||||
<text class="text-16px text-gray-800 font-medium">
|
<text class="text-16px text-gray-800 font-medium">
|
||||||
{{ currentTaskSubmit + 1 }}/{{ totalQuestions }}
|
{{ isViewingHistory ? historyModeText : `${currentTaskSubmit + 1}/${totalQuestions}` }}
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
|
@ -110,8 +164,7 @@ function formatScore(score: number): string {
|
||||||
@click="emit('toggleFullscreen')"
|
@click="emit('toggleFullscreen')"
|
||||||
>
|
>
|
||||||
<view
|
<view
|
||||||
:class="isFullscreen ? 'i-carbon-minimize' : 'i-carbon-maximize'"
|
class="i-carbon-image-search size-18px text-gray-700"
|
||||||
class="size-18px text-gray-700"
|
|
||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
@ -120,25 +173,46 @@ function formatScore(score: number): string {
|
||||||
|
|
||||||
<!-- 内容区域 -->
|
<!-- 内容区域 -->
|
||||||
<view class="h-0 flex flex-1">
|
<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
|
<view
|
||||||
v-for="(question, index) in questions"
|
v-for="(record, index) in historyList"
|
||||||
:key="question.question_id"
|
:key="record.id"
|
||||||
class="flex cursor-pointer items-center justify-center rounded py-6px text-12px transition-colors"
|
class="flex flex-col cursor-pointer items-center justify-center border-b border-gray-100 px-4px py-8px text-10px transition-colors"
|
||||||
:class="[
|
:class="[
|
||||||
index === currentQuestionIndex
|
currentHistoryIndexInList === index
|
||||||
? 'bg-blue-500 text-white'
|
? '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>
|
</view>
|
||||||
|
|
||||||
<!-- 右侧作答区域 -->
|
<!-- 右侧作答区域 -->
|
||||||
<view class="relative h-full flex-1 overflow-auto bg-green-200">
|
<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" />
|
<slot name="content" :current-question="currentQuestion" />
|
||||||
</view>
|
</view>
|
||||||
</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="h-12 flex items-center border-t border-gray-200 bg-white px-4 py-2">
|
||||||
<!-- 题号信息 -->
|
<!-- 题号信息 -->
|
||||||
<view class="flex items-center gap-4">
|
<view class="flex items-center gap-4">
|
||||||
<text class="text-base text-gray-800 font-medium">
|
<text v-if="!isViewingHistory" class="text-base text-gray-800 font-medium">
|
||||||
{{ currentTaskSubmit + 1 }}/{{ totalQuestions }}
|
{{ Math.min(totalQuestions, currentTaskSubmit + 1) }}/{{ totalQuestions }}
|
||||||
|
</text>
|
||||||
|
<text v-else class="text-base text-gray-800 font-medium">
|
||||||
|
{{ historyModeText }}
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
|
@ -215,8 +292,7 @@ function formatScore(score: number): string {
|
||||||
@click="emit('toggleFullscreen')"
|
@click="emit('toggleFullscreen')"
|
||||||
>
|
>
|
||||||
<view
|
<view
|
||||||
:class="isFullscreen ? 'i-carbon-minimize' : 'i-carbon-maximize'"
|
class="i-carbon-image-search size-16px text-gray-700"
|
||||||
class="size-16px text-gray-700"
|
|
||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
@ -224,6 +300,22 @@ function formatScore(score: number): string {
|
||||||
|
|
||||||
<!-- 作答区域 -->
|
<!-- 作答区域 -->
|
||||||
<view class="relative h-0 flex-1 bg-green-200">
|
<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" />
|
<slot name="content" :current-question="currentQuestion" />
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
|
||||||
|
|
@ -72,8 +72,8 @@ const displayItems = computed(() => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 横屏模式的两列布局
|
// 两列布局(展开时使用)
|
||||||
const landscapeLayout = computed(() => {
|
const twoColumnLayout = computed(() => {
|
||||||
const items = displayItems.value
|
const items = displayItems.value
|
||||||
const layout: Array<Array<typeof items[0]>> = []
|
const layout: Array<Array<typeof items[0]>> = []
|
||||||
|
|
||||||
|
|
@ -190,33 +190,33 @@ async function submitCurrentScore() {
|
||||||
'h-[calc(100vh-80px)] overflow-auto': isLandscape && !isCollapsed,
|
'h-[calc(100vh-80px)] overflow-auto': isLandscape && !isCollapsed,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<!-- 按钮内容 -->
|
<!-- 按钮内容 - 始终展示 -->
|
||||||
<div v-if="!isLandscape || !isCollapsed" class="flex flex-col gap-2px">
|
<div class="flex flex-col gap-2px">
|
||||||
<!-- 竖屏:一列布局 -->
|
<!-- 收起时:一列布局 -->
|
||||||
<template v-if="!isLandscape">
|
<template v-if="isCollapsed">
|
||||||
<div
|
<div
|
||||||
v-for="item in displayItems"
|
v-for="item in displayItems"
|
||||||
:key="item.label"
|
:key="item.label"
|
||||||
class="flex cursor-pointer items-center justify-center border-2 rounded-8px transition-all active:scale-95"
|
class="flex cursor-pointer items-center justify-center border-2 rounded-8px transition-all active:scale-95"
|
||||||
:class="[
|
:class="[
|
||||||
item.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"
|
@click="item.action"
|
||||||
>
|
>
|
||||||
<text
|
<text
|
||||||
class="font-medium"
|
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 }}
|
{{ item.label }}
|
||||||
</text>
|
</text>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 横屏:两列布局 -->
|
<!-- 展开时:两列布局 -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div
|
<div
|
||||||
v-for="(row, index) in landscapeLayout"
|
v-for="(row, index) in twoColumnLayout"
|
||||||
:key="index"
|
:key="index"
|
||||||
class="flex gap-2px"
|
class="flex gap-2px"
|
||||||
>
|
>
|
||||||
|
|
@ -246,12 +246,11 @@ async function submitCurrentScore() {
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 操作按钮 -->
|
<!-- 收起/展开按钮 - 始终显示 -->
|
||||||
<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="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'"
|
:class="isLandscape ? 'size-48rpx lg:size-12' : 'size-10'"
|
||||||
@click="isLandscape ? toggleCollapse() : undefined"
|
@click="toggleCollapse"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="text-blue-500 transition-transform"
|
class="text-blue-500 transition-transform"
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
// 匹配阿里云OSS的crop参数:/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>
|
||||||
|
|
@ -318,25 +318,6 @@ function resetQuickScoreScores() {
|
||||||
|
|
||||||
<!-- 快捷打分设置 -->
|
<!-- 快捷打分设置 -->
|
||||||
<div v-if="activeTab === 'quick'" class="space-y-24px">
|
<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>
|
||||||
<div class="mb-12px flex items-center justify-between text-14px text-gray-700">
|
<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 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>
|
||||||
<div class="mb-12px flex items-center justify-between text-14px text-gray-700">
|
<div class="mb-12px flex items-center justify-between text-14px text-gray-700">
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,11 @@
|
||||||
import type { KonvaMarkingData } from '../../composables/renderer/useMarkingKonva'
|
import type { KonvaMarkingData } from '../../composables/renderer/useMarkingKonva'
|
||||||
import type { ExamStudentMarkingQuestionResponse } from '@/api'
|
import type { ExamStudentMarkingQuestionResponse } from '@/api'
|
||||||
import { markingSettings } from '../../composables/useMarkingSettings'
|
import { markingSettings } from '../../composables/useMarkingSettings'
|
||||||
import { useSmartScale } from '../../composables/useSmartScale'
|
|
||||||
import QuestionRenderer from './QuestionRenderer.vue'
|
import QuestionRenderer from './QuestionRenderer.vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
imageSize: number
|
imageSize: number
|
||||||
|
questionData: ExamStudentMarkingQuestionResponse[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
|
|
@ -15,16 +15,43 @@ const emit = defineEmits<{
|
||||||
'marking-change': [questionIndex: number, imageIndex: number, data: KonvaMarkingData]
|
'marking-change': [questionIndex: number, imageIndex: number, data: KonvaMarkingData]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// 智能缩放控制
|
const scale = defineModel<number>('scale', { default: 1.0 })
|
||||||
const smartScale = useSmartScale(props.imageSize)
|
|
||||||
|
|
||||||
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.1倍到5倍
|
||||||
|
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 containerRef = ref<HTMLElement>()
|
||||||
const initialDistance = ref(0)
|
const initialDistance = ref(0)
|
||||||
const initialScale = ref(1)
|
|
||||||
const isGesturing = ref(false)
|
const isGesturing = ref(false)
|
||||||
|
const initialScale = ref(1.0)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 计算两点之间的距离
|
* 计算两点之间的距离
|
||||||
|
|
@ -43,7 +70,7 @@ function handleTouchStart(e: TouchEvent) {
|
||||||
// 双指触摸,开始缩放手势
|
// 双指触摸,开始缩放手势
|
||||||
isGesturing.value = true
|
isGesturing.value = true
|
||||||
initialDistance.value = getDistance(e.touches[0], e.touches[1])
|
initialDistance.value = getDistance(e.touches[0], e.touches[1])
|
||||||
initialScale.value = smartScale.userScaleFactor.value
|
initialScale.value = userScaleFactor.value
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -61,7 +88,7 @@ function handleTouchMove(e: TouchEvent) {
|
||||||
|
|
||||||
// 应用新的缩放比例
|
// 应用新的缩放比例
|
||||||
const newScale = initialScale.value * scaleChange
|
const newScale = initialScale.value * scaleChange
|
||||||
smartScale.setScale(newScale)
|
setScale(newScale)
|
||||||
|
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
}
|
}
|
||||||
|
|
@ -86,10 +113,10 @@ const questionsLayoutClass = computed(() => ({
|
||||||
|
|
||||||
// 转换为渲染数据
|
// 转换为渲染数据
|
||||||
const questionDataList = computed(() => {
|
const questionDataList = computed(() => {
|
||||||
if (!questionData.value?.length)
|
if (!props.questionData?.length)
|
||||||
return []
|
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}`,
|
id: `question_${item.tpl_question_id || index}_${item.scan_info_id}_${index}`,
|
||||||
title: `${item.question_major || ''}${item.question_minor || ''}`,
|
title: `${item.question_major || ''}${item.question_minor || ''}`,
|
||||||
fullScore: item.full_score || 0,
|
fullScore: item.full_score || 0,
|
||||||
|
|
@ -109,8 +136,8 @@ function handleMarkingChange(questionIndex: number, imageIndex: number, data: Ko
|
||||||
<div
|
<div
|
||||||
ref="containerRef"
|
ref="containerRef"
|
||||||
class="multi-question-renderer"
|
class="multi-question-renderer"
|
||||||
@touchstart="handleTouchStart"
|
|
||||||
@touchmove="handleTouchMove"
|
@touchmove="handleTouchMove"
|
||||||
|
@touchstart="handleTouchStart"
|
||||||
@touchend="handleTouchEnd"
|
@touchend="handleTouchEnd"
|
||||||
>
|
>
|
||||||
<!-- 缩放提示 -->
|
<!-- 缩放提示 -->
|
||||||
|
|
@ -118,7 +145,7 @@ function handleMarkingChange(questionIndex: number, imageIndex: number, data: Ko
|
||||||
v-if="isGesturing"
|
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"
|
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>
|
||||||
|
|
||||||
<div class="flex flex-col flex-nowrap" :class="questionsLayoutClass">
|
<div class="flex flex-col flex-nowrap" :class="questionsLayoutClass">
|
||||||
|
|
@ -127,7 +154,7 @@ function handleMarkingChange(questionIndex: number, imageIndex: number, data: Ko
|
||||||
:key="question.id"
|
:key="question.id"
|
||||||
:question="question"
|
:question="question"
|
||||||
:question-index="questionIndex"
|
:question-index="questionIndex"
|
||||||
:scale="smartScale.finalScale.value"
|
:scale="finalScale"
|
||||||
:image-layout="markingSettings.imageLayout"
|
:image-layout="markingSettings.imageLayout"
|
||||||
:show-toolbar="markingSettings.showTraceToolbar"
|
:show-toolbar="markingSettings.showTraceToolbar"
|
||||||
class="question-item w-fit"
|
class="question-item w-fit"
|
||||||
|
|
@ -140,6 +167,7 @@ function handleMarkingChange(questionIndex: number, imageIndex: number, data: Ko
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.multi-question-renderer {
|
.multi-question-renderer {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 8rpx 16rpx;
|
padding: 8rpx 16rpx;
|
||||||
padding-bottom: 96rpx;
|
padding-bottom: 96rpx;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { KonvaMarkingData, useSimpleKonvaLayer } from '../../composables/renderer/useMarkingKonva'
|
import type { KonvaMarkingData, useSimpleKonvaLayer } from '../../composables/renderer/useMarkingKonva'
|
||||||
|
import { MarkingTool } from '../../composables/renderer/useMarkingKonva'
|
||||||
import { DictCode, useDict } from '@/composables/useDict'
|
import { DictCode, useDict } from '@/composables/useDict'
|
||||||
|
|
||||||
import { useMarkingData } from '../../composables/useMarkingData'
|
import { useMarkingData } from '../../composables/useMarkingData'
|
||||||
|
|
@ -144,6 +145,15 @@ function undo() {
|
||||||
* 处理快捷打分
|
* 处理快捷打分
|
||||||
*/
|
*/
|
||||||
function handleQuickScore(value: number) {
|
function handleQuickScore(value: number) {
|
||||||
|
// 只有在没有启用任何工具时才能快捷打分
|
||||||
|
if (currentTool.value !== MarkingTool.SELECT) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!markingSettings.value.quickScoreClickMode) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if (currentMarkingData.value) {
|
if (currentMarkingData.value) {
|
||||||
// 减分模式:如果当前分数是-1(未打分),则从满分开始减
|
// 减分模式:如果当前分数是-1(未打分),则从满分开始减
|
||||||
const currentScore =
|
const currentScore =
|
||||||
|
|
|
||||||
|
|
@ -160,35 +160,28 @@ function getSpecialButtonClass(type: 'excellent' | 'typical' | 'problem') {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 切换工具
|
* 切换工具(支持取消激活)
|
||||||
*/
|
*/
|
||||||
function switchTool(tool: MarkingTool) {
|
function switchTool(tool: MarkingTool) {
|
||||||
currentTool.value = tool
|
currentTool.value = currentTool.value === tool ? MarkingTool.SELECT : tool
|
||||||
pendingMarkType.value = null
|
pendingMarkType.value = null
|
||||||
|
|
||||||
// 显示/隐藏工具选项
|
// 显示/隐藏工具选项
|
||||||
settings.value.showToolOptions = tool === 'pen' || tool === 'text'
|
settings.value.showToolOptions = currentTool.value === 'pen' || currentTool.value === 'text'
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理正确标记
|
* 处理标记工具切换(正确/错误/半对)
|
||||||
*/
|
*/
|
||||||
function handleCorrectMark() {
|
function handleMarkTool(type: 'correct' | 'wrong' | 'half') {
|
||||||
currentTool.value = MarkingTool.CORRECT
|
const toolMap = {
|
||||||
|
correct: MarkingTool.CORRECT,
|
||||||
|
wrong: MarkingTool.WRONG,
|
||||||
|
half: MarkingTool.HALF,
|
||||||
}
|
}
|
||||||
|
const tool = toolMap[type]
|
||||||
/**
|
currentTool.value = currentTool.value === tool ? MarkingTool.SELECT : tool
|
||||||
* 处理错误标记
|
pendingMarkType.value = null
|
||||||
*/
|
|
||||||
function handleWrongMark() {
|
|
||||||
currentTool.value = MarkingTool.WRONG
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理半对标记
|
|
||||||
*/
|
|
||||||
function handleHalfMark() {
|
|
||||||
currentTool.value = MarkingTool.HALF
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -489,19 +482,19 @@ defineExpose({
|
||||||
<div :class="separatorClass" />
|
<div :class="separatorClass" />
|
||||||
<!-- 对错半对标记 -->
|
<!-- 对错半对标记 -->
|
||||||
<wd-tooltip content="正确标记" placement="top">
|
<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" />
|
<div :class="iconClass" class="i-fluent:checkmark-24-regular" />
|
||||||
</button>
|
</button>
|
||||||
</wd-tooltip>
|
</wd-tooltip>
|
||||||
|
|
||||||
<wd-tooltip content="错误标记" placement="top">
|
<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" />
|
<div :class="iconClass" class="i-fluent:dismiss-24-regular" />
|
||||||
</button>
|
</button>
|
||||||
</wd-tooltip>
|
</wd-tooltip>
|
||||||
|
|
||||||
<wd-tooltip content="半对标记" placement="top">
|
<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">
|
<svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||||
<!-- 对勾的第一段:短的向下斜线 -->
|
<!-- 对勾的第一段:短的向下斜线 -->
|
||||||
<line
|
<line
|
||||||
|
|
|
||||||
|
|
@ -146,12 +146,16 @@ export function useSimpleKonvaLayer({
|
||||||
const target = e.target
|
const target = e.target
|
||||||
const clickedOnExistingShape = target !== (layer as any) && target.getClassName() !== 'Image'
|
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()
|
const pos = getRelativePosition()
|
||||||
if (pos) {
|
if (pos) {
|
||||||
handleQuickScoreClick(pos)
|
handleQuickScoreClick(pos)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -411,7 +415,7 @@ export function useSimpleKonvaLayer({
|
||||||
x: pos.x,
|
x: pos.x,
|
||||||
y: pos.y,
|
y: pos.y,
|
||||||
text,
|
text,
|
||||||
fontSize: markingSettings.value.textSize,
|
fontSize: 48,
|
||||||
color: scoreMode === 'add' ? '#ff4d4f' : '#1890ff',
|
color: scoreMode === 'add' ? '#ff4d4f' : '#1890ff',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import type { Ref } from 'vue'
|
import type { Ref } from 'vue'
|
||||||
import type { ExamBatchCreateMarkingTaskRecordRequest, ExamCreateMarkingTaskRecordRequest, ExamQuestionAverageScoreComparisonResponse, ExamQuestionWithTasksResponse, ExamSetProblemRecordRequest, ExamStudentMarkingQuestionResponse } from '@/api'
|
import type { ExamBatchCreateMarkingTaskRecordRequest, ExamCreateMarkingTaskRecordRequest, ExamQuestionWithTasksResponse, ExamSetProblemRecordRequest, ExamStudentMarkingQuestionResponse } from '@/api'
|
||||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
import { useQuery } from '@tanstack/vue-query'
|
||||||
import { useDebounceFn, useThrottleFn } from '@vueuse/core'
|
|
||||||
import { computed, inject, provide, readonly, ref, watch } from 'vue'
|
import { computed, inject, provide, readonly, ref, watch } from 'vue'
|
||||||
import { examMarkingTaskApi } from '@/api'
|
import { examMarkingTaskApi } from '@/api'
|
||||||
|
import { getMarkingContext } from '@/composables/marking/MarkingContext'
|
||||||
|
import { useMarkingHistory } from '@/composables/marking/useMarkingHistory'
|
||||||
import { useUserId } from '@/composables/useUserId'
|
import { useUserId } from '@/composables/useUserId'
|
||||||
|
|
||||||
export interface MarkingSubmitData {
|
export interface MarkingSubmitData {
|
||||||
|
|
@ -30,6 +31,10 @@ export interface UseMarkingDataOptions {
|
||||||
function createMarkingData(options: UseMarkingDataOptions) {
|
function createMarkingData(options: UseMarkingDataOptions) {
|
||||||
const { taskId, questionId, examId, subjectId, isLandscape, taskType } = options
|
const { taskId, questionId, examId, subjectId, isLandscape, taskType } = options
|
||||||
|
|
||||||
|
// 获取阅卷上下文和历史管理
|
||||||
|
const markingContext = getMarkingContext()
|
||||||
|
const markingHistory = useMarkingHistory()
|
||||||
|
|
||||||
// 基础数据
|
// 基础数据
|
||||||
const questionData = ref<ExamStudentMarkingQuestionResponse[]>([])
|
const questionData = ref<ExamStudentMarkingQuestionResponse[]>([])
|
||||||
const currentMarkingSubmitData = ref<MarkingSubmitData[]>([])
|
const currentMarkingSubmitData = ref<MarkingSubmitData[]>([])
|
||||||
|
|
@ -38,8 +43,6 @@ function createMarkingData(options: UseMarkingDataOptions) {
|
||||||
const markingStartTime = ref<number>(0)
|
const markingStartTime = ref<number>(0)
|
||||||
const mode = ref<'single' | 'multi'>('single')
|
const mode = ref<'single' | 'multi'>('single')
|
||||||
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
// 当前分数
|
// 当前分数
|
||||||
const firstNotScoredIndex = computed(() => {
|
const firstNotScoredIndex = computed(() => {
|
||||||
return 0
|
return 0
|
||||||
|
|
@ -56,7 +59,7 @@ function createMarkingData(options: UseMarkingDataOptions) {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// 获取题目数据
|
// 获取题目数据(使用上下文提供者)
|
||||||
const {
|
const {
|
||||||
data: questionResponse,
|
data: questionResponse,
|
||||||
isLoading: isQuestionLoading,
|
isLoading: isQuestionLoading,
|
||||||
|
|
@ -64,7 +67,27 @@ function createMarkingData(options: UseMarkingDataOptions) {
|
||||||
refetch: refetchQuestion,
|
refetch: refetchQuestion,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ['marking-question', taskId, useUserId()],
|
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),
|
enabled: computed(() => !!taskId.value),
|
||||||
gcTime: 0,
|
gcTime: 0,
|
||||||
})
|
})
|
||||||
|
|
@ -160,6 +183,7 @@ function createMarkingData(options: UseMarkingDataOptions) {
|
||||||
throw new Error('题目数据不存在')
|
throw new Error('题目数据不存在')
|
||||||
|
|
||||||
const currentData = data || currentMarkingSubmitData.value[index]
|
const currentData = data || currentMarkingSubmitData.value[index]
|
||||||
|
const isHistoryMode = markingHistory.isViewingHistory.value
|
||||||
|
|
||||||
// 如果是问题卷,只提交问题卷记录,不提交正常阅卷记录
|
// 如果是问题卷,只提交问题卷记录,不提交正常阅卷记录
|
||||||
if (currentData.isProblem) {
|
if (currentData.isProblem) {
|
||||||
|
|
@ -169,15 +193,24 @@ function createMarkingData(options: UseMarkingDataOptions) {
|
||||||
problem_type: currentData.problemType! as any,
|
problem_type: currentData.problemType! as any,
|
||||||
problem_addition: currentData.problemRemark,
|
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
|
isSubmitted.value = true
|
||||||
|
|
||||||
|
// 如果是历史模式,刷新历史记录
|
||||||
|
if (isHistoryMode) {
|
||||||
|
await markingHistory.forceRefreshHistory()
|
||||||
|
}
|
||||||
|
else {
|
||||||
// 重新获取下一题
|
// 重新获取下一题
|
||||||
questionData.value = []
|
questionData.value = []
|
||||||
await refetchQuestion()
|
await refetchQuestion()
|
||||||
processQuestionData()
|
processQuestionData()
|
||||||
return response
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -186,6 +219,7 @@ function createMarkingData(options: UseMarkingDataOptions) {
|
||||||
id: question.id,
|
id: question.id,
|
||||||
scan_info_id: question.scan_info_id!,
|
scan_info_id: question.scan_info_id!,
|
||||||
question_id: questionId.value,
|
question_id: questionId.value,
|
||||||
|
record_id: 0,
|
||||||
task_id: taskId.value,
|
task_id: taskId.value,
|
||||||
duration: getMarkingTime(),
|
duration: getMarkingTime(),
|
||||||
score: currentData.score,
|
score: currentData.score,
|
||||||
|
|
@ -200,15 +234,33 @@ function createMarkingData(options: UseMarkingDataOptions) {
|
||||||
batch_data: [submitData],
|
batch_data: [submitData],
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await examMarkingTaskApi.batchCreate(batchRequest)
|
// 使用上下文提供者提交
|
||||||
|
const response = await markingContext.dataProvider.submitRecords(batchRequest)
|
||||||
|
|
||||||
if (response) {
|
if (response) {
|
||||||
isSubmitted.value = true
|
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 = []
|
questionData.value = []
|
||||||
await refetchQuestion()
|
await refetchQuestion()
|
||||||
processQuestionData()
|
processQuestionData()
|
||||||
|
}
|
||||||
|
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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条
|
||||||
|
* - 索引0(最旧)对应第3页的最后一条
|
||||||
|
* - 索引24(最新)对应第1页的第一条
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,7 @@ defineOptions({
|
||||||
// 字典功能
|
// 字典功能
|
||||||
const { getDictOptionsAndGetLabel } = useDict()
|
const { getDictOptionsAndGetLabel } = useDict()
|
||||||
const [, getTaskTypeName] = getDictOptionsAndGetLabel(DictCode.TASK_TYPE)
|
const [, getTaskTypeName] = getDictOptionsAndGetLabel(DictCode.TASK_TYPE)
|
||||||
|
const [, getEvaluateMethodName] = getDictOptionsAndGetLabel(DictCode.EVALUATE_METHOD)
|
||||||
|
|
||||||
// 获取路由参数
|
// 获取路由参数
|
||||||
const examId = ref<number>()
|
const examId = ref<number>()
|
||||||
|
|
@ -52,25 +53,81 @@ const {
|
||||||
enabled: enableQuery,
|
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
|
const data = questionsData.value
|
||||||
if (!data || !Array.isArray(data))
|
if (!data || !Array.isArray(data))
|
||||||
return []
|
return {}
|
||||||
|
|
||||||
return data.flatMap((question) => {
|
// 按 evaluate_method 分组
|
||||||
const tasks = Object.values(question.tasks || {})
|
const groups: Record<string, Record<string, GroupedQuestion>> = {}
|
||||||
return tasks.map(task => ({
|
|
||||||
...task,
|
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_major: question.question_major || '',
|
||||||
question_minor: question.question_minor || '',
|
question_minor: question.question_minor || '',
|
||||||
full_score: question.full_score || 0,
|
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)),
|
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) {
|
function getTaskButtonType(task: any) {
|
||||||
|
|
@ -145,57 +202,54 @@ function handleRefresh() {
|
||||||
</wd-button>
|
</wd-button>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 任务列表 -->
|
<!-- 任务列表 - 按类型分组 -->
|
||||||
<view v-else class="flex flex-col gap-3">
|
<view v-else class="flex flex-col gap-4">
|
||||||
<view
|
<view
|
||||||
v-for="task in allTasks"
|
v-for="group in groupedTasksList"
|
||||||
:key="task.id"
|
: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"
|
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">
|
<view class="flex items-baseline gap-2">
|
||||||
<text class="text-lg text-gray-800 font-semibold">
|
<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>
|
||||||
<text class="text-sm text-gray-500">满分: {{ task.full_score }}</text>
|
<!-- <text class="text-sm text-blue-500">效率优先</text> -->
|
||||||
<text class="text-xs text-gray-400">{{ task.evaluate_method }}</text>
|
<text class="text-xs text-gray-500">满分: {{ questionGroup.questionInfo.full_score }}</text>
|
||||||
</view>
|
|
||||||
<view class="text-xs text-gray-500">
|
|
||||||
任务ID: {{ task.id }}
|
|
||||||
</view>
|
</view>
|
||||||
</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 gap-4">
|
||||||
<view class="flex flex-col items-center gap-1">
|
<text class="text-sm text-gray-600">
|
||||||
<text class="text-xs text-gray-500">总任务</text>
|
已阅<text class="text-gray-800 font-semibold">{{ task.marked_quantity || 0 }}</text>
|
||||||
<text class="text-base text-blue-500 font-semibold">{{ task.task_quantity || 0 }}</text>
|
</text>
|
||||||
</view>
|
<text class="text-sm text-gray-600">
|
||||||
<view class="flex flex-col items-center gap-1">
|
待阅<text class="text-orange-500 font-semibold">{{ task.not_mark_quantity }}</text>
|
||||||
<text class="text-xs text-gray-500">已阅</text>
|
</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>
|
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<!-- 操作按钮 -->
|
||||||
<view class="flex gap-2">
|
<view class="flex gap-2">
|
||||||
<!-- 查看回评按钮 -->
|
<!-- 查看回评按钮 -->
|
||||||
<wd-button
|
<wd-button
|
||||||
|
|
@ -219,9 +273,11 @@ function handleRefresh() {
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</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>
|
<text class="text-sm text-gray-500">暂无任务数据</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,15 @@ import { whenever } from '@vueuse/core'
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import AnswerDialog from '@/components/marking/components/dialog/AnswerDialog.vue'
|
import AnswerDialog from '@/components/marking/components/dialog/AnswerDialog.vue'
|
||||||
import AvgScoreDialog from '@/components/marking/components/dialog/AvgScoreDialog.vue'
|
import AvgScoreDialog from '@/components/marking/components/dialog/AvgScoreDialog.vue'
|
||||||
|
import FullscreenImageDialog from '@/components/marking/components/dialog/FullscreenImageDialog.vue'
|
||||||
import ScoreSettingsDialog from '@/components/marking/components/dialog/ScoreSettingsDialog.vue'
|
import ScoreSettingsDialog from '@/components/marking/components/dialog/ScoreSettingsDialog.vue'
|
||||||
import QuickScorePanel from '@/components/marking/components/QuickScorePanel.vue'
|
import QuickScorePanel from '@/components/marking/components/QuickScorePanel.vue'
|
||||||
import MarkingImageViewerNew from '@/components/marking/components/renderer/MarkingImageViewerNew.vue'
|
import MarkingImageViewerNew from '@/components/marking/components/renderer/MarkingImageViewerNew.vue'
|
||||||
import { provideMarkingData } from '@/components/marking/composables/useMarkingData'
|
import { provideMarkingData } from '@/components/marking/composables/useMarkingData'
|
||||||
import MarkingLayout from '@/components/marking/MarkingLayout.vue'
|
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({
|
defineOptions({
|
||||||
name: 'GradingPage',
|
name: 'GradingPage',
|
||||||
|
|
@ -34,6 +38,35 @@ const taskType = ref<'initial' | 'final' | 'arbitration'>('initial')
|
||||||
// 屏幕方向状态
|
// 屏幕方向状态
|
||||||
const isLandscape = ref(false)
|
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({
|
const markingData = provideMarkingData({
|
||||||
taskId,
|
taskId,
|
||||||
questionId,
|
questionId,
|
||||||
|
|
@ -44,8 +77,8 @@ const markingData = provideMarkingData({
|
||||||
})
|
})
|
||||||
const { questionData: questions, questionsList, totalQuestions: totalQuestionsCount } = markingData
|
const { questionData: questions, questionsList, totalQuestions: totalQuestionsCount } = markingData
|
||||||
|
|
||||||
// 全屏状态
|
// 全屏弹窗状态
|
||||||
const isFullscreen = ref(false)
|
const showFullscreenImage = ref(false)
|
||||||
|
|
||||||
const currentQuestionIndex = ref(0)
|
const currentQuestionIndex = ref(0)
|
||||||
const totalQuestions = computed(() => totalQuestionsCount.value)
|
const totalQuestions = computed(() => totalQuestionsCount.value)
|
||||||
|
|
@ -82,43 +115,24 @@ function handleOrientationChange() {
|
||||||
// #endif
|
// #endif
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听全屏状态变化
|
onMounted(async () => {
|
||||||
function handleFullscreenChange() {
|
|
||||||
// #ifdef H5
|
|
||||||
isFullscreen.value = !!document.fullscreenElement
|
|
||||||
// #endif
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
window.addEventListener('orientationchange', handleOrientationChange)
|
window.addEventListener('orientationchange', handleOrientationChange)
|
||||||
document.addEventListener('fullscreenchange', handleFullscreenChange)
|
|
||||||
handleOrientationChange()
|
handleOrientationChange()
|
||||||
// #endif
|
// #endif
|
||||||
|
|
||||||
|
// 初始化导航
|
||||||
|
await initNavigation()
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
window.removeEventListener('orientationchange', handleOrientationChange)
|
window.removeEventListener('orientationchange', handleOrientationChange)
|
||||||
document.removeEventListener('fullscreenchange', handleFullscreenChange)
|
|
||||||
// #endif
|
// #endif
|
||||||
})
|
})
|
||||||
|
|
||||||
// 切换屏幕方向
|
// 切换屏幕方向
|
||||||
function toggleOrientation() {
|
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
|
// #ifdef APP-PLUS
|
||||||
if (isLandscape.value) {
|
if (isLandscape.value) {
|
||||||
plus.screen.lockOrientation('portrait-primary')
|
plus.screen.lockOrientation('portrait-primary')
|
||||||
|
|
@ -131,39 +145,9 @@ function toggleOrientation() {
|
||||||
isLandscape.value = !isLandscape.value
|
isLandscape.value = !isLandscape.value
|
||||||
}
|
}
|
||||||
|
|
||||||
// 全屏切换
|
// 打开全屏图片弹窗
|
||||||
function toggleFullscreen() {
|
function toggleFullscreen() {
|
||||||
// #ifdef H5
|
showFullscreenImage.value = true
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 返回上一页
|
// 返回上一页
|
||||||
|
|
@ -208,9 +192,20 @@ function viewAnswer() {
|
||||||
|
|
||||||
// 当前题目信息
|
// 当前题目信息
|
||||||
const currentQuestions = computed(() => questionsList.value[currentQuestionIndex.value])
|
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 currentTask = computed(() => currentQuestions.value?.tasks?.[taskType.value])
|
||||||
|
|
||||||
|
// 当前答题卡图片列表
|
||||||
|
const currentImageUrls = computed(() => currentQuestion.value?.image_urls || [])
|
||||||
|
|
||||||
let isFirst = true
|
let isFirst = true
|
||||||
whenever(questionsList, () => {
|
whenever(questionsList, () => {
|
||||||
if (isFirst) {
|
if (isFirst) {
|
||||||
|
|
@ -232,19 +227,49 @@ whenever(currentTask, (task, oldTask) => {
|
||||||
function handleQuickScoreSelect(score: number) {
|
function handleQuickScoreSelect(score: number) {
|
||||||
console.log('选择分数:', score)
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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
|
<MarkingLayout
|
||||||
:is-landscape="isLandscape"
|
:is-landscape="isLandscape"
|
||||||
:is-fullscreen="isFullscreen"
|
:is-fullscreen="false"
|
||||||
:current-question-index="currentQuestionIndex"
|
:current-question-index="currentQuestionIndex"
|
||||||
:current-task-submit="currentTask?.marked_quantity || 0"
|
:current-task-submit="currentTask?.marked_quantity || 0"
|
||||||
:total-questions="totalQuestions"
|
:total-questions="totalQuestions"
|
||||||
:questions="questionsList"
|
:questions="questionsList"
|
||||||
:my-score="myScore"
|
:my-score="myScore"
|
||||||
:avg-score="avgScore"
|
: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"
|
@go-back="goBack"
|
||||||
@select-question="selectQuestion"
|
@select-question="selectQuestion"
|
||||||
@open-score-settings="openScoreSettings"
|
@open-score-settings="openScoreSettings"
|
||||||
|
|
@ -252,17 +277,20 @@ function handleQuickScoreSelect(score: number) {
|
||||||
@view-answer="viewAnswer"
|
@view-answer="viewAnswer"
|
||||||
@toggle-orientation="toggleOrientation"
|
@toggle-orientation="toggleOrientation"
|
||||||
@toggle-fullscreen="toggleFullscreen"
|
@toggle-fullscreen="toggleFullscreen"
|
||||||
|
@prev-question="handlePrevQuestion"
|
||||||
|
@next-question="handleNextQuestion"
|
||||||
>
|
>
|
||||||
<template #content>
|
<template #content>
|
||||||
<MarkingImageViewerNew
|
<MarkingImageViewerNew
|
||||||
v-if="questions[0]?.image_urls?.length"
|
v-if="questions[0]?.image_urls?.length"
|
||||||
v-model:question-data="questions"
|
v-model:scale="imageScale"
|
||||||
|
:question-data="[currentQuestion]"
|
||||||
:image-size="100"
|
:image-size="100"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 快捷打分面板 - 固定定位在右侧 -->
|
<!-- 快捷打分面板 - 固定定位在右侧 -->
|
||||||
<QuickScorePanel
|
<QuickScorePanel
|
||||||
v-if="currentQuestion"
|
v-if="currentQuestion && !isViewingHistory"
|
||||||
:is-landscape="isLandscape"
|
:is-landscape="isLandscape"
|
||||||
:full-score="currentQuestion.full_score"
|
:full-score="currentQuestion.full_score"
|
||||||
@score-selected="handleQuickScoreSelect"
|
@score-selected="handleQuickScoreSelect"
|
||||||
|
|
@ -293,7 +321,13 @@ function handleQuickScoreSelect(score: number) {
|
||||||
v-model="showAnswer"
|
v-model="showAnswer"
|
||||||
:question-title="`${currentQuestion?.question_major}.${currentQuestion?.question_minor}`"
|
:question-title="`${currentQuestion?.question_major}.${currentQuestion?.question_minor}`"
|
||||||
:full-score="currentQuestion?.full_score"
|
:full-score="currentQuestion?.full_score"
|
||||||
:standard-answer="currentQuestion?.standard_answer"
|
:standard-answer="currentQuestion?.standard_answer || ''"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 全屏图片弹窗 -->
|
||||||
|
<FullscreenImageDialog
|
||||||
|
v-model="showFullscreenImage"
|
||||||
|
:image-urls="currentImageUrls"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue