基于Spring Boot与uni-app的医院病房移动查房小程序开发实践
2026/9/14 13:53:47 网站建设 项目流程

查房这个场景,医院里几乎每天都要重复无数次。早上主任带着一队医生挨个病床问诊,护士手里夹着记录板跟在后面写体征,回到护士站再对着电脑把数据一条条敲进去。这个过程本身没问题,但效率还是有挺多提升空间的:病历本翻来翻去、记录纸容易丢、医嘱改完还要回工作站补录。所以当时我们接到的需求特别明确——把查房这件事搬到手机上,让医生和护士在病房里就能完成信息核对、体征录入、查房记录和医嘱调整,而且得是微信小程序形态,不用单独装App。

这套系统最终定下来的方案是:后端用Spring Boot搭RESTful API,前端用uni-app开发微信小程序端。在推倒重来两次之后,整套系统从病房模型到查房记录形成了一条完整的数据闭环。本文就把整个开发过程、关键设计和技术决策一次性讲清楚,尤其是那些不走到现场根本发现不了的坑。

1. 病房查房能不能用小程序做:场景梳理与系统定位

1.1 移动查房背后的四个真实使用场景

在动手写代码之前,先把业务场景拆清楚。这决定了后面所有表结构和接口的设计方向,漏一个后续就要返工。

第一个场景是晨间大查房。主任医师带着主治、住院医、进修生加护士长,通常十几个人,从第一间病房开始挨床过。每个患者的情况在主任脑子里,但细节数据需要有人提供——入院时间、最近一次化验结果、当前用药、昨夜体征变化。传统做法是护士提前打印一堆病历摘要,或者医生抱着一台厚重的笔记本工作站。小程序要解决的是:在任何病床前,扫码或者点开床位列表,患者当前全部关键信息一眼看完。

第二个场景是体征数据采集。护士每天早晚两轮常规测量体温、脉搏、血压、呼吸、血氧,遇到术后患者还要记录引流液量、伤口情况。这些数据需要在床边就录入系统,而且要支持异常值提醒。比如体温超过38度,录进去的时候就应该有个视觉提示,让护士当场确认是不是真的烧了。

第三个场景是查房记录。医生对每个患者的判断和处理意见,传统上写纸质病历,回到办公室再录入HIS。这个时间差往往是一两个小时,遇到急诊抢救就可能忘掉。移动端要支持文本记录、模板快速填充、语音转文字这三个输入途径,并且记录保存后科室主任可以实时查看。

第四个场景是医嘱执行确认。医生查房后开出的新医嘱,护士需要逐条执行。在移动端上,医嘱列表要能按患者、按时间、按状态(待执行/已执行/已取消)过滤,执行后用签名确认,这个签名要有法律效力,所以必须支持手写签名板功能。

1.2 系统角色权限划分

这套系统的角色比一般管理系统要简单,但权限边界必须严格。医生和护士能看的数据、能操作的功能差异很大。

角色核心权限特殊说明
医生查看患者病历、体征趋势、检验结果;书写查房记录;调整医嘱主治及以上可查看全科室患者,住院医仅限自己名下患者
护士录入与修改体征数据;执行医嘱;记录护理等级变化不可修改医生查房记录与医嘱内容
科室主任查看全科室查房统计、患者流转情况、危重患者预警不参与日常录入,侧重数据查看
系统管理员用户管理、科室配置、系统参数、操作日志仅限Web端管理后台操作

有了这个角色矩阵,后端设计权限模型的时候才不会拍脑袋。我们用的方案是Spring Security + JWT,在拦截器里做角色校验,接口上打@PreAuthorize注解控制操作权限,下面会详细讲到。

2. 后端骨架搭建:用 Spring Boot 把“病区、床位、患者、查房记录”四张核心表立起来

2.1 Spring Boot 项目初始化与依赖选型

后端服务是整个系统的心脏,所有数据流转、权限控制、业务计算都在这里完成。我选的是Spring Boot 2.7.x版本,配合JDK 8。为什么要卡在这个版本组合?两个原因:一是医院信息化系统的部署环境普遍保守,很多医院服务器还是CentOS 7配JDK 8,你非要上JDK 17,运维那边直接摇头。二是Spring Boot 2.7在Spring Security和MyBatis-Plus的兼容性上非常稳,网上踩坑资料也最多,真出了问题好查。

