☰
基于Flask+Vue的在线问诊系统设计与实现全解析
2026/10/1 4:24:19 网站建设 项目流程

做这样一个系统,最深的体会就是:一个在线问诊的完整链路,绕来绕去总跑不开"患者发起问诊、医生接诊回复、最后开出处方"这三件事。但真要把这三件事用代码落成一个能上线的东西,牵扯到的设计远比表面看到的要多。这篇文章就把我完成这套医院就诊管理系统的全过程——技术选型、后端建模、前端架构、核心业务流程、权限控制、部署上线——按实际开发的顺序捋一遍。项目最终配合的是Python Flask提供API,Vue做前端页面,中间走的是标准的前后端分离模式,适合正在做Web全栈课设、毕业设计,或者想从零搭一个中小型信息管理系统的同学参考。

1. 为什么是Flask+Vue:这套技术选型的真实考量

1.1 对比了Spring Boot、若依、Django之后的选择

开始动手之前,我在Spring Boot、Django和Flask之间纠结了挺久。很多人一提"医院管理系统"就默认要上Spring Boot,再加上若依这种后台脚手架,感觉这才是"正路"。但我仔细算了一笔账:这个系统的核心是问诊流程的管理,并发量不高,功能边界清晰,最需要的其实是开发速度和灵活度。

我列过一张对比表,选型时心里就清楚了:

方案上手成本部署重量适合场景
Spring Boot较高,Java生态配置多需要JDK+Maven运行环境大型企业级、高并发
Django中等,自带后台可快速搭建一套固定的MTV模式内容站点、快速原型
Flask低,路由和视图模型极简轻量,一个Gunicorn就够小型SaaS、内部系统、API服务
若依脚手架高,代码量大,前后端分离但结构重整体工程体量偏大后台管理框架型项目

Flask的优势不是"功能强大",而是"恰到好处"。这个系统就三类角色:患者、医生、管理员。核心业务是问诊单的流转,不是复杂的权限矩阵,也不是海量数据的统计分析。Flask的路由装饰器、蓝图模块化、以及庞大的第三方扩展库,能让我花最少的时间把API写出来,把时间省给前端交互和问诊流程的打磨。

另外还有一个很现实的原因:Flask框架本身是纯Python实现,数据模型用SQLAlchemy来映射,对后来做数据分析和扩展都有好处。比如后期要加个简单的诊断统计,直接复用现有的ORM模型写脚本就行,不用额外接数据管道。

1.2 Vue放在这里的真正作用

我选了Vue来做前端,而且刻意没有用Flask的Jinja2模板渲染。原因不复杂:在线问诊的交互是典型的"多状态页面"——患者要能实时看到问诊单状态从"待接诊"变成"医生已接诊",医生那边要快速切换接诊队列,这是传统模板渲染很难做流畅的。

Vue的好处在于组件化和响应式。我把患者端的问诊详情页拆成了几个组件:病情描述卡片、聊天记录区域、处方展示卡片、就医指引卡片。状态一变,页面局部刷新,不用整个重新加载。Vue 3的组合式API(Composition API)写这类业务逻辑非常顺手,逻辑相关的东西放在一起,比Vue 2的选项式API清晰不少。

前后端分离的另一个隐性好处是,后端Flask只需要老老实实提供JSON接口,我前端页面怎么跳转、组件怎么复用,完全自己说了算。调试的时候开个浏览器DevTools直接看接口请求,问题定位起来特别快。

2. 后端骨架:Flask的模块化设计与数据库建模

2.1 项目目录结构设计

Flask项目最忌讳把代码全堆在app.py里,哪怕系统不大,我也从一开始就按模块拆好了。目录结构供参考:

