Skip to content

教育应用实战案例

本文将介绍如何使用 uni-app 开发一个功能完善的教育应用,包括应用架构设计、核心功能实现和关键代码示例。

1. 应用概述

1.1 功能特点

教育应用是移动互联网时代的重要应用类型,主要功能包括:

  • 课程内容展示与学习
  • 视频播放与学习进度记录
  • 在线练习题与测验评估
  • 个性化学习计划与提醒
  • 学习数据统计与分析
  • 社区互动与问答交流

1.2 技术架构

前端技术栈

  • uni-app:跨平台开发框架,实现一次开发多端运行
  • Vue.js:响应式数据绑定,提供组件化开发模式
  • Vuex:状态管理,处理复杂组件间通信
  • uni-ui:官方UI组件库,提供统一的界面设计

后端技术栈

  • 云函数:处理业务逻辑,提供无服务器计算能力
  • 云数据库:存储课程内容和用户数据
  • 对象存储:存储视频、音频等媒体文件

2. 项目结构

├── components            // 自定义组件
│   ├── course-card       // 课程卡片组件
│   ├── video-player      // 视频播放器组件
│   └── quiz-item         // 测验题目组件
├── pages                 // 页面文件夹
│   ├── index             // 首页(课程列表)
│   ├── course            // 课程详情页
│   ├── video             // 视频播放页
│   ├── quiz              // 测验页面
│   └── user              // 用户中心
├── store                 // Vuex 状态管理
│   ├── index.js          // 组装模块并导出
│   ├── course.js         // 课程相关状态
│   └── user.js           // 用户相关状态
├── utils                 // 工具函数
│   ├── request.js        // 请求封装
│   ├── time.js           // 时间处理
│   └── storage.js        // 本地存储
├── static                // 静态资源
├── App.vue               // 应用入口
├── main.js               // 主入口
├── manifest.json         // 配置文件
└── pages.json            // 页面配置

3. 核心功能实现

3.1 首页与课程列表

首页是用户进入应用的第一个界面,需要展示丰富的课程内容并提供良好的浏览体验。

主要功能

  • 搜索功能
  • 分类筛选
  • 轮播图展示
  • 推荐课程列表
  • 最新课程列表
  • 学习计划提醒

实现代码

js
// store/course.js - 课程数据管理
export default {
  namespaced: true,
  state: {
    categories: [],
    banners: [],
    recommendCourses: [],
    newCourses: []
  },
  mutations: {
    SET_CATEGORIES(state, categories) {
      state.categories = categories;
    },
    SET_BANNERS(state, banners) {
      state.banners = banners;
    },
    SET_RECOMMEND_COURSES(state, courses) {
      state.recommendCourses = courses;
    },
    SET_NEW_COURSES(state, courses) {
      state.newCourses = courses;
    }
  },
  actions: {
    // 获取课程分类
    async getCategories({ commit }) {
      try {
        const { result } = await uniCloud.callFunction({
          name: 'course',
          data: { action: 'getCategories' }
        });
        commit('SET_CATEGORIES', result.data);
        return result.data;
      } catch (e) {
        console.error('获取分类失败', e);
        throw e;
      }
    },
    
    // 获取轮播图
    async getBanners({ commit }) {
      try {
        const { result } = await uniCloud.callFunction({
          name: 'course',
          data: { action: 'getBanners' }
        });
        commit('SET_BANNERS', result.data);
        return result.data;
      } catch (e) {
        console.error('获取轮播图失败', e);
        throw e;
      }
    },
    
    // 获取课程列表
    async getCourses({ commit }, params) {
      try {
        const { result } = await uniCloud.callFunction({
          name: 'course',
          data: { 
            action: 'getCourses',
            params
          }
        });
        
        if (params.type === 'recommend') {
          commit('SET_RECOMMEND_COURSES', result.data);
        } else if (params.type === 'new') {
          commit('SET_NEW_COURSES', result.data);
        }
        
        return result.data;
      } catch (e) {
        console.error('获取课程列表失败', e);
        throw e;
      }
    }
  }
};
vue
<!-- pages/index/index.vue - 首页组件 -->
<template>
  <view class="index-page">
    <!-- 搜索栏 -->
    <view class="search-bar">
      <text class="iconfont icon-search"></text>
      <input 
        type="text" 
        v-model="keyword" 
        placeholder="搜索课程" 
        @confirm="searchCourse"
      />
    </view>
    
    <!-- 分类标签 -->
    <scroll-view scroll-x class="category-scroll">
      <view class="category-list">
        <view 
          class="category-item" 
          :class="{ active: currentCategory === index }"
          v-for="(item, index) in categories" 
          :key="index"
          @tap="changeCategory(index)"
        >
          {{item.name}}
        </view>
      </view>
    </scroll-view>
    
    <!-- 轮播图 -->
    <swiper 
      class="banner" 
      indicator-dots 
      autoplay 
      circular
      v-if="banners.length > 0"
    >
      <swiper-item v-for="(item, index) in banners" :key="index">
        <image 
          :src="item.image" 
          mode="aspectFill" 
          class="banner-image"
          @tap="navigateTo(item.url)"
        ></image>
      </swiper-item>
    </swiper>
    
    <!-- 推荐课程 -->
    <view class="section">
      <view class="section-header">
        <text class="section-title">推荐课程</text>
        <text class="more-link" @tap="navigateTo('/pages/course/list?type=recommend')">查看更多</text>
      </view>
      <scroll-view scroll-x class="course-scroll">
        <view class="course-list">
          <course-card 
            v-for="(item, index) in recommendCourses" 
            :key="index"
            :course="item"
            @click="goToCourseDetail(item)"
          ></course-card>
        </view>
      </scroll-view>
    </view>
    
    <!-- 最新课程 -->
    <view class="section">
      <view class="section-header">
        <text class="section-title">最新课程</text>
        <text class="more-link" @tap="navigateTo('/pages/course/list?type=new')">查看更多</text>
      </view>
      <view class="grid-list">
        <course-card 
          v-for="(item, index) in newCourses" 
          :key="index"
          :course="item"
          type="grid"
          @click="goToCourseDetail(item)"
        ></course-card>
      </view>
    </view>
    
    <!-- 学习计划 -->
    <view class="section" v-if="studyPlan.length > 0">
      <view class="section-header">
        <text class="section-title">今日学习计划</text>
        <text class="more-link" @tap="navigateTo('/pages/user/plan')">全部计划</text>
      </view>
      <view class="plan-list">
        <view 
          class="plan-item" 
          v-for="(item, index) in studyPlan" 
          :key="index"
          @tap="continueLearning(item)"
        >
          <image :src="item.course.cover" mode="aspectFill" class="plan-image"></image>
          <view class="plan-info">
            <text class="plan-title">{{item.course.title}}</text>
            <view class="plan-progress">
              <progress 
                :percent="item.progress" 
                stroke-width="4" 
                activeColor="#1aad19"
              ></progress>
              <text class="progress-text">{{item.progress}}%</text>
            </view>
            <text class="plan-desc">{{item.nextChapter}}</text>
          </view>
          <view class="continue-btn">继续学习</view>
        </view>
      </view>
    </view>
  </view>
