Django+Vue 大学生心理健康管理系统:量表测评、预警与部署 简介围绕基于 B/S 架构的大学生心理健康管理系统这份毕业论文文档面向计算机相关专业准备毕业设计的学生采用 Python 语言、Django 框架与 MySQL 数据库实现并配合前端技术完成页面交互。论文从管理员、学生、心理导师三类角色出发覆盖个人信息修改、用户与导师信息管理、专题辅导、辅导预约、咨询信息管理、导师回复、热门文章及心理测评等模块还设计了首页推送最新资讯功能可作为选题参考、需求分析与系统设计写作的范本。压缩包内仅 1 个 docx 文件约 3.94MB包含中英文摘要、关键词、绪论、开发技术介绍与功能实现等章节结构完整方便直接对照论文格式与模块划分。文档已有 75 人学习适合需要借鉴 Django 项目架构、角色权限设计、数据库表结构以及论文排版的本科学生也能为开题报告与答辩材料提供参考。1. 从辅导员台账到系统DjangoVue 大学生心理健康管理系统要打通的三条链路一所高校每年秋季做新生心理普查常见流程是问卷收上来之后咨询中心老师手工录分逐份算 SCL-90 的九个因子分再按常模判断谁进预警名单。几百份要两三天上千份拖到一周后预警窗口早过了。搬到线上的核心不是做个网站而是打通三条链路题库与作答、作答与评分、评分到预警。Django 管后两条。模型层放量表、题目、作答记录MTV 里的 View 承担因子分聚合和预警判定前后端分离之后 Template 层退化成 DRF 的序列化输出admin 顺手变成咨询中心老师加量表的后台。Vue 管第一条答题卡、进度条、跳题这几个高频交互做顺了一套问卷组件才能靠 vue 路由参数复用到 SCL-90、UPI、PHQ-9 这些不同量表上。选 DjangoVue 做这套系统性价比在于 Django 自带 auth、admin、ORM 和迁移量表增删与权限位几乎零成本Vue 3 组合式 API 写答题卡状态比 jQuery 时代清晰一个量级。毕业设计拿它落地既有可讲的分层也有真正能跑起来的东西。2. Django MTV 分层心理档案、量表作答与预警规则的表结构设计心理系统跟普通内容管理最大的差别是数据敏感、规则复杂、留痕要求高。一份作答记录要能回答谁、什么时候、做了哪个量表、答了什么、算出来多少分、谁处理过。把这些字段拆到不同 app是为了让权限、备份、脱敏各走各的通道而不是在一个views.py里堆着。2.1 用 django 创建 app 划分 accounts、scale、warning 三个业务域别把所有模型堆在一个 app。校园场景下常见做法是三个各管一段app主要职责谁有写权限accounts学生/教师身份扩展、学院年级字段教务同步脚本scale量表、题目、作答、作答明细咨询中心管理员warning预警等级、处理记录、回访备注咨询师、辅导员受限创建命令本身一行python manage.py startapp accounts python manage.py startapp scale python manage.py startapp warning三个 app 建完在INSTALLED_APPS里注册python manage.py makemigrations按依赖顺序生成迁移。accounts 里的Student继承AbstractUser扩展学号、学院、年级scale 只放量表与作答warning 只放预警结果与处理状态。这么拆的直接好处是咨询记录权限只挂在 warning 上前两个 app 对普通辅导员开放只读即可。2.2 量表、题目与作答记录的字段设计这块最容易踩的坑是把题干和算法硬编码进 View。题干应该进数据库反向计分用字段标记加一个新量表只加数据、不改代码。# scale/models.py from django.db import models class Scale(models.Model): code models.CharField(量表编码, max_length32, uniqueTrue) # scl90 / upi name models.CharField(量表名, max_length64) factor_rule models.JSONField(因子归属规则, defaultdict) # {factor: [item_order...]} is_active models.BooleanField(defaultTrue) class Item(models.Model): scale models.ForeignKey(Scale, on_deletemodels.CASCADE, related_nameitems) order models.PositiveSmallIntegerField(题号) content models.CharField(题干, max_length255) factor models.CharField(所属因子, max_length16, db_indexTrue) reverse models.BooleanField(反向计分, defaultFalse) class Meta: unique_together (scale, order) class Answer(models.Model): student models.ForeignKey(accounts.Student, on_deletemodels.CASCADE) scale models.ForeignKey(Scale, on_deletemodels.CASCADE) batch models.CharField(普查批次, max_length16, db_indexTrue) submitted_at models.DateTimeField(auto_now_addTrue) class Meta: unique_together (student, scale, batch) class AnswerDetail(models.Model): answer models.ForeignKey(Answer, on_deletemodels.CASCADE, related_namedetails) item models.ForeignKey(Item, on_deletemodels.CASCADE) score models.PositiveSmallIntegerField() # 1-5 Likert反向计分在写入时已折算几个字段说清楚batch加db_indexTrue因为按批次筛名单是最频繁的查询unique_together (student, scale, batch)防重复提交score在写入时就做反向折算而不是查询期用Case/When——前者走普通索引后者在几万条明细上会明显拖慢预警扫描。提示作答明细是增长最快的表一次千人普查就是 90×1000 行按批次归档或分表要写进设计里别等答辩前才发现分页卡。2.3 用 Django 执行查询与聚合算出 SCL-90 因子分因子分就是同一因子下所有题的均值。用 ORM 的values annotate一次算完不要拉明细回 Python 循环# scale/services.py from django.db.models import Avg, Count from .models import Answer, AnswerDetail def factor_scores(answer: Answer) - dict: 返回 {因子: 均分}如 {dep: 1.8, anx: 2.3} rows (AnswerDetail.objects .filter(answeranswer) .values(item__factor) .annotate(avgAvg(score), nCount(id))) return {r[item__factor]: round(r[avg], 2) for r in rows} def scan_batch(batch: str, threshold: float 2.0): 扫一个批次返回触发阈值的 (student_id, factor, avg) 列表 return (AnswerDetail.objects .filter(answer__batchbatch) .values(answer__student_id, item__factor) .annotate(avgAvg(score)) .filter(avg__gtethreshold) # 第二个 filter 是 HAVING .order_by(answer__student_id))values(item__factor)把外键穿透到 Item 表取因子字段annotate(Avg(score))生成GROUP BY item__factor的 SQL跨表 JOIN 在数据库层完成。scan_batch里第二个filter(avg__gte...)是 HAVING不是 WHERE改写成 WHERE 会抛 FieldError新手常栽在这。裸跑一遍验证python manage.py shell -c from scale.services import scan_batch; print(scan_batch(2024A, 2.5)[:5])注意Avg在只有 3 道题的附加因子上会放大单个极端作答的影响实际项目里可以补一个阳性项目数指标一起判断。3. Vue 端工程化从 vue 安装依赖到 vue 路由参数驱动的测评流前端这部分测评场景对状态管理的要求比想象中高一页 90 题用户可能中途刷新、回退、切换量表答题卡必须跟 URL 走不能只活在组件里。3.1 vue 安装及环境配置与 Vite 起步Vue 3 时代不建议再拿vue-cli起手用 Vitenpm create vitelatest psy-front -- --template vue cd psy-front npm i vue-router pinia axios element-plus npm run dev--template vue生成组合式 API script setup骨架element-plus提供表单与进度条组件量表题用原生 radio 会写吐axios统一封装在src/api/http.js。开发端口默认 5173后端要把它加进CSRF_TRUSTED_ORIGINS否则登录接口一路 403。vue.config.js是 vue-cli 的产物Vite 项目里对应vite.config.js// vite.config.js import { defineConfig } from vite import vue from vitejs/plugin-vue export default defineConfig({ plugins: [vue()], base: /app/, // 与生产环境子路径一致上线后白屏多半是这里没改 server: { proxy: { /api: { target: http://127.0.0.1:8000, changeOrigin: true } } } })base开发期留/也行打包部署到/app/子路径时忘了改就是上线白屏的第一大源头。3.2 vue 路由参数设计让 scaleCode 和 batch 进 URL测评入口常见形态是/scale/:code/:batch。用路由参数而不是 query是为了让刷新继续答和同批次学生共享入口天然成立。// src/router/index.js import { createRouter, createWebHistory } from vue-router export default createRouter({ history: createWebHistory(/app/), routes: [ { path: /scale/:code/:batch, name: ScaleRun, component: () import(/views/ScaleRun.vue), props: true, // 组件用 props 收参减少 useRoute 侵入 meta: { needAuth: true } }, { path: /report/:answerId, name: Report, component: () import(/views/Report.vue) }, { path: /, redirect: /scale/list } ] })props: true会把code、batch以 props 传给组件不必再useRoute()解析。一个高频 bug从/scale/scl90/2024A跳到/scale/upi/2024A时组件相同路由不重新挂载onMounted里的请求不会重跑。要么watch(() props.code, load)要么给router-view加:key$route.fullPath。前者省性能后者省心。答题卡状态用 pinia// src/stores/paper.js import { defineStore } from pinia import { ref, computed } from vue export const usePaperStore defineStore(paper, () { const code ref() const answers ref({}) // { [itemId]: 1-5 } const done computed(() Object.keys(answers.value).length) function setScore(itemId, score) { answers.value[itemId] score // 直接赋值pinia 内部走 reactive 代理 } function reset() { answers.value {} } return { code, answers, done, setScore, reset } })setScore直接改对象属性不需要像 Vuex 那样先commit。响应式来自底层的ref/reactive一次赋值就能让进度条更新。3.3 pinia 与 vuex 的取舍项目里已经有 vuex没必要为一处答题卡全量迁移新起项目直接 pinia。差异集中在这几点维度piniavuex 4修改状态action 里直接赋值必须走 mutationTypeScript 推导开箱可推需手写类型声明模块拆分一个文件一个 store天然隔离modules namespaced 手动隔离打包体积~1.5KB~6KB调试体验devtools 直接改 state需触发 mutation 才看时间线对答题卡 少量用户信息这个量级pinia 够用。真要担心的不是状态库是答题数据丢了怎么办——把answers同步一份到sessionStorage断线重连先恢复再加个提示。4. 前后端联调与部署Django 接口配 Vue 打包上线本地开发跑通之后真正卡住这套系统的是联调与部署两段CSRF、静态目录、子路径资源 404几乎每次都会中一条。4.1 axios 封装、CSRF 与跨域开发期用 Vite 代理绕过跨域上线后前后端同域基本不涉及 CORS。麻烦在 CSRFDRF 的 SessionAuthentication 要求把 cookie 里的csrftoken回填到请求头。// src/api/http.js import axios from axios import { ElMessage } from element-plus const http axios.create({ baseURL: import.meta.env.VITE_API_BASE || /api, timeout: 8000, withCredentials: true // 带会话 Cookie }) http.interceptors.request.use(config { const m document.cookie.match(/csrftoken([^;])/) if (m) config.headers[X-CSRFToken] m[1] return config }) http.interceptors.response.use( res res.data, err { const msg err.response?.data?.detail || 请求失败请稍后重试 ElMessage.error(msg) return Promise.reject(err) } ) export default http后端配套三行# psy_backend/settings.py 片段 CSRF_COOKIE_HTTPONLY False # 前端要读到 csrftoken CSRF_TRUSTED_ORIGINS [http://127.0.0.1:5173, https://psy.example.edu.cn] SESSION_COOKIE_SAMESITE LaxCSRF_COOKIE_HTTPONLY默认是True不改前端读不到 cookie会一直 403。CSRF_TRUSTED_ORIGINS是 Django 4 之后的必填项来源域名少一个字符都会挂。改用 Token 认证可以省掉这三条但要自己处理刷新。4.2 django 项目在 Windows10 上用 waitress nginx 部署教学机和服务器的实际环境大多是 WindowsGunicorn 跑不了常见做法是 waitress 顶在前面nginx 做静态与反向代理。pip install waitress cd C:\web\psy\backend python manage.py collectstatic --noinput waitress-serve --listen127.0.0.1:8000 --threads8 psy_backend.wsgi:application--threads8对这种轻 IO 场景够用collectstatic必须先跑否则 admin 的 CSS 和 DRF 的页面资源会 404。要长期跑建议注册成 Windows 服务或用 nssm 托管避免命令行窗口被误关。nginx 配置片段server { listen 80; server_name psy.example.edu.cn; location /static/ { alias C:/web/psy/staticfiles/; } location /api/ { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; } location /app/ { alias C:/web/psy/dist/; try_files $uri $uri/ /app/index.html; # history 模式必须回退到 index } location / { return 302 /app/; } }try_files是 Vue history 路由的生命线漏了它刷新/app/scale/scl90/2024A直接 404。X-Forwarded-Proto要带否则 Django 生成的绝对 URL 还是 http浏览器会拦混合内容。4.3 vue 打包后布局异常的排查清单npm run build之后样式跑偏按这个顺序查基本能定位现象优先排查修法全站白屏、控制台/assets/xxx.js404vite.config.js的base改成与 nginxlocation一致如/app/右侧出现横向滚动条父容器width: 100vw遇上滚动条改100%或max-width: 100%移动端答题卡被状态栏盖住height: 100vh用100dvh或min-height: 100%v-html里的题干样式不生效style scoped不给子元素加属性用:deep()或去掉 scoped打包前后字体不一致字体文件没进 assets换系统字体栈或配assetsInclude通用建议npm run build npm run preview先在本地起一个与生产同构的服务复现一次比丢上服务器刷日志快得多。5. 导出报告与心理微课StreamingHttpResponse 与 Vue 端的 m3u8 播放系统跑到这一步两个常见需求会来批量导出测评报告、在系统里推心理微课视频。它们分别对应后端流式响应和前端视频播放两个具体技术点。5.1 Django StreamingHttpResponse 的 content_type 与 content_disposition导出几百份名单时普通HttpResponse会把整个字节流攒在内存里几百兆下来进程直接飙红。StreamingHttpResponse边生成边下发关键在响应头# warning/views.py import csv from urllib.parse import quote from django.http import StreamingHttpResponse class Echo: def write(self, value): return value def export_warnings(request, batch): def rows(): yield \ufeff # BOMExcel 打开中文不乱码 writer csv.writer(Echo()) yield writer.writerow([学号, 姓名, 学院, 预警等级, 触发因子]) qs Warning.objects.filter(answer__batchbatch).select_related(answer__student) for w in qs.iterator(chunk_size500): s w.answer.student yield writer.writerow([s.student_no, s.get_full_name(), s.college, w.get_level_display(), ,.join(w.factors)]) fn quote(f预警名单_{batch}.csv) resp StreamingHttpResponse(rows(), content_typetext/csv; charsetutf-8) resp[Content-Disposition] fattachment; filename*UTF-8{fn} return respcontent_type决定浏览器按什么处理text/csv交给表格软件打开application/octet-stream一律触发下载。Content-Disposition里attachment是下载inline是直接在浏览器显示导出场景必须用attachment文件名含中文用 RFC 5987 的filename*UTF-8形式最保险。两条一起看content_type管内容识别content_disposition管浏览器动作任何一个写反用户要么看到一堆乱码要么拿到没有扩展名的文件。5.2 Vue 端播放 m3u8 心理微课与降级处理心理中心常把情绪调节微课切成分片方便手机端弱网加载。前端用hls.js配合原生video播// components/MicroLesson.vue import Hls from hls.js import { onMounted, onBeforeUnmount, ref } from vue const videoRef ref(null) let hls null onMounted(() { const src new URL(/media/lesson/breath.m3u8, location.origin).href if (videoRef.value.canPlayType(application/vnd.apple.mpegurl)) { videoRef.value.src src // Safari / iOS 原生支持 } else if (Hls.isSupported()) { hls new Hls({ maxBufferLength: 30 }) // 缓冲 30 秒够一节微课片头 hls.loadSource(src) hls.attachMedia(videoRef.value) hls.on(Hls.Events.ERROR, (_e, data) { if (data.fatal) hls.startLoad() // 脏网络恢复先尝试重拉 }) } else { videoRef.value.src /media/lesson/breath.mp4 // 最后降级到整段 MP4 } }) onBeforeUnmount(() hls hls.destroy())maxBufferLength别调太大往上是内存往下是卡顿一节 5 分钟微课给 30 秒缓冲基本能平衡。Hls.Events.ERROR只对data.fatal处理非致命错误 hls.js 内部会自己重试手工插一脚反而打断重试间隔。想更省事就切成单码率nginx 配好video/mp2t的 MIME 别名即可比一上来做多码率简单得多。本文还有配套的精品资源点击获取