flask_backend/ ├── app/ │ ├── __init__.py # 应用工厂,注册蓝图和扩展 │ ├── config.py # 配置项,包含数据库连接、SECRET_KEY │ ├── models/ │ │ ├── __init__.py │ │ ├── user.py # 用户、患者、医生模型 │ │ ├── department.py # 科室模型 │ │ ├── consultation.py # 问诊单模型 │ │ ├── prescription.py # 处方及明细模型 │ │ └── message.py # 聊天消息模型 │ ├── api/ │ │ ├── __init__.py │ │ ├── auth.py # 登录注册接口 │ │ ├── patient.py # 患者端接口 │ │ ├── doctor.py # 医生端接口 │ │ ├── admin.py # 管理端接口 │ │ └── common.py # 科室、公共数据接口 │ ├── utils/ │ │ ├── jwt_auth.py # JWT生成与校验 │ │ ├── decorators.py # 角色鉴权装饰器 │ │ └── response.py # 统一返回格式 │ └── extensions.py # db = SQLAlchemy()等扩展实例 ├── migrations/ # 数据库迁移文件 ├── requirements.txt └── run.py

应用工厂模式是关键。extensions.py里单独存放db = SQLAlchemy()和ma = Marshmallow()这类扩展实例,然后在app/__init__.py里把它们绑定到应用上。这样写的好处是,需要跑单元测试时可以创建多个不同配置的应用实例,互不干扰。

2.2 数据模型的设计思路

数据库我用的MySQL,字符集统一utf8mb4。表结构设计是整个系统里最需要想清楚的部分,因为看病流程的状态流转,本质上就是数据表里几个字段的变化。核心表如下:

表名作用关键字段
users所有登录账号统一存放id, username, password_hash, role, real_name, phone
patients患者的扩展信息id, user_id, gender, birth_date, medical_history
doctors医生的扩展信息id, user_id, department_id, title, introduction, consultation_fee
departments科室信息id, name, description
consultations问诊单id, patient_id, doctor_id, status, symptom_desc, diagnosis, created_at
messages问诊聊天记录id, consultation_id, sender_id, content, msg_type, created_at
prescriptions处方主表id, consultation_id, doctor_id, patient_id, total_amount, note, created_at
prescription_items处方明细id, prescription_id, drug_name, spec, unit_price, quantity, dosage

这里有个设计点我想单独说说:为什么不单独建患者表,而是搞了个users表加扩展表?因为不管是患者还是医生,登录认证的逻辑是一样的——账号、密码、角色。把公共字段放在一张表里,鉴权逻辑写一次就够。扩展信息单独拆出去,又不会让users表字段太多。实际开发时,一张user表配一个role字段,比患者表、医生表分开建要省很多重复代码。

问诊单状态字段status我设计成了整数枚举:0待接诊、1已接诊(问诊中)、2已完成、3已取消。为什么不直接用字符串?因为前端做状态判断时,字符串比较容易出错,比如写成"正在接诊"和"接诊中",数据不一致前端就看不到正确状态了。数据库里只存数字,含义在代码里定义成常量,两端都按这份约定来。

2.3 Flask API路由怎么组织

API层用蓝图划分,每个文件只管自己那类接口。比如doctor.py里是用Blueprint('doctor', __name__)来创建的,注册路由时统一加前缀:

doctor_bp = Blueprint('doctor', __name__, url_prefix='/api/doctor') @doctor_bp.route('/consultations/pending', methods=['GET']) @jwt_required @role_required('doctor') def pending_consultations(): doctor_id = current_user.doctor_profile.id consultations = Consultation.query.filter_by( doctor_id=doctor_id, status=0 ).order_by(Consultation.created_at.asc()).all() return success_response([c.to_dict() for c in consultations])

所有接口统一走success_response()或error_response(),前端axios拦截器拿到数据后直接解包,不用每次都判断HTTP状态码和业务状态码的对应关系。这种统一格式写起来可能感觉多此一举,但当前端有几十个页面都要调接口时,统一封装的好处马上就出来了。

3. 前端Vue侧的页面架构:路由、状态管理与问诊流程交互

3.1 前端工程结构和路由规划

前端用的Vue 3加Vue Router 4,配合Pinia做状态管理。目录结构也不复杂,但路由规划是花了不少心思的。这个系统前端分四个大区域:访客区、患者区、医生区、管理区。路由设计如下:

