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)"
        />
      </swiper-item>
    </swiper>
    
    <!-- 推薦課程 -->
    <view class="course-section">
      <view class="section-header">
        <text class="section-title">推薦課程</text>
        <text class="more-btn" @tap="viewMore('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"
            @tap="navigateToCourse(item.id)"
          />
        </view>
      </scroll-view>
    </view>
    
    <!-- 最新課程 -->
    <view class="course-section">
      <view class="section-header">
        <text class="section-title">最新課程</text>
        <text class="more-btn" @tap="viewMore('new')">更多</text>
      </view>
      <view class="course-grid">
        <course-card 
          v-for="(item, index) in newCourses" 
          :key="index"
          :course="item"
          @tap="navigateToCourse(item.id)"
        />
      </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
    }
  },
  computed: {
    ...mapState('course', ['categories', 'banners', 'recommendCourses', 'newCourses'])
  },
  onLoad() {
    this.initData();
  },
  methods: {
    ...mapActions('course', ['getCategories', 'getBanners', 'getCourses']),
    
    // 初始化資料
    async initData() {
      try {
        await Promise.all([
          this.getCategories(),
          this.getBanners(),
          this.getCourses({ type: 'recommend', limit: 10 }),
          this.getCourses({ type: 'new', limit: 6 })
        ]);
      } catch (e) {
        uni.showToast({
          title: '載入失敗',
          icon: 'none'
        });
      }
    },
    
    // 搜尋課程
    searchCourse() {
      if (!this.keyword.trim()) {
        return;
      }
      
      uni.navigateTo({
        url: `/pages/search/search?keyword=${encodeURIComponent(this.keyword)}`
      });
    },
    
    // 切換分類
    changeCategory(index) {
      this.currentCategory = index;
      // 根據分類載入課程
      this.getCourses({
        categoryId: this.categories[index].id,
        type: 'category'
      });
    },
    
    // 跳轉到課程詳情
    navigateToCourse(courseId) {
      uni.navigateTo({
        url: `/pages/course/course?id=${courseId}`
      });
    },
    
    // 查看更多
    viewMore(type) {
      uni.navigateTo({
        url: `/pages/course-list/course-list?type=${type}`
      });
    },
    
    // 通用跳轉
    navigateTo(url) {
      if (url) {
        uni.navigateTo({ url });
      }
    }
  }
}
</script>

3.2 影片播放功能

影片播放是教育應用的核心功能,需要支援播放控制、進度記錄、倍速播放等功能。

vue
<!-- components/video-player/video-player.vue - 影片播放器元件 -->
<template>
  <view class="video-player">
    <video 
      :src="videoUrl"
      :poster="poster"
      :initial-time="initialTime"
      :controls="showControls"
      :autoplay="autoplay"
      :loop="loop"
      :muted="muted"
      :page-gesture="pageGesture"
      :direction="direction"
      :show-progress="showProgress"
      :show-fullscreen-btn="showFullscreenBtn"
      :show-play-btn="showPlayBtn"
      :show-center-play-btn="showCenterPlayBtn"
      :enable-progress-gesture="enableProgressGesture"
      :object-fit="objectFit"
      :play-btn-position="playBtnPosition"
      @play="onPlay"
      @pause="onPause"
      @ended="onEnded"
      @timeupdate="onTimeUpdate"
      @fullscreenchange="onFullscreenChange"
      @waiting="onWaiting"
      @error="onError"
      class="video"
    />
    
    <!-- 自訂控制欄 -->
    <view class="custom-controls" v-if="showCustomControls">
      <view class="progress-bar">
        <slider 
          :value="progress" 
          :max="duration"
          @changing="onProgressChanging"
          @change="onProgressChange"
          activeColor="#007aff"
          backgroundColor="rgba(255,255,255,0.3)"
          block-size="12"
        />
      </view>
      
      <view class="control-buttons">
        <text class="time-display">{{formatTime(currentTime)}} / {{formatTime(duration)}}</text>
        
        <view class="speed-control">
          <text class="speed-text" @tap="showSpeedModal">{{playbackRate}}x</text>
        </view>
        
        <text class="iconfont icon-fullscreen" @tap="toggleFullscreen"></text>
      </view>
    </view>
    
    <!-- 倍速選擇彈窗 -->
    <uni-popup ref="speedPopup" type="bottom">
      <view class="speed-popup">
        <view class="popup-header">
          <text class="popup-title">播放速度</text>
          <text class="close-btn" @tap="closeSpeedModal">×</text>
        </view>
        <view class="speed-list">
          <view 
            class="speed-item"
            :class="{ active: item === playbackRate }"
            v-for="item in speedOptions"
            :key="item"
            @tap="changeSpeed(item)"
          >
            {{item}}x
          </view>
        </view>
      </view>
    </uni-popup>
  </view>
