问题背景
在开发问卷调查/满意度调查功能时,通常支持多种问题类型:
- 单选题
- 多选题
- 评分题
当用户创建调查问卷,选择评分题类型后,点击保存草稿时出现报错。
问题复现
操作步骤:
- 进入满意度调查功能
前端问卷系统中评分题保存草稿报错的原因,发现是数据模型缺少 minScore 字段导致后端校验失败。解决方案包括完善数据模型添加最小分值、补充 UI 控件、增强表单验证逻辑确保分值范围合法、修正数据提交逻辑收集完整字段,以及优化草稿加载时的默认值处理。通过上述步骤,实现了评分题配置的完整性与稳定性。
在开发问卷调查/满意度调查功能时,通常支持多种问题类型:
当用户创建调查问卷,选择评分题类型后,点击保存草稿时出现报错。
操作步骤:
通过代码分析,发现问题根源:
评分题的数据结构只定义了 maxScore(最大分值),缺少 minScore(最小分值):
// 错误的数据结构
{
questionContent: "请对服务评分",
voteType: 3, // 3 = 评分题
maxScore: 5, // 缺少 minScore 字段!
}
页面模板中只有'最大分值'输入框,没有'最小分值'输入框。
提交给后端的数据缺少 minScore 字段,导致后端校验失败。
表单验证只检查了 maxScore,没有验证 minScore 以及两者的关系。
在表单初始化时添加完整的评分字段:
// 问卷表单数据结构
const surveyForm = ref({
title: '',
description: '',
questions: [
{
questionContent: '',
questionType: 1, // 1=单选,2=多选,3=评分
options: [
{ optionName: '', hasError: false },
],
// 评分题专用字段
maxScore: 5, // 最大分值
minScore: 1, // 最小分值(新增)
maxScoreError: false,
maxScoreErrorText: '',
minScoreError: false, // 新增
minScoreErrorText: '', // 新增
questionError: false,
},
],
});
<template>
<view v-for="(question, index) in surveyForm.questions" :key="index">
<!-- 问题内容 -->
<view>
<text>问题内容</text>
<input v-model="question.questionContent" placeholder="请输入问题内容" :class="{ 'input-error': question.questionError }" />
</view>
<!-- 问题类型选择 -->
<view>
<text>问题类型</text>
<view>
<view v-for="type in questionTypes" :key="type.value" :class="['type-option', { active: question.questionType === type.value }]" @click="setQuestionType(index, type.value)">
{{ type.label }}
</view>
</view>
</view>
<!-- 单选/多选的选项列表 -->
<view v-if="question.questionType !== 3">
<view v-for="(option, optIndex) in question.options" :key="optIndex">
<input v-model="option.optionName" placeholder="请输入选项内容" :class="{ 'input-error': option.hasError }" />
<button @click="removeOption(index, optIndex)">删除</button>
</view>
<button @click="addOption(index)">添加选项</button>
</view>
<!-- 评分题配置 -->
<view v-if="question.questionType === 3">
<!-- 最大分值 -->
<view>
<text>最大分值:</text>
<input type="number" v-model.number="question.maxScore" :class="{ 'input-error': question.maxScoreError }" placeholder="请输入最大分值 (1-10)" @blur="handleMaxScoreChange($event, index)" @input="clearMaxScoreError(index)" />
<text v-if="question.maxScoreError"> {{ question.maxScoreErrorText }} </text>
</view>
<!-- 最小分值(新增) -->
<view>
<text>最小分值:</text>
<input type="number" v-model.number="question.minScore" :class="{ 'input-error': question.minScoreError }" placeholder="请输入最小分值 (1-10)" @blur="handleMinScoreChange($event, index)" @input="clearMinScoreError(index)" />
<text v-if="question.minScoreError"> {{ question.minScoreErrorText }} </text>
</view>
<!-- 评分预览 -->
<view>
<text>评分范围预览:{{ question.minScore }} - {{ question.maxScore }} 分</text>
</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue';
const questionTypes = [
{ label: '单选', value: 1 },
{ label: '多选', value: 2 },
{ label: '评分', value: 3 },
];
const surveyForm = ref({
title: '',
description: '',
questions: [createEmptyQuestion()],
});
// 创建空问题
function createEmptyQuestion() {
return {
questionContent: '',
questionType: 1,
options: [{ optionName: '', hasError: false }],
maxScore: 5,
minScore: 1,
maxScoreError: false,
maxScoreErrorText: '',
minScoreError: false,
minScoreErrorText: '',
questionError: false,
};
}
// 设置问题类型
const setQuestionType = (questionIndex, type) => {
const question = surveyForm.value.questions[questionIndex];
question.questionType = type;
if (type === 3) {
// 评分题:设置默认分值,清空选项
question.maxScore = 5;
question.minScore = 1;
question.maxScoreError = false;
question.maxScoreErrorText = '';
question.minScoreError = false;
question.minScoreErrorText = '';
question.options = [];
} else {
// 非评分题:确保有选项
if (!question.options.length) {
question.options = [{ optionName: '', hasError: false }];
}
}
};
// 清除最大分值错误
const clearMaxScoreError = (questionIndex) => {
const question = surveyForm.value.questions[questionIndex];
question.maxScoreError = false;
question.maxScoreErrorText = '';
};
// 清除最小分值错误
const clearMinScoreError = (questionIndex) => {
const question = surveyForm.value.questions[questionIndex];
question.minScoreError = false;
question.minScoreErrorText = '';
};
// 处理最大分值变化
const handleMaxScoreChange = (event, questionIndex) => {
const question = surveyForm.value.questions[questionIndex];
const value = parseInt(event.detail?.value || event.target?.value, 10);
// 清除错误状态
question.maxScoreError = false;
question.maxScoreErrorText = '';
// 验证
if (isNaN(value) || value < 1) {
question.maxScoreError = true;
question.maxScoreErrorText = '请输入大于 0 的整数';
return;
}
if (value > 10) {
question.maxScoreError = true;
question.maxScoreErrorText = '最大值不能超过 10';
question.maxScore = 10;
return;
}
// 验证最大值必须大于最小值
if (question.minScore && value <= question.minScore) {
question.maxScoreError = true;
question.maxScoreErrorText = '最大分值必须大于最小分值';
return;
}
question.maxScore = value;
};
// 处理最小分值变化
const handleMinScoreChange = (event, questionIndex) => {
const question = surveyForm.value.questions[questionIndex];
const value = parseInt(event.detail?.value || event.target?.value, 10);
// 清除错误状态
question.minScoreError = false;
question.minScoreErrorText = '';
// 验证
if (isNaN(value) || value < 1) {
question.minScoreError = true;
question.minScoreErrorText = '请输入大于 0 的整数';
return;
}
if (value > 10) {
question.minScoreError = true;
question.minScoreErrorText = '最大值不能超过 10';
question.minScore = 10;
return;
}
// 验证最小值必须小于最大值
if (question.maxScore && value >= question.maxScore) {
question.minScoreError = true;
question.minScoreErrorText = '最小分值必须小于最大分值';
return;
}
question.minScore = value;
};
</script>
// 表单验证函数
const validateForm = () => {
const errors = [];
let firstErrorField = null;
// 验证标题
if (!surveyForm.value.title.trim()) {
errors.push('title');
if (!firstErrorField) firstErrorField = 'title';
}
// 验证每个问题
surveyForm.value.questions.forEach((question, index) => {
// 验证问题内容
if (!question.questionContent.trim()) {
question.questionError = true;
errors.push(`question_${index}_content`);
if (!firstErrorField) firstErrorField = `question_${index}_content`;
} else {
question.questionError = false;
}
// 根据问题类型验证
if (question.questionType === 3) {
// 评分题验证
validateScoreQuestion(question, index, errors, firstErrorField);
} else {
// 选择题验证
validateOptionsQuestion(question, index, errors, firstErrorField);
}
});
return {
isValid: errors.length === 0,
errors,
firstErrorField,
};
};
// 验证评分题
const validateScoreQuestion = (question, index, errors, firstErrorField) => {
// 验证最大分值
if (
question.maxScore === undefined ||
question.maxScore === null ||
question.maxScore === '' ||
question.maxScore < 1 ||
question.maxScore > 10
) {
question.maxScoreError = true;
question.maxScoreErrorText = '请输入 1-10 的整数';
errors.push(`question_${index}_maxScore`);
if (!firstErrorField) firstErrorField = `question_${index}_maxScore`;
} else {
question.maxScoreError = false;
question.maxScoreErrorText = '';
}
// 验证最小分值
if (
question.minScore === undefined ||
question.minScore === null ||
question.minScore === '' ||
question.minScore < 1 ||
question.minScore > 10
) {
question.minScoreError = true;
question.minScoreErrorText = '请输入 1-10 的整数';
errors.push(`question_${index}_minScore`);
if (!firstErrorField) firstErrorField = `question_${index}_minScore`;
} else {
question.minScoreError = false;
question.minScoreErrorText = '';
}
// 验证分值范围关系
if (!question.maxScoreError && !question.minScoreError && question.maxScore <= question.minScore) {
question.maxScoreError = true;
question.maxScoreErrorText = '最大分值必须大于最小分值';
errors.push(`question_${index}_scoreRange`);
if (!firstErrorField) firstErrorField = `question_${index}_maxScore`;
}
};
// 验证选择题
const validateOptionsQuestion = (question, index, errors, firstErrorField) => {
if (!question.options || question.options.length < 2) {
errors.push(`question_${index}_options`);
if (!firstErrorField) firstErrorField = `question_${index}_options`;
return;
}
question.options.forEach((option, optIndex) => {
if (!option.optionName.trim()) {
option.hasError = true;
errors.push(`question_${index}_option_${optIndex}`);
if (!firstErrorField) firstErrorField = `question_${index}_option_${optIndex}`;
} else {
option.hasError = false;
}
});
};
// 收集表单数据
const collectFormData = () => {
return {
title: surveyForm.value.title,
description: surveyForm.value.description,
questions: surveyForm.value.questions.map((question) => ({
questionContent: question.questionContent,
questionType: question.questionType,
// 选择题提交选项
options: question.questionType !== 3 ? question.options.map((opt) => ({ optionName: opt.optionName })) : [],
// 评分题提交分值范围(关键修复点)
maxScore: question.questionType === 3 ? question.maxScore : undefined,
minScore: question.questionType === 3 ? question.minScore : undefined,
})),
};
};
// 保存草稿
const saveDraft = async () => {
try {
// 验证表单
const { isValid, firstErrorField } = validateForm();
if (!isValid) {
showToast('请完善表单信息');
// 滚动到第一个错误字段
scrollToError(firstErrorField);
return;
}
showLoading('保存中...');
// 收集数据
const formData = collectFormData();
// 调用保存草稿接口
const result = await saveSurveyDraft({
...formData,
isDraft: true,
});
hideLoading();
if (result.success) {
showToast('草稿保存成功');
// 可选:返回列表页
// navigateBack();
} else {
showToast(result.message || '保存失败');
}
} catch (error) {
hideLoading();
console.error('保存草稿失败:', error);
showToast('保存失败,请重试');
}
};
// 提交调查
const submitSurvey = async () => {
try {
const { isValid, firstErrorField } = validateForm();
if (!isValid) {
showToast('请完善表单信息');
scrollToError(firstErrorField);
return;
}
showLoading('提交中...');
const formData = collectFormData();
const result = await createSurvey({
...formData,
isDraft: false,
});
hideLoading();
if (result.success) {
showToast('创建成功');
navigateBack();
} else {
showToast(result.message || '创建失败');
}
} catch (error) {
hideLoading();
showToast('提交失败,请重试');
}
};
// 加载草稿数据
const loadDraftData = async (draftId) => {
try {
showLoading('加载中...');
const response = await getSurveyDetail({ id: draftId });
hideLoading();
if (response.success && response.data) {
const data = response.data;
// 填充表单数据
surveyForm.value.title = data.title || '';
surveyForm.value.description = data.description || '';
// 处理问题列表,确保字段完整
surveyForm.value.questions = (data.questions || []).map((question) => ({
questionContent: question.questionContent || '',
questionType: question.questionType || 1,
options: (question.options || []).map((opt) => ({
optionName: opt.optionName || '',
hasError: false,
})),
// 关键:确保评分题有默认的最大/最小分值
maxScore: question.maxScore ?? (question.questionType === 3 ? 5 : undefined),
minScore: question.minScore ?? (question.questionType === 3 ? 1 : undefined),
maxScoreError: false,
maxScoreErrorText: '',
minScoreError: false,
minScoreErrorText: '',
questionError: false,
}));
// 如果没有问题,添加一个默认问题
if (surveyForm.value.questions.length === 0) {
surveyForm.value.questions.push(createEmptyQuestion());
}
}
} catch (error) {
hideLoading();
showToast('加载失败');
}
};
// 评分题数据 - 缺少 minScore
{
questionContent: "请对服务评分",
questionType: 3,
options: [],
maxScore: 5 // minScore 字段缺失,导致后端校验失败
}
// 评分题数据 - 包含完整的分值范围
{
questionContent: "请对服务评分",
questionType: 3,
options: [],
maxScore: 5,
minScore: 1 // 新增:最小分值
}
设计数据结构时,考虑所有必要字段:
minScore 和 maxScore数据模型有的字段,UI 也要有对应的控件让用户填写。
在 collectFormData 时确保所有必要字段都被正确收集。
从后端加载数据时,使用 ?? 或 || 为可能缺失的字段设置默认值。
// 使用 nullish coalescing 设置默认值
maxScore: data.maxScore ?? 5,
minScore: data.minScore ?? 1,
// TypeScript 类型定义
interface Question {
questionContent: string;
questionType: 1 | 2 | 3; // 1=单选,2=多选,3=评分
// 选择题字段
options?: Array<{
optionName: string;
hasError?: boolean;
}>;
// 评分题字段
maxScore?: number;
minScore?: number;
// 错误状态
questionError?: boolean;
maxScoreError?: boolean;
maxScoreErrorText?: string;
minScoreError?: boolean;
minScoreErrorText?: string;
}
interface Survey {
id?: string;
title: string;
description: string;
questions: Question[];
isDraft: boolean;
createTime?: string;
updateTime?: string;
}

微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online
JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online
使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online
Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online