Flask + Vue 问卷系统实战:从数据库设计到部署避坑 简介基于Python与Flask的问卷调查应用面向毕业设计及课程设计场景覆盖问卷创建、发布、填写、结果统计等典型功能后端采用Flask框架前端由HTML/CSS构成并配套数据库迁移与初始化脚本便于快速理解项目分层与核心处理流程。压缩包共48个文件包含14个Python源码、13个HTML页面、8个JSON配置和5个CSS样式另有依赖清单、部署配置及说明文档总体仅36KB结构紧凑适合直接导入开发工具运行调试。代码内可看到数据初始化脚本、数据库迁移目录、认证、问卷、提交、结果等业务模块组织方式清晰。项目已在Windows 10/11环境验证通过附带完整使用文档对环境准备、启动方式和功能操作均有说明下载后可按文档完成部署与体验。目前已有343人学习关注整体兼具规范性与扩展性可作为毕业设计高分参考模板或二次开发基础。1. 基于 Flask Vue 的问卷系统被毕设浪费掉的高分模板如果你正在为毕业设计发愁或者想找一份「前后端分离、有完整业务闭环」的 Python 项目来练手这套基于 Python Flask Vue 的问卷调查应用是少数能直接落地的源码。它不是那种只有 CRUD 的玩具项目而是把「问卷编辑器、发布状态机、答卷收集、统计聚合」整个链路都做了出来用户端拿链接就能填管理端能实时看每个选项的分布。比起大多数只用两张表糊弄答辩的课设这份源码的价值在于——你拿到的是一套可以一边读一边改的生产级骨架不只是能跑起来还值得复现。2. 项目骨架拆解Flask 与 Vue 的分工、目录布局和本地跑通2.1 为什么是 Flask Vue而不是 Django 模板先说技术选型。Django 自带 Admin 后台和 ORM写问卷系统确实省事但对毕业设计来说有两个问题一是模板渲染出来的页面交互感弱展开题目类型切换、拖动排序这类操作要堆大量 jQuery二是答辩老师看到「Django 全家桶」大概率会追问部署和性能答不好反而扣分。Flask 的优势在轻路由和 API 自己掌控数据库模型可以只写业务需要的四张表配合 Vue 做前端交互前后端职责特别清晰——Flask 只负责出 JSON 接口Vue 负责把问卷编辑器做成可拖可拽的样子。另一层原因是运行环境的问题。问卷编辑器这种页面原生 JS 写起来能让你怀疑人生而 Vue 的单文件组件把模板、脚本、样式放在一起改一个题型渲染逻辑只需要动一个文件。Flask 侧只需要按特定路由吐出数据比如「返回某份问卷的全部题目和选项」前端拿到以后自己渲染成表单。这种分离方式也方便把问卷填写页单独做一个公众链接不加管理后台的逻辑。2.2 源码目录布局五分钟找到每一层把压缩包解压之后目录组织大概是这样的具体文件名以源码里的为准但分层思路一致survey_project/ ├── run.py # 应用入口创建 app、注册蓝图、启动服务 ├── config.py # 配置项SECRET_KEY、数据库地址、上传大小 ├── requirements.txt # 依赖清单flask、flask-sqlalchemy 等 ├── app/ │ ├── __init__.py # create_app 工厂函数 │ ├── models.py # 四张核心表问卷、题目、选项、答卷/答案 │ ├── api/ │ │ ├── auth_api.py # 管理员登录、登出 │ │ ├── survey_api.py # 问卷 CRUD、发布、关闭 │ │ └── stat_api.py # 统计聚合接口 │ └── utils.py # 登录校验装饰器、统一返回格式 ├── frontend/ │ ├── package.json # Vue 项目依赖 │ └── src/ │ ├── main.js # Vue 入口挂载路由 │ ├── App.vue # 根组件菜单、路由出口 │ └── views/ │ ├── Login.vue # 管理员登录页 │ ├── SurveyList.vue # 问卷列表与状态管理 │ ├── SurveyEdit.vue # 问卷编辑器核心文件 │ └── SurveyFill.vue # 公众填写页 └── docs/ └── 使用文档.md # 操作说明、接口清单拿到源码第一步不要急着跑先对着目录认一下models.py和SurveyEdit.vue这两个文件。前者决定了数据的血缘关系后者是整个项目的交互核心。把这两个文件读懂了后面改需求就顺了。2.3 本地跑通安装依赖、建库与启动Python 环境建议用 3.8 到 3.10 之间的版本太高或太低都有可能遇到个别依赖没预编译包的问题。如果你机器上已经装了 Anaconda创建一个干净环境最省事conda create -n survey python3.9 -y conda activate survey cd survey_project pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple依赖安装完成后初始化数据库。源码一般会在docs/使用文档.md里写清楚建库命令如果没有现成脚本常见做法是直接跑一段 Python 命令python -c from app import create_app; from app.models import db; app create_app(); app.app_context().push(); db.create_all()这条命令做了三件事create_app()读取配置并生成应用实例db.create_all()根据 models 里定义的表结构直接建表。SQLite 默认就是在项目目录下生成一个 .db 文件不需要额外装数据库服务对毕设场景很友好。建完库以后启动开发服务器python run.py启动后访问http://127.0.0.1:5000。默认的管理员账号密码一般写在docs/使用文档.md或者utils.py的初始化逻辑里找不到就在数据库里直接插一条。开发模式下的默认端口是 5000如果被占用可以在run.py里给app.run()传入port5001换掉。3. 问卷数据模型设计四张表的血缘关系与建库脚本3.1 四张表的职责边界从问卷到答案的血缘链路问卷系统的核心不是页面是数据模型。如果只建一张「问卷表」加一个「答案表」那问卷题目和选项的编辑功能根本没法实现——题目是可增删的、选项是可增删的、问卷和答案之间还要按提交批次切分。所以这份源码里的表设计是分层拆的问卷表Survey存问卷标题、描述、状态草稿/已发布/已关闭。发布和关闭只动这张表的 status 字段和题目、答案不纠缠。题目表Question通过外键挂在问卷下存题型单选题、多选题、文本题、题干内容、是否必填、排序号。选项表OptionItem挂在题目下。单选题和多选题才有选项文本题该表无数据。答卷表Submission 答案表AnswerRecord一份答卷对应一条 Submission记录提交时间每个题的答案逐条写进 AnswerRecord选择题的答案存选项 ID文本题直接存文本。这四张表串起来就是一条完整血缘链问卷 → 题目 → 选项问卷 → 答卷 → 答案。这样做的好处有两个第一问卷复制的时候可以整棵树一起复制第二做统计的时候按选项 ID 做 GROUP BY 效率极高不需要去解析一个拼接的大字符串。3.2 models.py 建表代码与字段取舍models.py 里用 Flask-SQLAlchemy 定义模型核心代码长这样from flask_sqlalchemy import SQLAlchemy from datetime import datetime db SQLAlchemy() class Survey(db.Model): __tablename__ survey id db.Column(db.Integer, primary_keyTrue) title db.Column(db.String(200), nullableFalse) description db.Column(db.Text, default) status db.Column(db.String(20), defaultdraft) # draft/published/closed created_at db.Column(db.DateTime, defaultdatetime.now) questions db.relationship(Question, backrefsurvey, cascadeall, delete-orphan, order_byQuestion.sort_order) class Question(db.Model): __tablename__ question id db.Column(db.Integer, primary_keyTrue) survey_id db.Column(db.Integer, db.ForeignKey(survey.id), nullableFalse) qtype db.Column(db.String(10), nullableFalse) # single/multiple/text content db.Column(db.Text, nullableFalse) required db.Column(db.Boolean, defaultTrue) sort_order db.Column(db.Integer, default0) options db.relationship(OptionItem, backrefquestion, cascadeall, delete-orphan, order_byOptionItem.sort_order) class OptionItem(db.Model): __tablename__ option_item id db.Column(db.Integer, primary_keyTrue) question_id db.Column(db.Integer, db.ForeignKey(question.id), nullableFalse) content db.Column(db.String(200), nullableFalse) sort_order db.Column(db.Integer, default0) class Submission(db.Model): __tablename__ submission id db.Column(db.Integer, primary_keyTrue) survey_id db.Column(db.Integer, db.ForeignKey(survey.id), nullableFalse) submitted_at db.Column(db.DateTime, defaultdatetime.now) class AnswerRecord(db.Model): __tablename__ answer_record id db.Column(db.Integer, primary_keyTrue) submission_id db.Column(db.Integer, db.ForeignKey(submission.id), nullableFalse) question_id db.Column(db.Integer, db.ForeignKey(question.id), nullableFalse) content db.Column(db.Text) # 单选存选项ID多选用逗号拼接文本题存原文两个关键的字段取舍值得展开说。第一是status字段用了字符串而不是布尔值因为问卷有「草稿-已发布-已关闭」三种状态布尔值只能表达两种第二是cascadeall, delete-orphan这个配置的作用是删除问卷时数据库会自动连带删除它的所有题目和选项。如果漏掉这个参数你在前端删一份问卷时就会报外键约束错误或者留下脏数据。另外 text 类型的AnswerRecord.content用了万能存储法单选存选项 ID 字符串、多选存逗号拼接的 ID 串、文本题直接存文本。这种设计牺牲了一点规范化但换来的是全题型共用一张表。3.3 初始化建库与验证删掉重来也不慌建库脚本在models.py同目录下单独执行一次就好。每次改动模型字段以后SQLite 不支持直接 ALTER TABLE所以开发期的常见做法是把旧库文件删掉重新建。注意生产环境别这么干那会儿要用 Flask-Migrate 做迁移。验证建库是否成功连上 SQLite 看一眼python -c import sqlite3; conn sqlite3.connect(survey.db); print(conn.execute(SELECT name FROM sqlite_master WHERE type\table\).fetchall())正常输出会包含survey、question、option_item、submission、answer_record这五张表名。看到这里数据层就通了一半接下来可以进入接口逻辑。4. 核心流程实现问卷编辑、填写收集与统计聚合4.1 问卷编辑器的关键交互动态增删题目SurveyEdit.vue 是整个项目的技术担当。它要完成的事情核心就一条题目列表是动态的增删、切题型、填选项都要实时反映到数据模型上。这类页面的标准做法是在 Vue 的 data 里维护一个questions数组数组的每一项包含type、content、required和options列表然后通过v-for循环渲染编辑区。模板侧最核心的片段div v-for(q, qIndex) in questions :keyqIndex classquestion-card input v-modelq.content placeholder请输入题干 / select v-modelq.type changeonTypeChange(q) option valuesingle单选题/option option valuemultiple多选题/option option valuetext文本题/option /select div v-ifq.type ! text div v-for(opt, oIndex) in q.options :keyoIndex input v-modelopt.content placeholder选项内容 / button clickremoveOption(q, oIndex)删除/button /div button clickaddOption(q)添加选项/button /div button clickremoveQuestion(qIndex)删除本题/button /div button clickaddQuestion添加题目/button这段模板的逻辑是外层v-for遍历题目内层在题型不是文本题时再遍历选项。「切换题型」是这里最容易出 bug 的交互onTypeChange必须处理「从选择题切到文本题时清空 options」这个动作不然提交数据时会带着残留的选项 ID。对应的 script 逻辑methods: { addQuestion() { this.questions.push({ type: single, content: , required: true, options: [{ content: }, { content: }] }) }, addOption(q) { q.options.push({ content: }) }, onTypeChange(q) { if (q.type text) { q.options [] } else if (q.options.length 0) { q.options [{ content: }, { content: }] } } }addQuestion里每一题默认带两个空选项这是刻意设计的——一来模拟真实问卷至少两个选项二来避免多选题没选项可选。注意操作questions数组时千万不要用this.questions[index] xxx这种方式直接替换Vue 2 的响应式系统监听不到这种变更页面不会刷新要用$set或者先splice再插入。这是 Vue 2 的经典坑代码里遵守这个规则就能绕开。4.2 发布状态机与填写提交 API问卷编辑完保存成草稿发布以后生成公众链接。后端这一段的两个状态接口在survey_api.py里逻辑并不复杂发布就是校验问卷至少有一道题然后把status改为published关闭就是改成closed。真正容易疏忽的是「已发布的问卷如果题目被改动统计口径会变」这个问题。实践中一般做法是发布后禁止编辑题目前端在编辑页弹出提示并禁用保存。填写页的提交接口是公众端主入口拿到一份提交时要有两个动作插入Submission记录拿到自增 ID再逐题插入AnswerRecord。后端伪码survey_api.route(/api/surveys/int:sid/submit, methods[POST]) def submit_survey(sid): survey Survey.query.get_or_404(sid) if survey.status ! published: return {code: 400, msg: 问卷未发布或已关闭}, 400 sub Submission(survey_idsid) db.session.add(sub) db.session.flush() # 立即生成 sub.id data request.get_json() for item in data[answers]: db.session.add(AnswerRecord( submission_idsub.id, question_iditem[question_id], contentitem[content] )) db.session.commit() return {code: 0, msg: 提交成功}这里的db.session.flush()是关键。没有它sub.id在 commit 之前拿不到答卷和答案就无法建立关联。另一个隐藏校验是遍历答案时应该对照数据库里问卷的真实题目做过滤而不是全盘信任前端传来的题目 ID否则可能插入幽灵数据。4.3 统计聚合单选计数、多选拆分与文本收集统计是这份源码的亮点。单选和多选的处理方式完全不同核心差异在于存储单选在AnswerRecord.content里存的是一个选项 ID多选存的是逗号拼接的多个 ID 字符串。聚合时后端要分别对待。单选题的统计直接对选项表做 LEFT JOIN 分组from sqlalchemy import func stat_api.route(/api/surveys/int:sid/stats) def survey_stats(sid): survey Survey.query.get_or_404(sid) stats {} for q in survey.questions: qid q.id if q.qtype single: rows db.session.query( OptionItem.content, func.count(AnswerRecord.id) ).select_from(OptionItem) \ .outerjoin(AnswerRecord, db.and_(AnswerRecord.question_id qid, AnswerRecord.content str(OptionItem.id))) \ .filter(OptionItem.question_id qid) \ .group_by(OptionItem.id).all() stats[qid] [{label: r[0], count: r[1]} for r in rows] elif q.qtype multiple: all_rows AnswerRecord.query.filter_by(question_idqid).all() from collections import Counter counter Counter() for row in all_rows: for oid in row.content.split(,): if oid: counter[int(oid)] 1 opts {o.id: o.content for o in q.options} stats[qid] [{label: opts.get(k), count: v} for k, v in counter.items()] return {code: 0, data: stats}单选直接走数据库聚合效率高多选因为存的是拼接串只能先把所有回答拉进内存用Counter拆开计数。这个差值会在数据量大时体现出来如果你的问卷预期有十万级答卷建议把多选拆成一行一个选项的子表别用逗号拼接。对于毕设和中小规模场景现在这种做法够用且直观。5. 避坑实战录环境、数据迁移和部署期五个隐蔽问题5.1 现象pip install 时报依赖冲突装完 Flask 版本对不上原因requirements.txt里直接写flask不带版本号pip 拉下来的是最新版 Flask 2.3但源码里的flask_sqlalchemy是旧版写法两边不兼容。解决装的时候指定大版本pip install flask2.2.5 flask-sqlalchemy3.0.5装完后python -c import flask; print(flask.__version__)确认一下2.0 到 2.2 之间最稳。5.2 现象数据库文件里全是乱码中文变成了 ä¸Â原因终端编码和 SQLite 默认编码不一致Windows 下最常见。解决在config.py的数据库连接串里强制加编码参数SQLite 用db_path ?charsetutf-8不一定有效更可靠的办法是统一在 Python 里处理字符串并在建表后执行一条PRAGMA encoding UTF-8;。如果已经乱码删库重建别留着。5.3 现象运行 run.py 时报错 sqlite3.OperationalError: table already exists原因第二次执行db.create_all()时表已经存在按说不会报错但如果手动改过模型字段SQLite 的旧表结构对不上Flask-SQLAlchemy 检查时就会抛异常。解决开发期直接删掉旧的 .db 文件重新建库。要保留数据的话去装 Flask-Migrate 走正式迁移流程但这套源码的表结构不复杂删库重建更快别纠结。5.4 现象Vue 编辑器里删一道题页面卡住不动但控制台没报错原因前面代码说明里提过Vue 2 的响应式系统监听不到this.questions.splice(index, 1)之外的那些直接赋值操作比如用this.questions[index] this.questions[this.questions.length - 1]做数组交换。解决删除一律用this.questions.splice(qIndex, 1)修改某个对象的属性用this.$set(obj, key, value)。这个坑在 Vue 2 所有涉及动态表单的项目里都会碰到养成习惯能少掉很多头发。5.5 现象开发时 Vue 跑在 8080Flask 跑在 5000前端请求接口直接跨域报错原因前后端分离开发模式下两个端口不同浏览器拦截了跨域请求。解决开发期给 Flask 加flask-cors写白名单允许http://localhost:8080上线部署时不搞 CORS直接把 Vue 构建后的静态文件交给 Flask 托管一个端口出全部页面既没有跨域问题又省一台服务器配置。这是目前毕业设计场景最省心的部署形态。6. 上线自测清单一次部署跑通 Flask 静态托管与冒烟验证6.1 构建 Vue 产物并交给 Flask 托管部署不需要 Nginx把 Flask 既当 API 服务又当静态服务器用就够。先在前端目录构建cd frontend npm install npm run build构建完成后frontend/dist/目录下会出现编译好的静态文件。把它们复制到 Flask 应用的static/目录下并在 Flask 里加一条路由兜底app.route(/, defaults{path: }) app.route(/path:path) def catch_all(path): return send_from_directory(static, path if path else index.html)这条路由把 Vue 的 history 路由模式也兜住了刷新页面不会 404。然后在run.py里修改启动方式生产环境不用app.run(debugTrue)改用 gunicorngunicorn -w 4 -b 0.0.0.0:8000 run:apprun:app指的是 run.py 里创建的app实例。-w 4开四个 worker 进程避免单进程下问卷调查有并发提交时数据库锁死。6.2 冒烟验证五个接口一次跑通部署完成以后用 curl 按顺序验证一遍确认没错再交付序号请求预期结果1curl http://127.0.0.1:8000/api/login -X POST -d {username:admin,password:123456}返回登录凭证2curl http://127.0.0.1:8000/api/surveys返回问卷列表 JSON3curl http://127.0.0.1:8000/api/surveys/public/1返回问卷题目详情4curl http://127.0.0.1:8000/api/surveys/public/1/submit -X POST提交答卷返回 success5curl http://127.0.0.1:8000/api/surveys/1/stats返回选项聚合统计这里有个血泪经验别只看前 4 个都通就当完事了。统计接口的 bug 往往在有人真实提交之前是暴露不出来的——比如多选题目没人填的时候Counter统计出来的列表是空的前端图表就会白屏。从那以后我每次部署问卷类项目都会用 curl 模拟提交一份含单选、多选、文本题答案的数据再回头请求统计接口确认能正常出数最后才敢把链接发给用户。这套流程跑顺了希望帮到你。本文还有配套的精品资源点击获取