ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

基于Vue的心理表单设计器:Schema配置、跳题逻辑与计分引擎实践

2026/9/15 17:10:25 拓冰建站 浏览量
基于Vue的心理表单设计器:Schema配置、跳题逻辑与计分引擎实践 简介基于Vue.js构建的心理表单设计源码面向需要在线收集心理测评数据的开发者与心理专业人士用于打造符合用户心理习惯、交互友好的测评选填界面。资源包为ZIP压缩包约784KB共32个文件以8个Vue组件和11个JavaScript逻辑文件为主体搭配PNG/SVG视觉资源、JSON配置、HTML入口及Git忽略文件涵盖组件封装、API模块、路由配置与Vite构建等前端工程关键环节。已有239人浏览学习适合正在学习Vue组件化开发、关注表单交互设计或希望查阅完整前端工程结构的读者。通过源码可直观了解Vue单文件组件如何拆分、工具函数如何组织、页面路由如何串联还能借鉴心理表单的布局反馈与视觉细节配合package.json、路由配置与视图目录能够帮助理解一个Vue项目从搭建、开发到资源组织的完整流程为同类业务系统或信息收集工具提供可实践的参考。1. 从问卷到量表为什么心理表单不能只用普通表单组件做过心理测评类产品的人都有体会普通业务表单和“心理表单”完全是两种东西。业务表单是字段的堆叠心理表单是“状态机 逻辑路由”。一份焦虑自评量表可能只有 20 道题但每道题的出现与否取决于前一道题的回答量表总分决定了后续分支——是结束测评、弹出预警、还是跳转到下一维度。用传统v-modelel-form硬写问卷每改一版组件就要重构一次。“基于Vue的心理表单设计源码”解决的就是这个问题它把心理问卷抽象成一份可配置的 JSON Schema前端只负责渲染和交互题目编排、分值计算、跳题逻辑全部由配置驱动。源码的核心价值可以拆成四条线量表配置化、题型可扩展、逻辑引擎可解释、结果可计算。适合谁看做在线测评、问诊预检、学校心理普查、EAP 员工关怀系统的前端或全栈工程师。接下来按“设计器怎么组织数据 → 题型组件怎么扩展 → 逻辑引擎怎么跑 → 分值规则怎么挂 → 上线后怎么排查”这条链路往下拆。2. Vue 表单设计器的数据模型与 schema 设计2.1 一份心理量表的 schema 长什么样设计器的地基是数据模型。常见做法是参考 JSON Schema 的语法习惯再针对心理测评场景做扩展。一个题目节点至少要包含id题号、type题型、title题干、options选项、required是否必答、scoreMap选项分值映射、logic跳题/显隐规则。{ scaleId: GAD-7, title: 广泛性焦虑量表, version: 1.0, items: [ { id: q1, type: radio, title: 感到紧张、焦虑或急切, options: [ { label: 完全没有, value: 0 }, { label: 几天, value: 1 }, { label: 一半以上时间, value: 2 }, { label: 几乎每天, value: 3 } ], required: true, scoreMap: { 0: 0, 1: 1, 2: 2, 3: 3 }, logic: [] }, { id: q8, type: radio, title: 感到坐立不安、难以安静, options: [ { label: 完全没有, value: 0 }, { label: 几天, value: 1 }, { label: 一半以上时间, value: 2 }, { label: 几乎每天, value: 3 } ], required: true, scoreMap: { 0: 0, 1: 1, 2: 2, 3: 3 }, logic: [ { condition: q1.score 2, action: show, target: q9 } ] } ] }scoreMap单独拿出来很关键——不是所有题的分值都等于选项序号。心理量表里常见反向计分题比如选项value是 0~3但实际计分要用3 - value。设计器里不能写死计分逻辑要把映射关系作为配置暴露给量表编辑者。logic数组里每一条规则包含condition表达式、actionshow / hide / jump、target目标题号。用字符串表达式而不是硬编码 if 语句是为了让非开发人员也能在可视化面板里配置跳题。2.2 表单引擎的分层Schema、Render、Adapter基于 Vue 的表单设计器源码通常会拆三个层级。第一层是Schema只描述“有什么”第二层是Render负责“怎么显示”第三层是Adapter处理“何时显示、如何计分、如何校验”。VueFormEngine ├── schema/ # schema 解析器、校验器、默认值注入 ├── components/ # 各类题型组件radio、checkbox、likert、slider... ├── core/ │ ├── renderer.ts # 动态组件渲染器component is │ ├── logic.ts # 逻辑引擎解析 condition 表达式 │ ├── scorer.ts # 计分引擎汇总维度分、总分 │ └── validator.ts # 自定义校验规则 └── design-panel/ # 拖拽配置面板生成 schema JSONrenderer.ts是 Vue 动态组件的核心用法。它的思路是遍历 schema 里的items根据item.type动态匹配组件用v-bind$attrs把配置项透传给组件。Vue 3 下的实现大致是template component :iscomponentMap[item.type] :keyitem.id :itemitem :valuemodelValue[item.id] :disableddisabled update:valuehandleUpdate / /template script setup langts import { RadioQuestion, CheckboxQuestion, LikertQuestion, SliderQuestion, TextAreaQuestion } from /questions const componentMap: Recordstring, Component { radio: RadioQuestion, checkbox: CheckboxQuestion, likert: LikertQuestion, slider: SliderQuestion, textarea: TextAreaQuestion } /scriptcomponentMap是策略模式的典型落地。新增题型时不需要改动 renderer 本体——写一个新组件注册进componentMapschema 里的type就能识别。这也是设计器“可扩展”的第一个抓手。Adapter层解决“同一套 schema在不同场景下表现不同”的问题。比如在量表自测模式下每道题答完自动进入下一题在后台编辑模式所有题目平铺展示在打印模式下隐藏所有交互控件只保留题干和选项。这个差异不应该是复制三套表单而是为 renderer 提供不同的mode参数渲染策略跟着模式走。3. 基于 Vue 的动态表单渲染与题型组件扩展3.1 动态渲染component :is的边界与性能动态渲染心理表单最大的坑不是component :is本身而是表格类量表。心理学问卷大量使用“矩阵题”——一行是一个条目一列是程度选项。比如 PHQ-9 的九道条目共用同一组选项标签。如果每条都渲染成独立 radio 组页面会出现十几个重复的el-radio-group性能在低端移动设备上会明显卡顿。常见做法是把矩阵题作为整体渲染用两层v-for循环内部共享同一个选项集。Vue 的响应式系统会为每个选项生成依赖追踪几十个选项的重复渲染会导致内存占用翻倍。优化手段有两个一是给列表项加:keykey 用item.id option.value拼接二是把选项数组用shallowRefmarkRaw包起来避免深层响应式代理带来的开销。template table classmatrix-question thead tr th/th th v-foropt in options :keyopt.value{{ opt.label }}/th /tr /thead tbody tr v-forrow in rows :keyrow.id td{{ row.text }}/td td v-foropt in options :key${row.id}-${opt.value} classtext-center input typeradio :namerow.id :valueopt.value :checkedmodelValue[row.id] opt.value changehandleChange(row.id, opt.value) / /td /tr /tbody /table /templateVue 3 里:name绑定到题目 id保证同一条目内的 radio 互斥不同条目互不影响。为什么用原生input而不是el-radio矩阵题动辄几十个 radioElement Plus 的组件封装较厚渲染开销明显高于原生控件。心理测评场景下交互足够简单原生 radio 配合统一样式反而更快。动态渲染的第二个边界是v-model的滥用。心理表单的答案集合是一个对象Recordstring, string | number | string[]驱动这个集合的更新必须走统一入口而不是每个子组件用自己的 v-model。原因在于跳题逻辑需要监听答案变化统一入口方便做 watch 和事件派发。正确写法是子组件 emit 更新事件父组件统一修改modelValue。3.2 题型组件的分层设计presenter 与 behavior 分离心理表单的题型远不止单选多选。临床上常用的题型还有视觉模拟刻度VAS、Likert 七级量表、语义差异量表、图片选择比如儿童心理测评用情绪脸谱。这要求每个题型组件拆成两层presenter负责渲染behavior负责交互协议。// 每个题型组件导出的 props 结构 interface QuestionProps { item: SchemaItem value: AnswerValue mode: display | edit | print } // 每个题型组件必须实现的交互协议 interface QuestionEmits { (event: update:value, value: AnswerValue): void (event: submit-trigger, payload: { id: string }): void (event: logic-hook, payload: { id: string; value: AnswerValue }): void }logic-hook事件是逻辑引擎的前置触发点某些题型比如滑块题的值是连续变化的用户停留在一个值上超过一定时长逻辑引擎要先做一次轻量评估判断后续题目是否需要跳过。这比“下一题按钮点击后再评估”体验好得多也让逻辑评估从“一次性动作”变成“流式响应”。设计器源码里值得抄的一个做法是normalizeProps函数——把 schema 中各种形态的参数统一成组件内部标准格式。比如有的量表选项是一个数组有的选项是{ A: 完全符合, B: 不太符合 }的键值对象。normalizeProps在组件挂载前统一转换组件内部只认标准格式。3.3 反向计分与缺失值处理的代码位置心理测评的计分规则有两个坑反向计分题和缺失值处理。反向计分的正确做法是在计分引擎里做映射而不是在选项 value 上做文章。如果在 schema 里把选项 value 直接写成换算后的分那这道题展示时选“完全不符合”绑定的 value 是 4但用户实际看到的是“完全不符合”这四个字一旦出现 bug数据会和展示错位。缺失值处理更隐蔽。心理量表的计分通常用总分或均分但只有部分题目作答时不能简单填 0填 0 会把分数拉低导致假阳性。标准做法是允许设置“缺失阈值”比如missingThreshold: 0.2表示缺失超过总题数的 20% 时该维度不参与计分。这个逻辑放在scorer.ts的scoreDimension函数里function scoreDimension(items: SchemaItem[], answers: Recordstring, AnswerValue): DimensionScore { const scoredItems items.filter(item item.scorable ! false) const answeredItems scoredItems.filter(item answers[item.id] ! undefined answers[item.id] ! null answers[item.id] ! ) if (!scoredItems.length) return { score: 0, missingRate: 1, valid: false } const missingRate 1 - answeredItems.length / scoredItems.length if (missingRate 0.2) { return { score: 0, missingRate, valid: false } } let total 0 answeredItems.forEach(item { const rawValue answers[item.id] if (item.reverse) { const maxScore Math.max(...item.options.map(opt opt.value)) total maxScore - Number(rawValue) } else { total Number(rawValue) (item.scoreOffset || 0) } }) return { score: total, missingRate, valid: true } }scoreOffset字段是另一个很多人不知道的设计细节临床量表经常有“本题答‘是’得 1 分答‘否’得 0 分”但当选项 value 设为true/false时Number(true)是 1、Number(false)是 0可以直接用。但像 SCL-90 这类部分条目需要把选项“没有”视为 1 分而不是 0 分此时scoreOffset就派上用场——不用改选项配置只需要在题目上加一个偏移量。4. 心理表单的跳题逻辑引擎与动态校验4.1 表达式解析不要上来就写递归下降跳题逻辑引擎的核心是判断condition表达式。常见做法有三种正则替换 eval、自研表达式解析器、接入第三方表达式库如expr-eval或jexl。心理表单场景建议用第三方库原因有两层需求上表达式形态相对固定比较运算、逻辑与或、括号分组不需要完整语言能力但配置方是量表编辑者表达式写错要给出可读性好的报错第三方库的 AST 解析能较好支持这个诉求。用expr-eval的实现思路是这样先把q1.score这类占位符替换成实际值再交给解析器求值。import { Parser } from expr-eval const parser new Parser() export function evaluateCondition(expression: string, answers: Recordstring, AnswerValue): boolean { // 将 q1.score 替换为 answers 中的数值 const normalizedExpr expression.replace(/(q\d)\.(value|score)/g, (match, id, field) { const answer answers[id] if (answer undefined) return null if (field score) { return String(Number(answer)) } return JSON.stringify(answer) }) try { const result parser.evaluate(normalizedExpr) return Boolean(result) } catch (err) { console.warn(条件表达式解析失败: ${expression}, err) return false } }替换阶段有个用户看得见的体验问题心理测评问卷中用户跳过了某道题作答后续题目此时表达式里对应的q1.value取不到值。用null替换比较稳妥——所有算术运算遇到null都会返回null布尔判断自然变 false。相比用undefinednull不会触发生成 NaN 导致表达式返回true的边界。4.2 什么时候触发逻辑评估watch 深度的选择逻辑评估的触发时机直接影响体验。做过表单的人都知道 Vue 的watch有深层监听的开销问题。心理量表通常十到几十道题量不大深层 watch 完全能扛住但要注意监听整个 answers 对象时每次输入都会触发全量逻辑评估。如果量表中存在跨维度的跳题A 维度第 3 题决定 B 维度是否出现这个评估会在每道题作答时跑一遍。优化手段是给逻辑规则建立依赖索引// 以目标题目 id 为 key存储依赖它的逻辑规则 const logicDependencyMap computed(() { const map: Recordstring, LogicRule[] {} schema.items.forEach(item { item.logic?.forEach(rule { const depIds extractDependencyIds(rule.condition) // 用正则提取 q1、q2... depIds.forEach(depId { if (!map[depId]) map[depId] [] map[depId].push({ sourceId: depId, rule, targetId: item.id }) }) }) }) return map })答题组件 emitlogic-hook时只需找出logicDependencyMap[当前题号]只评估这几条规则涉及的targetId而不是全量重算。量表问卷从 20 道扩展到 200 道时这个优化的差距非常明显。4.3 动态校验必答、条件必答与提交拦截心理表单的校验和普通表单不同普通表单只校验“用户填写的内容合不合法”心理表单还要校验“该出现的题是否已经出现、该隐藏的题是否已隐藏”。比如q5 回答“是”后 q6 必须出现且必答但如果用户在 q5 选“否”后 q6 隐藏提交时 q6 不能参与必答校验。function validateBeforeSubmit(schema: ScaleSchema, answers: Recordstring, AnswerValue): ValidationResult { const errors: ValidationError[] [] // 第一步计算每题是否应显示 const visibilityMap evaluateAllVisibility(schema, answers) // 第二步仅校验 visible 的题目 schema.items.forEach(item { if (!visibilityMap[item.id]) return const answer answers[item.id] const isEmpty answer undefined || answer null || answer if (item.required isEmpty) { errors.push({ itemId: item.id, message: 第 ${item.order} 题为必答题, type: required }) } }) return { valid: errors.length 0, errors } }动态校验最反直觉的点是先算显隐再校验内容。如果把校验写在 visibilityMap 之前一堆已隐藏题目的必答错误会同时冒出来用户会很困惑。另一个细节是order字段——schema 中的题目顺序不一定等于显示顺序。量表设计器允许拖拽排序这个顺序要显式存下来不能用数组索引代替。心理测评会做“题序随机化”防止练习效应此时答案集合 key 也要用题目 id 而不是索引否则随机化后数据全部错乱。5. 心理表单的计分规则配置与结果映射5.1 维度分、总分、标准分的三层计费结构计分引擎是心理表单和普通表单拉开差距的地方。普通表单只需要把表单数据 POST 到后端心理表单还需要在前端完成一次或多次计分。因为很多量表的标准分转换表T 分、Z 分是率表或常模表放在前端避免每次结果请求都要回传全套原始答案也能在离线场景完成测评。三层计费结构是原始分Raw Score→ 维度分Dimension Score→ 标准分T Score。原始分是选项值之和维度分是原始分经反向计分、缺失值修正后歸组标准分是拿维度分查常模表得到的结果。设计器源码里的计分配置 Schema 需要同时包含这三层映射{ scoring: { dimensions: [ { id: depression, name: 抑郁维度, itemIds: [q1, q2, q3], reverseItemIds: [q3], missingThreshold: 0.2 } ], normTable: { type: t-score, mapping: [ { rawRange: [0, 4], tScore: 41 }, { rawRange: [5, 9], tScore: 50 }, { rawRange: [10, 14], tScore: 61 } ], mean: 50, stdDev: 10 } } }T 分常模一般是区间映射不是线性公式。但有些量表提供公式比如T 50 10 * (raw - mean) / std。设计器要同时支持区间查表和公式计算两种模式用一根type字段区分。源码里最常见的 bug 是把区间映射写成if (raw 4)、else if (raw 9)边界值raw 5和raw 4的归属容易错位。可靠做法是二分查找法预先对rawRange做排序每次查表用二分替代线性遍历。5.2 结果解析预警、提示与条码的映射计分结束后还要做结果映射。心理测评系统常需要把分数映射为预警等级绿色/黄色/红色。结果映射本质上是区间判断但它有个“多区间重叠”的问题同一个分数可能同时命中了“中度焦虑”和“自杀风险预警”。源码中需要做优先级排序const resultRules [ { id: suicide-risk, priority: 100, condition: q9.score 2, label: 立即转介 }, { id: severe-anxiety, priority: 80, condition: totalScore 15, label: 重度焦虑 }, { id: moderate-anxiety, priority: 60, condition: totalScore 10, label: 中度焦虑 }, { id: mild-anxiety, priority: 40, condition: totalScore 5, label: 轻度焦虑 } ] export function mapResult(scores: DimensionScore[], answers: Recordstring, AnswerValue): ResultRule[] { const matched resultRules .filter(rule evaluateCondition(rule.condition, { ...answers, totalScore: calculateTotal(scores) })) .sort((a, b) b.priority - a.priority) // 只返回最高优先级的一条或返回全部并按优先级排序由产品决定 return matched }5.3 分值缓存避免答案回退导致累加错误心理测评的计分有一种反直觉的场景用户答到第 10 题然后把第 2 题从“非常符合”改为“不符合”。如果计分用累加器必须在每次答案变化时重算全量分数而不是在旧总分上做差值运算。原因很简单反向计分题存在差值运算需要同时知道新旧两个选项的分值映射一旦缺失值处理规则复杂差值容易算错。源码中推荐在 watch 中做全量重算但要做防抖。用户频繁修改时watch 触发高频重算会卡顿分析面板。按照 100ms 防抖估一下用户连续拖动滑块时滑块事件每秒触发约 30 次100ms 防抖可以把计算量降到每秒最多 10 次体验上无感知、CPU 占用大幅下降。watch(answers, () { debouncedRecalculate() }, { deep: true }) const debouncedRecalculate useDebounceFn(() { const dimensionScores calculateAllDimensions(schema.scoring.dimensions, answers.value) const totalScore dimensionScores.filter(d d.valid).reduce((sum, d) sum d.score, 0) resultMeta.value { dimensionScores, totalScore, rules: mapResult(dimensionScores, answers.value) } }, 100)debouncedRecalculate里有一个容易忽略的安全点计分必须在答案的深拷贝副本上做不能在原对象上原地修改scoreMap的中间态。原因在于 Vue 3 的响应式对象在计算过程中如果被修改依赖追踪会记录变化后的状态下一次触发计算时数据源头已经被污染了。6. 验证与排错设计器上线的三道检查清单6.1 用固定 fixture 数据做回归校验心理表单设计器迭代快最怕的是改了一处题型组件结果量表的跳题逻辑悄悄失效。常见做法是维护一个“黄金答案集”——用一套固定的答案 JSON 跑全流程把得分结果和跳题结果作为快照存入测试代码。每次改动后运行一次快照对比任何分数偏差都能秒级定位。const goldenAnswers { q1: 2, q2: 1, q3: 0, q4: 2, q5: 3, q6: null, /* q6 应为 null因为 q53 才显示 */ q7: 1 } it(GAD-7 完整流程的总分应为 9, () { const result runScale(schema, goldenAnswers) expect(result.totalScore).toBe(9) }) it(q53 时 q6 应显示q5 不等于 3 时 q6 隐藏, () { const visible1 evaluateVisibility(schema, { ...goldenAnswers, q5: 3 }) const visible2 evaluateVisibility(schema, { ...goldenAnswers, q5: 2 }) expect(visible1.q6).toBe(true) expect(visible2.q6).toBe(false) })Fixture 数据时记得测试“中间方向性”的边界条件——比如滑块题正好处于阈值点、矩阵题只作答了一半、反向计分题同时存在多道。心理量表测评结果影响用户心理状态评估快照回归测试比普通业务表单更需要前置在 CI 流程里跑。6.2 Vue 端到端排查watch 触发的隐形事故事Vue 表单设计器上线后最常见的线上 issue 是“跳题没生效”。排查思路应从数据链路逐层点检先确认 answers 对象是否更新、再确认 compute visibilityMap 是否读取了新值、最后确认渲染层是否用了v-if而非v-showv-show 会把隐藏题目留在 DOM 里输入值还在但视觉上看不到恰恰是因为这个容易“看起来正常但数据一直存在”。另一个高频坑是“随机题目顺序”场景下v-for需要显式指定:key为题目 id而不能复用默认索引。一旦题目顺序随机化索引 key 会让组件状态错乱——用户在一道题上选择的内容随机化后可能被渲染在另一道题上。这类问题 Vue Devtools 里很难定位因为状态没有报错只有用户反馈“上次的选择跑到了别的题下面”才能发现。6.3 性能兜底大量表乱序渲染心理普查类量表有时会加载几百道题全部一次性渲染会导致首屏时间无法接受。常见兜底方案是v-if配合“题目批渲染”每次只渲染当前题的相邻 5 题 已答完的全部题目使用nextTick监听滚动位置动态追加后续题目所有题目组件注册进路由级组件缓存切题时避免重新创建心理量表的离散题型组件大多是轻量的但如果是矩阵题 滑块题的混排批渲染对首屏的改善可以达到数倍。设计器源码里可以预留一个batchSize参数默认 10 题一批让量表编辑者按设备性能调节。这个参数看似不起眼但在低端 Android 平板做校园批量测评时决定的是 500 道题能不能在两分钟内流畅跑完。本文还有配套的精品资源点击获取