首页 / 资讯中心 / 文章详情

Spring Boot+Vue在线问卷调查系统:表结构、接口与联调实战

Spring Boot+Vue在线问卷调查系统:表结构、接口与联调实战 ★ FEATURED ARTICLE
简介基于SpringBoot与Vue的在线问卷调查系统是一套面向计算机专业毕设学生及Java学习者的完整项目方案可作为课程设计、期末大作业或毕业设计直接使用。系统围绕问卷全生命周期设计涵盖用户登录认证、问卷创建与编辑、题目配置、发布分享、在线填写、回收数据、统计分析与结果展示等环节前后端分离经严格调试可稳定运行。资源包为RAR压缩格式共755个文件整体约18.28MB主要包含Java后端源码、Vue前端组件、JavaScript交互逻辑、CSS样式及SQL数据库脚本另有开发说明文档、部署和代码讲解视频。已有446人学习下载对于掌握SpringBoot与Vue整合开发、了解前后端分离项目结构的进阶学习者颇具参考价值。借助源码和配套讲解可从零复现运行环境并扩展问卷题型、权限管理等个性化功能。1. 基于SpringbootVue的在线问卷调查系统为什么我还要自己写一个问卷系统第一次决定自己写一个基于SpringbootVue的在线问卷调查系统不是因为找不到现成问卷平台而是因为那些平台对内部系统太不友好用户体系对不上、问卷状态流转不透明、统计数据留在别人家的黑匣子里。等你想在草稿、发布、关闭之间加入自己的审核流程想把答卷明细落到业务库和用户表做关联分析第三方工具基本帮不上忙。这篇文章会从表结构、Springboot后端接口、Vue前端组件到前后端联调排错给出一套能照着敲的最小实现也会把几个让我翻过车的坑提前交代清楚。适合正在做毕业设计或者要给公司后台补一个问卷模块的全栈开发者。2. 从需求到表结构问卷模型设计与Springboot项目骨架在线问卷的核心不是“问卷”这个词而是“题型、选项、答卷”之间的关系。很多人在Controller里拿一个JSON对象就开始存等做到统计功能时才发现题目和选项都在一张表里根本算不出比例。所以第一步不是写接口而是把数据模型画清楚。我习惯按“问卷主表 题目表 选项表 答卷表”拆下面这套表结构是从几个项目中沉淀出来的基本覆盖单选、多选、文本这三种最常见的题型。2.1 问卷、题目、选项、答卷先画这四张核心表先看建表SQL我用的MySQL 8字符集固定utf8mb4避免用户输入生僻字或者Emoji时乱码。CREATE TABLE survey ( id bigint(20) NOT NULL AUTO_INCREMENT, title varchar(128) NOT NULL COMMENT 问卷标题, description varchar(512) DEFAULT NULL COMMENT 问卷说明, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 0草稿 1已发布 2已关闭, start_time datetime DEFAULT NULL COMMENT 生效开始时间, end_time datetime DEFAULT NULL COMMENT 生效结束时间, create_by varchar(64) DEFAULT NULL COMMENT 创建人ID, create_time datetime DEFAULT NULL, update_time datetime DEFAULT NULL, PRIMARY KEY (id), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT问卷主表; CREATE TABLE question ( id bigint(20) NOT NULL AUTO_INCREMENT, survey_id bigint(20) NOT NULL COMMENT 所属问卷ID, question_type tinyint(4) NOT NULL COMMENT 1单选 2多选 3文本, title varchar(512) NOT NULL COMMENT 题干, sort_no int(11) NOT NULL DEFAULT 0 COMMENT 排序号, is_required tinyint(1) NOT NULL DEFAULT 0 COMMENT 是否必答, PRIMARY KEY (id), KEY idx_survey_id (survey_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT题目表; CREATE TABLE question_option ( id bigint(20) NOT NULL AUTO_INCREMENT, question_id bigint(20) NOT NULL COMMENT 所属题目ID, option_text varchar(256) NOT NULL COMMENT 选项内容, sort_no int(11) NOT NULL DEFAULT 0 COMMENT 排序号, PRIMARY KEY (id), KEY idx_question_id (question_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT选项表; CREATE TABLE answer_record ( id bigint(20) NOT NULL AUTO_INCREMENT, survey_id bigint(20) NOT NULL COMMENT 问卷ID, user_id varchar(64) DEFAULT NULL COMMENT 答题人ID匿名问卷为空, submit_time datetime DEFAULT NULL, PRIMARY KEY (id), KEY idx_survey_user (survey_id, user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT答卷主表; CREATE TABLE answer_detail ( id bigint(20) NOT NULL AUTO_INCREMENT, record_id bigint(20) NOT NULL COMMENT 答卷主表ID, question_id bigint(20) NOT NULL COMMENT 题目ID, answer_text text COMMENT 单选存选项ID多选存逗号分隔的选项ID文本存原始内容, PRIMARY KEY (id), KEY idx_record_id (record_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT答卷明细表;题目和选项单独拆表是为了支持“一份问卷里有不同数量的题目一道题里有不同数量的选项”。如果把选项存成JSON数组塞在题目表里后面的统计和题目复用都会很痛苦。status字段是问卷的“生命线”草稿、发布、关闭三种状态必须由后端统一流转不能靠前端改一个数字。答卷主表和明细表分开是因为一次提交对应一批答案明细表以record_id关联可以批量插入查询统计时也能走索引。2.2 用Spring Initializr快速起一个Springboot工程后端我这里选用Spring Boot 2.7.18搭配MyBatis-Plus。在这个版本上JDK8和Maven项目都能稳定跑自动装配原理也不复杂足够撑起一个在线问卷调查系统。用IDEA创建时依赖先勾Spring Web、Validation、Lombok再手动补MyBatis-Plus和MySQL驱动。parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies选MyBatis-Plus而不是JPA是因为原生Mapper和Page插件对复杂查询更直接问卷统计时写SQL不受ORM限制。spring-boot-starter-validation是必加的创建问卷时前端传过来的DTO要靠它做空值校验。接下来是application.yml注意时区和驼峰映射这两个配置后面前后对阵时间格式会用到。server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/survey_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: automap-underscore-to-camel-case让create_time自动映射到createTime不用每个字段都写映射注解。log-impl打开后控制台会打印SQL联调阶段排查参数问题非常有用。id-type: auto配合数据库自增主键插入后实体类的id字段会被自动回填。2.3 用MyBatis-Plus把CRUD落到最小闭环实体类只需要在类名和主键上做标记。以Survey为例Data TableName(survey) public class Survey { TableId(type IdType.AUTO) private Long id; private String title; private String description; private Integer status; private LocalDateTime startTime; private LocalDateTime endTime; private String createBy; private LocalDateTime createTime; private LocalDateTime updateTime; }TableName指定表名TableId(type IdType.AUTO)声明数据库自增主键。这里所有字段都用LocalDateTime而不是Date配合后面的JsonFormat更好控制输出格式。Mapper接口继承BaseMapper单表CRUD、分页、条件查询基本不用手写SQL。Mapper public interface SurveyMapper extends BaseMapperSurvey { } Mapper public interface QuestionMapper extends BaseMapperQuestion { } Mapper public interface QuestionOptionMapper extends BaseMapperQuestionOption { }分页是问卷后台列表的刚需MyBatis-Plus 3.5.x 必须显式注册分页插件否则selectPage会查全表。Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }PaginationInnerInterceptor参数里的DbType.MYSQL告诉插件当前数据库方言它才能拼接LIMIT ?。这一步往往是网上教程没提的坑很多新项目抄了Mapper代码却忘了注册这个Bean结果分页整页都是同一个List。Service层拿到这个配置后一个标准的后台分页查询长这样Service RequiredArgsConstructor public class SurveyService { private final SurveyMapper surveyMapper; public PageSurvey pageQuery(int pageNum, int pageSize) { PageSurvey page new Page(pageNum, pageSize); LambdaQueryWrapperSurvey wrapper new LambdaQueryWrapperSurvey() .eq(Survey::getStatus, 1) .orderByDesc(Survey::getCreateTime); return surveyMapper.selectPage(page, wrapper); } }LambdaQueryWrapper是MyBatis-Plus推荐的条件构造器用方法引用写字段名编译期就能发现拼写错误。page对象里已经带了total、records可以直接丢给前端不需要自己封装分页Result。3. 后端接口设计一份问卷从草稿到发布的完整链路问卷系统的后端难点不在增删改查而在于“状态机”和“事务边界”。创建一个问卷要同时写题目和选项发布问卷要校验状态提交答卷要防止重复。这些动作如果没设计好用户连点两下就会造出脏数据。我见过的靠谱做法是把接口按“草稿管理、发布、答卷提交”三条链路拆开每条链路只做一件事。3.1 问卷状态机与接口分层先明确状态流转规则后端才算有守门人。状态值含义允许的操作0草稿编辑问卷、修改题目、删除1已发布用户答题、关闭问卷题目只读2已关闭查看统计、查看答卷不可再提交Controller只负责接收参数、调用ServiceService负责事务和状态判断Mapper只碰SQL。这样分层的好处是将来要加“审核中”状态只需要改Service里的判断逻辑Controller和数据库表都稳定。状态流转不能裸写survey.setStatus(1)然后updateById因为并发情况下两个请求同时执行可能把“草稿”和“已发布”都跳过去。状态更新必须带查询条件这是关键。3.2 创建问卷题目批量保存的实现创建问卷的入参用DTO接收不要直接吃前端传来的JSON Map。DTO里嵌一个ListQuestionItem题目选项一起传入。Data public class SurveySaveRequest { NotBlank(message 问卷标题不能为空) private String title; private String description; NotNull(message 题目列表不能为空) Size(min 1, message 至少包含一道题目) private ListQuestionItem questions; Data public static class QuestionItem { NotNull(message 题型不能为空) private Integer questionType; // 1单选 2多选 3文本 NotBlank(message 题干不能为空) private String title; private Boolean required; private ListString options; } }Size(min 1)是很多人会漏掉的一层校验只靠前端判断“题目不能为空”不够Postman直接调接口时后端必须拦住。Service层用注解事务一次性插入问卷、题目、选项。Transactional(rollbackFor Exception.class) public Long createSurvey(SurveySaveRequest request) { Survey survey new Survey(); survey.setTitle(request.getTitle()); survey.setDescription(request.getDescription()); survey.setStatus(0); survey.setCreateTime(LocalDateTime.now()); surveyMapper.insert(survey); if (CollectionUtils.isEmpty(request.getQuestions())) { throw new BizException(题目不能为空); } int questionIndex 0; for (SurveySaveRequest.QuestionItem item : request.getQuestions()) { Question question new Question(); question.setSurveyId(survey.getId()); question.setQuestionType(item.getQuestionType()); question.setTitle(item.getTitle()); question.setIsRequired(Boolean.TRUE.equals(item.getRequired()) ? 1 : 0); question.setSortNo(questionIndex); questionMapper.insert(question); if (item.getQuestionType() ! 3 !CollectionUtils.isEmpty(item.getOptions())) { int optionIndex 0; for (String optionText : item.getOptions()) { QuestionOption option new QuestionOption(); option.setQuestionId(question.getId()); option.setOptionText(optionText); option.setSortNo(optionIndex); questionOptionMapper.insert(option); } } } return survey.getId(); }为什么必须在方法上加Transactional(rollbackFor Exception.class)默认事务只在遇到RuntimeException时才回滚如果中途爆了BizException这类受检异常前面插入的题库会残留。把题目的sortNo和选项的sortNo都用自增索引赋值能保证前端渲染顺序稳定。文本题没有选项所以用questionType ! 3做判断避免给文本题插入一行空选项。3.3 发布问卷状态校验与幂等处理发布操作的正确姿势是“条件更新”。把status 0放到UPDATE的WHERE里数据库层保证同时只有一个请求能成功。Transactional(rollbackFor Exception.class) public boolean publish(Long id) { Survey survey surveyMapper.selectById(id); if (survey null) { throw new BizException(问卷不存在); } if (survey.getStatus() ! 0) { throw new BizException(只有草稿状态才能发布); } LambdaUpdateWrapperSurvey updateWrapper new LambdaUpdateWrapperSurvey() .eq(Survey::getId, id) .eq(Survey::getStatus, 0) .set(Survey::getStatus, 1) .set(Survey::getUpdateTime, LocalDateTime.now()); return surveyMapper.update(null, updateWrapper) 1; }先用selectById查出问卷是为了给用户一个明确的业务提示再用条件更新update是为了防止两次点击时并发重复发布。两段代码各有职责不能省略第一次查询直接返回“操作失败”。用户提交答卷同样要防重。最简单可靠的防重做法是给answer_record加唯一索引ALTER TABLE answer_record ADD UNIQUE KEY uk_survey_user (survey_id, user_id);如果业务允许一个用户多次提交那就不加唯一索引改成在业务表里记录“提交批次号”。但绝大多数内部问卷只要一次唯一索引是最后一道防线代码里先查再插只会减少错误提示无法根治并发。提交接口里做了唯一索引后重复提交时数据库会抛DuplicateKeyException把它翻译成“你已经提交过问卷”就行。4. 前端Vue3Vite问卷编辑器与答题页从零搭起来问卷调查系统的前端其实分两个完全不同的页面问卷编辑器给配置人员用和答题页给普通用户用。编辑器要能动态增删题目、调整选项答题页要能按题型渲染控件、收集答案并校验。用Vue3 Vite跑这件事很合适组件化之后编辑器里的每个题目都能独立维护答题页则保持极简一个组件负责一种题型。4.1 问卷编辑器的组件拆分编辑器我习惯拆成SurveyEditor和QuestionItem两个组件。父组件只维护题目数组子组件负责渲染单个题目。!-- SurveyEditor.vue -- template div div v-for(q, index) in questions :keyq.id QuestionItem :model-valueq update:model-valuequestions[index] $event removeremoveQuestion(index) / /div button clickaddQuestion新增题目/button /div /template script setup import { ref } from vue; import QuestionItem from ./QuestionItem.vue; const questions ref([ { id: Date.now(), questionType: 1, title: , required: true, options: [, ] } ]); const addQuestion () { questions.value.push({ id: Date.now(), questionType: 1, title: , required: false, options: [, ] }); }; const removeQuestion (index) questions.value.splice(index, 1); /script父组件用v-model把每个题目对象传给子组件子组件修改后通过update:model-value事件把新对象抛回。这样每个题目都是一个不可直接乱改的独立数据流方便以后扩展“复制题目”“移动题目顺序”这些操作。子组件里对每个输入框做单向绑定再包一层更新方法!-- QuestionItem.vue -- template div classquestion-item input :valuemodelValue.title placeholder请输入题干 inputupdateField(title, $event.target.value) / select :valuemodelValue.questionType changeupdateField(questionType, Number($event.target.value)) option :value1单选/option option :value2多选/option option :value3文本/option /select div v-for(opt, i) in modelValue.options :keyi input :valueopt placeholder选项内容 inputupdateOption(i, $event.target.value) / button clickremoveOption(i)删除/button /div /div /template script setup const props defineProps({ modelValue: { type: Object, required: true } }); const emit defineEmits([update:modelValue, remove]); const updateField (field, value) { emit(update:modelValue, { ...props.modelValue, [field]: value }); }; const updateOption (index, text) { const options [...props.modelValue.options]; options[index] text; emit(update:modelValue, { ...props.modelValue, options }); }; const removeOption (index) { const options [...props.modelValue.options]; options.splice(index, 1); emit(update:modelValue, { ...props.modelValue, options }); }; /script这里没有直接v-modelmodelValue.title是因为modelValue是父组件传下来的 prop直接改对象属性在Vue3里会破坏单向数据流调试时会变得很难追踪。用展开运算符生成新对象每次修改都能明确知道“题目对象哪一处变了”。updateOption里先复制数组再替换保证不可变更新文本题没有选项数组所以模板里要用v-ifmodelValue.questionType ! 3把选项区包起来不然控制台会报错。4.2 答题页的数据收集与校验答题页的核心是“一个对象接住所有答案”。键是题目id值是字符串或数组。单选存选项id多选自动变成数组文本直接存字符串。template form submit.preventsubmit div v-forq in questions :keyq.id p {{ q.title }} span v-ifq.isRequired classrequired*/span /p template v-ifq.questionType 1 label v-foropt in q.options :keyopt.id input typeradio :nameq- q.id :valueopt.id v-modelanswers[q.id] / {{ opt.optionText }} /label /template template v-else-ifq.questionType 2 label v-foropt in q.options :keyopt.id input typecheckbox :valueopt.id v-modelanswers[q.id] / {{ opt.optionText }} /label /template textarea v-else-ifq.questionType 3 v-modelanswers[q.id] rows3 /textarea /div button typesubmit提交问卷/button /form /template script setup import { reactive } from vue; const props defineProps({ questions: { type: Array, required: true } }); const answers reactive({}); const submit () { for (const q of props.questions) { if (!q.isRequired) continue; const value answers[q.id]; if (Array.isArray(value) ? value.length 0 : value undefined || value ) { alert(请回答 q.title); return; } } const payload { surveyId: props.surveyId, answers: props.questions.map((q) { const value answers[q.id]; return { questionId: q.id, answerText: Array.isArray(value) ? value.join(,) : value }; }) }; console.log(提交数据, payload); // 调用后端接口 }; /script这里的技巧在于reactive({})里动态加属性是响应式的v-modelanswers[q.id]在首次渲染时如果answers[q.id]不存在Vue会自动为它创建空值并绑定。提交校验时多选字段是一个数组所以用Array.isArray区分处理单选和文本字段是字符串或undefined统一判断空值。把多选数组用join(,)转成逗号分隔字符串和后端answer_detail.answer_text字段的设计对齐。4.3 用Axios接后端接口跨域配置与统一响应处理前端页面对接Springboot后端我一般封装一个http实例统一处理响应码和错误提示。import axios from axios; const http axios.create({ baseURL: /api, timeout: 10000 }); http.interceptors.response.use( (response) { const res response.data; if (res.code ! 200) { return Promise.reject(new Error(res.message)); } return res.data; }, (error) Promise.reject(error) ); export default http;后端约定所有接口返回{ code: 200, message: ok, data: ... }前端拦截器直接把data解出来业务代码里拿到的就是干净的业务对象不需要每个页面都写response.data.data。超时时间设为10秒问卷保存到数据库时如果题目数量多一次事务可能超过3秒太短会误报网络错误。前后端分离后跨域是绕不开的问题。开发环境下前端的Vite默认跑在5173后端是8080两个端口不同浏览器会拦截。后端统一允许跨域Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOrigins(http://localhost:5173) .allowedMethods(GET, POST, PUT, DELETE) .allowedHeaders(*) .allowCredentials(true); } }注意allowedOrigins不能和allowCredentials(true)一起用*。allowCredentials(true)表示允许携带Cookie此时源地址必须是明确白名单如果前端不需要Cookie可以直接把allowCredentials删掉允许所有源。问卷系统要做登录态识别所以保留Cookie并锁定源地址。5. 避坑/常见问题排查前后端联调里最常翻车的4个现场问卷系统本质是表单系统前后端联调时出问题的地方高度集中。下面这4个坑基本是我在两个项目里真实踩过的按“现象 → 原因 → 解决”的顺序写清楚。5.1 跨域配置好了还是报CORS先看是不是预检请求被拦了现象后端加了CorsConfig前端请求还是报 “Access-Control-Allow-Origin” 错误而且只在POST请求时出现GET请求正常。原因当POST请求携带JSON数据时浏览器会先发一个OPTIONS预检请求。Spring Boot的CorsConfig能处理这个预检但如果你项目里同时存在自定义过滤器或Spring Security过滤器且过滤器没有对OPTIONS直接放行预检请求就会在进入CorsConfig之前被拦截返回的响应里没有CORS头。解决在全局过滤器最前面加一段放行逻辑。我一般在自定义OncePerRequestFilter的doFilterInternal里写if (OPTIONS.equalsIgnoreCase(request.getMethod())) { response.setStatus(HttpServletResponse.SC_OK); response.setHeader(Access-Control-Allow-Origin, request.getHeader(Origin)); response.setHeader(Access-Control-Allow-Methods, GET, POST, PUT, DELETE, OPTIONS); response.setHeader(Access-Control-Allow-Headers, *); return; }这样预检请求在进入业务链路前就被放掉真实请求再走CorsConfig。如果你用了Spring Security记住securityFilterChain里必须permitAll掉/api/**否则登录态验不过。5.2 问卷接口返回JSON死循环实体类双向引用惹的祸现象接口返回问卷列表时控制台报 “Could not write JSON: Infinite recursion (StackOverflowError)”浏览器收到的响应永远转圈。原因我在Survey实体里加了ListQuestion questions又在Question实体里加了Survey survey。Jackson序列化Survey时发现它关联Question就继续序列化Question而Question又带着Survey于是无限递归下去。解决最稳妥的方案是不在实体里搞双向关联。Question实体只保留surveyId字段需要查题目时用Service单独查而不是在实体上直接OneToMany。如果项目已经用了MyBatis-Plus本身也没有JPA那种自动关联这个坑反而不太容易出现。但如果你用了TableField(exist false)加关联列表一定要注意在其中一个方向加JsonIgnoreJsonIgnore private Survey survey;更推荐的做法是后端返回一个SurveyDetailVO里面按需组装题目和选项列表。VO只包含前端展示要的数据彻底绕开实体关系的序列化问题。这个思路在问卷详情接口里也适用。5.3 前端拿到的时间少了8个小时LocalDateTime格式化没做干净现象前端在表单里查看问卷发布时间显示的是2024-08-01T10:00:00而且比数据库里看到的时间差了8个小时。原因实体用了LocalDateTimeJackson对它的默认序列化格式是ISO格式不带你说的 “yyyy-MM-dd HH:mm:ss”。如果后端配置只写了spring.jackson.date-format这个配置只对java.util.Date生效对LocalDateTime完全无效。解决我建议直接在实体时间字段上加JsonFormat一劳永逸JsonFormat(pattern yyyy-MM-dd HH:mm:ss, timezone GMT8) private LocalDateTime createTime;timezone GMT8必须带上不然服务器部署在UTC时区时返回的时间会少8小时。如果你项目里时间字段特别多也可以全局自定义一个Jackson配置类注册LocalDateTimeSerializer但记得把LocalDateTime的JsonDeserialize也对应配好否则反序列化JSON提交时又会出问题。5.4 Vue项目用history路由刷新页面就404现象本地开发好好的打包部署到Linux后点进问卷详情页一刷新就出现 Nginx 404。原因Vue Router用了createWebHistory路由走的是前端路由比如/surveys/123。浏览器刷新时Nginx会到服务器找surveys/123这个文件找不到就返回404。实际上这个路径应该交给前端index.html去匹配。解决在Nginx站点配置里加一个兜底location / { root /usr/share/nginx/html; try_files $uri $uri/ /index.html; }try_files的意思是先找真实文件找不到就全部交给index.html由Vue Router接管。如果你的问卷后台和API共用同一个域名记得把/api的location放在前面让接口请求先被代理到后端Springboot服务不要被这条规则吞掉。这个坑非常经典我跟朋友排查时发现他前端路由模式和部署方式不匹配。换createWebHashHistory确实也能解决但URL会多一个#不够整洁。生产环境我倾向于保留history模式把Nginx配置写对。6. 进阶让问卷系统真正能上线的三个小技巧基础链路跑通之后真正决定这个系统能不能交付的是几个边角料。第一个必做的是答卷防重复提交。除了前面说的answer_record唯一索引接口层还应该在提交时先查一次已答记录给用户返回一个友好提示。只靠数据库约束的话用户双击提交收到的是一行红字“Duplicate entry”体验很差。两个手段一起上前端按钮提交后也要立刻置灰。第二个技巧是问卷关闭后通过一条SQL把每个题目的选项分布算出来。文本题没法统计单选和多选可以用这种思路SELECT d.question_id, d.answer_text, COUNT(*) AS count FROM answer_detail d JOIN answer_record r ON d.record_id r.id WHERE r.survey_id #{surveyId} AND d.answer_text IN (SELECT id FROM question_option WHERE question_id d.question_id) GROUP BY d.question_id, d.answer_text ORDER BY d.question_id;这条SQL把明细表里的选项ID和选项表做匹配直接按题目和选项聚合。注意多选如果存的是1,3IN匹配不上所以建表时多选答案如果真要放进answer_text应该每个选项单独一行。我现在做问卷系统从建表开始就把多选答案拆成多条明细统计时就不需要LIKE%1,3%这种性能灾难。第三个技巧是导出答卷为Excel。网上有EasyExcel的现成API但如果只是给管理员看我更喜欢直接用POI手动写列。导出的列顺序按题目ID排序 选项结果拼成一个二维列表导出逻辑和问卷详情接口完全解耦。这里有个习惯值得保留导出文件名的后缀加上时间戳避免浏览器缓存同名文件不然每次导出的数据都是旧的。现在我做问卷系统不管对方是不是只要求毕设水平都会先把状态机、唯一索引和导出文件名的这三个问题写进设计文档再动手。它们单看不复杂但加在一起决定了这个系统是演示Demo还是一个能交付的内部工具。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站