Skip to content

自定义组件

为了提升开发效率和界面复用性,我们将常用的界面元素抽取并封装成了组件。以下是这些组件的详细说明。

试题

管理员可使用该组件,灵活切换列表视图与试卷视图。列表视图便于概览与管理,而试卷视图则能模拟最终考试时的试卷展示效果,方便进行预览与调整。

示例

vue
<Question v-else 
	:no="examQuestion.no" 
	:type="examQuestion.questionType || 1"
	:markType="examQuestion.markType || 1" 
	:title="examQuestion.title || ''"
	:score="examQuestion.score || 1" 
	:answers="examQuestion.answers"
	:userAnswers="examQuestion.userAnswers" 
	:userScore="examQuestion.userScore"
	:options="examQuestion.options" 
	:editable="isAnswer"
	@change="(answers: string[]) => answerUpdate(examQuestion, answers)" 
	:errShow="scoreShow">
	<template #bottom-right
		v-if="myExam.state === 3 || (myExam.state === 1 && myExam.markState === 3)"><!-- 已交卷或(未考试已阅卷) -->
		<el-tooltip placement="top" effect="light" :content="answerShow(examQuestion) || '稍后查看答案'"
			popper-class="popper-class" raw-content>
			<el-button type="success" size="small">标准答案</el-button>
		</el-tooltip>
	</template>
</Question>
js
<div v-else-if="display === 'paper'" class="question-paper">
<!-- 题干 -->
<QuestionTitle 
	:no="no" 
	:editable="editable"
	:type="type" 
	:title="title" 
	:score="score" 
	:answers="answers"
	:userAnswers="userAnswers"
	:userScore="userScore"
	:userAnswerShow="userAnswerShow"
	:errShow="errShow"
	@change="(value: string[]) => emit('change', value)"
	/>
<!-- 单选题选项 -->
<el-radio-group 
	v-if="type === 1" 
	:modelValue="userAnswerShow ? (userAnswers[0] || '') : (answers[0] || '')"
	@change="(value: string) => emit('change', [value])"
	@update:modelValue="(value: string) => userAnswers[0] = value"
	:disabled="!editable"
	>
	<el-radio v-for="(option, index) in options" :key="index" :label="`${optionLabs[index]}`">
		<div v-html="`${optionLabs[index]}、${option}`" :style="{ color: errColor(optionLabs[index]) }"/>
	</el-radio>
</el-radio-group>
</div>

属性

属性类型默认值说明必填
nostringnull题号
editablebooleanfalse可编辑(true:是;false:否)
displaystring'paper'显示(paper:试卷;list:列表)
typenumbernull试题类型(1:单选;2:多选;3:填空;4:判断;5:问答)
markTypenumbernull阅卷类型
titlestringnull题干
scorenumbernull分数
answersstring[][]标准答案
userAnswersstring[][]用户答案
userScorenumbernull用户分数
optionsstring[][]试题选项
userAnswerShowbooleantrue用户答案显示(true:用户答案;false:标准答案)
errShowbooleanfalse错误显示(true:标记错误;false:不显示)
updateUserNamestringnull修改用户名称

插槽

参数描述必填
bottom-right右下角追加内容,如操作按钮。
bottom下侧追加内容,如自定义分数。

事件

事件参数说明必填
change(value: string[]) => void答案改变时自动触发change事件

试题编辑器

针对Word导入试题时“错误定位难、排错繁琐”的问题,我们开发了一款可视化编辑组件。该组件借鉴Markdown的编辑理念,左侧为格式化的试题编辑区,右侧则实时展示试题的最终呈现效果。当格式错误时,编辑器能迅速定位至出错行,并明确提示错误原因,从而显著提升试题导入的效率和准确性。

示例

