自定义组件
为了提升开发效率和界面复用性,我们将常用的界面元素抽取并封装成了组件。以下是这些组件的详细说明。
试题
管理员可使用该组件,灵活切换列表视图与试卷视图。列表视图便于概览与管理,而试卷视图则能模拟最终考试时的试卷展示效果,方便进行预览与调整。
示例
vue
<xmks-question
v-else
:id="`q${index}`"
:type="examQuestion.questionType as number"
:title="examQuestion.title as string"
:options="examQuestion.options"
:answers="examQuestion.answers"
:mark-type="examQuestion.markType as number"
:score="examQuestion.score as number"
:scores="examQuestion.scores"
:analysis="examQuestion.analysis"
:user-answers="examQuestion.userAnswers"
:user-score="examQuestion.userScore"
:answer-show="toolbars.answerShow"
:user-answer-show="true"
:analysis-show="toolbars.analysisShow"
:display="'paper'"
:editable="examing" class="paper-question"
@change="(answers: string[]) => { examQuestion.userAnswers = answers; answerUpdate(examQuestion, answers) }"
>
<template #title-pre>{{ examQuestion.no }}、</template>
</xmks-question>
js
<template>
<div class="xmks-question">
<!-- 列表样式 -->
<div v-if="display === 'list'" class="list">
<div class="list__title">
<slot name="title-pre"></slot>
<span v-html="title"></span>
</div>
<div class="list__tags">
<el-tag class="list__tag list__tag--type">
{{ dictStore.getValue('QUESTION_TYPE', type) }}
</el-tag>
<el-tag class="list__tag list__tag--mark-type">
{{ dictStore.getValue('PAPER_MARK_TYPE', markType) }}
</el-tag>
<el-tag class="list__tag list__tag--score">
{{ score }}分
</el-tag>
<el-tag class="list__tag list__tag--username">
{{ updateUserName }}
</el-tag>
</div>
<div class="list__opt">
<span v-for="(btn, index) in btns" :key="index" :data-name="btn.name" class="list__btn"
@click="btn.event">
<i :class="`iconfont ${btn.icon} `"></i>
</span>
</div>
</div>
<!-- 试卷样式 -->
<div v-else-if="display === 'paper'" class="paper">
<!-- 题干 -->
<div class="paper-title">
<span class="paper-title__pre-txt">
<slot name="title-pre"></slot>
</span>
<xmks-question-title :type="type" :title="title" :answers="answers" :user-answers="userAnswers"
:editable="editable" :user-answer-show="userAnswerShow" :answerShow="answerShow" :score="score"
:user-score="userScore" @change="(value: string[]) => emit('change', value)"></xmks-question-title>
</div>
<!-- 单选题选项 -->
<el-radio-group v-if="type === 1"
:modelValue="userAnswerShow ? (userAnswers[0] || '') : (answerShow ? (answers[0] || '') : '')"
@change="(value: string) => emit('change', [value])" :disabled="!editable"
class="question__single-select__wrap">
<el-radio v-for="(option, index) in options" :key="index" :value="toLetter(index)"
class="question__single-select" :class="{
'question__single-select--succ': isCorrectSelect(toLetter(index)),
'question__single-select--fail': isWrongSelect(toLetter(index))
}">
{{ toLetter(index) }}、{{ escape2Html(option) }}
</el-radio>
</el-radio-group>
<!-- 多选题选项 -->
<el-checkbox-group v-else-if="type === 2"
:modelValue="userAnswerShow ? userAnswers : (answerShow ? answers : [])"
@change="(value: string[]) => emit('change', value)" :disabled="!editable"
class="question__multiple-select__wrap">
<el-checkbox v-for="(option, index) in options" :key="index" :value="`${toLetter(index)}`"
class="question__multiple-select" :class="{
'question__multiple-select--succ': isCorrectSelect(toLetter(index)),
'question__multiple-select--fail': isWrongSelect(toLetter(index))
}">
{{ toLetter(index) }}、{{ escape2Html(option) }}
</el-checkbox>
</el-checkbox-group>
<!-- 填空题(题干区域) -->
<!-- 判断题选项 -->
<el-radio-group v-else-if="type === 4"
:modelValue="userAnswerShow ? (userAnswers[0] || '') : (answerShow ? (answers[0] || '') : '')"
@change="(value: string) => emit('change', [value])" :disabled="!editable"
class="question__single-select__wrap">
<el-radio v-for="(option, index) in ['对', '错']" :key="index" :value="option"
class="question__single-select" :class="{
'question__single-select--succ': isCorrectSelect(option),
'question__single-select--fail': isWrongSelect(option)
}">
{{ option }}
</el-radio>
</el-radio-group>
<!-- 问答题答案 -->
<el-input v-if="type === 5 && !userAnswerShow && !answerShow" :modelValue="''" placeholder="请输入答案"
type="textarea" :autosize="{ minRows: 5 }" :readonly="true" resize="none" class="question__qa" />
<el-input v-else-if="type === 5 && answerShow" :modelValue="qaAnswer" placeholder="请输入答案" type="textarea"
:autosize="{ minRows: 5 }" :readonly="true" resize="none" class="question__qa question__qa--answer" />
<el-input v-else-if="type === 5 && userAnswerShow" :modelValue="escape2Html(userAnswers[0]) || ''"
placeholder="请输入答案" type="textarea" :autosize="{ minRows: 5 }"
@input="(value: string) => emit('change', [value])" :readonly="!editable" resize="none"
class="question__qa question__qa--user-answer" />
<!-- 解析 -->
<div v-if="analysisShow" class="question__analysis">
<div class="question__analysis-title">解析</div>
<span class="question__analysis-content" v-html="analysis?.replaceAll('\n', '<br/>') || '暂无解析'">
</span>
</div>
<!-- 底部插槽,可用于设置分数等用途 -->
<slot name="foot"></slot>
</div>
</div>
</template>
属性
属性 | 类型 | 默认值 | 说明 | 必填 |
---|---|---|---|---|
type | number | null | 试题类型(1:单选;2:多选;3:填空;4:判断;5:问答) | 是 |
title | string | null | 题干 | 是 |
options | string[] | [] | 试题选项 | 否 |
answers | string[] | [] | 标准答案 | 否 |
mark-type | number | null | 阅卷类型(1:客观题;2:主观题) | 是 |
score | number | null | 分数 | 是 |
scores | number | null | 子分数 | 否 |
analysis | string | null | 解析 | 否 |
user-answers | string[] | [] | 用户答案 | 否 |
user-score | number | null | null | 用户分数 | 否 |
editable | boolean | false | 可编辑(true:是;false:否) | 否 |
display | string | 'paper' | 显示(paper:试卷;list:列表;paper-with-answer:列表带答案) | 否 |
answer-show | boolean | false | 标准答案显示 | 否 |
user-answer-show | boolean | true | 用户答案显示 | 否 |
analysis-show | boolean | false | 解析显示 | 否 |
update-user-name | string | '' | 修改用户名称 | 否 |
btns | CardBtn[] | false | 按钮组 | 否 |
插槽
参数 | 描述 | 必填 |
---|---|---|
title-pre | 题干qian插槽,可用于添加题号 | 否 |
foot | 底部插槽,可用于设置分数等用途 | 否 |
事件
事件 | 参数 | 说明 | 必填 |
---|---|---|---|
change | (value: string[]) => void | 答案改变时自动触发change事件 | 否 |
试题编辑器
针对Word导入试题时“错误定位难、排错繁琐”的问题,我们开发了一款可视化编辑组件。该组件借鉴Markdown的编辑理念,左侧为格式化的试题编辑区,右侧则实时展示试题的最终呈现效果。当格式错误时,编辑器能迅速定位至出错行,并明确提示错误原因,从而显著提升试题导入的效率和准确性。
示例
vue
<template>
<xmks-question-editor ref="questionEditorRef"></xmks-question-editor>
</template>
<script lang="ts" setup>
import QuestionEditor from '@/components/question/xmks-question-editor.vue'
import { useRoute, useRouter } from 'vue-router'
/************************变量定义相关***********************/
const questionEditorRef = ref<InstanceType<typeof QuestionEditor>>();
/************************事件相关*****************************/
// 完成导入
function txtImport() {
const result = questionEditorRef.value?.validate()
if (!result?.succ) {
ElMessage.error(result?.msg)
return
}
result?.data?.forEach(async (question) => {
question.questionBankId = Number(route.params.questionBankId)
await questionAdd({ ...question })
})
router.push(`/question-bank/question-nav/list/${route.params.questionBankId}`)
}
</script>
js
<template>
<div class="xmks-question-editor">
<div class="xmks-question-editor__head">
<div class="editor-opt">
<el-button v-if="toolbars.egShow" type='' class="editor-opt__btn" @click="egShow">
<span class="iconfont icon-bianjibanli-93 editor-opt__btn-icon"></span>
<span class="editor-opt__btn-txt">返回编辑</span>
</el-button>
<el-button v-else type='' class="editor-opt__btn" @click="egShow">
<span class="iconfont icon-yangli editor-opt__btn-icon"></span>
<span class="editor-opt__btn-txt">查看样例</span>
</el-button>
</div>
<div class="editor-toolbar">
<div class="editor-toolbar__tip">
共{{ questions.length }}题 错误
<span class="editor-toolbar__tip-warn">{{ errNum }}</span>题
</div>
<el-button type='' class="editor-toolbar__btn" @click="locationErr">
<span class="iconfont icon-dingwei editor-toolbar__btn-icon"></span>
<span class="editor-toolbar__btn-txt">定位错误</span>
</el-button>
<el-button type='' class="editor-toolbar__btn "
:class="{ 'editor-toolbar__btn--active': toolbars.answerShow }"
@click="toolbars.answerShow = !toolbars.answerShow">
<span class="iconfont icon-icon-03 editor-toolbar__btn-icon"></span>
<span class="editor-toolbar__btn-txt">{{ toolbars.answerShow ? '隐藏标准答案' : '显示标准答案' }}</span>
</el-button>
<el-button type='' class="editor-toolbar__btn"
:class="{ 'editor-toolbar__btn--active': toolbars.analysisShow }"
@click="toolbars.analysisShow = !toolbars.analysisShow">
<span class="iconfont icon-icon-06 editor-toolbar__btn-icon"></span>
<span class="editor-toolbar__btn-txt">{{ toolbars.analysisShow ? '隐藏解析' : '显示解析' }}</span>
</el-button>
<el-button type='' class="editor-toolbar__btn"
:class="{ 'editor-toolbar__btn--active': toolbars.markOptionShow }"
@click="toolbars.markOptionShow = !toolbars.markOptionShow">
<span class="iconfont icon-icon-05 editor-toolbar__btn-icon"></span>
<span class="editor-toolbar__btn-txt">{{ toolbars.markOptionShow ? '隐藏阅卷选项' : '显示阅卷选项' }}</span>
</el-button>
</div>
</div>
<div class="xmks-question-editor__main">
<el-scrollbar max-height="calc(100vh - 370px)" class="edit-area">
<el-input v-model="txt" :autosize="{ minRows: 50, maxRows: 10000 }" type="textarea" resize="none"
maxlength="5000" :readonly="toolbars.egShow" class="edit-area__input " />
<div v-if="!txt.length" class="edit-area__tip">
<span class="iconfont icon-bianjibanli-93 edit-area__tip-icon"></span>
<span class="edit-area__tip-title">快去编辑试题吧!</span>
<span class="edit-area__tip-desc">点击左上角“查看样例”预览效果</span>
</div>
</el-scrollbar>
<el-scrollbar max-height="calc(100vh - 370px)" class="review-area">
<template v-for="(question, index) in questions" :key="index">
<el-alert v-if="question.errs" :title="`${index + 1}、${question.errs}`" type="error"
:closable="false" />
<xmks-question v-else :type="question.type" :title="question.title" :options="question.options"
:answers="question.answers" :markType="question.markType" :score="question.score"
:scores="question.scores" :analysis="question.analysis" :userAnswers="[]" :userScore="0"
:answer-show="toolbars.answerShow" :user-answer-show="false"
:analysisShow="toolbars.analysisShow" :display="'paper'" :editable="false">
<template #title-pre>{{ index + 1 }}、</template>
<template #foot>
<div v-if="toolbars.markOptionShow" class="mark-option">
<div class="mark-option__title">阅卷选项</div>
<template v-if="question.type === 1 || question.type === 2
|| question.type === 4 || (question.type === 5 && question.markType === 2)">
<span class="mark-option__txt">本题</span>
<el-input-number v-model="question.score" :min="0.5" :max="20" :precision="2"
controls-position="right" class="mark-option__input-number"
:readonly="true" /><!-- 用blur事件,输入字母或删除数字不触发change事件 -->
<span class="mark-option__txt">分</span>
</template>
<template v-if="question.type === 2">
<span class="mark-option__txt">,漏选</span>
<el-input-number v-model="question.scores[0]" :min="0" :max="20" :precision="2"
controls-position="right" class="mark-option__input-number"
:readonly="true"></el-input-number>
<span class="mark-option__txt">分</span>
</template>
<template
v-if="question.type === 3 || (question.type === 5 && question.markType === 1)">
<template v-for="(score, index) of question.scores" :key="index">
{{ index > 0 ? "," : "" }}
<span class="mark-option__txt">
第{{ index + 1 }}{{ question.type === 3 ? '空' : '关键词' }}
</span>
<el-input-number v-if="question.scores" v-model="question.scores[index]"
:min="0.5" :max="20" :precision="2" class="mark-option__input-number"
:readonly="true"></el-input-number>
<span class="mark-option__txt">分</span>
</template>
<el-checkbox-group v-model="question.markOptions" style="width:300px">
<el-tooltip v-if="question.markType === 1 && question.type === 3"
content="默认答案有顺序">
<el-checkbox :value="2" class="checkbox">答案无顺序</el-checkbox>
</el-tooltip>
<el-tooltip v-if="question.markType === 1" content="默认区分大小写">
<el-checkbox :value="3" class="checkbox">不分大小写</el-checkbox>
</el-tooltip>
</el-checkbox-group>
</template>
</div>
</template>
</xmks-question>
</template>
</el-scrollbar>
</div>
</div>
</template>
属性
属性 | 类型 | 默认值 | 说明 | 必填 |
---|
插槽
参数 | 描述 | 必填 |
---|
事件
事件 | 参数 | 说明 | 必填 |
---|---|---|---|
change | void | 试题文本变化并格式正确时,触发change事件 | 否 |
暴露函数
函数 | 类型 | 说明 | 必填 |
---|---|---|---|
validate | () => object | 数据校验 | 否 |
倒计时
针对当前业务场景,前端对时间的控制需精确至秒级。因市面插件依赖本地时间存在风险,我们开发了基于后端的计时组件。
- 后端时间同步:前端每30秒与服务器进行一次通信,获取服务器当前时间,确保时间基准的精准性。
- 前端倒计时机制:在两次服务器时间同步的间隔期,前端通过浏览器的setTimeout函数执行秒级倒计时。虽然存在细微偏差,但下次服务器同步时间后都会进行校正,确保整体准确性。此方法已充分满足当前业务需求。
示例
js
<xmks-count-down
v-if="myExam.state === 1 || myExam.state === 2"
:expireTime="myExam.state === 1 ? myExam.examStartTime : myExam.answerEndTime"
:preTxt="myExam.state === 1 ? '距离考试开始:' : '距离考试结束:'"
class="my-exam__time">
</xmks-count-down>
js
<template>
<span :style="`color: ${timeWarn ? remindColor : color};font-size: ${fontSize}`">{{ preTxt }}{{ d > 0 ?
`${d}天` : '' }}{{ padNumber(h) }}小时{{ padNumber(m) }}分{{ padNumber(s) }}秒</span>
</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;
const { data: { 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)
}
// 如果有提醒,触发提醒事件
if (props.remind) {
if (curTime.value.getTime() + props.remind * 1000 >= _expireTime.value.getTime()) {
timeWarn.value = true
// 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>
属性
属性 | 类型 | 默认值 | 说明 | 必填 |
---|---|---|---|---|
expire-time | string | null | 到期时间 yyyy-MM-dd HH:mm:ss | 否 |
pre-txt | string | null | 前缀文字 | 否 |
remind | number | null | 剩余多久提醒(单位:秒) | 否 |
color | string | #303133 | 文字颜色 | 否 |
remind-color | string | '#FF5D15' | 提醒文字颜色 | 否 |
font-size | string | '14px' | 文字大小 | 否 |
事件
事件 | 参数 | 说明 | 必填 |
---|---|---|---|
end | void | 倒计时结束,触发结束事件 | 否 |
remind | void | 倒计时结束前,剩余时间不足,触发剩余时间不足事件 | 否 |
下拉选带分页
在标准流程中,选择人员的过程相对繁琐。用户需先点击“选择人员”按钮,随后弹出“已选人员”列表。再次点击“选择”按钮后,上层页面会显示“未选人员”列表。用户需从中勾选并点击确认,才能完成选择。这一连串的操作导致用户体验不佳。而且,系统设计之初就已决定不使用弹窗风格。当前,layui的JQuery插件市场提供了一种更便捷的方案:下拉选择带分页功能。它允许用户在选择人员时翻页继续,从而有效优化现有交互方式。然而,vue社区中缺乏此类插件,且向element官方提出的相应建议也未获采纳。因此,我们决定自主开发一个符合layui风格的下拉选择带分页组件。
示例
js
<template>
<xmks-select
v-model="assistForm.markUserIds"
url="user/listpage"
:params="{ state: 1, type: 3 }"
search-parm-name="name"
option-label="name"
option-value="id" :options="markUsers"
:multiple="true"
clearable
search-placeholder="请输入用户名称进行筛选"
>
<template #default="{ option }">
{{ option.name }}
</template>
</xmks-select>
</template>
vue
<template>
<!-- 下拉选择框(popper-class:class是页面外层的) -->
<el-select v-model="selectedValue" :multiple="multiple" filterable remote :automatic-dropdown="true"
:placeholder="placeholder" collapse-tags collapse-tags-tooltip :max-collapse-tags="3" remote-show-suffix
popper-class="xmks-select" @visible-change="(visible: boolean) => { visible && query() }"
@change="emit('update:modelValue', selectedValue)">
<!-- 搜索框 -->
<el-input v-model="listpage.searchParmValue" :placeholder="searchPlaceholder" class="xmks-select__search"
@input="() => { listpage.curPage = 1; query() }">
<template #prefix>
<span class="iconfont icon-sousuo xmks-select__search-icon"></span>
</template>
</el-input>
<div class="xmks-select__wrap">
<!-- 分页条件 -->
<el-pagination v-model:current-page="listpage.curPage" v-model:page-size="listpage.pageSize"
:total="listpage.total" background layout="prev, pager, next, sizes" :page-sizes="[5, 20, 100]"
:pager-count="5" class="pagination" @size-change="listpage.curPage = 1; query()" @current-change="query"
@prev-click="query" @next-click="query" />
<!-- 工具条 -->
<div class="toolbar">
<el-button v-if="multiple" type="info" link class="toolbar__btn" @click="selectAll">
<span class="iconfont icon-tubiaoziti-52">全选</span>
</el-button>
<el-button v-if="multiple" type="info" link class="toolbar__btn" @click="invertAll">
<span class="iconfont icon-tubiaoziti-51">反选</span>
</el-button>
</div>
</div>
<!-- 选项 -->
<el-option v-for="option in listpage.list" :key="option[optionValue]" :label="option[optionLabel]"
:value="option[optionValue]" class="xmks-select__option">
<slot :option="option"></slot>
</el-option>
<!-- 无选项时显示 -->
<el-option v-if="listpage.list.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 | '请输入查询条件' | 点击下拉选,搜索框显示的文字 | 否 |
pageSize | number | 5 | 默认每页显示多少条(V5.2.0新增) | 否 |
disabledValues | string | number | [] | 选项值不可选。比如考试中允许添加人,不能删除人(V5.2.0新增) | 否 |
插槽
参数 | 描述 | 必填 |
---|---|---|
option | 自定义选项ID和选项名称 | 是 |