</template>

<script>
import uniPopup from '@/components/uni-popup/uni-popup.vue';

export default {
  components: {
    uniPopup
  },
  props: {
    videoUrl: {
      type: String,
      required: true
    },
    poster: {
      type: String,
      default: ''
    },
    autoplay: {
      type: Boolean,
      default: false
    },
    courseId: {
      type: String,
      required: true
    },
    chapterId: {
      type: String,
      required: true
    }
  },
  data() {
    return {
      initialTime: 0,
      currentTime: 0,
      duration: 0,
      progress: 0,
      playbackRate: 1,
      isPlaying: false,
      isFullscreen: false,
      showControls: true,
      showCustomControls: false,
      speedOptions: [0.5, 0.75, 1, 1.25, 1.5, 2],
      
      // 播放器設定
      loop: false,
      muted: false,
      pageGesture: false,
      direction: 0,
      showProgress: true,
      showFullscreenBtn: true,
      showPlayBtn: true,
      showCenterPlayBtn: true,
      enableProgressGesture: true,
      objectFit: 'contain',
      playBtnPosition: 'bottom'
    }
  },
  mounted() {
    this.loadProgress();
  },
  beforeDestroy() {
    this.saveProgress();
  },
  methods: {
    // 載入播放進度
    async loadProgress() {
      try {
        const progress = uni.getStorageSync(`video_progress_${this.courseId}_${this.chapterId}`);
        if (progress) {
          this.initialTime = progress.currentTime || 0;
        }
      } catch (e) {
        console.error('載入播放進度失敗', e);
      }
    },
    
    // 儲存播放進度
    saveProgress() {
      try {
        const progressData = {
          currentTime: this.currentTime,
          duration: this.duration,
          progress: this.progress,
          timestamp: Date.now()
        };
        
        uni.setStorageSync(`video_progress_${this.courseId}_${this.chapterId}`, progressData);
        
        // 同步到雲端
        this.syncProgressToCloud(progressData);
      } catch (e) {
        console.error('儲存播放進度失敗', e);
      }
    },
    
    // 同步進度到雲端
    async syncProgressToCloud(progressData) {
      try {
        await uniCloud.callFunction({
          name: 'learning',
          data: {
            action: 'updateProgress',
            courseId: this.courseId,
            chapterId: this.chapterId,
            progress: progressData
          }
        });
      } catch (e) {
        console.error('同步進度失敗', e);
      }
    },
    
    // 播放事件
    onPlay() {
      this.isPlaying = true;
      this.$emit('play');
    },
    
    // 暫停事件
    onPause() {
      this.isPlaying = false;
      this.saveProgress();
      this.$emit('pause');
    },
    
    // 播放結束
    onEnded() {
      this.isPlaying = false;
      this.saveProgress();
      this.$emit('ended');
      
      // 標記章節為已完成
      this.markChapterCompleted();
    },
    
    // 時間更新
    onTimeUpdate(e) {
      this.currentTime = e.detail.currentTime;
      this.duration = e.detail.duration;
      this.progress = this.currentTime;
      
      // 每30秒儲存一次進度
      if (Math.floor(this.currentTime) % 30 === 0) {
        this.saveProgress();
      }
    },
    
    // 全螢幕變化
    onFullscreenChange(e) {
      this.isFullscreen = e.detail.fullScreen;
    },
    
    // 載入中
    onWaiting() {
      // 顯示載入狀態
    },
    
    // 播放錯誤
    onError(e) {
      console.error('影片播放錯誤', e);
      uni.showToast({
        title: '播放失敗',
        icon: 'none'
      });
    },
    
    // 進度條拖動中
    onProgressChanging(e) {
      this.progress = e.detail.value;
    },
    
    // 進度條拖動結束
    onProgressChange(e) {
      this.currentTime = e.detail.value;
      // 跳轉到指定時間
      this.seekTo(this.currentTime);
    },
    
    // 跳轉到指定時間
    seekTo(time) {
      const videoContext = uni.createVideoContext('video', this);
      videoContext.seek(time);
    },
    
    // 顯示倍速選擇
    showSpeedModal() {
      this.$refs.speedPopup.open();
    },
    
    // 關閉倍速選擇
    closeSpeedModal() {
      this.$refs.speedPopup.close();
    },
    
    // 改變播放速度
    changeSpeed(speed) {
      this.playbackRate = speed;
      const videoContext = uni.createVideoContext('video', this);
      videoContext.playbackRate(speed);
      this.closeSpeedModal();
    },
    
    // 切換全螢幕
    toggleFullscreen() {
      const videoContext = uni.createVideoContext('video', this);
      if (this.isFullscreen) {
        videoContext.exitFullScreen();
      } else {
        videoContext.requestFullScreen();
      }
    },
    
    // 格式化時間
    formatTime(seconds) {
      const mins = Math.floor(seconds / 60);
      const secs = Math.floor(seconds % 60);
      return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
    },
    
    // 標記章節完成
    async markChapterCompleted() {
      try {
        await uniCloud.callFunction({
          name: 'learning',
          data: {
            action: 'completeChapter',
            courseId: this.courseId,
            chapterId: this.chapterId
          }
        });
        
        this.$emit('chapterCompleted', this.chapterId);
      } catch (e) {
        console.error('標記章節完成失敗', e);
      }
    }
  }
}
</script>

