ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python Flask + Vue 构建智能在线预约挂号系统完整实战指南

Python Flask + Vue 构建智能在线预约挂号系统完整实战指南 门诊排队的苦几乎每个人都经历过。早上六点冲到窗口队伍已经拐了两个弯好不容易排到你专家号早没了只剩一个下午的普通号——还是那种“医生看起来比你还累”的班次。也正是这些真实的场景让我动了手写一个东西的念头用python、flask和vue三件套做一个智能在线预约挂号系统把挂号这件事从“碰运气”变成“按需匹配”。这篇文章不是教科书是我从零搭这个系统的完整记录。包含数据库怎么设计、号源怎么防止超卖、症状怎么智能匹配科室、前端怎么和Flask对接以及我在本地部署时踩过的所有坑。适合正在做毕业设计、想写简历项目或者单纯想搞清楚Web全栈开发流程的朋友跟着敲一遍你会对“前后端分离”这四个字有完全不一样的理解。1. 项目整体设计与技术选型1.1 为什么是Flask Vue而不是其他组合选型之前我其实犹豫过。Java系的Spring Boot在校园项目里统治力很强但对我来说太重了写一个预约挂号系统要引入一堆XML配置和注解前期的环境折腾成本远高于业务本身。Flask的优势在于自由路由、请求处理、数据库绑定全凭自己说了算一个Python文件就能跑起来配合Flask-SQLAlchemy操作数据库非常顺手这对需要快速验证业务逻辑的场景特别合适。前端为什么选Vue而不是React因为我希望这套系统的学习曲线尽量平缓。Vue的模板语法接近原生HTML接口返回的数据可以直接挂在data里配合Vue Router做页面跳转Vuex或Pinia做用户状态存储几行代码就能串起来。更重要的是Vue生态里Element Plus这类组件库对表单和表格的支持非常完善预约挂号里面量大面广的“选科室、选医生、选时间”这类交互几乎就是为这些组件量身定做的。我实测下来这套组合还有一个隐形优势调试链路短。Flask跑在5000端口Vue的devServer跑在5173端口两者通过代理或CORS通信逻辑问题一眼就能定位是前端的还是后端的不用像单体应用那样在堆栈里翻半天。对于个人开发和课程设计来说这就是效率。1.2 功能模块拆解挂号系统真正需要什么很多人第一次设计这种系统容易把功能表堆得特别满什么健康资讯、在线问诊、药品商城全塞进去最后做出来一个四不像。我把预约挂号这件事还原到本质其实只有五个核心闭环科室与医生展示用户要能按科室找医生看到医生的职称、擅长领域、门诊排班。号源排班管理医生按日期和时段放出号源每个时段有总数和已预约数。在线预约与取消用户选择号源后提交预约生成预约记录未就诊前可以取消并释放号源。用户认证与个人信息注册、登录、身份信息维护、预约历史查询。智能导诊推荐用户输入症状描述系统匹配出最可能的科室甚至具体医生。其中第五项是这套系统的差异化亮点也是面试和评优时最能讲出东西的部分。它的核心逻辑不复杂但需要一套合理的匹配策略来支撑。后面我会单独用一整节来讲。1.3 数据库设计五张表如何支撑整个业务数据库设计是整个系统最先要定死的东西表结构一旦确定后面所有接口和前端页面都得围着它转。我最终设计了五张核心表刻意保持轻量这也是FlaskSQLite组合的舒适区user用户表id、username、password_hash、real_name、id_card、phone、role。role区分患者和管理员权限控制靠它。密码永远不存明文用werkzeug的generate_password_hash做哈希。department科室表id、name、description、keywords。keywords字段是给智能匹配用的存储“发热、咳嗽、胸闷”这类症状关键词用逗号分隔。doctor医生表id、dept_id、name、title、intro、avatar_url。职称和擅长领域放在这里前端医生卡片直接渲染。schedule排班表id、doctor_id、work_date、time_slot、total_num、booked_num。这是整个系统的核心并发点total_num和booked_num两个字段配合事务是防超卖的看门狗。appointment预约表id、user_id、doctor_id、schedule_id、visit_date、time_slot、status、created_at。status用整数枚举0待就诊、1已就诊、2已取消。我自己加了一个小技巧schedule表里存了doctor_id冗余的visit_date和time_slot虽然违反了一点范式但查询预约记录时少了一次JOIN页面响应快得多。对于小体量系统这种合理冗余是值得的。2. 智能导诊匹配从症状到科室的精准推送2.1 匹配逻辑的设计思路智能导诊是这周项目里我花时间最多的地方。去中医院或者综合医院的导诊台你会发现护士第一句永远问“您哪里不舒服”然后在小本子上翻对应的科室。这套系统的智能化本质上是把“护士翻本子”这个过程自动化。我采用的方案是中文分词 TF-IDF向量化 余弦相似度匹配外加一个手工维护的科室关键词库做兜底。用户输入一段症状描述系统先对描述做分词过滤掉无意义的停用词然后与每个科室的关键词集合做相似度计算分数最高的科室胜出并且把该科室下擅长对应方向的医生也一并推荐出来。输入我最近总是头晕还伴有恶心特别是早上起来的时候 分词最近/总是/头晕/还/伴有/恶心/特别/早上/起来/时候 过滤停用词后核心词头晕、恶心 匹配神经内科头晕、头痛、眩晕相似度最高 推荐神经内科 该科室主治眩晕方向的医生2.2 关键代码实现分词、向量化与相似度计算分词我用了jieba这是Python中文文本处理绕不开的库安装简单分词效果在通用领域足够好。TF-IDF向量化用sklearn.feature_extraction.text.TfidfVectorizer。核心代码大概长这样import jieba import jieba.analyse from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity # 科室关键词库实际项目中从department表的keywords字段读取 dept_keywords { 神经内科: 头晕 头痛 眩晕 失眠 面瘫 肢体麻木 脑梗, 心血管内科: 胸闷 胸痛 心悸 气短 高血压 水肿, 消化内科: 腹痛 腹泻 恶心 呕吐 反酸 腹胀 便秘, 呼吸内科: 咳嗽 咳痰 发热 咽痛 气喘 胸痛, 骨科: 腰痛 关节痛 骨折 颈椎 肩周炎 腿痛, 眼科: 视力模糊 眼痛 结膜炎 流泪 眼干, 耳鼻喉科: 耳鸣 听力下降 鼻塞 流涕 咽喉肿痛 } def recommend_department(user_input): # 1. jieba分词并抽取关键词 words jieba.analyse.extract_tags(user_input, topK5) if not words: return None # 2. 将科室关键词库和用户输入一起向量化 corpus list(dept_keywords.values()) # 追加用户输入作为最后一个元素 corpus_ext corpus [ .join(words)] vectorizer TfidfVectorizer() tfidf_matrix vectorizer.fit_transform(corpus_ext) # 3. 取最后一行用户输入与前n行科室计算余弦相似度 user_vec tfidf_matrix[-1] dept_vecs tfidf_matrix[:-1] scores cosine_similarity(user_vec, dept_vecs).flatten() # 4. 取最高分科室 dept_names list(dept_keywords.keys()) best_idx scores.argmax() if scores[best_idx] 0.1: # 低于阈值认为是无效输入 return None return {department: dept_names[best_idx], score: round(float(scores[best_idx]), 4)}这里有个细节要说明TfidfVectorizer默认的token_pattern对英文有效对中文必须以字词为单位处理所以我提前用空格把jieba分词结果拼接成了“拼音式”的字符串再喂给向量器否则整个矩阵都是空的匹配永远返回0分。这个坑我调了一个多小时才反应过来。2.3 无效信息过滤与匹配精度优化真实场景下用户的输入会很随意比如有人直接输“帮我看看”有人输“医生你好我想挂号”这些句子没有实质医学信息如果硬匹配会随机推荐一个科室体验非常糟糕。我用了两层过滤第一层是输入长度校验分词后核心词少于一个就判定无效。第二层是相似度阈值我实测下来阈值设在0.1比较合理。高于0.1认为有实质症状指向低于0.1则返回“请输入更详细的症状描述”的提示。这个值不是拍脑袋定的我拿了几十条测试语料调出来的阈值太高容易漏掉有效描述太低则会把“你好谢谢”这类话匹配成某个科室。精度优化还有一个方向同义词映射。比如用户说“心里不舒服”和“胸闷”在向量空间里距离很远但实际是同一个意思。我在科室关键词库里做了人工扩充“心慌”对应“心悸”“拉肚子”对应“腹泻”“发高烧”对应“发热”。这本质上是一个领域词典的维护工作不复杂但效果立竿见影。3. Flask后端API设计、JWT鉴权与防超卖3.1 蓝图划分与统一返回格式Flask项目结构我一直坚持按蓝图拆分避免所有路由堆在同一个文件里。这个预约挂号系统我划分了五个蓝图分别对应用户认证、科室医生、排班号源、预约操作、智能导诊app/ ├── app.py # 应用入口与配置 ├── models.py # SQLAlchemy模型 ├── extensions.py # db、jwt、cors等扩展实例化 ├── utils/ │ └── response.py # 统一JSON返回封装 ├── api/ │ ├── auth.py # 注册、登录、用户信息 │ ├── dept.py # 科室列表、医生列表 │ ├── schedule.py # 排班查询与号源状态 │ ├── appointment.py # 提交预约、取消预约、我的预约 │ └── recommend.py # 智能导诊推荐接口层最容易踩的坑是返回格式不统一。前端处理响应时希望所有接口都长一个样但我一开始写的接口有的返回纯数组有的返回带message的对象Vue组件里做数据解析简直想砸键盘。后来我封装了一个统一返回函数def ok(dataNone, msgsuccess): return {code: 0, data: data, msg: msg} def fail(msgerror, code-1): return {code: code, data: None, msg: msg}所有接口都在这个基础上返回前端axios拦截器只需要判断code是不是0逻辑瞬间清爽。这个习惯后来带到了我所有的Flask项目里省掉了大量联调扯皮的时间。3.2 JWT登录认证预约挂号系统里预约操作必须登录我用flask-jwt-extended做JWT认证。说下实际流程用户注册时密码通过werkzeug.security.generate_password_hash生成密文入库登录时用check_password_hash校验成功后签发一个包含用户id的JWT token前端把它存在localStorage里每次请求在Authorization头带上Bearer token。from flask_jwt_extended import create_access_token, jwt_required, get_jwt_identity auth_bp.post(/login) def login(): data request.get_json() username data.get(username, ) password data.get(password, ) user User.query.filter_by(usernameusername).first() if user and check_password_hash(user.password_hash, password): token create_access_token(identitystr(user.id)) return ok({token: token, username: user.username, role: user.role}) return fail(用户名或密码错误) appointment_bp.post(/create) jwt_required() def create_appointment(): user_id int(get_jwt_identity()) # ... 后续业务逻辑JWT有一个现场调试小细节token默认24小时过期我根据预约场景改成了2小时保证用户挂号操作足够从容又不会在没关闭窗口的情况下过期。测试时可以调小JWT_ACCESS_TOKEN_EXPIRES到60秒方便验证失效逻辑。3.3 预约防超卖事务与行锁才是关键预约挂号系统的并发核心在schedule表的 booked_num 上。想象一个场景上午的号源总共20个两个用户同时提交预约如果程序先查booked_num是否小于20再执行1更新两个请求都可能查询到“还剩余1个”的状态同时执行更新最后booked_num变成22超卖了两个号。我在Flask-SQLAlchemy里用带行锁的事务查询来解决from sqlalchemy import func from flask import current_app from app.extensions import db appointment_bp.post(/create) jwt_required() def create_appointment(): user_id int(get_jwt_identity()) data request.get_json() schedule_id data.get(schedule_id) # 使用with_for_update锁定该排班行直到事务提交 schedule Schedule.query.filter_by(idschedule_id).with_for_update().first() if not schedule: return fail(排班不存在) if schedule.booked_num schedule.total_num: return fail(该时段已约满) schedule.booked_num 1 appt Appointment( user_iduser_id, doctor_idschedule.doctor_id, schedule_idschedule.id, visit_dateschedule.work_date, time_slotschedule.time_slot, status0 ) db.session.add(appt) db.session.commit() return ok({appointment_id: appt.id})注意这里with_for_update()必须是显式事务db.session.commit()提交时释放行锁。我本地用两个终端同时发请求测试过第二个请求会在锁上等待第一个提交后拿到最新booked_num然后正确返回“已约满”。有个坑必须提醒SQLAlchemy默认的isolation_level在SQLite下是SERIALIZABLE但在MySQL下如果你是REPEATABLE READwith_for_update的表现才符合预期。如果用MySQL生产部署一定要确认连接串里设置了合适的隔离级别。SQLite本身对并发写有锁本地测试够用真正高并发还是要切到MySQL。3.4 取消预约与号源状态回滚取消预约的接口同样不能马虎。用户取消后不仅预约表要更新状态排班表的booked_num也要同步减1否则取消过的号就永远放不出来了。这个逻辑用同一个事务包裹appointment_bp.post(/cancel) jwt_required() def cancel_appointment(): user_id int(get_jwt_identity()) data request.get_json() appt_id data.get(appointment_id) appt Appointment.query.filter_by(idappt_id, user_iduser_id).first() if not appt: return fail(预约记录不存在) if appt.status 2: return fail(该预约已取消请勿重复操作) if appt.status 1: return fail(该预约已就诊无法取消) schedule Schedule.query.filter_by(idappt.schedule_id).with_for_update().first() schedule.booked_num max(0, schedule.booked_num - 1) appt.status 2 db.session.commit() return ok(msg取消成功)这个过程中最容易遗漏的是“门诊停诊”场景。比如医生临时停诊管理员批量改排班那些已预约的用户必须收到状态变化。我第一次做的时候完全没考虑这个后来加了status字段的两个额外取值3已停诊、4已改签但真正跑通的第一版还是被这个业务逻辑卡了很久。这一块的教训是系统的业务逻辑不只在代码里更多在真实流程的细节里。4. Vue前端工程搭建、路由管理与核心页面实现4.1 Vue工程创建与环境配置细节前端我用Vite Vue 3 Vue Router Pinia Element Plus这套组合。脚手架创建命令是npm create vitelatest frontend -- --template vue cd frontend npm install npm install vue-router4 pinia element-plus axios环境这块有几个新手极易踩的坑我逐个说。第一Node版本必须在14.18以上推荐用18 LTS。第二Element Plus要用按需导入配置unplugin-auto-import和unplugin-vue-components否则打包体积会大得离谱首次加载要好几秒。第三Vite默认端口5173Flask默认5000两个服务要跨域通信。开发阶段我在vite.config.js里配置代理让前端把/api开头的请求全部转发到Flask后端去import { defineConfig } from vite import vue from vitejs/plugin-vue export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { /api: { target: http://127.0.0.1:5000, changeOrigin: true } } } })代理配好以后前端代码里发请求统一用相对路径/api/xxx既解决了跨域生产环境下通过Nginx反向代理也天然兼容。我强烈不建议跨域时直接开Flask-CORS全放行虽然开发时少写几行配置但生产环境的安全隐患和配置切换的麻烦会反噬你。4.2 路由设计页面层级与动态切换挂号系统的页面逻辑主线上有四个关键页面首页科室列表、科室详情医生列表、医生详情排班与预约、我的预约。对应Vue Router配置const routes [ { path: /, component: HomeView, meta: { title: 首页 } }, { path: /login, component: LoginView, meta: { title: 登录 } }, { path: /dept/:id, component: DeptDetail, props: true, meta: { title: 科室详情 } }, { path: /doctor/:id, component: DoctorDetail, props: true, meta: { title: 医生详情 } }, { path: /appointments, component: ApptList, meta: { requiresAuth: true, title: 我的预约 } } ]这里props: true是我特别推荐的写法路由参数直接以props传给组件组件内部通过defineProps([id])接收比在组件里route.params.id获取要清晰得多。requiresAuth字段配合导航守卫实现登录跳转router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { next(/login) } else { next() } })4.3 核心页面的Vue组件实现拿“医生详情页”举个例子。这个页面要处理的是顶部医生简介卡片 下方排班日历 点击时段弹出确认框 预约成功后跳转。在Vue 3的组合式API下核心逻辑集中在onMounted里先拉医生信息和排班数据然后根据日期分组渲染每一个时段。script setup import { ref, onMounted } from vue import { useRoute, useRouter } from vue-router import { ElMessage } from element-plus import axios from axios const route useRoute() const router useRouter() const doctorId route.params.id const doctor ref(null) const schedules ref([]) async function fetchDoctorInfo() { const res await axios.get(/api/doctor/${doctorId}) if (res.data.code 0) { doctor.value res.data.data } } async function fetchSchedules() { const res await axios.get(/api/doctor/${doctorId}/schedules) if (res.data.code 0) { schedules.value res.data.data } } async function doBook(scheduleId) { const token localStorage.getItem(token) if (!token) { ElMessage.warning(请先登录) router.push(/login) return } const res await axios.post(/api/appointment/create, { schedule_id: scheduleId }, { headers: { Authorization: Bearer ${token} } }) if (res.data.code 0) { ElMessage.success(预约成功) fetchSchedules() } else { ElMessage.error(res.data.msg) } } onMounted(() { fetchDoctorInfo() fetchSchedules() }) /script这里有一个页面状态同步的问题值得说一说。预约成功后如果不重新拉取排班数据界面上那个时段的剩余号数还停留在旧状态用户再点一次就会收到“已约满”的提示。所以我在doBook成功分支里主动调用了fetchSchedules()保证前端UI始终和后端数据一致。这个细节反应了前后端分离架构下一个核心原则前端展示的数据是后端状态的投影任何操作之后都要重新同步投影。4.4 登录状态全局管理用户信息在多个页面都需要访问我用Pinia做了全局storeimport { defineStore } from pinia export const useUserStore defineStore(user, { state: () ({ token: localStorage.getItem(token) || , username: localStorage.getItem(username) || , role: localStorage.getItem(role) || }), actions: { setLoginInfo({ token, username, role }) { this.token token this.username username this.role role localStorage.setItem(token, token) localStorage.setItem(username, username) localStorage.setItem(role, role) }, logout() { this.token this.username this.role localStorage.removeItem(token) localStorage.removeItem(username) localStorage.removeItem(role) } } })axios请求拦截器在每次请求之前自动带上token这样每个业务组件里就不用重复写Authorization头了axios.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config })5. 联调、部署与常见问题排查实录5.1 开发联调中的CORS与代理问题我开发时候因为偷懒想直接在Flask端开CORS让前端直接访问结果调试出了各种奇奇怪怪的报错。最常见的就是Access-Control-Allow-Origin缺失浏览器的CORS预检直接拦截POST请求。后来我果断放弃CORS路线改走Vite proxy代理前端的请求都发到同源的5173端口由devServer转发给Flask整个联调立刻顺畅。还有一个隐藏很深的坑Flask侧的URL前缀要统一。我最初设计接口时有的路由是/api/dept/list有的是/dept/list结果前端代理规则既要配/api又要配/dept自己把自己绕晕。教训是后端Blueprint注册时统一使用/api前缀全项目只暴露这一个入口代理规则和Nginx配置都会简单到极致。5.2 生产部署要点Flask托管Vue构建产物开发完成后要交付一个可运行的系统。最省事的方式是让Flask直接托管Vue的构建产物把前端和后端打包成单个Flask应用本地跑一个服务就能访问全部功能。前端构建cd frontend npm run build构建完成后dist目录里生成静态资源。在Flask端做两件事第一把dist目录作为静态文件目录第二处理Vue Router的history模式刷新404问题。from flask import send_from_directory # dist目录 app.route(/assets/path:filename) def static_files(filename): return send_from_directory(../frontend/dist/assets, filename) # 所有非API路由都回页面入口 app.route(/, defaults{path: }) app.route(/path:path) def catch_all(path): if path.startswith(api): return fail(接口不存在) return send_from_directory(../frontend/dist, index.html)这个配置实现的关键点是API请求不会落在catch_all路由上页面跳转则通过history/api回退到index.html由Vue Router接管。我第一次部署时没处理history路由回退结果用户从首页点进科室页手动刷新就白屏了。这个问题在Vue部署里极其常见务必提前处理。5.3 常见问题排查速查表现象可能原因排查与解决前端请求接口提示Network ErrorVite代理未配置或Flask未启动确认Flask监听5000端口访问http://127.0.0.1:5000/api/xxx是否能直接返回部署后刷新页面404Vue Router history模式未配置回退在Flask添加catch_all路由让非API路径返回index.html预约时提示“数据库被锁定”SQLite并发写冲突用Flask-SQLAlchemy的with_for_update加行锁生产切换MySQL预约成功但页面剩余号数没变前端未重新拉取排班数据预约成功回调里重新调用排班列表接口登录后刷新就掉登录状态token未持久化或未在拦截器附加确认login时写入localStorageaxios拦截器附加Authorization头中文科室匹配结果不准确停用词表缺失或关键词库不完整扩展jiejba停用词表维护科室同义词映射Vue打包文件太大首次加载慢Element Plus全量引入配置unplugin-vue-components按需引入组件上传头像后页面不显示静态资源路径未对齐确认Flask静态目录和avatar字段存储路径一致5.4 我踩过最狠的一个坑时间字段的时区陷阱这个坑必须单独拿出来说。我的schedule表里work_date用的是Date类型time_slot是字符串。Flask-SQLAlchemy默认的JSON序列化会把Date转成YYYY-MM-DD格式但Vue侧如果用new Date()解析带T的ISO字符串会被当成UTC时间在中国时区下会偏移8小时导致选“上午”的号源前端日期显示成前一天。我的解决方式是后端序列化之前统一格式化def schedule_to_dict(s): return { id: s.id, work_date: s.work_date.strftime(%Y-%m-%d), time_slot: s.time_slot, total_num: s.total_num, booked_num: s.booked_num, remain: s.total_num - s.booked_num }让前端拿到的永远是字符串日期不在JS里做任何时区转换。这个经验看起来简单但排查时间偏移问题花了我整整一个下午最后发现是时区在捣鬼。做任何和时间相关的系统建议前后端之间一律传字符串或时间戳避免Date对象被隐式转换到本地时区。6. 一点想法预约挂号系统的业务规模不大但五脏俱全。它把用户认证、权限控制、数据建模、并发处理、搜索匹配、前后端分离部署这些Web开发的核心命题全串了一遍。做完之后你再去看市面上任何一个SaaS系统的架构基本都能想象出它背后的表和接口是怎么组织的。如果你接下来想扩展有几个方向性价比比较高。一是把推荐算法换成交互式多轮对话用户第一次描述“头痛”系统追问“是持续痛还是一阵一阵”不断缩小科室范围体验立刻上一个台阶。二是增加号源监控和管理员图形化排班面板让医生排班像拖日历一样直观。三是把预约成功通知从简单的页面提示换成本地消息提醒这个配合PWA或者Electron都能做到热词里提到的“electron打包vue项目”其实就是把前端壳子套上桌面应用外壳后端接口不变改造量并不大。我个人实操中的体会是这种全栈小项目最大的价值不是把代码写出来而是遭遇并解决那些材料和文档里根本没有的坑。每一个坑都是一次思维锤炼。你把这套系统完整走通再回头看自己刚开始时那种“不知从何下手”的不知所措大概会觉得原来所有看似复杂的系统拆到最底层不过是一张张表、一个个接口、一次次点击的组合。把它们一件一件做对系统就成了。
返回列表