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

This commit is contained in:
AfyerCu 2025-11-16 18:35:37 +08:00
parent 65e2d19089
commit cab94aaf6f
15 changed files with 541 additions and 273 deletions

View File

@ -38,7 +38,7 @@ function handlePageNavigation(targetPath?: string) {
const isLoggedIn = userStore.isLogin && userStore.accessToken
//
if (!isLoggedIn && targetPath && !isPublicPage(targetPath)) {
if (!isLoggedIn) {
console.log('用户未登录,跳转到加载页')
uni.reLaunch({
url: '/pages/auth/index',
@ -47,11 +47,12 @@ function handlePageNavigation(targetPath?: string) {
}
// 访
if (isLoggedIn && targetPath && isPublicPage(targetPath)) {
if (isLoggedIn) {
console.log('用户已登录,跳转到首页')
uni.reLaunch({
url: '/pages/index/index',
})
tabbarStore.setCurIdx(0)
return
}

View File

@ -0,0 +1,161 @@
<script lang="ts" setup generic="T">
import { computed, ref } from 'vue'
interface Props {
list: T[]
columns?: number
itemSize?: number
gap?: number
}
interface Emits {
(e: 'change', list: T[]): void
}
const props = withDefaults(defineProps<Props>(), {
columns: 5,
itemSize: 32,
gap: 8,
})
const emit = defineEmits<Emits>()
const draggingIndex = ref<number>(-1)
const hoverIndex = ref<number>(-1)
const startPos = ref({ x: 0, y: 0 })
const currentPos = ref({ x: 0, y: 0 })
const isDragging = ref(false)
const containerWidth = computed(() => props.columns * props.itemSize + (props.columns - 1) * props.gap)
const containerHeight = computed(() => {
const rows = Math.ceil(props.list.length / props.columns)
return rows * props.itemSize + (rows - 1) * props.gap
})
function getPosition(index: number) {
const row = Math.floor(index / props.columns)
const col = index % props.columns
return {
x: col * (props.itemSize + props.gap),
y: row * (props.itemSize + props.gap),
}
}
function getIndexFromPosition(x: number, y: number) {
const col = Math.round(x / (props.itemSize + props.gap))
const row = Math.round(y / (props.itemSize + props.gap))
const index = row * props.columns + col
return Math.max(0, Math.min(index, props.list.length - 1))
}
function handleStart(e: any, index: number) {
const touch = e.touches?.[0] || e.changedTouches?.[0] || e
const pos = getPosition(index)
startPos.value = { x: touch.clientX - pos.x, y: touch.clientY - pos.y }
currentPos.value = pos
draggingIndex.value = index
hoverIndex.value = index
isDragging.value = true
}
function handleMove(e: any) {
if (draggingIndex.value === -1) return
const touch = e.touches?.[0] || e.changedTouches?.[0] || e
const x = touch.clientX - startPos.value.x
const y = touch.clientY - startPos.value.y
currentPos.value = { x, y }
const newIndex = getIndexFromPosition(x, y)
if (newIndex !== hoverIndex.value && newIndex >= 0 && newIndex < props.list.length) {
hoverIndex.value = newIndex
}
}
function handleEnd() {
if (draggingIndex.value === -1) return
if (draggingIndex.value !== hoverIndex.value) {
const newList = [...props.list]
const [item] = newList.splice(draggingIndex.value, 1)
newList.splice(hoverIndex.value, 0, item)
emit('change', newList)
}
draggingIndex.value = -1
hoverIndex.value = -1
isDragging.value = false
}
function getItemStyle(index: number) {
const pos = getPosition(index)
const isBeingDragged = index === draggingIndex.value
const isShifted = isDragging.value && index !== draggingIndex.value
let finalPos = pos
if (isShifted) {
if (draggingIndex.value < hoverIndex.value && index > draggingIndex.value && index <= hoverIndex.value) {
finalPos = getPosition(index - 1)
} else if (draggingIndex.value > hoverIndex.value && index < draggingIndex.value && index >= hoverIndex.value) {
finalPos = getPosition(index + 1)
}
}
if (isBeingDragged) {
finalPos = currentPos.value
}
return {
width: `${props.itemSize}px`,
height: `${props.itemSize}px`,
transform: `translate(${finalPos.x}px, ${finalPos.y}px)`,
zIndex: isBeingDragged ? 10 : 1,
opacity: isBeingDragged ? 0.8 : 1,
transition: isBeingDragged ? 'none' : 'transform 0.2s ease, opacity 0.2s ease',
}
}
</script>
<template>
<view
class="drag-sort-container"
:style="{ width: containerWidth + 'px', height: containerHeight + 'px', position: 'relative' }"
@touchmove.stop.prevent="handleMove"
@touchend.stop="handleEnd"
@touchcancel.stop="handleEnd"
@mousemove.stop="handleMove"
@mouseup.stop="handleEnd"
@mouseleave.stop="handleEnd"
>
<view
v-for="(item, index) in list"
:key="index"
class="drag-item"
:style="getItemStyle(index)"
@touchstart.stop="handleStart($event, index)"
@mousedown.stop="handleStart($event, index)"
>
<slot name="item" :item="item" :index="index">
{{ item }}
</slot>
</view>
</view>
</template>
<style scoped>
.drag-sort-container {
position: relative;
touch-action: none;
user-select: none;
}
.drag-item {
position: absolute;
top: 0;
left: 0;
cursor: move;
touch-action: none;
}
</style>

View File

@ -49,6 +49,15 @@ function getStatusTagType(status?: string): 'primary' | 'success' | 'warning' |
//
function handleEnterMarking(subject: SubjectInfo) {
//
if (props.task.is_scored) {
uni.showToast({
title: '已完成,不能再进入',
icon: 'none',
})
return
}
if (!props.task.exam_id || !subject.subject_id) {
uni.showToast({
title: '参数错误',
@ -84,22 +93,24 @@ function navigateToQuality(subjectId: number) {
<template>
<view class="border border-slate-100 rounded-2xl bg-white p-4 shadow-md">
<!-- 第一行名称 + 时间 -->
<!-- 第一行名称 + 已完成标签 -->
<view class="mb-4">
<view class="mb-2 flex items-center justify-between">
<text class="text-lg font-semibold">{{ task.exam_name }}</text>
<wd-tag v-if="task.is_scored" type="success" size="small">已完成</wd-tag>
</view>
<!-- 第二行类型 + ID + 时间 + 年级 -->
<view class="flex items-center space-x-2">
<view class="flex items-center rounded-md bg-blue-100 px-2">
<text class="text-xs text-blue-600">{{ task.exam_type }}</text>
</view>
<text class="text-xs text-green-600">ID: {{ task.exam_id }}</text>
<view class="flex items-center">
<view class="i-mingcute:calendar-line mr-1 text-xs text-slate-500" />
<text class="text-xs text-gray-500">{{ formatDate(task.exam_date) }}</text>
</view>
</view>
<!-- 第二行类型 + ID -->
<view class="flex items-center space-x-2">
<view class="flex items-center rounded-md bg-blue-100 px-2">
<text class="text-xs text-blue-600"> {{ task.exam_type }}</text>
</view>
<text class="text-xs text-green-600">考试ID: {{ task.exam_id }}</text>
<text v-if="task.grade" class="text-xs text-purple-600">{{ task.grade }}</text>
</view>
</view>

View File

@ -1,8 +1,8 @@
<script lang="ts" setup>
import { useSessionStorage } from '@vueuse/core'
import { computed, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useMarkingData } from '@/components/marking/composables/useMarkingData'
import { getSelectedOnekeyScores, useMarkingSettings } from '@/components/marking/composables/useMarkingSettings'
import { getOnekeyScores, useMarkingSettings } from '@/components/marking/composables/useMarkingSettings'
interface Props {
isLandscape?: boolean
@ -37,8 +37,21 @@ const isCollapsed = ref(true)
// (使 SessionStorage )
const floatingButtonPosition = useSessionStorage('quick-score-submit-button-position', {
x: 16,
y: 16,
x: 0,
y: 0,
})
//
onMounted(() => {
if (floatingButtonPosition.value.x === 0 && floatingButtonPosition.value.y === 0) {
const { windowWidth, windowHeight } = uni.getSystemInfoSync()
const buttonSize = 48 //
const margin = 16 //
floatingButtonPosition.value = {
x: windowWidth - buttonSize - margin,
y: windowHeight - buttonSize - margin,
}
}
})
//
@ -91,14 +104,8 @@ function handleDragEnd() {
//
const displayItems = computed(() => {
if (scoreMode.value === 'onekey') {
//
const selectedScores = getSelectedOnekeyScores(
settings.value.stepSize,
props.fullScore,
settings.value.onekeyScoreSelections,
)
return selectedScores.map(score => ({
const scores = getOnekeyScores(settings.value.stepSize, props.fullScore)
return scores.map(score => ({
type: 'score' as const,
value: score,
label: score.toString(),
@ -107,7 +114,6 @@ const displayItems = computed(() => {
}))
}
else {
//
const isAdd = currentMode.value === 'add'
return settings.value.quickScoreScores.map((step) => {
const isActive = settings.value.quickScoreClickMode && settings.value.quickScoreClickValue === step
@ -281,7 +287,7 @@ async function submitCurrentScore() {
<!-- 收起/展开按钮 - 仅在一键打分模式显示 -->
<div
v-if="scoreMode === 'onekey'"
class="sticky bottom-0 mt-6px h-32px flex flex-shrink-0 cursor-pointer items-center justify-center border-2 border-blue-200 rounded-4px bg-blue-50 transition-all active:scale-95"
class="mt-6px h-32px flex flex-shrink-0 cursor-pointer items-center justify-center border-2 border-blue-200 rounded-4px bg-blue-50 transition-all active:scale-95"
@click="toggleCollapse"
>
<div
@ -354,7 +360,7 @@ async function submitCurrentScore() {
<!-- 收起/展开按钮 - 仅在一键打分模式显示 -->
<div
v-if="scoreMode === 'onekey'"
class="sticky bottom-0 mt-12rpx h-64rpx flex flex-shrink-0 cursor-pointer items-center justify-center border-2 border-blue-200 rounded-8rpx bg-blue-50 transition-all active:scale-95"
class="mt-12rpx h-64rpx flex flex-shrink-0 cursor-pointer items-center justify-center border-2 border-blue-200 rounded-8rpx bg-blue-50 transition-all active:scale-95"
@click="toggleCollapse"
>
<div

View File

@ -1,12 +1,12 @@
<script lang="ts" setup>
import { computed, ref, watch } from 'vue'
import DragSort from '@/components/common/DragSort.vue'
import {
generateOnekeyScores,
getSelectedOnekeyScores,
updateOnekeyScoreSelection,
getOnekeyScores,
resetOnekeyScores,
setOnekeyScores,
useMarkingSettings,
} from '@/components/marking/composables/useMarkingSettings'
import LDrag from '@/uni_modules/lime-drag/components/l-drag/l-drag.vue'
interface Props {
modelValue: boolean
@ -24,6 +24,41 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<Emits>()
//
const screenInfo = ref({ width: 375, height: 667, isPortrait: true })
const dragLayout = computed(() => {
const { width, height, isPortrait } = screenInfo.value
const dialogWidth = isPortrait ? width * 0.9 : Math.min(width * 0.5, 600)
const itemSize = 32 // 2em = 32px ( 1em = 16px)
const gap = 8 // 0.5em = 8px
const buttonWidth = 80 //
const containerPadding = 16 // 1em padding
const availableWidth = dialogWidth - containerPadding * 2 - buttonWidth - gap
const columns = Math.floor((availableWidth + gap) / (itemSize + gap))
return {
columns: Math.max(columns, 3),
itemSize,
containerWidth: availableWidth,
}
})
//
function updateScreenInfo() {
uni.getSystemInfo({
success: (res) => {
screenInfo.value = {
width: res.windowWidth,
height: res.windowHeight,
isPortrait: res.windowHeight > res.windowWidth,
}
},
})
}
//
updateScreenInfo()
//
const visible = computed({
get: () => props.modelValue,
@ -38,18 +73,18 @@ const initialSettings = ref({
quickScoreMode: '',
stepSize: 0,
quickScoreScores: [] as number[],
onekeyScoreSelections: {} as Record<string, boolean>,
onekeyScores: [] as number[],
})
//
watch(() => props.modelValue, (newVal) => {
if (newVal) {
//
updateScreenInfo()
initialSettings.value = {
quickScoreMode: settings.value.quickScoreMode,
stepSize: settings.value.stepSize,
quickScoreScores: [...settings.value.quickScoreScores],
onekeyScoreSelections: { ...settings.value.onekeyScoreSelections },
onekeyScores: [...getOnekeyScores(settings.value.stepSize, props.fullScore)],
}
}
})
@ -65,14 +100,21 @@ const activeTab = computed({
//
const stepOptions = [0.5, 1, 2, 3, 4, 5]
//
function generateAllScores(stepSize: number, fullScore: number): number[] {
const scores: number[] = []
for (let i = 0; i <= fullScore; i += stepSize) {
scores.push(i)
}
return scores
}
// -
const commonScores = computed(() => {
if (activeTab.value === 'onekey') {
//
return generateOnekeyScores(settings.value.stepSize, props.fullScore)
return generateAllScores(settings.value.stepSize, props.fullScore)
}
else {
//
return [0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5]
}
})
@ -80,7 +122,6 @@ const commonScores = computed(() => {
//
function selectStepSize(step: number) {
settings.value.stepSize = step
//
}
//
@ -90,12 +131,7 @@ const customStepValue = ref('')
//
const selectedCommonScores = computed(() => {
if (activeTab.value === 'onekey') {
//
return getSelectedOnekeyScores(
settings.value.stepSize,
props.fullScore,
settings.value.onekeyScoreSelections,
)
return getOnekeyScores(settings.value.stepSize, props.fullScore)
}
else {
return settings.value.quickScoreScores
@ -112,38 +148,18 @@ watch(selectedCommonScores, (newScores) => {
//
function updateDraggableOrder(newOrder: number[]) {
if (activeTab.value === 'onekey') {
draggableScores.value = newOrder
//
//
const currentKey = `${settings.value.stepSize}_${props.fullScore}`
Object.keys(settings.value.onekeyScoreSelections).forEach((key) => {
if (key.startsWith(currentKey)) {
delete settings.value.onekeyScoreSelections[key]
}
})
//
newOrder.forEach((score) => {
updateOnekeyScoreSelection(
settings.value.stepSize,
props.fullScore,
score,
true,
settings.value.onekeyScoreSelections,
)
})
draggableScores.value = newOrder
if (activeTab.value === 'quick') {
settings.value.quickScoreScores = newOrder
}
else {
settings.value.quickScoreScores = newOrder
setOnekeyScores(settings.value.stepSize, props.fullScore, newOrder)
}
}
//
function handleDragChange(newList) {
console.log('newList', newList)
updateDraggableOrder(newList.map(i => i.content))
function handleDragChange(newList: number[]) {
updateDraggableOrder(newList)
}
//
@ -157,15 +173,15 @@ const canAddMoreSteps = computed(() => {
//
function toggleCommonScore(score: number) {
if (activeTab.value === 'onekey') {
//
const isCurrentlySelected = selectedCommonScores.value.includes(score)
updateOnekeyScoreSelection(
settings.value.stepSize,
props.fullScore,
score,
!isCurrentlySelected,
settings.value.onekeyScoreSelections,
)
const current = getOnekeyScores(settings.value.stepSize, props.fullScore)
const index = current.indexOf(score)
if (index > -1) {
current.splice(index, 1)
}
else {
current.push(score)
}
setOnekeyScores(settings.value.stepSize, props.fullScore, current)
}
else {
const currentScores = [...settings.value.quickScoreScores]
@ -174,7 +190,6 @@ function toggleCommonScore(score: number) {
currentScores.splice(index, 1)
}
else {
//
if (currentScores.length >= 5) {
uni.showToast({
title: '快捷打分最多5个步长',
@ -257,13 +272,11 @@ function cancelCustomInput() {
//
function resetSortOrder() {
const sortedScores = [...selectedCommonScores.value].sort((a, b) => a - b)
if (activeTab.value === 'onekey') {
// draggableScores
const sortedScores = [...selectedCommonScores.value].sort((a, b) => a - b)
updateDraggableOrder(sortedScores)
setOnekeyScores(settings.value.stepSize, props.fullScore, sortedScores)
}
else {
const sortedScores = [...settings.value.quickScoreScores].sort((a, b) => a - b)
settings.value.quickScoreScores = sortedScores
}
}
@ -278,7 +291,7 @@ function cancel() {
settings.value.quickScoreMode = initialSettings.value.quickScoreMode
settings.value.stepSize = initialSettings.value.stepSize
settings.value.quickScoreScores = [...initialSettings.value.quickScoreScores]
settings.value.onekeyScoreSelections = { ...initialSettings.value.onekeyScoreSelections }
setOnekeyScores(settings.value.stepSize, props.fullScore, initialSettings.value.onekeyScores)
close()
}
@ -292,17 +305,13 @@ function confirm() {
}
function resetOnekeyScoreScores() {
//
const currentKey = `${settings.value.stepSize}_${props.fullScore}`
Object.keys(settings.value.onekeyScoreSelections).forEach((key) => {
if (key.startsWith(currentKey)) {
delete settings.value.onekeyScoreSelections[key]
}
})
resetOnekeyScores(settings.value.stepSize, props.fullScore)
draggableScores.value = [...getOnekeyScores(settings.value.stepSize, props.fullScore)]
}
function resetQuickScoreScores() {
settings.value.quickScoreScores = [0.5, 1, 2, 3, 4, 5]
settings.value.quickScoreScores = [0.5, 1, 2, 3, 4]
draggableScores.value = [0.5, 1, 2, 3, 4]
}
</script>
@ -313,7 +322,7 @@ function resetQuickScoreScores() {
:close-on-click-modal="false"
custom-class="score-settings-popup"
>
<view class="root-container">
<view class="max-h-90vh overflow-auto root-container">
<view class="w-full rounded-2 bg-white p-1em">
<!-- 标题栏 -->
<view class="mb-1em flex items-center justify-between">
@ -413,21 +422,8 @@ function resetQuickScoreScores() {
<!-- 给分排序 -->
<view class="mb-1em">
<text class="text-0.875em text-gray-700">给分排序</text>
<view class="mt-0.5em flex items-center justify-between gap-0.5em">
<l-drag
:list="draggableScores"
class="flex-1"
:column="8"
grid-height="2em"
@change="handleDragChange"
>
<template #grid="{ content }">
<view class="h-2em w-2em flex flex-shrink-0 cursor-move items-center justify-center rounded-full bg-blue-500 text-0.75em text-white font-medium transition-all active:(scale-110 bg-blue-600)">
{{ content }}
</view>
</template>
</l-drag>
<view class="mb-0.5em flex items-center justify-between">
<text class="text-0.875em text-gray-700">给分排序</text>
<wd-button
type="error"
size="small"
@ -438,6 +434,19 @@ function resetQuickScoreScores() {
重新排序
</wd-button>
</view>
<DragSort
:list="draggableScores"
:columns="dragLayout.columns"
:item-size="dragLayout.itemSize"
:gap="8"
@change="handleDragChange"
>
<template #item="{ item }">
<view class="h-full w-full flex items-center justify-center rounded-full bg-blue-500 text-0.75em text-white font-medium">
{{ item }}
</view>
</template>
</DragSort>
</view>
</view>
@ -492,21 +501,8 @@ function resetQuickScoreScores() {
<!-- 给分排序 -->
<view class="mb-1em">
<text class="text-0.875em text-gray-700">给分排序</text>
<view class="mt-0.5em flex items-center justify-between gap-0.5em">
<LDrag
:list="draggableScores"
class="flex-1"
:column="8"
grid-height="2em"
@change="handleDragChange"
>
<template #grid="{ content }">
<view class="h-2em w-2em flex flex-shrink-0 cursor-move items-center justify-center rounded-full bg-blue-500 text-0.75em text-white font-medium transition-all active:(scale-110 bg-blue-600)">
{{ content }}
</view>
</template>
</LDrag>
<view class="mb-0.5em flex items-center justify-between">
<text class="text-0.875em text-gray-700">给分排序</text>
<wd-button
type="error"
size="small"
@ -517,6 +513,19 @@ function resetQuickScoreScores() {
重新排序
</wd-button>
</view>
<DragSort
:list="draggableScores"
:columns="dragLayout.columns"
:item-size="dragLayout.itemSize"
:gap="8"
@change="handleDragChange"
>
<template #item="{ item }">
<view class="h-full w-full flex items-center justify-center rounded-full bg-blue-500 text-0.75em text-white font-medium">
{{ item }}
</view>
</template>
</DragSort>
</view>
</view>
@ -539,7 +548,13 @@ function resetQuickScoreScores() {
<style lang="scss">
.score-settings-popup {
width: max(90vw, 400px);
/* 竖屏(高>宽占90%横屏占50%或最大600px */
@media (orientation: portrait) {
width: 90vw;
}
@media (orientation: landscape) {
width: min(50vw, 600px);
}
}
/* 根容器设置 font-size1rem 最大 16px */

View File

@ -8,6 +8,7 @@ import QuestionRenderer from './QuestionRenderer.vue'
interface Props {
imageSize: number
questionData: ExamStudentMarkingQuestionResponse[]
nextQuestionImages?: string[]
}
const props = defineProps<Props>()
@ -15,13 +16,55 @@ const props = defineProps<Props>()
const emit = defineEmits<{
'marking-change': [questionIndex: number, imageIndex: number, data: DomMarkingData]
'ready': [imageInfo: { width: number, height: number }]
'scroll-edge': [atLeftEdge: boolean, atRightEdge: boolean]
}>()
const scale = defineModel<number>('scale', { default: 1.0 })
//
const atLeftEdge = ref(true)
const atRightEdge = ref(true)
//
const firstImageSize = ref({ width: 0, height: 0 })
// ID uni.createSelectorQuery
const scrollContainerId = `scroll-container-${Math.random().toString(36).slice(2)}`
/**
* 检查滚动边缘状态使用 uni.createSelectorQuery
*/
function checkScrollEdge() {
const query = uni.createSelectorQuery()
query.select(`#${scrollContainerId}`).scrollOffset()
query.select(`#${scrollContainerId}`).boundingClientRect()
query.exec((res) => {
if (!res || res.length < 2) return
const scrollInfo = res[0]
const rectInfo = res[1]
if (!scrollInfo || !rectInfo) return
const scrollLeft = scrollInfo.scrollLeft || 0
const scrollWidth = scrollInfo.scrollWidth || 0
const clientWidth = rectInfo.width || 0
// 1px
atLeftEdge.value = scrollLeft <= 1
atRightEdge.value = scrollLeft + clientWidth >= scrollWidth - 1
emit('scroll-edge', atLeftEdge.value, atRightEdge.value)
})
}
/**
* 监听滚动事件
*/
function handleScroll() {
checkScrollEdge()
}
//
const questionDataList = computed(() => {
if (!props.questionData?.length)
@ -83,22 +126,37 @@ watch(
() => props.questionData,
async () => {
await loadFirstImageSize()
//
nextTick(() => {
checkScrollEdge()
})
},
{ immediate: true },
)
// scale
watch(
() => scale.value,
() => {
nextTick(() => {
checkScrollEdge()
})
},
)
//
defineExpose({
firstImageSize,
atLeftEdge,
atRightEdge,
})
</script>
<template>
<scroll-view
ref="containerRef"
scroll-x
scroll-y
class="h-full w-full"
<div
:id="scrollContainerId"
class="h-full w-full overflow-auto"
@scroll="handleScroll"
>
<!-- 题目列表 -->
<view
@ -126,5 +184,15 @@ defineExpose({
@marking-change="(imageIndex, data) => handleMarkingChange(questionIndex, imageIndex, data)"
/>
</view>
</scroll-view>
<!-- 预加载下一题图片 -->
<view v-if="nextQuestionImages?.length" class="hidden">
<image
v-for="(url, index) in nextQuestionImages"
:key="`preload_${index}`"
:src="url"
class="w-0 h-0"
/>
</view>
</div>
</template>

View File

@ -339,7 +339,7 @@ defineExpose({
class="absolute left-2 top-2 z-10 flex items-baseline"
:style="{ gap: '2px', textShadow: '1px 1px 2px rgba(0, 0, 0, 0.3)' }"
>
<text class="text-3xl text-red-600 font-bold">{{ currentMarkingData.score || question.finalScore }}</text>
<text class="text-3xl text-red-600 font-bold">{{ question.finalScore || currentMarkingData.score }}</text>
<text class="text-lg text-red-500 font-semibold">/{{ question.fullScore }}</text>
</view>
</view>

View File

@ -1,5 +1,6 @@
import type { Ref } from 'vue'
import type { ExamBatchCreateMarkingTaskRecordRequest, ExamCreateMarkingTaskRecordRequest, ExamQuestionWithTasksResponse, ExamSetProblemRecordRequest, ExamStudentMarkingQuestionResponse } from '@/api'
import type { MarkingNavigation } from '@/composables/marking/useMarkingNavigation'
import { useQuery } from '@tanstack/vue-query'
import { computed, inject, provide, readonly, ref, watch } from 'vue'
import { examMarkingTaskApi } from '@/api'
@ -26,10 +27,11 @@ export interface UseMarkingDataOptions {
subjectId?: Ref<number>
isLandscape?: Ref<boolean>
taskType?: Ref<string>
markingNavigation: MarkingNavigation
}
function createMarkingData(options: UseMarkingDataOptions) {
const { taskId, questionId, examId, subjectId, isLandscape, taskType } = options
const { taskId, questionId, examId, subjectId, isLandscape, taskType, markingNavigation } = options
// 获取阅卷上下文和历史管理
const markingContext = getMarkingContext()
@ -178,44 +180,18 @@ function createMarkingData(options: UseMarkingDataOptions) {
// 提交单个记录
const submitSingleRecord = async (data?: MarkingSubmitData, index: number = 0) => {
try {
const question = questionData.value[index]
if (!question)
throw new Error('题目数据不存在')
const currentData = data || currentMarkingSubmitData.value[index]
const isHistoryMode = markingHistory.isViewingHistory.value
// 如果是问题卷,只提交问题卷记录,不提交正常阅卷记录
if (currentData.isProblem) {
const problemRequest: ExamSetProblemRecordRequest = {
id: question.id!,
task_id: taskId.value,
problem_type: currentData.problemType! as any,
problem_addition: currentData.problemRemark,
}
// 根据模式选择数据源
const question = isHistoryMode
? markingHistory.currentHistoryRecord.value!
: questionData.value[index]
if (markingContext.dataProvider.createProblemRecord) {
await markingContext.dataProvider.createProblemRecord(problemRequest as any)
}
if (!question)
throw new Error('题目数据不存在')
isSubmitted.value = true
// 如果是历史模式,刷新历史记录
if (isHistoryMode) {
await markingHistory.forceRefreshHistory()
}
else {
currentTaskInfo.value.marked_quantity += 1
// 重新获取下一题
questionData.value = []
await refetchQuestion()
processQuestionData()
}
return
}
// 正常阅卷记录提交
// 构建提交数据
const submitData: ExamCreateMarkingTaskRecordRequest = {
id: question.id,
scan_info_id: question.scan_info_id!,
@ -228,42 +204,65 @@ function createMarkingData(options: UseMarkingDataOptions) {
page_mode: 'single',
is_excellent: currentData.isExcellent ? 1 : 0,
is_model: currentData.isTypical ? 1 : 0,
is_problem: 0, // 问题卷已经在上面处理了这里必须是0
is_problem: currentData.isProblem ? 1 : 0,
}
const batchRequest: ExamBatchCreateMarkingTaskRecordRequest = {
batch_data: [submitData],
}
// 如果是问题卷,不提交正常记录,只提交问题卷记录
const response = !currentData.isProblem
? await markingContext.dataProvider.submitRecords({
is_review: isHistoryMode,
batch_data: [submitData],
})
: null
// 使用上下文提供者提交
const response = await markingContext.dataProvider.submitRecords(batchRequest)
// 处理问题卷
if (currentData.isProblem) {
const problemRequest: ExamSetProblemRecordRequest = {
id: question.id!,
task_id: taskId.value,
problem_type: currentData.problemType! as any,
problem_addition: currentData.problemRemark,
}
if (markingContext.dataProvider.createProblemRecord) {
await markingContext.dataProvider.createProblemRecord(problemRequest as any)
}
}
if (response) {
isSubmitted.value = true
if (response.success_count === 0) {
const errorMessage = response.fail_data?.map(item => item.message).join('、') || '提交失败'
throw new Error(errorMessage)
}
// 更新历史记录总数
if (!isHistoryMode && response.success_count > 0) {
markingHistory.incrementHistoryCount(response.success_count)
}
}
isSubmitted.value = true
// 如果是历史模式,刷新历史记录
if (!isHistoryMode) {
// 更新已阅数量
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
// 重新获取下一题
questionData.value = []
currentMarkingSubmitData.value = []
markingStartTime.value = 0
await refetchQuestion()
processQuestionData()
}
else {
(question as any).final_score = currentData.score
markingNavigation.goToNextQuestion()
}
return response
}
catch (error) {
console.error('提交阅卷记录失败:', error)

View File

@ -1,6 +1,9 @@
import { useStorage } from '@vueuse/core'
const SETTINGS_VERSION = 2 // 当前版本号
export interface MarkingSettings {
version?: number
// 图片位置
imagePosition: 'left' | 'center' | 'right'
// 背景颜色
@ -42,8 +45,8 @@ export interface MarkingSettings {
quickScoreMode: 'onekey' | 'quick'
quickScoreLayout: 'single' | 'double'
// 一键打分相关设置 - 保存用户自定义的选择状态(哪些分数被选中)
onekeyScoreSelections: Record<string, boolean> // key: "stepSize_fullScore", value: 是否选中该分数
// 一键打分配置key 为 "stepSize_fullScore"value 为排序后的分数数组
onekeyScoreConfigs: Record<string, number[]>
// 缩放控制面板相关设置
showScalePanel: boolean
@ -52,6 +55,7 @@ export interface MarkingSettings {
// 默认设置
const defaultSettings: MarkingSettings = {
version: SETTINGS_VERSION,
imagePosition: 'left',
backgroundColor: '#F0F0F0',
keyboardSize: 'normal',
@ -87,78 +91,65 @@ const defaultSettings: MarkingSettings = {
quickScoreScores: [1, 2],
quickScoreLayout: 'single',
// 一键打分默认值 - 用户选择状态
onekeyScoreSelections: {},
// 一键打分默认值
onekeyScoreConfigs: {},
// 缩放控制面板默认值
showScalePanel: true,
scalePanelCollapsed: false,
}
export const markingSettings = useStorage<MarkingSettings>('marking_settings', defaultSettings)
const rawSettings = useStorage<MarkingSettings>('marking_settings', defaultSettings)
// 版本检查和迁移
if (!rawSettings.value.version || rawSettings.value.version < SETTINGS_VERSION) {
console.log(`Settings version mismatch (${rawSettings.value.version} < ${SETTINGS_VERSION}), resetting to defaults`)
rawSettings.value = { ...defaultSettings }
}
export const markingSettings = rawSettings
/**
*
* @param stepSize
* @param fullScore
* @returns
* 0
*/
export function generateOnekeyScores(stepSize: number, fullScore: number): number[] {
function generateDefaultScores(stepSize: number, fullScore: number): number[] {
const scores: number[] = []
const max = Math.max(fullScore, 2)
for (let i = 0; i <= max; i += stepSize) {
for (let i = 0; i <= fullScore; i += stepSize) {
scores.push(i)
}
return scores
}
/**
*
* @param stepSize
* @param fullScore
* @param selections
* @returns
* key
*/
export function getSelectedOnekeyScores(
stepSize: number,
fullScore: number,
selections: Record<string, boolean> = {},
): number[] {
const allScores = generateOnekeyScores(stepSize, fullScore)
const selectionKey = `${stepSize}_${fullScore}`
// 如果没有保存的选择状态,默认全选
const hasSelections = Object.keys(selections).some(key => key.startsWith(`${stepSize}_`))
if (!hasSelections) {
return allScores
}
// 根据保存的选择状态过滤
return allScores.filter((score) => {
const scoreKey = `${selectionKey}_${score}`
return selections[scoreKey] !== false // 默认选中除非明确设置为false
})
function getConfigKey(stepSize: number, fullScore: number): string {
return `${stepSize}_${fullScore}`
}
/**
*
* @param stepSize
* @param fullScore
* @param score
* @param selected
* @param selections
*
*/
export function updateOnekeyScoreSelection(
stepSize: number,
fullScore: number,
score: number,
selected: boolean,
selections: Record<string, boolean>,
): void {
const selectionKey = `${stepSize}_${fullScore}_${score}`
selections[selectionKey] = selected
export function getOnekeyScores(stepSize: number, fullScore: number): number[] {
const key = getConfigKey(stepSize, fullScore)
const config = markingSettings.value.onekeyScoreConfigs[key]
return config || generateDefaultScores(stepSize, fullScore)
}
/**
*
*/
export function setOnekeyScores(stepSize: number, fullScore: number, scores: number[]): void {
const key = getConfigKey(stepSize, fullScore)
markingSettings.value.onekeyScoreConfigs[key] = scores
}
/**
*
*/
export function resetOnekeyScores(stepSize: number, fullScore: number): void {
const key = getConfigKey(stepSize, fullScore)
delete markingSettings.value.onekeyScoreConfigs[key]
}
export function useMarkingSettings() {

View File

@ -104,28 +104,6 @@ export interface MarkingContext {
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
*
@ -137,6 +115,8 @@ export class DefaultMarkingDataProvider implements MarkingDataProvider {
private hasSubmitted = false
private fetchingPromise: Promise<ExamStudentMarkingQuestionsResponse | null> | null = null
cachedImages = ref<string[]>([])
async getSingleQuestion(taskId: number): Promise<ExamStudentMarkingQuestionResponse | null> {
// 如果任务ID变化清空缓存
if (this.lastTaskId !== taskId) {
@ -196,7 +176,7 @@ export class DefaultMarkingDataProvider implements MarkingDataProvider {
// 预加载图片资源
if (this.cachedQuestion.questions?.[0]?.image_urls) {
preloadImages(this.cachedQuestion.questions[0].image_urls)
this.cachedImages.value = this.cachedQuestion.questions?.[0]?.image_urls || []
}
}
else {

View File

@ -121,8 +121,8 @@ function createMarkingNavigation(_props: MarkingNavigationProps) {
*
*/
const swipeConfig = {
threshold: 50, // 最小滑动距离px
timeout: 100, // 最大滑动时间ms
threshold: 40, // 最小滑动距离px
timeout: 200, // 最大滑动时间ms
}
/**
@ -261,7 +261,7 @@ function createMarkingNavigation(_props: MarkingNavigationProps) {
}
}
type MarkingNavigation = ReturnType<typeof createMarkingNavigation>
export type MarkingNavigation = ReturnType<typeof createMarkingNavigation>
const MarkingNavigationSymbol = Symbol('MarkingNavigation')
export function provideMarkingNavigation(props: MarkingNavigationProps) {

View File

@ -1,4 +1,5 @@
import type { CustomRequestOptions } from '@/http/types'
import { useUserStore } from '@/store'
export interface Response<T> {
code?: number
@ -17,7 +18,7 @@ export function http<T = unknown>(options: CustomRequestOptions): Promise<T exte
// #endif
// 响应成功
success(res) {
// 状态码 2xx参考 axios 的设计
console.log('res===>', res)
if (res.statusCode >= 200 && res.statusCode < 300) {
const responseData = res.data as Response<T>
@ -43,9 +44,10 @@ export function http<T = unknown>(options: CustomRequestOptions): Promise<T exte
}
}
else if (res.statusCode === 401) {
const userStore = useUserStore()
// 401错误 -> 清理用户信息,跳转到登录页
// userStore.clearUserInfo()
// uni.navigateTo({ url: '/pages/login/login' })
userStore.logOut(true)
uni.navigateTo({ url: '/pages/login/login' })
reject(res)
}
else {

View File

@ -19,7 +19,7 @@ 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 { DefaultMarkingDataProvider, DefaultMarkingHistoryProvider, provideMarkingContext, useMarkingContext } from '@/composables/marking/MarkingContext'
import { provideMarkingHistory } from '@/composables/marking/useMarkingHistory'
import { provideMarkingNavigation } from '@/composables/marking/useMarkingNavigation'
import { useSafeArea } from '@/composables/useSafeArea'
@ -50,6 +50,11 @@ const imageViewerRef = ref<InstanceType<typeof MarkingImageViewerNew>>()
//
const containerSize = ref({ width: 0, height: 0 })
const hasInitializedScale = ref(false)
//
const atLeftEdge = ref(true)
const atRightEdge = ref(true)
/**
* 获取容器尺寸
@ -100,8 +105,11 @@ function calculateInitialScale(imageWidth: number, imageHeight: number): number
* 处理图片就绪事件
*/
function handleImageReady(imageInfo: { width: number, height: number }) {
if (hasInitializedScale.value)
return
const initialScale = calculateInitialScale(imageInfo.width, imageInfo.height)
imageScale.value = initialScale
hasInitializedScale.value = true
console.log('Initial scale calculated:', initialScale, 'for image:', imageInfo)
}
@ -131,8 +139,9 @@ async function recalculateScale() {
}
// 使
const markingDataProvider = new DefaultMarkingDataProvider()
provideMarkingContext({
dataProvider: new DefaultMarkingDataProvider(),
dataProvider: markingDataProvider,
historyProvider: new DefaultMarkingHistoryProvider(),
isHistory: false,
defaultPosition: 'last',
@ -163,6 +172,7 @@ const markingData = provideMarkingData({
subjectId,
isLandscape,
taskType,
markingNavigation,
})
const { questionData: questions, questionsList, totalQuestions: totalQuestionsCount } = markingData
@ -396,6 +406,14 @@ async function handleNextQuestion() {
await goToNextQuestion()
}
/**
* 处理滚动边缘变化
*/
function handleScrollEdge(left: boolean, right: boolean) {
atLeftEdge.value = left
atRightEdge.value = right
}
//
const swipeHandler = createSwipeHandler()
@ -422,7 +440,8 @@ function handleGlobalTouchStart(e: TouchEvent) {
initialDistance.value = getDistance(e.touches[0], e.touches[1])
initialScale.value = imageScale.value
}
else {
else if (atLeftEdge.value || atRightEdge.value) {
//
swipeHandler.onTouchStart(e)
}
}
@ -445,10 +464,17 @@ function handleGlobalTouchMove(e: TouchEvent) {
*/
function handleGlobalTouchEnd(e: TouchEvent) {
if (e.touches.length < 2) {
swipeHandler.onTouchEnd(e)
if (atLeftEdge.value || atRightEdge.value) {
swipeHandler.onTouchEnd(e)
}
isGesturing.value = false
}
}
//
const nextQuestionImages = computed(() => {
return markingDataProvider.cachedImages.value
})
</script>
<template>
@ -504,7 +530,9 @@ function handleGlobalTouchEnd(e: TouchEvent) {
v-model:scale="imageScale"
:question-data="[currentQuestion]"
:image-size="100"
:next-question-images="nextQuestionImages"
@ready="handleImageReady"
@scroll-edge="handleScrollEdge"
/>
</div>
</template>
@ -512,7 +540,7 @@ function handleGlobalTouchEnd(e: TouchEvent) {
<!-- 打分区域横屏和竖屏共用 -->
<template #scoring>
<QuickScorePanel
v-if="currentQuestion && !isViewingHistory"
v-if="currentQuestion"
:is-landscape="isLandscape"
:full-score="currentQuestion.full_score"
@score-selected="handleQuickScoreSelect"

View File

@ -630,6 +630,10 @@ export type ExamMarkingTaskResponse = {
exam_name?: string;
/** 考试类型 */
exam_type?: string;
/** 年级 */
grade?: string;
/** 是否已统分 */
is_scored?: boolean;
/** 科目列表 */
subjects?: SubjectInfo[];
};

View File

@ -116,7 +116,7 @@ export const useUserStore = defineStore(
* 退
*
*/
const logOut = () => {
const logOut = (fromRequest = false) => {
// 清空用户信息
info.value = {}
// 重置登录状态
@ -132,7 +132,9 @@ export const useUserStore = defineStore(
uni.removeStorageSync('userInfo')
uni.removeStorageSync('token')
toast.success('退出成功')
if (!fromRequest) {
toast.success('退出成功')
}
}
/**