我整理了家里三年的体检报告、门诊病历和用药记录,数据散在纸质档案、手机截图和各个医院的公众号里,每次复诊翻起来都很痛苦。干脆自己动手做了一套个人健康档案管理系统,前端用 Vue 3 搭界面,后端接口走 ThinkPHP 提供数据服务,Node.js 则负责把开发工具链跑起来,顺便承担了一个轻量的实时指标推送服务。这套系统解决的核心问题很直接:把家庭成员的健康数据集中存起来,随时能看趋势、查报告、设置用药提醒。
这套组合初看有点"混搭",实际跑通之后我觉得它非常适合个人项目,也适合刚接触前后端分离的开发者去完整走一遍流程。标题里的三个关键词——Nodejs、vue、thinkphp——并不是各写各的,而是有明确分工。下面我把整个项目从选型、环境搭建、核心功能到部署交付的完整过程复盘一遍,里面包含了不少热搜榜上出现过的坑,比如 PowerShell 禁止运行 npm 脚本、Vue 里怎么播放 m3u8 视频、打包后如何放进后端工程等,我尽量把排查思路和解决命令写清楚。
1. 为什么是 Vue + Node.js + ThinkPHP:一套系统的技术选型复盘
1.1 先看需求,再看框架:健康档案到底要管理什么
做系统之前我先把需求列了一张纸,不急着选技术栈。个人健康档案管理系统要处理的无非是这几类数据:
- 家庭成员信息:姓名、性别、生日、血型、身高体重等基础资料;
- 体检报告:医院给的 PDF、图片、影像视频,需要能上传、预览、归档;
- 日常指标:血压、血糖、心率、体重,这些是长期监测的核心数据;
- 用药记录:药名、剂量、频次、起止时间,最好还能有提醒;
- 就诊记录:门诊时间、医院、科室、医生诊断和处方。
这些需求对系统要求并不多高,单机或者单服务器跑完全够用,最大的压力反而是数据格式多样、展示形式复杂。PDF 要能预览,体检中心的颈部血管超声录像经常是 m3u8 格式的视频流,还需要把血压血糖画成趋势图。把这些需求翻译成技术语言后,事情就清楚多了:一个能做表格和表单的前端框架、一个能干文件上传和数据 CRUD 的后端框架、外加一个能处理视频流和实时推送的工具。
1.2 三者的边界:谁负责界面,谁负责数据,谁负责实时能力
我最终确定的分工是这样的:
| 技术 | 在本系统中的职责 | 具体用途 |
|---|---|---|
| Vue 3 | 前端界面 | 用户登录、档案列表、表单录入、图表展示、视频播放 |
| ThinkPHP 6 | 后端 API | 用户鉴权、健康数据 CRUD、文件上传、用药提醒接口 |
| Node.js | 工具链 + 实时服务 | Vite 构建、npm 依赖管理、Excel 导入脚本、SSE 指标推送 |
Vue 部分用的是 Vue 3 组合式 API 写法,加上 Element Plus 做后台界面,Vite 做开发服务器和打包工具。这些工具本来就需要 Node.js 环境,所以 Node.js 的第一身份是"前端基础设施"。
但仅仅把 Node.js 当构建工具用,它在这个项目里的价值就不够突出了。我在实际开发中发现,ThinkPHP 做常规接口很顺手,但要做类似健康监测设备实时推送这种长连接场景,PHP 的阻塞模型写起来不痛快。于是我单独用 Node.js 写了一个非常轻量的 SSE 服务,专门负责把血糖、心率这类变化频率高的数据推送到前端图表上。这样 Node.js 就从"配套工具"变成了系统的第二个后端服务,标题里的三件套才算真正各司其职。
1.3 为什么不换个更"统一"的栈
这是朋友问得最多的问题:既然都用 Node.js 了,为什么不干脆用 NestJS 或者 Express 把后端一起写了?既然用了 PHP,为什么不直接用模板渲染页面,省去前后端分离的麻烦?
我的真实想法是,个人项目最重要的不是技术统一,而是上手速度、维护成本、出活效率。ThinkPHP 的 ORM、验证器、中间件是我最熟悉的组合,写健康档案这种带大量表单校验和文件管理的业务,效率明显比用 Express 从零搭要高。Vue 和 ThinkPHP 做前后端分离,好处是以后想加一个给老人用的简易版、或者做微信小程序端,后端 API 可以直接复用,不用重写。
Node.js 在这里属于"哪里需要哪里搬"的角色,这也是我建议初学者参考的地方:不要被"一个框架打天下"的思路捆住,项目里出现多个运行时很正常,关键是每个组件解决什么问题、边界在哪要搞清楚。
2. 开发环境从零到跑通:Node 安装、npm 脚本权限、Vue 脚手架、ThinkPHP 运行
2.1 Node.js 的安装与环境配置,第一步别装错版本
我在两台电脑上分别装了 Windows 和 macOS 两套环境,踩过不少版本坑。Node.js 的安装本身不复杂,但版本管理建议一开始就做好。Windows 上我用了 nvm-windows,macOS 上用的 nvm,Linux 同理。理由很简单:项目 A 可能要求 Node 18,项目 B 可能就要 Node 20,没有版本切换工具就只能反复卸载安装,太浪费生命。
安装步骤简述如下:
- 去 Node.js 官网下载 LTS 版本安装包,或者先装 nvm,再用
nvm install 20装指定版本; - Windows 安装包会根据系统架构自动配置 PATH,装完务必重开一个终端窗口;
- 打开新终端执行验证命令:
node -v npm -vmacOS 上用 Homebrew 的话是brew install node,或者brew install nvm后同步配置 shell 环境变量,网上教程很多,这里不多说。重点是装完后node -v能正常输出版本号,才算环境配置完成。
这个环节最常见的问题不是安装失败,而是装完发现 npm 命令能用、node 命令也能用,但项目启动脚本就是跑不起来——那就进入下面这个热搜级报错。
2.2 最烦人的 PowerShell 脚本执行策略问题
搜索热词里连续好几条都是这个报错,我必须专门说一下:
npm : 无法加载文件 C:\Program Files\nodejs\npm.ps1,因为在此系统上禁止运行脚本。 有关详细信息,请参阅 https://go.microsoft.com/fwlink/?LinkID=135170 中的 about_Execution_Policies。这个问题的本质不是 npm 坏了,而是 Windows PowerShell 默认的脚本执行策略是受限的,不允许加载 .ps1 脚本。npm 命令在 PowerShell 里本质上调用的就是 npm.ps1,所以被拦截。
排查思路应该是这样的:
- 先确认自己当前用的终端是 PowerShell 还是 CMD,CMD 下通常没这个问题;
- 在 PowerShell 里执行
Get-ExecutionPolicy,看到返回Restricted,说明确实是被策略拦了; - 以管理员身份打开 PowerShell,执行:
Set-ExecutionPolicy RemoteSigned按提示输入 Y 确认,然后重新打开终端,npm 就可以正常用了。
RemoteSigned的含义值得多说一句:它允许本地创建的脚本直接运行,从互联网下载的脚本必须带有可信发布者签名。npm.ps1 是安装 Node.js 时本地生成的,属于本地脚本,所以可以执行,这个策略兼顾了安全性和便利性,是最常用的配置。如果你所在环境对安全要求高,也可以只在当前用户下设置:Set-ExecutionPolicy -Scope CurrentUser RemoteSigned。
2.3 Vue 项目脚手架与依赖安装
前端我选择用 Vite 作为构建工具,创建项目的命令很简单:
npm create vue@latest health-frontend过程中可以选择 TypeScript、Vue Router、Pinia 等选项。如果之前没装过 create-vue,npm 会提示安装,按 Y 继续即可。创建完成后:
cd health-frontend npm install npm run dev这里 npm install 时间取决于网络环境,建议先把 npm 镜像源切到 npmmirror,可以在用户目录下配.npmrc:
registry=https://registry.npmmirror.comElement Plus 的安装方式是按需引入,我用的 Vite 插件方式:
npm install element-plus @element-plus/icons-vue npm install -D unplugin-vue-components unplugin-auto-import然后修改vite.config.js,加入两个插件,组件就能自动按需导入了。这套配置做完,Element Plus 的表格、表单、日期选择器用起来非常顺手,做档案管理界面能省下一大半样式工作量。
2.4 ThinkPHP 项目的初始化与本地运行
后端我用 Composer 创建 ThinkPHP 6 项目:
composer create-project topthink/think health-api cd health-api php think runphp think run会启动一个 PHP 内置开发服务器,默认监听 8000 端口,本地调试完全够用。如果电脑没装 Composer,先装 Composer 并确认 PHP 版本在 8.0 以上。
ThinkPHP 项目跑起来之后,建议先访问一下http://localhost:8000,看到默认欢迎页说明环境正常。接下来要做的是关闭默认的multi_app设置或是按单应用模式配置路由规则,我用的是单应用模式,所有接口都写在route目录下的路由文件里。
这里容易踩的坑是伪静态配置。ThinkPHP 的 URL 默认是index.php?s=/api/xxx这种形式,本地开发用php think run没感觉,一旦部署到 Nginx 或者 Apache 上,不配伪静态就会出现 404。线上环境我的 Nginx 配置会在第 5 节给出。
2.5 Vite 开发代理:让前端请求找到后端
前后端分离后,开发阶段会遇到跨域。最省事的方案不是在后端拼命配跨域头,而是在 Vite 里做代理,让浏览器感觉请求就是发给前端自己服务器的。
我在vite.config.js里加了这样一段:
server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8000', changeOrigin: true } } }这样前端页面里所有/api/xxx的请求,Vite 都会转发到http://localhost:8000,也就是 ThinkPHP 的服务地址。浏览器地址栏看到的始终是localhost:5173,不存在跨域问题。后端的路由只需要统一挂在/api前缀下即可。
实际联调阶段,我建议第一次跑通一个最简单的接口,比如登录接口,确认从 Vue 页面点击按钮到后端数据库返回结果的整条链路都通了,再开始写复杂功能。链路不通就盲目堆代码,出了问题很难定位。
3. 健康档案系统的数据模型与核心功能落地
3.1 数据库设计:从用户到健康记录的建模思路
个人健康档案的数据模型不需要太花哨,但表结构要能支撑长期累积的数据。我设计了下面几张核心表,这里贴出关键字段:
user:用户表,存登录账号、密码哈希、姓名、角色;family_member:家庭成员表,关联 user,存血型、身高、体重、身份证号(脱敏后)、关系;health_record:主要档案表,存体检/就诊记录,字段包括成员 ID、记录日期、类型、医院、科室、医生、诊断、报告文件路径;vital_sign:日常指标表,存血压、血糖、心率、体重,这是画趋势图和做提醒的主力表;medication:用药记录表,存药名、剂量、频次、开始日期、结束日期、提醒时间。
一个 user 对多个 family_member,每个 family_member 对多个 health_record 和 vital_sign,一对多关系足够,不需要引入过复杂的设计。SQL 建表语句我就不全部贴了,只提一个容易忽略的点:health_record.report_file字段我存的是相对路径而非完整 URL,这样换域名、迁服务器的时候不用改数据库内容。
ThinkPHP 6 里我用了模型关联查询,在 FamilyMember 模型里定义:
public function records() { return $this->hasMany(HealthRecord::class, 'member_id'); }前端获取某个成员的全部档案时,调一个接口就能把关联数据一次性取出来,避免 N+1 查询问题。
3.2 登录鉴权与 Vue 路由守卫
系统有"看自己的数据"和"管理整个家庭档案"两种角色,权限核心逻辑是:未登录用户只能看登录页,普通用户只能看自己被授权的成员档案,管理员可以管理全部成员。
后端登录成功后返回一个 token,前端存到 localStorage。Vue Router 里加一个全局前置守卫:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next('/login') } else { next() } })注意一个细节:不要把用户信息也全量塞进 localStorage,只存 token,用户姓名、角色、头像这些敏感字段应该用另一个接口按需获取。动态路由的做法是按角色从后端返回的菜单配置来注册路由,比如管理员多一个"成员管理"路由模块,普通用户不注册这个模块。这样前端跳转时天然就少了很多越权入口。
后端鉴权我写在 ThinkPHP 的中间件里,拦截所有/api/*请求,解析 token 失败就返回 401。这个中间件注册在app/middleware.php中,写起来非常快。
3.3 体检报告上传、PDF 预览与 m3u8 视频播放
文件上传是健康档案系统的硬需求。前后端分离下,我的做法是前端用 Element Plus 的el-upload组件上传文件到 ThinkPHP 的公共上传接口,后端把文件存到 public 目录下,返回访问路径。
PDF 预览这个需求,经常有人问"Vue 的 image 组件能不能显示 PDF"。答案是不能,Vue 不是浏览器,不负责 PDF 渲染,最终干活的是浏览器或者独立的前端库。我的两种实现方案:
- 简单场景,直接用 iframe 嵌入浏览器预览:
<iframe :src="pdfUrl" style="width:100%;height:600px;"></iframe>- 需要批注、高亮、多页签等复杂场景,用 pdf.js 或封装好的 pdfvuer 组件,渲染到 canvas 上。个人项目我推荐先用 iframe,够用且零依赖。
体检中心的影像资料,比如颈动脉超声的视频流,经常是 m3u8 格式。m3u8 本质是一个索引文件,浏览器自带 video 标签不能直接播放,需要 JS 解析并分片加载。我的做法是引入 hls.js:
import Hls from 'hls.js' function playM3u8(videoEl, src) { if (Hls.isSupported()) { const hls = new Hls() hls.loadSource(src) hls.attachMedia(videoEl) } else if (videoEl.canPlayType('application/vnd.apple.mpegurl')) { videoEl.src = src // Safari 原生支持 } }这样用户不需要电脑上装任何播放器插件,打开网页就能看体检视频档案,这也是搜索引擎里"vue 播放 m3u8 免安装"这个热词的正确答案。
3.4 指标趋势图表与用药提醒
健康档案如果只是堆报告,价值不大,真正有长期价值的是趋势分析。我用 ECharts 画血压、血糖、心率的折线图,数据源就是vital_sign表。前端在某个成员详情页里按时间范围拉取指标数据,渲染成折线图,复诊时能直观看到三个月血糖走势,医生也很认可这种形式。
用药提醒功能我做得比较轻量:后端只负责保存提醒配置,前端在进入系统后启动一个定时器,到点后用浏览器的 Notification API 弹通知。代码逻辑不复杂:
setInterval(() => { const now = new Date().getHours() + ':' + new Date().getMinutes() const reminders = store.getters.reminders reminders.forEach(item => { if (item.time === now && !item.doneTodayFlag) { new Notification(`该吃 ${item.drugName} 了`, { body: `剂量:${item.dosage}` }) } }) }, 30000)提醒功能一开始不必做得太重,能准时弹出通知、能在页面里标记已吃,这两个核心动作完成,对家里老人来说就非常实用了。
4. 联调阶段的典型报错与处理方法
4.1 跨域问题:开发和生产的解决思路完全不同
开发阶段跨域靠 Vite 代理解决,我在第 2.5 节已经写了配置。生产环境如果前端静态文件和后端接口不在同一个域名下,还是逃不过跨域,此时要在 ThinkPHP 里配跨域中间件。
我实现了一个简单的跨域中间件,核心逻辑就是在响应头里加上允许跨域的字段,并在收到OPTIONS预检请求时直接返回 200:
return $response ->header('Access-Control-Allow-Origin', 'https://health.example.com') ->header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS') ->header('Access-Control-Allow-Headers', 'Content-Type, Authorization');这里我给一个建议:生产环境尽量让前端和后端保持同源,也就是把打包出来的静态文件交给 Nginx 托管,同时把/api路径反向代理到 ThinkPHP。同源之后就不需要开跨域,暴露面更小,遇到问题也更少。
4.2 Vue 路由参数和插槽的两个高频疑问
开发中 Vue Router 参数传递总是让人混淆。路由跳转时我用 query 方式传参,格式如下:
router.push({ path: '/member/detail', query: { id: memberId } })在详情页读取参数用route.query.id。如果希望参数不暴露在 URL 里,可以改用 params 配合路由命名,但刷新页面后 params 会丢失,需要结合状态管理保存。做健康档案这种详情页场景,我推荐 query 方式,直观、刷新不掉。
Vue 插槽(slot)是另一个高频话题。档案列表页我封装了一个通用卡片组件,不同业务区块通过具名插槽扩展:
<template> <div class="record-card"> <div class="card-header"> <slot name="header">默认标题</slot> </div> <div class="card-body"> <slot></slot> </div> </div> </template>在使用时通过<template #header>传入自定义内容。插槽的本质是把组件内部留一个位置给父级填充内容,理解了这个抽象,再去看那些复杂的封装组件就不发怵了。
另外还要提一个常见报错:failed to load tsconfig '@vue/tsconfig/tsconfig.web.json'。这个错多数是项目刚创建时依赖没有安装完整,或者 tsconfig.json 里引用的@vue/tsconfig包版本不对。我遇到时执行了npm install -D @vue/tsconfig,然后重启 Vite 开发服务器就恢复正常。如果依旧报错,检查tsconfig.node.json和tsconfig.app.json里的 extends 路径是否和 node_modules 中实际安装路径一致。
4.3 Node.js 在项目里真正干活的两个场景
前面说了 Node.js 写了 SSE 推送服务,这里贴出简化代码方便参考:
const http = require('http') http.createServer((req, res) => { res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' }) const timer = setInterval(() => { res.write(`data: ${JSON.stringify({ value: getLatestGlucose() })}\n\n`) }, 5000) req.on('close', () => clearInterval(timer)) }).listen(3001)前端用 EventSource 接收:
const source = new EventSource('http://localhost:3001/glucose') source.onmessage = (e) => { const data = JSON.parse(e.data) chart.appendData({ value: data.value }) }SSE 特别适合"后端单向、持续推送"的场景,比如健康监测设备上报血糖数据,前端图表实时刷新。相比 WebSocket 不用管双向通道和重连逻辑,对个人项目的复杂度来说非常友好。
第二个场景是 Excel 导入。家里老人原来手动记录了两年的血压台账在 Excel 里,我用 Node.js 写了一个一次性脚本,读 Excel 转成 JSON,再调用后端导入接口批量写入vital_sign表:
node scripts/import-health-records.js写这类工具脚本用 Node.js 很顺手,因为 npm 生态里有现成的xlsx库。这里也能体现 Node.js 在标题里的位置不是摆设——它是整个系统的"外围工具层"。
5. 把项目交付出去:源码分享、打包部署与环境配置
5.1 如何把 Vue 项目源码干净地发给别人
好几个人问过我"vue 项目源码怎么发给别人",后来我发现很多人发出去的是带着node_modules文件夹的压缩包,又大又容易因路径差异跑不起来。
标准的交付方式是:
- 删除项目里的
node_modules和dist目录; - 保留
package.json和package-lock.json,后者可以保证对方安装依赖时版本一致; - 写一份 README,注明 Node.js 版本要求,以及执行顺序:
npm install->npm run dev; - 环境配置里有数据库密码、密钥等内容的,单独提供
.env.example模板,不要直接把真实配置发出去。
对方拿到源码跑不起来,90% 是 Node 工具链问题,剩下的可能是镜像源问题。我在 README 里直接写了:如果npm install很慢或者失败,设置镜像源之后再装。
ThinkPHP 端同理,composer.json保留,vendor目录不用发,对方执行composer install即可。数据库迁移脚本和初始 SQL 文件一定记得放进仓库,不然对方拿到手连表都没有,系统根本跑不了。
5.2 前端打包后如何与 ThinkPHP 一起部署
经常有人搜"vue 打包放进 springboot 中",其实把打包后的前端放入任何后端工程托管,思路都是一样的。我把步骤列一下,这里以 ThinkPHP 为例:
- 执行前端构建命令:
npm run build生成结果在dist目录下。
把
dist里的文件复制到 ThinkPHP 的public目录中,比如public_front/health子目录。配置 ThinkPHP 或 Nginx,让静态文件请求直接命中这个目录,API 请求继续转发给 index.php。
生产环境我更推荐用 Nginx 来管理,配置大概是这样:
server { listen 80; server_name health.example.com; root /var/www/health-frontend; index index.html; location /api/ { proxy_pass http://127.0.0.1:8000/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { try_files $uri $uri/ /index.html; } }前端历史路由模式必须配置try_files ... /index.html,否则用户直接访问health.example.com/member/detail这类带路径的 URL 会 404。
5.3 上线后的日常维护与备份
个人系统上线后最怕丢数据,所以备份是第一优先级。我写了一个简单的定时任务,每天凌晨把 MySQL 数据库导出成 SQL 文件,保留最近 30 天,同步到另一个磁盘目录。命令很简单:
mysqldump -u root -p health_db > /backup/health_$(date +%Y%m%d).sql文件上传的文件目录也要定期同步备份,毕竟 PDF 报告和检查影像丢了很难找回。另外我建议给系统加一个简单的访问日志,方便排查哪天被哪个接口大量调用,我用的办法是 Nginx 的 access_log 配合 ThinkPHP 的日志。
还有一个小体会:个人健康档案属于长期使用的数据系统,界面可以朴素,但稳定性比花哨重要。我在后端对常见字段做了统一的输入校验,文件上传做了类型和大小限制,之前遇到过用户上传超大的体检视频导致磁盘变满的问题,后来在php.ini里把post_max_size和upload_max_filesize都改为 100M,并在业务层限制视频文件不大于 50M,这个坑才算填上。
最后再分享一点实际使用中的体会
这套系统我已经用了不短的时间,最满意的功能不是界面多好看,而是复诊时打开网页就能调出过去半年的血糖曲线,顺手把 PDF 报告投到医生屏幕上。健康档案管理这件事,难的不是技术,而是坚持记录,所以系统里所有录入路径我都尽量设计得短,手机上也能完成一次血压录入。
如果你也想照着做一个,我的建议是从最简单的版本开始:先实现 Vue 首页 + ThinkPHP 登录接口 + 一个健康记录表的增删改查,跑通后再逐步加 PDF 预览、m3u8 播放、图表趋势和用药提醒。不要一开始就把所有依赖装齐,Node 版本问题、PowerShell 权限问题、跨域问题每一个都会来一遍,分阶段推进,遇到报错逐个解决,最后你得到的不仅是一个能用的系统,还有一条完整的排错经验链。