路由路径页面访问角色
/login、/register登录、注册所有人
/首页,展示科室、医院信息所有登录用户
/patient/consult发起问诊(选科室选医生)患者
/patient/consultations我的问诊列表患者
/patient/consultations/:id问诊详情+聊天室患者、医生
/doctor/workbench医生接诊工作台医生
/doctor/prescription/:cId开处方页面医生
/admin/departments科室管理管理员
/admin/doctors医生审核与管理管理员

最先写的是登录,登录后根据角色跳转到对应的工作台。这个跳转逻辑我放在路由守卫里,结合Pinia里保存的userInfo判断。组件层面,views目录放页面,components目录放复用组件。问诊聊天室是一个单独组件,患者和医生端共用,只通过role属性控制显示哪些按钮。比如患者端显示"等待医生接诊",医生端显示"接诊"按钮,聊天记录组件本身是同一套。

3.2 axios封装与登录态管理

axios封装是这个前端项目的关键。我在src/utils/request.js里做了一个带请求拦截器和响应拦截器的实例:

import axios from 'axios' import { ElMessage } from 'element-plus' import router from '@/router' const request = axios.create({ baseURL: '/api', timeout: 10000 }) request.interceptors.request.use(config => { const token = localStorage.getItem('med_token') if (token) { config.headers['Authorization'] = 'Bearer ' + token } return config }) request.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) } return res }, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('med_token') router.push('/login') } ElMessage.error(error.message || '网络错误') return Promise.reject(error) } ) export default request

这里有个细节:baseURL配的是/api而不是完整地址,是因为生产环境前后端共用同一个域名,靠Nginx把/api转发给Flask服务,这就避免了写死IP端口带来的跨域问题。开发环境再通过Vite的proxy把/api代理到Flask的8000端口,一套代码开发生产都跑得通,非常省心。

登录态我选了localStorage存token,而不是sessionStorage。原因是刷新页面时token不能丢,否则患者刷新一下聊天页就要重新登录,体验很糟。Pinia里则用userInfo对象记录用户名、角色、显示昵称,这些信息在页面导航上要频繁使用。

3.3 患者端和医生端的页面交互拆解

患者端的核心交互流程是:首页点"在线问诊"→选择科室→查看医生列表→选择医生→填写病情描述→提交订单。提交完成后,患者进入"我的问诊",看到这个问诊单的状态时点是"待接诊"。点击进入详情,就是和医生对话的聊天室。

医生端则完全不一样。医生登录后直接进接诊工作台,页面分成两部分:左边是待接诊列表,右边是当前正在处理的问诊会话。点"接诊"后,问诊单状态从0变1,患者那边同步看到状态变化,聊天室里医生端的输入框也解锁。

这两个页面的交互模式不同,但底层依赖的是同一个问诊单状态字段。前端之所以能做得这么顺畅,靠的就是Vue的响应式——医生接诊触发的接口返回后,前端把返回的status值更新到Pinia里,相关组件自动刷新。

4. 在线问诊的核心链路:从发起问诊到医生接诊再到处方查看

4.1 问诊单的创建与状态流转

在线问诊最核心的业务逻辑是问诊单的状态流转。从代码角度说,就是一套状态机的实现。我把状态定义放在后端模型上:

class Consultation(db.Model): __tablename__ = 'consultations' STATUS_PENDING = 0 # 待接诊 STATUS_ACCEPTED = 1 # 已接诊,问诊中 STATUS_FINISHED = 2 # 已完成 STATUS_CANCELLED = 3 # 已取消 id = db.Column(db.Integer, primary_key=True) patient_id = db.Column(db.Integer, db.ForeignKey('patients.id'), nullable=False) doctor_id = db.Column(db.Integer, db.ForeignKey('doctors.id'), nullable=False) status = db.Column(db.Integer, default=STATUS_PENDING, nullable=False, index=True) symptom_desc = db.Column(db.Text, nullable=False) # 病情描述 diagnosis = db.Column(db.Text) # 医生诊断结果 created_at = db.Column(db.DateTime, default=datetime.utcnow) accepted_at = db.Column(db.DateTime) finished_at = db.Column(db.DateTime)

创建问诊单时,患者在前端表单填病情描述,提交后后端做几件事:确认患者存在、确认医生存在且属于所选科室、然后创建一条status=0的记录。这里特别值得注意的一点是创建后要返回完整的问诊单信息,包括model里的to_dict(),前端拿到后就跳转问诊详情页。

