uni-app 实现做练习题(每一题从后端接口请求&切换动画&记录错题)

发布于:2025-09-03 ⋅ 阅读:(14) ⋅ 点赞:(0)

1.每一道题都从后端去请求数据

2.选择完之后请求数据拿到结果 反馈用户

3.记录错题 做完计算正确率

<template>
  <view class="exercise-content">
    <baseHead title="练习题" @toBack="goHome" />
    <!-- 题目内容区域 -->
    <view class="question-container" @touchstart="touchStart" @touchend="touchEnd">
      <view
        class="question-card"
        :class="[slideDirection]"
        :style="{ transform: `translateX(${translateX}px)`, transition: isAnimating ? 'transform 0.3s ease' : 'none' }"
      >
        <!-- 题目序号 -->
        <view class="question-number">第{{ currentIndex }}题</view>

        <!-- 题目内容 -->
        <view class="question-content">
          <text>{{ currentQuestion?.content }}</text>
        </view>

        <!-- 选项列表 -->
        <view class="options-list">
          <view
            v-for="(option, index) in currentQuestion.options"
            :key="index"
            class="option-item"
            :class="{ selected: selectedOption === option.optionKey }"
            @click="selectOption(option.optionKey)"
          >
            <image
              :src="getSpecImgUrl('problem/check.png')"
              v-if="selectedOption === option.optionKey && selectedOption === correctOption"
              mode="scaleToFill"
            />
            <image :src="getSpecImgUrl('problem/fork.png')" v-else-if="selectedOption === option.optionKey && correctOption" mode="scaleToFill" />
            <text v-else class="option-label">{{ option.optionKey }}</text>
            <text class="option-text">{{ option.optionContent }}</text>
          </view>
        </view>

        <!-- 下一题按钮 right_icon-->
        <view class="next-btn">
          <view @click="nextQuestion">
            <text>下一题</text>
            <image :src="getSpecImgUrl('problem/right_icon.png')" mode="scaleToFill" />
          </view>
        </view>
      </view>
    </view>
    <view class="question-result" v-if="selectedOption && !isAnimating">
      <view class="mr-40rpx">
        <text class="label">答案:</text>
        <text class="c-#087BFF">{{ correctOption }}</text>
      </view>
      <view>
        <text class="label">您选择:</text>
        <text :class="correctOption == selectedOption ? 'c-#087BFF' : 'c-#E75D5C'">{{ selectedOption }}</text>
      </view>
    </view>
    <BottomOperation :height="100">
      <view class="bottom-stats">
        <view class="correct-count">
          <image :src="getSpecImgUrl('problem/true_icon.png')" mode="scaleToFill" />
          <text>{{ correctCount }}</text>
        </view>
        <view class="line"></view>
        <view class="wrong-count">
          <image :src="getSpecImgUrl('problem/false_icon.png')" mode="scaleToFill" />
          <text>{{ wrongCount }}</text>
        </view>
      </view>
    </BottomOperation>
  </view>
</template>

<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { debounce } from '@/utils/index'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { getSpecImgUrl } from '@/config/app'
import baseHead from '@/components/base-head/base-head.vue'
import BottomOperation from '@/components/bottom-operation/bottom-operation.vue'
import { getQuestionApi, answerApi } from '@/api/modules/plate_management'

// 动画相关状态
const translateX = ref(0)
const isAnimating = ref(false)
const slideDirection = ref('')
const loading = ref(false)

const examId = ref()
const paramsVal = ref()
onLoad((options: any) => {
  examId.value = options.examId
  paramsVal.value = JSON.parse(options.params)
  currentIndex.value = paramsVal.value.lastQuestionNum || 1
  getQuestion()
})

// 获取选项
const getQuestion = () => {
  loading.value = true
  const params = {
    examId: examId.value,
    sortNum: currentIndex.value
  }
  getQuestionApi(params).then((res) => {
    if (res.code == 200) {
      currentQuestion.value = res.data
      if (res.data.userAnswer && res.data.userAnswer.length > 0) {
        selectedOption.value = res.data.userAnswer[0]
        const current = res.data.options.find((val) => val.isCorrect)
        correctOption.value = current.optionKey
      }
    }
    loading.value = false
  })
}
let timer
//作答
const answer = (optionKey) => {
  const pramas = {
    id: currentQuestion.value.id,
    optionKey: optionKey
  }
  answerApi(pramas).then((res) => {
    if (res.code == 200) {
      correctOption.value = res.data.answer
      if (correctOption.value == selectedOption.value) {
        correctCount.value++
        timer = setTimeout(() => {
          nextQuestion()
        }, 500)
      } else {
        wrongCount.value++
      }
    }
  })
}
const nextResult = () => {
  uni.navigateTo({
    url: `/pages/exercise_result/index?correctCount=${correctCount.value}&wrongCount=${wrongCount.value}&bankId=${paramsVal.value.bankId}`
  })
}

