十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

医疗小程序跨端开发:Vue3+TypeScript+Uniapp全流程实践

医疗小程序跨端开发:Vue3+TypeScript+Uniapp全流程实践 简介一份以医疗问诊为业务背景的 Vue3 TypeScript Uniapp 跨端小程序完整案例主要面向具备基础前端知识、希望系统掌握小程序工程化开发的读者。案例围绕真实挂号问诊流程设计了预约挂号、科室选择、医生排班、视频问诊、个人中心等功能页面并通过 Uniapp 页面路由和组件化结构组织代码目录层次清晰便于按模块学习。资源共包括 30 个文件核心代码以 Vue 单文件组件、TypeScript 逻辑脚本和 JSON 配置文件为主辅以 SCSS 样式与 PNG 图片素材压缩包整体大小仅 1.48MB内容紧凑适合快速通读。工程内封装了统一的 request 请求模块、公共类型声明、环境变量配置和基础工具函数同时包含应用入口、根组件、全局样式以及 Vite 构建配置可以帮助读者从页面 UI 到底层数据请求完整理解 Vue3 组合式 API、TypeScript 类型约束与 Uniapp 多端适配。目前已有 2257 人学习下载非常适合希望在真实项目中快速沉淀小程序开发经验、提升前端工程化能力的初中级开发者。 这两年我一直在用 Vue3 TypeScript Uniapp 给医疗机构做微信小程序从最开始的诊所预约挂号到后来的体检报告查询、在线问诊前后落地了三个完整项目。说实话这套技术栈一开始上手并不算顺Uniapp 官方文档对 Vue3 的支持一度很零散TypeScript 的类型约束又经常和小程序原生 API 打架。但把所有坑趟平之后回头看这套组合确实是目前做跨端小程序效率最高、代码可维护性最好的方案之一。这篇文章就按照我实际开发医疗小程序的主线来写从为什么选这套技术栈开始到工程初始化、请求层封装、登录会话、预约挂号核心模块再到打包上架遇到的各种坑。里面所有代码都是我真实项目里抽出来的精简版去掉业务敏感信息但结构完整能直接参考改造。准备接手医疗类小程序、或者想用 Uniapp Vue3 从零搭一个正经项目的朋友可以跟着走一遍。1. 项目为什么这么搭医疗业务的约束与选型逻辑1.1 医疗小程序对技术栈的硬性要求医疗类和普通电商小程序差距很大。第一个感知就是页面全部围绕实名信息和安全操作展开登录要真实身份预约要授权手机号支付要符合监管要求用户协议和隐私政策必须提前弹窗确认。这些业务约束直接决定了工程里必须有一套严格的请求拦截、Token 管理、权限校验机制否则后面做合规检查必炸。第二个痛点是多端发布。诊所和医院往往不只要微信小程序有些机构还要支付宝小程序、抖音小程序甚至后期要出 App。如果每个端单独写一套光登录和支付就能写崩人。Uniapp 的价值就在这里一套 Vue3 语法编译到多端H5、微信、支付宝、App 都能跑虽然个别端有兼容细节要处理但维护成本比原生多端低一个数量级。第三个约束是医疗数据敏感代码里到处是患者姓名、身份证号、诊断记录类型安全特别重要。TypeScript 在这里不是锦上添花而是刚需。比如预约单这个对象后端返回status字段如果你定义成字符串枚举前端写pending和confirmed就会被编译器拦住写错了直接编译报错而不是线上跑起来才发现。1.2 为什么是 Vue3 而不是 Vue2 或者 ReactVue2 在 Uniapp 里很成熟但我选 Vue3 不是因为追新。核心原因是组合式 API 对复杂业务逻辑的抽离能力。医疗小程序有大量跨页面复用的逻辑比如获取当前位置并计算距离最近的医院、解析排班周期并生成可预约时间格这些逻辑如果用 options API 写要么塞进methods变成一个大杂烩要么挂到mixins里产生隐式依赖调试起来很痛苦。换成setup之后我习惯把每个业务模块的逻辑独立成函数页面里只做组装。举个例子排班日历这块逻辑我抽成了一个useSchedule函数里面维护日期数组、选中状态、剩余号源然后页面里直接调用。// composables/useSchedule.ts import { ref, computed } from vue export function useSchedule(departmentId: number) { const currentDate ref() const availableDates refstring[]([]) const selectedDate ref() async function loadSchedule() { const res await api.getDoctorSchedule({ departmentId }) availableDates.value res.data.dateList if (!selectedDate.value res.data.dateList.length) { selectedDate.value res.data.dateList[0] } } const canBook computed(() !!selectedDate.value) return { currentDate, availableDates, selectedDate, loadSchedule, canBook } }这种写法最大的好处是页面里没有一堆散落的 data 和 methods逻辑按业务内聚后期有人接手也能快速看懂。Vue3 在 Uniapp 里还有一个实际优势就是响应式性能比 Vue2 好不少。医疗列表页动不动几十条医生卡片每条卡片里包含头像、职称、剩余号源、简介如果用 Vue2 的Object.defineProperty做响应式数据更新时性能会明显掉帧Vue3 的 Proxy 方案实测要好很多。2. 工程骨架初始化、目录约定和基础设施2.1 初始化工程与 TypeScript 配置Uniapp 官方提供了 Vue3 Vite TypeScript 的模板直接用npx degit dcloudio/uni-preset-vue#vite-ts拉下来就行。这个模板历史包袱比较重拉下来第一件事我一般先清理src目录下默认 demo 页面只留pages/index/index作为启动页。TypeScript 配置需要单独看一眼。默认的tsconfig.json里paths通常已经配好了/*指向src/*但是这里有个坑如果你的 Vite 版本比较新TypeScript 也更新到 5.x 以上baseUrl会在编译时给出弃用警告提示说在未来的 TS 7.0 会移除。这个警告不影响编译但看着很烦。正确的做法是不配置baseUrl直接写相对路径映射{ compilerOptions: { target: ESNext, module: ESNext, moduleResolution: Node, paths: { /*: [./src/*] }, strict: true, types: [dcloudio/types] }, include: [src/**/*.ts, src/**/*.d.ts] }strict: true一定要开。虽然开发阶段会频繁被类型检查打断但医疗项目的字段极其容易写错比如把appointmentNo写成appointNo这种低级错误在类型保护下根本走不到运行环节。刚开始团队成员可能会抱怨类型报错多习惯之后基本就离不开了。2.2 目录结构业务优先而不是页面优先初次做 Uniapp 项目的人最容易把目录结构设计成以 pages 为主页面文件夹里堆一堆组件和逻辑第一周开发很爽到第二周开始维护页面的代码量大了之后你会发现在页面 A 用到的公共组件在页面 B 里没法直接引用得靠复制粘贴。我跑过两个项目之后总结了一套稳定结构src/ api/ // 接口请求函数按模块拆分 modules/ user.ts doctor.ts appointment.ts components/ // 公共跨平台组件 composables/ // 逻辑复用函数 pages/ // 页面文件只负责组装 index/ index.vue schedule.vue static/ stores/ // Pinia 状态 types/ // 全局类型定义比如 API 响应结构 utils/这个结构最大的不同是api/modules里每个文件对应后端一个业务域页面和组件都不直接写uni.request而是调用api函数。这样后端接口地址改了只需要在一个文件里改不会出现全项目搜索url: /api/xxx的尴尬。2.3 请求层封装与 Token 刷新处理请求层是医疗小程序的生命线封装不好后面所有页面都跟着遭殃。我封装请求层时最核心的关注点是自动携带 Token、统一错误处理、401 时自动跳转登录页。这里直接给出一版可以改改就能用的请求封装// utils/request.ts import { useUserStore } from /stores/user interface RequestOptions { url: string method?: GET | POST | PUT | DELETE data?: Recordstring, any loading?: boolean } export function requestT({ url, method GET, data, loading true, }: RequestOptions): PromiseT { const userStore useUserStore() if (loading) { uni.showLoading({ title: 加载中, mask: true }) } return new Promise((resolve, reject) { uni.request({ url: ${import.meta.env.VITE_API_BASE_URL}${url}, method, data, header: { Content-Type: application/json, Authorization: userStore.token ? Bearer ${userStore.token} : , }, success: (res) { const response res.data as ApiResponseT if (res.statusCode 200 response.code 0) { resolve(response.data) } else if (res.statusCode 401) { userStore.logout() uni.reLaunch({ url: /pages/login/index }) reject(new Error(登录已过期)) } else { uni.showToast({ title: response.message || 请求失败, icon: none }) reject(new Error(response.message)) } }, fail: (err) { uni.showToast({ title: 网络异常请检查网络, icon: none }) reject(err) }, complete: () { if (loading) { uni.hideLoading() } }, }) }) }这里有个容易被忽略的细节Token 不要直接存uni.getStorageSync读取而是从 Pinia store 里拿。因为在 Uniapp 里页面和请求模块如果通过uni.getStorageSync读 Token一旦用户在其他页面被踢下线所有并发请求拿到的还是旧 Token。用 Pinia 管理 Token再配合uni.setStorageSync持久化可以保证同一时刻所有请求拿到的 Token 状态一致。2.4 Pinia 状态管理与用户会话Uniapp 官方默认的 Vuex 写法在 Vue3 里已经明显落伍了我现在清一色用 Pinia。创建 store 之后别忘在main.ts里注册应用这是 Vue3 Uniapp 项目最常见的初始化遗漏// main.ts import { createSSRApp } from vue import { createPinia } from pinia import App from ./App.vue export function createApp() { const app createSSRApp(App) app.use(createPinia()) return { app } }用户 store 里除了 Token我习惯把用户基本信息也放进去并且初始化时从缓存恢复避免冷启动后页面拿不到用户信息。代码如下// stores/user.ts import { defineStore } from pinia export interface UserInfo { name: string avatar: string phone: string idCard?: string } export const useUserStore defineStore(user, { state: () ({ token: uni.getStorageSync(token) || , userInfo: (uni.getStorageSync(userInfo) || {}) as UserInfo, }), actions: { setLoginInfo(token: string, userInfo: UserInfo) { this.token token this.userInfo userInfo uni.setStorageSync(token, token) uni.setStorageSync(userInfo, userInfo) }, logout() { this.token this.userInfo {} as UserInfo uni.removeStorageSync(token) uni.removeStorageSync(userInfo) }, }, })注意医疗小程序里用户信息可能包含敏感的实名信息所以 store 里的userInfo一定要在 App 进入后台时清理掉敏感字段至少把身份证号打码后再缓存这个后面讲隐私合规时会再提。3. 核心模块实战登录、医生排班与预约下单3.1 登录链路微信登录与手机号绑定医疗小程序的登录比普通商城要严谨因为后面关联的是电子病历和实名报告。我在项目里走的是微信登录获取身份 授权手机号绑定 身份证实名认证三个阶段。第一个阶段uni.login拿code发给后端换自定义登录态import { login as apiLogin } from /api/modules/user export function wechatLogin(): Promisestring { return new Promise((resolve, reject) { uni.login({ provider: weixin, success: async (loginRes) { try { const { token } await apiLogin({ code: loginRes.code }) resolve(token) } catch (e) { reject(e) } }, fail: reject, }) }) }第二个阶段用户未绑定手机号时需要弹窗引导用户授权手机号。这一步注意要用button open-typegetPhoneNumber不能自己调原生的uni.getPhoneNumber接口——微信从基础库某个版本之后已经禁止非 button 方式直接获取手机号了这是很多新手的坑。拿到code之后把手机号解密逻辑都丢给后端前端不要把加密数据传给自己的服务器这个数据链路设计上是违规的。第三个阶段是身份证实名认证。这块一般嵌入在后端接口校验里前端只在用户预约支付前做一次校验提示如果后端返回needVerify就用一个半屏弹层让用户填写姓名 身份证后四位。这块代码不涉及核心算法关键是交互要轻量不要在预约主流程上挡用户太久。3.2 医生排班列表列表性能与空状态处理医生排班页是医疗小程序里最典型的信息密集型页面。一屏里有科室筛选 tab、日期横滑、医生卡片列表、剩余号源标识。我第一次做的时候直接把所有数据一次性塞进onLoad结果微信开发者工具上没问题真机上一打开就白屏一秒多明显是渲染线程卡死了。后来把数据结构重新梳理了一下日期横滑单独做成一个组件每次只渲染当前可见的 7 天医生卡片列表用分页加载每次 10 条滚动到底部再请求下一批。核心逻辑在组件里维护数据源子组件负责展示。template view classdoctor-list view v-fordoctor in doctors :keydoctor.id classdoctor-card image :srcdoctor.avatar classavatar / view classinfo text classname{{ doctor.name }}/text text classtitle{{ doctor.title }}/text text classremaining :class{ full: doctor.remaining 0 } {{ doctor.remaining 0 ? 余号 ${doctor.remaining} : 已约满 }} /text /view button :disableddoctor.remaining 0 clickhandleBook(doctor) 预约 /button /view view v-ifloading classloading-text加载中.../view view v-iffinished doctors.length 0 classempty-text 当前科室暂无排班医生 /view /view /template script setup langts interface DoctorItem { id: number name: string title: string avatar: string remaining: number } const props defineProps{ doctors: DoctorItem[] loading: boolean finished: boolean }() const emit defineEmits{ (e: book, doctor: DoctorItem): void (e: loadMore): void }() function handleBook(doctor: DoctorItem) { emit(book, doctor) } function onReachBottom() { if (!props.loading !props.finished) { emit(loadMore) } } /script这里有一个写 Vue3 Uniapp 时很容易踩的坑onReachBottom这个生命周期在子组件里不一定生效。微信小程序的onReachBottom是页面级生命周期子组件里直接写它不会被触发。我一般是在页面里监听onReachBottom然后通过调用子组件暴露的loadMore方法或者更新 props 来驱动子组件加载更多。上面这个组件里的onReachBottom只是示例写法实战中我更喜欢直接让页面持有列表数据在页面onReachBottom里请求新数据再传给子组件。3.3 预约下单与支付对接预约下单的核心是防重复提交。医疗号源是稀缺资源用户连续点击确认预约按钮如果后端没有做并发幂等会出现一个号被抢两次的严重事故。前端能做的事情主要是按钮置灰 请求进行中标记const submitting ref(false) async function handleSubmit() { if (submitting.value) return submitting.value true try { const orderNo await api.createAppointment({ doctorId: selectedDoctor.value.id, date: selectedDate.value, timeSlot: selectedTimeSlot.value, }) // 跳转订单确认页 } finally { submitting.value false } }支付对接方面Uniapp 里微信支付一般走uni.requestPayment。这步的配置很容易出问题尤其是timeStamp、nonceStr、package、signType这些参数任何一个是 null 都会导致拉起支付失败。我的经验是后端返回什么字段就传什么字段不要在前端做二次转换。真实项目中踩过一个大坑后端把package字段发成了payPackage前端没注意直接透传结果调uni.requestPayment始终报package is required。支付成功后的回调处理同样重要。支付结果不要只看success回调因为微信支付的回调状态有时候并不完全可靠最稳的方式是支付成功跳转到订单详情页后让页面重新从后端拉一次订单最新状态。uni.requestPayment({ provider: wxpay, timeStamp: payment.timeStamp, nonceStr: payment.nonceStr, package: payment.package, signType: MD5, paySign: payment.paySign, success: () { uni.redirectTo({ url: /pages/order/detail?orderNo${orderNo}, }) }, fail: (err) { if (err.errMsg.includes(cancel)) { uni.showToast({ title: 您已取消支付, icon: none }) } }, })这里要说一句医疗小程序的微信支付有严格类目限制如果主体资质不满足医疗类目支付功能会被微信禁用。开发前一定要先确认账号的类目权限否则整个预约流程做完了发现支付拉不起来再去补资质周期非常长。4. 真机部署前后的坑从开发者工具到应用市场的排雷记录4.1 路由参数获取和动态标题设置开发小程序绕不开路由参数传递。Uniapp 里获取路由参数最常用的是onLoad(options)但如果你在 Vue3 组合式 API 里用onLoad必须从dcloudio/uni-app里导入import { onLoad } from dcloudio/uni-app onLoad((options) { const doctorId Number(options.doctorId) loadDoctorDetail(doctorId) })稍微冷门一点的坑是onLoad接收的参数全是字符串如果要从?doctorId1001type2里拿到数字型 ID 做判断一定记得先Number()强转否则判断会踩坑。另一个容易忽略的场景是页面之间通过事件总线传参这个在小程序里很容易出现内存泄漏我不推荐能用 URL 参数就用 URL 参数对象类型可以先用encodeURIComponent(JSON.stringify(obj))塞到地址里再在目标页解析。动态标题就简单多了但有一个应用场景——医生详情页的标题需要从医生昵称动态设置import { onLoad } from dcloudio/uni-app onLoad(async (options) { const doctor await getDoctorDetail(Number(options.doctorId)) uni.setNavigationBarTitle({ title: doctor.name }) })在微信小程序里uni.setNavigationBarTitle只在页面加载后设置才有效如果你在onLoad里直接同步设置有时候会被微信开发者工具忽略。稳妥做法是放在nextTick里再调用或者改到onReady生命周期里执行。4.2 软键盘遮挡查询框和输入框的问题医疗小程序里最常见的一个交互就是搜索框 查询按钮尤其是报告查询和药品查询页面。微信开发者工具上一切正常真机上一弹软键盘就把输入框挡住了查询按钮被键盘遮住半边体验极差。问题的根源是页面布局默认按窗口高度渲染软键盘弹出后窗口高度并没有实时变化。解决方案有两种。一种最简单粗暴给page设置adjust-position为true让输入框跟随键盘上顶。但在医疗小程序里页面里往往还有固定底部的提交按钮键盘一弹底部按钮就被顶得乱七八糟所以这个方法在这种场景并不好用。我更推荐用键盘高度动态监听把底部按钮先隐藏uni.onKeyboardHeightChange((res) { keyboardHeight.value res.height }) const isKeyboardVisible computed(() keyboardHeight.value 0)然后在模板里给底部按钮加一个v-if或v-show键盘弹起时把按钮替换成一个收起键盘的占位符这样既不会遮挡输入内容也不会让按钮顶到键盘上。这招在报告查询页和在线问诊提交页都验证过安卓 iOS 表现都稳定。4.3 onShareAppMessage 被全局覆盖的问题用户分享到好友或者朋友圈是小程序裂变的主要方式医疗小程序里典型场景是分享给家人查看体检报告。Uniapp 默认支持这个生命周期但如果你在某个全局 mixin 或者 App.vue 里覆盖了onShareAppMessage再在页面里重新写一份页面里的不会被调用全局的反而会生效。我遇到的具体情况是App.vue 里写了一个全局分享配置目的是统一生成分享图片结果所有页面的自定义分享标题全部失效。排查了很久最后发现是页面里的onShareAppMessage配置过晚全局的onShareAppMessage优先执行而且执行完后不会被页面的覆盖。解决方案是放弃在页面里写死分享标题改成动态从全局 store 中读取。页面进入时先uni.setStorageSync(shareTitle, xxx)全局分享回调里再读这个值。这样所有页面只需要维护一个分享标题变量不用每个页面都重写一遍分享逻辑也避免了覆盖问题。4.4 manifest 配置与上架合规Uniapp 打包上架微信小程序和安卓应用市场有一个共同的高频问题隐私政策弹窗不符合平台要求。尤其是医疗类小程序涉及个人信息收集条目特别多应用市场审核基本都会盯这块。我现在的做法是在 App.vue 里加一个启动路由守卫首次启动时弹原生弹窗让用户确认用户协议和隐私政策用户不同意就直接退出应用。这里给一下 App 端的退出逻辑微信小程序端不能主动退出只能通过uni.showModal反复提示export function checkAgreement() { const agreed uni.getStorageSync(privacyAgreed) if (agreed) return true uni.showModal({ title: 温馨提示, content: 请先阅读并同意《用户协议》和《隐私政策》后再使用, confirmText: 同意, cancelText: 退出, success: (res) { if (res.confirm) { uni.setStorageSync(privacyAgreed, 1) } else { // #ifdef APP-PLUS plus.runtime.quit() // #endif // #ifdef H5 window.history.back() // #endif } }, }) return false }manifest.json 的配置也有坑。小程序端要重点检查appid是否正确微信开发者工具连不上项目时十有八九是 appid 没填或者填错了测试号。App 端打包时权限声明不能乱勾尤其不能勾选定位和通讯录权限如果只用到地图功能就不要申请存储权限医疗类应用市场对这个特别敏感。4.5 微信开发者工具运行没反应Uniapp 运行到微信开发者工具上没反应是我在社群里被问得最多的问题没有之一。绝大多数情况不是代码问题而是开发者工具的服务端口没开。微信开发者工具现在默认禁用了远程调试服务需要在设置 - 安全设置 - 服务端口里打开127.0.0.1的调试端口关掉之后 Uniapp 的 HBuilderX 或 CLI 就没法往工具里推代码了。其次要检查项目里的manifest.json的mp-weixin配置确保appid是真实的而不是touristappid。用测试号开发时工具也可以正常打开但要运行到真机预览或者体验版就必须换成正式 appid 并做域名校验。5. 最后分享几个让项目更稳的习惯如果你正准备开始做一个 Uniapp Vue3 的项目我给你三个我带团队时定的规矩。第一个习惯是接口请求函数一定要全部走api/modules页面里禁止直接写uni.request。这条规矩一开始看起来麻烦但它会让后端接口变更的影响面降到最低。医疗项目的接口字段说改就改如果只有 api 层多个地方做映射维护压力小很多。第二个习惯是给所有接口响应定义类型别名。后端返回的数据结构统一叫ApiResponseT里面包含code、message、data。你只要把这个基础类型写好后面每个接口自动获得类型推导永远不会出现res.data.result.list这种层层不安全的链式访问。第三个习惯是发布前跑一遍隐私合规自检。医疗小程序被平台下架的概率远高于普通工具类应用我吃过一次亏之后就不再抱侥幸心理专门做了一个检查清单用户协议和隐私政策是否在首次启动展示、是否采集了与功能无关的信息、是否在未授权时调用了uni.getLocation、退出登录后是否清除了本地敏感缓存。每一条都核对一遍再提审基本都能一次过。Uniapp 这套技术栈在医疗行业的成熟案例越来越多踩坑经验也会慢慢沉淀成社区共识。希望我这篇实践笔记能让你少走一些弯路如果你的业务场景和医疗不太一样只要把核心模块替换一下工程骨架和这套设计思路依然可以直接复用。本文还有配套的精品资源点击获取
返回列表