依赖方面,核心是这几个:

  • Spring Web:提供RESTful接口支撑
  • MyBatis-Plus:单表操作用它的通用Mapper,复杂查询写XML,效率和灵活性兼顾
  • MySQL Connector:数据库驱动,配合MySQL 5.7
  • Spring Security + JWT:认证与授权
  • Lombok:减少实体类的getter/setter模板代码
  • Hutool:工具类库,处理日期、加密、随机数之类的日常操作非常方便
  • MinIO SDK:如果要做文件上传(签名图片、患者照片),MinIO是私有化部署的首选

2.2 数据库表设计思路:把病房的现实逻辑映射成关系模型

这是整个项目里最需要反复推敲的部分。病房的现实世界是这样的:一个医院有多个科室,每个科室有多个病区,每个病区有若干病房,每个病房有若干床位,患者办理住院后分配到一个床位。查房查的是患者,但患者的位置信息要跟床位、病房、病区关联起来。

我用五张核心表来承载这个模型:

-- 科室表 CREATE TABLE `department` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `dept_name` VARCHAR(100) NOT NULL COMMENT '科室名称,如呼吸内科', `dept_code` VARCHAR(50) NOT NULL COMMENT '科室编码', `sort_order` INT DEFAULT 0 COMMENT '排序号', `status` TINYINT DEFAULT 1 COMMENT '状态:1启用 0停用', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_dept_code` (`dept_code`) ) COMMENT '科室表'; -- 病区表 CREATE TABLE `ward` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `dept_id` BIGINT NOT NULL COMMENT '所属科室ID', `ward_name` VARCHAR(100) NOT NULL COMMENT '病区名称,如内科一病区', `floor` VARCHAR(20) DEFAULT NULL COMMENT '所在楼层', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) COMMENT '病区表';

床位表要单独设计,里面有一个关键字段叫bed_status,标记这张床当前的状态:空床、已占用、消毒中、预留。查房的时候医生看到的床位列表是按状态排列的,已占用的床优先展示患者信息,空床直接置灰。这样医生扫一眼就知道哪张床还有患者等到。

患者表和查房记录表是业务核心,字段设计直接影响后面功能开发:

-- 患者住院信息表(一个患者一次住院一条记录) CREATE TABLE `patient` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `patient_no` VARCHAR(32) NOT NULL COMMENT '住院号', `name` VARCHAR(50) NOT NULL, `gender` TINYINT DEFAULT 1 COMMENT '1男 2女', `age` INT DEFAULT NULL, `bed_id` BIGINT NOT NULL COMMENT '当前床位ID', `ward_id` BIGINT NOT NULL COMMENT '当前病区ID', `dept_id` BIGINT NOT NULL COMMENT '当前科室ID', `doctor_user_id` BIGINT DEFAULT NULL COMMENT '主治医生用户ID', `diagnosis` VARCHAR(500) DEFAULT NULL COMMENT '初步诊断', `admission_date` DATETIME NOT NULL COMMENT '入院时间', `discharge_date` DATETIME DEFAULT NULL COMMENT '出院时间', `status` TINYINT DEFAULT 1 COMMENT '1在院 2出院 3转科', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_bed_id` (`bed_id`), KEY `idx_patient_no` (`patient_no`) ) COMMENT '患者住院信息表'; -- 查房记录表 CREATE TABLE `round_record` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `patient_id` BIGINT NOT NULL COMMENT '患者ID', `round_type` TINYINT DEFAULT 1 COMMENT '1晨间查房 2晚间查房 3临时查房', `doctor_id` BIGINT NOT NULL COMMENT '查房医生用户ID', `subjective` TEXT COMMENT '主诉', `physical_exam` TEXT COMMENT '体格检查', `diagnosis_opinion` TEXT COMMENT '诊断意见', `treatment_plan` TEXT COMMENT '处理方案', `prescription` TEXT COMMENT '用药调整', `signature_url` VARCHAR(255) DEFAULT NULL COMMENT '电子签名图片地址', `round_time` DATETIME NOT NULL COMMENT '查房时间', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_patient_id` (`patient_id`), KEY `idx_doctor_id` (`doctor_id`) ) COMMENT '查房记录表';

五张核心表的关系我用一句话总结就是:科室管病区,病区管床位,床位管患者,查房记录挂在患者名下。查询链路是从科室到病区到床位再到患者,反过来从患者也能一路找到科室。这样设计的好处是,管理员调整床位的归属时不会影响已存在的查房记录——查房记录只关联patient_id,不直接关联床位。

2.3 接口层设计:一次性理清 RESTful API 资源路径

后端接口的数量不需要很多,但路径和组织一定要清晰。我的习惯是资源路径全部用名词复数形式,操作方式用HTTP动词表达。

功能模块方法路径说明
登录认证POST/api/auth/login用户名密码登录,返回JWT
退出登录POST/api/auth/logout注销Token
患者查询GET/api/patients分页查询,支持住院号/姓名/诊断模糊搜索
患者详情GET/api/patients/{id}返回患者基本信息+当前床位+查房记录摘要
床位移床PUT/api/patients/{id}/transfer患者转床,需要校验目标床状态
体征录入POST/api/vital-signs单条体征录入
体征趋势GET/api/patients/{id}/vital-signs按时间范围查询患者体征趋势
查房记录创建POST/api/round-records新增一条查房记录
查房记录查询GET/api/patients/{id}/round-records按患者查询历史查房记录
医嘱列表GET/api/patients/{id}/orders查询患者当前有效医嘱
医嘱新增POST/api/patients/{id}/orders查房时开新医嘱
文件上传POST/api/upload上传签名图片或患者照片,返回URL

关于接口还有一个细节值得单独说说:响应体结构必须统一。我们用的是R<T>通用返回结构:

@Data public class R<T> { private Integer code; private String message; private T data; public static <T> R<T> ok(T data) { R<T> r = new R<>(); r.setCode(200); r.setMessage("success"); r.setData(data); return r; } public static <T> R<T> fail(Integer code, String message) { R<T> r = new R<>(); r.setCode(code); r.setMessage(message); return r; } }

可能有人觉得这不是多此一举吗?实际开发里,前端要统一处理loading状态、错误提示、Token过期跳转,如果每个接口返回格式都不一样,前端封装层就要写一堆if-else。统一格式后,前端响应拦截器只需要判断code == 200这一个条件就够了,后面uni-app的请求封装会用到这个设计。

3. 小程序端动手:uni-app 项目初始化与查房主流程跑通

3.1 为什么是 uni-app 而不是原生微信小程序

选uniapp做小程序端,团队里其实有争议。原生小程序性能好、生态成熟,为什么非要用跨端框架?我的理由有两条。

第一条很实际:这套系统未来大概率要出App端。医院信息科后来追加需求,说医生不希望只在小程序里用,还想在手机桌面放一个图标直接打开,那样体验更接近原生应用。用uni-app只需要在HBuilderX里配好证书,一套代码同时打包成微信小程序、安卓App、iOS App,省掉一整个原生开发团队的工作量。

第二条是开发效率。uni-app用Vue语法写页面,组件化和生命周期管理比原生小程序的setData要顺手得多。尤其是复杂的表单页面,原生小程序写起来要手动管理很多数据绑定,uni-app里用v-model直接搞定。

3.2 项目结构与页面规划

创建uni-app项目,我推荐用Vue 3版本的模板。项目初始化命令:

npx degit dcloudio/uni-preset-vue#vite-ts my-patient-rounds cd my-patient-rounds npm install

页面规划上,小程序端聚焦四个核心页面,其他功能页面从这四个页面跳转进入:

页面路径页面功能关键交互
pages/index/index首页-科室病区总览顶部搜索框,中部病区卡片列表,点击进入床位列表
pages/ward/beds床位列表页按病床号排列,显示床状态和患者姓名,扫码定位床位
pages/patient/detail患者详情页基本信息+体征趋势图+近期查房记录,底部操作按钮
pages/round/create新建查房记录页主诉/体格检查/诊断意见/处理方案四个富文本编辑区域

患者详情页是整个小程序的枢纽。医生在病房里打开这个页面,往下滑动看完患者所有信息,然后点击底部“新建查房记录”按钮进入填表页面,填写完签名保存。护士的操作路径不同,她们更多从床位列表直接点“体征录入”,进入一个快速录入弹窗,录完体温血压就离开,不需要进入完整详情页。所以患者详情页底部按钮要区分角色动态渲染——医生看到查房记录按钮,护士看到体征录入按钮。

3.3 请求封装:把 uni.request 包装成可用的 API 工具

uni-app原生的uni.request功能太裸了,直接把URL、数据、header全部暴露在业务代码里非常痛苦。我封装了一个统一的请求模块,关键点包括基础地址配置、Token自动注入、响应拦截、错误提示。

// utils/request.js import { getToken, clearAuth } from './auth' const BASE_URL = 'https://api.hospital.example.com/api' export function request(options) { return new Promise((resolve, reject) => { uni.request({ url: BASE_URL + options.url, method: options.method || 'GET', data: options.data || {}, header: { 'Content-Type': 'application/json', 'Authorization': getToken() ? `Bearer ${getToken()}` : '' }, success: (res) => { const { code, message, data } = res.data if (code === 200) { resolve(data) } else if (code === 401) { // Token 过期,清除本地登录态并跳转到登录页 clearAuth() uni.reLaunch({ url: '/pages/login/login' }) uni.showToast({ title: '登录已过期,请重新登录', icon: 'none' }) reject(new Error(message)) } else { uni.showToast({ title: message || '请求失败', icon: 'none' }) reject(new Error(message)) } }, fail: (err) => { // 网络异常情况,统一提示 uni.showToast({ title: '网络连接失败,请检查网络', icon: 'none' }) reject(err) } }) }) } // 具体业务API export const patientApi = { getPatients(params) { return request({ url: '/patients', method: 'GET', data: params }) }, getPatientDetail(id) { return request({ url: `/patients/${id}`, method: 'GET' }) }, createRoundRecord(data) { return request({ url: '/round-records', method: 'POST', data }) } }

这套封装的妙处在于,业务代码里永远不需要关心Token是怎么带的,也不用关心接口错误了怎么提示。所有页面只需要引用对应的API模块,然后处理成功回调就行:

// 示例:创建查房记录页面 import { patientApi } from '@/utils/request' const submitRoundRecord = async () => { if (!formData.subjective) { uni.showToast({ title: '请填写主诉内容', icon: 'none' }) return } uni.showLoading({ title: '提交中...' }) try { await patientApi.createRoundRecord({ patientId: currentPatientId, roundType: roundType.value, subjective: formData.subjective, physicalExam: formData.physicalExam, diagnosisOpinion: formData.diagnosisOpinion, treatmentPlan: formData.treatmentPlan }) uni.hideLoading() uni.showToast({ title: '保存成功', icon: 'success' }) setTimeout(() => uni.navigateBack(), 800) } catch (e) { uni.hideLoading() } }

3.4 查房主流程前端页面代码

新建查房记录页面,核心是表单数据组织。这里有一个从实际使用中总结的经验:不要把表单拆成太多输入框。医生查房时操作时间通常不超过一分钟,太复杂的表单会让他们产生抵触情绪。所以我用了一个折叠式表单结构——默认只展开主诉和诊断意见两个必填项,体格检查和处理方案折叠起来,点“展开更多”才显示完整字段。

模板结构代码如下:

<template> <view class="round-create-page"> <view class="patient-card"> <text class="patient-name">{{ patient.name }}</text> <text class="patient-meta">住院号:{{ patient.patientNo }}</text> <text class="patient-meta">床号:{{ patient.bedName }}</text> </view> <view class="form-section"> <view class="form-item"> <text class="form-label">查房类型</text> <radio-group @change="onRoundTypeChange"> <label v-for="item in roundTypeOptions" :key="item.value" class="radio-label"> <radio :value="item.value" :checked="roundType === item.value" /> <text>{{ item.label }}</text> </label> </radio-group> </view> <view class="form-item"> <text class="form-label">主诉 <text class="required">*</text></text> <textarea v-model="formData.subjective" placeholder="患者当前主要症状与不适" /> </view> <view class="form-item"> <text class="form-label">诊断意见 <text class="required">*</text></text> <textarea v-model="formData.diagnosisOpinion" placeholder="本次查房的诊断判断" /> </view> <view class="form-item collapse-item" v-if="showMore"> <text class="form-label">体格检查</text> <textarea v-model="formData.physicalExam" placeholder="体征检查结果" /> </view> <view class="form-item collapse-item" v-if="showMore"> <text class="form-label">处理方案</text> <textarea v-model="formData.treatmentPlan" placeholder="本次查房的处理意见" /> </view> <view class="form-item collapse-toggle" @click="showMore = !showMore"> <text>{{ showMore ? '收起更多' : '展开更多' }}</text> </view> </view> <!-- 手写签名区域 --> <view class="signature-section"> <text class="form-label">医生签名</text> <canvas canvas-id="signCanvas" class="signature-canvas" @touchstart="onTouchStart" @touchmove="onTouchMove" @touchend="onTouchEnd" /> <view class="signature-actions"> <button size="mini" @click="clearSign">清除重签</button> <button size="mini" type="primary" @click="saveSign">保存签名</button> </view> </view> <button class="submit-btn" type="primary" @click="submitRoundRecord">提交查房记录</button> </view> </template>

签名功能是查房记录里最容易被忽略但实际最重要的部分。查房记录是医疗文书的组成部分,按照规范必须有医生亲笔签名,电子签名也要有法律效力。用canvas实现手写签名其实很简单,关键是采集触摸路径然后转成图片保存。核心逻辑就是三个事件:touchstart记录起点,touchmove画线,touchend把画布内容导出为图片文件上传到服务器。

// 签名板核心逻辑 function onTouchStart(e) { const { x, y } = e.touches[0] ctx.beginPath() ctx.moveTo(x, y) isDrawing = true } function onTouchMove(e) { if (!isDrawing) return const { x, y } = e.touches[0] ctx.lineTo(x, y) ctx.stroke() } function saveSign() { uni.canvasToTempFilePath({ canvasId: 'signCanvas', success(res) { const tempFilePath = res.tempFilePath // 上传到后端 uploadFile(tempFilePath).then(url => { signatureUrl = url uni.showToast({ title: '签名已保存', icon: 'success' }) }) } }) }

4. 权限与数据安全:Token登录、角色校验在移动查房里怎么做

4.1 JWT认证流程设计

移动端小程序的认证流程和Web端有个显著区别——小程序没有浏览器Session机制,每次请求都必须携带身份凭证。我们采用的方案是JWT(JSON Web Token),服务端签发Token,客户端存储在本地并随请求携带。

整个认证流程是这样的:

  1. 医生和护士使用工号+密码登录,后端验证通过后签发JWT
  2. JWT包含用户ID、角色、科室ID、过期时间这些关键信息
  3. 前端把Token存储在uni.setStorageSync中
  4. 每个请求在header的Authorization字段携带Bearer {token}
  5. 后端过滤器解析Token,如果无效或过期则返回401

4.2 Spring Security + JWT 核心配置代码

用Spring Security做JWT认证,很多新手一上来就被绕晕了,实际核心就三个东西:一个过滤器、一个Provider、一套配置类

先写JWT工具类,负责生成Token和解析Token:

@Component public class JwtUtils { @Value("${jwt.secret}") private String secret; @Value("${jwt.expiration}") private Long expiration; // 单位:秒 // 生成Token public String generateToken(Long userId, String username, String role) { Date now = new Date(); Date expiryDate = new Date(now.getTime() + expiration * 1000); return Jwts.builder() .setSubject(username) .claim("userId", userId) .claim("role", role) .setIssuedAt(now) .setExpiration(expiryDate) .signWith(SignatureAlgorithm.HS256, secret) .compact(); } // 解析Token获取用户信息 public Claims parseToken(String token) { return Jwts.parser() .setSigningKey(secret) .parseClaimsJws(token) .getBody(); } // 校验Token是否过期 public boolean isTokenExpired(String token) { try { Claims claims = parseToken(token); return claims.getExpiration().before(new Date()); } catch (Exception e) { return true; } } }

再写JWT过滤器,继承OncePerRequestFilter,在每个请求进来时先尝试解析Token:

@Component public class JwtAuthenticationFilter extends OncePerRequestFilter { @Autowired private JwtUtils jwtUtils; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String header = request.getHeader("Authorization"); if (header != null && header.startsWith("Bearer ")) { String token = header.substring(7); try { Claims claims = jwtUtils.parseToken(token); // 从Token中解析出用户身份,放到SecurityContext中 LoginUser loginUser = new LoginUser(); loginUser.setUserId(claims.get("userId", Long.class)); loginUser.setUsername(claims.getSubject()); loginUser.setRole(claims.get("role", String.class)); UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(loginUser, null, Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + loginUser.getRole()))); SecurityContextHolder.getContext().setAuthentication(authentication); } catch (Exception e) { // Token无效时不设置认证信息 SecurityContextHolder.clearContext(); } } filterChain.doFilter(request, response); } }

权限控制层面,我采用两级方案。第一级是接口URL级别的粗粒度控制,比如所有/api/admin/**的请求只有管理员能访问;第二级是方法级别的细粒度控制,用@PreAuthorize注解挂在具体方法上,比查房记录创建接口要校验当前用户是医生角色:

@RestController @RequestMapping("/api/round-records") public class RoundRecordController { @Autowired private RoundRecordService roundRecordService; @PostMapping @PreAuthorize("hasRole('DOCTOR') or hasRole('CHIEF_DOCTOR')") public R<Long> createRoundRecord(@RequestBody RoundRecordCreateDTO dto) { LoginUser currentUser = SecurityUtils.getCurrentUser(); Long recordId = roundRecordService.createRecord(dto, currentUser); return R.ok(recordId); } @GetMapping("/patient/{patientId}") @PreAuthorize("hasAnyRole('DOCTOR', 'NURSE', 'CHIEF_DOCTOR')") public R<List<RoundRecordVO>> getPatientRoundRecords(@PathVariable Long patientId) { return R.ok(roundRecordService.getPatientRecords(patientId)); } }

这里有个很实际的坑:小程序返回301/302重定向时会丢header。微信小程序的uni.request默认不跟随重定向,如果你的后端接口加了/api前缀但部署的时候又配置了servlet的context-path,前端请求就变成/api/api/xxx,或者导致401时被重定向到登录页,Token就丢了。后来统一在前端BASE_URL里写死完整路径,后端不再配置context-path,这个问题才算根治。

4.3 角色与数据范围:只能看到自己病区的患者

权限控制不只是“能不能看”,还有“能看到谁的数据”。医院的数据隐私要求极高,内科医生绝对不能看到外科的患者信息,护士也只能操作自己病区内的床位数据。

实现方案是后端在查询患者列表时,从当前登录用户的Token里取出deptId和wardId,在SQL查询里强制加上这个过滤条件:

// 患者列表查询 public PageResult<PatientVO> queryPatientPage(PatientQueryDTO query, LoginUser user) { LambdaQueryWrapper<Patient> wrapper = new LambdaQueryWrapper<>(); // 关键:根据角色判断数据范围 if ("DOCTOR".equals(user.getRole())) { wrapper.eq(Patient::getDeptId, user.getDeptId()); } else if ("NURSE".equals(user.getRole())) { wrapper.eq(Patient::getWardId, user.getWardId()); } // 关键词搜索 if (StringUtils.hasText(query.getKeyword())) { wrapper.and(w -> w.like(Patient::getName, query.getKeyword()) .or().like(Patient::getPatientNo, query.getKeyword()) .or().like(Patient::getDiagnosis, query.getKeyword())); } // 状态过滤 wrapper.eq(Patient::getStatus, 1); // 只在院患者 return patientMapper.selectPage(new Page<>(query.getPageNum(), query.getPageSize()), wrapper); }

这个逻辑放在后端好处很明显——前端小程序不管传什么参数,后端都会强制拼接科室、病区的过滤条件,不存在绕过前端越权访问的可能性。这也是做医疗类系统的一个底线思维:前端只是展示层,数据边界必须由后端兜住

5. 病房现场最怕断网:离线草稿与数据补录方案

5.1 病房网络环境的真实困境

开发之前,我一度以为医院网络建设已经很完善了,直到去实地考察才发现完全不是这么回事。病房楼为了防辐射,墙体非常厚,很多房间的4G/5G信号直接被屏蔽;医院的WiFi覆盖也不全面,护士站信号满格,但走到走廊尽头或者进了病房就掉到一格。医生查房时需要不停地在各个病房之间移动,网络时断时续是家常便饭。

如果小程序在断网的时候直接提示“网络连接失败”,让医生干等着,这个系统肯定会被医生们弃用。所以,离线能力是这套系统上线的前提条件,不是可选项。

5.2 离线草稿箱的设计与实践

我的方案是做一个“本地草稿箱”机制,核心思路是:查房记录先存在本地,等网络恢复后自动上传。

具体实现逻辑分三步:

第一步,表单数据本地化采集。在查房记录页面提交时,先不直接调接口,而且判断网络状态。如果网络正常则直接提交;如果网络异常,把表单数据(包含签名图片的本地临时路径)存入uni.setStorageSync的一个草稿数组里。

// 提交查房记录 async function submitRoundRecord() { // 先校验必填项 if (!validateForm()) return const draftData = { id: Date.now().toString(), patientId: currentPatientId, roundType: roundType.value, subjective: formData.subjective, physicalExam: formData.physicalExam, diagnosisOpinion: formData.diagnosisOpinion, treatmentPlan: formData.treatmentPlan, signatureLocalPath: signatureLocalPath, createTime: new Date().toISOString(), status: 'pending' // 待上传 } // 尝试直接提交 try { await patientApi.createRoundRecord(draftData) uni.showToast({ title: '提交成功', icon: 'success' }) } catch (e) { // 提交失败或网络异常,存入草稿箱 saveDraft(draftData) uni.showToast({ title: '网络异常,已保存到草稿箱', icon: 'none' }) } }

第二步,草稿箱列表页面。在首页增加一个草稿箱入口,显示待上传记录的数量,点进去可以看到所有离线保存的记录,支持手动重试和删除。

第三步,自动补传机制。在首页的onShow生命周期里检查网络状态,如果网络正常且有pending状态的草稿,则批量上传。这里用uni.getNetworkType判断网络类型:

// 首页 onShow 时检查网络并自动补传 onShow() { this.checkNetworkAndSync() } checkNetworkAndSync() { uni.getNetworkType({ success: (res) => { if (res.networkType !== 'none') { this.syncDrafts() } } }) } async syncDrafts() { const drafts = uni.getStorageSync('round_drafts') || [] if (drafts.length === 0) return const pendingDrafts = drafts.filter(d => d.status === 'pending') for (const draft of pendingDrafts) { try { // 先上传签名图片(如果有) if (draft.signatureLocalPath) { const url = await uploadFile(draft.signatureLocalPath) draft.signatureUrl = url } // 再提交查房记录 await patientApi.createRoundRecord(draft) // 标记为已同步 draft.status = 'synced' } catch (e) { // 单条失败不阻塞其他草稿 console.error('同步草稿失败', draft.id, e) } } // 清理已同步的草稿 const remainingDrafts = drafts.filter(d => d.status !== 'synced') uni.setStorageSync('round_drafts', remainingDrafts) this.draftCount = remainingDrafts.length }

这个方案的容错策略用了“partial success”思路——批量上传时,单条失败不影响其他记录的上传,失败的下次触发时重新尝试。这样即便某条记录因为特殊原因一直失败(比如签名图片损坏),也不会把所有草稿都卡住,剩下一两条失败的,护士在护士站手动点重试或者删除重录就行。

5.3 签名图片的离线处理

签名图片在离线场景下有个特殊问题——canvas导出的是本地临时文件路径,直接提交到后端是无效的。所以草稿同步时必须先把本地临时文件通过文件上传接口传到服务器,拿到URL之后再提交查房记录数据。

用uni.uploadFile封装一个通用的图片上传方法:

function uploadFile(filePath) { return new Promise((resolve, reject) => { uni.uploadFile({ url: BASE_URL + '/upload', filePath: filePath, name: 'file', header: { 'Authorization': 'Bearer ' + getToken() }, success: (res) => { try { const result = JSON.parse(res.data) if (result.code === 200) { resolve(result.data) // 返回文件的URL } else { reject(new Error(result.message)) } } catch (e) { reject(e) } }, fail: (err) => reject(err) }) }) }

这里要注意的是uni.uploadFile成功后res.data是字符串,需要手动JSON.parse。很多人在这一步吃过亏——在success里直接使用res.data.url取不到值,因为还没解析成对象。

6. 上线前必查的细节:小程序备案、图片上传、签名板与打印联动

6.1 小程序审核与备案的准备工作

现在的微信小程序上线流程,比两年前严格了许多。首先要在微信公众平台完成小程序的注册和认证,这个过程需要企业或医疗机构的主体资质。如果你是给医院做外包开发,这一步最好由医院的信息科去申请,你要做的是把开发版配置好,通过微信开发者工具上传代码,然后提交审核。

小程序备案这件事也要提前规划。备案会需要一个专门的备案码和主体信息,通常要1-2周才能下来,一定要提前申请,不要等到上线前一天才做。而且备案信息要填写服务内容,医疗类目还需要额外上传《医疗机构执业许可证》或相关资质证明。如果小程序涉及在线问诊、处方药销售,那审批会更严格,甚至需要互联网医院牌照。我们的系统只做内部查房记录,不涉及患者自助服务,边界比较清晰,所以审核相对顺利。

还有一个小细节:小程序的隐私协议必须明确列出收集了哪些信息、用于什么目的。因为我们有拍照功能(上传病历照片),必须在隐私协议里说明。如果审核时发现隐私协议没有覆盖实际功能,会被驳回。

6.2 图片上传:解决不了的大文件限制和压缩策略

微信小程序的图片上传有一个硬限制——临时文件大小不能超过10MB,平时拍的照片随便一张就3-5MB,加上现在手机像素高,一张可能超过10MB。所以上传前必须先做压缩处理。

uni-app里有uni.compressImageAPI,可以在上传前把图片压缩到合理大小:

uni.compressImage({ src: tempFilePath, quality: 80, success: (res) => { // 压缩后的图片路径 uploadFile(res.tempFilePath) } })

但我们测试发现,一张4000x3000像素的照片,即使压缩质量到80%,体积仍然可能超过1MB。后续在后端做一层限制,超过2MB的图片直接返回“图片过大”错误。同时,后端上传接口用的MinIO存储桶也要配置好跨域,否则小程序端本地开发调试时,图片上传会报跨域错误。

6.3 打印联动:查房记录要能进病历系统

小程序上做的查房记录,最终要能打印出来归档到纸质病历里。这是医院信息科最初没提出、但实际上线后必须加的功能。因为电子病历系统还没有完全普及,很多医院仍然是电子记录+纸质打印双轨制。

做好这个功能,其实不需要在小程序端做打印,只需要在后端生成一份打印友好的HTML或PDF。具体方案是:后端根据查房记录ID查询出完整数据,然后用模板引擎(我们用的Thymeleaf)渲染一份格式化的查房记录单,再通过浏览器打印或者用第三方库转成PDF。

这里有几个要注意的格式问题:打印版查房记录单,必须有打印时间、打印人签名、患者住院号条码。尤其是住院号条码,如果漏了,病房护士要把记录单拿去找患者重新核对,非常浪费时间。建议在后端生成打印HTML时,用Zxing库生成一个住院号的条形码图片,嵌入到打印模板中。

6.4 性能优化:体量不大但也要注意首屏加载

这套小程序的整体体量不大,但医院网络环境差,首屏加载仍然需要优化。两个关键措施:

第一,分包加载。uni-app天然支持分包,把查房记录创建、患者详情这些低频次页面放到分包里,首页和床位列表放主包。小程序主包限制2MB,分包总共不超过8MB,把页面拆开后整个项目体积控制在2MB以内,加载速度明显提升。

第二,列表页懒加载。患者列表页用分页加载,每次请求20条,滚动到底部自动加载下一页。不要一次性查全科室几百个患者,小程序渲染几百个DOM节点时页面会明显卡顿。

第二个优化还有个细节:用uni-app的onReachBottom监听滚动到底部事件,需要用page配置开启enablePullDownRefresh或者在onReachBottom中做判断。不同平台这个事件的行为略有差异,实测在微信小程序上,页面内容不足一屏时onReachBottom会直接触底加载,需要在代码里额外判断是否还有更多数据,防止重复请求。

写在最后

这套医院病房住院移动查房系统从立项到跑通核心流程,大概用了两个月。给我的整体感受是:技术本身没有特别玄乎的东西,Spring Boot + uni-app都是成熟方案,真正的难度在于把业务流程理解透,然后把医院环境中那些不太美好的现实情况(网络差、签名合规、备案审核、双轨打印)考虑进设计里。

如果你也打算做类似的医疗信息化系统,我建议你开发前先去医院病房走一圈,站在医生和护士的角度想想他们每天遇到的具体问题。你会发现,很多你以为的功能需求,现场调研之后会被推翻重来。但正是这些现场的细节,决定了一套系统是真正被人天天用,还是上线之后成了摆设。

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

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

立即咨询