</template>

<script>
import courseCard from '@/components/course-card/course-card.vue';
import { mapState, mapActions } from 'vuex';

export default {
  components: {
    courseCard
  },
  data() {
    return {
      keyword: '',
      currentCategory: 0,
      studyPlan: []
    }
  },
  computed: {
    ...mapState('course', ['categories', 'banners', 'recommendCourses', 'newCourses'])
  },
  onLoad() {
    this.initData();
  },
  methods: {
    ...mapActions('course', ['getCategories', 'getBanners', 'getCourses']),
    ...mapActions('user', ['getStudyPlan']),
    
    // 初始化数据
    async initData() {
      uni.showLoading({ title: '加载中' });
      
      try {
        await Promise.all([
          this.getCategories(),
          this.getBanners(),
          this.getCourses({ type: 'recommend', limit: 5 }),
          this.getCourses({ type: 'new', limit: 4 }),
          this.loadStudyPlan()
        ]);
      } catch (e) {
        console.error('初始化数据失败', e);
      } finally {
        uni.hideLoading();
      }
    },
    
    // 加载学习计划
    async loadStudyPlan() {
      try {
        this.studyPlan = await this.getStudyPlan();
      } catch (e) {
        console.error('加载学习计划失败', e);
      }
    },
    
    // 切换分类
    async changeCategory(index) {
      this.currentCategory = index;
      
      try {
        const categoryId = this.categories[index].id;
        await this.getCourses({
          categoryId,
          type: 'recommend',
          limit: 5
        });
      } catch (e) {
        console.error('加载分类课程失败', e);
      }
    },
    
    // 搜索课程
    searchCourse() {
      if (!this.keyword.trim()) return;
      
      uni.navigateTo({
        url: `/pages/course/search?keyword=${encodeURIComponent(this.keyword)}`
      });
    },
    
    // 跳转到课程详情
    goToCourseDetail(course) {
      uni.navigateTo({
        url: `/pages/course/detail?id=${course.id}`
      });
    },
    
    // 继续学习
    continueLearning(plan) {
      uni.navigateTo({
        url: `/pages/video/player?courseId=${plan.course.id}&chapterId=${plan.nextChapterId}`
      });
    },
    
    // 通用导航
    navigateTo(url) {
      uni.navigateTo({ url });
    }
  }
}
</script>

3.2 课程卡片组件

课程卡片组件是应用中的基础UI组件,用于在不同场景下展示课程信息。

组件特点

  • 支持横向和网格两种布局模式
  • 展示课程封面、标题、教师、学习人数和价格信息
  • 支持标签显示(如"热门"、"新课"等)

