简介:围绕基于 B/S 架构的大学生心理健康管理系统,这份毕业论文文档面向计算机相关专业准备毕业设计的学生,采用 Python 语言、Django 框架与 MySQL 数据库实现,并配合前端技术完成页面交互。论文从管理员、学生、心理导师三类角色出发,覆盖个人信息修改、用户与导师信息管理、专题辅导、辅导预约、咨询信息管理、导师回复、热门文章及心理测评等模块,还设计了首页推送最新资讯功能,可作为选题参考、需求分析与系统设计写作的范本。压缩包内仅 1 个 docx 文件,约 3.94MB,包含中英文摘要、关键词、绪论、开发技术介绍与功能实现等章节,结构完整,方便直接对照论文格式与模块划分。文档已有 75 人学习,适合需要借鉴 Django 项目架构、角色权限设计、数据库表结构以及论文排版的本科学生,也能为开题报告与答辩材料提供参考。
1. 从辅导员台账到系统:Django+Vue 大学生心理健康管理系统要打通的三条链路
一所高校每年秋季做新生心理普查,常见流程是:问卷收上来之后,咨询中心老师手工录分,逐份算 SCL-90 的九个因子分,再按常模判断谁进预警名单。几百份要两三天,上千份拖到一周后,预警窗口早过了。搬到线上的核心不是"做个网站",而是打通三条链路:题库与作答、作答与评分、评分到预警。
Django 管后两条。模型层放量表、题目、作答记录;MTV 里的 View 承担因子分聚合和预警判定,前后端分离之后 Template 层退化成 DRF 的序列化输出,admin 顺手变成咨询中心老师加量表的后台。Vue 管第一条,答题卡、进度条、跳题这几个高频交互做顺了,一套问卷组件才能靠 vue 路由参数复用到 SCL-90、UPI、PHQ-9 这些不同量表上。
选 Django+Vue 做这套系统,性价比在于 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_length=32, unique=True) # scl90 / upi name = models.CharField("量表名", max_length=64) factor_rule = models.JSONField("因子归属规则", default=dict) # {factor: [item_order...]} is_active = models.BooleanField(default=True) class Item(models.Model): scale = models.ForeignKey(Scale, on_delete=models.CASCADE, related_name="items") order = models.PositiveSmallIntegerField("题号") content = models.CharField("题干", max_length=255) factor = models.CharField("所属因子", max_length=16, db_index=True) reverse = models.BooleanField("反向计分", default=False) class Meta: unique_together = ("scale", "order") class Answer(models.Model): student = models.ForeignKey("accounts.Student", on_delete=models.CASCADE) scale = models.ForeignKey(Scale, on_delete=models.CASCADE) batch = models.CharField("普查批次", max_length=16, db_index=True) submitted_at = models.DateTimeField(auto_now_add=True) class Meta: unique_together = ("student", "scale", "batch") class AnswerDetail(models.Model): answer = models.ForeignKey(Answer, on_delete=models.CASCADE, related_name="details") item = models.ForeignKey(Item, on_delete=models.CASCADE) score = models.PositiveSmallIntegerField() # 1-5 Likert,反向计分在写入时已折算几个字段说清楚:batch加db_index=True,因为按批次筛名单是最频繁的查询;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(answer=answer) .values("item__factor") .annotate(avg=Avg("score"), n=Count("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__batch=batch) .values("answer__student_id", "item__factor") .annotate(avg=Avg("score")) .filter(avg__gte=threshold) # 第二个 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起手,用 Vite:
npm create vite@latest 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。差异集中在这几点:
| 维度 | pinia | vuex 4 |
|---|---|---|
| 修改状态 | action 里直接赋值 | 必须走 mutation |
| TypeScript 推导 | 开箱可推 | 需手写类型声明 |
| 模块拆分 | 一个文件一个 store,天然隔离 | modules + namespaced 手动隔离 |
| 打包体积 | ~1.5KB | ~6KB |
| 调试体验 | devtools 直接改 state | 需触发 mutation 才看时间线 |
对"答题卡 + 少量用户信息"这个量级,pinia 够用。真要担心的不是状态库,是答题数据丢了怎么办——把answers同步一份到sessionStorage,断线重连先恢复再加个提示。
4. 前后端联调与部署:Django 接口配 Vue 打包上线
本地开发跑通之后,真正卡住这套系统的是联调与部署两段:CSRF、静态目录、子路径资源 404,几乎每次都会中一条。
4.1 axios 封装、CSRF 与跨域
开发期用 Vite 代理绕过跨域,上线后前后端同域,基本不涉及 CORS。麻烦在 CSRF:DRF 的 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 = "Lax"CSRF_COOKIE_HTTPONLY默认是True,不改前端读不到 cookie,会一直 403。CSRF_TRUSTED_ORIGINS是 Django 4 之后的必填项,来源域名少一个字符都会挂。改用 Token 认证可以省掉这三条,但要自己处理刷新。
4.2 django 项目在 Windows10 上用 waitress + nginx 部署
教学机和服务器的实际环境大多是 Windows,Gunicorn 跑不了,常见做法是 waitress 顶在前面,nginx 做静态与反向代理。
pip install waitress cd C:\web\psy\backend python manage.py collectstatic --noinput waitress-serve --listen=127.0.0.1:8000 --threads=8 psy_backend.wsgi:application--threads=8对这种轻 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.js404 | vite.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" # BOM,Excel 打开中文不乱码 writer = csv.writer(Echo()) yield writer.writerow(["学号", "姓名", "学院", "预警等级", "触发因子"]) qs = Warning.objects.filter(answer__batch=batch).select_related("answer__student") for w in qs.iterator(chunk_size=500): 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_type="text/csv; charset=utf-8") resp["Content-Disposition"] = f"attachment; 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 别名即可,比一上来做多码率简单得多。
本文还有配套的精品资源,点击获取