// 选择选项
const selectOption = (value) => {
  if (selectedOption.value !== '' || loading.value) return
  selectedOption.value = value
  answer(value)
  // 记录用户答案
  userAnswers.value[currentQuestion.value.id] = value
}

// 当前题目索引
const currentIndex = ref(1)
// 已选择的选项
const selectedOption = ref('')
const correctOption = ref('') // 正确选项
// 正确题目数量
const correctCount = ref(0)
// 错误题目数量
const wrongCount = ref(0)
// 用户答案记录
const userAnswers = ref({})

// 计算当前题目

const currentQuestion = ref()

// 执行动画
const performAnimation = (direction, callback) => {
  slideDirection.value = direction === 'next' ? 'slide-out-left' : 'slide-out-right'
  isAnimating.value = true

  // 等待出场动画结束
  setTimeout(() => {
    if (callback) callback()
    // 切完题后再进场
    slideDirection.value = direction === 'next' ? 'slide-in-right' : 'slide-in-left'

    setTimeout(() => {
      isAnimating.value = false
      slideDirection.value = ''
    }, 300) // 和CSS动画时长保持一致
  }, 300)
}

// 下一题
const nextQuestion = () => {
  if (!selectedOption.value) {
    uni.showToast({
      title: '当前题目未作答',
      icon: 'none'
    })
    return
  }
  function foo() {
    // 如果不是最后一题,执行动画并切换
    if (!currentQuestion.value.isFinalQuestion) {
      clearTimeout(timer)
      if (loading.value) return
      currentIndex.value++
      performAnimation('next', () => {
        selectedOption.value = ''
        correctOption.value = ''
        getQuestion()
      })
    } else {
      nextResult()
    }
  }
  const resultFoo = debounce(foo, 300)
  resultFoo()
}

// 上一题
const prevQuestion = async () => {
  if (currentIndex.value > 1) {
    currentIndex.value--
    selectedOption.value = ''
    performAnimation('prev', async () => {
      await getQuestion()
      selectedOption.value = userAnswers.value[currentQuestion.value.id]
    })
  } else {
    uni.showToast({
      title: '前面没有题目啦~',
      icon: 'none'
    })
  }
}

// 触摸开始位置
let startX = 0

// 触摸开始事件
const touchStart = (e) => {
  startX = e.touches[0].clientX
}

// 触摸结束事件
const touchEnd = (e) => {
  const endX = e.changedTouches[0].clientX
  const diff = endX - startX

  // 左滑:切换到下一题
  if (diff < -50) {
    if (!selectedOption.value) {
      uni.showToast({
        title: '当前题目未作答',
        icon: 'none'
      })
      return
    }
    nextQuestion()
  }

  // 右滑:切换到上一题
  if (diff > 50) {
    prevQuestion()
  }
}

const goHome = () => {
  uni.switchTab({ url: `/pages/index/index` })
}
</script>

<style lang="scss" scoped>
.exercise-content {
  min-height: 100vh;
  background-color: #f5f5f6;
}
.question-container {
  padding: 24rpx;
  overflow: hidden; /* 确保动画不会溢出 */
}

.question-card {
  background: #ffffff;
  border-radius: 24rpx;
  padding: 32rpx 34rpx;
  will-change: transform; /* 优化动画性能 */

  &.slide-left {
    animation: fadeIn 0.3s ease;
  }

  &.slide-right {
    animation: fadeIn 0.3s ease;
  }
}

.question-card {
  background: #ffffff;
  border-radius: 24rpx;
  padding: 32rpx 34rpx;
  will-change: transform, opacity;
}

/* 出场 */
.slide-out-left {
  animation: slideOutLeft 0.3s forwards ease;
}
.slide-out-right {
  animation: slideOutRight 0.3s forwards ease;
}

/* 入场 */
.slide-in-left {
  animation: slideInLeft 0.3s forwards ease;
}
.slide-in-right {
  animation: slideInRight 0.3s forwards ease;
}

@keyframes slideOutLeft {
  from { transform: translateX(0); opacity: 1; }
  to { transform: translateX(-100%); opacity: 0; }
}

@keyframes slideOutRight {
  from { transform: translateX(0); opacity: 1; }
  to { transform: translateX(100%); opacity: 0; }
}

@keyframes slideInLeft {
  from { transform: translateX(-100%); opacity: 0; }
  to { transform: translateX(0); opacity: 1; }
}

@keyframes slideInRight {
  from { transform: translateX(100%); opacity: 0; }
  to { transform: translateX(0); opacity: 1; }
}


</style>

动画通过@keyframes 设置入场和出场动画


网站公告

今日签到

点亮在社区的每一天
去签到