实现代码

vue
<!-- components/course-card/course-card.vue -->
<template>
  <view 
    class="course-card" 
    :class="{ 'grid-type': type === 'grid' }"
  >
    <image :src="course.cover" mode="aspectFill" class="course-image"></image>
    <view class="course-info">
      <text class="course-title">{{course.title}}</text>
      <view class="course-meta">
        <text class="teacher">{{course.teacher}}</text>
        <text class="student-count">{{formatCount(course.studentCount)}}人学习</text>
      </view>
      <view class="course-price">
        <text class="price" v-if="course.price > 0">¥{{course.price}}</text>
        <text class="free" v-else>免费</text>
        <text class="original-price" v-if="course.originalPrice > course.price">¥{{course.originalPrice}}</text>
      </view>
    </view>
    
    <!-- 标签 -->
    <view class="course-tag" v-if="course.tag">{{course.tag}}</view>
  </view>
</template>

<script>
export default {
  props: {
    course: {
      type: Object,
      required: true
    },
    type: {
      type: String,
      default: 'horizontal' // horizontal, grid
    }
  },
  methods: {
    // 格式化学习人数
    formatCount(count) {
      if (count >= 10000) {
        return (count / 10000).toFixed(1) + '万';
      }
      return count;
    }
  }
}
</script>

3.3 视频播放功能

视频播放是教育应用的核心功能,需要支持视频播放控制、进度记录、章节切换等功能。

主要功能

  • 视频播放与控制
  • 播放进度记录与恢复
  • 章节列表与切换
  • 课程笔记功能
  • 问答互动功能

实现代码

