问卷考试系统设计(五):前端引擎——渲染、状态管理与防作弊
上一篇:问卷考试系统设计(四):质量控制
后端写好了,接口也通了。前端要解决四个问题:怎么把后端返回的 JSON 渲染成可交互的答题界面、状态怎么管、考试场景怎么防作弊、手机上表格挤成一团怎么办。
前端答题状态要围绕一份本地快照流转,草稿和提交使用同一套校验结果:
一、注册表模式:一套 DSL 驱动所有题型渲染
硬编码的坑
直观的写法是 if-else:
<SingleChoice v-if="q.type === 'single_choice'" :question="q" />
<MultipleChoice v-else-if="q.type === 'multiple_choice'" :question="q" />
<LikertScale v-else-if="q.type === 'likert'" :question="q" />每加一种题型,渲染层、校验层、状态层都得改。后面会越来越难维护。
注册表模式
核心是一个 Map,把题型字符串映射到对应的 Vue 组件:
const componentMap: Record<string, () => Promise<Component>> = {
single_choice: () => import('../components/questions/SingleChoice.vue'),
multiple_choice:() => import('../components/questions/MultipleChoice.vue'),
likert: () => import('../components/questions/LikertScale.vue'),
matrix: () => import('../components/questions/MatrixQuestion.vue'),
ranking: () => import('../components/questions/RankingQuestion.vue'),
fill_blank: () => import('../components/questions/FillBlank.vue'),
essay: () => import('../components/questions/EssayQuestion.vue'),
upload: () => import('../components/questions/UploadQuestion.vue'),
demographic: () => import('../components/questions/Demographic.vue'),
}
export function resolveComponent(type: string) {
const loader = componentMap[type]
if (!loader) {
console.warn(`未知题型: ${type}`)
return null
}
return defineAsyncComponent({
loader,
loadingComponent: () => import('../components/QuestionSkeleton.vue'),
delay: 100, // 100ms 内加载完不显示 loading
timeout: 10000,
})
}组件用 defineAsyncComponent 按需加载,首屏只渲染当前可见的题型,其余懒加载。
通用渲染组件
QuestionRenderer 很薄,只负责读 type、找组件、传数据:
<script setup lang="ts">
import { resolveComponent } from '@/registry/questionRegistry'
const props = defineProps<{
question: Question
modelValue: unknown
}>()
const emit = defineEmits<{
'update:modelValue': [value: unknown]
}>()
const DynamicComponent = computed(() =>
resolveComponent(props.question.type)
)
const value = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
</script>
<template>
<NCard :id="`question-${question.id}`" class="mb-4">
<template #header>
<div class="flex items-start gap-2">
<NTag :type="question.required ? 'error' : 'default'" size="small">
{{ question.required ? '必填' : '选填' }}
</NTag>
<span class="text-base font-medium">{{ question.title }}</span>
</div>
</template>
<component
v-if="DynamicComponent"
:is="DynamicComponent"
:question="question"
v-model="value"
/>
<div v-else class="text-red-500">不支持的题型: {{ question.type }}</div>
</NCard>
</template>问卷页按可见题目集合复用同一个渲染入口:
<QuestionRenderer
v-for="q in visibleQuestions"
:key="q.id"
:question="q"
v-model="answers[q.id]"
/>新增题型时,写一个 Vue 组件,往注册表里加一行;如果有特殊校验,再补一条校验规则。渲染层和状态管理层不用跟着大改。
各题型组件的统一接口
每个组件遵循相同约定:接收 question prop,通过 v-model 双向绑定答案值。不同题型的答案类型不一样(单选是 string,多选是数组,量表是 Record),但 v-model 的用法完全一致。
单选题的实现:
<NRadioGroup v-model:value="value" :name="question.id">
<NSpace vertical :size="12">
<NRadio
v-for="opt in question.options"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</NRadio>
</NSpace>
</NRadioGroup>二、Pinia 答题状态管理
渲染引擎跑起来之后,更先撞上的问题就是状态散落:每个组件各自持有 modelValue,页面要汇总答案、算进度、做校验,到处 emit 和 props drilling。
用 Pinia 把答题状态收拢到一个 store 里:
// stores/useAnswerStore.ts
export const useAnswerStore = defineStore('answer', () => {
const questions = ref<Question[]>([])
const answers = ref<Record<string, unknown>>({})
const answeredIds = computed(() => {
return new Set(
Object.entries(answers.value)
.filter(([, v]) => !isEmpty(v))
.map(([id]) => id)
)
})
const unansweredIds = computed(() => {
return questions.value
.filter(q => q.required && !answeredIds.value.has(q.id))
.map(q => q.id)
})
const progress = computed(() => {
if (questions.value.length === 0) return 0
return Math.round((answeredIds.value.size / questions.value.length) * 100)
})
function isEmpty(value: unknown): boolean {
if (value === null || value === undefined) return true
if (typeof value === 'string') return value.trim() === ''
if (Array.isArray(value)) return value.length === 0
if (typeof value === 'object') return Object.keys(value).length === 0
return false
}
function validate(): { valid: boolean; firstUnansweredId: string | null } {
const first = unansweredIds.value[0] ?? null
return { valid: first === null, firstUnansweredId: first }
}
function setAnswer(questionId: string, value: unknown) {
answers.value[questionId] = value
}
function initQuestionnaire(qs: Question[]) {
questions.value = qs
answers.value = {}
qs.forEach(q => {
if (q.type === 'multiple_choice' || q.type === 'ranking') {
answers.value[q.id] = []
} else if (q.type === 'likert' || q.type === 'matrix') {
answers.value[q.id] = {}
} else if (q.type === 'upload') {
answers.value[q.id] = []
} else {
answers.value[q.id] = null
}
})
}
return {
questions, answers, answeredIds, unansweredIds, progress,
validate, setAnswer, initQuestionnaire, isEmpty,
}
})初始化时按题型预设默认值,多选和排序给空数组,量表和矩阵给空对象,其余给 null。validate() 在提交前调用,返回第一个未答必填题的 ID,直接 scrollIntoView 滚过去。
三、条件跳题
DSL 里用 condition 字段描述条件关系:
{
"id": "q3",
"condition": { "questionId": "q1", "operator": "eq", "value": "male" }
}写一个 composable 做条件求值:
export function useConditionEvaluator(
questions: () => Question[],
answers: () => Record<string, unknown>,
) {
function evaluate(condition: Condition | null | undefined): boolean {
if (!condition) return true
const answer = answers()[condition.questionId]
const { operator, value } = condition
switch (operator) {
case 'eq': return answer === value
case 'neq': return answer !== value
case 'in': return Array.isArray(value) && value.includes(answer as any)
case 'not_in': return Array.isArray(value) && !value.includes(answer as any)
case 'gt': return typeof answer === 'number' && answer > (value as number)
case 'lt': return typeof answer === 'number' && answer < (value as number)
default: return true
}
}
const visibleQuestions = computed(() => {
return questions().filter(q => evaluate(q.condition))
})
return { visibleQuestions, evaluate }
}关键细节:题目被隐藏后,它的答案要清掉。不然提交上来的数据里混着不该存在的答案:
watch(visibleQuestionIds, (newIds, oldIds) => {
oldIds.forEach(id => {
if (!newIds.has(id)) {
delete answers.value[id]
}
})
})答题导航
问卷题目一多,需要顶部进度条 + 可跳转的题目导航面板:
<template>
<div class="fixed top-0 left-0 right-0 z-50 bg-white shadow-sm px-4 py-2">
<div class="max-w-3xl mx-auto flex items-center gap-3">
<NProgress :percentage="progress" :stroke-width="10" class="flex-1" />
<span class="text-sm text-gray-500 whitespace-nowrap">
{{ answeredIds.size }}/{{ questions.length }}
</span>
<NButton text @click="showNav = true">
<NIcon :component="List" size="20" />
</NButton>
</div>
</div>
<NDrawer v-model:show="showNav" :width="280" placement="right">
<NDrawerContent title="题目导航">
<div class="grid grid-cols-5 gap-2">
<NButton
v-for="(q, idx) in questions"
:key="q.id"
size="small"
:type="answeredIds.has(q.id) ? 'success' : 'default'"
@click="scrollToQuestion(q.id)"
>
{{ idx + 1 }}
</NButton>
</div>
</NDrawerContent>
</NDrawer>
</template>四、防作弊
考试场景下,前端防作弊不是万能的,真正的安全靠后端校验和监控。但前端能挡住大部分"随手作弊"。
防作弊分两层:物理隔离(切屏、复制、后退)和内容防偷(题目打乱、选项打乱)。前者是辅助手段,后者才是核心。
4.1 题目打乱 + 选项打乱
核心问题:打乱了怎么记分?
直接的做法:服务端给每个考生生成一份打乱后的试卷,答案按打乱后的顺序存。问题是:
- 1000 人考试 = 1000 份不同的试卷,服务端要存 1000 份
- 评分时没法统一比对,每份试卷的"选项 A"含义不同
- 并发量大的时候,每人一份独立试卷,数据库压力会很大
更稳的做法:打乱只影响展示,不影响存储。
答案永远按原始索引提交和评分。打乱纯粹是前端的展示层行为,跟后端完全解耦。
低成本方案:种子打乱
核心思路:服务端下发一个种子(seed),前端用种子生成确定性的随机序列。同一个种子 = 同一个打乱顺序,不需要服务端逐份生成。
// utils/shuffle.ts
// 种子随机数生成器(Mulberry32,轻量、确定性)
function mulberry32(seed: number): () => number {
return () => {
seed |= 0
seed = seed + 0x6d2b79f5 | 0
let t = Math.imul(seed ^ seed >>> 15, 1 | seed)
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t
return ((t ^ t >>> 14) >>> 0) / 4294967296
}
}
// Fisher-Yates 洗牌,用种子随机数替代 Math.random()
export function seededShuffle<T>(arr: T[], seed: number): T[] {
const result = [...arr]
const rand = mulberry32(seed)
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(rand() * (i + 1))
;[result[i], result[j]] = [result[j], result[i]]
}
return result
}种子从哪来?后端在下发试卷时带上:
{
"paperId": "P20260524001",
"seed": 1748064000,
"questions": [ ... ]
}种子可以用 userId + examId 的哈希值,也可以用时间戳。关键是:同一个考生同一份试卷,种子不变,打乱顺序就不变。
打乱策略
题目和选项分别打乱,但逻辑不同:
interface ShuffledQuestion {
originalIndex: number // 原始索引,提交答案用这个
question: Question
shuffledOptions?: { originalValue: string; label: string }[]
}
function shufflePaper(
questions: Question[],
seed: number
): ShuffledQuestion[] {
// 1. 题目打乱
const shuffledQuestions = seededShuffle(questions, seed)
return shuffledQuestions.map((q, displayIndex) => {
// 2. 选项打乱(只对选择题打乱,量表题不打乱)
const shouldShuffleOptions = [
'single_choice', 'multiple_choice'
].includes(q.type)
let shuffledOptions
if (shouldShuffleOptions && q.options) {
// 每道题用不同种子,避免所有题选项顺序一样
const optSeed = seed + displayIndex
shuffledOptions = seededShuffle(
q.options.map(opt => ({
originalValue: opt.value,
label: opt.label,
})),
optSeed
)
}
return {
originalIndex: questions.indexOf(q),
question: q,
shuffledOptions,
}
})
}前端组件:只认原始值
渲染时,组件展示的是打乱后的选项,但 v-model 绑定的始终是原始值:
<template>
<NRadioGroup v-model:value="selectedOriginalValue">
<NSpace vertical :size="12">
<NRadio
v-for="opt in shuffledOptions"
:key="opt.originalValue"
:value="opt.originalValue"
>
{{ opt.label }}
</NRadio>
</NSpace>
</NRadioGroup>
</template>
<script setup lang="ts">
// shuffledOptions 来自 shufflePaper() 的输出
// selectedOriginalValue 始终是原始的 option.value
// 提交时答案就是原始值,后端无需知道前端打乱了什么
const props = defineProps<{
shuffledOptions: { originalValue: string; label: string }[]
}>()
const selectedOriginalValue = defineModel<string>()
</script>关键:考生看到的选项顺序跟别人不同,但提交的答案是原始值。后端评分时直接比对原始 value,几乎没有额外开销。
方案对比:种子打乱 vs 预生成卷
实际做防作弊时,主流有三种方案,各有优劣:
方案一:后端逐份生成打乱试卷
每人一份独立试卷,题目顺序和选项顺序都不同。
- 优点:安全性更高,服务端可审计每份试卷的顺序
- 缺点:1000 人考试 = 1000 份 JSON 生成 + 存储,并发高的时候扛不住
- 适合:小规模考试(< 100 人),或者对安全性要求极高的场景
方案二:预生成 N 套卷,按用户 hash 分配
提前生成 10-50 套打乱后的试卷,用户登录时按 userId % N 分配到某一套。
// 预生成 20 套卷
int variantCount = 20;
List<PaperVariant> variants = IntStream.range(0, variantCount)
.mapToObj(i -> paperGenerator.generateVariant(questionnaire, i))
.collect(Collectors.toList());
// 用户分配
public PaperVariant assignVariant(String userId, String examId) {
int index = Math.floorMod((userId + examId).hashCode(), variantCount);
return variants.get(index);
}- 优点:开销可控(只生成 20 份,不随人数增长),安全性不错(同一套卷的人之间还是没法抄,因为不知道对方是第几套)
- 缺点:同一套卷的人之间理论上可以互传答案;需要存储 N 份试卷 JSON
- 适合:中等规模(100-5000 人),大部分考试场景够用了
方案三:种子打乱,前端执行
后端只下发一个种子,前端用种子生成确定性随机序列来打乱。
// 后端:每人只生成一个种子 + 签名
public String signSeed(String userId, String examId, long seed) {
String payload = userId + ":" + examId + ":" + seed;
return HmacUtils.hmacSha256(HMAC_SECRET, payload);
}
// 后端校验提交
public void verifyAnswer(String userId, String examId, long seed, String signature) {
String expected = signSeed(userId, examId, seed);
if (!expected.equals(signature)) {
throw new BusinessException("种子签名无效,可能存在篡改");
}
}- 优点:服务端开销极低(每人只有一个 long + 一个 HMAC 字符串),不需要存储试卷变体,1000 人同时开考只是一次批量 HMAC 计算
- 缺点:前端可被绕过(技术能力强的人可以拿到种子后自己算出原始顺序),安全性依赖前端环境
- 适合:大规模考试(> 5000 人),或者配合前端加固(代码混淆、WebView 容器)使用
| 方案 | 服务端开销 | 并发影响 | 安全性 | 适用规模 |
|---|---|---|---|---|
| 逐份生成 | 高 | 高 | 更高 | < 100 人 |
| 预生成 N 套 | 中(固定) | 低 | 高 | 100-5000 人 |
| 种子打乱 | 极低 | 极低 | 中 | > 5000 人 |
| 种子 + 签名 | 极低 | 极低 | 中高 | > 5000 人 |
实际建议:大部分考试场景用**方案二(预生成 N 套卷)**就够了。20 套卷已经能有效防止作弊,开销可控,安全性比纯种子方案好。只有真正大规模的在线考试(万人级别)才需要考虑种子方案。
4.2 什么题该打乱,什么题不该打乱
不是所有题型都适合打乱,需要区分:
| 题型 | 题目打乱 | 选项打乱 | 原因 |
|---|---|---|---|
| 单选题 | ✅ | ✅ | 核心防作弊场景 |
| 多选题 | ✅ | ✅ | 同上 |
| 量表题(Likert) | ✅ | ❌ | 量表有固定顺序(从低到高),打乱会误导 |
| 矩阵题 | ✅ | ❌ | 行通常是具体条目,列是评分等级;尤其评分等级不能乱 |
| 排序题 | ✅ | ❌ | 选项本身就是排序对象,打乱无意义 |
| 填空题 | ✅ | — | 没有选项 |
| 问答题 | ✅ | — | 没有选项 |
| 上传题 | ✅ | — | 没有选项 |
心理问卷(Likert 量表)不要打乱选项顺序。量表的语义是有方向的,“非常不同意”到“非常同意”是固定顺序,打乱后被试会误解题意,数据基本就不能用了。
普通考试的选择题则应该打乱。“A 是正确答案"这个信息在打乱后失效,每个考生的正确选项位置不同。
4.3 三种方案的代码实现
预生成 N 套卷
// 预生成,启动时或考试创建时执行一次
public class PaperVariantGenerator {
public List<PaperVariant> generate(Questionnaire q, int count) {
return IntStream.range(0, count)
.mapToObj(i -> {
long seed = i; // 用序号做种子
List<ShuffledQuestion> shuffled = shuffleQuestions(q.getQuestions(), seed);
return new PaperVariant(i, shuffled, seed);
})
.collect(Collectors.toList());
}
}
// 用户分配,每人一次 hash 计算
public PaperVariant assign(String userId, String examId, List<PaperVariant> variants) {
int index = Math.floorMod((userId + examId).hashCode(), variants.size());
return variants.get(index);
}种子方案的批量下发
public List<PaperDTO> batchGeneratePapers(String examId, List<String> userIds) {
// 题目内容只生成一次,所有人共享
Questionnaire questionnaire = questionnaireService.getLatest(examId);
List<Question> questions = questionnaire.getQuestions();
// 每人只生成种子 + 签名
return userIds.stream().map(userId -> {
long seed = generateSeed(userId, examId);
String signature = signSeed(userId, examId, seed);
return PaperDTO.builder()
.paperId(generatePaperId())
.examId(examId)
.seed(seed)
.signature(signature)
.questions(questions) // 共享同一份题目
.build();
}).collect(Collectors.toList());
}题目内容只生成一次(或从缓存读取),每人只额外生成一个 long + 一个 HMAC 字符串。1000 人的批量下发,核心开销就是 1000 次 HMAC 计算,微秒级。
4.4 物理隔离层
内容防偷之外,还需要一些物理层面的威慑:
export function useAntiCheat(options?: {
onSwitchTab?: () => void
disableCopy?: boolean
recordTime?: boolean
}) {
let switchCount = 0
const switchTimestamps: number[] = []
const startTime = Date.now()
const timeLog: Record<string, { start: number; end?: number }> = {}
// 切屏检测
function handleVisibilityChange() {
if (document.hidden) {
switchCount++
switchTimestamps.push(Date.now())
message.warning(`检测到切屏(第 ${switchCount} 次),已记录`)
options?.onSwitchTab?.()
}
}
// 复制粘贴禁用
function preventCopy(e: Event) {
e.preventDefault()
message.warning('禁止复制粘贴')
}
onMounted(() => {
document.addEventListener('visibilitychange', handleVisibilityChange)
if (options?.disableCopy) {
document.addEventListener('copy', preventCopy)
document.addEventListener('cut', preventCopy)
document.addEventListener('paste', preventCopy)
}
})
function getReport() {
return {
switchCount,
switchTimestamps,
totalTimeSeconds: Math.floor((Date.now() - startTime) / 1000),
questionTimeLog: timeLog,
}
}
return { getReport, switchCount }
}禁止浏览器后退:
history.pushState(null, '', location.href)
window.addEventListener('popstate', () => {
history.pushState(null, '', location.href)
message.warning('考试模式下禁止后退')
})4.5 防作弊报告
提交时把防作弊报告一起带上:
interface AntiCheatReport {
switchCount: number // 切屏次数
switchTimestamps: number[] // 切屏时间戳
totalTimeSeconds: number // 总作答时长
questionTimeLog: Record<string, { // 每题停留时间
start: number
end?: number
}>
seed: number // 使用的种子(后端可验证)
seedSignature: string // 种子签名
}后端拿到这些数据,结合 IP、设备指纹、答题时长分布,就能判断是否需要人工复核。比如:
- 每题停留时间都 < 3 秒 → 可能是乱填
- 切屏 5 次以上 → 可能查了资料
- 答题时间异常短 → 可能是提前拿到了题目
这些措施的定位很明确:防君子不防小人。真正有技术能力的人总能找到绕过方式,但对于绝大多数考生,种子打乱 + 物理隔离已经足够。安全的重心始终在后端。
五、移动端适配
响应式判断
export function useResponsive() {
const isMobile = ref(false)
function update() { isMobile.value = window.innerWidth < 768 }
onMounted(() => { update(); window.addEventListener('resize', update) })
onUnmounted(() => window.removeEventListener('resize', update))
return { isMobile }
}量表题适配
桌面端量表题是经典的横向表格布局,行是评估维度,列是评分等级。移动端把每一行拆成独立卡片,评分等级纵向排列:
<template>
<!-- 移动端:纵向卡片 -->
<div v-if="isMobile" class="likert-mobile space-y-3">
<NCard v-for="row in rows" :key="row.value" size="small">
<div class="font-medium mb-2">{{ row.label }}</div>
<NRadioGroup
:value="modelValue?.[row.value] ?? null"
@update:value="updateRow(row.value, $event)"
>
<NSpace vertical :size="8">
<NRadio v-for="sp in scalePoints" :key="sp.value" :value="sp.value"
class="w-full py-1">
{{ sp.label }}
</NRadio>
</NSpace>
</NRadioGroup>
</NCard>
</div>
<!-- 桌面端:横向表格 -->
<div v-else class="likert-desktop">
<!-- 桌面版表格布局 -->
</div>
</template>触摸优化
@media (pointer: coarse) {
.n-radio, .n-checkbox, .n-button {
min-height: 44px; /* Apple HIG 推荐的最小触摸目标 */
min-width: 44px;
}
.ranking-item {
padding: 14px 16px;
margin-bottom: 12px;
}
}移动端适配的核心原则就一条:不要让用户缩放和精确点击。表格拆卡片、按钮放大、间距拉开,做到单手拇指能完成所有操作就够了。
异步 vs 同步:前端的取舍
前端有两个典型的异步 vs 同步决策:
条件跳题的计算:必须同步。用户选了一个选项,下一道题的可见性需要立刻变化,不能有延迟。所以 useConditionEvaluator 返回的是 computed,同步求值。
问卷数据的保存:应该异步。用户每答一道题就自动保存草稿(防丢失),这个保存操作不应该阻塞用户继续答题。用 debounce + 异步请求:
watch(answers, debounce(async () => {
await api.saveDraft(paperId, answers.value)
}, 2000), { deep: true })提交答卷:同步等待结果。用户点了"提交"按钮,需要等后端返回答卷 ID 才能跳转到报告页。但如果后端处理时间超过 5 秒,应该先返回一个"提交中"的状态,后端异步处理完再通知前端。
小结
前端引擎的五个核心模块:
- 注册表模式:DSL 驱动渲染,新增题型只加组件不动引擎
- Pinia 状态管理:统一收口,组件只管渲染不管数据
- 条件跳题:DSL 的 condition 字段驱动可见性,隐藏时清答案
- 防作弊:切屏检测、复制禁用、时间记录,前端做威慑后端做兜底
- 移动端适配:量表/矩阵拆卡片,触摸区域放大
下一篇聊报告生成,模板引擎、AI 接入与 PDF 导出。
上一篇:问卷考试系统设计(四):质量控制 下一篇:问卷考试系统设计(六):报告生成,模板引擎、AI 接入与 PDF 导出