Skip to content

自定义组件

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

试题

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

示例

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>

属性

属性类型默认值说明必填
typenumbernull试题类型(1:单选;2:多选;3:填空;4:判断;5:问答)
titlestringnull题干
optionsstring[][]试题选项
answersstring[][]标准答案
mark-typenumbernull阅卷类型(1:客观题;2:主观题)
scorenumbernull分数
scoresnumbernull子分数
analysisstringnull解析
user-answersstring[][]用户答案
user-scorenumber | nullnull用户分数
editablebooleanfalse可编辑(true:是;false:否)
displaystring'paper'显示(paper:试卷;list:列表;paper-with-answer:列表带答案)
answer-showbooleanfalse标准答案显示
user-answer-showbooleantrue用户答案显示
analysis-showbooleanfalse解析显示
update-user-namestring''修改用户名称
btnsCardBtn[]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>

属性

属性类型默认值说明必填

插槽

参数描述必填

事件

事件参数说明必填
changevoid试题文本变化并格式正确时,触发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-timestringnull到期时间 yyyy-MM-dd HH:mm:ss
pre-txtstringnull前缀文字
remindnumbernull剩余多久提醒(单位:秒)
colorstring#303133文字颜色
remind-colorstring'#FF5D15'提醒文字颜色
font-sizestring'14px'文字大小

事件

事件参数说明必填
endvoid倒计时结束,触发结束事件
remindvoid倒计时结束前,剩余时间不足,触发剩余时间不足事件

下拉选带分页

在标准流程中,选择人员的过程相对繁琐。用户需先点击“选择人员”按钮,随后弹出“已选人员”列表。再次点击“选择”按钮后,上层页面会显示“未选人员”列表。用户需从中勾选并点击确认,才能完成选择。这一连串的操作导致用户体验不佳。而且,系统设计之初就已决定不使用弹窗风格。当前,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-modelstring | number| array''当前选中索引
urlstring''请求地址
paramsObject{}请求参数
searchParmNamestring''搜索框参数名称(请求接口时要携带的参数)
optionLabelstring'id'选项显示名称
optionValuestring | number'name'选项实际值
multiplebooleantrue是否多选
optionsarray[]用于解决回显数据时,只显示option-value的值的问题。解决方式为本地先缓存一份数据
placeholderstring'请选择'未选择时,下拉框显示的文字
searchPlaceholderstring'请输入查询条件'点击下拉选,搜索框显示的文字
pageSizenumber5默认每页显示多少条(V5.2.0新增)
disabledValuesstring | number[]选项值不可选。比如考试中允许添加人,不能删除人(V5.2.0新增)

插槽

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

小猫考试