js
// store/course.js - 视频相关功能
export default {
  namespaced: true,
  state: {
    // ...其他状态
    currentCourse: null,
    chapters: [],
    currentVideo: null,
    learningProgress: {}
  },
  mutations: {
    // ...其他mutations
    SET_CURRENT_COURSE(state, course) {
      state.currentCourse = course;
    },
    SET_CHAPTERS(state, chapters) {
      state.chapters = chapters;
    },
    SET_CURRENT_VIDEO(state, video) {
      state.currentVideo = video;
    },
    UPDATE_LEARNING_PROGRESS(state, { courseId, chapterId, progress, position }) {
      if (!state.learningProgress[courseId]) {
        state.learningProgress[courseId] = {};
      }
      
      state.learningProgress[courseId][chapterId] = {
        progress,
        position,
        timestamp: Date.now()
      };
    }
  },
  actions: {
    // ...其他actions
    
    // 获取课程详情
    async getCourseDetail({ commit }, courseId) {
      try {
        const { result } = await uniCloud.callFunction({
          name: 'course',
          data: { 
            action: 'getCourseDetail',
            courseId
          }
        });
        
        commit('SET_CURRENT_COURSE', result.data);
        return result.data;
      } catch (e) {
        console.error('获取课程详情失败', e);
        throw e;
      }
    },
    
    // 获取课程章节
    async getCourseChapters({ commit }, courseId) {
      try {
        const { result } = await uniCloud.callFunction({
          name: 'course',
          data: { 
            action: 'getCourseChapters',
            courseId
          }
        });
        
        commit('SET_CHAPTERS', result.data);
        return result.data;
      } catch (e) {
        console.error('获取课程章节失败', e);
        throw e;
      }
    },
    
    // 获取视频信息
    async getVideoInfo({ commit, state }, { courseId, chapterId }) {
      try {
        const { result } = await uniCloud.callFunction({
          name: 'course',
          data: { 
            action: 'getVideoInfo',
            courseId,
            chapterId
          }
        });
        
        // 获取学习进度
        let lastPosition = 0;
        if (state.learningProgress[courseId] && 
            state.learningProgress[courseId][chapterId]) {
          lastPosition = state.learningProgress[courseId][chapterId].position;
        }
        
        const videoInfo = {
          ...result.data,
          lastPosition
        };
        
        commit('SET_CURRENT_VIDEO', videoInfo);
        return videoInfo;
      } catch (e) {
        console.error('获取视频信息失败', e);
        throw e;
      }
    },
    
    // 更新学习进度
    async updateLearningProgress({ commit }, { courseId, chapterId, progress, position }) {
      try {
        // 更新本地状态
        commit('UPDATE_LEARNING_PROGRESS', { 
          courseId, 
          chapterId, 
          progress, 
          position 
        });
        
        // 同步到服务器
        await uniCloud.callFunction({
          name: 'user',
          data: { 
            action: 'updateLearningProgress',
            courseId,
            chapterId,
            progress,
            position
          }
        });
      } catch (e) {
        console.error('更新学习进度失败', e);
      }
    }
  }
};
vue
<!-- pages/video/player.vue - 视频播放页面 -->
<template>
  <view class="video-page">
    <!-- 视频播放器 -->
    <view class="video-container">
      <video
        id="myVideo"
        :src="currentVideo.url"
        :poster="currentVideo.poster"
        :controls="true"
        :show-center-play-btn="true"
        :enable-progress-gesture="true"
        :show-fullscreen-btn="true"
        :show-play-btn="true"
        :show-progress="true"
        :initial-time="currentVideo.lastPosition || 0"
        @timeupdate="onTimeUpdate"
        @play="onPlay"
        @pause="onPause"
        @ended="onEnded"
      ></video>
    </view>
    
    <!-- 视频信息 -->
    <view class="video-info">
      <text class="video-title">{{currentVideo.title}}</text>
      <view class="video-meta">
        <text class="chapter-info">{{courseInfo.title}} · 第{{currentChapterIndex + 1}}章</text>
        <text class="duration">{{formatTime(currentVideo.duration)}}</text>
      </view>
    </view>
    
    <!-- 操作按钮 -->
    <view class="action-bar">
      <view class="action-item" @tap="toggleCollect">
        <text class="iconfont" :class="isCollected ? 'icon-star-filled' : 'icon-star'"></text>
        <text>收藏</text>
      </view>
      <view class="action-item" @tap="showNotePanel">
        <text class="iconfont icon-note"></text>
        <text>笔记</text>
      </view>
      <view class="action-item" @tap="downloadVideo">
        <text class="iconfont icon-download"></text>
        <text>下载</text>
      </view>
      <view class="action-item" @tap="shareVideo">
        <text class="iconfont icon-share"></text>
        <text>分享</text>
      </view>
    </view>
    
    <!-- 选项卡 -->
    <view class="tabs">
      <view 
        class="tab-item" 
        :class="{ active: activeTab === 'chapters' }"
        @tap="switchTab('chapters')"
      >
        <text>章节</text>
      </view>
      <view 
        class="tab-item" 
        :class="{ active: activeTab === 'intro' }"
        @tap="switchTab('intro')"
      >
        <text>简介</text>
      </view>
      <view 
        class="tab-item" 
        :class="{ active: activeTab === 'qa' }"
        @tap="switchTab('qa')"
      >
        <text>问答</text>
      </view>
    </view>
    
    <!-- 选项卡内容 -->
    <swiper 
      class="tab-content" 
      :current="tabIndex" 
      @change="onSwiperChange"
    >
      <!-- 章节列表 -->
      <swiper-item>
        <scroll-view scroll-y class="chapter-list">
          <view 
            class="chapter-item" 
            v-for="(chapter, index) in chapters" 
            :key="index"
            :class="{ active: currentChapterId === chapter.id }"
            @tap="switchChapter(chapter)"
          >
            <view class="chapter-info">
              <text class="chapter-title">{{chapter.title}}</text>
              <view class="chapter-meta">
                <text class="duration">{{formatTime(chapter.duration)}}</text>
                <text class="status" v-if="chapter.progress === 100">已学完</text>
                <text class="status" v-else-if="chapter.progress > 0">已学{{chapter.progress}}%</text>
              </view>
            </view>
            <text class="iconfont icon-play" v-if="currentChapterId === chapter.id"></text>
            <text class="iconfont icon-lock" v-else-if="chapter.locked"></text>
          </view>
        </scroll-view>
      </swiper-item>
      
      <!-- 课程简介 -->
      <swiper-item>
        <scroll-view scroll-y class="intro-content">
          <rich-text :nodes="courseInfo.description"></rich-text>
          
          <view class="section-title">课程目标</view>
          <view class="goal-list">
            <view class="goal-item" v-for="(goal, index) in courseInfo.goals" :key="index">
              <text class="goal-index">{{index + 1}}</text>
              <text class="goal-text">{{goal}}</text>
            </view>
          </view>
          
          <view class="section-title">适合人群</view>
          <view class="target-list">
            <view class="target-item" v-for="(target, index) in courseInfo.targetAudience" :key="index">
              <text class="iconfont icon-check"></text>
              <text>{{target}}</text>
            </view>
          </view>
        </scroll-view>
      </swiper-item>
      
      <!-- 问答区 -->
      <swiper-item>
        <scroll-view scroll-y class="qa-content">
          <view class="qa-header">
            <text class="qa-title">课程问答</text>
            <button class="ask-btn" @tap="showAskModal">我要提问</button>
          </view>
          
          <view class="qa-list">
            <view class="qa-item" v-for="(item, index) in questions" :key="index" @tap="goToQuestionDetail(item)">
              <view class="question">
                <text class="q-icon">问</text>
                <text class="q-content">{{item.content}}</text>
              </view>
              <view class="answer" v-if="item.answer">
                <text class="a-icon">答</text>
                <text class="a-content">{{item.answer}}</text>
              </view>
              <view class="qa-meta">
                <text class="time">{{formatDate(item.createTime)}}</text>
                <text class="reply-count" v-if="item.replyCount > 0">{{item.replyCount}}回复</text>
              </view>
            </view>
          </view>
        </scroll-view>
      </swiper-item>
    </swiper>
  </view>