3.3 測驗系統

測驗系統用於檢驗學習效果,支援多種題型和即時評分。

vue
<!-- pages/quiz/quiz.vue - 測驗頁面 -->
<template>
  <view class="quiz-page">
    <!-- 頂部進度 -->
    <view class="quiz-header">
      <view class="progress-info">
        <text>第 {{currentIndex + 1}} 題 / 共 {{questions.length}} 題</text>
      </view>
      <view class="progress-bar">
        <view 
          class="progress-fill" 
          :style="{ width: progressPercent + '%' }"
        ></view>
      </view>
      <view class="time-info" v-if="timeLimit > 0">
        <text class="iconfont icon-time"></text>
        <text>{{formatTime(remainingTime)}}</text>
      </view>
    </view>
    
    <!-- 題目內容 -->
    <view class="question-content" v-if="currentQuestion">
      <view class="question-text">
        <rich-text :nodes="currentQuestion.content"></rich-text>
      </view>
      
      <!-- 圖片 -->
      <image 
        v-if="currentQuestion.image" 
        :src="currentQuestion.image" 
        mode="widthFix"
        class="question-image"
      />
      
      <!-- 選項 -->
      <view class="options">
        <!-- 單選題 -->
        <view 
          v-if="currentQuestion.type === 'single'"
          class="option-item"
          :class="{ 
            selected: selectedAnswers[currentIndex] === option.key,
            correct: showResult && option.key === currentQuestion.correctAnswer,
            wrong: showResult && selectedAnswers[currentIndex] === option.key && option.key !== currentQuestion.correctAnswer
          }"
          v-for="option in currentQuestion.options"
          :key="option.key"
          @tap="selectOption(option.key)"
        >
          <view class="option-mark">{{option.key}}</view>
          <text class="option-text">{{option.text}}</text>
        </view>
        
        <!-- 多選題 -->
        <view 
          v-if="currentQuestion.type === 'multiple'"
          class="option-item"
          :class="{ 
            selected: isOptionSelected(option.key),
            correct: showResult && currentQuestion.correctAnswers.includes(option.key),
            wrong: showResult && isOptionSelected(option.key) && !currentQuestion.correctAnswers.includes(option.key)
          }"
          v-for="option in currentQuestion.options"
          :key="option.key"
          @tap="toggleOption(option.key)"
        >
          <view class="option-mark multiple">
            <text class="iconfont" :class="isOptionSelected(option.key) ? 'icon-check' : 'icon-uncheck'"></text>
          </view>
          <text class="option-text">{{option.text}}</text>
        </view>
        
        <!-- 判斷題 -->
        <view v-if="currentQuestion.type === 'judge'" class="judge-options">
          <view 
            class="judge-option"
            :class="{ 
              selected: selectedAnswers[currentIndex] === true,
              correct: showResult && currentQuestion.correctAnswer === true,
              wrong: showResult && selectedAnswers[currentIndex] === true && currentQuestion.correctAnswer !== true
            }"
            @tap="selectJudge(true)"
          >
            <text class="iconfont icon-check"></text>
            <text>正確</text>
          </view>
          <view 
            class="judge-option"
            :class="{ 
              selected: selectedAnswers[currentIndex] === false,
              correct: showResult && currentQuestion.correctAnswer === false,
              wrong: showResult && selectedAnswers[currentIndex] === false && currentQuestion.correctAnswer !== false
            }"
            @tap="selectJudge(false)"
          >
            <text class="iconfont icon-close"></text>
            <text>錯誤</text>
          </view>
        </view>
        
        <!-- 填空題 -->
        <view v-if="currentQuestion.type === 'fill'" class="fill-inputs">
          <view 
            v-for="(blank, index) in currentQuestion.blanks"
            :key="index"
            class="fill-item"
          >
            <text class="fill-label">第 {{index + 1}} 空:</text>
            <input 
              type="text"
              v-model="fillAnswers[index]"
              :placeholder="blank.placeholder || '請輸入答案'"
              class="fill-input"
              :class="{ 
                correct: showResult && fillAnswers[index] === blank.answer,
                wrong: showResult && fillAnswers[index] !== blank.answer
              }"
            />
          </view>
        </view>
      </view>
      
      <!-- 解析 -->
      <view class="explanation" v-if="showResult && currentQuestion.explanation">
        <view class="explanation-title">
          <text class="iconfont icon-lightbulb"></text>
          <text>解析</text>
        </view>
        <view class="explanation-content">
          <rich-text :nodes="currentQuestion.explanation"></rich-text>
        </view>
      </view>
    </view>
    
    <!-- 底部按鈕 -->
    <view class="quiz-footer">
      <button 
        class="btn-prev" 
        :disabled="currentIndex === 0"
        @tap="prevQuestion"
        v-if="!showResult"
      >上一題</button>
      
      <button 
        class="btn-next" 
        :disabled="!hasAnswer"
        @tap="nextQuestion"
        v-if="currentIndex < questions.length - 1 && !showResult"
      >下一題</button>
      
      <button 
        class="btn-submit" 
        :disabled="!allAnswered"
        @tap="submitQuiz"
        v-if="currentIndex === questions.length - 1 && !showResult"
      >提交答案</button>
      
      <button 
        class="btn-continue" 
        @tap="continueQuiz"
        v-if="showResult && currentIndex < questions.length - 1"
      >繼續</button>
      
      <button 
        class="btn-finish" 
        @tap="finishQuiz"
        v-if="showResult && currentIndex === questions.length - 1"
      >完成測驗</button>
    </view>
  </view>