状态的合法流转是这样的:

状态名数值触发动作什么角色能触发
待接诊0患者创建问诊单患者
已接诊1医生点击接诊医生
已完成2医生提交诊断和处方医生
已取消3患者取消或管理员关闭患者/管理员

我在接诊和完成两个接口里都加了状态校验,不是当前状态值就直接返回错误。像这样在接口层做一次判断,虽然看着啰嗦,但能防止前端误操作或者接口被直接调用时出现状态跳变。比如患者正在等,医生已经提交了诊断,这时候患者再点取消,接口判断当前状态不是0了,返回"当前状态不允许取消",就不会出现已完成的问诊单还能被撤销的问题。

4.2 医生端接诊的并发问题

接诊这个动作看起来简单——改状态、绑定医生——但实际做的时候我踩过并发问题的坑。设想这个场景:两位医生同时在待接诊列表里看到同一张问诊单,几乎同时点了"接诊"。如果用简单的查询+更新逻辑,两人都可能通过校验,都修改了这条记录的doctor_id,造成数据错乱。

解决方案我用的MySQL的UPDATE ... WHERE status=0条件更新。不是先查后改,而是直接执行带条件限制的更新语句,通过受影响行数判断是否成功:

result = db.session.execute( db.update(Consultation) .where(Consultation.id == consultation_id, Consultation.status == 0) .values(status=1, doctor_id=doctor_id, accepted_at=datetime.now()) ) db.session.commit() if result.rowcount == 0: return error_response('该问诊单已被其他医生接诊')

这种写法利用数据库的行锁来保证并发下只有一个医生能更新成功。受影响行数为0,说明当前状态已经不是0——已经被抢了。这种"不加锁却防了并发"的写法,对这种中小型系统来说是最简洁可靠的方案。

4.3 聊天消息的设计方案

问诊过程中患者和医生要交流病情,所以需要消息功能。最初我考虑过WebSocket方案,用Flask-SocketIO,页面收到消息实时推送。但仔细评估后,这个项目的聊天频率很低——一次问诊来回可能也就十几条消息,对实时性要求并不高。最后选了轮询方案:前端每2秒请求一次该问诊单的消息列表,用消息id做增量拉取。如果系统规模大或者要做语音视频问诊,再升级到WebSocket不迟。

消息表结构很简单:

class Message(db.Model): __tablename__ = 'messages' id = db.Column(db.Integer, primary_key=True) consultation_id = db.Column(db.Integer, db.ForeignKey('consultations.id'), nullable=False, index=True) sender_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) content = db.Column(db.Text, nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow)

增量拉取的接口逻辑是:前端把当前已加载的最大消息id传过来,后端只返回比这个id大的消息。这种设计比整表拉取高效,也避免聊天室滚动时把全部历史都重新渲染一遍。

4.4 处方生成与患者查看

医生在问诊单处于"已接诊"状态时,可以填诊断结论,并开具处方。处方是一主多明细的结构,主表记录归属关系,明细表记录药品名、规格、价格、数量、用法用量。一次提交处方,后端是在一个事务里同时插入prescriptions和prescription_items的:

@doctor_bp.route('/consultations/<int:consultation_id>/prescription', methods=['POST']) @jwt_required @role_required('doctor') def create_prescription(consultation_id): data = request.get_json() items = data.get('items', []) if not items: return error_response('处方明细不能为空') consultation = Consultation.query.get(consultation_id) if not consultation or consultation.status != 1: return error_response('当前问诊状态无法开处方') total_amount = sum(item['unit_price'] * item['quantity'] for item in items) prescription = Prescription( consultation_id=consultation_id, doctor_id=consultation.doctor_id, patient_id=consultation.patient_id, total_amount=total_amount, note=data.get('note', '') ) db.session.add(prescription) for item in items: db.session.add(PrescriptionItem( prescription=prescription, drug_name=item['drug_name'], spec=item.get('spec', ''), unit_price=item['unit_price'], quantity=item['quantity'], dosage=item.get('dosage', '') )) consultation.status = 2 consultation.diagnosis = data.get('diagnosis', '') consultation.finished_at = datetime.now() db.session.commit() return success_response({'prescription_id': prescription.id})