</template>

<script>
import { mapState, mapActions } from 'vuex';
import { formatTime, formatDate } from '@/utils/time';

export default {
  data() {
    return {
      courseId: '',
      currentChapterId: '',
      currentChapterIndex: 0,
      courseInfo: {},
      videoContext: null,
      currentTime: 0,
      isCollected: false,
      activeTab: 'chapters',
      tabIndex: 0,
      questions: []
    }
  },
  computed: {
    ...mapState('course', ['chapters', 'currentVideo'])
  },
  onLoad(options) {
    this.courseId = options.courseId;
    this.currentChapterId = options.chapterId;
    
    this.initData();
  },
  onReady() {
    this.videoContext = uni.createVideoContext('myVideo', this);
  },
  methods: {
    ...mapActions('course', [
      'getCourseDetail', 
      'getCourseChapters', 
      'getVideoInfo',
      'updateLearningProgress'
    ]),
    
    // 初始化数据
    async initData() {
      uni.showLoading({ title: '加载中' });
      
      try {
        // 获取课程详情
        this.courseInfo = await this.getCourseDetail(this.courseId);
        
        // 获取章节列表
        const chapters = await this.getCourseChapters(this.courseId);
        
        // 获取当前章节索引
        this.currentChapterIndex = chapters.findIndex(chapter => chapter.id === this.currentChapterId);
        if (this.currentChapterIndex === -1) {
          this.currentChapterIndex = 0;
          this.currentChapterId = chapters[0].id;
        }
        
        // 获取视频信息
        await this.getVideoInfo({
          courseId: this.courseId,
          chapterId: this.currentChapterId
        });
        
        // 加载问答列表
        await this.loadQuestions();
      } catch (e) {
        console.error('初始化数据失败', e);
        uni.showToast({
          title: '加载失败,请重试',
          icon: 'none'
        });
      } finally {
        uni.hideLoading();
      }
    },
    
    // 加载问答列表
    async loadQuestions() {
      try {
        const { result } = await uniCloud.callFunction({
          name: 'course',
          data: { 
            action: 'getCourseQuestions',
            courseId: this.courseId,
            chapterId: this.currentChapterId
          }
        });
        
        this.questions = result.data;
      } catch (e) {
        console.error('加载问答失败', e);
      }
    },
    
    // 视频播放时间更新
    onTimeUpdate(e) {
      this.currentTime = e.detail.currentTime;
      
      // 每30秒更新一次学习进度
      if (Math.floor(this.currentTime) % 30 === 0 && this.currentTime > 0) {
        this.updateProgress();
      }
    },
    
    // 更新学习进度
    updateProgress() {
      const duration = this.currentVideo.duration;
      const progress = Math.floor((this.currentTime / duration) * 100);
      
      this.updateLearningProgress({
        courseId: this.courseId,
        chapterId: this.currentChapterId,
        progress: progress,
        position: this.currentTime
      });
    },
    
    // 视频播放
    onPlay() {
      console.log('视频开始播放');
    },
    
    // 视频暂停
    onPause() {
      // 暂停时更新进度
      this.updateProgress();
    },
    
    // 视频播放结束
    onEnded() {
      // 更新进度为100%
      this.updateLearningProgress({
        courseId: this.courseId,
        chapterId: this.currentChapterId,
        progress: 100,
        position: this.currentVideo.duration
      });
      
      // 提示播放下一章
      if (this.currentChapterIndex < this.chapters.length - 1) {
        uni.showModal({
          title: '提示',
          content: '当前章节已学习完成,是否继续学习下一章节?',
          success: (res) => {
            if (res.confirm) {
              this.playNextChapter();
            }
          }
        });
      } else {
        uni.showToast({
          title: '恭喜您,课程已学习完成!',
          icon: 'none'
        });
      }
    },
    
    // 播放下一章
    playNextChapter() {
      if (this.currentChapterIndex < this.chapters.length - 1) {
        const nextChapter = this.chapters[this.currentChapterIndex + 1];
        
        // 检查章节是否锁定
        if (nextChapter.locked) {
          uni.showToast({
            title: '该章节暂未解锁',
            icon: 'none'
          });
          return;
        }
        
        this.currentChapterIndex++;
        this.currentChapterId = nextChapter.id;
        
        // 加载下一章视频
        this.getVideoInfo({
          courseId: this.courseId,
          chapterId: this.currentChapterId
        });
      }
    },
    
    // 切换章节
    switchChapter(chapter) {
      if (chapter.locked) {
        uni.showToast({
          title: '该章节暂未解锁',
          icon: 'none'
        });
        return;
      }
      
      if (chapter.id === this.currentChapterId) {
        return;
      }
      
      // 更新当前进度
      this.updateProgress();
      
      // 切换章节
      this.currentChapterId = chapter.id;
      this.currentChapterIndex = this.chapters.findIndex(item => item.id === chapter.id);
      
      // 加载新章节视频
      this.getVideoInfo({
        courseId: this.courseId,
        chapterId: this.currentChapterId
      });
    },
    
    // 切换选项卡
    switchTab(tab) {
      this.activeTab = tab;
      this.tabIndex = tab === 'chapters' ? 0 : (tab === 'intro' ? 1 : 2);
    },
    
    // 轮播图切换
    onSwiperChange(e) {
      const index = e.detail.current;
      this.tabIndex = index;
      this.activeTab = index === 0 ? 'chapters' : (index === 1 ? 'intro' : 'qa');
    },
    
    // 显示笔记面板
    showNotePanel() {
      // 暂停视频
      this.videoContext.pause();
      
      // 显示笔记面板
      this.$refs.notePopup.open();
    },
    
    // 显示提问面板
    showAskModal() {
      // 暂停视频
      this.videoContext.pause();
      
      // 显示提问面板
      this.$refs.askPopup.open();
    },
    
    // 格式化时间
    formatTime,
    formatDate
  }
}
</script>

