医疗小程序跨端开发:Vue3+TypeScript+Uniapp全流程实践
2026/9/9 0:15:14 网站建设 项目流程

简介:一份以医疗问诊为业务背景的 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 或者 React

Vue2 在 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 = ref<string[]>([]) 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.jsonpaths通常已经配好了@/*指向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?: Record<string, any> loading?: boolean } export function request<T>({ url, method = 'GET', data, loading = true, }: RequestOptions): Promise<T> { 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 ApiResponse<T> 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.logincode,发给后端换自定义登录态:

import { login as apiLogin } from '@/api/modules/user' export function wechatLogin(): Promise<string> { 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-type="getPhoneNumber">,不能自己调原生的uni.getPhoneNumber接口——微信从基础库某个版本之后已经禁止非 button 方式直接获取手机号了,这是很多新手的坑。

拿到code之后把手机号解密逻辑都丢给后端,前端不要把加密数据传给自己的服务器,这个数据链路设计上是违规的。

第三个阶段是身份证实名认证。这块一般嵌入在后端接口校验里,前端只在用户预约支付前做一次校验提示,如果后端返回needVerify,就用一个半屏弹层让用户填写姓名 + 身份证后四位。这块代码不涉及核心算法,关键是交互要轻量,不要在预约主流程上挡用户太久。

3.2 医生排班列表:列表性能与空状态处理

医生排班页是医疗小程序里最典型的信息密集型页面。一屏里有科室筛选 tab、日期横滑、医生卡片列表、剩余号源标识。我第一次做的时候直接把所有数据一次性塞进onLoad,结果微信开发者工具上没问题,真机上一打开就白屏一秒多,明显是渲染线程卡死了。

后来把数据结构重新梳理了一下:日期横滑单独做成一个组件,每次只渲染当前可见的 7 天;医生卡片列表用分页加载,每次 10 条,滚动到底部再请求下一批。核心逻辑在组件里维护数据源,子组件负责展示。

<template> <view class="doctor-list"> <view v-for="doctor in doctors" :key="doctor.id" class="doctor-card"> <image :src="doctor.avatar" class="avatar" /> <view class="info"> <text class="name">{{ doctor.name }}</text> <text class="title">{{ doctor.title }}</text> <text class="remaining" :class="{ full: doctor.remaining === 0 }"> {{ doctor.remaining > 0 ? `余号 ${doctor.remaining}` : '已约满' }} </text> </view> <button :disabled="doctor.remaining === 0" @click="handleBook(doctor)" > 预约 </button> </view> <view v-if="loading" class="loading-text">加载中...</view> <view v-if="finished && doctors.length === 0" class="empty-text"> 当前科室暂无排班医生 </view> </view> </template> <script setup lang="ts"> 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。这步的配置很容易出问题,尤其是timeStampnonceStrpackagesignType这些参数,任何一个是 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接收的参数全是字符串,如果要从?doctorId=1001&type=2里拿到数字型 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-positiontrue,让输入框跟随键盘上顶。但在医疗小程序里,页面里往往还有固定底部的提交按钮,键盘一弹底部按钮就被顶得乱七八糟,所以这个方法在这种场景并不好用。

我更推荐用键盘高度动态监听,把底部按钮先隐藏:

uni.onKeyboardHeightChange((res) => { keyboardHeight.value = res.height }) const isKeyboardVisible = computed(() => keyboardHeight.value > 0)

然后在模板里给底部按钮加一个v-ifv-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.jsonmp-weixin配置,确保appid是真实的,而不是touristappid。用测试号开发时,工具也可以正常打开,但要运行到真机预览或者体验版,就必须换成正式 appid 并做域名校验。

5. 最后分享几个让项目更稳的习惯

如果你正准备开始做一个 Uniapp + Vue3 的项目,我给你三个我带团队时定的规矩。

第一个习惯是接口请求函数一定要全部走api/modules,页面里禁止直接写uni.request。这条规矩一开始看起来麻烦,但它会让后端接口变更的影响面降到最低。医疗项目的接口字段说改就改,如果只有 api 层多个地方做映射,维护压力小很多。

第二个习惯是给所有接口响应定义类型别名。后端返回的数据结构统一叫ApiResponse<T>,里面包含codemessagedata。你只要把这个基础类型写好,后面每个接口自动获得类型推导,永远不会出现res.data.result.list这种层层不安全的链式访问。

第三个习惯是发布前跑一遍隐私合规自检。医疗小程序被平台下架的概率远高于普通工具类应用,我吃过一次亏之后就不再抱侥幸心理,专门做了一个检查清单:用户协议和隐私政策是否在首次启动展示、是否采集了与功能无关的信息、是否在未授权时调用了uni.getLocation、退出登录后是否清除了本地敏感缓存。每一条都核对一遍再提审,基本都能一次过。

Uniapp 这套技术栈在医疗行业的成熟案例越来越多,踩坑经验也会慢慢沉淀成社区共识。希望我这篇实践笔记能让你少走一些弯路,如果你的业务场景和医疗不太一样,只要把核心模块替换一下,工程骨架和这套设计思路依然可以直接复用。

本文还有配套的精品资源,点击获取

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

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

立即咨询