vue
<template>
<QuestionEditor v-if="paperShow === 'editor'" @back="paperShow = 'paper'" @txt-import="txtImport"></QuestionEditor>
</template>
<script lang="ts" setup>
// 文本导入
function txtImport(questions: any) {
    questions._value.forEach((question: any) => {
        form.examQuestions.push({
            type: 2,
            questionId: question.id,
            questionType: question.type,
            markType: question.markType,
            title: question.title,
            score: question.score,
            answers: question.answers,
            scores: question.scores,
            options: question.options,
            markOptions: question.markOptions,
            analysis: question.analysis,
        })
    })
    form.noUpdate()
    paperShow.value = 'paper'
}
</script>
js
<template>
    <div class="question-editor">
        <div class="question-editor-top">
            <div class="question-editor-left-btn">
                <el-button type="primary" size="small" @click="emit('back')">返回</el-button>
                <el-button type="success" size="small" @click="showEg">{{egShow ? '返回编辑' : '查看示例'}}</el-button>
            </div>
            <div>
                <slot name="top-right"></slot>
                <span style="font-size: 14px;">共{{ questions.length }}题</span>
                <span style="color: #f56c6c;font-size: 14px;">&nbsp;&nbsp;错误{{ errNum }}题&nbsp;&nbsp;</span>
                <el-button type="danger" size="small" @click="locationErr">定位错误</el-button>
                <el-button type="success" size="small" @click="txtImport">导入</el-button>
            </div>
        </div>
        <div class="question-editor-bottom">
            <el-scrollbar max-height="calc(100vh - 250px)">
                <el-input 
                    v-model="txt" 
                    :autosize="{ minRows: 50, maxRows: 10000 }"
                    type="textarea" 
                    resize="none"
                    placeholder="建议一次编辑10道题,多次导入" />
            </el-scrollbar>
            <el-scrollbar max-height="calc(100vh - 250px)">
                <template v-for="(question, index) in questions" :key="index">
                    <el-alert v-if="question.errs" :title="`${index+1}、${question.errs}`" type="error" :closable="false"/>
                    <Question 
                        v-else
                        :no="index + 1" 
                        :type="question.type"
                        :markType="question.markType" 
                        :title="question.title"
                        :score="question.score" 
                        :answers="question.answers"
                        :options="question.options"
                        :user-answer-show="false"
                        :editable="false">
                    </Question>
                </template>
            </el-scrollbar>
        </div>
    </div>
</template>

属性

属性类型默认值说明必填

插槽

参数描述必填

事件

事件参数说明必填
backvoid点击返回按钮,执行back回调,用于返回到哪里。
txtImport(value: question[]) => void点击导入按钮,执行txtImport回调,参数为标准化的试题数组,可用于二次处理,比如调用api接口,把试题导入题库。

倒计时

针对当前业务场景,前端对时间的控制需精确至秒级。因市面插件依赖本地时间存在风险,我们开发了基于后端的计时组件。

  • 后端时间同步:前端每30秒与服务器进行一次通信,获取服务器当前时间,确保时间基准的精准性。
  • 前端倒计时机制:在两次服务器时间同步的间隔期,前端通过浏览器的setTimeout函数执行秒级倒计时。虽然存在细微偏差,但下次服务器同步时间后都会进行校正,确保整体准确性。此方法已充分满足当前业务需求。

示例

js
<div v-if="isAnswer" class="paper-left-top-time">
	<CountDown :expireTime="myExamEndTime" preTxt="距结束:" :remind="300" @end="finish()"
		@remind="exam.color = 'var(--el-color-danger)'" :color="exam.color"></CountDown>
</div>
js
<template>
	<text :style="`color: ${props.color};`">{{ preTxt }}{{ d > 0 ? `${d}天` : '' }}{{ padNumber(h) }}小时{{ padNumber(m) }}分{{ padNumber(s) }}秒</text>
</template>
<script lang="ts" setup>
/**
 * 同步服务器时间
 * 每隔30秒同步一次服务器时间;30秒内使用本地浏览器计时;30秒内会有误差,但影响不大
 */
async function synTime() {
	// 如果没有过期时间,继续等待
	if (!(expireTime.value instanceof Date)) {
		setTimeout(synTime, 1000);
		return;
	}

	// 每间隔30秒同步一次服务器时间
	if (times.value <= 0) {
		times.value = 30;
		let { data } = await loginSysTime();
		curTime.value = new Date(data.replaceAll('-', '/'));
		//console.log('服务时间:', data.replaceAll('-', '/'))
	} else {
		curTime.value = new Date((curTime.value as Date).getTime() + 1000);
		times.value--;
		//console.log('本地时间:', curTime.value, !expireTime.value ? '-' : Math.floor(((expireTime.value.getTime() - curTime.value.getTime()) / 1000 ) % 60), s.value)
	}
	emit('change', curTime.value);

	// 如果有提醒,触发提醒事件
	if (props.remind) {
		if (curTime.value.getTime() + props.remind * 1000 >= expireTime.value.getTime()) {
			// console.log('倒计时事件:remind', curTime.value, expireTime.value)
			emit('remind');
		}
	}

	// 如果时间已到,触发事件,让上层处理
	if (curTime.value.getTime() >= expireTime.value.getTime()) {
		// console.log('倒计时事件:end', curTime.value, expireTime.value)
		emit('end');
		return;
	}

	setTimeout(synTime, 1000);
	return;
}
</script>

属性