3.4 在线测验功能

在线测验是教育应用中重要的学习评估功能,包括多种题型支持、自动评分和错题收集等功能。

主要功能

  • 多种题型支持(单选、多选、判断题等)
  • 答题进度记录
  • 自动评分与解析
  • 错题收集与复习

实现代码

js
// store/quiz.js - 测验相关功能
export default {
  namespaced: true,
  state: {
    quizList: [],
    currentQuiz: null,
    questions: [],
    userAnswers: {},
    quizResult: null
  },
  mutations: {
    SET_QUIZ_LIST(state, quizList) {
      state.quizList = quizList;
    },
    SET_CURRENT_QUIZ(state, quiz) {
      state.currentQuiz = quiz;
    },
    SET_QUESTIONS(state, questions) {
      state.questions = questions;
    },
    SET_USER_ANSWER(state, { questionId, answer }) {
      state.userAnswers[questionId] = answer;
    },
    CLEAR_USER_ANSWERS(state) {
      state.userAnswers = {};
    },
    SET_QUIZ_RESULT(state, result) {
      state.quizResult = result;
    }
  },
  actions: {
    // 获取课程测验列表
    async getQuizList({ commit }, courseId) {
      try {
        const { result } = await uniCloud.callFunction({
          name: 'quiz',
          data: { 
            action: 'getQuizList',
            courseId
          }
        });
        
        commit('SET_QUIZ_LIST', result.data);
        return result.data;
      } catch (e) {
        console.error('获取测验列表失败', e);
        throw e;
      }
    },
    
    // 获取测验详情
    async getQuizDetail({ commit }, quizId) {
      try {
        const { result } = await uniCloud.callFunction({
          name: 'quiz',
          data: { 
            action: 'getQuizDetail',
            quizId
          }
        });
        
        commit('SET_CURRENT_QUIZ', result.data);
        return result.data;
      } catch (e) {
        console.error('获取测验详情失败', e);
        throw e;
      }
    },
    
    // 获取测验题目
    async getQuizQuestions({ commit }, quizId) {
      try {
        const { result } = await uniCloud.callFunction({
          name: 'quiz',
          data: { 
            action: 'getQuizQuestions',
            quizId
          }
        });
        
        commit('SET_QUESTIONS', result.data);
        return result.data;
      } catch (e) {
        console.error('获取测验题目失败', e);
        throw e;
      }
    },
    
    // 保存用户答案
    saveUserAnswer({ commit }, { questionId, answer }) {
      commit('SET_USER_ANSWER', { questionId, answer });
    },
    
    // 提交测验
    async submitQuiz({ commit, state }, quizId) {
      try {
        const { result } = await uniCloud.callFunction({
          name: 'quiz',
          data: { 
            action: 'submitQuiz',
            quizId,
            answers: state.userAnswers
          }
        });
        
        commit('SET_QUIZ_RESULT', result.data);
        return result.data;
      } catch (e) {
        console.error('提交测验失败', e);
        throw e;
      }
    },
    
    // 清除测验数据
    clearQuizData({ commit }) {
      commit('CLEAR_USER_ANSWERS');
      commit('SET_QUIZ_RESULT', null);
    }
  }
};
vue
<!-- pages/quiz/quiz.vue - 测验页面 -->
<template>
  <view class="quiz-page">
    <!-- 顶部信息 -->
    <view class="quiz-header">
      <view class="quiz-title">{{currentQuiz.title}}</view>
      <view class="quiz-meta">
        <text class="quiz-count">共{{questions.length}}题</text>
        <text class="quiz-time">时间:{{currentQuiz.timeLimit}}分钟</text>
      </view>
      <view class="progress-bar">
        <progress 
          :percent="progressPercent" 
          stroke-width="4" 
          activeColor="#1aad19"
        ></progress>
        <text class="progress-text">{{answeredCount}}/{{questions.length}}</text>
      </view>
    </view>
    
    <!-- 题目列表 -->
    <swiper 
      class="question-swiper" 
      :current="currentIndex" 
      @change="onQuestionChange"
      :disable-touch="true"
    >
      <swiper-item v-for="(question, index) in questions" :key="question.id">
        <scroll-view scroll-y class="question-scroll">
          <view class="question-container">
            <!-- 题目标题 -->
            <view class="question-header">
              <text class="question-type">{{getQuestionType(question.type)}}</text>
              <text class="question-index">第 {{index + 1}} 题</text>
            </view>
            
            <!-- 题目内容 -->
            <view class="question-content">
              <rich-text :nodes="question.content"></rich-text>
              
              <!-- 题目图片 -->
              <image 
                v-if="question.image" 
                :src="question.image" 
                mode="widthFix" 
                class="question-image"
                @tap="previewImage(question.image)"
              ></image>
            </view>
            
            <!-- 选项 - 单选题 -->
            <view class="options-list" v-if="question.type === 'single'">
              <view 
                class="option-item" 
                v-for="(option, optIndex) in question.options" 
                :key="optIndex"
                :class="{ active: userAnswers[question.id] === option.value }"
                @tap="selectOption(question.id, option.value)"
              >
                <text class="option-label">{{optionLabels[optIndex]}}</text>
                <text class="option-content">{{option.text}}</text>
              </view>
            </view>
            
            <!-- 选项 - 多选题 -->
            <view class="options-list" v-else-if="question.type === 'multiple'">
              <view 
                class="option-item" 
                v-for="(option, optIndex) in question.options" 
                :key="optIndex"
                :class="{ active: isOptionSelected(question.id, option.value) }"
                @tap="toggleOption(question.id, option.value)"
              >
                <text class="option-label">{{optionLabels[optIndex]}}</text>
                <text class="option-content">{{option.text}}</text>
              </view>
            </view>
            
            <!-- 选项 - 判断题 -->
            <view class="options-list" v-else-if="question.type === 'boolean'">
              <view 
                class="option-item" 
                :class="{ active: userAnswers[question.id] === true }"
                @tap="selectOption(question.id, true)"
              >
                <text class="option-label">A</text>
                <text class="option-content">正确</text>
              </view>
              <view 
                class="option-item" 
                :class="{ active: userAnswers[question.id] === false }"
                @tap="selectOption(question.id, false)"
              >
                <text class="option-label">B</text>
                <text class="option-content">错误</text>
              </view>
            </view>
          </view>
        </scroll-view>
      </swiper-item>
    </swiper>
    
    <!-- 底部操作栏 -->
    <view class="action-bar">
      <button 
        class="prev-btn" 
        :disabled="currentIndex === 0"
        @tap="prevQuestion"
      >上一题</button>
      
      <view class="question-dots">
        <view 
          class="dot" 
          v-for="(question, index) in questions" 
          :key="index"
          :class="{ 
            active: index === currentIndex,
            answered: userAnswers[question.id] !== undefined
          }"
          @tap="goToQuestion(index)"
        ></view>
      </view>
      
      <button 
        class="next-btn" 
        v-if="currentIndex < questions.length - 1"
        @tap="nextQuestion"
      >下一题</button>
      
      <button 
        class="submit-btn" 
        v-else
        @tap="showSubmitConfirm"
      >提交</button>
    </view>
  </view>
