自定义组件
为了提升开发效率和界面复用性,我们将常用的界面元素抽取并封装成了组件。以下是这些组件的详细说明。
试题
管理员可使用该组件,灵活切换列表视图与试卷视图。列表视图便于概览与管理,而试卷视图则能模拟最终考试时的试卷展示效果,方便进行预览与调整。
示例
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>
属性
属性 | 类型 | 默认值 | 说明 | 必填 |
---|---|---|---|---|
no | string | null | 题号 | 否 |
editable | boolean | false | 可编辑(true:是;false:否) | 否 |
display | string | 'paper' | 显示(paper:试卷;list:列表) | 否 |
type | number | null | 试题类型(1:单选;2:多选;3:填空;4:判断;5:问答) | 是 |
markType | number | null | 阅卷类型 | 是 |
title | string | null | 题干 | 是 |
score | number | null | 分数 | 是 |
answers | string[] | [] | 标准答案 | 否 |
userAnswers | string[] | [] | 用户答案 | 否 |
userScore | number | null | 用户分数 | 否 |
options | string[] | [] | 试题选项 | 否 |
userAnswerShow | boolean | true | 用户答案显示(true:用户答案;false:标准答案) | 否 |
errShow | boolean | false | 错误显示(true:标记错误;false:不显示) | 否 |
updateUserName | string | null | 修改用户名称 | 否 |
插槽
参数 | 描述 | 必填 |
---|---|---|
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;"> 错误{{ errNum }}题 </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>
属性
属性 | 类型 | 默认值 | 说明 | 必填 |
---|
插槽
参数 | 描述 | 必填 |
---|
事件
事件 | 参数 | 说明 | 必填 |
---|---|---|---|
back | void | 点击返回按钮,执行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>
属性
属性 | 类型 | 默认值 | 说明 | 必填 |
---|---|---|---|---|
expireTime | string | null | yyyy-MM-dd HH:mm:ss | 否 |
preTxt | string | '' | 倒计时前缀文字 | 否 |
remind | number | null | 剩余多久提醒(单位:秒) | 否 |
color | string | #ff5d15 | 文字颜色功能可与remind 属性相结合,在时间充裕时,采用绿色显示;而当时间不足时,则切换为红色,以起到警示作用。 | 否 |
事件
事件 | 参数 | 说明 | 必填 |
---|---|---|---|
end | void | 倒计时结束,触发结束事件 | 否 |
remind | void | 倒计时结束前,剩余时间不足,触发剩余时间不足事件 | 否 |
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-model | string | number| array | '' | 当前选中索引 | 是 |
url | string | '' | 请求地址 | 是 |
params | Object | {} | 请求参数 | 否 |
searchParmName | string | '' | 搜索框参数名称(请求接口时要携带的参数) | 否 |
optionLabel | string | 'id' | 选项显示名称 | 是 |
optionValue | string | number | 'name' | 选项实际值 | 是 |
multiple | boolean | true | 是否多选 | 否 |
options | array | [] | 用于解决回显数据时,只显示option-value的值的问题。解决方式为本地先缓存一份数据 | 否 |
placeholder | string | '请选择' | 未选择时,下拉框显示的文字 | 否 |
searchPlaceholder | string | '请输入查询条件' | 点击下拉选,搜索框显示的文字 | 否 |
插槽
参数 | 描述 | 必填 |
---|---|---|
option | 自定义选项ID和选项名称 | 是 |