← 返回

问卷考试系统设计(五):前端引擎——渲染、状态管理与防作弊

上一篇:问卷考试系统设计(四):质量控制

后端写好了,接口也通了。前端要解决四个问题:怎么把后端返回的 JSON 渲染成可交互的答题界面、状态怎么管、考试场景怎么防作弊、手机上表格挤成一团怎么办。

前端答题状态要围绕一份本地快照流转,草稿和提交使用同一套校验结果:

stateDiagram-v2 [*] --> LoadingDsl LoadingDsl --> InitStore: 加载问卷 DSL InitStore --> Answering: 初始化 Pinia Answering --> RecomputeLogic: 选项变化 RecomputeLogic --> Answering: 更新跳题结果 Answering --> SavingDraft: 定时或失焦 SavingDraft --> Answering: 保存成功 Answering --> SubmitCheck: 点击提交 SubmitCheck --> Answering: 校验失败 SubmitCheck --> SubmitAnswer: 校验通过 SubmitAnswer --> SubmitSecurity: 附带质量与防作弊数据 SubmitSecurity --> Done Done --> [*]

一、注册表模式:一套 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,页面要汇总答案、算进度、做校验,到处 emitprops 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,
  }
})

初始化时按题型预设默认值,多选和排序给空数组,量表和矩阵给空对象,其余给 nullvalidate() 在提交前调用,返回第一个未答必填题的 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 导出