把"开处方"和"完成问诊"放在同一个事务里是刻意的:医生只要提交了诊断和处方,这次问诊就必须同时完成,不能出现处方开了但状态还停在已接诊的悬空状态。

前端展示处方时,患者端详情页会多出一个处方卡片,按药品明细列表展示,每项有单价和数量,底部汇总总价。因为涉及金额展示,我在前端做了BigDecimal风格的金额处理思路——后端返回的是整数分,前端自己除以100转为元再显示。这样避免浮点数计算误差,也符合支付系统里"以分为单位"的惯例。这个小细节是在对接模拟缴费时想明白的,建议做任何涉及金钱的系统都默认采用。

5. 权限与安全:患者、医生、管理员三种角色的权限控制

5.1 JWT认证与登录流程

登录接口接收用户名和密码,校验通过后签发JWT。签发时把用户id和role放进去,过期时间设为7天。接口端通过装饰器解析token,拿到当前用户信息。

def generate_token(user): payload = { 'user_id': user.id, 'role': user.role, 'exp': datetime.utcnow() + timedelta(days=7) } return jwt.encode(payload, current_app.config['SECRET_KEY'], algorithm='HS256')

jwt_required装饰器做token的解析和校验,具体实现是取出Authorization请求头,去掉Bearer前缀,校验签名,然后把解析出的用户对象挂到current_user这个全局对象上。Flask里用g对象来存当前请求全局数据,装饰器里给g.user赋值,接口里直接读,这样很方便。不过要注意:Flask的g对象是跟随请求上下文存在的,多个并发请求不会互相串数据,这个可以放心用。

5.2 后端接口的角色鉴权

只有登录校验还不够,得防住"患者调医生接口"这类越权行为。Role-based的鉴权我直接做成装饰器,和jwt_required配合用:

def role_required(*roles): def decorator(f): @wraps(f) def wrapper(*args, **kwargs): user = getattr(g, 'user', None) if not user: return error_response('未登录', 401) if user.role not in roles: return error_response('无权限访问', 403) return f(*args, **kwargs) return wrapper return decorator

实际接口上就是这样用:

@doctor_bp.route('/workbench', methods=['GET']) @jwt_required @role_required('doctor') def workbench(): ...

这种装饰器权限控制的思路,适合角色固定、数量少的系统。遇到特别复杂、动态的权限模型,建议还是用现成的权限框架,但在这个系统里,三行装饰器的方案远比引入一堆权限框架来得好维护。

5.3 前端路由守卫

后端做了鉴权,前端还需要做路由守卫来拦掉"未登录直接输URL跳页面"的情况。Vue Router的beforeEach全局守卫,逻辑不复杂,但有两层判断。第一层,白名单外的页面要求必须有token;第二层,登录后每个页面的路由的meta.roles数组,判断当前用户角色是否匹配,不匹配就跳回自己的工作台:

router.beforeEach((to, from, next) => { const token = localStorage.getItem('med_token') const userInfo = JSON.parse(localStorage.getItem('med_user_info') || '{}') if (!token && to.path !== '/login' && to.path !== '/register') { next('/login') return } if (token && to.meta.roles) { if (!to.meta.roles.includes(userInfo.role)) { next(userInfo.role === 'doctor' ? '/doctor/workbench' : '/') return } } next() })

前端路由守卫只能优化体验,真正的安全防线在后端。这一点做前端时必须想清楚,不然一不小心就会做出"隐藏按钮但接口裸奔"的系统。

5.4 医疗数据安全处理的几个习惯

涉及问诊聊天记录、病情描述这类敏感数据,我做了几件额外的事:数据库里密码存的是generate_password_hash后的值,绝对不存明文;敏感接口的响应里手动过滤字段,不用to_dict()一股脑把整条record返回;前端展示聊天记录时分页加载,不把整个问诊的所有历史消息一次性拉下来。这些措施虽然不能说做到了等保级别,但对一个标准的管理信息系统来说,已经把能想到的风险点都覆盖了。