</template>

<script>
import { mapState, mapActions } from 'vuex';

export default {
  data() {
    return {
      quizId: '',
      currentIndex: 0,
      timer: null,
      remainingTime: 0,
      optionLabels: ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']
    }
  },
  computed: {
    ...mapState('quiz', ['currentQuiz', 'questions', 'userAnswers']),
    
    // 已答题数量
    answeredCount() {
      return Object.keys(this.userAnswers).length;
    },
    
    // 进度百分比
    progressPercent() {
      return (this.answeredCount / this.questions.length) * 100;
    }
  },
  onLoad(options) {
    this.quizId = options.id;
    this.initQuiz();
  },
  onUnload() {
    // 清除定时器
    if (this.timer) {
      clearInterval(this.timer);
    }
  },
  methods: {
    ...mapActions('quiz', [
      'getQuizDetail', 
      'getQuizQuestions', 
      'saveUserAnswer',
      'submitQuiz',
      'clearQuizData'
    ]),
    
    // 初始化测验
    async initQuiz() {
      uni.showLoading({ title: '加载中' });
      
      try {
        // 清除之前的测验数据
        this.clearQuizData();
        
        // 获取测验详情
        const quizDetail = await this.getQuizDetail(this.quizId);
        
        // 获取测验题目
        await this.getQuizQuestions(this.quizId);
        
        // 设置倒计时
        if (quizDetail.timeLimit > 0) {
          this.remainingTime = quizDetail.timeLimit * 60;
          this.startTimer();
        }
      } catch (e) {
        console.error('初始化测验失败', e);
        uni.showToast({
          title: '加载失败,请重试',
          icon: 'none'
        });
      } finally {
        uni.hideLoading();
      }
    },
    
    // 开始计时器
    startTimer() {
      this.timer = setInterval(() => {
        this.remainingTime--;
        
        if (this.remainingTime <= 0) {
          clearInterval(this.timer);
          this.autoSubmit();
        }
      }, 1000);
    },
    
    // 自动提交
    autoSubmit() {
      uni.showModal({
        title: '提示',
        content: '测验时间已到,系统将自动提交您的答案',
        showCancel: false,
        success: () => {
          this.submitQuizAnswer();
        }
      });
    },
    
    // 获取题型名称
    getQuestionType(type) {
      const typeMap = {
        'single': '单选题',
        'multiple': '多选题',
        'boolean': '判断题'
      };
      return typeMap[type] || '未知题型';
    },
    
    // 选择选项(单选、判断)
    selectOption(questionId, value) {
      this.saveUserAnswer({ questionId, answer: value });
    },
    
    // 切换选项(多选)
    toggleOption(questionId, value) {
      let currentAnswers = this.userAnswers[questionId] || [];
      
      if (!Array.isArray(currentAnswers)) {
        currentAnswers = [];
      }
      
      const index = currentAnswers.indexOf(value);
      
      if (index === -1) {
        currentAnswers.push(value);
      } else {
        currentAnswers.splice(index, 1);
      }
      
      this.saveUserAnswer({ questionId, answer: currentAnswers });
    },
    
    // 检查选项是否被选中(多选)
    isOptionSelected(questionId, value) {
      const answers = this.userAnswers[questionId];
      return Array.isArray(answers) && answers.includes(value);
    },
    
    // 上一题
    prevQuestion() {
      if (this.currentIndex > 0) {
        this.currentIndex--;
      }
    },
    
    // 下一题
    nextQuestion() {
      if (this.currentIndex < this.questions.length - 1) {
        this.currentIndex++;
      }
    },
    
    // 跳转到指定题目
    goToQuestion(index) {
      this.currentIndex = index;
    },
    
    // 题目切换事件
    onQuestionChange(e) {
      this.currentIndex = e.detail.current;
    },
    
    // 预览图片
    previewImage(url) {
      uni.previewImage({
        urls: [url]
      });
    },
    
    // 显示提交确认
    showSubmitConfirm() {
      const unansweredCount = this.questions.length - this.answeredCount;
      
      if (unansweredCount > 0) {
        uni.showModal({
          title: '提示',
          content: `您还有 ${unansweredCount} 题未作答,确定要提交吗?`,
          success: (res) => {
            if (res.confirm) {
              this.submitQuizAnswer();
            }
          }
        });
      } else {
        uni.showModal({
          title: '提示',
          content: '确定要提交测验吗?',
          success: (res) => {
            if (res.confirm) {
              this.submitQuizAnswer();
            }
          }
        });
      }
    },
    
    // 提交测验答案
    async submitQuizAnswer() {
      uni.showLoading({ title: '提交中' });
      
      try {
        const result = await this.submitQuiz(this.quizId);
        
        // 清除计时器
        if (this.timer) {
          clearInterval(this.timer);
        }
        
        // 跳转到结果页
        uni.redirectTo({
          url: `/pages/quiz/result?id=${this.quizId}`
        });
      } catch (e) {
        console.error('提交测验失败', e);
        uni.showToast({
          title: '提交失败,请重试',
          icon: 'none'
        });
      } finally {
        uni.hideLoading();
      }
    }
  }
}
</script>