</template>

<script>
export default {
  data() {
    return {
      quizId: '',
      questions: [],
      currentIndex: 0,
      selectedAnswers: [],
      fillAnswers: [],
      showResult: false,
      timeLimit: 0,
      remainingTime: 0,
      timer: null,
      score: 0,
      correctCount: 0
    }
  },
  computed: {
    currentQuestion() {
      return this.questions[this.currentIndex];
    },
    
    progressPercent() {
      return ((this.currentIndex + 1) / this.questions.length) * 100;
    },
    
    hasAnswer() {
      const current = this.currentQuestion;
      if (!current) return false;
      
      switch (current.type) {
        case 'single':
        case 'judge':
          return this.selectedAnswers[this.currentIndex] !== undefined;
        case 'multiple':
          return this.selectedAnswers[this.currentIndex] && this.selectedAnswers[this.currentIndex].length > 0;
        case 'fill':
          return this.fillAnswers.every(answer => answer && answer.trim());
        default:
          return false;
      }
    },
    
    allAnswered() {
      return this.questions.every((question, index) => {
        switch (question.type) {
          case 'single':
          case 'judge':
            return this.selectedAnswers[index] !== undefined;
          case 'multiple':
            return this.selectedAnswers[index] && this.selectedAnswers[index].length > 0;
          case 'fill':
            return question.blanks.every((blank, blankIndex) => {
              const answer = this.selectedAnswers[index] && this.selectedAnswers[index][blankIndex];
              return answer && answer.trim();
            });
          default:
            return false;
        }
      });
    }
  },
  onLoad(options) {
    this.quizId = options.id;
    this.loadQuiz();
  },
  onUnload() {
    this.clearTimer();
  },
  methods: {
    // 載入測驗
    async loadQuiz() {
      try {
        const { result } = await uniCloud.callFunction({
          name: 'quiz',
          data: {
            action: 'getQuiz',
            quizId: this.quizId
          }
        });
        
        this.questions = result.data.questions;
        this.timeLimit = result.data.timeLimit || 0;
        
        // 初始化答案陣列
        this.selectedAnswers = new Array(this.questions.length).fill(undefined);
        
        // 初始化填空答案
        this.questions.forEach((question, index) => {
          if (question.type === 'fill') {
            this.$set(this.selectedAnswers, index, new Array(question.blanks.length).fill(''));
          }
        });
        
        // 開始計時
        if (this.timeLimit > 0) {
          this.remainingTime = this.timeLimit * 60; // 轉換為秒
          this.startTimer();
        }
        
      } catch (e) {
        console.error('載入測驗失敗', e);
        uni.showToast({
          title: '載入失敗',
          icon: 'none'
        });
      }
    },
    
    // 開始計時
    startTimer() {
      this.timer = setInterval(() => {
        this.remainingTime--;
        if (this.remainingTime <= 0) {
          this.timeUp();
        }
      }, 1000);
    },
    
    // 清除計時器
    clearTimer() {
      if (this.timer) {
        clearInterval(this.timer);
        this.timer = null;
      }
    },
    
    // 時間到
    timeUp() {
      this.clearTimer();
      uni.showModal({
        title: '時間到',
        content: '測驗時間已結束,將自動提交答案',
        showCancel: false,
        success: () => {
          this.submitQuiz();
        }
      });
    },
    
    // 選擇選項(單選)
    selectOption(key) {
      if (this.showResult) return;
      this.$set(this.selectedAnswers, this.currentIndex, key);
    },
    
    // 切換選項(多選)
    toggleOption(key) {
      if (this.showResult) return;
      
      let selected = this.selectedAnswers[this.currentIndex] || [];
      const index = selected.indexOf(key);
      
      if (index > -1) {
        selected.splice(index, 1);
      } else {
        selected.push(key);
      }
      
      this.$set(this.selectedAnswers, this.currentIndex, [...selected]);
    },
    
    // 判斷選項是否被選中
    isOptionSelected(key) {
      const selected = this.selectedAnswers[this.currentIndex];
      return Array.isArray(selected) ? selected.includes(key) : selected === key;
    },
    
    // 選擇判斷題答案
    selectJudge(value) {
      if (this.showResult) return;
      this.$set(this.selectedAnswers, this.currentIndex, value);
    },
    
    // 上一題
    prevQuestion() {
      if (this.currentIndex > 0) {
        this.currentIndex--;
        this.showResult = false;
        this.updateFillAnswers();
      }
    },
    
    // 下一題
    nextQuestion() {
      if (this.currentIndex < this.questions.length - 1) {
        this.saveFillAnswers();
        this.currentIndex++;
        this.showResult = false;
        this.updateFillAnswers();
      }
    },
    
    // 更新填空答案
    updateFillAnswers() {
      const current = this.currentQuestion;
      if (current && current.type === 'fill') {
        this.fillAnswers = this.selectedAnswers[this.currentIndex] || new Array(current.blanks.length).fill('');
      }
    },
    
    // 儲存填空答案
    saveFillAnswers() {
      const current = this.currentQuestion;
      if (current && current.type === 'fill') {
        this.$set(this.selectedAnswers, this.currentIndex, [...this.fillAnswers]);
      }
    },
    
    // 提交測驗
    async submitQuiz() {
      this.clearTimer();
      this.saveFillAnswers();
      
      try {
        const { result } = await uniCloud.callFunction({
          name: 'quiz',
          data: {
            action: 'submitQuiz',
            quizId: this.quizId,
            answers: this.selectedAnswers
          }
        });
        
        this.score = result.data.score;
        this.correctCount = result.data.correctCount;
        
        // 顯示第一題結果
        this.currentIndex = 0;
        this.showResult = true;
        this.updateFillAnswers();
        
      } catch (e) {
        console.error('提交測驗失敗', e);
        uni.showToast({
          title: '提交失敗',
          icon: 'none'
        });
      }
    },
    
    // 繼續查看結果
    continueQuiz() {
      this.nextQuestion();
      this.showResult = true;
    },
    
    // 完成測驗
    finishQuiz() {
      uni.showModal({
        title: '測驗完成',
        content: `您的得分:${this.score}分\n正確題數:${this.correctCount}/${this.questions.length}`,
        showCancel: false,
        success: () => {
          uni.navigateBack();
        }
      });
    },
    
    // 格式化時間
    formatTime(seconds) {
      const mins = Math.floor(seconds / 60);
      const secs = seconds % 60;
      return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
    }
  }
}
</script>