属性类型默认值说明必填
expireTimestringnullyyyy-MM-dd HH:mm:ss
preTxtstring''倒计时前缀文字
remindnumbernull剩余多久提醒(单位:秒)
colorstring#ff5d15文字颜色功能可与remind属性相结合,在时间充裕时,采用绿色显示;而当时间不足时,则切换为红色,以起到警示作用。

事件

事件参数说明必填
endvoid倒计时结束,触发结束事件
remindvoid倒计时结束前,剩余时间不足,触发剩余时间不足事件
change(val: Date) => void时间变更立即触发事件,如用进度条实时显示剩余时间。

下拉选带分页

在标准流程中,选择人员的过程相对繁琐。用户需先点击“选择人员”按钮,随后弹出“已选人员”列表。再次点击“选择”按钮后,上层页面会显示“未选人员”列表。用户需从中勾选并点击确认,才能完成选择。这一连串的操作导致用户体验不佳。而且,系统设计之初就已决定不使用弹窗风格。当前,layui的JQuery插件市场提供了一种更便捷的方案:下拉选择带分页功能。它允许用户在选择人员时翻页继续,从而有效优化现有交互方式。然而,vue社区中缺乏此类插件,且向element官方提出的相应建议也未获采纳。因此,我们决定自主开发一个符合layui风格的下拉选择带分页组件。

示例

js
<template>
    <el-card class="mark-setting" shadow="never">
        <el-form v-if="form.loginType === 1" ref="formRef" :model="form" :rules="formRules" label-width="100">
            <el-form-item label="考试用户:" prop="examUserIds">
                <Select
                    v-model="form.examUserIds"
                    url="user/listpage"
                    :params="{ state: 1 }"
                    search-parm-name="name"
                    option-label="name"
                    option-value="id"
                    :options="examUsers"
                    :multiple="true"
                    clearable
                    searchPlaceholder="请输入机构名称或用户名称进行筛选"
                    >
                    <template #default="{ option }">
                        {{ option.name }} - {{option.orgName}}
                    </template>
                </Select>
            </el-form-item>
        </el-form>
    </el-card>
</template>
vue
<template>
    <!-- 下拉选择 -->
    <el-select v-model="selectedValue" :multiple="multiple" filterable remote :automatic-dropdown="true"
        :placeholder="placeholder" collapse-tags collapse-tags-tooltip :max-collapse-tags="8"
        @visible-change="(visible: Boolean) => { if (visible) { query() } }"
        @change="emit('update:modelValue', selectedValue)" popper-class="my-select">

        <!-- 搜索框 -->
        <el-input v-model="listpage.searchParmValue" @click.stop="" @input="() => { listpage.curPage = 1; query() }"
            :placeholder="searchPlaceholder">
            <template #prefix>
                <span class="iconfont icon-search"></span>
            </template>
        </el-input>

        <div style="display: flex;justify-content: space-between;margin: 5px 15px;">
            <!-- 工具条 -->
            <div>
                <el-button v-if="multiple" type="info" link @click="selectAll">
                    <span class="iconfont icon-quanxuan">全选</span>
                </el-button>
                <el-button v-if="multiple" type="info" link @click="invertAll">
                    <span class="iconfont icon-list-row">反选</span>
                </el-button>
            </div>
            <!-- 分页条件 -->
            <el-pagination small v-model:current-page="listpage.curPage" v-model:page-size="listpage.pageSize"
                :total="listpage.total" background layout="prev, pager, next, sizes" :page-sizes="[5, 10, 100]"
                @size-change="listpage.curPage = 1; query()" @current-change="query" @prev-click="query"
                @next-click="query" />
        </div>

        <!-- 选项 -->
        <el-option v-for="option in listpage.options" :key="option[optionValue]" :label="option[optionLabel]"
            :value="option[optionValue]">
            <slot :option="option"></slot>
        </el-option>
        <!-- 无选项时显示 -->
        <el-option v-if="listpage.options.length === 0" key="" lable="" value="暂无数据" disabled>
        </el-option>
    </el-select>
</template>

属性

参数类型默认值描述必填
v-modelstring | number| array''当前选中索引
urlstring''请求地址
paramsObject{}请求参数
searchParmNamestring''搜索框参数名称(请求接口时要携带的参数)
optionLabelstring'id'选项显示名称
optionValuestring | number'name'选项实际值
multiplebooleantrue是否多选
optionsarray[]用于解决回显数据时,只显示option-value的值的问题。解决方式为本地先缓存一份数据
placeholderstring'请选择'未选择时,下拉框显示的文字
searchPlaceholderstring'请输入查询条件'点击下拉选,搜索框显示的文字

插槽

参数描述必填
option自定义选项ID和选项名称

小猫考试