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
|
||||
version: 2.2.12(typescript@5.9.2)
|
||||
|
||||
src/uni_modules/uni-icons: {}
|
||||
|
||||
src/uni_modules/uni-scss: {}
|
||||
|
||||
packages:
|
||||
|
||||
'@alova/adapter-uniapp@2.0.14':
|
||||
|
|
|
|||
|
|
@ -1,17 +1,22 @@
|
|||
<!-- 阅卷布局组件 -->
|
||||
<script lang="ts" setup>
|
||||
import type { ExamQuestionWithTasksResponse } from '@/api'
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useMarkingHistory } from '@/composables/marking/useMarkingHistory'
|
||||
|
||||
interface Props {
|
||||
isLandscape: boolean
|
||||
isFullscreen: boolean
|
||||
currentQuestionIndex: number
|
||||
totalQuestions: number
|
||||
currentTaskSubmit: number
|
||||
questions: ExamQuestionWithTasksResponse[] // 改为 any[] 以支持不同的数据结构
|
||||
questions: ExamQuestionWithTasksResponse[]
|
||||
myScore?: number
|
||||
avgScore?: number
|
||||
isViewingHistory?: boolean
|
||||
canGoPrev?: boolean
|
||||
canGoNext?: boolean
|
||||
historyModeText?: string
|
||||
taskId?: number
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
|
|
@ -22,15 +27,36 @@ interface Emits {
|
|||
(e: 'viewAnswer'): void
|
||||
(e: 'toggleOrientation'): void
|
||||
(e: 'toggleFullscreen'): void
|
||||
(e: 'prevQuestion'): void
|
||||
(e: 'nextQuestion'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
myScore: 0,
|
||||
avgScore: 0,
|
||||
isViewingHistory: false,
|
||||
canGoPrev: false,
|
||||
canGoNext: false,
|
||||
historyModeText: '',
|
||||
taskId: 0,
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// 获取历史记录管理
|
||||
const markingHistory = useMarkingHistory()
|
||||
const currentHistoryPage = ref(1)
|
||||
const { data: historyPageData, isLoading: isLoadingHistory } = markingHistory.useHistoryPage(
|
||||
computed(() => currentHistoryPage.value),
|
||||
)
|
||||
|
||||
// 监听 taskId 变化,重置历史记录页面
|
||||
watch(() => props.taskId, () => {
|
||||
if (props.taskId) {
|
||||
currentHistoryPage.value = 1
|
||||
}
|
||||
})
|
||||
|
||||
// 当前题目信息
|
||||
const currentQuestion = computed(() => props.questions[props.currentQuestionIndex])
|
||||
|
||||
|
|
@ -53,6 +79,34 @@ function formatScore(score: number): string {
|
|||
const formatted = score.toFixed(2)
|
||||
return formatted.replace(/\.?0+$/, '')
|
||||
}
|
||||
|
||||
// 历史记录列表(最新的在顶部)
|
||||
const historyList = computed(() => {
|
||||
if (!historyPageData.value?.list)
|
||||
return []
|
||||
// 不反转,后端返回的数据最新的在前,直接使用
|
||||
return historyPageData.value.list
|
||||
})
|
||||
|
||||
// 跳转到历史记录
|
||||
async function goToHistoryIndex(index: number) {
|
||||
// 列表索引转全局索引
|
||||
// 列表 index 0 是最新的,对应全局索引 total - 1
|
||||
// 列表 index n-1 是最旧的,对应全局索引 0
|
||||
const total = markingHistory.totalHistoryCount.value
|
||||
const globalIndex = total - 1 - index
|
||||
await markingHistory.goToHistoryIndex(globalIndex)
|
||||
}
|
||||
|
||||
// 当前选中的历史记录索引(用于高亮)
|
||||
const currentHistoryIndexInList = computed(() => {
|
||||
if (!props.isViewingHistory)
|
||||
return -1
|
||||
const currentIndex = markingHistory.currentHistoryIndex.value
|
||||
const total = markingHistory.totalHistoryCount.value
|
||||
// 全局索引转列表索引:全局索引 n-1(最新)对应列表索引 0
|
||||
return total - 1 - currentIndex
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -75,7 +129,7 @@ function formatScore(score: number): string {
|
|||
<!-- 题号信息 -->
|
||||
<view class="mr-16px flex items-center">
|
||||
<text class="text-16px text-gray-800 font-medium">
|
||||
{{ currentTaskSubmit + 1 }}/{{ totalQuestions }}
|
||||
{{ isViewingHistory ? historyModeText : `${currentTaskSubmit + 1}/${totalQuestions}` }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
|
|
@ -110,8 +164,7 @@ function formatScore(score: number): string {
|
|||
@click="emit('toggleFullscreen')"
|
||||
>
|
||||
<view
|
||||
:class="isFullscreen ? 'i-carbon-minimize' : 'i-carbon-maximize'"
|
||||
class="size-18px text-gray-700"
|
||||
class="i-carbon-image-search size-18px text-gray-700"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
|
@ -120,25 +173,46 @@ function formatScore(score: number): string {
|
|||
|
||||
<!-- 内容区域 -->
|
||||
<view class="h-0 flex flex-1">
|
||||
<!-- 左侧题目选择器 -->
|
||||
<view class="w-80px flex flex-col gap-8px border-r border-gray-200 bg-white p-8px">
|
||||
<!-- 左侧历史记录面板 -->
|
||||
<view class="w-80px flex flex-col overflow-auto border-r border-gray-200 bg-white">
|
||||
<!-- 历史记录列表 -->
|
||||
<view
|
||||
v-for="(question, index) in questions"
|
||||
:key="question.question_id"
|
||||
class="flex cursor-pointer items-center justify-center rounded py-6px text-12px transition-colors"
|
||||
v-for="(record, index) in historyList"
|
||||
:key="record.id"
|
||||
class="flex flex-col cursor-pointer items-center justify-center border-b border-gray-100 px-4px py-8px text-10px transition-colors"
|
||||
:class="[
|
||||
index === currentQuestionIndex
|
||||
currentHistoryIndexInList === index
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200',
|
||||
: 'bg-white text-gray-700 hover:bg-gray-50',
|
||||
]"
|
||||
@click="emit('selectQuestion', index)"
|
||||
@click="goToHistoryIndex(index)"
|
||||
>
|
||||
{{ question.question_major }}.{{ question.question_minor }}
|
||||
<view class="mb-2px font-medium">
|
||||
{{ record.question_major }}.{{ record.question_minor }}
|
||||
</view>
|
||||
<view class="text-12px font-semibold">
|
||||
{{ formatScore(record.score) }}/{{ formatScore(record.full_score) }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 右侧作答区域 -->
|
||||
<view class="relative h-full flex-1 overflow-auto bg-green-200">
|
||||
<!-- 左右箭头按钮 -->
|
||||
<view
|
||||
class="absolute left-2 top-1/2 size-32px flex cursor-pointer items-center justify-center rounded transition-colors -translate-y-1/2"
|
||||
:class="canGoPrev ? 'bg-gray-100 hover:bg-gray-200' : 'bg-gray-50 opacity-50 cursor-not-allowed'"
|
||||
@click="canGoPrev && emit('prevQuestion')"
|
||||
>
|
||||
<view class="i-carbon-chevron-left size-16px text-gray-700" />
|
||||
</view>
|
||||
<view
|
||||
class="absolute right-2 top-1/2 size-32px flex cursor-pointer items-center justify-center rounded transition-colors -translate-y-1/2"
|
||||
:class="canGoNext ? 'bg-gray-100 hover:bg-gray-200' : 'bg-gray-50 opacity-50 cursor-not-allowed'"
|
||||
@click="canGoNext && emit('nextQuestion')"
|
||||
>
|
||||
<view class="i-carbon-chevron-right size-16px text-gray-700" />
|
||||
</view>
|
||||
<slot name="content" :current-question="currentQuestion" />
|
||||
</view>
|
||||
</view>
|
||||
|
|
@ -179,8 +253,11 @@ function formatScore(score: number): string {
|
|||
<view class="h-12 flex items-center border-t border-gray-200 bg-white px-4 py-2">
|
||||
<!-- 题号信息 -->
|
||||
<view class="flex items-center gap-4">
|
||||
<text class="text-base text-gray-800 font-medium">
|
||||
{{ currentTaskSubmit + 1 }}/{{ totalQuestions }}
|
||||
<text v-if="!isViewingHistory" class="text-base text-gray-800 font-medium">
|
||||
{{ Math.min(totalQuestions, currentTaskSubmit + 1) }}/{{ totalQuestions }}
|
||||
</text>
|
||||
<text v-else class="text-base text-gray-800 font-medium">
|
||||
{{ historyModeText }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
|
|
@ -215,8 +292,7 @@ function formatScore(score: number): string {
|
|||
@click="emit('toggleFullscreen')"
|
||||
>
|
||||
<view
|
||||
:class="isFullscreen ? 'i-carbon-minimize' : 'i-carbon-maximize'"
|
||||
class="size-16px text-gray-700"
|
||||
class="i-carbon-image-search size-16px text-gray-700"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
|
@ -224,6 +300,22 @@ function formatScore(score: number): string {
|
|||
|
||||
<!-- 作答区域 -->
|
||||
<view class="relative h-0 flex-1 bg-green-200">
|
||||
<!-- 左右箭头按钮 -->
|
||||
<view
|
||||
class="absolute left-2 top-1/2 size-28px flex cursor-pointer items-center justify-center rounded transition-colors -translate-y-1/2"
|
||||
:class="canGoPrev ? 'bg-gray-100 hover:bg-gray-200' : 'bg-gray-50 opacity-50 cursor-not-allowed'"
|
||||
@click="canGoPrev && emit('prevQuestion')"
|
||||
>
|
||||
<view class="i-carbon-chevron-left size-16px text-gray-700" />
|
||||
</view>
|
||||
<view
|
||||
class="absolute right-2 top-1/2 size-28px flex cursor-pointer items-center justify-center rounded transition-colors -translate-y-1/2"
|
||||
:class="canGoNext ? 'bg-gray-100 hover:bg-gray-200' : 'bg-gray-50 opacity-50 cursor-not-allowed'"
|
||||
@click="canGoNext && emit('nextQuestion')"
|
||||
>
|
||||
<view class="i-carbon-chevron-right size-16px text-gray-700" />
|
||||
</view>
|
||||
|
||||
<slot name="content" :current-question="currentQuestion" />
|
||||
</view>
|
||||
</view>
|
||||
|
|
|
|||
|
|
@ -72,8 +72,8 @@ const displayItems = computed(() => {
|
|||
}
|
||||
})
|
||||
|
||||
// 横屏模式的两列布局
|
||||
const landscapeLayout = computed(() => {
|
||||
// 两列布局(展开时使用)
|
||||
const twoColumnLayout = computed(() => {
|
||||
const items = displayItems.value
|
||||
const layout: Array<Array<typeof items[0]>> = []
|
||||
|
||||
|
|
@ -190,33 +190,33 @@ async function submitCurrentScore() {
|
|||
'h-[calc(100vh-80px)] overflow-auto': isLandscape && !isCollapsed,
|
||||
}"
|
||||
>
|
||||
<!-- 按钮内容 -->
|
||||
<div v-if="!isLandscape || !isCollapsed" class="flex flex-col gap-2px">
|
||||
<!-- 竖屏:一列布局 -->
|
||||
<template v-if="!isLandscape">
|
||||
<!-- 按钮内容 - 始终展示 -->
|
||||
<div class="flex flex-col gap-2px">
|
||||
<!-- 收起时:一列布局 -->
|
||||
<template v-if="isCollapsed">
|
||||
<div
|
||||
v-for="item in displayItems"
|
||||
:key="item.label"
|
||||
class="flex cursor-pointer items-center justify-center border-2 rounded-8px transition-all active:scale-95"
|
||||
:class="[
|
||||
item.class,
|
||||
scoreMode === 'onekey' ? 'size-10' : 'h-10 w-16',
|
||||
scoreMode === 'onekey' ? 'size-48rpx lg:size-12' : 'h-48rpx w-80rpx lg:h-12 lg:w-20',
|
||||
]"
|
||||
@click="item.action"
|
||||
>
|
||||
<text
|
||||
class="font-medium"
|
||||
:class="scoreMode === 'onekey' ? 'text-18px' : 'text-14px'"
|
||||
:class="scoreMode === 'onekey' ? 'text-18rpx lg:text-base' : 'text-14rpx lg:text-sm'"
|
||||
>
|
||||
{{ item.label }}
|
||||
</text>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 横屏:两列布局 -->
|
||||
<!-- 展开时:两列布局 -->
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="(row, index) in landscapeLayout"
|
||||
v-for="(row, index) in twoColumnLayout"
|
||||
:key="index"
|
||||
class="flex gap-2px"
|
||||
>
|
||||
|
|
@ -246,12 +246,11 @@ async function submitCurrentScore() {
|
|||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<!-- 收起/展开按钮 - 始终显示 -->
|
||||
<div
|
||||
v-if="scoreMode === 'onekey' && isLandscape"
|
||||
class="flex cursor-pointer items-center justify-center border-2 border-blue-200 rounded-8px bg-blue-50 transition-all active:scale-95"
|
||||
:class="isLandscape ? 'size-48rpx lg:size-12' : 'size-10'"
|
||||
@click="isLandscape ? toggleCollapse() : undefined"
|
||||
@click="toggleCollapse"
|
||||
>
|
||||
<div
|
||||
class="text-blue-500 transition-transform"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<div class="mb-12px text-14px text-gray-700">
|
||||
缩放面板设置
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-12px">
|
||||
<text class="text-13px text-gray-600">显示缩放面板</text>
|
||||
<wd-switch v-model="settings.showScalePanel" size="small" />
|
||||
</div>
|
||||
<div v-if="settings.showScalePanel" class="flex items-center gap-12px">
|
||||
<text class="text-13px text-gray-600">默认收起</text>
|
||||
<wd-switch v-model="settings.scalePanelCollapsed" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-8px text-12px text-gray-500">
|
||||
缩放面板可以帮助您快速调整图片大小和适应屏幕
|
||||
</div>
|
||||
</div>
|
||||
<!-- 设置加分/扣分步长 -->
|
||||
<div>
|
||||
<div class="mb-12px flex items-center justify-between text-14px text-gray-700">
|
||||
|
|
@ -437,25 +418,6 @@ function resetQuickScoreScores() {
|
|||
|
||||
<!-- 一键打分设置 -->
|
||||
<div v-if="activeTab === 'onekey'" class="space-y-24px">
|
||||
<!-- 缩放面板设置 -->
|
||||
<div>
|
||||
<div class="mb-12px text-14px text-gray-700">
|
||||
缩放面板设置
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-12px">
|
||||
<text class="text-13px text-gray-600">显示缩放面板</text>
|
||||
<wd-switch v-model="settings.showScalePanel" size="small" />
|
||||
</div>
|
||||
<div v-if="settings.showScalePanel" class="flex items-center gap-12px">
|
||||
<text class="text-13px text-gray-600">默认收起</text>
|
||||
<wd-switch v-model="settings.scalePanelCollapsed" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-8px text-12px text-gray-500">
|
||||
缩放面板可以帮助您快速调整图片大小和适应屏幕
|
||||
</div>
|
||||
</div>
|
||||
<!-- 设置步长 -->
|
||||
<div>
|
||||
<div class="mb-12px flex items-center justify-between text-14px text-gray-700">
|
||||
|
|
|
|||
|
|
@ -2,11 +2,11 @@
|
|||
import type { KonvaMarkingData } from '../../composables/renderer/useMarkingKonva'
|
||||
import type { ExamStudentMarkingQuestionResponse } from '@/api'
|
||||
import { markingSettings } from '../../composables/useMarkingSettings'
|
||||
import { useSmartScale } from '../../composables/useSmartScale'
|
||||
import QuestionRenderer from './QuestionRenderer.vue'
|
||||
|
||||
interface Props {
|
||||
imageSize: number
|
||||
questionData: ExamStudentMarkingQuestionResponse[]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
|
@ -15,16 +15,43 @@ const emit = defineEmits<{
|
|||
'marking-change': [questionIndex: number, imageIndex: number, data: KonvaMarkingData]
|
||||
}>()
|
||||
|
||||
// 智能缩放控制
|
||||
const smartScale = useSmartScale(props.imageSize)
|
||||
const scale = defineModel<number>('scale', { default: 1.0 })
|
||||
|
||||
const questionData = defineModel<ExamStudentMarkingQuestionResponse[]>('questionData')
|
||||
// 智能缩放控制 - 内联实现
|
||||
// 用户手动调节的缩放因子(1.0 = 默认100%,0.5 = 缩小50%,2.0 = 放大200%)
|
||||
const userScaleFactor = ref(scale.value || 1.0)
|
||||
|
||||
// 最终的缩放比例 - 默认100%
|
||||
const finalScale = computed(() => {
|
||||
return userScaleFactor.value
|
||||
})
|
||||
|
||||
// 设置缩放比例(用于手势缩放)
|
||||
function setScale(newScale: number) {
|
||||
// 限制缩放范围:0.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 initialDistance = ref(0)
|
||||
const initialScale = ref(1)
|
||||
const isGesturing = ref(false)
|
||||
const initialScale = ref(1.0)
|
||||
|
||||
/**
|
||||
* 计算两点之间的距离
|
||||
|
|
@ -43,7 +70,7 @@ function handleTouchStart(e: TouchEvent) {
|
|||
// 双指触摸,开始缩放手势
|
||||
isGesturing.value = true
|
||||
initialDistance.value = getDistance(e.touches[0], e.touches[1])
|
||||
initialScale.value = smartScale.userScaleFactor.value
|
||||
initialScale.value = userScaleFactor.value
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
|
@ -61,7 +88,7 @@ function handleTouchMove(e: TouchEvent) {
|
|||
|
||||
// 应用新的缩放比例
|
||||
const newScale = initialScale.value * scaleChange
|
||||
smartScale.setScale(newScale)
|
||||
setScale(newScale)
|
||||
|
||||
e.preventDefault()
|
||||
}
|
||||
|
|
@ -86,10 +113,10 @@ const questionsLayoutClass = computed(() => ({
|
|||
|
||||
// 转换为渲染数据
|
||||
const questionDataList = computed(() => {
|
||||
if (!questionData.value?.length)
|
||||
if (!props.questionData?.length)
|
||||
return []
|
||||
|
||||
return questionData.value.map((item, index) => ({
|
||||
return props.questionData.map((item, index) => ({
|
||||
id: `question_${item.tpl_question_id || index}_${item.scan_info_id}_${index}`,
|
||||
title: `${item.question_major || ''}${item.question_minor || ''}`,
|
||||
fullScore: item.full_score || 0,
|
||||
|
|
@ -109,8 +136,8 @@ function handleMarkingChange(questionIndex: number, imageIndex: number, data: Ko
|
|||
<div
|
||||
ref="containerRef"
|
||||
class="multi-question-renderer"
|
||||
@touchstart="handleTouchStart"
|
||||
@touchmove="handleTouchMove"
|
||||
@touchstart="handleTouchStart"
|
||||
@touchend="handleTouchEnd"
|
||||
>
|
||||
<!-- 缩放提示 -->
|
||||
|
|
@ -118,7 +145,7 @@ function handleMarkingChange(questionIndex: number, imageIndex: number, data: Ko
|
|||
v-if="isGesturing"
|
||||
class="fixed left-1/2 top-1/2 z-50 rounded-lg bg-black/70 px-4 py-2 text-white -translate-x-1/2 -translate-y-1/2"
|
||||
>
|
||||
{{ smartScale.scaleText.value }}
|
||||
{{ scaleText }}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col flex-nowrap" :class="questionsLayoutClass">
|
||||
|
|
@ -127,7 +154,7 @@ function handleMarkingChange(questionIndex: number, imageIndex: number, data: Ko
|
|||
:key="question.id"
|
||||
:question="question"
|
||||
:question-index="questionIndex"
|
||||
:scale="smartScale.finalScale.value"
|
||||
:scale="finalScale"
|
||||
:image-layout="markingSettings.imageLayout"
|
||||
:show-toolbar="markingSettings.showTraceToolbar"
|
||||
class="question-item w-fit"
|
||||
|
|
@ -140,6 +167,7 @@ function handleMarkingChange(questionIndex: number, imageIndex: number, data: Ko
|
|||
<style scoped>
|
||||
.multi-question-renderer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
padding: 8rpx 16rpx;
|
||||
padding-bottom: 96rpx;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import type { KonvaMarkingData, useSimpleKonvaLayer } from '../../composables/renderer/useMarkingKonva'
|
||||
import { MarkingTool } from '../../composables/renderer/useMarkingKonva'
|
||||
import { DictCode, useDict } from '@/composables/useDict'
|
||||
|
||||
import { useMarkingData } from '../../composables/useMarkingData'
|
||||
|
|
@ -144,6 +145,15 @@ function undo() {
|
|||
* 处理快捷打分
|
||||
*/
|
||||
function handleQuickScore(value: number) {
|
||||
// 只有在没有启用任何工具时才能快捷打分
|
||||
if (currentTool.value !== MarkingTool.SELECT) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!markingSettings.value.quickScoreClickMode) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (currentMarkingData.value) {
|
||||
// 减分模式:如果当前分数是-1(未打分),则从满分开始减
|
||||
const currentScore =
|
||||
|
|
|
|||
|
|
@ -160,35 +160,28 @@ function getSpecialButtonClass(type: 'excellent' | 'typical' | 'problem') {
|
|||
}
|
||||
|
||||
/**
|
||||
* 切换工具
|
||||
* 切换工具(支持取消激活)
|
||||
*/
|
||||
function switchTool(tool: MarkingTool) {
|
||||
currentTool.value = tool
|
||||
currentTool.value = currentTool.value === tool ? MarkingTool.SELECT : tool
|
||||
pendingMarkType.value = null
|
||||
|
||||
// 显示/隐藏工具选项
|
||||
settings.value.showToolOptions = tool === 'pen' || tool === 'text'
|
||||
settings.value.showToolOptions = currentTool.value === 'pen' || currentTool.value === 'text'
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理正确标记
|
||||
* 处理标记工具切换(正确/错误/半对)
|
||||
*/
|
||||
function handleCorrectMark() {
|
||||
currentTool.value = MarkingTool.CORRECT
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理错误标记
|
||||
*/
|
||||
function handleWrongMark() {
|
||||
currentTool.value = MarkingTool.WRONG
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理半对标记
|
||||
*/
|
||||
function handleHalfMark() {
|
||||
currentTool.value = MarkingTool.HALF
|
||||
function handleMarkTool(type: 'correct' | 'wrong' | 'half') {
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -489,19 +482,19 @@ defineExpose({
|
|||
<div :class="separatorClass" />
|
||||
<!-- 对错半对标记 -->
|
||||
<wd-tooltip content="正确标记" placement="top">
|
||||
<button :class="getMarkButtonClass('correct')" @click="handleCorrectMark">
|
||||
<button :class="getMarkButtonClass('correct')" @click="handleMarkTool('correct')">
|
||||
<div :class="iconClass" class="i-fluent:checkmark-24-regular" />
|
||||
</button>
|
||||
</wd-tooltip>
|
||||
|
||||
<wd-tooltip content="错误标记" placement="top">
|
||||
<button :class="getMarkButtonClass('wrong')" @click="handleWrongMark">
|
||||
<button :class="getMarkButtonClass('wrong')" @click="handleMarkTool('wrong')">
|
||||
<div :class="iconClass" class="i-fluent:dismiss-24-regular" />
|
||||
</button>
|
||||
</wd-tooltip>
|
||||
|
||||
<wd-tooltip content="半对标记" placement="top">
|
||||
<button :class="getMarkButtonClass('half')" @click="handleHalfMark">
|
||||
<button :class="getMarkButtonClass('half')" @click="handleMarkTool('half')">
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- 对勾的第一段:短的向下斜线 -->
|
||||
<line
|
||||
|
|
|
|||
|
|
@ -146,11 +146,15 @@ export function useSimpleKonvaLayer({
|
|||
const target = e.target
|
||||
const clickedOnExistingShape = target !== (layer as any) && target.getClassName() !== 'Image'
|
||||
|
||||
// 如果快捷打分点击模式激活,优先处理
|
||||
if (markingSettings.value.quickScoreClickMode) {
|
||||
const pos = getRelativePosition()
|
||||
if (pos) {
|
||||
handleQuickScoreClick(pos)
|
||||
// 如果快捷打分点击模式激活,且当前没有选择任何工具,优先处理(仅左键)
|
||||
if (markingSettings.value.quickScoreClickMode && currentTool.value === MarkingTool.SELECT) {
|
||||
const mouseEvent = e.evt as MouseEvent
|
||||
// 只处理左键点击(button === 0)
|
||||
if (mouseEvent.button === 0) {
|
||||
const pos = getRelativePosition()
|
||||
if (pos) {
|
||||
handleQuickScoreClick(pos)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
@ -411,7 +415,7 @@ export function useSimpleKonvaLayer({
|
|||
x: pos.x,
|
||||
y: pos.y,
|
||||
text,
|
||||
fontSize: markingSettings.value.textSize,
|
||||
fontSize: 48,
|
||||
color: scoreMode === 'add' ? '#ff4d4f' : '#1890ff',
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import type { Ref } from 'vue'
|
||||
import type { ExamBatchCreateMarkingTaskRecordRequest, ExamCreateMarkingTaskRecordRequest, ExamQuestionAverageScoreComparisonResponse, ExamQuestionWithTasksResponse, ExamSetProblemRecordRequest, ExamStudentMarkingQuestionResponse } from '@/api'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { useDebounceFn, useThrottleFn } from '@vueuse/core'
|
||||
import type { ExamBatchCreateMarkingTaskRecordRequest, ExamCreateMarkingTaskRecordRequest, ExamQuestionWithTasksResponse, ExamSetProblemRecordRequest, ExamStudentMarkingQuestionResponse } from '@/api'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { computed, inject, provide, readonly, ref, watch } from 'vue'
|
||||
import { examMarkingTaskApi } from '@/api'
|
||||
import { getMarkingContext } from '@/composables/marking/MarkingContext'
|
||||
import { useMarkingHistory } from '@/composables/marking/useMarkingHistory'
|
||||
import { useUserId } from '@/composables/useUserId'
|
||||
|
||||
export interface MarkingSubmitData {
|
||||
|
|
@ -30,6 +31,10 @@ export interface UseMarkingDataOptions {
|
|||
function createMarkingData(options: UseMarkingDataOptions) {
|
||||
const { taskId, questionId, examId, subjectId, isLandscape, taskType } = options
|
||||
|
||||
// 获取阅卷上下文和历史管理
|
||||
const markingContext = getMarkingContext()
|
||||
const markingHistory = useMarkingHistory()
|
||||
|
||||
// 基础数据
|
||||
const questionData = ref<ExamStudentMarkingQuestionResponse[]>([])
|
||||
const currentMarkingSubmitData = ref<MarkingSubmitData[]>([])
|
||||
|
|
@ -38,8 +43,6 @@ function createMarkingData(options: UseMarkingDataOptions) {
|
|||
const markingStartTime = ref<number>(0)
|
||||
const mode = ref<'single' | 'multi'>('single')
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// 当前分数
|
||||
const firstNotScoredIndex = computed(() => {
|
||||
return 0
|
||||
|
|
@ -56,7 +59,7 @@ function createMarkingData(options: UseMarkingDataOptions) {
|
|||
},
|
||||
})
|
||||
|
||||
// 获取题目数据
|
||||
// 获取题目数据(使用上下文提供者)
|
||||
const {
|
||||
data: questionResponse,
|
||||
isLoading: isQuestionLoading,
|
||||
|
|
@ -64,7 +67,27 @@ function createMarkingData(options: UseMarkingDataOptions) {
|
|||
refetch: refetchQuestion,
|
||||
} = useQuery({
|
||||
queryKey: ['marking-question', taskId, useUserId()],
|
||||
queryFn: async () => examMarkingTaskApi.questionDetail(taskId.value),
|
||||
queryFn: async () => {
|
||||
// 如果在历史查看模式,返回历史记录
|
||||
if (markingHistory.isViewingHistory.value && markingHistory.currentHistoryRecord.value) {
|
||||
const record = markingHistory.currentHistoryRecord.value
|
||||
// 将历史记录转换为题目数据格式
|
||||
return {
|
||||
id: record.id,
|
||||
task_id: record.task_id,
|
||||
question_id: record.question_id,
|
||||
scan_info_id: record.scan_info_id,
|
||||
score: record.score,
|
||||
full_score: record.full_score,
|
||||
question_major: record.question_major,
|
||||
question_minor: record.question_minor,
|
||||
image_urls: record.image_urls,
|
||||
} as ExamStudentMarkingQuestionResponse
|
||||
}
|
||||
|
||||
// 否则使用上下文提供者获取当前题目
|
||||
return markingContext.dataProvider.getSingleQuestion(taskId.value)
|
||||
},
|
||||
enabled: computed(() => !!taskId.value),
|
||||
gcTime: 0,
|
||||
})
|
||||
|
|
@ -160,6 +183,7 @@ function createMarkingData(options: UseMarkingDataOptions) {
|
|||
throw new Error('题目数据不存在')
|
||||
|
||||
const currentData = data || currentMarkingSubmitData.value[index]
|
||||
const isHistoryMode = markingHistory.isViewingHistory.value
|
||||
|
||||
// 如果是问题卷,只提交问题卷记录,不提交正常阅卷记录
|
||||
if (currentData.isProblem) {
|
||||
|
|
@ -169,15 +193,24 @@ function createMarkingData(options: UseMarkingDataOptions) {
|
|||
problem_type: currentData.problemType! as any,
|
||||
problem_addition: currentData.problemRemark,
|
||||
}
|
||||
const response = await examMarkingTaskApi.problemRecordCreate(problemRequest)
|
||||
if (response) {
|
||||
isSubmitted.value = true
|
||||
|
||||
if (markingContext.dataProvider.createProblemRecord) {
|
||||
await markingContext.dataProvider.createProblemRecord(problemRequest as any)
|
||||
}
|
||||
|
||||
isSubmitted.value = true
|
||||
|
||||
// 如果是历史模式,刷新历史记录
|
||||
if (isHistoryMode) {
|
||||
await markingHistory.forceRefreshHistory()
|
||||
}
|
||||
else {
|
||||
// 重新获取下一题
|
||||
questionData.value = []
|
||||
await refetchQuestion()
|
||||
processQuestionData()
|
||||
return response
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -186,6 +219,7 @@ function createMarkingData(options: UseMarkingDataOptions) {
|
|||
id: question.id,
|
||||
scan_info_id: question.scan_info_id!,
|
||||
question_id: questionId.value,
|
||||
record_id: 0,
|
||||
task_id: taskId.value,
|
||||
duration: getMarkingTime(),
|
||||
score: currentData.score,
|
||||
|
|
@ -200,15 +234,33 @@ function createMarkingData(options: UseMarkingDataOptions) {
|
|||
batch_data: [submitData],
|
||||
}
|
||||
|
||||
const response = await examMarkingTaskApi.batchCreate(batchRequest)
|
||||
// 使用上下文提供者提交
|
||||
const response = await markingContext.dataProvider.submitRecords(batchRequest)
|
||||
|
||||
if (response) {
|
||||
isSubmitted.value = true
|
||||
currentTaskInfo.value!.marked_quantity = (currentTaskInfo.value!.marked_quantity || 0) + 1
|
||||
// 重新获取下一题
|
||||
questionData.value = []
|
||||
await refetchQuestion()
|
||||
processQuestionData()
|
||||
|
||||
// 更新历史记录总数
|
||||
if (!isHistoryMode && response.success_count > 0) {
|
||||
markingHistory.incrementHistoryCount(response.success_count)
|
||||
}
|
||||
|
||||
// 更新已阅数量
|
||||
if (currentTaskInfo.value) {
|
||||
currentTaskInfo.value.marked_quantity = (currentTaskInfo.value.marked_quantity || 0) + 1
|
||||
}
|
||||
|
||||
// 如果是历史模式,刷新历史记录
|
||||
if (isHistoryMode) {
|
||||
await markingHistory.forceRefreshHistory()
|
||||
}
|
||||
else {
|
||||
// 重新获取下一题
|
||||
questionData.value = []
|
||||
await refetchQuestion()
|
||||
processQuestionData()
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 [, getTaskTypeName] = getDictOptionsAndGetLabel(DictCode.TASK_TYPE)
|
||||
const [, getEvaluateMethodName] = getDictOptionsAndGetLabel(DictCode.EVALUATE_METHOD)
|
||||
|
||||
// 获取路由参数
|
||||
const examId = ref<number>()
|
||||
|
|
@ -52,24 +53,80 @@ const {
|
|||
enabled: enableQuery,
|
||||
})
|
||||
|
||||
// 展平所有任务 - 使用flatMap简化
|
||||
const allTasks = computed(() => {
|
||||
// 按评价类型和题目分组任务
|
||||
interface GroupedQuestion {
|
||||
questionKey: string
|
||||
questionInfo: {
|
||||
question_major: string
|
||||
question_minor: string
|
||||
full_score: number
|
||||
evaluate_method: string
|
||||
question_id: number
|
||||
}
|
||||
tasks: Array<{
|
||||
id: number
|
||||
question_id: number
|
||||
task_type: string
|
||||
task_quantity: number
|
||||
marked_quantity: number
|
||||
not_mark_quantity: number
|
||||
[key: string]: any
|
||||
}>
|
||||
}
|
||||
|
||||
const groupedTasks = computed(() => {
|
||||
const data = questionsData.value
|
||||
if (!data || !Array.isArray(data))
|
||||
return []
|
||||
return {}
|
||||
|
||||
return data.flatMap((question) => {
|
||||
// 按 evaluate_method 分组
|
||||
const groups: Record<string, Record<string, GroupedQuestion>> = {}
|
||||
|
||||
data.forEach((question) => {
|
||||
const evaluateMethod = Object.values(question.tasks || {})[0]?.evaluate_method || 'other'
|
||||
const questionKey = `${question.question_major || ''}.${question.question_minor || ''}`
|
||||
|
||||
// 初始化分组
|
||||
if (!groups[evaluateMethod]) {
|
||||
groups[evaluateMethod] = {}
|
||||
}
|
||||
|
||||
// 初始化题目组
|
||||
if (!groups[evaluateMethod][questionKey]) {
|
||||
groups[evaluateMethod][questionKey] = {
|
||||
questionKey,
|
||||
questionInfo: {
|
||||
question_major: question.question_major || '',
|
||||
question_minor: question.question_minor || '',
|
||||
full_score: question.full_score || 0,
|
||||
evaluate_method: evaluateMethod,
|
||||
question_id: question.question_id || 0,
|
||||
},
|
||||
tasks: [],
|
||||
}
|
||||
}
|
||||
|
||||
// 添加该题目的所有任务
|
||||
const tasks = Object.values(question.tasks || {})
|
||||
return tasks.map(task => ({
|
||||
...task,
|
||||
question_major: question.question_major || '',
|
||||
question_minor: question.question_minor || '',
|
||||
full_score: question.full_score || 0,
|
||||
evaluate_method: question.evaluate_method || '',
|
||||
// 计算待阅数量
|
||||
not_mark_quantity: Math.max(0, (task.task_quantity || 0) - (task.marked_quantity || 0)),
|
||||
}))
|
||||
tasks.forEach((task) => {
|
||||
groups[evaluateMethod][questionKey].tasks.push({
|
||||
...task,
|
||||
question_id: question.question_id || 0,
|
||||
not_mark_quantity: Math.max(0, (task.task_quantity || 0) - (task.marked_quantity || 0)),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return groups
|
||||
})
|
||||
|
||||
// 获取分组后的数组(用于模板渲染)
|
||||
const groupedTasksList = computed(() => {
|
||||
return Object.entries(groupedTasks.value).map(([evaluateMethod, questions]) => ({
|
||||
evaluateMethod,
|
||||
evaluateMethodName: getEvaluateMethodName(evaluateMethod),
|
||||
questions: Object.values(questions),
|
||||
}))
|
||||
})
|
||||
|
||||
// 获取任务按钮样式
|
||||
|
|
@ -145,83 +202,82 @@ function handleRefresh() {
|
|||
</wd-button>
|
||||
</view>
|
||||
|
||||
<!-- 任务列表 -->
|
||||
<view v-else class="flex flex-col gap-3">
|
||||
<!-- 任务列表 - 按类型分组 -->
|
||||
<view v-else class="flex flex-col gap-4">
|
||||
<view
|
||||
v-for="task in allTasks"
|
||||
:key="task.id"
|
||||
class="relative rounded-3 bg-white p-4 shadow-sm"
|
||||
v-for="group in groupedTasksList"
|
||||
:key="group.evaluateMethod"
|
||||
class="flex flex-col gap-3"
|
||||
>
|
||||
<!-- 左上角任务类型标签 -->
|
||||
<!-- 分组标题 -->
|
||||
<view class="flex items-center gap-2">
|
||||
<text class="text-base text-gray-800 font-semibold">{{ group.evaluateMethodName }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 该组下的题目卡片 -->
|
||||
<view
|
||||
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),
|
||||
}"
|
||||
v-for="questionGroup in group.questions"
|
||||
:key="questionGroup.questionKey"
|
||||
class="relative rounded-3 bg-white p-4 shadow-sm"
|
||||
>
|
||||
{{ getTaskTypeName(task.task_type) }}
|
||||
</view>
|
||||
|
||||
<!-- 题目信息 -->
|
||||
<view class="mb-3 mt-2 flex items-start justify-between">
|
||||
<view class="flex items-baseline gap-2">
|
||||
<text class="text-lg text-gray-800 font-semibold">
|
||||
{{ task.question_major }}.{{ task.question_minor }}
|
||||
</text>
|
||||
<text class="text-sm text-gray-500">满分: {{ task.full_score }}</text>
|
||||
<text class="text-xs text-gray-400">{{ task.evaluate_method }}</text>
|
||||
</view>
|
||||
<view class="text-xs text-gray-500">
|
||||
任务ID: {{ task.id }}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 统计信息和操作按钮 -->
|
||||
<view class="flex items-center justify-between">
|
||||
<view class="flex gap-4">
|
||||
<view class="flex flex-col items-center gap-1">
|
||||
<text class="text-xs text-gray-500">总任务</text>
|
||||
<text class="text-base text-blue-500 font-semibold">{{ task.task_quantity || 0 }}</text>
|
||||
</view>
|
||||
<view class="flex flex-col items-center gap-1">
|
||||
<text class="text-xs text-gray-500">已阅</text>
|
||||
<text class="text-base text-green-500 font-semibold">{{ task.marked_quantity || 0 }}</text>
|
||||
</view>
|
||||
<view class="flex flex-col items-center gap-1">
|
||||
<text class="text-xs text-gray-500">待阅</text>
|
||||
<text class="text-base text-orange-500 font-semibold">{{ task.not_mark_quantity }}</text>
|
||||
<!-- 题目信息 -->
|
||||
<view class="mb-3 flex items-start justify-between">
|
||||
<view class="flex items-baseline gap-2">
|
||||
<text class="text-lg text-gray-800 font-semibold">
|
||||
{{ questionGroup.questionInfo.question_major }}.{{ questionGroup.questionInfo.question_minor }}
|
||||
</text>
|
||||
<!-- <text class="text-sm text-blue-500">效率优先</text> -->
|
||||
<text class="text-xs text-gray-500">满分: {{ questionGroup.questionInfo.full_score }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="flex gap-2">
|
||||
<!-- 查看回评按钮 -->
|
||||
<wd-button
|
||||
v-if="(task.marked_quantity || 0) > 0"
|
||||
size="small"
|
||||
type="info"
|
||||
@click="handleViewReview(task)"
|
||||
<!-- 该题目的多个任务 - 竖着展示 -->
|
||||
<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 }"
|
||||
>
|
||||
回评
|
||||
</wd-button>
|
||||
<!-- 任务统计信息 -->
|
||||
<view class="flex gap-4">
|
||||
<text class="text-sm text-gray-600">
|
||||
已阅<text class="text-gray-800 font-semibold">{{ task.marked_quantity || 0 }}</text>
|
||||
</text>
|
||||
<text class="text-sm text-gray-600">
|
||||
待阅<text class="text-orange-500 font-semibold">{{ task.not_mark_quantity }}</text>
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 开始阅卷按钮 -->
|
||||
<wd-button
|
||||
size="small"
|
||||
:type="getTaskButtonType(task)"
|
||||
:disabled="task.not_mark_quantity <= 0"
|
||||
@click="handleStartMarking(task)"
|
||||
>
|
||||
{{ task.not_mark_quantity > 0 ? getTaskTypeName(task.task_type) : '已完成' }}
|
||||
</wd-button>
|
||||
<!-- 操作按钮 -->
|
||||
<view class="flex gap-2">
|
||||
<!-- 查看回评按钮 -->
|
||||
<wd-button
|
||||
v-if="(task.marked_quantity || 0) > 0"
|
||||
size="small"
|
||||
type="info"
|
||||
@click="handleViewReview(task)"
|
||||
>
|
||||
回评
|
||||
</wd-button>
|
||||
|
||||
<!-- 开始阅卷按钮 -->
|
||||
<wd-button
|
||||
size="small"
|
||||
:type="getTaskButtonType(task)"
|
||||
:disabled="task.not_mark_quantity <= 0"
|
||||
@click="handleStartMarking(task)"
|
||||
>
|
||||
{{ task.not_mark_quantity > 0 ? getTaskTypeName(task.task_type) : '已完成' }}
|
||||
</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<view v-if="allTasks.length === 0" class="h-50 flex items-center justify-center">
|
||||
<view v-if="groupedTasksList.length === 0" class="h-50 flex items-center justify-center">
|
||||
<text class="text-sm text-gray-500">暂无任务数据</text>
|
||||
</view>
|
||||
</view>
|
||||
|
|
|
|||
|
|
@ -13,11 +13,15 @@ import { whenever } from '@vueuse/core'
|
|||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import AnswerDialog from '@/components/marking/components/dialog/AnswerDialog.vue'
|
||||
import AvgScoreDialog from '@/components/marking/components/dialog/AvgScoreDialog.vue'
|
||||
import FullscreenImageDialog from '@/components/marking/components/dialog/FullscreenImageDialog.vue'
|
||||
import ScoreSettingsDialog from '@/components/marking/components/dialog/ScoreSettingsDialog.vue'
|
||||
import QuickScorePanel from '@/components/marking/components/QuickScorePanel.vue'
|
||||
import MarkingImageViewerNew from '@/components/marking/components/renderer/MarkingImageViewerNew.vue'
|
||||
import { provideMarkingData } from '@/components/marking/composables/useMarkingData'
|
||||
import MarkingLayout from '@/components/marking/MarkingLayout.vue'
|
||||
import { DefaultMarkingDataProvider, DefaultMarkingHistoryProvider, provideMarkingContext } from '@/composables/marking/MarkingContext'
|
||||
import { provideMarkingHistory } from '@/composables/marking/useMarkingHistory'
|
||||
import { provideMarkingNavigation } from '@/composables/marking/useMarkingNavigation'
|
||||
|
||||
defineOptions({
|
||||
name: 'GradingPage',
|
||||
|
|
@ -34,6 +38,35 @@ const taskType = ref<'initial' | 'final' | 'arbitration'>('initial')
|
|||
// 屏幕方向状态
|
||||
const isLandscape = ref(false)
|
||||
|
||||
// 图片缩放比例
|
||||
const imageScale = ref(1.0)
|
||||
|
||||
// 提供阅卷上下文(使用默认实现)
|
||||
provideMarkingContext({
|
||||
dataProvider: new DefaultMarkingDataProvider(),
|
||||
historyProvider: new DefaultMarkingHistoryProvider(),
|
||||
isHistory: false,
|
||||
defaultPosition: 'last',
|
||||
})
|
||||
|
||||
// 提供历史记录管理
|
||||
provideMarkingHistory({ taskId })
|
||||
|
||||
// 提供导航管理
|
||||
const markingNavigation = provideMarkingNavigation({ taskId })
|
||||
const {
|
||||
currentIndex: navCurrentIndex,
|
||||
totalCount: navTotalCount,
|
||||
isViewingHistory,
|
||||
canGoPrev,
|
||||
canGoNext,
|
||||
goToPrevQuestion,
|
||||
goToNextQuestion,
|
||||
initNavigation,
|
||||
createSwipeHandler,
|
||||
} = markingNavigation
|
||||
|
||||
// 提供数据管理
|
||||
const markingData = provideMarkingData({
|
||||
taskId,
|
||||
questionId,
|
||||
|
|
@ -44,8 +77,8 @@ const markingData = provideMarkingData({
|
|||
})
|
||||
const { questionData: questions, questionsList, totalQuestions: totalQuestionsCount } = markingData
|
||||
|
||||
// 全屏状态
|
||||
const isFullscreen = ref(false)
|
||||
// 全屏弹窗状态
|
||||
const showFullscreenImage = ref(false)
|
||||
|
||||
const currentQuestionIndex = ref(0)
|
||||
const totalQuestions = computed(() => totalQuestionsCount.value)
|
||||
|
|
@ -82,43 +115,24 @@ function handleOrientationChange() {
|
|||
// #endif
|
||||
}
|
||||
|
||||
// 监听全屏状态变化
|
||||
function handleFullscreenChange() {
|
||||
// #ifdef H5
|
||||
isFullscreen.value = !!document.fullscreenElement
|
||||
// #endif
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
// #ifdef H5
|
||||
window.addEventListener('orientationchange', handleOrientationChange)
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange)
|
||||
handleOrientationChange()
|
||||
// #endif
|
||||
|
||||
// 初始化导航
|
||||
await initNavigation()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// #ifdef H5
|
||||
window.removeEventListener('orientationchange', handleOrientationChange)
|
||||
document.removeEventListener('fullscreenchange', handleFullscreenChange)
|
||||
// #endif
|
||||
})
|
||||
|
||||
// 切换屏幕方向
|
||||
function toggleOrientation() {
|
||||
// #ifdef H5
|
||||
if (document.fullscreenElement) {
|
||||
if (screen.orientation && 'lock' in screen.orientation) {
|
||||
if (isLandscape.value) {
|
||||
(screen.orientation as any).lock('portrait-primary')
|
||||
}
|
||||
else {
|
||||
(screen.orientation as any).lock('landscape-primary')
|
||||
}
|
||||
}
|
||||
}
|
||||
// #endif
|
||||
|
||||
// #ifdef APP-PLUS
|
||||
if (isLandscape.value) {
|
||||
plus.screen.lockOrientation('portrait-primary')
|
||||
|
|
@ -131,39 +145,9 @@ function toggleOrientation() {
|
|||
isLandscape.value = !isLandscape.value
|
||||
}
|
||||
|
||||
// 全屏切换
|
||||
// 打开全屏图片弹窗
|
||||
function toggleFullscreen() {
|
||||
// #ifdef H5
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen().then(() => {
|
||||
isFullscreen.value = true
|
||||
}).catch((err) => {
|
||||
console.error('进入全屏失败:', err)
|
||||
uni.showToast({
|
||||
title: '全屏功能不支持',
|
||||
icon: 'none',
|
||||
})
|
||||
})
|
||||
}
|
||||
else {
|
||||
document.exitFullscreen().then(() => {
|
||||
isFullscreen.value = false
|
||||
})
|
||||
}
|
||||
// #endif
|
||||
|
||||
// #ifdef APP-PLUS
|
||||
// App端可以通过设置状态栏来模拟全屏
|
||||
if (isFullscreen.value) {
|
||||
plus.navigator.setStatusBarStyle('dark')
|
||||
plus.navigator.setStatusBarBackground('#000000')
|
||||
}
|
||||
else {
|
||||
plus.navigator.setStatusBarStyle('light')
|
||||
plus.navigator.setStatusBarBackground('#ffffff')
|
||||
}
|
||||
isFullscreen.value = !isFullscreen.value
|
||||
// #endif
|
||||
showFullscreenImage.value = true
|
||||
}
|
||||
|
||||
// 返回上一页
|
||||
|
|
@ -208,9 +192,20 @@ function viewAnswer() {
|
|||
|
||||
// 当前题目信息
|
||||
const currentQuestions = computed(() => questionsList.value[currentQuestionIndex.value])
|
||||
const currentQuestion = computed(() => questions.value[0])
|
||||
// 如果在历史查看模式,使用历史记录数据,否则使用当前题目数据
|
||||
const currentQuestion = computed(() => {
|
||||
if (isViewingHistory.value && markingNavigation.currentHistoryQuestion.value) {
|
||||
// 历史模式:返回历史记录
|
||||
return markingNavigation.currentHistoryQuestion.value
|
||||
}
|
||||
// 正常模式:返回当前题目
|
||||
return questions.value[0]
|
||||
})
|
||||
const currentTask = computed(() => currentQuestions.value?.tasks?.[taskType.value])
|
||||
|
||||
// 当前答题卡图片列表
|
||||
const currentImageUrls = computed(() => currentQuestion.value?.image_urls || [])
|
||||
|
||||
let isFirst = true
|
||||
whenever(questionsList, () => {
|
||||
if (isFirst) {
|
||||
|
|
@ -232,19 +227,49 @@ whenever(currentTask, (task, oldTask) => {
|
|||
function handleQuickScoreSelect(score: number) {
|
||||
console.log('选择分数:', score)
|
||||
}
|
||||
|
||||
// 创建手势处理器
|
||||
const swipeHandler = createSwipeHandler()
|
||||
|
||||
// 历史查看模式提示
|
||||
const historyModeText = computed(() => {
|
||||
if (isViewingHistory.value) {
|
||||
return `${navCurrentIndex.value}/${totalQuestionsCount.value}`
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
// 上一题
|
||||
async function handlePrevQuestion() {
|
||||
await goToPrevQuestion()
|
||||
}
|
||||
|
||||
// 下一题
|
||||
async function handleNextQuestion() {
|
||||
await goToNextQuestion()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative h-screen w-100vw flex flex-col touch-pan-x touch-pan-y overflow-hidden overscroll-none pt-safe">
|
||||
<div
|
||||
class="relative h-screen w-100vw flex flex-col touch-pan-x touch-pan-y overflow-hidden overscroll-none pt-safe"
|
||||
@touchstart="swipeHandler.onTouchStart"
|
||||
@touchend="swipeHandler.onTouchEnd"
|
||||
>
|
||||
<MarkingLayout
|
||||
:is-landscape="isLandscape"
|
||||
:is-fullscreen="isFullscreen"
|
||||
:is-fullscreen="false"
|
||||
:current-question-index="currentQuestionIndex"
|
||||
:current-task-submit="currentTask?.marked_quantity || 0"
|
||||
:total-questions="totalQuestions"
|
||||
:questions="questionsList"
|
||||
:my-score="myScore"
|
||||
:avg-score="avgScore"
|
||||
:is-viewing-history="isViewingHistory"
|
||||
:can-go-prev="canGoPrev"
|
||||
:can-go-next="canGoNext"
|
||||
:history-mode-text="historyModeText"
|
||||
:task-id="taskId"
|
||||
@go-back="goBack"
|
||||
@select-question="selectQuestion"
|
||||
@open-score-settings="openScoreSettings"
|
||||
|
|
@ -252,17 +277,20 @@ function handleQuickScoreSelect(score: number) {
|
|||
@view-answer="viewAnswer"
|
||||
@toggle-orientation="toggleOrientation"
|
||||
@toggle-fullscreen="toggleFullscreen"
|
||||
@prev-question="handlePrevQuestion"
|
||||
@next-question="handleNextQuestion"
|
||||
>
|
||||
<template #content>
|
||||
<MarkingImageViewerNew
|
||||
v-if="questions[0]?.image_urls?.length"
|
||||
v-model:question-data="questions"
|
||||
v-model:scale="imageScale"
|
||||
:question-data="[currentQuestion]"
|
||||
:image-size="100"
|
||||
/>
|
||||
|
||||
<!-- 快捷打分面板 - 固定定位在右侧 -->
|
||||
<QuickScorePanel
|
||||
v-if="currentQuestion"
|
||||
v-if="currentQuestion && !isViewingHistory"
|
||||
:is-landscape="isLandscape"
|
||||
:full-score="currentQuestion.full_score"
|
||||
@score-selected="handleQuickScoreSelect"
|
||||
|
|
@ -293,7 +321,13 @@ function handleQuickScoreSelect(score: number) {
|
|||
v-model="showAnswer"
|
||||
:question-title="`${currentQuestion?.question_major}.${currentQuestion?.question_minor}`"
|
||||
:full-score="currentQuestion?.full_score"
|
||||
:standard-answer="currentQuestion?.standard_answer"
|
||||
:standard-answer="currentQuestion?.standard_answer || ''"
|
||||
/>
|
||||
|
||||
<!-- 全屏图片弹窗 -->
|
||||
<FullscreenImageDialog
|
||||
v-model="showFullscreenImage"
|
||||
:image-urls="currentImageUrls"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
Loading…
Reference in New Issue