4. 最佳實踐

4.1 效能最佳化

  • 影片載入最佳化:使用適當的影片格式和解析度
  • 資料快取:合理使用本地儲存快取課程資料
  • 圖片最佳化:使用適當的圖片格式和尺寸
  • 分頁載入:大量資料採用分頁載入策略

4.2 使用者體驗

  • 離線支援:支援離線觀看已下載的課程
  • 學習進度同步:多裝置間學習進度同步
  • 個人化推薦:根據學習歷史推薦相關課程
  • 互動回饋:及時的學習回饋和成就系統

4.3 資料安全

  • 內容保護:影片內容加密和防盜鏈
  • 使用者隱私:合規的使用者資料收集和使用
  • 付費驗證:完善的課程購買和權限驗證

5. 總結

教育應用的開發需要關注學習體驗、內容管理和資料分析等多個方面。透過 uni-app 的跨平台能力,我們可以高效地開發出功能豐富的教育應用,為使用者提供優質的學習體驗。

關鍵成功因素包括:

  • 流暢的影片播放體驗
  • 完善的學習進度追蹤
  • 豐富的互動功能
  • 個人化的學習推薦
  • 穩定的技術架構

希望本文能為您的教育應用開發提供有價值的參考。

一次開發,多端部署 - 讓跨平台開發更簡單