feat: 回评
continuous-integration/drone/push Build is passing Details

This commit is contained in:
AfyerCu 2025-09-25 22:53:50 +08:00
parent fb6a63d7a5
commit 52f7cac53a
14 changed files with 1449 additions and 80 deletions

View File

@ -0,0 +1,10 @@
{
"folders": [
{
"path": "."
},
{
"path": "../xlx_client/art-design-pro"
}
]
}

View File

@ -1,6 +1,6 @@
<!-- 阅卷布局组件 --> <!-- 阅卷布局组件 -->
<script lang="ts" setup> <script lang="ts" setup>
import type { ExamStudentMarkingQuestionResponse } from '@/api' import type { ExamQuestionWithTasksResponse } from '@/api'
import { computed } from 'vue' import { computed } from 'vue'
interface Props { interface Props {
@ -8,7 +8,8 @@ interface Props {
isFullscreen: boolean isFullscreen: boolean
currentQuestionIndex: number currentQuestionIndex: number
totalQuestions: number totalQuestions: number
questions: ExamStudentMarkingQuestionResponse[] currentTaskSubmit: number
questions: ExamQuestionWithTasksResponse[] // any[]
myScore?: number myScore?: number
avgScore?: number avgScore?: number
} }
@ -40,6 +41,18 @@ const questionPickerColumns = computed(() => [
value: index, value: index,
})), })),
]) ])
//
function formatScore(score: number): string {
if (score === 0)
return '0'
if (score % 1 === 0)
return score.toString() //
// 20
const formatted = score.toFixed(2)
return formatted.replace(/\.?0+$/, '')
}
</script> </script>
<template> <template>
@ -62,7 +75,7 @@ const questionPickerColumns = computed(() => [
<!-- 题号信息 --> <!-- 题号信息 -->
<view class="mr-16px flex items-center"> <view class="mr-16px flex items-center">
<text class="text-16px text-gray-800 font-medium"> <text class="text-16px text-gray-800 font-medium">
{{ currentQuestionIndex + 1 }}/{{ totalQuestions }} {{ currentTaskSubmit + 1 }}/{{ totalQuestions }}
</text> </text>
</view> </view>
@ -111,7 +124,7 @@ const questionPickerColumns = computed(() => [
<view class="w-80px flex flex-col gap-8px border-r border-gray-200 bg-white p-8px"> <view class="w-80px flex flex-col gap-8px border-r border-gray-200 bg-white p-8px">
<view <view
v-for="(question, index) in questions" v-for="(question, index) in questions"
:key="question.scan_info_id" :key="question.question_id"
class="flex cursor-pointer items-center justify-center rounded py-6px text-12px transition-colors" class="flex cursor-pointer items-center justify-center rounded py-6px text-12px transition-colors"
:class="[ :class="[
index === currentQuestionIndex index === currentQuestionIndex
@ -150,14 +163,14 @@ const questionPickerColumns = computed(() => [
:columns="questionPickerColumns" :columns="questionPickerColumns"
:value="String(currentQuestionIndex)" :value="String(currentQuestionIndex)"
:title="`${currentQuestion?.question_major}.${currentQuestion?.question_minor}`" :title="`${currentQuestion?.question_major}.${currentQuestion?.question_minor}`"
@confirm="(value) => emit('selectQuestion', Number(value))" @confirm="({ value }) => emit('selectQuestion', Number(value))"
/> />
</view> </view>
<!-- 右侧分数信息 --> <!-- 右侧分数信息 -->
<view class="flex flex-1 justify-end"> <view class="flex flex-1 justify-end">
<text class="text-sm text-gray-600"> <text class="text-sm text-gray-600">
我的均分/本题均分: {{ myScore }}/{{ avgScore }} 我的均分/本题均分: {{ formatScore(myScore) }}/{{ formatScore(avgScore) }}
</text> </text>
</view> </view>
</view> </view>
@ -167,7 +180,7 @@ const questionPickerColumns = computed(() => [
<!-- 题号信息 --> <!-- 题号信息 -->
<view class="flex items-center gap-4"> <view class="flex items-center gap-4">
<text class="text-base text-gray-800 font-medium"> <text class="text-base text-gray-800 font-medium">
{{ currentQuestionIndex + 1 }}/{{ totalQuestions }} {{ currentTaskSubmit + 1 }}/{{ totalQuestions }}
</text> </text>
</view> </view>

View File

@ -12,12 +12,15 @@ interface Props {
backgroundColor?: string backgroundColor?: string
currentTool: MarkingTool currentTool: MarkingTool
readOnly?: boolean readOnly?: boolean
adaptiveWidth?: boolean //
onQuickScore?: (value: number) => boolean
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
scale: 1, scale: 1,
backgroundColor: '#ffffff', backgroundColor: '#ffffff',
readOnly: false, readOnly: false,
adaptiveWidth: false,
}) })
const emit = defineEmits<{ const emit = defineEmits<{
@ -45,30 +48,67 @@ let layer: Konva.Layer | null = null
const naturalWidth = ref(0) const naturalWidth = ref(0)
const naturalHeight = ref(0) const naturalHeight = ref(0)
//
const containerWidth = ref(0)
const containerHeight = ref(0)
// 使
const actualScale = computed(() => {
if (!props.adaptiveWidth || !naturalWidth.value || !containerWidth.value) {
return props.scale
}
//
const adaptiveScale = containerWidth.value / naturalWidth.value
return Math.min(adaptiveScale, props.scale) //
})
// //
const containerStyle = computed(() => { const containerStyle = computed(() => {
if (!naturalWidth.value || !naturalHeight.value) { if (!naturalWidth.value || !naturalHeight.value) {
return { return {
width: '100%', width: props.adaptiveWidth ? '100%' : '100%',
height: '400px', height: '400px',
backgroundColor: props.backgroundColor, backgroundColor: props.backgroundColor,
} }
} }
const scaledWidth = naturalWidth.value * props.scale if (props.adaptiveWidth) {
const scaledHeight = naturalHeight.value * props.scale // 100%
const scaledHeight = naturalHeight.value * actualScale.value
return {
width: '100%',
height: `${scaledHeight}px`,
backgroundColor: props.backgroundColor,
}
}
else {
//
const scaledWidth = naturalWidth.value * actualScale.value
const scaledHeight = naturalHeight.value * actualScale.value
return { return {
width: `${scaledWidth}px`, width: `${scaledWidth}px`,
height: `${scaledHeight}px`, height: `${scaledHeight}px`,
backgroundColor: props.backgroundColor, backgroundColor: props.backgroundColor,
} }
}
}) })
// Layer // Layer
let layerManager: ReturnType<typeof useSimpleKonvaLayer> | null = null let layerManager: ReturnType<typeof useSimpleKonvaLayer> | null = null
const message = useMessage() const message = useMessage()
/**
* 更新容器尺寸
*/
function updateContainerSize() {
if (!konvaContainer.value)
return
containerWidth.value = konvaContainer.value.offsetWidth
containerHeight.value = konvaContainer.value.offsetHeight
}
/** /**
* 初始化Konva画布 * 初始化Konva画布
*/ */
@ -79,14 +119,17 @@ async function initKonva() {
// //
await nextTick() await nextTick()
const containerWidth = konvaContainer.value.offsetWidth //
const containerHeight = konvaContainer.value.offsetHeight updateContainerSize()
const currentContainerWidth = containerWidth.value
const currentContainerHeight = containerHeight.value
// Stage // Stage
const newStage = new Konva.Stage({ const newStage = new Konva.Stage({
container: konvaContainer.value, container: konvaContainer.value,
width: containerWidth, width: currentContainerWidth,
height: containerHeight, height: currentContainerHeight,
}) })
// Layer // Layer
@ -102,10 +145,10 @@ async function initKonva() {
// layer // layer
layerManager = useSimpleKonvaLayer({ layerManager = useSimpleKonvaLayer({
layer: newLayer, layer: newLayer,
scale, scale: actualScale,
currentTool, currentTool,
initialData: props.markingData, initialData: props.markingData,
onQuickScore: (value: number) => emit('quick-score', value), onQuickScore: (value: number) => props.onQuickScore?.(value),
readOnly: props.readOnly, readOnly: props.readOnly,
message, message,
}) })
@ -176,19 +219,31 @@ function applyScale() {
if (!layer || !stage) if (!layer || !stage)
return return
const currentScale = actualScale.value
// layer // layer
layer.scale({ x: props.scale, y: props.scale }) layer.scale({ x: currentScale, y: currentScale })
layer.offset({ x: 0, y: 0 }) layer.offset({ x: 0, y: 0 })
layer.position({ x: 0, y: 0 }) layer.position({ x: 0, y: 0 })
// stage // stage
const scaledWidth = naturalWidth.value * props.scale if (props.adaptiveWidth && containerWidth.value) {
const scaledHeight = naturalHeight.value * props.scale // stage
const scaledHeight = naturalHeight.value * currentScale
stage.size({
width: containerWidth.value,
height: scaledHeight,
})
}
else {
//
const scaledWidth = naturalWidth.value * currentScale
const scaledHeight = naturalHeight.value * currentScale
stage.size({ stage.size({
width: scaledWidth, width: scaledWidth,
height: scaledHeight, height: scaledHeight,
}) })
}
layer.draw() layer.draw()
} }
@ -210,28 +265,115 @@ watch(
) )
// //
watch( watch([() => props.scale, actualScale], () => {
() => props.scale,
() => {
applyScale() applyScale()
})
//
watch(
() => props.adaptiveWidth,
(newAdaptiveWidth) => {
if (newAdaptiveWidth && konvaContainer.value) {
//
nextTick(() => {
updateContainerSize()
applyScale()
})
}
}, },
) )
// ResizeObserver -
let resizeObserver: any = null
/**
* 设置尺寸监听器适配小程序
*/
function setupResizeObserver() {
if (!props.adaptiveWidth || !containerRef.value)
return
// ResizeObserver 使
if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect
containerWidth.value = width
containerHeight.value = height
// DOM
nextTick(() => {
applyScale()
})
}
})
resizeObserver.observe(containerRef.value)
}
else {
// 使
let lastWidth = 0
let lastHeight = 0
const checkSizeChange = () => {
if (!containerRef.value)
return
const newWidth = containerRef.value.offsetWidth || 0
const newHeight = containerRef.value.offsetHeight || 0
if (newWidth !== lastWidth || newHeight !== lastHeight) {
lastWidth = newWidth
lastHeight = newHeight
containerWidth.value = newWidth
containerHeight.value = newHeight
nextTick(() => {
applyScale()
})
}
}
resizeObserver = setInterval(checkSizeChange, 500) // 500ms
}
}
/**
* 清理尺寸监听器
*/
function cleanupResizeObserver() {
if (resizeObserver) {
if (typeof ResizeObserver !== 'undefined' && resizeObserver.disconnect) {
resizeObserver.disconnect()
}
else if (typeof resizeObserver === 'number') {
clearInterval(resizeObserver)
}
resizeObserver = null
}
}
// //
defineExpose({ defineExpose({
getLayerManager, getLayerManager,
getStage: () => stage, getStage: () => stage,
getLayer: () => layer, getLayer: () => layer,
updateContainerSize,
}) })
// //
onMounted(async () => { onMounted(async () => {
await initKonva() await initKonva()
//
if (props.adaptiveWidth) {
setupResizeObserver()
}
}) })
// //
onUnmounted(() => { onUnmounted(() => {
layerManager?.cleanupEvents() layerManager?.cleanupEvents()
cleanupResizeObserver()
stage?.destroy() stage?.destroy()
}) })
</script> </script>

View File

@ -207,7 +207,13 @@ async function toggleSpecialMark(type: 'excellent' | 'typical' | 'problem') {
isProblem: true, isProblem: true,
problemType: result.problemType, problemType: result.problemType,
problemRemark: result.problemRemark, problemRemark: result.problemRemark,
score: 0,
}) })
//
if (markingData.mode.value === 'single') {
markingData.submitRecord()
}
} }
} }
catch (error) { catch (error) {
@ -268,11 +274,6 @@ defineExpose({
&& markingData.firstNotScoredIndex.value === questionIndex, && markingData.firstNotScoredIndex.value === questionIndex,
}" }"
> >
<div v-if="question.title" class="question-header">
<div class="question-title">
<span class="question-title-text">{{ question.title }}</span>
</div>
</div>
<div class="images-container" :class="imagesLayoutClass"> <div class="images-container" :class="imagesLayoutClass">
<div <div
v-for="(imageUrl, imageIndex) in question.imageUrls" v-for="(imageUrl, imageIndex) in question.imageUrls"

View File

@ -1,6 +1,7 @@
import type { Ref } from 'vue' import type { Ref } from 'vue'
import type { ExamBatchCreateMarkingTaskRecordRequest, ExamCreateMarkingTaskRecordRequest, ExamQuestionAverageScoreComparisonResponse, ExamSetProblemRecordRequest, ExamStudentMarkingQuestionResponse } from '@/api' import type { ExamBatchCreateMarkingTaskRecordRequest, ExamCreateMarkingTaskRecordRequest, ExamQuestionAverageScoreComparisonResponse, ExamQuestionWithTasksResponse, ExamSetProblemRecordRequest, ExamStudentMarkingQuestionResponse } from '@/api'
import { useQuery } from '@tanstack/vue-query' import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { useDebounceFn, useThrottleFn } from '@vueuse/core'
import { computed, inject, provide, readonly, ref, watch } from 'vue' import { computed, inject, provide, readonly, ref, watch } from 'vue'
import { examMarkingTaskApi } from '@/api' import { examMarkingTaskApi } from '@/api'
@ -19,11 +20,14 @@ export interface MarkingSubmitData {
export interface UseMarkingDataOptions { export interface UseMarkingDataOptions {
taskId: Ref<number> taskId: Ref<number>
questionId: Ref<number> questionId: Ref<number>
examId?: Ref<number>
subjectId?: Ref<number>
isLandscape?: Ref<boolean> isLandscape?: Ref<boolean>
taskType?: Ref<string>
} }
function createMarkingData(options: UseMarkingDataOptions) { function createMarkingData(options: UseMarkingDataOptions) {
const { taskId, questionId, isLandscape } = options const { taskId, questionId, examId, subjectId, isLandscape, taskType } = options
// 基础数据 // 基础数据
const questionData = ref<ExamStudentMarkingQuestionResponse[]>([]) const questionData = ref<ExamStudentMarkingQuestionResponse[]>([])
@ -33,6 +37,8 @@ function createMarkingData(options: UseMarkingDataOptions) {
const markingStartTime = ref<number>(0) const markingStartTime = ref<number>(0)
const mode = ref<'single' | 'multi'>('single') const mode = ref<'single' | 'multi'>('single')
const queryClient = useQueryClient()
// 当前分数 // 当前分数
const firstNotScoredIndex = computed(() => { const firstNotScoredIndex = computed(() => {
return 0 return 0
@ -59,7 +65,23 @@ function createMarkingData(options: UseMarkingDataOptions) {
queryKey: computed(() => ['marking-question', taskId.value]), queryKey: computed(() => ['marking-question', taskId.value]),
queryFn: async () => examMarkingTaskApi.questionDetail(taskId.value), queryFn: async () => examMarkingTaskApi.questionDetail(taskId.value),
enabled: computed(() => !!taskId.value), enabled: computed(() => !!taskId.value),
gcTime: 0,
}) })
// 获取题目列表数据用于Layout显示
const {
data: questionsListData,
refetch: refetchQuestionsList,
} = useQuery({
queryKey: computed(() => ['marking-questions-list', examId?.value, subjectId?.value]),
queryFn: async () => examMarkingTaskApi.byQuestionList({
exam_id: examId!.value!,
exam_subject_id: subjectId!.value!,
}) as unknown as Promise<ExamQuestionWithTasksResponse[]>,
enabled: computed(() => !!examId?.value && !!subjectId?.value),
placeholderData: [],
})
// 获取平均分对比 // 获取平均分对比
const { const {
data: avgScoreData, data: avgScoreData,
@ -101,6 +123,24 @@ function createMarkingData(options: UseMarkingDataOptions) {
} }
} }
// 当前任务信息(用于获取总数)
const currentTaskInfo = computed(() => {
if (!questionId.value || !taskId.value || !questionsListData.value.length)
return null
// 找到当前题目
const currentQuestion = questionsListData.value.find(q => q.question_id === questionId.value)
if (!currentQuestion)
return null
return currentQuestion.tasks?.[taskType.value]
})
// 总题目数量(从任务中获取)
const totalQuestions = computed(() => {
return currentTaskInfo.value?.task_quantity || 0
})
// 监听数据变化 // 监听数据变化
watch(() => questionResponse.value, processQuestionData, { immediate: true }) watch(() => questionResponse.value, processQuestionData, { immediate: true })
@ -132,6 +172,7 @@ function createMarkingData(options: UseMarkingDataOptions) {
if (response) { if (response) {
isSubmitted.value = true isSubmitted.value = true
// 重新获取下一题 // 重新获取下一题
questionData.value = []
await refetchQuestion() await refetchQuestion()
processQuestionData() processQuestionData()
return response return response
@ -162,7 +203,9 @@ function createMarkingData(options: UseMarkingDataOptions) {
if (response) { if (response) {
isSubmitted.value = true isSubmitted.value = true
currentTaskInfo.value!.marked_quantity = (currentTaskInfo.value!.marked_quantity || 0) + 1
// 重新获取下一题 // 重新获取下一题
questionData.value = []
await refetchQuestion() await refetchQuestion()
processQuestionData() processQuestionData()
return response return response
@ -184,15 +227,19 @@ function createMarkingData(options: UseMarkingDataOptions) {
await Promise.all([ await Promise.all([
refetchQuestion(), refetchQuestion(),
refetchAvgScore(), refetchAvgScore(),
refetchQuestionsList(),
]) ])
} }
return { return {
// 数据状态 // 数据状态
questionData, questionData,
questionsList: computed(() => questionsListData.value || []), // 题目列表数据
currentMarkingSubmitData, currentMarkingSubmitData,
currentScore, currentScore,
firstNotScoredIndex, firstNotScoredIndex,
totalQuestions, // 总题目数量
currentTaskInfo,
isLoading: computed(() => isQuestionLoading.value || isLoading.value), isLoading: computed(() => isQuestionLoading.value || isLoading.value),
isSubmitted: readonly(isSubmitted), isSubmitted: readonly(isSubmitted),
mode, mode,
@ -211,6 +258,7 @@ function createMarkingData(options: UseMarkingDataOptions) {
reload, reload,
refetchQuestion, refetchQuestion,
refetchAvgScore, refetchAvgScore,
refetchQuestionsList,
} }
} }

View File

@ -124,7 +124,7 @@ export function generateOnekeyScores(stepSize: number, fullScore: number): numbe
export function getSelectedOnekeyScores( export function getSelectedOnekeyScores(
stepSize: number, stepSize: number,
fullScore: number, fullScore: number,
selections: Record<string, boolean>, selections: Record<string, boolean> = {},
): number[] { ): number[] {
const allScores = generateOnekeyScores(stepSize, fullScore) const allScores = generateOnekeyScores(stepSize, fullScore)
const selectionKey = `${stepSize}_${fullScore}` const selectionKey = `${stepSize}_${fullScore}`

View File

@ -0,0 +1,168 @@
<script setup lang="ts">
interface Props {
historyScoreOptions?: number[]
isLoadingOptions?: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
'score-change': [score?: number]
'order-change': [orderBy: number]
'go-back': []
'refresh': []
}>()
//
const selectedScore = ref<number | undefined>()
const selectedOrderBy = ref<number>(1) //
//
const orderOptions = [
{ label: '按打分从小到大', value: 1 },
{ label: '按打分从大到小', value: 2 },
{ label: '按时间从早到晚', value: 3 },
{ label: '按时间从晚到早', value: 4 },
]
//
function handleScoreChange(score?: number) {
selectedScore.value = score
emit('score-change', score)
}
//
function handleOrderChange(orderBy: number) {
selectedOrderBy.value = orderBy
emit('order-change', orderBy)
}
//
function goBack() {
emit('go-back')
}
//
function handleRefresh() {
emit('refresh')
}
//
const showOrderPicker = ref(false)
function handleOrderPickerConfirm(value: number) {
handleOrderChange(value)
showOrderPicker.value = false
}
</script>
<template>
<view class="review-header border-b border-gray-200 bg-white">
<!-- 筛选条件 -->
<view class="filter-section p-4 pt-2">
<!-- 历史评分筛选 -->
<view class="filter-group mb-3">
<text class="filter-label mb-2 block text-sm text-gray-600">历史评分:</text>
<view class="score-filters flex flex-wrap gap-2">
<view
class="score-tag"
:class="{ active: selectedScore === undefined }"
@click="handleScoreChange(undefined)"
>
全部
</view>
<view
v-for="score in historyScoreOptions"
:key="score"
class="score-tag"
:class="{ active: selectedScore === score }"
@click="handleScoreChange(score)"
>
{{ score }}
</view>
<!-- 加载状态 -->
<view v-if="isLoadingOptions" class="score-tag loading">
<wd-loading size="12" />
<text class="ml-1">加载中</text>
</view>
</view>
</view>
<!-- 试卷排序 -->
<view class="filter-group">
<text class="filter-label mb-2 block text-sm text-gray-600">试卷排序:</text>
<view
class="order-selector flex items-center justify-between rounded-2 bg-gray-50 px-3 py-2"
@click="showOrderPicker = true"
>
<text class="text-sm text-gray-700">
{{ orderOptions.find(item => item.value === selectedOrderBy)?.label }}
</text>
<view class="i-fluent:chevron-down-20-filled size-4 text-gray-400" />
</view>
</view>
</view>
<!-- 排序选择器 -->
<wd-action-sheet
v-model="showOrderPicker"
:actions="orderOptions.map(item => ({ name: item.label, value: item.value }))"
cancel-text="取消"
@select="({ item }) => handleOrderPickerConfirm(item.value)"
/>
</view>
</template>
<style scoped>
.review-header {
position: sticky;
top: 0;
z-index: 10;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
}
.back-btn:active,
.refresh-btn:active {
opacity: 0.6;
}
.score-tag {
padding: 4px 12px;
border-radius: 12px;
background-color: #f3f4f6;
color: #6b7280;
font-size: 12px;
transition: all 0.2s ease;
cursor: pointer;
}
.score-tag.active {
background-color: #3b82f6;
color: white;
}
.score-tag.loading {
background-color: #f9fafb;
color: #9ca3af;
cursor: not-allowed;
display: flex;
align-items: center;
}
.score-tag:not(.loading):not(.active):active {
background-color: #e5e7eb;
}
.order-selector {
transition: all 0.2s ease;
}
.order-selector:active {
background-color: #e5e7eb;
}
.filter-label {
font-weight: 500;
}
</style>

View File

@ -0,0 +1,171 @@
<script setup lang="ts">
import KonvaImageRenderer from '../components/renderer/KonvaImageRenderer.vue'
import { MarkingTool } from '../composables/renderer/useMarkingKonva'
interface Props {
imageUrls: string[]
markingData?: string
score?: number
fullScore?: number
scale?: number
}
const props = withDefaults(defineProps<Props>(), {
scale: 0.5, //
})
//
const processedMarkingData = computed(() => {
if (!props.markingData || typeof props.markingData !== 'string') {
//
return props.imageUrls.map(() => ({
version: '1.0',
shapes: [],
specialMarks: [],
annotations: [],
}))
}
let markingData = []
try {
markingData = JSON.parse(props.markingData)
}
catch (error) {
console.error('解析标记数据失败:', error)
markingData = []
}
//
return props.imageUrls.map((_, index) => {
const data = markingData?.[index]
if (data && typeof data === 'object') {
return {
version: data.version || '1.0',
shapes: data.shapes || [],
specialMarks: data.specialMarks || [],
annotations: data.annotations || [],
}
}
return {
version: '1.0',
shapes: [],
specialMarks: [],
annotations: [],
}
})
})
// layer
function handleLayerReady() {
// layer
}
</script>
<template>
<view class="review-image-renderer">
<!-- 图片列表 -->
<view class="image-list flex flex-col gap-3">
<view
v-for="(imageUrl, index) in imageUrls"
:key="`review_${index}`"
class="image-item relative"
>
<!-- 图片渲染器 -->
<view class="image-wrapper relative overflow-hidden rounded-2 shadow-sm">
<KonvaImageRenderer
:image-url="imageUrl"
:scale="scale"
:marking-data="processedMarkingData[index]"
:current-tool="MarkingTool.SELECT"
:read-only="true"
:adaptive-width="true"
@layer-ready="handleLayerReady"
/>
<!-- 点击编辑提示 -->
<view class="edit-hint absolute bottom-2 right-2 z-1">
<view class="hint-badge rounded-1 bg-black bg-opacity-60 px-2 py-1 text-white">
<view class="i-fluent:edit-20-filled mr-1 inline-block size-3" />
<text class="hint-text text-xs">点击编辑</text>
</view>
</view>
</view>
</view>
</view>
<!-- 空状态 -->
<view v-if="imageUrls.length === 0" class="empty-state flex-center h-32 rounded-2 bg-gray-100">
<view class="text-center">
<view class="i-fluent:image-20-filled mb-2 size-8 text-gray-400" />
<text class="text-sm text-gray-400">暂无图片</text>
</view>
</view>
</view>
</template>
<style scoped>
.review-image-renderer {
width: 100%;
}
.image-item {
width: 100%;
}
.image-wrapper {
position: relative;
background-color: #ffffff;
border: 1px solid #e5e7eb;
}
.score-overlay {
backdrop-filter: blur(2px);
}
.score-badge {
display: flex;
align-items: baseline;
gap: 1px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.score-text {
line-height: 1;
font-weight: 700;
}
.separator {
line-height: 1;
opacity: 0.8;
}
.full-score-text {
line-height: 1;
opacity: 0.9;
}
.edit-hint {
opacity: 0.8;
transition: opacity 0.2s ease;
}
.image-wrapper:active .edit-hint {
opacity: 1;
}
.hint-badge {
display: flex;
align-items: center;
font-size: 10px;
}
.hint-text {
line-height: 1;
}
.flex-center {
display: flex;
justify-content: center;
align-items: center;
}
</style>

View File

@ -0,0 +1,499 @@
<script setup lang="ts">
import type { ExamBatchCreateMarkingTaskRecordRequest, ExamMarkingTaskHistoryRecord } from '@/api'
import { useMutation } from '@tanstack/vue-query'
import { examMarkingTaskApi } from '@/api'
interface EditData {
currentScore: number
recordTask: ExamMarkingTaskHistoryRecord
}
//
const show = ref(false)
const editScore = ref(0)
const fullScore = ref(0)
const recordTask = ref<ExamMarkingTaskHistoryRecord>()
//
let resolvePromise: ((value: any) => void) | null = null
let rejectPromise: ((reason?: any) => void) | null = null
// mutation
const { mutate: updateScore, isPending } = useMutation({
mutationFn: async (params: { recordTask: ExamMarkingTaskHistoryRecord, score: number }) => {
//
const request: ExamBatchCreateMarkingTaskRecordRequest = {
batch_data: [{
id: params.recordTask.id!,
scan_info_id: params.recordTask.scan_info_id!,
question_id: params.recordTask.question_id!,
record_id: params.recordTask.id!,
task_id: params.recordTask.task_id!,
duration: params.recordTask.duration || 0,
remark: params.recordTask.remark || '',
page_mode: 'single',
is_excellent: params.recordTask.is_excellent || 0,
is_model: params.recordTask.is_model || 0,
is_problem: params.recordTask.is_problem || 0,
score: params.score,
}],
is_review: true, //
}
return examMarkingTaskApi.batchCreate(request)
},
onSuccess: () => {
uni.showToast({
title: '分数修改成功',
icon: 'success',
})
handleConfirm(editScore.value)
},
})
//
function open(data: EditData): Promise<number | null> {
return new Promise((resolve, reject) => {
editScore.value = data.currentScore
fullScore.value = data.recordTask.full_score
recordTask.value = data.recordTask
show.value = true
resolvePromise = resolve
rejectPromise = reject
})
}
//
function handleConfirm(score: number) {
show.value = false
resolvePromise?.(score)
cleanup()
}
//
function handleCancel() {
show.value = false
rejectPromise?.('cancel')
cleanup()
}
//
function cleanup() {
resolvePromise = null
rejectPromise = null
}
//
function handleSubmit() {
if (editScore.value < 0 || editScore.value > fullScore.value) {
uni.showToast({
title: `分数必须在0-${fullScore.value}之间`,
icon: 'none',
})
return
}
updateScore({
recordTask: recordTask.value!,
score: editScore.value,
})
}
//
const quickScoreButtons = computed(() => {
const buttons = []
const step = fullScore.value <= 10 ? 1 : Math.ceil(fullScore.value / 10)
for (let i = 0; i <= fullScore.value; i += step) {
if (i <= fullScore.value) {
buttons.push(i)
}
}
//
if (buttons[buttons.length - 1] !== fullScore.value) {
buttons.push(fullScore.value)
}
return buttons
})
//
function adjustScore(delta: number) {
const newScore = editScore.value + delta
if (newScore >= 0 && newScore <= fullScore.value) {
editScore.value = newScore
}
}
//
watch(show, (newValue) => {
if (!newValue && rejectPromise) {
handleCancel()
}
})
//
defineExpose({
open,
})
</script>
<template>
<wd-popup
v-model="show"
position="bottom"
round
closable
safe-area-inset-bottom
@close="handleCancel"
>
<view class="score-edit-dialog">
<!-- 拖拽指示器 -->
<view class="drag-indicator" />
<!-- 标题区域 -->
<view class="dialog-header">
<text class="title">修改分数</text>
<text class="subtitle">请为当前题目设置分数</text>
</view>
<!-- 当前分数显示 -->
<view class="current-score">
<view class="score-container">
<view class="score-display">
<text class="score">{{ editScore }}</text>
<text class="separator">/</text>
<text class="total">{{ fullScore }}</text>
</view>
<text class="score-label">当前分数</text>
</view>
</view>
<!-- 分数调整器 -->
<view class="score-adjuster">
<wd-button
type="primary"
size="small"
round
:disabled="editScore <= 0"
custom-class="adjust-btn minus-btn"
@click="adjustScore(-1)"
>
-1
</wd-button>
<view class="score-input-container">
<wd-input
v-model.number="editScore"
:min="0"
:max="fullScore"
:precision="0"
size="large"
custom-class="score-input"
/>
</view>
<wd-button
type="primary"
size="small"
round
:disabled="editScore >= fullScore"
custom-class="adjust-btn plus-btn"
@click="adjustScore(1)"
>
+1
</wd-button>
</view>
<!-- 快捷分数按钮 -->
<view class="quick-scores">
<text class="section-title">快捷分数</text>
<view class="quick-buttons">
<wd-button
v-for="score in quickScoreButtons"
:key="score"
size="small"
round
:type="editScore === score ? 'primary' : 'default'"
:custom-class="editScore === score ? 'quick-btn active' : 'quick-btn'"
@click="editScore = score"
>
{{ score }}
</wd-button>
</view>
</view>
<!-- 操作按钮 -->
<view class="dialog-actions">
<wd-button
size="large"
round
:disabled="isPending"
custom-class="cancel-btn"
@click="handleCancel"
>
取消
</wd-button>
<wd-button
type="primary"
size="large"
round
:loading="isPending"
custom-class="confirm-btn"
@click="handleSubmit"
>
确定修改
</wd-button>
</view>
</view>
</wd-popup>
</template>
<style lang="scss" scoped>
.score-edit-dialog {
background: linear-gradient(180deg, #ffffff 0%, #f8f9fa 100%);
border-radius: 20px 20px 0 0;
padding: 0;
position: relative;
max-height: 85vh;
overflow: hidden;
}
/* 拖拽指示器 */
.drag-indicator {
width: 40px;
height: 4px;
background: #e1e5e9;
border-radius: 2px;
margin: 12px auto 0;
}
/* 标题区域 */
.dialog-header {
text-align: center;
padding: 20px 24px 16px;
border-bottom: 1px solid #f0f0f0;
background: #ffffff;
}
.title {
display: block;
font-size: 20px;
font-weight: 600;
color: #1f2937;
margin-bottom: 4px;
}
.subtitle {
display: block;
font-size: 14px;
color: #6b7280;
}
/* 当前分数显示 */
.current-score {
padding: 32px 24px;
background: #ffffff;
}
.score-container {
text-align: center;
}
.score-display {
display: flex;
align-items: baseline;
justify-content: center;
gap: 8px;
margin-bottom: 8px;
}
.score {
font-size: 48px;
font-weight: 700;
color: #dc2626;
line-height: 1;
text-shadow: 0 2px 4px rgba(220, 38, 38, 0.1);
}
.separator {
font-size: 24px;
color: #9ca3af;
font-weight: 500;
}
.total {
font-size: 24px;
color: #4b5563;
font-weight: 600;
}
.score-label {
font-size: 13px;
color: #6b7280;
font-weight: 500;
letter-spacing: 0.5px;
}
/* 分数调整器 */
.score-adjuster {
display: flex;
align-items: center;
justify-content: center;
gap: 20px;
padding: 24px;
background: #f8f9fa;
}
.score-input-container {
background: #ffffff;
border-radius: 12px;
padding: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border: none;
}
/* 快捷分数按钮区域 */
.quick-scores {
padding: 24px;
background: #ffffff;
border-top: 1px solid #f0f0f0;
}
.section-title {
display: block;
font-size: 14px;
font-weight: 600;
color: #374151;
margin-bottom: 16px;
letter-spacing: 0.3px;
}
.quick-buttons {
display: flex;
flex-wrap: wrap;
gap: 8px;
max-height: 120px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: #d1d5db transparent;
}
.quick-buttons::-webkit-scrollbar {
width: 4px;
}
.quick-buttons::-webkit-scrollbar-track {
background: transparent;
}
.quick-buttons::-webkit-scrollbar-thumb {
background: #d1d5db;
border-radius: 2px;
}
/* 操作按钮 */
.dialog-actions {
padding: 24px;
background: #ffffff;
border-top: 1px solid #f0f0f0;
display: flex;
gap: 12px;
}
/* 自定义按钮样式 */
:deep(.adjust-btn) {
min-width: 44px;
height: 44px;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.2);
transition: all 0.2s ease;
}
:deep(.adjust-btn:active) {
transform: scale(0.95);
}
:deep(.score-input) {
border: none !important;
text-align: center;
font-size: 18px;
font-weight: 600;
width: 120px;
&::after {
display: none;
}
}
:deep(.quick-btn) {
min-width: 44px;
height: 36px;
transition: all 0.2s ease;
font-weight: 500;
}
:deep(.quick-btn.active) {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
:deep(.cancel-btn) {
flex: 1;
height: 48px;
background: #f3f4f6 !important;
color: #374151 !important;
border: 1px solid #e5e7eb !important;
font-weight: 500;
}
:deep(.confirm-btn) {
flex: 1;
height: 48px;
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%) !important;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
font-weight: 600;
transition: all 0.2s ease;
}
:deep(.confirm-btn:active) {
transform: translateY(1px);
box-shadow: 0 2px 6px rgba(59, 130, 246, 0.3);
}
/* 动画效果 */
.score-edit-dialog {
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
/* 分数变化动画 */
.score {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* 响应式设计 */
@media (max-height: 600px) {
.current-score {
padding: 20px 24px;
}
.score {
font-size: 36px;
}
.quick-buttons {
max-height: 80px;
}
}
</style>

View File

@ -154,6 +154,13 @@
"style": { "style": {
"navigationBarTitleText": "阅卷监控" "navigationBarTitleText": "阅卷监控"
} }
},
{
"path": "pages/marking/review",
"type": "page",
"style": {
"navigationBarTitleText": "回评"
}
} }
], ],
"subPackages": [] "subPackages": []

View File

@ -103,7 +103,7 @@ async function handleStartMarking(task: any) {
// //
uni.navigateTo({ uni.navigateTo({
url: `/pages/marking/grading?examId=${examId.value}&subjectId=${subjectId.value}&questionId=${task.question_id}&taskId=${task.id}&type=${evaluationType}`, url: `/pages/marking/grading?examId=${examId.value}&subjectId=${subjectId.value}&questionId=${task.question_id}&taskId=${task.id}&type=${evaluationType}&taskType=${task.task_type}`,
}) })
} }
catch (error) { catch (error) {
@ -115,32 +115,11 @@ async function handleStartMarking(task: any) {
} }
} }
// //
async function handleRemark(task: any) { async function handleViewReview(task: any) {
try { uni.navigateTo({
await uni.showModal({ url: `/pages/marking/review?examId=${examId.value}&subjectId=${subjectId.value}&questionId=${task.question_id}&taskId=${task.id}`,
title: '确认回评',
content: '回评将清除已阅记录,是否继续?',
}) })
uni.showToast({
title: '回评功能开发中',
icon: 'none',
})
// TODO:
//
refetch()
}
catch (error: any) {
if (error.errMsg !== 'showModal:fail cancel') {
console.error('回评失败:', error)
uni.showToast({
title: '回评失败',
icon: 'none',
})
}
}
} }
// //
@ -217,12 +196,12 @@ function handleRefresh() {
</view> </view>
<view class="flex gap-2"> <view class="flex gap-2">
<!-- 回评按钮 --> <!-- 查看回评按钮 -->
<wd-button <wd-button
v-if="(task.marked_quantity || 0) > 0" v-if="(task.marked_quantity || 0) > 0"
size="small" size="small"
type="warning" type="info"
@click="handleRemark(task)" @click="handleViewReview(task)"
> >
回评 回评
</wd-button> </wd-button>

View File

@ -8,6 +8,8 @@
</route> </route>
<script lang="ts" setup> <script lang="ts" setup>
import { useQueryClient } from '@tanstack/vue-query'
import { whenever } from '@vueuse/core'
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import AnswerDialog from '@/components/marking/components/dialog/AnswerDialog.vue' import AnswerDialog from '@/components/marking/components/dialog/AnswerDialog.vue'
import AvgScoreDialog from '@/components/marking/components/dialog/AvgScoreDialog.vue' import AvgScoreDialog from '@/components/marking/components/dialog/AvgScoreDialog.vue'
@ -27,6 +29,7 @@ const subjectId = ref<number>()
const questionId = ref<number>() const questionId = ref<number>()
const taskId = ref<number>() const taskId = ref<number>()
const evaluationType = ref<'single' | 'double'>('single') const evaluationType = ref<'single' | 'double'>('single')
const taskType = ref<'initial' | 'final' | 'arbitration'>('initial')
// //
const isLandscape = ref(false) const isLandscape = ref(false)
@ -34,15 +37,18 @@ const isLandscape = ref(false)
const markingData = provideMarkingData({ const markingData = provideMarkingData({
taskId, taskId,
questionId, questionId,
examId,
subjectId,
isLandscape, isLandscape,
taskType,
}) })
const { questionData: questions } = markingData const { questionData: questions, questionsList, totalQuestions: totalQuestionsCount } = markingData
// //
const isFullscreen = ref(false) const isFullscreen = ref(false)
const currentQuestionIndex = ref(0) const currentQuestionIndex = ref(0)
const totalQuestions = computed(() => questions.value.length) const totalQuestions = computed(() => totalQuestionsCount.value)
// //
const myScore = computed(() => { const myScore = computed(() => {
@ -61,6 +67,7 @@ onLoad((options) => {
questionId.value = Number(options.questionId) questionId.value = Number(options.questionId)
taskId.value = Number(options.taskId) // taskId taskId.value = Number(options.taskId) // taskId
evaluationType.value = options.type as 'single' | 'double' || 'single' evaluationType.value = options.type as 'single' | 'double' || 'single'
taskType.value = options.taskType
}) })
// //
@ -167,6 +174,7 @@ function goBack() {
// //
function selectQuestion(index: number) { function selectQuestion(index: number) {
currentQuestionIndex.value = index currentQuestionIndex.value = index
questions.value = []
} }
// //
@ -199,7 +207,18 @@ function viewAnswer() {
} }
// //
const currentQuestion = computed(() => questions.value[currentQuestionIndex.value]) const currentQuestions = computed(() => questionsList.value[currentQuestionIndex.value])
const currentQuestion = computed(() => questions.value[0])
const currentTask = computed(() => currentQuestions.value?.tasks?.[taskType.value])
const queryClient = useQueryClient()
whenever(currentTask, (task, oldTask) => {
taskId.value = task?.id
questionId.value = task?.question_id
queryClient.invalidateQueries({ queryKey: ['marking-question', oldTask?.id] })
queryClient.invalidateQueries({ queryKey: ['marking-question', task.id] })
})
// //
function handleQuickScoreSelect(score: number) { function handleQuickScoreSelect(score: number) {
@ -213,8 +232,9 @@ function handleQuickScoreSelect(score: number) {
:is-landscape="isLandscape" :is-landscape="isLandscape"
:is-fullscreen="isFullscreen" :is-fullscreen="isFullscreen"
:current-question-index="currentQuestionIndex" :current-question-index="currentQuestionIndex"
:current-task-submit="currentTask?.marked_quantity || 0"
:total-questions="totalQuestions" :total-questions="totalQuestions"
:questions="questions" :questions="questionsList"
:my-score="myScore" :my-score="myScore"
:avg-score="avgScore" :avg-score="avgScore"
@go-back="goBack" @go-back="goBack"

View File

@ -0,0 +1,275 @@
<!-- 回评页面 -->
<route lang="jsonc">
{
"style": {
"navigationBarTitleText": "回评"
}
}
</route>
<script lang="ts" setup>
import { useQuery } from '@tanstack/vue-query'
import dayjs from 'dayjs'
import { computed, ref } from 'vue'
import { examMarkingTaskApi } from '@/api'
import ReviewHeader from '@/components/marking/review/ReviewHeader.vue'
import ReviewImageRenderer from '@/components/marking/review/ReviewImageRenderer.vue'
import ScoreEditDialog from '@/components/marking/review/ScoreEditDialog.vue'
defineOptions({
name: 'MarkingReviewPage',
})
//
const taskId = ref<number>()
const questionId = ref<number>()
const examId = ref<number>()
const subjectId = ref<number>()
const enableQuery = computed(() => !!taskId.value)
//
onLoad((options) => {
taskId.value = Number(options.taskId)
questionId.value = Number(options.questionId)
examId.value = Number(options.examId)
subjectId.value = Number(options.subjectId)
console.log('回评页面参数:', { taskId: taskId.value, questionId: questionId.value })
})
//
const selectedScore = ref<number>()
const selectedOrderBy = ref<number>(1) //
//
const {
data: historyScoreOptions,
isLoading: isLoadingScoreOptions,
} = useQuery({
queryKey: ['review-history-score', taskId],
queryFn: () => examMarkingTaskApi.reviewHistoryScoreCreate({
task_id: taskId.value!,
}),
enabled: enableQuery,
select: data => data?.history_scores || [],
})
//
const currentPage = ref(1)
const pageSize = ref(20)
const {
data: historyData,
isLoading: isLoadingHistory,
error: historyError,
refetch: refetchHistory,
} = useQuery({
queryKey: ['review-history', taskId, currentPage, selectedScore, selectedOrderBy],
queryFn: () => examMarkingTaskApi.historyDetail(
taskId.value!,
{
page: currentPage.value,
page_size: pageSize.value,
is_review: true,
history_score: selectedScore.value,
order_by: selectedOrderBy.value,
},
),
enabled: enableQuery,
})
//
const historyList = computed(() => historyData.value?.list || [])
const totalCount = computed(() => historyData.value?.total || 0)
//
function handleScoreChange(score?: number) {
console.log('分数筛选变化:', score)
selectedScore.value = score
currentPage.value = 1 //
}
//
function handleOrderChange(orderBy: number) {
console.log('排序变化:', orderBy)
selectedOrderBy.value = orderBy
currentPage.value = 1 //
}
//
function goBack() {
uni.navigateBack()
}
//
const scoreEditDialogRef = ref<InstanceType<typeof ScoreEditDialog>>()
//
async function handleImageClick(record: any) {
try {
const result = await scoreEditDialogRef.value?.open({
currentScore: record.score,
recordTask: record,
})
if (result) {
//
await refetchHistory()
uni.showToast({
title: '分数修改成功',
icon: 'success',
})
}
}
catch (error) {
if (error !== 'cancel') {
console.error('修改分数失败:', error)
}
}
}
//
function loadMore() {
if (historyData.value?.has_more) {
currentPage.value += 1
}
}
//
function handleRefresh() {
currentPage.value = 1
refetchHistory()
}
</script>
<template>
<view class="review-page bg-gray-50">
<!-- 顶部筛选栏 -->
<ReviewHeader
:history-score-options="historyScoreOptions || []"
:is-loading-options="isLoadingScoreOptions"
@score-change="handleScoreChange"
@order-change="handleOrderChange"
@go-back="goBack"
@refresh="handleRefresh"
/>
<!-- 主体内容区 -->
<view class="content-area flex-1 p-4">
<!-- 加载状态 -->
<view v-if="isLoadingHistory" class="loading-container flex-center h-50">
<wd-loading size="24" />
<text class="ml-2 text-sm text-gray-600">加载中...</text>
</view>
<!-- 错误状态 -->
<view v-else-if="historyError" class="error-container flex-center h-50">
<view class="text-center">
<text class="mb-3 block text-sm text-gray-600">加载失败</text>
<wd-button type="primary" size="small" @click="handleRefresh">
重试
</wd-button>
</view>
</view>
<!-- 历史记录列表 -->
<view v-else-if="historyList.length > 0" class="history-list">
<view
v-for="record in historyList"
:key="record.id"
class="history-item mb-4 rounded-3 bg-white p-4 shadow-sm"
@click="handleImageClick(record)"
>
<!-- 头部信息 -->
<view class="item-header mb-3 flex items-center justify-between">
<view class="score-info">
<text class="score text-2xl text-red-600 font-bold">{{ record.score }}</text>
<text class="total-score text-base text-gray-500">/ {{ record.full_score }}</text>
</view>
<view class="time-info text-right">
<text class="time text-sm text-gray-500">{{ dayjs(record.created_time).format('YYYY-MM-DD HH:mm:ss') }}</text>
</view>
</view>
<!-- 图片展示区 -->
<view class="image-container">
<ReviewImageRenderer
v-if="record.image_urls?.length"
:image-urls="record.image_urls"
:marking-data="record.remark"
:score="record.score"
:full-score="record.full_score"
/>
<view v-else class="no-image flex-center h-32 rounded-2 bg-gray-100">
<text class="text-gray-400">暂无图片</text>
</view>
</view>
</view>
<!-- 加载更多 -->
<view v-if="historyData?.has_more" class="load-more py-4">
<wd-button
type="primary"
size="small"
block
@click="loadMore"
>
加载更多
</wd-button>
</view>
<!-- 没有更多数据 -->
<view v-else-if="historyList.length > 0" class="no-more py-4 text-center">
<text class="text-sm text-gray-400">没有更多数据了</text>
</view>
</view>
<!-- 空状态 -->
<view v-else class="empty-state flex-center h-50">
<view class="text-center">
<view class="i-fluent:document-search-20-filled mb-4 size-20 text-gray-400" />
<text class="text-sm text-gray-500">暂无历史记录</text>
<text class="mt-2 block text-xs text-gray-400">请调整筛选条件</text>
</view>
</view>
</view>
<!-- 分数编辑对话框 -->
<ScoreEditDialog ref="scoreEditDialogRef" />
</view>
</template>
<style scoped>
.review-page {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.content-area {
flex: 1;
}
.flex-center {
display: flex;
justify-content: center;
align-items: center;
}
.history-item {
transition: all 0.2s ease;
}
.history-item:active {
transform: scale(0.98);
background-color: #f8f9fa;
}
.score {
line-height: 1;
}
.total-score {
line-height: 1;
}
</style>

View File

@ -1,3 +1,4 @@
/* eslint-disable style/operator-linebreak */
/** /**
* HTTP * HTTP
* uni.request baseURL: api.xianglexue.com * uni.request baseURL: api.xianglexue.com
@ -10,12 +11,44 @@ import { http, httpDelete, httpGet, httpPost, httpPut } from '@/http/http'
// 桌面端 baseURL // 桌面端 baseURL
const DESKTOP_BASE_URL = 'http://api.xianglexue.com/api/v1' const DESKTOP_BASE_URL = 'http://api.xianglexue.com/api/v1'
interface ControllerResponse<T> {
code?: number
data?: T
}
// 重写 ModelPageResponse 以支持泛型,避免 any[] 覆盖问题
interface TypedModelPageResponse<T = any> {
/** 是否有更多数据 */
has_more?: boolean
/** 当前页数据列表 */
list?: T[]
/** 当前页 */
page?: number
/** 每页数量 */
page_size?: number
/** 总记录数 */
total?: number
}
// 检测是否为分页响应类型
type IsPageResponse<T> = T extends { list?: any[], page?: number, total?: number } ? true : false
// 优化的类型解压,智能处理分页响应
type ExtractData<T> = T extends ControllerResponse<infer U>
? IsPageResponse<U> extends true
? // 如果是分页响应,保持原始结构但替换 ModelPageResponse 为 TypedModelPageResponse
U extends { list?: infer L }
? Omit<U, 'list'> & { list?: L }
: U
: U
: T
/** /**
* GET * GET
*/ */
function desktopHttpGet<T>(url: string, query?: Record<string, any>, header?: Record<string, any>, options?: Partial<CustomRequestOptions>) { function desktopHttpGet<T>(url: string, query?: Record<string, any>, header?: Record<string, any>, options?: Partial<CustomRequestOptions>) {
const desktopUrl = url.startsWith('http') ? url : `${DESKTOP_BASE_URL}${url}` const desktopUrl = url.startsWith('http') ? url : `${DESKTOP_BASE_URL}${url}`
return httpGet<T>(desktopUrl, query, header, options) return httpGet<ExtractData<T>>(desktopUrl, query, header, options)
} }
/** /**
@ -23,7 +56,7 @@ function desktopHttpGet<T>(url: string, query?: Record<string, any>, header?: Re
*/ */
function desktopHttpPost<T>(url: string, data?: Record<string, any>, query?: Record<string, any>, header?: Record<string, any>, options?: Partial<CustomRequestOptions>) { function desktopHttpPost<T>(url: string, data?: Record<string, any>, query?: Record<string, any>, header?: Record<string, any>, options?: Partial<CustomRequestOptions>) {
const desktopUrl = url.startsWith('http') ? url : `${DESKTOP_BASE_URL}${url}` const desktopUrl = url.startsWith('http') ? url : `${DESKTOP_BASE_URL}${url}`
return httpPost<T>(desktopUrl, data, query, header, options) return httpPost<ExtractData<T>>(desktopUrl, data, query, header, options)
} }
/** /**
@ -31,7 +64,7 @@ function desktopHttpPost<T>(url: string, data?: Record<string, any>, query?: Rec
*/ */
function desktopHttpPut<T>(url: string, data?: Record<string, any>, query?: Record<string, any>, header?: Record<string, any>, options?: Partial<CustomRequestOptions>) { function desktopHttpPut<T>(url: string, data?: Record<string, any>, query?: Record<string, any>, header?: Record<string, any>, options?: Partial<CustomRequestOptions>) {
const desktopUrl = url.startsWith('http') ? url : `${DESKTOP_BASE_URL}${url}` const desktopUrl = url.startsWith('http') ? url : `${DESKTOP_BASE_URL}${url}`
return httpPut<T>(desktopUrl, data, query, header, options) return httpPut<ExtractData<T>>(desktopUrl, data, query, header, options)
} }
/** /**
@ -39,7 +72,7 @@ function desktopHttpPut<T>(url: string, data?: Record<string, any>, query?: Reco
*/ */
function desktopHttpDelete<T>(url: string, query?: Record<string, any>, header?: Record<string, any>, options?: Partial<CustomRequestOptions>) { function desktopHttpDelete<T>(url: string, query?: Record<string, any>, header?: Record<string, any>, options?: Partial<CustomRequestOptions>) {
const desktopUrl = url.startsWith('http') ? url : `${DESKTOP_BASE_URL}${url}` const desktopUrl = url.startsWith('http') ? url : `${DESKTOP_BASE_URL}${url}`
return httpDelete<T>(desktopUrl, query, header, options) return httpDelete<ExtractData<T>>(desktopUrl, query, header, options)
} }
/** /**
@ -189,4 +222,7 @@ class DesktopHttpClient {
// 创建桌面端 HTTP 客户端实例 // 创建桌面端 HTTP 客户端实例
const httpClient = new DesktopHttpClient() const httpClient = new DesktopHttpClient()
// 导出类型和客户端
export { TypedModelPageResponse }
export type { ExtractData }
export default httpClient export default httpClient