自定义组件
为了提升开发效率和界面复用性,我们将常用的界面元素抽取并封装成了组件。以下是这些组件的详细说明。
弹出层
针对答题卡功能,我们开发了一个通用组件。该组件采用从屏幕底部滑出的展现方式,自定义插槽区域可用于展示题目编号,并通过颜色区分已答、未答、已标记。
示例
js
<xm-popup ref="answerSheet" name="答题卡" class="answer-sheet">
<view class="answer-sheet-head">
<view class="answer-sheet-head__state answer-sheet-head__state--mark"></view>
<text class="answer-sheet-head__name">标记</text>
<view class="answer-sheet-head__state answer-sheet-head__state--answer"></view>
<text class="answer-sheet-head__name">已答</text>
</view>
<view class="answer-sheet-main">
<template v-for="(examQuestion, index) in examQuestions" :index="index" :key="index">
<text v-if="examQuestion.type === 1" @click="curQuestionIndex = index" class="answer-sheet_chapter-name">{{ examQuestion.chapterName }}</text>
<view
v-else
@click="curQuestionIndex = index"
:class="[
'answer-sheet__question-no',
{ 'answer-sheet__question-no--answer': isAnswer(examQuestion) },
{ 'answer-sheet__question-no--mark': isMark(examQuestion) }
]"
>
<text>{{ examQuestion.no }}</text>
</view>
</template>
</view>
</xm-popup>
js
<template>
<uni-popup ref="popup" type="share" safeArea background-color="#fff" border-radius="10px 10px 10px 10px" class="xm-popup">
<view class="xm-popup-head">
<text class="xm-popup-head__name">{{ name }}</text>
<uni-icons customPrefix="iconfont" type="icon-guanbi" @click="popup.close()" color="#231815" size="34rpx" class="xm-popup-head__close-btn"></uni-icons>
</view>
<view class="xm-popup-main">
<scroll-view scroll-y="true" class="xm-popup-main__scroll">
<slot></slot>
</scroll-view>
</view>
</uni-popup>
</template>
<script lang="ts" setup>
// 打开
function open() {
popup.value.open();
}
</script>
属性
属性 | 类型 | 默认值 | 说明 | 必填 |
---|---|---|---|---|
name | string | ’‘ | 头部名称,如:答题卡 | 否 |
倒计时
参考PC端倒计时
空白页
当列表无内容时,展示骨架屏作为占位。
示例
js
<xm-empty v-if="!todoExerList?.length"></xm-empty>
js
<template>
<view class="xm-empty">
<image src="@/static/img/list-blank.png" class="xm-empty-img"></image>
<view class="xm-empty-txt">暂无数据</view>
</view>
</template>
试题
试题是一个核心组件,在用户答题界面、错题预览等多个场景中均有应用。
示例
js
<xm-question
v-model="examQuestion.userAnswers"
:type="examQuestion.questionType"
:markType="examQuestion.markType"
:title="examQuestion.title"
:score="examQuestion.score"
:answers="examQuestion.answers"
:userScore="examQuestion.userScore"
:options="examQuestion.options"
:analysis="examQuestion.analysis"
:editable="examing"
:analysisShow="analysisShow"
@change="(answers: string[]) => answer(examQuestion, answers)"
>
<template #title-pre>
<text class="mypaper-main__question-cur-no">{{ examQuestion.no }}、</text>
</template>
<template #title-post>
<text>({{ examQuestion.score }}分)</text>
</template>
</xm-question>
js
<template>
<view class="question">
<!-- 标题 -->
<view class="question-title">
<slot name="title-pre"></slot>
<text class="question-title__text">{{ title }}</text>
<slot name="title-post"></slot>
</view>
<!-- 单选题选项 -->
<radio-group
v-if="type === 1"
@change="(e: any) => { userAnswers[0] = e.detail.value; $emit('update:modelValue', userAnswers); $emit('change', userAnswers) }"
class="question-option__radio-wrap"
>
<label
v-for="(option, index) in options"
:key="index"
:class="['question-option', { 'is-checked': isChecked(optionLabs(index)) }, { 'is-succ': isSucc(optionLabs(index)) }, { 'is-err': isErr(optionLabs(index)) }]"
>
<radio :value="optionLabs(index)" :checked="isChecked(optionLabs(index))" :disabled="!editable" class="question-option__radio-hover" />
<view class="question-option__radio">
<view class="question-option__radio-inner"></view>
</view>
<text class="question-option__content">{{ optionLabs(index) }}、{{ option }}</text>
</label>
</radio-group>
</view>
</template>
属性
属性 | 类型 | 默认值 | 说明 | 必填 |
---|---|---|---|---|
v-model | string[] | [] | 用户答案 | 否 |
title | string | '' | 题干 | 是 |
options | string[] | [] | 试题选项 | 否 |
type | number | 1 | 试题类型(1:单选;2:多选;3:填空;4:判断;5:问答) | 是 |
mark-type | number | 1 | 阅卷方式(1:客观题;2:主观题;) | 是 |
answers | string[] | [] | 标准答案 | 否 |
score | number | 0 | 分数 | 是 |
user-score | number | null | 用户分数 | 否 |
analysis | string | null | 解析 | 否 |
editable | boolean | false | 可编辑(true:是;false:否) | 否 |
answer-show | boolean | false | 标准答案显示(true:用户答案显示;false:标准答案显示) | 否 |
analysis-show | boolean | false | 解析显示(true:显示;false:不显示) | 否 |
插槽
参数 | 描述 | 必填 |
---|---|---|
title-pre | 属性title前面追加内容,如题号。 | 否 |
title-post | 属性title后面追加内容,如分数。 | 否 |
事件
事件 | 参数 | 说明 | 必填 |
---|---|---|---|
change | (value: string[]) => void | 答案改变时自动触发change事件 | 否 |
滑动
用户考试时,页面增加了左右滑动翻题效果,在正常题量下,使用swiper组件也会卡顿,影响用户体验。参考官网和市面上解决方案,我们完成了如下插件:
- 启用无限循环:为swiper组件配置circular属性,使用户滑动至末尾时能自动跳转至开头,实现无缝衔接的滑动效果。
- 固定展示项:将swiper-item的展示数量固定为3个,包括当前题目及其紧邻的前后两题。这样做能显著减少DOM操作,提高滑动流畅度。
- 动态数据加载:当用户滑动swiper时,根据滑动方向动态加载并更新这3个swiper-item中的数据内容。这样既能保证数据实时性,又能避免一次性加载过多数据导致卡顿。
示例
js
<xm-swiper v-model="curQuestionIndex" :items="examQuestions">
<template #default="{ item: examQuestion }">
<scroll-view scroll-y="true" style="height: 100%">
{{ examQuestion }}
</scroll-view>
</template>
</xm-swiper>
vue
<template>
<swiper :current="curSwiperIndex" :circular="true" @change="(e: any) => e.detail.source === 'touch' && synIndex(e.detail.current)">
<swiper-item v-for="(item, index) in swiperItems" :key="index">
<slot :item="item"></slot>
</swiper-item>
</swiper>
</template>
<script lang="ts" setup>
/************************计算属性相关*************************/
const swiperItems = computed(() => {
let curItem = props.items[curItemIndex.value];
let itemLen = props.items.length;
let nextItemIndex = curItemIndex.value >= itemLen - 1 ? 0 : curItemIndex.value + 1;
let nextItem = props.items[nextItemIndex];
let preItemIndex = curItemIndex.value <= 0 ? itemLen - 1 : curItemIndex.value - 1;
let preItem = props.items[preItemIndex];
let tempItems = [];
if (curSwiperIndex.value === 0) {
tempItems.push(curItem);
tempItems.push(nextItem);
tempItems.push(preItem);
} else if (curSwiperIndex.value === 1) {
tempItems.push(preItem);
tempItems.push(curItem);
tempItems.push(nextItem);
} else if (curSwiperIndex.value === 2) {
tempItems.push(nextItem);
tempItems.push(preItem);
tempItems.push(curItem);
}
return tempItems;
});
/************************事件相关*****************************/
/**
* 同步索引
* 类似两个齿轮相互带动转动,如:a1b1 a2b2 a3b3 a1b4 a2b1
* b1
* a1 b4 b2
* a3 a2 b3
*
* @param newSwiperIndex 最新滑动索引
*/
function synIndex(newSwiperIndex: number) {
let itemLen = props.items.length;
let oldSwiperIndex = curSwiperIndex.value;
let swipeRight = oldSwiperIndex - newSwiperIndex === -2 || oldSwiperIndex - newSwiperIndex === 1;
if (swipeRight) {
curSwiperIndex.value <= 0 ? (curSwiperIndex.value = 2) : curSwiperIndex.value--; // 索引同步右滑
curItemIndex.value <= 0 ? (curItemIndex.value = itemLen - 1) : curItemIndex.value--;
} else {
curSwiperIndex.value >= 2 ? (curSwiperIndex.value = 0) : curSwiperIndex.value++; // 索引同步左滑
curItemIndex.value >= itemLen - 1 ? (curItemIndex.value = 0) : curItemIndex.value++;
}
emit('update:modelValue', curItemIndex.value);
}
</script>
属性
参数 | 类型 | 默认值 | 描述 | 必填 |
---|---|---|---|---|
v-model | number | null | 当前选中索引 | 是 |
items | any[] | [{}] | 待滑动列表 | 是 |
插槽
参数 | 描述 | 必填 |
---|---|---|
item | 属性items当前循环到的项 | 否 |