从零开发私人牙科诊所管理系统:Django+Vue实战与预约冲突处理

发布时间:2026/10/10 22:18:09
从零开发私人牙科诊所管理系统:Django+Vue实战与预约冲突处理 接手这个项目纯属偶然。朋友在城郊经营一家私人牙科诊所规模不大两台牙椅三个诊室但预约登记到病历归档全靠纸质登记簿加一张Excel表。有一天他跟我吐槽患者打电话来约洗牙前台要先翻纸看哪天有空经常一翻就是两分钟约重了就只能再打电话道歉。更麻烦的是根管治疗这种要跑好几趟的项目上次做到哪一步、用了什么材料都写在纸质病历里医生有时候找不到就得问患者。后面聊下来我就知道他需要的不是一套通用诊所管理软件而是一个贴合牙科场景的私人牙科诊治管理系统——能管预约、管病历、管收费、管材料库存最好还能让医生在诊室里用平板快速记录。当时网上关于这类系统的讨论不算少但通用HIS软件对私人小诊所来说太笨重落地成本高。于是我用Python Django Vue自己定制了一套开发工具用PyCharm从数据库设计到前端交互全走了一遍。整个过程踩了不少坑也沉淀了不少经验。这篇就顺着实际开发顺序把核心设计逻辑和实现细节完整复盘一遍希望对正在做诊所管理系统、医务类管理系统的同行有参考价值。1. 从纸质排班到数字化管理私人牙科的业务逻辑拆解1.1 预约管理不是事件表那么简单很多人听到预约管理第一反应是做个日历选个日期时间存进一张表里完事。但放到牙科诊所这个场景约束条件要比想象中多得多。首先每个医生在同一时间只能服务一个患者这个好理解。但牙科治疗项目的时间长度差异极大——常规洗牙30分钟补牙45分钟根管治疗单次可能1小时正畸复诊可能只要15分钟。如果预约模型的粒度只做到上午、下午这种粗粒度医生时间是浪费的但做到每分钟都可选患者和前台又都受不了。因此我在设计里引入了半小时时间片预约记录必须带明确的开始时间和结束时间而不是只存一个日期。其次牙科预约有很强的连续性需求。根管治疗通常分三到四次完成每次间隔一两周种植牙从第一期手术到戴上牙冠可能要跨三个月。这类治疗一旦预约就往往伴随明确的复诊计划。如果系统只做一次性预约医生每次都要重新查病历确认下一步效率提升有限。所以预约模块必须和病历模块联动——预约记录里可以直接带出本次就诊目的和关联治疗计划编号。再者取消与改约在诊所里是高频操作。患者临时改时间前台重新排旧的预约不能简单删掉否则排班记录、医生绩效统计、到院率分析就全乱了。所以我给预约设计了待确认、已确认、已就诊、已取消、已完成五种状态改改约产生的历史记录也保留只是状态变更为已取消。1.2 病历、收费与库存的联动关系前面说的还是单点功能真正让这个系统复杂度上台阶的是病历、收费、库存三者之间的联动关系。牙科的一个收费项目往往对应多种材料消耗。比如一颗烤瓷牙的制备收费单上是烤瓷冠1颗这笔收费同时会扣减材料库存中的冠体材料、粘接剂、临时冠材料。如果库存模块和收费模块各管各的前台忘了录出库月底一盘点就对不上账。因此我在收费单模型里设计了明细子表每条收费明细都可以关联一个或多个库存出库记录。保存收费单时通过Django的事务机制一并写入出库流水保证收费成功则库存必定扣减。病历模块的设计则要区分初诊记录和治疗记录。初诊记录是患者的症状主诉、检查结果、诊断结论治疗记录则按次记录本次做了什么操作、用了什么材料、下次复诊安排。这两者共用一个外键关联到患者档案但内容结构差异较大如果塞进同一张表会有很多空字段。我当时在Django里用了多表继承的方式做:一张CaseRecord主表保存通用字段再拆出InitialVisit和TreatmentRecord两张子表存各自专有字段。这样查询患者历史时统一查CaseRecord需要看细节时再查对应子表逻辑干净模型维护也方便。库存模块还有个特殊点牙科材料有大量非独立包装的小类耗材比如树脂、粘接剂、麻药按支/瓶入库但单次使用量可能是毫克、毫升或半支。按整数管理库存根本走不通。后来我放弃了精确到单位用量的念头采用最小销售单元管理——即库存单位按支/瓶/盒记录每次使用按1扣减或按0.5扣减模拟半支耗材月底允许盘盈盘亏并做调账。这么做精度降低了一些但对小诊所来说已经足够实用总比Excel手工记账强太多。2. Django与Flask的取舍复盘重框架带来的安全感2.1 按真实需求把两个框架拉出来遛一遛项目标题里同时出现了Django和Flask两个词很多人会问到底用哪个。我当时的真实过程是在这两个框架之间纠结了差不多一周。Flask轻量、自由、上手快理论上几百行代码就能把一个原型跑起来Django则自带了Admin后台、ORM、迁移工具、认证体系和模板引擎说是全家桶也不过分。我把需求清单列出来逐项打了个分:需求项DjangoFlask数据模型迁移与改表自带迁移机制改了模型直接生成迁移文件需要额外配Alembic迁移链路由自己维护后台管理界面Admin后台几乎零成本需自己拼SQLAlchemy Admin或引入Flask-Admin用户角色与权限自带User模型和权限系统可扩展需要Flask-Login加自定义权限装饰器写起来烦表单校验与CSRF防护内置默认启用需要Flask-WTF主流做法但要自己接上线后的维护体积代码重但体系完整不容易漏代码少但每个安全细节都要自己补真实项目里,这套系统除了对外API还需要一个给内部人员使用的管理前台。如果选Flask光是用户登录、权限控制、数据表格分页筛选、Excel导出这几个基础功能就要额外接六七个子库。而Django把这些都内建了开发效率和后续维护成本差异非常明显。最终我选择了Django作为主框架,Flask在实践中只用来写过两个内部小工具脚本并没有进入主线业务代码。这不是说Flask不行。如果你的项目是纯API后端前端完全独立部署团队又熟悉Flask全家桶Flask完全能胜任。但诊所管理系统这种重度依赖增删改查后台管理权限的业务Django的成套体系能让你少写很多重复代码也更不需要担心遗漏基础安全配置。2.2 PyCharm里的工程骨架与开发环境准备项目从零开始第一步是在PyCharm里搭建工程骨架。我用的版本是社区版加一个专业版License主要看重专业版对Django模板、SQL数据库、调试器集成的支持。打开PyCharm后选择New Project解释器用Python 3.10虚拟环境就选项目目录下的venv不要用全局环境不然后面装依赖容易和系统Python打架。工程结构我是这样组织的clinic_project/ ├── manage.py ├── config/ # 项目配置 │ ├── settings/ │ │ ├── base.py │ │ ├── dev.py │ │ └── prod.py │ ├── urls.py │ └── wsgi.py ├── apps/ │ ├── patients/ # 患者档案 │ ├── appointments/ # 预约管理 │ ├── medical_records/ # 病历模块 │ ├── billing/ # 收费与库存 │ └── accounts/ # 用户与权限 ├── static/ ├── templates/ └── requirements.txt业务代码全部收敛到apps目录下每个app只做一件事这是Django的最佳实践。如果全写在一个app里项目过半年再看就会变成一堆互相耦合的模型和视图改一个字段要牵动整个项目。开发环境我用的SQLite起步方便本地调试配置写在dev.py生产部署切到MySQL配置写在prod.py通过环境变量切换。系统在起步阶段不需要一开始就上重型数据库但settings里留好了切换开关这点后面部署时很省心。2.3 长线维护角度下的最终选择开发到中期还发生过一次数据库结构调整——给预约表加了一个复诊计划外键。Django的迁移机制让我只跑了一行命令就能在保留已有数据的前提下完成改表。那个瞬间让我确信没选错工具。顺带一提开发过程中使用PyCharm的Run Django management task功能会很顺。在配置里加一个manage.py的工程配置启动项选择Runserver调试时可以直接在编辑器的断点处看到ORM查询的原始SQL这比打印一堆print语句高效得多。代码写完后右键选择Coverage还能看测试覆盖率这些工具上的流畅度也是开发效率的一部分个人开发时尤其明显。3. 数据模型从设计到落地牙位编号与时间片是关键3.1 患者档案与FDI牙位编号的结构化存储患者档案本身是个标准的主数据模型字段不复杂姓名、性别、出生日期、联系电话、过敏史、既往病史。真正有牙科特色的是牙位记录。牙科里最常用的是FDI牙位标记法用两位数字表示牙齿第一位代表象限第二到八位代表牙齿位置。但普通用户包括前台并不记得这些编号医生在看图时习惯直接点击左上第三颗牙这种直观说法。我的解决方案是在后端数据表里用字符串存储标准FDI编号比如11表示右上中切牙24表示左上第一前磨牙前端展示时则渲染一张牙位图医生点哪颗牙前端自动换算成对应的FDI编号交给后端。患者病历中的牙位信息不再局限于自由文本而是结构化的ToothRecord表class ToothRecord(models.Model): case_record models.ForeignKey( medical_records.CaseRecord, on_deletemodels.CASCADE, related_nametooth_records, ) tooth_code models.CharField(max_length3, db_indexTrue) # 如 11, 24, 48 diagnosis models.CharField(max_length200) treatment models.TextField(blankTrue) created_at models.DateTimeField(auto_now_addTrue)有了这张表统计某个时间段内哪个牙位最常见、某位医生处理过多少颗特定牙位的牙齿都变得很简单直接按tooth_code过滤即可。这比在长文本里用模糊搜索精确得多也是这类系统一个很实用的细节设计。3.2 预约时间片该怎么建模预约表的设计是这套系统的核心难点。我最初的模型只有start_time一个字段后来和医生聊需求时才发现没有结束时间的话查询重叠预约的SQL写起来很别扭而且前台无法直观看到某个时段是否被占满。最终模型是class Appointment(models.Model): STATUS_CHOICES ( (pending, 待确认), (confirmed, 已确认), (finished, 已就诊), (cancelled, 已取消), ) doctor models.ForeignKey( accounts.Doctor, on_deletemodels.PROTECT, related_nameappointments, ) patient models.ForeignKey( patients.Patient, on_deletemodels.PROTECT, related_nameappointments, ) start_at models.DateTimeField(db_indexTrue) end_at models.DateTimeField(db_indexTrue) purpose models.CharField(max_length100) # 就诊目的洗牙/补牙/根管复诊 status models.CharField( max_length20, choicesSTATUS_CHOICES, defaultpending, db_indexTrue, ) plan models.ForeignKey( medical_records.TreatmentPlan, nullTrue, blankTrue, on_deletemodels.SET_NULL, )时间粒度上我让前端提交时把开始时间传给后端结束时间由后端根据purpose自动计算。比如洗牙默认30分钟根管治疗默认60分钟。这样避免出现一套系统中同一治疗项目不同医生预估时长不一致的情况。系统管理员后来可以根据实际运营数据调整每种治疗的推荐时长改配置就行不用动代码。关于模型关联Monday注意避开一个坑不要把医生和患者的关联删除模式设为CASCADE。假如某个医生离职CASCADE会把该医生关联的所有历史预约全部连带删除这绝对不是期望的行为。所以这里我用的是PROTECT删除被业务记录引用的医生会直接被数据库拒绝逼着你先处理历史数据或做逻辑删除。3.3 电子病历、收费单与库存流水如何串起来病历和收费的串联靠治疗计划这个中间层。一个CaseRecord可以关联多个TreatmentPlan每个TreatmentPlan又关联多个TreatmentRecord按次记录的每次操作。这样患者来复诊时医生或前台只要定位到本次关联的TreatmentPlan就能看到之前完成了几个步骤、还剩几个步骤。收费模块则直接关联到TreatmentPlan层一次收费单对应一个治疗计划收费明细里的项目和材料逐条列出然后通过流水表关联到库存出库。class ChargeOrder(models.Model): number models.CharField(max_length32, uniqueTrue) patient models.ForeignKey(patients.Patient, on_deletemodels.PROTECT) plan models.ForeignKey( medical_records.TreatmentPlan, nullTrue, blankTrue, on_deletemodels.SET_NULL, ) total_amount models.DecimalField(max_digits10, decimal_places2) paid models.BooleanField(defaultFalse) created_at models.DateTimeField(auto_now_addTrue) class StockFlow(models.Model): material models.ForeignKey(billing.Material, on_deletemodels.PROTECT) charge_order models.ForeignKey(ChargeOrder, on_deletemodels.PROTECT) change_quantity models.IntegerField() # 负数为出库 created_at models.DateTimeField(auto_now_addTrue)这套结构的核心思想是业务链条从病历起点流经治疗计划终结于收费明细库存出库。不管业务怎么变数据流始终一致且可追溯。前端操作员录收费单时后端在一个事务里同时验证库存余量、创建出库流水、扣减库存一旦其中任何环节失败整单回滚。4. 预约冲突与并发写入后端最难啃的一块骨头4.1 半小时粒度背后的业务约束预约冲突是整个系统最容易出bug的部分。设想一下场景前台给患者A约了9点到9点30分的洗牙此时患者B打来电话想约9点15分到9点45分的补牙——两个预约在9点15分到9点30分这一段重叠了系统必须拒绝。但冲突的边界定义并不只是时间重叠。两位不同医生的预约可以重叠同一医生同一时段不能重叠状态为已取消的预约不占用时间待确认和已确认都要占用时间。所以查询条件要同时过滤doctor和status。这类业务逻辑如果写在视图函数里随着后续需求增长会越来越乱。我把它收敛到模型管理器的一个类方法里后续REST API和后台表单都调用这个方法做校验逻辑只维护一处。4.2 select_for_update与唯一索引的双保险写完冲突检测的逻辑后我曾以为大功告成。直到某次用并发模拟工具压测试时发现两个请求同时进来理论上只有第一个能预约成功但实际两个都通过了——这就是经典的并发竞态问题两个事务同时查询发现没有重叠预约然后同时插入双双成功。解决思路有两层。第一层用事务加锁from django.db import transaction from django.db.models import Q transaction.atomic def check_and_create_appointment(doctor, patient, start_at, end_at, purpose): locked_exists Appointment.objects.select_for_update().filter( doctordoctor, start_at__ltend_at, end_at__gtstart_at, status__in[pending, confirmed], ).exists() if locked_exists: raise AppointmentConflictError(该医生在所选时间段已有预约) return Appointment.objects.create( doctordoctor, patientpatient, start_atstart_at, end_atend_at, purposepurpose, )select_for_update会在满足条件的行上加排他锁直到事务结束。这样并发事务查询时后进入的事务会等待前一个事务提交然后才执行exists()检查此时就能看到前一个事务刚刚插入的新记录从而正确拒绝冲突。不过数据库加锁也有限制在Django默认的MySQL事务隔离级别下如果查询用的是非唯一索引范围条件有可能会锁住更大范围导致不必要的等待反之如果索引设计不恰当也可能压根没锁住目标记录。为了稳妥我还加了第二层保障在表上建一个覆盖时间段的唯一约束思路——把医生的ID和预约的开始时间、结束时间做联合唯一索引显然不合适不同患者完全可能约同一医生不同时间。所以我选择了逻辑锁最终一致性校验作为兜底。在写入Appointment前再查一次重叠记录即使前面有极端情况漏掉前端在收到重复确认弹窗时会重新拉取最新排班数据从业务层面兜住冲突。实际运行中这套组合足够稳定没有出现过真实的重复预约。4.3 冲突检测的业务语义与测试用例处理的越往后越发现业务语义比并发细节更容易踩坑。比如改约操作患者想把周五改成下周一前台修改预约时间。这本质上也是先检查下周一是否有空然后把原预约状态改为已取消再插入一条新预约。如果直接UPDATE原记录的start_at和end_at前面的check逻辑依然能查重叠但会失去改约历史。所以我在Service层单独封装了一个reschedule方法内部先delete语义改为cancelled再create语义新增一条pending保证每个操作都走相同的校验路径。为了让这套逻辑以后不会被改坏我写了pytest测试用例覆盖四种典型场景同一医生时间完全重叠必须拒绝同一医生时间部分重叠必须拒绝同一医生时间完全不重叠允许通过不同医生同一时间段两个预约允许同时存在写测试时我特意启用了数据库事务回滚每个用例结束都自动清空数据避免测试数据互相污染。这些测试后来帮了大忙因为上线后需求方提了不少小改动比如周末可以半天不排班、某个医生外出学习一天不排班每次改完跑一遍测试至少核心预约逻辑没被弄坏。5. Vue前端与流程交互让诊所真正用起来的关键5.1 前端技术栈与工程搭建后端开发的同时前端基本同步进行。选型上我用的Vue 3 Element Plus Vite后端提供JSON API。为什么不用Django自带的模板渲染因为预约日历、牙位图这类交互组件用原生模板写会非常痛苦而且医生诊室需要用平板操作前后端分离后前端资源可以部署到CDN或者独立Nginx路径下也更灵活。Vite新建项目后我直接用npm装vue-router、axios、element-plus。UI组件库选Element Plus是因为它在Vue 3生态里最成熟表格、弹窗、表单校验都有现成组件对于管理系统这种重表格、重表单的业务场景能省一半工作量。k这里特别强调一个实操点Element Plus在按需引入时组件和对应样式要一起注册不然运行时会显示加载了组件但样式不对。资源打包配置建议开启分包首屏加载会快很多尤其是牙位图这种图片资源较多的页面。5.2 牙位图组件的简易实现牙位图是本项目前端最独特的部分。实现思路并不复杂用一张上下颚牙齿排列的SVG底图每颗牙齿都是独立的可点击区域。我把恒牙编号挂在对应牙齿的data属性上点击事件拿到编号后就可以打开病历编辑抽屉。核心逻辑大致是const FDI_MAP { 11: { row: 1, col: 1 }, 12: { row: 1, col: 2 }, // ... 完整映射省略 }; function handleToothClick(event) { const toothCode event.currentTarget.dataset.toothCode; if (selectedTooth.value toothCode) { selectedTooth.value null; } else { selectedTooth.value toothCode; } }上面的FDI_MAP实际有32条映射恒牙28颗智齿4颗完整展开太长。前端拿到toothCode后在病历编辑表单里把该牙齿标记为选中并设为主诉牙或治疗牙。这个交互满足了牙科医生的日常操作习惯看图说话不用记编号。5.3 接口层设计与Token处理接口层我在axios实例上封装了统一的请求和响应拦截器。请求拦截器从localStorage取token附带在Authorization请求头响应拦截器统一处理401跳转登录、200正常返回、500弹错误提示。这套封装让业务组件里的API调用非常精简。但这里有一个本地部署场景必须注意如果诊所内网访问前端和后端是分开的两个服务很容易遇到跨域问题。我在Django侧的解决办法是装django-cors-headers配置允许的域名列表。如果嫌麻烦也可以在Nginx层做反向代理把/api路径直接代理到Django服务浏览器视角就完全同源了CORS自然消失。我最后用的是Nginx代理方案更干净还能顺带做HTTPS终结。Token策略上我直接用Django的Session认证前端登录后通过Cookie交互——因为系统跑在诊所内网没有跨域第三方数据交换需求Session够用且省心。如果用JWT还要处理刷新、过期、黑名单对这个项目来说是过度设计。这个取舍是我做完之后体会比较深的一条技术选型不重要适配场景才重要。6. 部署落地与数据安全开发完成只是第一步6.1 Linux服务器部署的实际操作系统开发完不等于能用部署上线才是另一个坑的起点。诊所没有专职运维我尽量把流程做得简单可控一台云服务器系统选Ubuntu部署Nginx uWSGI MySQL全部通过Docker Compose编排。配置文件里比较关键的是uWSGI的进程数和Django的ALLOWED_HOSTS。ALLOWED_HOSTS如果配得不全外部请求会被Django直接返回400这个新手最容易踩。我当时把诊所的公网IP、内网IP、域名都加了进去排查问题少花了不少时间。还有一点是Django的settings里DEBUG必须设为False同时把静态文件交给Nginx处理否则管理后台的CSS会全部丢失。数据库方面我用MySQL 8.0字符集统一utf8mb4排序规则utf8mb4_general_ci。Django自身带的迁移文件在MySQL上执行时可能会遇到一些索引过长的问题解决方案是把相关字段的max_length调小或者手动指定索引名这需要在正式部署前完整跑一遍迁移流程。6.2 敏感数据的加密与备份策略说到数据安全这是医疗系统不能回避的一环。虽然我不去涉及具体合规政策条文但负责的开发者至少要具备这份意识患者姓名、电话、病历记录都属于敏感数据。我在数据库层面没有对姓名做全列加密因为这会影响检索但对病历详情字段做了加密存储方案很简单用加密库对长文本内容加密后写入字段查询时再解密。这个方案的取舍是检索病历全文时需要先把该批次数据解密再做内存匹配对数据量不大的小型诊所来说完全可接受。备份策略更要重视因为诊所一旦用上系统病历数据就是核心资产。我写了一个每日凌晨3点的备份脚本用mysqldump导数据保留最近30天。同时在应用里做了一个导出病历归档的功能护士每个月可以手动导出一份PDF版病历汇总放到诊所自己的离线硬盘里。技术手段是双保险管理习惯才是更稳的那条腿。6.3 上线初期的运维盲区上线后第一个月我几乎每周都会被叫过去看一个小问题。比较典型的有三类。第一类是打印机兼容性前台打印收费单有些老式打印机驱动不支持浏览器直接打印后来我引入了一个轻量打印插件客户端收费单通过调用本地打印服务完成问题解决。第二类是权限越权前台小妹不小心点进了后台管理页她可以看到所有患者的联系方式。虽然她不会乱用但对权限控制还是做了收紧非医生账号只能看自己的预约列表。第三类是高并发误判某天瞬间来了十几个患者预约页面卡了几秒很多人以为是服务器性能问题实际是牙位图SVG资源没有走缓存。给静态资源加Etag缓存后秒开级别就恢复了。上线的整个过程提醒了我开发清单上做得再完善也不如真实环境里跑一个月暴露出来的问题多。作为开发者我能做的就是把这些偶然的运维问题转化为可复现的排查流程比如日志记录里加上操作人、操作时间、前端请求ID问题出现时根据日志快速还原操作路径而不是在服务器上瞎试命令。从最初接需求到开发完成再到线上稳定运行这套私人牙科诊治管理系统给我的最大收获不是代码量本身而是以下三点第一业务理解永远比技术选型重要不了解牙科时间片、牙位编号、耗材扣减的细节写出来的系统就是通用模板第二复杂逻辑要靠多层防护而不是单点保障预约冲突既要查又要锁还要有测试用例兜底第三系统的价值最终体现在使用者手上医生愿意在诊室点开它记录病历前台能快速查空位、收费、开发票这个系统才算真正落地。如果这篇文章能帮到正在做同类系统的人避开几个我踩过的坑那就是它最大的价值了。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询