简介:面向医院日常管理的Web系统项目,基于JavaScript与TypeScript构建,采用AdminLTE后台模板,适合Web开发学习者、计算机专业学生用于课程设计或毕业设计参考,也适合需要搭建后台管理系统的开发者。系统围绕患者、医生、管理员三类角色设计:患者可登录、预约医生、查看预约记录、修改密码;医生可添加/删除患者、在线开具处方、回复患者预约;管理员则集中管理所有患者、医生、预约及反馈信息,并支持新增/删除医生。三个模块彼此联动,覆盖了预约、排班、处方、用户管理等典型业务环节。压缩包内共2000个文件,以JS、HTML、CSS、SCSS、TypeScript等前端源码为主,另含PHP、SQL后端交互与数据库脚本,以及SVG图标、字体、配置文件等,整体约50.49MB。目前已有158人学习/下载,资源按模块组织、目录结构清晰,适合二次开发和深入理解前后端交互与角色权限控制。
1. hospital-management-esystem:先说清它治什么病
医院管理系统难的地方从来不是“功能多”,而是挂号、候诊、看诊、收费、发药这几条线之间必须始终保持状态一致。hospital-management-esystem 这个项目标题指向的就是一套覆盖日常医院管理的最小闭环系统——患者建档、医生排班、门诊看诊、收费退费、药房发药,以及最基本的权限和操作留痕。我见过不少团队把它想成“写几个增删改查页面”,结果上线第一周就被退费冲销和并发挂号打回原形。这套东西适合三类人:正在给中小医院或诊所做信息化的工程师、接手别人半成品系统准备重构的开发者、以及想用真实业务来练手的后端学习者。它真正要解决的问题只有一个:让钱、药、号三条数据流在忙碌的日常里不打架。
2. 模块拆解:日常医院管理到底管哪些事
2.1 最小模块集合:先把边界画清楚
拿到 hospital-management-esystem 这类标题,最容易犯的错是想一次做完所有功能。我建议先按业务边界拆成下面这组最小模块,每块只负责一件事:
| 模块 | 核心职责 | 常见易错点 |
|---|---|---|
| 基础数据 | 科室、医生、排班、号源规则 | 排班改期后旧号源没作废 |
| 患者管理 | 建档、证件号唯一、手机号留痕 | 同一患者重复建档 |
| 挂号 | 现场挂号、预约、退号 | 并发窗口抢同一个号 |
| 候诊叫号 | 入队、叫号、过号处理 | 跨天号序错乱 |
| 门诊看诊 | 医生查看患者、开医嘱、写处方 | 医嘱与收费单脱节 |
| 收费结算 | 收费单、支付、退费 | 退费把账冲不平 |
| 药房发药 | 处方核验、扣库存、退药 | 库存负数、效期混乱 |
| 统计报表 | 日门诊量、收入、退费占比 | 报表口径对不上 |
先按这个边界做,把每个模块之间只留一个调用入口,后面加住院、检验、手术才不会被既有结构卡死。模块之间最要紧的是“就诊记录”贯穿全局,其他模块都围绕它转。
2.2 门诊主流程:一次就诊要躺过几个状态
日常医院管理的主流程可以压缩成一条状态链:挂号 → 候诊 → 看诊 → 开医嘱 → 收费 → 发药 → 离院。中间任何一步被打断,系统都要能说清楚“这个患者现在在哪”。我用过一次很深的教训:最初只给挂号记录配了一个 status 字段,状态流转拿 if 散写在各个接口里,结果退号、爽约、医生临时停诊三种情况叠加时,状态根本对不上。
正确做法是把状态机显式建出来。每个状态只允许固定的几个转移路径,比如:
- 已挂号 → 候诊中:护士确认入队
- 候诊中 → 看诊中:医生叫号
- 看诊中 → 已完成:医生点完成并保存医嘱
- 已完成 → 已退号:不允许,必须走退费的独立流程
- 已挂号 → 已退号:需要收费员操作并校验未收费
状态变化时必须同时记录操作人、操作时间和变更原因,这是后面做对账和审计的基础。把状态机写成独立枚举类并在入口处做校验,比到处贴 if 判断要稳得多,这也是整个系统最值得先写对的部分。
2.3 医生工作台的数据视图:医嘱、处方、收费单各管一头
医生工作台是医院管理系统最重要的日常界面,但它的数据结构经常被做错。一次就诊记录 visit 是根节点,下面挂三种数据:医嘱、处方、检查申请。我一般会严格区分它们的语义——医嘱是临床动作的描述,比如“静滴”;处方是具体的药品明细,比如“阿莫西林胶囊 0.5g × 3 天”;收费单才是财务口径,记录这笔处方收了多少钱、什么状态。
这三者如果混在同一张表里,后面做退费、拆分结算、医保对账都会极其痛苦。一个患者看一次病,可能看诊时先开了一盒药收费,后来又开一个检查再收一次费,也就是一个 visit 下会有多张处方和多张收费单。所以数据结构上应该是 visit(1)→ charge_order(N),charge_order(1)→ order_item(N),而处方信息挂在 order_item 上或单独与收费项关联。药房只看已收费状态的收费项,避免“处方开了但没交钱也发了药”。
3. 用四张表撑起一套医院后台
3.1 患者、就诊、收费三张核心表
数据库设计不用一上来追求二十张表。把 patient、visit、charge_order 这三张核心表先建扎实,整个系统就立住了。下面是我常用的建表脚本,字段注释里标了必须注意的点:
-- 患者表:证件号唯一,防止重复建档 CREATE TABLE patient ( id BIGINT UNSIGNED AUTO_INCREMENT, id_card_no VARCHAR(32) NOT NULL COMMENT '证件号,唯一索引', name VARCHAR(50) NOT NULL, gender TINYINT NOT NULL DEFAULT 0 COMMENT '0未知 1男 2女', phone VARCHAR(20) DEFAULT NULL COMMENT '联系方式,可空', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_id_card (id_card_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='患者表'; -- 就诊表:一次挂号对应一条记录,队列号是当天叫号的唯一依据 CREATE TABLE visit ( id BIGINT UNSIGNED AUTO_INCREMENT, visit_no VARCHAR(32) NOT NULL COMMENT '就诊号,格式YYYYMMDD+序号', patient_id BIGINT UNSIGNED NOT NULL, dept_id BIGINT UNSIGNED NOT NULL, doctor_id BIGINT UNSIGNED NOT NULL, queue_no INT NOT NULL COMMENT '当天号序,叫号排序只认这个', channel TINYINT NOT NULL DEFAULT 1 COMMENT '1现场 2预约', status TINYINT NOT NULL DEFAULT 1 COMMENT '1已挂号 2候诊 3看诊中 4已完成 5已退号 6爽约', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, checked_in_at DATETIME DEFAULT NULL COMMENT '入队时间', finished_at DATETIME DEFAULT NULL COMMENT '看诊完成时间', PRIMARY KEY (id), UNIQUE KEY uk_visit_no (visit_no), KEY idx_patient (patient_id), KEY idx_doctor_queue (doctor_id, queue_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='就诊表'; -- 收费单表:财务和临床解耦的关键 CREATE TABLE charge_order ( id BIGINT UNSIGNED AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, visit_id BIGINT UNSIGNED NOT NULL, total_amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT '1待支付 2已支付 3部分退 4已退费', paid_at DATETIME DEFAULT NULL, operator_id BIGINT UNSIGNED NOT NULL COMMENT '收费员', PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_visit (visit_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='收费单表';这里三个关键参数值得多说两句。queue_no 不要直接取日期加自增的字符串做排序,否则跨天时会乱;id_card_no 建唯一索引,从数据库层面拦住重复建档,而不是靠应用程序判断;total_amount 用 DECIMAL(10,2) 而不是 FLOAT,金额计算不做浮点。查慢的时候先看医生维度索引有没有建,很多医院系统卡顿都出在就诊表按医生和时间检索时走了全表。
3.2 为什么不能给处方直接加“已收费”字段
很多第一次做医院系统的人喜欢在处方表上加一个 paid 字段,0 表示未收费,1 表示已收费,发药前检查一下。这个设计在单张处方只收一次费的时候没问题,但日常医院管理的真实场景很快会击穿它:一次就诊开了三种药,患者只想先交其中一种的钱;或者做了一半退了部分金额。paid 字段在这种场景下根本无法表达“部分退”,只能改成 2、3 个状态硬凑。
更合理的做法是引入收费单作为中间层。处方药品明细(order_item)与收费单(charge_order)通过 visit_id 关联,收费单记录完整金额和支付状态,order_item 不存钱。发药时以“收费单已支付且未退费”为准,药品明细只是数据来源。这样退费、退药、医保对价全都有清晰的操作对象。退回部分金额时,要么把原收费单状态改成“部分退”并新增一条负金额流水,要么红冲原单再开新单,两种方案都要求收费单有幂等号和操作日志。
3.3 状态字段写枚举还是写字典表
医院系统里到处是状态字段,选择直接写死后端枚举,还是做一张字典表动态加载,我一般直接选枚举写死。原因很简单:状态转移逻辑写在后端代码里,状态值必须在编译期就可预期。如果放进前端可配置的字典表,就会出现运营同学手滑把 2 和 3 含义改反,整个系统像黑匣子一样难以排查。
@Getter public enum VisitStatus { REGISTERED(1, "已挂号"), WAITING(2, "候诊"), IN_CONSULT(3, "看诊中"), FINISHED(4, "已完成"), CANCELLED(5, "已退号"), NO_SHOW(6, "爽约"); private final int code; private final String desc; VisitStatus(int code, String desc) { this.code = code; this.desc = desc; } }枚举类里可以加一个静态方法,用来校验状态迁移是否合法,比如 from(WAITING) 只能到 IN_CONSULT 或 CANCELLED。这样所有接口在入口处先校验一次,状态错乱的问题基本从源头消失。字典表只保留在科室名称、药品单位这类纯展示数据上,不参与任何业务判断逻辑。
4. 权限与操作留痕:医院系统不能赌人性
4.1 五张 RBAC 表,权限点别先求细
医院系统里的角色差异非常大:医生要能看患者病史,护士要能操作队列,药房只能看已收费处方,收费员不能改药品价格。一套覆盖日常管理的系统,至少要有五张 RBAC 基础表:用户表、角色表、权限表、用户角色关联表、角色权限关联表。我见过有人把“医生可以删患者”“收费员可以作废收费单”写死在业务代码里,最后管理成本极高,每来一个新角色就要改一遍代码。
CREATE TABLE sys_user ( id BIGINT UNSIGNED AUTO_INCREMENT, username VARCHAR(50) NOT NULL, password VARCHAR(100) NOT NULL COMMENT '存哈希,不存明文', dept_id BIGINT UNSIGNED NOT NULL COMMENT '所属科室', status TINYINT NOT NULL DEFAULT 1, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; CREATE TABLE sys_role ( id BIGINT UNSIGNED AUTO_INCREMENT, code VARCHAR(50) NOT NULL COMMENT '角色编码,如DOCTOR/NURSE/PHARMACIST/CASHIER', name VARCHAR(50) NOT NULL, PRIMARY KEY (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE sys_permission ( id BIGINT UNSIGNED AUTO_INCREMENT, code VARCHAR(100) NOT NULL COMMENT '权限点,如visit:cancel', name VARCHAR(100) NOT NULL, PRIMARY KEY (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE sys_user_role ( user_id BIGINT UNSIGNED NOT NULL, role_id BIGINT UNSIGNED NOT NULL, PRIMARY KEY (user_id, role_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE sys_role_permission ( role_id BIGINT UNSIGNED NOT NULL, permission_id BIGINT UNSIGNED NOT NULL, PRIMARY KEY (role_id, permission_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这套表的最大好处是权限点可以按动作粒度去配,真正要守住的是少数敏感操作,比如退费、作废、盘点、改价。普通写操作不建议为了权限点太细而牺牲开发效率,日常管理系统保持“角色粗、动作准”就够了。
4.2 数据权限的一条铁律:按科室隔离
很多团队把权限做成“能访问某个按钮”就完了,但医院系统真正麻烦的是数据可见范围。医生应该只能看到自己科室和自己名下患者的就诊记录,收费员只能操作自己收费窗口的单据,药房不能看到患者的诊断结论。这条铁律靠 SQL 查询里强制拼入 dept_id 或 doctor_id 来实现,而不是靠前端隐藏按钮。
<select id="selectVisits" resultType="Visit"> SELECT v.* FROM visit v WHERE v.dept_id = #{deptId} AND v.created_at >= #{startTime} AND v.created_at < #{endTime} ORDER BY v.queue_no </select>这里的 deptId 不要从页面参数里取,必须由后端从当前登录用户的 token 或会话中解析,防止有人直接拼参数越权。跨科室会诊怎么办?我一般会在 visit 上加一个 co_doctor_id 字段,并单独给被邀请的医生开一条只读授权记录,而不是放开整张表的查询权限。数据权限比按钮权限重要得多,先做好前者再谈后者。
4.3 操作留痕:把改前改后各存一份
日常医院管理系统里最容易被省略、出事时最被需要的表就是操作日志。做留痕不能只记“某用户修改了收费单”,要完整记录他改之前是什么、改之后变成什么。医生改处方、收费员退费编号、药房盘点调整库存,这三类操作的 before/after 必须全部落库。
CREATE TABLE operation_log ( id BIGINT UNSIGNED AUTO_INCREMENT, operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人', action VARCHAR(50) NOT NULL COMMENT '动作编码,如charge_order:refund', target_table VARCHAR(50) NOT NULL COMMENT '目标表名', target_id BIGINT UNSIGNED NOT NULL, before_data JSON COMMENT '操作前数据快照', after_data JSON COMMENT '操作后数据快照', ip VARCHAR(45) COMMENT '来源IP', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_target (target_table, target_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='操作日志表';落在代码里可以用 AOP 统一处理:在需要留痕的方法上打一个注解,切面方法执行前取一次参数快照,执行后再取一次落盘。核心是快照要用 JSON 全字段序列化,而不是只记录“状态从 1 改成 2”。出了问题才能快速定位是谁把状态改坏了。注意留痕写入不要阻塞主流程,可以异步落库,避免每次退费都多一次不必要的等待。
5. 医院系统的血泪坑:现象、原因、解法
5.1 退费把账做平:从拒绝退费到红冲设计
现象:护士帮患者退了一笔药费,但药房已经发了药,月底结账时收入、库存、处方三边对不上。原因很简单:退费操作没有检查关联状态,也没有走冲销流水。解决:退费必须是一条完整链路——先判断该收费单是否已发药,已发药的要先触发退药单、退回库存,再生成退费记录;财务侧不允许物理删除原收费单,而是把原单状态改成已退费,并生成一条负金额的冲销记录。这样任何时候对账,原单和冲销流水都在,差额能解释清楚。
// 退费链路伪代码:顺序不能反 if (chargeOrder.getStatus() != PAID) { throw new BizException("只有已支付收费单才能退费"); } if (dispenseService.hasDispensed(chargeOrder.getId())) { dispenseService.returnDrug(chargeOrder.getId()); // 先退药 } chargeOrder.setStatus(REFUNDED); refundService.createRefundFlow(chargeOrder); // 再生成负金额流水 operationLogService.log("charge_order:refund", before, after);参数说明:退药和退费必须放在同一事务里,否则退费成功退药失败会造成库存多出一盒、账上少一笔钱。真实项目里还要处理部分退费,这时原单状态更新为“部分退”,并新增负金额流水,不能直接改成退费。
5.2 两个窗口抢一个号:并发挂号的唯一约束
现象:每天上午 8 点到 9 点挂号高峰期,同一个医生的同一个号被两个窗口同时挂出,数据库里有两条同号 record,后来的人拿着两张不同号的票据进了诊室。原因就是先 select 判断号有没有被挂,再 insert,判断和插入之间被另一个请求插队了。解决是在号源表上加数据库唯一约束,同时把取号改成原子操作。
CREATE TABLE visit_queue ( queue_date DATE NOT NULL, doctor_id BIGINT UNSIGNED NOT NULL, queue_no INT NOT NULL, patient_id BIGINT UNSIGNED NOT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT '1占用 0释放', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_queue (queue_date, doctor_id, queue_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='号源表';插入号源时先做一次条件插入,插入成功才算占号成功,失败就重取下一个号。应用层再去循环查号是典型的把并发压力扛在了数据库慢查询上。号源表的数据量不大,走唯一索引判断耗时可忽略,这是把并发问题交给数据库兜底,比分布式锁更省心。
5.3 药房库存变负数:批次、效期、盘点三方对不上
现象:药房发着发着发现某个药品库存变成负数,查下来既不是入库录错,也不是发药录错,而是一次手动盘点把库存直接 update 覆盖了。原因就在盘点太粗暴,把账上数量和实际数量混为一谈。医院系统的库存必须分三个独立单据流:入库单、出库单、盘点单,任何库存数值变动都来自单据流水,不允许直接 UPDATE。
| 单据类型 | 数量方向 | 场景 |
|---|---|---|
| 入库单 | 增加 | 采购入库、退货入库 |
| 出库单 | 减少 | 发药、报损、退药回仓 |
| 盘点单 | 修正 | 实盘差异,生成差异记录 |
盘点时实际数量与账面数量不一致,就生成一张盘点差异单,注明盘盈还是盘亏和原因,再走审批调整账存。这样哪怕调整错了,也有原始记录可以追溯。每次发药前先 SELECT 库存并锁定批次记录,扣减后立刻检查是否小于零,小于零就回滚事务,并给门诊一个明确提示“药品库存不足,请医生改开其他药品”。
5.4 叫号排队跨天乱序:排班与号序的隐藏坑
现象:第二天早上医生开诊,排队队列里混进了前一天没看完的患者,还排在了最前面,导致当天新挂号患者等了近一个小时。原因有两点:一是队列号生成时包含了日期,排序逻辑直接按字符串排,跨天后旧号大于新号;二是停诊改约的患者被重新塞进队列时带了原号序。解决:queue_no 的生成必须按“当日当前医生下的自增”,排序只认 queue_no,不看门诊时间。停诊复诊的患者分配一个靠前的新号,但要通过独立状态标识出来,让护士能区分正常号和复诊号。每天开诊前对未完成就诊记录做一次处理,要么转为爽约,要么统一顺延到次日并重排号序,不能等着患者来了现场才修。
6. 用对账脚本验证这套系统值不值得投入
6.1 上线前必跑的四组冒烟用例
系统做完别急着铺开,先用四组场景把主链路打一遍:现场挂号 → 退号;医生开处方 → 收费 → 发药 → 退药 → 退费;跨科室会诊的数据权限隔离;库存不足时的拦截和提示。每组场景都要跑正常路径和异常路径,比如退费时药房已经发药、挂号时重复证件号。这几条过完,系统的核心数据流基本就稳了。
6.2 数据对账 SQL:专查黑匣子
我习惯每周跑一段 SQL 对账,专门怀疑那些“看起来一切正常”的数据。下面的查询找出“就诊已完成但没有对应已支付收费单”的异常记录,说明有人绕过了收费直接结束就诊,或者收费单被误删:
SELECT v.id AS visit_id, v.visit_no, v.status, COUNT(c.id) AS charge_count, SUM(CASE WHEN c.status IN (2, 3) THEN 1 ELSE 0 END) AS paid_count FROM visit v LEFT JOIN charge_order c ON c.visit_id = v.id WHERE v.status = 4 AND v.created_at BETWEEN CURRENT_DATE - INTERVAL 7 DAY AND CURRENT_DATE GROUP BY v.id, v.visit_no, v.status HAVING paid_count = 0;数值上无需纠结统计周期,重点是这类“已完成但没收费”“已发药但收费单被退费”的交叉查询值得放进巡检清单。业务可以不复杂,但财务和药品的异常一定要能查出来。
6.3 自研还是采购的判断线
如果要问我值不值得为日常医院管理自研一套系统,我的判断线是三条:门诊并发量、药房库存复杂度、报表口径要求。日门诊量不超过几百人、药品库存不做批次效期追踪、报表只需要日收入日和退费占比,这套系统完全可以自研并运行得不错,技术栈就是 Spring Boot + MySQL + Redis 就能扛住。一旦涉及医保接口、检验设备对接、复杂药品批次效期追溯,投入会指数级上升,这时候买成熟产品再定制反而更划算。
我后来养成一个习惯:任何医院管理系统的改动,上线前先跑对账脚本,上线后把操作日志留严一点,宁可在这些地方多花半天,也不去赌人性。技术层面没有什么玄学,绝大多数翻车都来自状态没校验、并发没兜底、日志没留够。希望帮到你。
本文还有配套的精品资源,点击获取