4. 总结与最佳实践

4.1 性能优化

  • 组件懒加载:对于复杂组件,使用异步组件进行懒加载
  • 数据缓存:合理使用本地存储缓存不常变化的数据
  • 图片优化:使用适当的图片格式和大小,避免加载过大的图片
  • 减少重渲染:合理使用计算属性和缓存,避免不必要的重渲染

4.2 跨端适配

  • 条件编译:使用条件编译处理不同平台的差异
  • 统一样式:使用 flex 布局和相对单位(rpx)实现跨端一致的界面
  • 平台判断:根据平台特性提供不同的交互方式

4.3 用户体验优化

  • 骨架屏:在数据加载过程中显示骨架屏,提升用户体验
  • 下拉刷新和上拉加载:实现列表的分页加载和刷新功能
  • 状态反馈:操作过程中提供明确的状态反馈
  • 离线支持:支持课程内容的离线缓存和学习

4.4 安全性考虑

  • 数据加密:敏感数据传输和存储时进行加密
  • 权限控制:严格控制用户权限,防止未授权访问
  • 输入验证:对用户输入进行严格验证,防止注入攻击

通过以上实践,可以开发出功能完善、体验良好的教育应用,满足用户的学习需求,提供优质的教育服务。

一次开发,多端部署 - 让跨平台开发更简单