教育应用实战案例
本文将介绍如何使用 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 安全性考虑
- 数据加密:敏感数据传输和存储时进行加密
- 权限控制:严格控制用户权限,防止未授权访问
- 输入验证:对用户输入进行严格验证,防止注入攻击
通过以上实践,可以开发出功能完善、体验良好的教育应用,满足用户的学习需求,提供优质的教育服务。