6. 部署上线:从开发环境到生产环境的折腾记录

6.1 跨域问题怎么处理的

开发阶段的跨域是最先遇到的坎。前端跑在Vite默认的5173端口,后端Flask监听在8000端口,直接请求必然被浏览器的同源策略拦掉。开发环境的方案是在Vite配置里做代理:

// vite.config.js export default defineConfig({ server: { proxy: { '/api': { target: 'http://localhost:8000', changeOrigin: true } } } })

这样前端页面里请求/api/xxx,Vite开发服务器会转发给后端的8000端口,而且浏览器里看到的还是同源,就没有跨域问题了。进入生产环境后,前后端部署在同一个域名的不同路径下,Nginx转发,根本不会触发跨域限制。

6.2 Flask后端在生产环境怎么跑

Flask内置的开发服务器明确不能用于生产环境,我用了Gunicorn来启动:

gunicorn -w 4 -b 127.0.0.1:8000 run:app

-w 4是开了4个worker进程。这个数字不是越大越好,要根据服务器CPU核数来定,经验公式是2*CPU核数+1。再配合supervisor做进程守护,进程挂了自动拉起,不然服务器一重启,后端就再也不会自己跑起来了。

迁移数据库用的Flask-Migrate。表结构改了之后,执行flask db migrate和flask db upgrade推送变更。这个流程比手改SQL安全多了,特别是表结构迭代了好几次之后,历史变更记录一目了然。

6.3 Nginx反向代理与前端history模式

生产环境的Nginx配置文件是这个项目的关键一环。前端打包后如果是history路由模式,直接部署会碰到404问题——因为刷新页面时Nginx去找/doctor/workbench对应的物理文件,找不到就404了。解决办法是配置try_files指向index.html:

server { listen 80; server_name your_domain.com; root /var/www/html; index index.html; location / { try_files $uri $uri/ /index.html; } 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-For $proxy_add_x_forwarded_for; } location /static/ { alias /var/www/html/static/; expires 30d; } }

这里location /api/把API请求单向转发给Gunicorn,前端静态文件由Nginx直接服务。页面秒开,接口响应也稳定,整体架构非常干净。配套的,前端路由不能用createWebHashHistory,要用createWebHistory,不然URL里到处都是#,既不好看也不方便分享。

6.4 上线后遇到的几个实际问题

上线后最先暴露的是时区问题。Flask的datetime.utcnow()存进MySQL后,前端展示的时候和本地时间差了8小时。解决方式是统一在后端返回时间戳(epoch毫秒数),前端格式化时用本地时区转换。这个方案跨时区无坑,我从那之后所有项目的时间接口都返回时间戳。

然后是上传图片的需求。如果患者或者医生要上传检查报告图片,Flask需要配置一个上传目录并用send_from_directory提供访问。生产环境里更好用的方案是用MinIO或者云存储,但小项目不想引外部依赖的话,本地存储加Nginx静态映射也能跑。要注意的是上传目录的权限和文件名防注入,文件后缀白名单校验不能省。

还有个性能细节:问诊列表页需要关联查询科室名、医生名、状态。最开始直接遍历每个问诊单再去查关联表,一次性加载N条就产生N+1次SQL查询,数据库压力翻倍。改成join查询或SQLAlchemy的joinedload之后,列表页秒开。这个优化虽然简单,但在编码初期就注意,能省掉后期不少重构。

上线后做了一轮完整的流程验证,从患者注册到问诊结束,把每个接口和页面都过了一遍。整体跑通后终于松了口气。做这类系统的经验总结下来就是:选型稳、建模细、流程少、权限不偷懒。Flask加Vue的组合,在中小型信息管理系统这个体量上,确实是开发效率和后期维护成本之间最平衡的选择。

最后再分享一个小经验:这种带角色流转的系统,开发时一定先把"状态机"想清楚。哪个角色在哪个状态下能做什么操作,在代码里用常量定义出来,前后端约定好。状态一旦混乱,排查成本远远大于提前设计的成本。做这个在线问诊系统,我最庆幸的就是一开始把问诊单的状态定义清楚了,后面所有功能都是在这个状态机上生长的结果。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询