Compare commits

..

33 Commits

Author SHA1 Message Date
iven
3d683dfe82 docs(wiki): 新增上线后待办清单(6 必须项 + 4 建议项 + 数据修复 SQL) 2026-06-05 16:39:51 +08:00
iven
ee5ae9e1fb docs(wiki): 新增真机微信登录限流症状条目 + 校准日期 2026-06-05 16:36:14 +08:00
iven
01a0fffc43 fix(auth): 微信登录端点独立限流 30 次/分钟
真机调试首次登录即触发 '请求过于频繁' 错误,根因是微信登录
与密码登录共享 5 次/分钟的限制,且 extract_client_ip 在无
代理头时返回 'unknown',所有真机请求共享同一个 rate limit key。

修复:将微信登录/绑定路由从 public_routes 拆分为独立的
wechat_routes,使用 30 次/分钟的宽松限流(与 token 刷新一致)。
密码登录保持 5 次/分钟的严格限制不变。
2026-06-05 16:33:42 +08:00
iven
976b9d94a0 docs(wiki): 校准关键数字至 2026-06-05 — 用户管理过滤+患者摘要过滤 2026-06-05 11:04:08 +08:00
iven
5d61f19966 docs(wiki): 新增小程序上传数据不可见症状条目 2026-06-05 10:52:30 +08:00
iven
1982698b79 fix(health): 患者摘要列表按 user_id 过滤
小程序 loadPatients() 现在只获取当前登录用户关联的患者,
不再返回整个租户的所有患者。修复 wx_7141 上传数据写到
错误 patient 记录下的问题。

- PatientListParams 增加 user_id 可选参数
- list_summaries 增加 user_id 过滤条件
- 小程序 getPatientSummaries 传入 userId
- auth store loadPatients 传入当前 user.id
2026-06-05 10:51:17 +08:00
iven
76a89dc7de docs(wiki): 新增 wx_* 患者混入用户管理症状条目 2026-06-05 10:20:04 +08:00
iven
201a91580c feat(auth): 用户管理页面过滤纯患者用户 + fix(health): clippy 修复
后端 list_users API 新增 exclude_only_roles 参数,排除仅有指定角色的用户。
前端用户管理页面默认传 'patient' 过滤,wx_* 微信患者不再混入员工列表。
同时修复 erp-health dashboard.rs 的 clippy 警告(unused import + collapsible if)。
2026-06-05 10:17:59 +08:00
iven
a5c67d6bec docs(wiki): 校准关键数字至 2026-06-04 — 自动测量+登录修复+知识库V2
关键数字变更:
- Rust 源文件: 705→726 (+21), 代码行 ~134K
- 迁移: 165→175 (+10, 知识库 V2 + RLS)
- erp-health: 58→59 Entity, 31→33 handler, 216→217 文件
- erp-ai: 20→24 Entity, 95→105 文件 (知识库 V2)
- 全系统 Entity: 115→118
- utoipa: 94→98 文件
- 后端测试: 1030→1031 函数
- Git 提交: 1061→1065
- 小程序: 185→202 TS/TSX, 49→51 service, 5→6 store, 12→13 hooks, 103→110 SCSS

症状导航新增:
- M2 测量页仪表盘数值不可见 (gauge__center 缺背景色)
- 微信登录后显示绑定失败-登录态丢失 (login() 吞错误)
2026-06-04 16:01:59 +08:00
iven
958110cc73 fix(mp): 微信登录 API 失败时不应显示手机绑定按钮
根因:login() 的 catch 块把所有错误都返回 false,
导致 handleWechatLogin 误判为'未绑定'并显示手机绑定按钮。
用户点击绑定后因 wechat_openid 从未写入而报'登录态丢失'。

修复:
- API 失败时 throw 而非 return false,让调用方区分错误和未绑定
- 增加 resp.openid 空值校验,防止后端返回空 openid 进入绑定流程
- 现在后端不可用时用户会看到正确的错误提示而非误导性的绑定按钮
2026-06-01 18:54:03 +08:00
iven
13705a3eaf feat(mp): 连接M2手环后自动测量5项指标 + 修复仪表盘数值不可见
- 连接认证成功后自动依次测量心率→血氧→血压→体温→压力
- 列表式进度UI展示每项指标状态(等待/测量中/完成/跳过)
- 总进度条百分比实时更新
- 测量完成后保存结果并显示'查看结果并返回'按钮
- 支持取消自动测量,已测得的数据不丢失
- 修复仪表盘中心区域缺少背景色导致数值与底色混淆不可见
2026-06-01 11:08:22 +08:00
iven
92ffd8cecb feat(mp): Veepoo M2 BLE 管线扩展 — 精准睡眠数据 + 自动测量 + UI 重构
- 新增 VeepooBridge API:精准睡眠读取(readPreciseSleepData)、B3自动测量配置
  (readAutoTestConfig/setAutoTestConfig)、开关设置(setAutoHeartRate/BP/Temp)、
  体温自动数据读取(readAutoTemperatureData),共 10 个新 API
- 新增 SDK 事件类型:SDK_EVENT_SLEEP(4)、SDK_EVENT_AUTO_TEST(54)
- VeepooPipeline 新增:readSleepData/readAllSleepData(enableAutoMeasurement
  睡眠数据 Promise 化读取 + 自动测量一键开启
- VeepooHistoryReader 新增:uploadSleepReadings 睡眠数据上传
- stores/veepoo.ts 实装:注册 onSleepData 回调、syncHistory 实际读取+上传、
  readSleepData 状态管理、enableAutoMeasurement、连接后自动触发三件事
- 原生页面(native/pkg-veepoo):_onReady 后自动读取 3 天睡眠 + 开启自动测量,
  新增 _readSleepData/_handleSleepEvent/_enableAutoMeasurement
- UI 重构:测量页药丸式选择器+SVG 圆环仪表盘+健康评估标签
- 数据上传页:2 列结果卡片网格+彩色条标识+睡眠数据卡片(★评分+总时长)
- 修复上传按钮无响应 bug:patientId 增加 URL fallback + 错误提示不再静默
- 设计原型:docs/design/veepoo-measure-prototype.html(4 状态预览)
2026-05-31 21:48:06 +08:00
iven
6d073840aa docs(wiki): 记录 Veepoo M2 BLE SDK 对接踩坑和正确流程
- wiki/index.md 症状导航新增 5 条:??语法错误、useRef白屏、扫描匹配、认证超时三层根因
- wiki/index.md 关键数字更新:Git 提交 1061 次,小程序描述补充原生分包页面
- docs/discussions/ 新增 SDK 对接流程文档:
  - 连接回调 4 次触发机制(只响应 connection:true)
  - veepooWeiXinSDKNotifyMonitorValueChange 必须在连接后注册
  - VPDeviceAck vs VPDevicepassword 字段区别
  - deviceChipStatus 布尔值兼容
  - 完整踩坑清单(9 项)
2026-05-30 23:07:03 +08:00
iven
f96e88b17b fix(mp): 检查 VPDeviceAck 而非 VPDevicepassword 判断认证结果
SDK 认证回调结构:
- VPDevicepassword = "0000"(设备密码原始值,不是认证状态)
- VPDeviceAck = "successfulVerification"(认证结果)

之前代码错误检查 VPDevicepassword 的值,永远不匹配,
导致认证成功但代码未识别 → 超时。

同时修复 deviceChipStatus 轮询:SDK 可能写入布尔值 true
而非字符串。
2026-05-30 22:56:03 +08:00
iven
dc5d689d11 fix(mp): 监听器改为 connection:true 后注册,修复 notifyBLECharacteristicValueChange:not init
根因日志:
  SDK 数据事件: {errno:1500101, errMsg:"notifyBLECharacteristicValueChange:fail:not init"}

veepooWeiXinSDKNotifyMonitorValueChange 内部调用
wx.notifyBLECharacteristicValueChange,需要蓝牙适配器已初始化。
onLoad 时适配器未初始化 → 订阅失败 → 后续所有 BLE 数据丢失。

修正:从 onLoad 移除,改到 connection:true 回调中注册
(此时适配器已初始化、连接已建立、特征值已发现并订阅)。
2026-05-30 22:43:49 +08:00
iven
695b61f850 fix(mp): 数据监听器改为 onLoad 全局注册一次
重构原生页面:
- 数据监听器和连接状态监听器在 onLoad 中全局注册一次
- _listenersRegistered 防止重复注册
- 连接流程不再注册监听器,只处理连接+认证
- 增加关键节点诊断日志(SDK函数类型、连接阶段、认证调用)
- 连接回调简化为只匹配 connection:true

注意:需要清除 dist/ 后完整重建(dev 模式不监听 native/ 变更)
2026-05-30 22:32:06 +08:00
iven
8d3b3a0491 fix(mp): 数据监听器改回连接就绪后注册 + 增加轮询诊断日志
wx.onBLECharacteristicValueChange 只支持一个回调,
SDK 合并连接函数可能内部也调用它,连接前注册会被覆盖。
改为 connection:true 回调后、认证前注册(对齐 SDK 文档顺序)。

增加每 500ms 轮询时打印 deviceChipStatus 实际值便于定位。
2026-05-30 13:59:05 +08:00
iven
bc3c056c8d fix(mp): 只在 connection:true 最终回调触发认证,修复过早认证无响应
连接回调触发 4 次(createBLEConnection → services → characteristics → connection:true),
errno:0 在第一次回调就匹配了条件,导致认证指令在特征值订阅完成前发送,
BLE 通知通道未建立,认证响应无法送达。

修正:仅 result.connection === true 时触发认证流程,
这是 SDK 合并接口的最终就绪信号。
2026-05-30 13:53:31 +08:00
iven
3e36e31cf6 fix(mp): 数据监听器移到连接前注册,修复认证超时
根因:veepooWeiXinSDKNotifyMonitorValueChange 封装
wx.onBLECharacteristicValueChange,必须在连接+订阅
特征值之前注册,否则认证响应(type=1)丢失。

流程修正:registerListeners → connect → auth
原流程:connect → callback → registerListeners → auth(错误)

同时增加 SDK 事件诊断日志和认证超时时输出
deviceChipStatus 实际值便于排查。
2026-05-30 13:43:46 +08:00
iven
ec404a3e25 fix(mp): M2 设备扫描放宽名称匹配 + vibrateShort 异步 catch
1. 扫描匹配放宽:支持 M2 / VPM / VEEPOO 三种广播名前缀
2. 增加诊断日志:SDK 加载状态 + 每个扫描到的设备完整信息
3. 扫描超时从 10s 增加到 15s
4. vibrateShort 改用 .catch() 捕获异步 rejection(DevTools 不支持 type 参数)
2026-05-30 13:36:57 +08:00
iven
7924768df3 fix(mp): veepoo-measure 缺少 useRef 导入导致页面空白
import 语句仅写了 'import React from react',
组件中使用的 useRef 未被解构导入,运行时报 ReferenceError。
2026-05-30 13:28:15 +08:00
iven
ac9896d375 fix(mp): 原生页面 ?? 运算符不兼容微信小程序运行时
_formatValues 中 values.systolic ?? '--' 改为 != null 三元表达式,
微信小程序 JS 引擎不支持 ES2020 nullish coalescing。
2026-05-30 13:15:03 +08:00
iven
a86219c8a0 fix(mp): Veepoo M2 BLE 审计 C1-C5/H1-H6 全量修复
CRITICAL 修复:
- C1: 体温测量传 { switch: boolean } 参数 + 停止指令
- C2: uploadReadings 使用正确 NormalizedReading 类型替代 as any
- C3: navigatedRef 防重入避免 React 18 Strict Mode 双触发导航
- C4: WXML gauge 空闲态用 data 预计算值替代 findIndex+匿名函数
- C5: _onReady 清除 _authTimeout 防止 Timer 泄漏

HIGH 修复:
- H1: WXML 用 results[type] 替代未声明的 measureStates
- H2: handleConnect 添加 _connecting 防重入保护
- H4: 连接回调兼容 errno:0 / errCode:0 fallback
- H5: _formatValues 零值合法显示(!== undefined && !== null)

MEDIUM:
- Storage key 添加 hms: 命名空间前缀防冲突
2026-05-30 13:11:49 +08:00
iven
432c5d96f2 chore: 清理 .gitignore + 添加 wiki/permissions.md
1. .gitignore: 补充临时调试文件(_*.txt/server_*.txt/tmp_*.txt 等)、
   graphify-out/、apps/mp-native/ 的忽略规则
2. wiki/permissions.md: 角色权限体系文档
2026-05-29 17:20:45 +08:00
iven
aa6d93129d fix(security): P0 安全修复 — Access Token 吊销 + OpenAPI 保护 + RLS 补齐 + CI 加固 + 测试修复
P0-5: Access Token 吊销机制
- 新增内存 DashMap 黑名单(token_hash → exp),支持单 token 吊销
- 密码修改/登出时自动清除用户权限缓存,强制重新认证
- 惰性清理过期条目,防止内存无限增长

P0-6: OpenAPI 端点安全
- 生产构建返回 404,仅 cfg(debug_assertions) 模式可用
- 防止 385+ API 端点 schema 对外暴露

P0-4: RLS 策略补充迁移 (m000169)
- 幂等遍历所有含 tenant_id 的表,补齐缺失的 RLS 策略
- 覆盖 m000088 之后创建的约 20 张新表

P0-3: CI 安全加固
- 移除 CI 中硬编码密码 123123,改用 postgres
- 保持 cargo audit / npm-audit 严格门禁

P0-7: AI prompt 集成测试修复
- get_active_prompt 改按 analysis_type 查找而非 name
- list_prompts 过滤参数从 category 改为 analysis_type
- 167 集成测试全部通过(原 164 passed / 3 failed)
2026-05-29 11:38:38 +08:00
iven
9a67bf80c1 refactor(health): 消除双套脱敏实现 — 统一使用 erp-core Unicode 安全版本
erp-health/src/service/masking.rs 中的 mask_id_number/mask_phone 使用
字节切片(&s[..3])而非字符切片,对非 ASCII 输入会 panic。
改为 pub use erp_core::crypto 的 Unicode 安全版本(chars().collect()),
仅保留 health 业务特有的 validate_status_transition。
2026-05-29 08:09:40 +08:00
iven
03ead44385 fix(security): P0 安全修复 — 审计日志 PII 脱敏 + AI Token 计量 + backup.sh 拼写 + CI audit
1. 审计日志 PII 脱敏: audit_service.rs 中 old_value/new_value 自动 mask
   patient/consultation/follow_up 等资源类型的 PII 字段(id_number/phone/name 等)
2. AI Token 计量: chat_handler.rs 从 Provider response 和 AgentOrchestrator 提取
   实际 input_tokens/output_tokens,替代硬编码 0
3. AI display_hints: 从 AgentOrchestrator 传递 display_hints 给前端 ChatResponse
4. backup.sh: PGDATABSE 拼写错误修复为 PGDATABASE
5. CI: npm audit 移除 || true,高危漏洞阻止合并
6. 新增六维度深度分析报告 docs/discussions/2026-05-28
2026-05-29 07:56:29 +08:00
iven
ddf5c196e4 fix(mp): 健康页滚动卡死 + 文章样式丢失 — ScrollView height:0 修复 + RichArticle 18 条 tag-style 规则
- 健康页:移除冗余"健康"标题栏,ScrollView scrollY 添加 height:0 修复 flex 高度分配
- 健康页:useReachBottom(页面级)替换为 ScrollView onScrollToLower,修复模拟器卡死
- RichArticle:新增 18 条 tag-style 规则(h1-h4/p/ul/ol/li/table/th/td 等),确保文章内容在小程序中正确渲染样式
2026-05-27 19:37:25 +08:00
iven
23cd0b14a7 refactor(ai): 用知识库 V2 替换旧版 — 删除旧页面/API,菜单路径不变
- 删除旧版 AiKnowledgePage.tsx 和 api/ai/knowledge.ts
- V2 页面接管 /health/ai-knowledge 路由(不再用 -v2 后缀)
- 迁移 168 改为 UPDATE 旧菜单名称+component(而非新增菜单)
- routeConfig 和 App.tsx 路由声明同步更新
2026-05-27 11:15:17 +08:00
iven
803a27fb84 fix(db): 知识库 V2 菜单迁移 — PL/pgSQL 安全检查 sys_menu 存在性
迁移 168 引用 sys_menu 表但在无基础 ERP 数据的数据库中不存在。
改用 DO $$ block + information_schema 检查,表不存在时安全跳过。
2026-05-27 11:04:45 +08:00
iven
a4d09269a4 fix(ai): 同步集成测试 create_prompt 签名 — 补充 analysis_type 参数
Prompt 管理 Phase 2 新增了 analysis_type 参数,但集成测试未同步。
5 处调用点全部补齐第 8 个参数。
2026-05-27 01:06:16 +08:00
iven
b0323ec89c feat(ai): 知识库 V2 菜单迁移 + 文本切片器 + 前端路由权限
- 新增迁移 000168:在 AI 知识库同级添加「知识库 V2」菜单,绑定 admin 角色
- 新增 document/chunker.rs:固定大小 + overlap 文本切片器(5 单元测试)
- 前端 routeConfig 添加 /health/ai-knowledge-v2 权限声明
- App.tsx validateRouteCoverage 补充 v2 路径
2026-05-27 00:49:27 +08:00
iven
2324d770bc feat(web): 知识库 V2 管理页面 — 列表/CRUD/上传/向量搜索测试
- knowledgeV2.ts API client: 知识库/文档/搜索完整接口
- KnowledgeV2Page: 知识库列表 + 创建/编辑/删除
- 文档列表 Drawer: 按知识库查看文档(状态/切片进度)
- 上传 Modal: Multipart 文件上传(PDF/TXT/MD/DOCX/XLSX)
- 向量搜索测试 Drawer: 输入查询 → 余弦相似度结果展示

路由: /health/ai-knowledge-v2
Phase 3 Task 16-19
2026-05-27 00:38:11 +08:00
57 changed files with 7615 additions and 1509 deletions

View File

@@ -17,7 +17,7 @@ jobs:
image: postgres:16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: 123123
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
ports:
- 5432:5432
@@ -28,9 +28,9 @@ jobs:
--health-retries 5
env:
TEST_DB_URL: postgres://postgres:123123@localhost:5432/postgres
TEST_DB_URL: postgres://postgres:postgres@localhost:5432/postgres
JWT_SECRET: test-jwt-secret-for-ci
DATABASE_URL: postgres://postgres:123123@localhost:5432/erp_ci
DATABASE_URL: postgres://postgres:postgres@localhost:5432/erp_ci
steps:
- uses: actions/checkout@v4
@@ -81,7 +81,7 @@ jobs:
run: pnpm build
- name: Security audit (npm)
run: npx npm-audit --audit-level=high || true
run: npx npm-audit --audit-level=high
miniprogram-test:
runs-on: ubuntu-latest

22
.gitignore vendored
View File

@@ -82,6 +82,28 @@ tmp/
screenshots/
server-log.txt
snapshot_*.txt
_*.txt
_server_*.txt
tmp_*.txt
direct_*.txt
server_*.txt
server_combined.txt
out.txt
_wx_login.json
.claude/settings.json
# Trace/debug JSON
trace-*.json
# Graphify knowledge graph (regenerated locally)
graphify-out/
# Native miniprogram (separate project)
apps/mp-native/
# Misc untracked
err.txt
uploads/g:/hms/.superpowers/
.claude/skills/design-handoff/node_modules/
.design/config.yml
.superpowers/

View File

@@ -0,0 +1,863 @@
/**
* Veepoo M2 原生小程序页面 — 连接 + 测量
*
* 完全脱离 Taro 框架,直接使用微信原生 API + Veepoo SDK。
* 流程严格对齐官方 Demo
* onLoad 注册全局监听器
* → scan → stopScan → connect(等待 connection:true)
* → delay 500ms → authenticate
* → SDK 事件(type=1) / Storage 轮询 → ready
*/
// eslint-disable-next-line no-undef
const { veepooBle, veepooFeature, veepooLogger } = require('./libs/veepoo-sdk');
// ── 常量 ──
var SDK_EVENT_AUTH = 1;
var SDK_EVENT_BATTERY = 2;
var SDK_EVENT_SLEEP = 4;
var SDK_EVENT_DAILY = 5;
var SDK_EVENT_TEMPERATURE = 6;
var SDK_EVENT_BLOOD_PRESSURE = 18;
var SDK_EVENT_BLOOD_OXYGEN = 31;
var SDK_EVENT_HEART_RATE = 51;
var SDK_EVENT_PRESSURE = 58;
var SDK_EVENT_AUTO_TEST = 54;
var MEASURE_TYPES = [
{ type: 'heart_rate', label: '心率', unit: 'bpm', icon: '♥', color: '#EF4444', sdkType: SDK_EVENT_HEART_RATE },
{ type: 'blood_oxygen', label: '血氧', unit: '%', icon: 'O₂', color: '#3B82F6', sdkType: SDK_EVENT_BLOOD_OXYGEN },
{ type: 'blood_pressure', label: '血压', unit: 'mmHg', icon: '↕', color: '#8B5CF6', sdkType: SDK_EVENT_BLOOD_PRESSURE },
{ type: 'temperature', label: '体温', unit: '°C', icon: 'T', color: '#F59E0B', sdkType: SDK_EVENT_TEMPERATURE },
{ type: 'pressure', label: '压力', unit: '', icon: '~', color: '#6366F1', sdkType: SDK_EVENT_PRESSURE },
];
var MEASURE_TIMEOUTS = {
heart_rate: 60000,
blood_oxygen: 60000,
blood_pressure: 120000,
temperature: 60000,
pressure: 90000,
};
var MEASURE_SETTLE_DELAY = 1500;
function _findConfig(type) {
for (var i = 0; i < MEASURE_TYPES.length; i++) {
if (MEASURE_TYPES[i].type === type) return MEASURE_TYPES[i];
}
return MEASURE_TYPES[0];
}
// ── Page ──
// eslint-disable-next-line no-undef
Page({
data: {
phase: 'idle',
deviceId: '',
deviceName: 'M2',
batteryLevel: null,
error: '',
selectedType: 'heart_rate',
selectedIcon: '♥',
selectedColor: '#EF4444',
selectedLabel: '心率',
selectedUnit: 'bpm',
measureTypes: MEASURE_TYPES,
measurePhase: 'idle',
measureProgress: 0,
measureDisplayValue: '',
measureError: '',
results: {},
hasResults: false,
// 自动测量状态
autoMeasuring: false,
autoMeasureDone: false,
autoMeasureStatus: {},
autoMeasureValues: {},
autoMeasureProgress: 0,
},
_authTimer: null,
_authTimeout: null,
_scanTimer: null,
_scanFound: null,
_measureTimer: null,
_settleTimer: null,
_lastValues: null,
_connected: false,
_eventChannel: null,
_connecting: false,
_listenersRegistered: false,
_autoQueue: null,
_autoQueueIndex: 0,
// ── 生命周期 ──
onLoad: function () {
// eslint-disable-next-line no-undef
this._eventChannel = this.getOpenerEventChannel();
// eslint-disable-next-line no-undef
wx.setNavigationBarTitle({ title: 'M2 手环测量' });
this._updateSelectedDisplay('heart_rate');
// 注意:不在 onLoad 注册 veepooWeiXinSDKNotifyMonitorValueChange
// 该函数内部会调用 wx.notifyBLECharacteristicValueChange需要蓝牙适配器已初始化。
// onLoad 时适配器未初始化 → 返回 "notifyBLECharacteristicValueChange:fail:not init"
// 监听器改在 _doConnect 的 connection:true 回调中注册。
// eslint-disable-next-line no-undef
console.log('[veepoo-native] 页面已加载');
},
onUnload: function () {
this._cleanup();
},
_cleanup: function () {
if (this._authTimer) { clearInterval(this._authTimer); this._authTimer = null; }
if (this._authTimeout) { clearTimeout(this._authTimeout); this._authTimeout = null; }
if (this._scanTimer) { clearTimeout(this._scanTimer); this._scanTimer = null; }
if (this._measureTimer) { clearTimeout(this._measureTimer); this._measureTimer = null; }
if (this._settleTimer) { clearTimeout(this._settleTimer); this._settleTimer = null; }
this._connecting = false;
if (this._connected) {
try { veepooBle.veepooWeiXinSDKloseBluetoothAdapterManager(function () {}); } catch (e) {
// eslint-disable-next-line no-undef
console.warn('[veepoo-native] 断开异常:', e);
}
this._connected = false;
}
},
// ── 全局监听器onLoad 注册一次) ──
_registerGlobalListeners: function () {
if (this._listenersRegistered) return;
this._listenersRegistered = true;
var self = this;
// SDK 数据监听 — 接收所有解析后的事件auth/measure/battery 等)
veepooBle.veepooWeiXinSDKNotifyMonitorValueChange(function (data) {
// eslint-disable-next-line no-undef
console.log('[veepoo-native] SDK 数据事件:', JSON.stringify(data).substring(0, 500));
self._handleSdkEvent(data);
});
// BLE 连接状态变化
veepooBle.veepooWeiXinSDKBLEConnectionStateChangeManager(function (res) {
// eslint-disable-next-line no-undef
console.log('[veepoo-native] 连接状态变化:', JSON.stringify(res));
if (!res.connected) {
self._connected = false;
self._connecting = false;
self._cancelPendingMeasure();
self.setData({ phase: 'disconnected' });
}
});
// eslint-disable-next-line no-undef
console.log('[veepoo-native] SDK 函数类型:', {
scanFn: typeof veepooBle.veepooWeiXinSDKStartScanDeviceAndReceiveScanningDevice,
connectFn: typeof veepooBle.veepooWeiXinSDKBleConnectionServicesCharacteristicsNotifyManager,
dataFn: typeof veepooBle.veepooWeiXinSDKNotifyMonitorValueChange,
authFn: typeof veepooFeature.veepooBlePasswordCheckManager,
});
},
_updateSelectedDisplay: function (type) {
var cfg = _findConfig(type);
this.setData({
selectedType: type,
selectedIcon: cfg.icon,
selectedColor: cfg.color,
selectedLabel: cfg.label,
selectedUnit: cfg.unit,
});
},
// ── 连接流程 ──
handleConnect: function () {
if (this.data.phase !== 'idle' && this.data.phase !== 'error' && this.data.phase !== 'disconnected') return;
if (this._connecting) return;
this._connecting = true;
this.setData({ phase: 'scanning', error: '' });
veepooLogger.setLevel(0);
var self = this;
self._scanFound = null;
veepooBle.veepooWeiXinSDKStartScanDeviceAndReceiveScanningDevice(function (res) {
var device = Array.isArray(res) ? res[0] : res;
if (!device) return;
var name = (device.localName || device.name || '').toUpperCase();
var deviceId = device.deviceId || device.mac || '';
// eslint-disable-next-line no-undef
console.log('[veepoo-native] 扫描到:', name, deviceId);
if (!self._scanFound && (name.indexOf('M2') !== -1 || name.indexOf('VPM') !== -1 || name.indexOf('VEEPOO') !== -1)) {
self._scanFound = device;
veepooBle.veepooWeiXinSDKStopSearchBleManager(function () {
self._doConnect(device);
});
}
});
this._scanTimer = setTimeout(function () {
if (!self._scanFound) {
veepooBle.veepooWeiXinSDKStopSearchBleManager(function () {});
self._connecting = false;
self.setData({ phase: 'error', error: '未找到 M2 设备,请确保手环已开机且蓝牙已开启' });
}
}, 15000);
},
_doConnect: function (device) {
this.setData({ phase: 'connecting' });
// eslint-disable-next-line no-undef
console.log('[veepoo-native] 开始连接:', device.deviceId || device.mac);
var self = this;
veepooBle.veepooWeiXinSDKBleConnectionServicesCharacteristicsNotifyManager(device, function (result) {
// eslint-disable-next-line no-undef
console.log('[veepoo-native] 连接阶段回调:', JSON.stringify(result).substring(0, 300));
// 只响应最终就绪回调connection:true
if (result.connection === true) {
self._connected = true;
self._connecting = false;
self.setData({
deviceId: device.deviceId || device.mac || '',
});
// 关键:在连接就绪后注册数据监听器
// veepooWeiXinSDKNotifyMonitorValueChange 内部会调用
// wx.notifyBLECharacteristicValueChange需要蓝牙适配器已初始化+已连接
self._registerGlobalListeners();
// eslint-disable-next-line no-undef
console.log('[veepoo-native] 连接就绪监听器已注册500ms 后发送认证');
setTimeout(function () {
// eslint-disable-next-line no-undef
console.log('[veepoo-native] 调用 veepooBlePasswordCheckManager');
veepooFeature.veepooBlePasswordCheckManager();
self.setData({ phase: 'authenticating' });
}, 500);
// Storage 轮询兜底
self._authTimer = setInterval(function () {
try {
// eslint-disable-next-line no-undef
var status = wx.getStorageSync('deviceChipStatus');
// SDK 可能写入字符串或布尔值 true
if (status === 'successfulVerification' || status === 'passTheVerification' || status === true) {
clearInterval(self._authTimer);
self._authTimer = null;
self._onReady();
}
} catch (_) { /* ignore */ }
}, 500);
self._authTimeout = setTimeout(function () {
if (self._authTimer) {
clearInterval(self._authTimer);
self._authTimer = null;
self._connecting = false;
// eslint-disable-next-line no-undef
console.error('[veepoo-native] 认证超时 deviceChipStatus=', wx.getStorageSync('deviceChipStatus'));
self.setData({ phase: 'error', error: '设备认证超时,请重新连接' });
}
}, 8000);
}
});
},
_onReady: function () {
if (this._authTimeout) { clearTimeout(this._authTimeout); this._authTimeout = null; }
this._connecting = false;
// eslint-disable-next-line no-undef
console.log('[veepoo-native] 认证成功,设备就绪');
this.setData({ phase: 'ready' });
veepooFeature.veepooReadElectricQuantityManager();
// 认证成功后自动读取 3 天睡眠数据 + 开启自动测量
this._readSleepData();
this._enableAutoMeasurement();
// 自动依次测量所有指标(面向中老年人,减少操作)
this._startAutoMeasureQueue();
},
// ── SDK 事件路由 ──
_handleSdkEvent: function (data) {
if (!data || data.type === undefined) return;
var type = data.type;
if (type === SDK_EVENT_AUTH) {
var content = data.content || {};
// eslint-disable-next-line no-undef
console.log('[veepoo-native] 认证事件: VPDeviceAck=' + content.VPDeviceAck + ' VPDevicepassword=' + content.VPDevicepassword);
// VPDeviceAck 是认证结果successfulVerification/passTheVerification
// VPDevicepassword 是设备密码原始值(如 "0000"),不是认证结果
if (content.VPDeviceAck === 'successfulVerification' || content.VPDeviceAck === 'passTheVerification') {
if (this._authTimer) { clearInterval(this._authTimer); this._authTimer = null; }
if (this._authTimeout) { clearTimeout(this._authTimeout); this._authTimeout = null; }
this._onReady();
}
return;
}
if (type === SDK_EVENT_BATTERY) {
var pct = (data.content || {}).VPDeviceElectricPercent;
if (pct !== undefined && pct !== null) {
this.setData({ batteryLevel: Number(pct) });
}
return;
}
// 睡眠数据回调type=4
if (type === SDK_EVENT_SLEEP) {
this._handleSleepEvent(data);
return;
}
// 日常数据回调type=5
if (type === SDK_EVENT_DAILY) {
// 日常数据用于历史同步,原生页面暂不处理
return;
}
// 自动测量配置回调type=54
if (type === SDK_EVENT_AUTO_TEST) {
// eslint-disable-next-line no-undef
console.log('[veepoo-native] 自动测量配置回调');
return;
}
for (var i = 0; i < MEASURE_TYPES.length; i++) {
if (MEASURE_TYPES[i].sdkType === type) {
this._handleMeasureEvent(MEASURE_TYPES[i].type, data);
return;
}
}
},
// ── 测量流程 ──
handleSelectType: function (e) {
var type = e.currentTarget.dataset.type;
if (this.data.measurePhase === 'measuring') return;
this._updateSelectedDisplay(type);
this.setData({ measureError: '' });
},
handleStartMeasure: function () {
var type = this.data.selectedType;
if (this.data.measurePhase === 'measuring') return;
if (!this._connected) {
this.setData({ measureError: '设备未连接' });
return;
}
var self = this;
self._lastValues = null;
self.setData({
measurePhase: 'measuring',
measureProgress: 0,
measureDisplayValue: '',
measureError: '',
});
self._sendMeasureCommand(type, true);
self._measureTimer = setTimeout(function () {
self._onMeasureError('测量超时,请重试');
}, MEASURE_TIMEOUTS[type] || 60000);
},
handleCancelMeasure: function () {
this._cancelPendingMeasure();
this.setData({
measurePhase: 'idle',
measureProgress: 0,
measureDisplayValue: '',
});
},
handleDisconnect: function () {
this._cleanup();
this.setData({ phase: 'idle', deviceId: '', batteryLevel: null, error: '' });
},
handleBack: function () {
var results = this.data.results;
if (Object.keys(results).length > 0) {
try {
// eslint-disable-next-line no-undef
wx.setStorageSync('hms:veepoo_measure_results', JSON.stringify(results));
} catch (_) { /* ignore */ }
}
if (this._eventChannel) {
this._eventChannel.emit('measureComplete', { results: results, count: Object.keys(results).length });
}
// eslint-disable-next-line no-undef
wx.navigateBack({ delta: 1 });
},
handleResetResult: function () {
var type = this.data.selectedType;
var newResults = Object.assign({}, this.data.results);
delete newResults[type];
this.setData({
measurePhase: 'idle',
measureProgress: 0,
measureDisplayValue: '',
measureError: '',
results: newResults,
hasResults: Object.keys(newResults).length > 0,
});
},
// ── 测量事件处理 ──
_handleMeasureEvent: function (type, data) {
// 自动测量模式下,路由到自动测量处理器
if (this.data.autoMeasuring) {
this._handleAutoMeasureEvent(type, data);
return;
}
// 手动测量模式
if (this.data.selectedType !== type || this.data.measurePhase !== 'measuring') return;
var content = data.content || {};
var self = this;
if (content.deviceBusy === true) { self._onMeasureError('设备正忙,请稍后重试'); return; }
if (content.notWear === true || data.state === 6) { self._onMeasureError('请将手环佩戴到手腕上'); return; }
if (data.state === 7) { self._onMeasureError('设备正在充电'); return; }
if (data.state === 8) { self._onMeasureError('设备电量不足'); return; }
if (type === 'pressure' && data.ack === 2) { self._onMeasureError('设备电量不足'); return; }
if (type === 'pressure' && data.ack === 3) { self._onMeasureError('设备正在测量其他数据'); return; }
if (type === 'pressure' && data.ack === 4) { self._onMeasureError('佩戴检测未通过'); return; }
var values = self._extractValues(type, content);
if (!values) return;
self._lastValues = values;
var displayVal = self._formatValues(type, values);
var progress = data.Progress !== undefined ? data.Progress : 0;
self.setData({
measureDisplayValue: displayVal,
measureProgress: Math.max(progress, 0),
});
if (progress >= 100) {
self._onMeasureSuccess(type, values);
return;
}
if (!self._settleTimer) {
self._settleTimer = setTimeout(function () {
if (self._lastValues && self.data.measurePhase === 'measuring') {
self._onMeasureSuccess(type, self._lastValues);
}
}, MEASURE_SETTLE_DELAY);
}
},
_extractValues: function (type, content) {
switch (type) {
case 'heart_rate': {
var hr = Number(content.heartRate);
return (hr >= 30 && hr <= 250) ? { heart_rate: hr } : null;
}
case 'blood_oxygen': {
var bo = Number(content.bloodOxygen);
return (bo >= 70 && bo <= 100) ? { blood_oxygen: bo } : null;
}
case 'blood_pressure': {
var high = Number(content.bloodPressureHigh);
var low = Number(content.bloodPressureLow);
return (high > 0 && low > 0) ? { systolic: high, diastolic: low } : null;
}
case 'temperature': {
var temp = Number(content.bodyTemperature);
return (temp > 30 && temp < 45) ? { temperature: temp } : null;
}
case 'pressure': {
var p = Number(content.pressure);
return (p >= 0 && p <= 100) ? { pressure: p } : null;
}
default: return null;
}
},
_formatValues: function (type, values) {
if (type === 'blood_pressure') {
return (values.systolic != null ? values.systolic : '--') + '/' + (values.diastolic != null ? values.diastolic : '--');
}
var v = Object.values(values)[0];
return (v !== undefined && v !== null) ? String(v) : '--';
},
_onMeasureSuccess: function (type, values) {
this._cancelPendingMeasure();
var result = { type: type, values: values, measuredAt: Date.now() };
var newResults = Object.assign({}, this.data.results);
newResults[type] = result;
this.setData({
measurePhase: 'success',
measureProgress: 100,
measureDisplayValue: this._formatValues(type, values),
results: newResults,
hasResults: true,
});
if (this._eventChannel) {
this._eventChannel.emit('measureResult', result);
}
},
_onMeasureError: function (msg) {
this._cancelPendingMeasure();
this.setData({ measurePhase: 'error', measureError: msg });
},
_cancelPendingMeasure: function () {
if (this._measureTimer) { clearTimeout(this._measureTimer); this._measureTimer = null; }
if (this._settleTimer) { clearTimeout(this._settleTimer); this._settleTimer = null; }
this._lastValues = null;
var type = this.data.selectedType;
if (type) this._sendMeasureCommand(type, false);
},
_sendMeasureCommand: function (type, on) {
switch (type) {
case 'heart_rate':
veepooFeature.veepooSendHeartRateTestSwitchManager({ switch: !!on });
break;
case 'blood_oxygen':
veepooFeature.veepooSendBloodOxygenControlDataManager({ switch: on ? 'start' : 'stop' });
break;
case 'blood_pressure':
veepooFeature.veepooSendReadUniversalBloodPressureDataManager({ switch: on ? 'start' : 'stop' });
break;
case 'temperature':
veepooFeature.veepooSendTemperatureMeasurementSwitchManager({ switch: !!on });
break;
case 'pressure':
veepooFeature.veepooSendPressureTestManager({ switch: !!on });
break;
}
},
// ── 睡眠数据读取 ──
_sleepResults: null,
_sleepDay: 0,
_readSleepData: function () {
this._sleepResults = [];
this._sleepDay = 0;
// eslint-disable-next-line no-undef
console.log('[veepoo-native] 开始读取睡眠数据3天');
// 依次读取 3 天睡眠
var self = this;
veepooFeature.veepooSendReadPreciseSleepManager({ day: 0 });
// 延迟读取后续天(避免并发冲突)
// eslint-disable-next-line no-undef
setTimeout(function () { veepooFeature.veepooSendReadPreciseSleepManager({ day: 1 }); }, 3000);
// eslint-disable-next-line no-undef
setTimeout(function () { veepooFeature.veepooSendReadPreciseSleepManager({ day: 2 }); }, 6000);
},
_handleSleepEvent: function (data) {
var progress = data.Progress || 0;
if (progress < 100) return;
var content = data.content || {};
var readDay = data.readDay || 0;
var totalTime = Number(content.sleepTotalTime || 0);
if (totalTime <= 0) return;
var sleepResult = {
day: readDay,
deepSleepMinutes: Number(content.deepSleepTime || 0),
lightSleepMinutes: Number(content.lightSleepTime || 0),
totalSleepMinutes: totalTime,
qualityScore: Number(content.sleepQuality || 0),
fallAsleepTime: String(content.fallAsleepTime || ''),
exitSleepTime: String(content.exitSleepTime || ''),
};
if (!this._sleepResults) this._sleepResults = [];
this._sleepResults.push(sleepResult);
// eslint-disable-next-line no-undef
console.log('[veepoo-native] 睡眠数据 day=' + readDay + ' 总时长=' + totalTime + '分钟 质量=' + sleepResult.qualityScore + '星');
// 保存到 Storage 供 Taro 页面读取
try {
// eslint-disable-next-line no-undef
wx.setStorageSync('hms:veepoo_sleep_results', JSON.stringify(this._sleepResults));
} catch (_) { /* ignore */ }
},
// ── 自动测量 ──
_enableAutoMeasurement: function () {
// eslint-disable-next-line no-undef
console.log('[veepoo-native] 开启自动测量功能');
// 开启心率自动监测
try {
veepooFeature.veepooSendSwitchSettingDataManager({
VPSettingAutomaticHRTest: 'open',
});
} catch (e) {
// eslint-disable-next-line no-undef
console.warn('[veepoo-native] 开启心率自动监测失败:', e);
}
// 开启血压自动监测
try {
veepooFeature.veepooSendSwitchSettingDataManager({
VPSettingAutomaticBPTest: 'open',
});
} catch (e) {
// eslint-disable-next-line no-undef
console.warn('[veepoo-native] 开启血压自动监测失败:', e);
}
// 开启体温自动监测
try {
veepooFeature.veepooSendSwitchSettingDataManager({
VPSettingAutomaticTemperatureTest: 'open',
});
} catch (e) {
// eslint-disable-next-line no-undef
console.warn('[veepoo-native] 开启体温自动监测失败:', e);
}
},
// ── 自动测量队列 ──
_startAutoMeasureQueue: function () {
var types = [];
var status = {};
for (var i = 0; i < MEASURE_TYPES.length; i++) {
types.push(MEASURE_TYPES[i].type);
status[MEASURE_TYPES[i].type] = 'pending';
}
this._autoQueue = types;
this._autoQueueIndex = 0;
this.setData({
autoMeasuring: true,
autoMeasureDone: false,
autoMeasureStatus: status,
autoMeasureValues: {},
autoMeasureProgress: 0,
});
// eslint-disable-next-line no-undef
console.log('[veepoo-native] 启动自动测量队列,共 ' + types.length + ' 项');
var self = this;
setTimeout(function () {
self._startNextAutoMeasure();
}, 800);
},
_startNextAutoMeasure: function () {
if (!this._autoQueue || this._autoQueueIndex >= this._autoQueue.length) {
this._onAutoMeasureComplete();
return;
}
var type = this._autoQueue[this._autoQueueIndex];
this._updateSelectedDisplay(type);
var status = Object.assign({}, this.data.autoMeasureStatus);
status[type] = 'measuring';
this.setData({
autoMeasureStatus: status,
measurePhase: 'measuring',
measureProgress: 0,
measureDisplayValue: '',
measureError: '',
});
// eslint-disable-next-line no-undef
console.log('[veepoo-native] 自动测量 [' + (this._autoQueueIndex + 1) + '/' + this._autoQueue.length + ']: ' + type);
this._lastValues = null;
this._sendMeasureCommand(type, true);
var self = this;
var timeout = MEASURE_TIMEOUTS[type] || 60000;
this._measureTimer = setTimeout(function () {
// eslint-disable-next-line no-undef
console.warn('[veepoo-native] 自动测量超时: ' + type);
self._onAutoMeasureError(type, '测量超时');
}, timeout);
},
_handleAutoMeasureEvent: function (type, data) {
if (!this._autoQueue || this._autoQueueIndex >= this._autoQueue.length) return;
if (type !== this._autoQueue[this._autoQueueIndex]) return;
var content = data.content || {};
var self = this;
if (content.deviceBusy === true) { self._onAutoMeasureError(type, '设备正忙'); return; }
if (content.notWear === true || data.state === 6) { self._onAutoMeasureError(type, '未佩戴'); return; }
if (data.state === 7) { self._onAutoMeasureError(type, '设备充电中'); return; }
if (data.state === 8) { self._onAutoMeasureError(type, '电量不足'); return; }
if (type === 'pressure' && data.ack === 2) { self._onAutoMeasureError(type, '电量不足'); return; }
if (type === 'pressure' && data.ack === 3) { self._onAutoMeasureError(type, '设备正忙'); return; }
if (type === 'pressure' && data.ack === 4) { self._onAutoMeasureError(type, '未佩戴'); return; }
var values = self._extractValues(type, content);
if (!values) return;
self._lastValues = values;
var progress = data.Progress !== undefined ? data.Progress : 0;
self.setData({ measureProgress: Math.max(progress, 0) });
if (progress >= 100) {
self._onAutoMeasureSuccess(type, values);
return;
}
if (!self._settleTimer) {
self._settleTimer = setTimeout(function () {
if (self._lastValues && self.data.autoMeasuring) {
self._onAutoMeasureSuccess(type, self._lastValues);
}
}, MEASURE_SETTLE_DELAY);
}
},
_onAutoMeasureSuccess: function (type, values) {
this._cancelPendingMeasure();
var result = { type: type, values: values, measuredAt: Date.now() };
var newResults = Object.assign({}, this.data.results);
newResults[type] = result;
var status = Object.assign({}, this.data.autoMeasureStatus);
status[type] = 'done';
var newValues = Object.assign({}, this.data.autoMeasureValues);
newValues[type] = this._formatValues(type, values);
var doneCount = 0;
var keys = Object.keys(status);
for (var i = 0; i < keys.length; i++) {
if (status[keys[i]] === 'done' || status[keys[i]] === 'error') doneCount++;
}
var progress = Math.round((doneCount / this._autoQueue.length) * 100);
// eslint-disable-next-line no-undef
console.log('[veepoo-native] 自动测量完成: ' + type + ' = ' + newValues[type] + ' (' + progress + '%)');
this.setData({
results: newResults,
hasResults: true,
autoMeasureStatus: status,
autoMeasureValues: newValues,
autoMeasureProgress: progress,
measurePhase: 'idle',
});
this._autoQueueIndex++;
var self = this;
setTimeout(function () {
self._startNextAutoMeasure();
}, 800);
},
_onAutoMeasureError: function (type, msg) {
this._cancelPendingMeasure();
var status = Object.assign({}, this.data.autoMeasureStatus);
status[type] = 'error';
var doneCount = 0;
var keys = Object.keys(status);
for (var i = 0; i < keys.length; i++) {
if (status[keys[i]] === 'done' || status[keys[i]] === 'error') doneCount++;
}
var progress = Math.round((doneCount / this._autoQueue.length) * 100);
// eslint-disable-next-line no-undef
console.warn('[veepoo-native] 自动测量失败: ' + type + ' - ' + msg + ' (' + progress + '%)');
this.setData({
autoMeasureStatus: status,
autoMeasureProgress: progress,
measurePhase: 'idle',
});
this._autoQueueIndex++;
var self = this;
setTimeout(function () {
self._startNextAutoMeasure();
}, 500);
},
_onAutoMeasureComplete: function () {
// eslint-disable-next-line no-undef
console.log('[veepoo-native] 自动测量全部完成');
var results = this.data.results;
if (Object.keys(results).length > 0) {
try {
// eslint-disable-next-line no-undef
wx.setStorageSync('hms:veepoo_measure_results', JSON.stringify(results));
} catch (_) { /* ignore */ }
}
this.setData({
autoMeasureDone: true,
autoMeasuring: false,
autoMeasureProgress: 100,
measurePhase: 'idle',
});
},
handleCancelAutoMeasure: function () {
this._cancelPendingMeasure();
this._autoQueue = null;
var results = this.data.results;
if (Object.keys(results).length > 0) {
try {
// eslint-disable-next-line no-undef
wx.setStorageSync('hms:veepoo_measure_results', JSON.stringify(results));
} catch (_) { /* ignore */ }
}
this.setData({
autoMeasuring: false,
autoMeasureDone: false,
measurePhase: 'idle',
});
},
});

View File

@@ -0,0 +1,6 @@
{
"navigationBarTitleText": "M2 手环测量",
"navigationBarBackgroundColor": "#FFFFFF",
"navigationBarTextStyle": "black",
"backgroundColor": "#F5F5F4"
}

View File

@@ -0,0 +1,215 @@
<!--
Veepoo M2 原生小程序页面 — 连接 + 测量
设计原型: docs/design/veepoo-measure-prototype.html
完全匹配 SDK 官方 Demo 流程,不依赖 Taro
-->
<!-- ═══ 未连接 / 错误 / 断开 ═══ -->
<block wx:if="{{phase === 'idle' || phase === 'error' || phase === 'disconnected'}}">
<view class="connect-screen">
<view class="connect-anim">
<view class="connect-ring"></view>
<view class="connect-center">
<text class="connect-bt">BT</text>
</view>
</view>
<text class="connect-title">M2 手环健康测量</text>
<text class="connect-hint">请确保手环已开机且蓝牙已开启</text>
<view wx:if="{{error}}" class="connect-error">
<text class="connect-error-text">{{error}}</text>
</view>
<view class="connect-btn-wrap">
<view class="btn-primary" bindtap="handleConnect">
{{phase === 'error' ? '重新连接' : phase === 'disconnected' ? '重新连接' : '连接 M2 手环'}}
</view>
</view>
<view wx:if="{{hasResults}}" class="connect-back">
<view class="btn-text" bindtap="handleBack">查看测量结果并返回</view>
</view>
</view>
</block>
<!-- ═══ 连接中(扫描/连接/认证) ═══ -->
<block wx:elif="{{phase === 'scanning' || phase === 'connecting' || phase === 'authenticating'}}">
<view class="connect-screen">
<view class="connect-anim">
<view class="connect-ring connect-ring--active"></view>
<view class="connect-center">
<text class="connect-bt">BT</text>
</view>
</view>
<text class="connect-title">
{{phase === 'scanning' ? '正在搜索 M2 手环...' : phase === 'connecting' ? '正在连接...' : '正在认证...'}}
</text>
<text class="connect-hint">请确保手环已开机且靠近手机</text>
</view>
</block>
<!-- ═══ 自动测量中 / 自动测量完成 ═══ -->
<block wx:elif="{{phase === 'ready' && (autoMeasuring || autoMeasureDone)}}">
<view class="measure-page">
<!-- 设备状态栏 -->
<view class="device-bar">
<view class="device-bar__left">
<view class="device-bar__dot"></view>
<text class="device-bar__name">{{deviceName}}</text>
<text wx:if="{{batteryLevel !== null}}" class="device-bar__battery">{{batteryLevel}}%</text>
</view>
<view wx:if="{{autoMeasuring}}" class="device-bar__disconnect" bindtap="handleCancelAutoMeasure">取消</view>
</view>
<view class="auto-measure">
<!-- 标题 -->
<view class="auto-measure__header">
<text class="auto-measure__title">{{autoMeasureDone ? '✓ 测量完成!' : '正在自动测量...'}}</text>
<text wx:if="{{!autoMeasureDone}}" class="auto-measure__subtitle">请保持手环佩戴,无需任何操作</text>
</view>
<!-- 指标列表 -->
<view class="auto-measure__list">
<view
wx:for="{{measureTypes}}"
wx:key="type"
class="auto-item {{autoMeasureStatus[item.type] === 'done' ? 'auto-item--done' : autoMeasureStatus[item.type] === 'measuring' ? 'auto-item--active' : autoMeasureStatus[item.type] === 'error' ? 'auto-item--error' : ''}}"
>
<view class="auto-item__left">
<view class="auto-item__icon-wrap" style="background: {{autoMeasureStatus[item.type] === 'done' ? item.color : autoMeasureStatus[item.type] === 'error' ? '#ccc' : item.color}}">
<text class="auto-item__icon">{{autoMeasureStatus[item.type] === 'done' ? '✓' : autoMeasureStatus[item.type] === 'error' ? '✕' : item.icon}}</text>
</view>
<text class="auto-item__label">{{item.label}}</text>
</view>
<view class="auto-item__right">
<block wx:if="{{autoMeasureStatus[item.type] === 'done'}}">
<text class="auto-item__value" style="color: {{item.color}}">{{autoMeasureValues[item.type]}}</text>
<text wx:if="{{item.unit}}" class="auto-item__unit">{{item.unit}}</text>
</block>
<text wx:elif="{{autoMeasureStatus[item.type] === 'measuring'}}" class="auto-item__status auto-item__status--active">测量中...</text>
<text wx:elif="{{autoMeasureStatus[item.type] === 'error'}}" class="auto-item__status auto-item__status--error">已跳过</text>
<text wx:else class="auto-item__status">等待中</text>
</view>
</view>
</view>
<!-- 进度条 -->
<view class="auto-progress">
<view class="auto-progress__bar">
<view class="auto-progress__fill" style="width: {{autoMeasureProgress}}%"></view>
</view>
<text class="auto-progress__text">{{autoMeasureProgress}}%</text>
</view>
<!-- 免责声明 -->
<view class="disclaimer">
<text class="disclaimer__text">测量数据仅供参考,不作为医学诊断依据。如有不适请及时就医。</text>
</view>
<!-- 操作按钮 -->
<view class="actions">
<block wx:if="{{autoMeasureDone}}">
<view class="btn btn--primary" bindtap="handleBack">查看结果并返回</view>
</block>
<block wx:elif="{{autoMeasuring}}">
<view class="btn btn--text" bindtap="handleCancelAutoMeasure">取消自动测量</view>
</block>
</view>
</view>
</view>
</block>
<!-- ═══ 就绪 + 手动测量 ═══ -->
<block wx:elif="{{phase === 'ready'}}">
<view class="measure-page">
<!-- 设备状态栏 -->
<view class="device-bar">
<view class="device-bar__left">
<view class="device-bar__dot"></view>
<text class="device-bar__name">{{deviceName}}</text>
<text wx:if="{{batteryLevel !== null}}" class="device-bar__battery">{{batteryLevel}}%</text>
</view>
<view class="device-bar__disconnect" bindtap="handleDisconnect">断开</view>
</view>
<!-- 指标选择器 — 药丸式 -->
<scroll-view class="selector" scroll-x enhanced show-scrollbar="{{false}}">
<view
wx:for="{{measureTypes}}"
wx:key="type"
class="selector__pill {{selectedType === item.type ? 'selector__pill--active' : ''}} {{results[item.type] ? 'selector__pill--done' : ''}}"
data-type="{{item.type}}"
bindtap="handleSelectType"
>
<view class="selector__icon-wrap" style="background: {{item.color}}">
<text class="selector__icon">{{item.icon}}</text>
</view>
<text class="selector__label">{{item.label}}</text>
</view>
</scroll-view>
<!-- 仪表盘区域 -->
<view class="gauge-section">
<view class="gauge {{measurePhase === 'measuring' ? 'gauge--measuring' : ''}}">
<!-- SVG 圆环 -->
<view class="gauge__ring-wrap">
<view class="gauge__ring-bg"></view>
<view class="gauge__ring-progress" style="background: conic-gradient({{selectedColor}} {{measureProgress * 3.6}}deg, #E8E2DC 0deg);"></view>
<view class="gauge__center">
<!-- 空闲 -->
<block wx:if="{{measurePhase === 'idle'}}">
<text class="gauge__icon-lg" style="color: {{selectedColor}}">{{selectedIcon}}</text>
<text class="gauge__hint">点击下方按钮开始</text>
</block>
<!-- 测量中 -->
<block wx:elif="{{measurePhase === 'measuring'}}">
<text wx:if="{{measureDisplayValue}}" class="gauge__value" style="color: {{selectedColor}}">{{measureDisplayValue}}</text>
<text wx:else class="gauge__loading">测量中...</text>
<text wx:if="{{measureDisplayValue}}" class="gauge__unit">{{selectedUnit}}</text>
</block>
<!-- 成功 -->
<block wx:elif="{{measurePhase === 'success'}}">
<text class="gauge__value" style="color: {{selectedColor}}">{{measureDisplayValue}}</text>
<text class="gauge__unit">{{selectedUnit}}</text>
</block>
<!-- 错误 -->
<block wx:elif="{{measurePhase === 'error'}}">
<text class="gauge__err">!</text>
<text class="gauge__err-text">{{measureError}}</text>
</block>
</view>
</view>
</view>
<!-- 进度条 -->
<view wx:if="{{measurePhase === 'measuring' && measureProgress > 0}}" class="progress-bar">
<view class="progress-bar__fill" style="width: {{measureProgress}}%; background: {{selectedColor}};"></view>
</view>
</view>
<!-- 免责声明 -->
<view class="disclaimer">
<text class="disclaimer__text">测量数据仅供参考,不作为医学诊断依据。如有不适请及时就医。</text>
</view>
<!-- 操作按钮 -->
<view class="actions">
<block wx:if="{{measurePhase === 'idle'}}">
<view class="btn btn--primary" style="background: {{selectedColor}}; box-shadow: 0 4px 16px rgba(0,0,0,0.15);" bindtap="handleStartMeasure">
开始测量{{selectedLabel}}
</view>
</block>
<block wx:elif="{{measurePhase === 'measuring'}}">
<view class="btn btn--secondary" bindtap="handleCancelMeasure">停止测量</view>
<view class="btn btn--text" bindtap="handleBack">完成并查看结果</view>
</block>
<block wx:elif="{{measurePhase === 'success'}}">
<view class="btn btn--primary" style="background: {{selectedColor}}; box-shadow: 0 4px 16px rgba(0,0,0,0.15);" bindtap="handleResetResult">重新测量</view>
<view class="btn btn--secondary" bindtap="handleBack">完成并查看结果</view>
</block>
<block wx:elif="{{measurePhase === 'error'}}">
<view class="btn btn--primary" style="background: {{selectedColor}}; box-shadow: 0 4px 16px rgba(0,0,0,0.15);" bindtap="handleStartMeasure">重新测量</view>
</block>
</view>
</view>
</block>

View File

@@ -0,0 +1,619 @@
/**
* Veepoo M2 原生页面样式
* 设计原型: docs/design/veepoo-measure-prototype.html
* 复刻小程序 design token
*/
page {
--pri: #C4623A;
--pri-l: #F0DDD4;
--bg: #F5F0EB;
--card: #FFFFFF;
--tx: #2D2A26;
--tx2: #5A554F;
--tx3: #78716C;
--bd: #E8E2DC;
--acc: #5B7A5E;
--acc-l: #E8F0E8;
--dan: #B54A4A;
--dan-l: #FDEAEA;
background: var(--bg);
min-height: 100vh;
}
/* ═══════════════════════════════════════
连接页面(未连接/连接中/错误)
═══════════════════════════════════════ */
.connect-screen {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 0 40px;
}
.connect-anim {
position: relative;
width: 120px;
height: 120px;
margin-bottom: 28px;
}
.connect-ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 3px solid var(--pri);
animation: pulse-ring 2s ease-out infinite;
}
.connect-ring--active {
border-color: var(--pri);
animation: pulse-ring 1.5s ease-out infinite;
}
.connect-center {
position: absolute;
inset: 20px;
border-radius: 50%;
background: var(--pri);
display: flex;
align-items: center;
justify-content: center;
}
.connect-bt {
color: #fff;
font-size: 20px;
font-weight: 700;
}
.connect-title {
font-family: Georgia, 'Times New Roman', serif;
font-size: 22px;
font-weight: 700;
color: var(--tx);
margin-bottom: 8px;
line-height: 1.3;
}
.connect-hint {
font-size: 14px;
color: var(--tx3);
margin-bottom: 32px;
text-align: center;
}
.connect-error {
margin-bottom: 16px;
text-align: center;
}
.connect-error-text {
font-size: 14px;
color: var(--dan);
}
.connect-btn-wrap {
width: 200px;
}
.connect-back {
margin-top: 16px;
}
@keyframes pulse-ring {
0% { transform: scale(1); opacity: 1; }
100% { transform: scale(1.4); opacity: 0; }
}
/* ═══════════════════════════════════════
测量页面(就绪态)
═══════════════════════════════════════ */
/* ═══ 自动测量进度 ═══ */
.auto-measure {
padding: 24px 20px 40px;
}
.auto-measure__header {
text-align: center;
margin-bottom: 28px;
}
.auto-measure__title {
display: block;
font-family: Georgia, 'Times New Roman', serif;
font-size: 24px;
font-weight: 700;
color: var(--tx);
margin-bottom: 8px;
}
.auto-measure__subtitle {
display: block;
font-size: 14px;
color: var(--tx3);
}
.auto-measure__list {
background: var(--card);
border-radius: 16px;
box-shadow: 0 2px 12px rgba(45,42,38,0.06);
overflow: hidden;
}
.auto-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--bd);
transition: background 200ms ease;
}
.auto-item:last-child {
border-bottom: none;
}
.auto-item--active {
background: rgba(196,98,58,0.04);
}
.auto-item--done {
background: rgba(91,122,94,0.04);
}
.auto-item--error {
opacity: 0.6;
}
.auto-item__left {
display: flex;
align-items: center;
gap: 14px;
}
.auto-item__icon-wrap {
width: 40px;
height: 40px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.auto-item--done .auto-item__icon-wrap {
background: var(--acc) !important;
}
.auto-item--error .auto-item__icon-wrap {
background: var(--tx3) !important;
}
.auto-item__icon {
font-size: 18px;
color: #fff;
font-weight: 700;
}
.auto-item__label {
font-size: 16px;
font-weight: 600;
color: var(--tx);
}
.auto-item__right {
display: flex;
align-items: baseline;
gap: 4px;
}
.auto-item__value {
font-family: Georgia, 'Times New Roman', serif;
font-size: 22px;
font-weight: 700;
}
.auto-item__unit {
font-size: 13px;
color: var(--tx3);
}
.auto-item__status {
font-size: 14px;
color: var(--tx3);
}
.auto-item__status--active {
color: var(--pri);
font-weight: 600;
}
.auto-item__status--error {
color: var(--tx3);
}
/* ── 自动测量进度条 ── */
.auto-progress {
display: flex;
align-items: center;
gap: 12px;
margin-top: 24px;
padding: 0 4px;
}
.auto-progress__bar {
flex: 1;
height: 6px;
background: var(--bd);
border-radius: 3px;
overflow: hidden;
}
.auto-progress__fill {
height: 100%;
background: var(--pri);
border-radius: 3px;
transition: width 0.5s ease-out;
}
.auto-progress__text {
font-size: 14px;
font-weight: 600;
color: var(--pri);
min-width: 36px;
text-align: right;
}
.measure-page {
min-height: 100vh;
padding-bottom: 40px;
}
/* ── 设备状态栏 ── */
.device-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 20px;
background: var(--card);
border-bottom: 1px solid var(--bd);
}
.device-bar__left {
display: flex;
align-items: center;
gap: 8px;
}
.device-bar__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--acc);
}
.device-bar__name {
font-size: 16px;
font-weight: 600;
color: var(--tx);
}
.device-bar__battery {
font-size: 13px;
color: var(--tx3);
margin-left: 4px;
}
.device-bar__disconnect {
font-size: 13px;
color: var(--tx3);
padding: 6px 12px;
background: transparent;
border: 1px solid var(--bd);
border-radius: 999px;
}
/* ── 指标选择器(药丸式) ── */
.selector {
display: flex;
white-space: nowrap;
padding: 16px 20px;
gap: 8px;
}
.selector__pill {
flex-shrink: 0;
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 10px 14px;
border-radius: 16px;
position: relative;
min-width: 64px;
transition: all 200ms ease;
}
.selector__pill--active {
background: var(--card);
box-shadow: 0 2px 12px rgba(45,42,38,0.10);
}
.selector__pill--done::after {
content: '✓';
position: absolute;
top: 4px;
right: 6px;
font-size: 10px;
color: #fff;
background: var(--acc);
width: 16px;
height: 16px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.selector__icon-wrap {
width: 36px;
height: 36px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
transition: transform 200ms ease;
}
.selector__pill--active .selector__icon-wrap {
transform: scale(1.15);
}
.selector__icon {
font-size: 18px;
color: #fff;
}
.selector__label {
font-size: 13px;
color: var(--tx3);
}
.selector__pill--active .selector__label {
color: var(--tx);
font-weight: 600;
}
/* ── 仪表盘 ── */
.gauge-section {
display: flex;
flex-direction: column;
align-items: center;
padding: 16px 0 24px;
}
.gauge {
position: relative;
}
.gauge--measuring {
animation: gauge-breathe 2s ease-in-out infinite;
}
@keyframes gauge-breathe {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.03); }
}
.gauge__ring-wrap {
position: relative;
width: 220px;
height: 220px;
}
.gauge__ring-bg {
position: absolute;
inset: 0;
border-radius: 50%;
border: 10px solid var(--bd);
box-sizing: border-box;
}
.gauge__ring-progress {
position: absolute;
inset: 0;
border-radius: 50%;
}
.gauge__center {
position: absolute;
inset: 20px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: var(--bg);
border-radius: 50%;
}
.gauge__icon-lg {
font-size: 40px;
margin-bottom: 8px;
}
.gauge__hint {
font-size: 13px;
color: var(--tx3);
text-align: center;
}
.gauge__value {
font-family: Georgia, 'Times New Roman', serif;
font-size: 52px;
font-weight: 700;
line-height: 1;
}
.gauge__unit {
font-size: 14px;
color: var(--tx3);
margin-top: 4px;
}
.gauge__loading {
font-size: 16px;
color: var(--tx2);
}
.gauge__err {
font-size: 36px;
color: var(--dan);
font-weight: 700;
}
.gauge__err-text {
font-size: 13px;
color: var(--tx2);
text-align: center;
}
/* ── 进度条 ── */
.progress-bar {
width: 240px;
height: 4px;
background: var(--bd);
border-radius: 2px;
margin-top: 16px;
overflow: hidden;
}
.progress-bar__fill {
height: 100%;
border-radius: 2px;
transition: width 0.3s ease-out;
}
/* ── 免责声明 ── */
.disclaimer {
text-align: center;
padding: 0 20px;
margin-bottom: 16px;
}
.disclaimer__text {
font-size: 11px;
color: var(--tx3);
line-height: 1.5;
}
/* ── 操作按钮 ── */
.actions {
padding: 0 20px;
display: flex;
flex-direction: column;
gap: 12px;
}
.btn {
height: 52px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 600;
text-align: center;
transition: opacity 150ms;
}
.btn:active {
opacity: 0.85;
}
.btn--primary {
background: var(--pri);
color: #fff;
box-shadow: 0 4px 16px rgba(196,98,58,0.3);
}
.btn--secondary {
background: var(--card);
color: var(--tx);
border: 1px solid var(--bd);
}
.btn--text {
background: transparent;
color: var(--tx3);
height: 44px;
font-size: 14px;
}
/* ═══ 旧版兼容样式 ═══ */
.btn-primary {
background: var(--pri);
color: #fff;
height: 52px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 600;
box-shadow: 0 4px 16px rgba(196,98,58,0.3);
}
.btn-primary:active { opacity: 0.85; }
.btn-secondary {
background: var(--card);
color: var(--tx);
height: 52px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 600;
border: 1px solid var(--bd);
}
.btn-secondary:active { opacity: 0.85; }
.btn-text {
background: transparent;
color: var(--tx3);
height: 44px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
}
.btn-large { margin: 0; }
/* 旧版 header/selector/gauge 兼容 */
.header { display: none; }
.header-device { display: none; }
.header-dot { display: none; }
.header-name { display: none; }
.header-battery { display: none; }
.header-disconnect { display: none; }
.selector-item { display: none; }
.selector-icon { display: none; }
.selector-label { display: none; }
.selector-check { display: none; }
.gauge-circle { display: none; }
.gauge-icon { display: none; }
.gauge-hint { display: none; }
.gauge-value { display: none; }
.gauge-loading { display: none; }
.gauge-err { display: none; }
.gauge-err-text { display: none; }
.gauge-progress-bar { display: none; }
.gauge-progress-fill { display: none; }
.assessment { display: none; }
.assessment-text { display: none; }
.disclaimer-text { display: none; }
.measure-error { display: none; }
.measure-error-text { display: none; }

View File

@@ -7,6 +7,28 @@ interface RichArticleProps {
className?: string;
}
const TAG_STYLE = JSON.stringify({
h1: 'font-size:20px;font-weight:700;color:#2D2A26;margin:16px 0 8px',
h2: 'font-size:18px;font-weight:700;color:#2D2A26;margin:16px 0 8px',
h3: 'font-size:16px;font-weight:700;color:#2D2A26;margin:16px 0 8px',
h4: 'font-size:15px;font-weight:600;color:#2D2A26;margin:12px 0 6px',
p: 'font-size:16px;color:#2D2A26;line-height:1.85;margin-bottom:12px',
ul: 'padding-left:20px;margin:8px 0;font-size:16px;line-height:1.9;color:#2D2A26',
ol: 'padding-left:20px;margin:8px 0;font-size:16px;line-height:1.9;color:#2D2A26',
li: 'margin-bottom:4px',
blockquote: 'border-left:3px solid #C4623A;padding:6px 12px;color:#5A554F;margin:12px 0',
strong: 'font-weight:700;color:#2D2A26',
em: 'font-style:italic',
code: 'background:#F5F0EB;padding:2px 6px;border-radius:4px;font-size:14px;color:#C4623A',
pre: 'background:#F5F0EB;padding:12px;border-radius:8px;margin:14px 0;overflow-x:auto',
table: 'width:100%;border-collapse:collapse;margin:8px 0;font-size:14px',
th: 'border:1px solid #E8E2DC;padding:6px 8px;background:#FAF8F5;font-weight:600;text-align:left',
td: 'border:1px solid #E8E2DC;padding:6px 8px',
hr: 'border:none;border-top:1px dashed #D1D5DB;margin:14px 0',
img: 'max-width:100%;border-radius:8px;margin:8px 0;display:block',
a: 'color:#C4623A;text-decoration:none',
});
function prepareHtml(raw: string): string {
return sanitizeHtml(raw);
}
@@ -23,7 +45,7 @@ function RichArticle({ html, className }: RichArticleProps) {
lazy-load
selectable
container-style="font-size:16px;color:#5A554F;line-height:1.8;word-break:break-word"
tag-style='{"img":"max-width:100%;border-radius:8px;margin:12px auto;display:block","a":"color:#C4623A;text-decoration:none"}'
tag-style={TAG_STYLE}
/>
</View>
);

View File

@@ -2,515 +2,131 @@
@import '../../styles/mixins.scss';
.health-page {
padding-bottom: calc(var(--tk-tabbar-space) + env(safe-area-inset-bottom));
}
/* ─── 页头 ─── */
.health-header {
margin-bottom: var(--tk-gap-sm);
display: flex;
align-items: baseline;
justify-content: space-between;
}
.health-title {
@include serif-number;
font-size: var(--tk-font-h1);
font-weight: 700;
color: $tx;
letter-spacing: -0.02em;
}
.health-date {
font-size: var(--tk-font-cap);
color: $tx3;
}
/* ─── 今日体征 hero 卡片 ─── */
.vitals-grid {
margin-bottom: var(--tk-section-gap);
background: linear-gradient(135deg, $card 60%, $pri-l);
border-radius: var(--tk-card-radius);
box-shadow: $shadow-md;
padding: var(--tk-card-padding);
/* 覆盖 ContentCard 默认 padding/margin */
&.content-card {
padding: var(--tk-card-padding);
margin-bottom: var(--tk-section-gap);
}
}
.vitals-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--tk-gap-md);
}
.vitals-title {
font-size: var(--tk-font-body-sm);
font-weight: 600;
color: $tx2;
letter-spacing: 0.04em;
}
.vitals-badge {
font-size: var(--tk-font-micro);
color: $acc;
background: $acc-l;
padding: 3px 10px;
border-radius: $r-pill;
font-weight: 500;
}
.vitals-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--tk-gap-sm);
}
.vital-cell {
text-align: center;
padding: var(--tk-gap-md) var(--tk-gap-sm);
border-radius: $r-sm;
background: $bg;
&:active {
opacity: var(--tk-touch-feedback-opacity);
}
}
.vital-value {
@include serif-number;
font-size: var(--tk-font-num);
font-weight: 700;
color: $tx;
font-variant-numeric: tabular-nums;
display: block;
line-height: 1.1;
}
.vital-unit {
font-size: var(--tk-font-micro);
color: $tx3;
display: block;
margin-top: 2px;
}
.vital-label {
font-size: var(--tk-font-cap);
color: $tx2;
font-weight: 500;
display: block;
margin-top: 6px;
}
.vital-cell.vital-warn {
background: $wrn-l;
.vital-value {
color: $wrn;
}
}
.vital-cell.vital-ok {
.vital-value {
color: $acc;
}
}
/* ─── 快捷入口 — 横排 4 格图标 ─── */
.quick-entries {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--tk-gap-xs);
margin-bottom: var(--tk-section-gap);
}
.quick-entry {
display: flex;
flex-direction: column;
height: 100vh;
}
/* ─── 分类标签 ─── */
.health-categories {
white-space: nowrap;
padding: var(--tk-gap-xs) var(--tk-page-padding);
margin-bottom: var(--tk-gap-xs);
flex-shrink: 0;
}
.health-cat-tab {
display: inline-flex;
align-items: center;
gap: var(--tk-gap-xs);
min-height: var(--tk-touch-min);
justify-content: center;
padding: var(--tk-gap-sm) 0;
&:active {
opacity: var(--tk-touch-feedback-opacity);
}
}
.quick-icon {
width: 44px;
height: 44px;
border-radius: $r-sm;
@include flex-center;
}
.quick-icon-text {
font-size: 18px;
font-weight: 600;
}
.quick-icon--input {
background: $pri-l;
.quick-icon-text {
color: $pri;
}
}
.quick-icon--trend {
background: $doc-pri-l;
.quick-icon-text {
color: $doc-pri;
}
}
.quick-icon--report {
background: $acc-l;
.quick-icon-text {
color: $acc;
}
}
.quick-icon--med {
background: $wrn-l;
.quick-icon-text {
color: $wrn;
}
}
.quick-label {
font-size: var(--tk-font-cap);
color: $tx2;
font-weight: 500;
}
/* ─── 告警横幅 ─── */
.alert-hint {
display: flex;
align-items: center;
gap: var(--tk-gap-sm);
margin-bottom: var(--tk-section-gap);
background: $dan-l;
border-radius: $r-sm;
/* 覆盖 ContentCard 默认样式 */
&.content-card {
background: $dan-l;
box-shadow: none;
border: none;
}
&:active {
opacity: var(--tk-touch-feedback-opacity);
}
}
.alert-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: $dan;
flex-shrink: 0;
}
.alert-text {
flex: 1;
padding: 8px 18px;
margin-right: 8px;
font-size: var(--tk-font-body-sm);
font-weight: 500;
color: $dan;
}
.alert-arrow {
font-size: var(--tk-font-body);
color: $dan;
flex-shrink: 0;
opacity: 0.6;
}
/* ─── 趋势图 ─── */
.trend-section {
margin-bottom: var(--tk-gap-sm);
}
.section-title {
@include section-title;
}
.trend-empty {
text-align: center;
}
.trend-empty-text {
font-size: var(--tk-font-cap);
font-weight: 400;
color: $tx2;
background: $surface-alt;
border-radius: 20px;
transition: all 0.2s;
&--active {
background: var(--tk-pri);
color: $white;
font-weight: 600;
box-shadow: 0 2px 8px rgba(196, 98, 58, 0.2);
}
}
.trend-chart {
padding: var(--tk-gap-md);
}
.trend-bars {
display: flex;
align-items: flex-end;
height: 120px;
background: $bg;
border-radius: $r-sm;
padding: var(--tk-gap-sm) var(--tk-gap-xs);
gap: 0;
position: relative;
}
.trend-threshold-line {
position: absolute;
left: 8px;
right: 8px;
border-top: 1.5px dashed $wrn;
opacity: 0.5;
pointer-events: none;
}
.trend-threshold-label {
position: absolute;
right: 0;
top: -16px;
font-size: var(--tk-font-micro);
color: $wrn;
opacity: 0.7;
}
.trend-bar-col {
/* ─── 可滚动内容区 ─── */
.health-scroll {
flex: 1;
overflow: hidden;
/* 微信小程序 ScrollView scrollY 需要显式高度 */
height: 0; /* flex:1 + height:0 让 flex 布局正确分配剩余高度 */
}
/* ─── 文章列表 ─── */
.health-article-list {
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
justify-content: flex-end;
}
.trend-bar {
width: 24px;
border-radius: $r-xs $r-xs 0 0;
min-height: 6px;
&.trend-bar-normal {
background: var(--tk-pri);
opacity: 0.75;
}
&.trend-bar-warn {
background: $wrn;
opacity: 0.85;
}
}
.trend-bar-label {
font-size: var(--tk-font-micro);
color: var(--tk-text-secondary);
margin-top: var(--tk-gap-2xs);
}
/* ─── BLE 设备卡片 ─── */
.device-section {
margin-bottom: var(--tk-gap-sm);
}
.device-card {
display: flex;
align-items: center;
gap: var(--tk-gap-sm);
padding: 0 var(--tk-page-padding) var(--tk-gap-lg);
&:active {
opacity: var(--tk-touch-feedback-opacity);
.content-card {
display: flex;
gap: 14px;
}
}
.device-icon {
width: 48px;
height: 48px;
border-radius: $r-sm;
background: var(--tk-pri-l);
@include flex-center;
flex-shrink: 0;
}
.device-icon-text {
font-size: var(--tk-font-body);
}
.device-info {
.health-article-body {
flex: 1;
display: flex;
min-width: 0;
}
.device-name {
font-size: var(--tk-font-cap);
font-weight: 500;
color: $tx;
display: block;
}
.device-desc {
font-size: var(--tk-font-cap);
color: $acc;
display: block;
}
.device-arrow {
font-size: var(--tk-font-cap);
color: var(--tk-text-secondary);
flex-shrink: 0;
}
/* ─── 健康资讯入口 ─── */
.article-entry {
&:active {
opacity: var(--tk-touch-feedback-opacity);
}
}
.article-entry-text {
font-size: var(--tk-font-body-sm);
color: $tx;
font-weight: 500;
}
/* ─── AI 建议卡片 ─── */
.ai-suggestion-card {
background: linear-gradient(135deg, #F0F7F0 0%, $acc-l 100%);
border-radius: $r;
padding: var(--tk-card-padding);
margin-bottom: var(--tk-section-gap);
box-shadow: none;
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, $acc, $acc 60%, transparent);
border-radius: 3px 3px 0 0;
}
}
.ai-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--tk-gap-md);
}
.ai-card-title {
font-size: var(--tk-font-body-sm);
font-weight: 600;
color: $acc;
}
.ai-card-count {
font-size: var(--tk-font-micro);
color: $acc;
opacity: 0.7;
}
.ai-suggestion-item {
padding: var(--tk-gap-sm) 0;
border-bottom: 1px solid rgba($acc, 0.12);
&:last-child {
border-bottom: none;
}
}
.ai-suggestion-main {
display: flex;
align-items: flex-start;
gap: var(--tk-gap-xs);
&:active {
opacity: var(--tk-touch-feedback-opacity);
}
}
.ai-risk-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
margin-top: 5px;
&.ai-risk-high {
background: $dan;
}
&.ai-risk-medium {
background: $wrn;
}
&.ai-risk-low {
background: $acc;
}
}
.ai-suggestion-text {
font-size: var(--tk-font-cap);
color: $tx2;
line-height: 1.6;
.health-article-content {
flex: 1;
}
/* ─── AI 建议反馈按钮 ─── */
.ai-feedback-row {
display: flex;
gap: var(--tk-gap-xs);
margin-top: var(--tk-gap-xs);
padding-left: 20px;
flex-direction: column;
justify-content: space-between;
min-width: 0;
}
.ai-feedback-btn {
height: 36px;
min-height: 36px;
border-radius: $r-xs;
@include flex-center;
padding: 0 var(--tk-gap-sm);
&:active {
opacity: var(--tk-touch-feedback-opacity);
}
&.ai-feedback-adopt {
background: rgba($acc, 0.15);
}
&.ai-feedback-ignore {
background: $surface-alt;
}
&.ai-feedback-consult {
background: var(--tk-pri-l);
}
.health-article-title {
font-family: Georgia, 'Times New Roman', serif;
font-size: var(--tk-font-body);
font-weight: 700;
color: $tx;
line-height: 1.35;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.ai-feedback-btn-text {
font-size: var(--tk-font-micro);
font-weight: 500;
.health-article-summary {
font-size: var(--tk-font-cap);
color: $tx2;
line-height: 1.4;
display: block;
margin-bottom: var(--tk-gap-2xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-feedback-adopt .ai-feedback-btn-text {
color: $acc;
.health-article-meta {
display: flex;
gap: var(--tk-gap-sm);
font-size: var(--tk-font-micro);
color: $tx3;
align-items: center;
}
.ai-feedback-consult .ai-feedback-btn-text {
.health-article-tag {
font-size: var(--tk-font-micro);
color: var(--tk-pri);
background: var(--tk-pri-l);
padding: 2px 8px;
border-radius: $r-xs;
}
.health-article-date {
font-size: var(--tk-font-micro);
color: $tx3;
}
// 长者模式
.elder-mode .health-page {
.health-cat-tab {
padding: 10px 20px;
font-size: 15px;
}
.health-article-title {
font-size: 18px;
}
.health-article-summary {
font-size: 15px;
}
}

View File

@@ -1,244 +1,152 @@
import { View, Text } from '@tarojs/components';
import { useState, useCallback } from 'react';
import { View, Text, ScrollView } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { safeNavigateTo } from '@/utils/navigate';
import { usePageData } from '@/hooks/usePageData';
import { useAuthStore } from '../../stores/auth';
import { useElderClass } from '../../hooks/useElderClass';
import GuestGuard from '../../components/GuestGuard';
import Loading from '../../components/Loading';
import {
listArticles,
listCategories,
listPublicArticles,
listPublicCategories,
type Article,
type ArticleCategory,
} from '../../services/article';
import PageShell from '@/components/ui/PageShell';
import ContentCard from '@/components/ui/ContentCard';
import SegmentTabs from '../../components/SegmentTabs';
import { useHealthOverview, VITAL_TABS, type VitalType } from './useHealthOverview';
import { submitSuggestionFeedback } from '../../services/ai-analysis';
import EmptyState from '../../components/EmptyState';
import ErrorState from '../../components/ErrorState';
import Loading from '../../components/Loading';
import { useElderClass } from '../../hooks/useElderClass';
import './index.scss';
const QUICK_ENTRIES = [
{ label: '录入体征', icon: '✏', color: 'input', path: '/pages/pkg-health/input/index' },
{ label: '健康趋势', icon: '📈', color: 'trend', path: '/pages/pkg-health/trend/index' },
{ label: '我的报告', icon: '📋', color: 'report', path: '/pages/pkg-profile/reports/index' },
{ label: '健康档案', icon: '健', color: 'med', path: '/pages/pkg-profile/health-records/index' },
] as const;
function statusClass(status?: string): string {
if (!status) return '';
if (status === 'high' || status === 'abnormal') return 'vital-warn';
if (status === 'low') return 'vital-warn';
return 'vital-ok';
}
function formatDate(): string {
const d = new Date();
const month = d.getMonth() + 1;
const day = d.getDate();
const weekDays = ['日', '一', '二', '三', '四', '五', '六'];
return `${month}${day}日 周${weekDays[d.getDay()]}`;
}
export default function Health() {
const user = useAuthStore((s) => s.user);
const modeClass = useElderClass();
const {
todaySummary, loading, error, activeTab, trendData, trendLoading,
aiSuggestions, thresholds, alertCount, handleTabChange, fetchData,
} = useHealthOverview();
const isLoggedIn = !!useAuthStore((s) => s.user);
const [articles, setArticles] = useState<Article[]>([]);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const [categories, setCategories] = useState<ArticleCategory[]>([]);
const [activeCategory, setActiveCategory] = useState<string | null>(null);
if (!user) {
return <GuestGuard title='请先登录' desc='登录后即可查看健康数据' />;
}
if (error) {
return (
<PageShell padding="md" safeBottom={false} scroll={false} className={`health-page ${modeClass}`}>
<View className='health-header'>
<Text className='health-title'></Text>
</View>
<Loading />
</PageShell>
);
}
const maxTrendValue = trendData.reduce((max, d) => Math.max(max, d.value), 1);
const dayLabels = ['日', '一', '二', '三', '四', '五', '六'];
const summary = todaySummary || {};
const vitals = [
{ label: '血压', value: summary.blood_pressure ? `${summary.blood_pressure.systolic}/${summary.blood_pressure.diastolic}` : '—', unit: 'mmHg', status: summary.blood_pressure?.status },
{ label: '心率', value: summary.heart_rate ? `${summary.heart_rate.value}` : '—', unit: 'bpm', status: summary.heart_rate?.status },
{ label: '血糖', value: summary.blood_sugar ? `${summary.blood_sugar.value}` : '—', unit: 'mmol/L', status: summary.blood_sugar?.status },
{ label: '体重', value: summary.weight ? `${summary.weight.value}` : '—', unit: 'kg', status: summary.weight?.status },
];
const recordedCount = vitals.filter((v) => v.value !== '—').length;
const getThresholdValue = (type: VitalType): number | null => {
if (!thresholds.length) return null;
const th = thresholds;
if (type === 'blood_pressure') {
const v = th.find((t) => t.indicator === 'systolic_bp' && t.level === 'high');
return v?.threshold_value ?? 140;
const fetchData = useCallback(async (p: number, append = false, categoryId?: string | null) => {
setLoading(true);
setError(false);
try {
const cid = categoryId !== undefined ? categoryId : activeCategory;
const res = isLoggedIn
? await listArticles({ page: p, category_id: cid || undefined })
: await listPublicArticles({ page: p, category_id: cid || undefined });
const list = res.data || [];
setArticles(append ? (prev) => [...prev, ...list] : list);
setTotal(res.total);
setPage(p);
} catch (err) {
console.warn('[health] 加载文章列表失败:', err);
setError(true);
Taro.showToast({ title: '加载失败', icon: 'none' });
} finally {
setLoading(false);
}
if (type === 'heart_rate') {
const v = th.find((t) => t.indicator === 'heart_rate' && t.level === 'high');
return v?.threshold_value ?? 100;
}, [activeCategory, isLoggedIn]);
usePageData(
useCallback(async () => {
try {
const cats = isLoggedIn
? await listCategories()
: await listPublicCategories();
setCategories(cats || []);
} catch (err) {
console.warn('[health] 加载分类失败:', err);
setCategories([]);
}
await fetchData(1);
}, [fetchData, isLoggedIn]),
{ throttleMs: 10000, enablePullDown: true },
);
const loadMore = useCallback(() => {
if (!loading && articles.length < total) {
fetchData(page + 1, true);
}
if (type === 'blood_sugar') {
const v = th.find((t) => t.indicator === 'blood_sugar_fasting' && t.level === 'high');
return v?.threshold_value ?? 6.1;
}
return null;
}, [loading, articles.length, total, page, fetchData]);
const handleCategoryChange = (categoryId: string | null) => {
setActiveCategory(categoryId);
fetchData(1, false, categoryId);
};
const formatDate = (dateStr?: string) => {
if (!dateStr) return '';
const d = new Date(dateStr);
const month = d.getMonth() + 1;
const day = d.getDate();
return `${month}${day}`;
};
return (
<PageShell padding="md" safeBottom={false} scroll className={`health-page ${modeClass}`}>
<View className='health-header'>
<Text className='health-title'></Text>
<Text className='health-date'>{formatDate()}</Text>
</View>
<PageShell safeBottom={false} padding="none" scroll={false} className={`health-page ${modeClass}`}>
{/* 分类标签 */}
{categories.length > 0 && (
<ScrollView scrollX className='health-categories'>
<View
className={`health-cat-tab ${!activeCategory ? 'health-cat-tab--active' : ''}`}
onClick={() => handleCategoryChange(null)}
>
<Text></Text>
</View>
{categories.map((cat) => (
<View
key={cat.id}
className={`health-cat-tab ${activeCategory === cat.id ? 'health-cat-tab--active' : ''}`}
onClick={() => handleCategoryChange(cat.id)}
>
<Text>{cat.name}</Text>
</View>
))}
</ScrollView>
)}
{/* 今日体征 hero 卡片 */}
<View className='vitals-grid'>
<View className='vitals-header'>
<Text className='vitals-title'></Text>
{recordedCount > 0 && (
<Text className='vitals-badge'> {recordedCount} </Text>
)}
</View>
{loading ? <Loading /> : (
<View className='vitals-row'>
{vitals.map((v) => (
<View className={`vital-cell ${statusClass(v.status)}`} key={v.label}>
<Text className='vital-value'>{v.value}</Text>
<Text className='vital-unit'>{v.unit}</Text>
<Text className='vital-label'>{v.label}</Text>
</View>
{/* 文章列表 */}
<ScrollView scrollY className='health-scroll' onScrollToLower={loadMore} lowerThreshold={200}>
{error ? (
<ErrorState onRetry={() => fetchData(1, false, null)} />
) : articles.length === 0 && !loading ? (
<EmptyState text='暂无健康资讯' />
) : (
<View className='health-article-list'>
{articles.map((a) => (
<ContentCard
key={a.id}
padding='sm'
margin='none'
onPress={() => safeNavigateTo(`/pages/article/detail/index?id=${a.id}`)}
>
<View className='health-article-body'>
<View className='health-article-content'>
<Text className='health-article-title'>{a.title}</Text>
{a.summary && (
<Text className='health-article-summary'>{a.summary}</Text>
)}
<View className='health-article-meta'>
{(a.category_name || a.category) && (
<Text className='health-article-tag'>{a.category_name || a.category}</Text>
)}
{a.published_at && (
<Text className='health-article-date'>{formatDate(a.published_at)}</Text>
)}
</View>
</View>
</View>
</ContentCard>
))}
</View>
)}
</View>
{/* 快捷入口 — 横排 4 格图标 */}
<View className='quick-entries'>
{QUICK_ENTRIES.map((e) => (
<View
key={e.label}
className='quick-entry'
onClick={() => safeNavigateTo(e.path)}
>
<View className={`quick-icon quick-icon--${e.color}`}>
<Text className='quick-icon-text'>{e.icon}</Text>
</View>
<Text className='quick-label'>{e.label}</Text>
</View>
))}
</View>
{/* 告警横幅 */}
{alertCount > 0 && (
<ContentCard
variant="default"
padding="sm"
margin="none"
className='alert-hint'
onPress={() => safeNavigateTo('/pages/pkg-health/alerts/index')}
>
<View className='alert-dot' />
<Text className='alert-text'>{alertCount} </Text>
<Text className='alert-arrow'></Text>
</ContentCard>
)}
{/* AI 建议 */}
{aiSuggestions.length > 0 && (
<View className='ai-suggestion-card'>
<View className='ai-card-header'>
<Text className='ai-card-title'>AI </Text>
<Text className='ai-card-count'>{aiSuggestions.length} </Text>
</View>
{aiSuggestions.map((s) => {
const riskCls = s.risk_level === 'high' ? 'ai-risk-high' : s.risk_level === 'medium' ? 'ai-risk-medium' : 'ai-risk-low';
const params = s.params as Record<string, unknown> | null;
const reason = (params?.reason as string) || (params?.message as string) || '健康建议';
return (
<View key={s.id} className='ai-suggestion-item'>
<View className='ai-suggestion-main' onClick={() => {
if (s.suggestion_type === 'appointment') safeNavigateTo('/pages/appointment/create/index');
else if (s.suggestion_type === 'followup') safeNavigateTo('/pages/pkg-profile/followups/index');
}}>
<View className={`ai-risk-dot ${riskCls}`} />
<Text className='ai-suggestion-text'>{reason.slice(0, 50)}</Text>
</View>
<View className='ai-feedback-row'>
<View className='ai-feedback-btn ai-feedback-adopt' onClick={async () => {
try { await submitSuggestionFeedback(s.id, 'adopt'); Taro.showToast({ title: '已采纳', icon: 'success' }); fetchData(); }
catch { Taro.showToast({ title: '操作失败', icon: 'none' }); }
}}>
<Text className='ai-feedback-btn-text'></Text>
</View>
<View className='ai-feedback-btn ai-feedback-ignore' onClick={async () => {
try { await submitSuggestionFeedback(s.id, 'ignore'); Taro.showToast({ title: '已忽略', icon: 'success' }); fetchData(); }
catch { Taro.showToast({ title: '操作失败', icon: 'none' }); }
}}>
<Text className='ai-feedback-btn-text'></Text>
</View>
<View className='ai-feedback-btn ai-feedback-consult' onClick={async () => {
try { await submitSuggestionFeedback(s.id, 'consult'); safeNavigateTo('/pages/consultation/index'); }
catch { Taro.showToast({ title: '操作失败', icon: 'none' }); }
}}>
<Text className='ai-feedback-btn-text'></Text>
</View>
</View>
</View>
);
})}
</View>
)}
{/* 7天趋势 */}
<View className='trend-section'>
<Text className='section-title'> 7 </Text>
<SegmentTabs tabs={VITAL_TABS} activeKey={activeTab} onChange={handleTabChange} variant="pill" />
{trendLoading ? <Loading /> : trendData.length === 0 ? (
<ContentCard padding="md">
<Text className='trend-empty-text'></Text>
</ContentCard>
) : (
<ContentCard padding="md">
<View className='trend-bars'>
{(() => {
const tv = getThresholdValue(activeTab);
if (tv) {
const pct = Math.min(95, (tv / maxTrendValue) * 100);
return (
<View className='trend-threshold-line' style={`bottom:${((12 + pct * 1.08) / 120 * 100).toFixed(1)}%;`}>
<Text className='trend-threshold-label'>{tv}</Text>
</View>
);
}
return null;
})()}
{trendData.map((point, i) => {
const heightPct = Math.max(8, (point.value / maxTrendValue) * 100);
const tv = getThresholdValue(activeTab);
const isAbnormal = tv ? point.value >= tv : false;
const dayOfWeek = new Date(point.date).getDay();
return (
<View className='trend-bar-col' key={i}>
<View
className={`trend-bar ${isAbnormal ? 'trend-bar-warn' : 'trend-bar-normal'}`}
style={`height:${heightPct}%;`}
/>
<Text className='trend-bar-label'>{dayLabels[dayOfWeek]}</Text>
</View>
);
})}
</View>
</ContentCard>
)}
</View>
{/* 健康资讯入口 */}
<ContentCard onPress={() => safeNavigateTo('/pages/article/index')}>
<Text className='article-entry-text'> </Text>
</ContentCard>
{loading && <Loading />}
</ScrollView>
</PageShell>
);
}

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '健康测量',
});

View File

@@ -0,0 +1,256 @@
// Veepoo 测量结果 + 上传页样式
// 设计原型: docs/design/veepoo-measure-prototype.html
@import '../../../styles/variables.scss';
.vm-page {
min-height: 100vh;
background: var(--tk-bg-primary, $bg);
}
// ── 连接中(等待跳转态) ──
.vm-connect {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 0 32px;
&__anim {
position: relative;
width: 120px;
height: 120px;
margin-bottom: 24px;
}
&__ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 3px solid $pri;
animation: vm-pulse-ring 2s ease-out infinite;
}
&__center {
position: absolute;
inset: 20px;
border-radius: 50%;
background: $pri;
display: flex;
align-items: center;
justify-content: center;
}
&__bt {
color: #fff;
font-size: 20px;
font-weight: 700;
}
&__title {
font-family: Georgia, 'Times New Roman', serif;
font-size: var(--tk-font-h2, 22px);
font-weight: 700;
color: $tx;
margin-bottom: 8px;
}
&__hint {
font-size: var(--tk-font-body-sm, 14px);
color: $tx3;
}
}
@keyframes vm-pulse-ring {
0% { transform: scale(1); opacity: 1; }
100% { transform: scale(1.4); opacity: 0; }
}
// ── 上传页面 ──
.vm-upload {
min-height: 100vh;
padding-bottom: 40px;
&__header {
padding: var(--tk-gap-lg, 24px) var(--tk-page-padding, 20px) var(--tk-gap-md, 16px);
}
&__title {
display: block;
font-family: Georgia, 'Times New Roman', serif;
font-size: var(--tk-font-h2, 22px);
font-weight: 700;
color: $tx;
line-height: 1.3;
}
&__subtitle {
display: block;
font-size: var(--tk-font-body-sm, 14px);
color: $tx3;
margin-top: 4px;
}
}
// ── 结果卡片网格 ──
.vm-results-grid {
padding: 0 var(--tk-page-padding, 20px);
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--tk-gap-sm, 12px);
}
.vm-result-card {
background: $card;
border-radius: var(--tk-card-radius, 16px);
padding: var(--tk-gap-md, 16px);
box-shadow: $shadow-sm;
position: relative;
overflow: hidden;
&--full {
grid-column: 1 / -1;
}
&--empty {
opacity: 0.5;
}
&__badge {
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
border-radius: 0 4px 4px 0;
}
&__label {
display: block;
font-size: var(--tk-font-cap, 13px);
color: $tx2;
margin-bottom: 8px;
padding-left: 8px;
}
&__row {
display: flex;
align-items: baseline;
gap: 4px;
padding-left: 8px;
}
&__value {
font-family: Georgia, 'Times New Roman', serif;
font-size: var(--tk-font-num-lg, 34px);
font-weight: 700;
color: $tx;
line-height: 1;
}
&__unit {
font-size: var(--tk-font-cap, 13px);
color: $tx3;
}
&__tag {
display: inline-flex;
align-items: center;
gap: 3px;
margin-top: 8px;
margin-left: 8px;
padding: 2px 8px;
border-radius: 999px;
font-size: var(--tk-font-micro, 11px);
font-weight: 500;
&--normal {
background: $acc-l;
color: $acc;
}
&--warning {
background: $wrn-l;
color: $wrn;
}
&--danger {
background: $dan-l;
color: $dan;
}
}
&__placeholder {
padding-left: 8px;
font-size: var(--tk-font-body-sm, 14px);
color: $tx3;
}
&--sleep {
padding-bottom: 12px;
}
}
// ── 睡眠数据行 ──
.vm-sleep-row {
display: flex;
align-items: center;
gap: 12px;
padding: 6px 8px;
margin-left: 8px;
&__day {
font-size: var(--tk-font-body-sm, 14px);
color: $tx2;
min-width: 40px;
}
&__time {
font-family: Georgia, 'Times New Roman', serif;
font-size: var(--tk-font-body, 16px);
font-weight: 600;
color: $tx;
}
&__quality {
font-size: 12px;
color: $wrn;
margin-left: auto;
}
}
// ── 底部上传播区 ──
.vm-upload-footer {
padding: var(--tk-gap-lg, 24px) var(--tk-page-padding, 20px) var(--tk-gap-xl, 32px);
&__hint {
display: block;
font-size: var(--tk-font-cap, 13px);
color: $tx3;
text-align: center;
margin-bottom: var(--tk-gap-sm, 12px);
}
&__btn {
width: 100%;
}
&__time {
display: block;
font-size: var(--tk-font-micro, 11px);
color: $tx3;
text-align: center;
margin-top: var(--tk-gap-sm, 12px);
}
}
// ── 长者模式 ──
.elder-mode {
.vm-results-grid {
grid-template-columns: 1fr;
}
.vm-result-card__value {
font-size: var(--tk-font-num-lg, 40px);
}
}

View File

@@ -0,0 +1,306 @@
import React, { useRef } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useDidShow, useRouter } from '@tarojs/taro';
import { useAuthStore } from '@/stores/auth';
import { uploadReadings } from '@/services/device-sync';
import type { NormalizedReading } from '@/services/ble/types';
import { useElderClass } from '@/hooks/useElderClass';
import PageShell from '@/components/ui/PageShell';
import PrimaryButton from '@/components/ui/PrimaryButton';
import './index.scss';
/** 原生页面返回的测量结果格式 */
interface NativeMeasureResult {
type: string;
values: Record<string, number>;
measuredAt: number;
}
/** 原生页面返回的睡眠数据格式 */
interface NativeSleepResult {
day: number;
deepSleepMinutes: number;
lightSleepMinutes: number;
totalSleepMinutes: number;
qualityScore: number;
fallAsleepTime: string;
exitSleepTime: string;
}
/** 指标配置 */
const METRIC_CONFIG = [
{ type: 'heart_rate', label: '心率', unit: 'bpm', color: '#EF4444', icon: '♥' },
{ type: 'blood_oxygen', label: '血氧', unit: '%', color: '#3B82F6', icon: 'O₂' },
{ type: 'blood_pressure', label: '血压', unit: 'mmHg', color: '#8B5CF6', icon: '↕' },
{ type: 'temperature', label: '体温', unit: '°C', color: '#F59E0B', icon: 'T' },
{ type: 'pressure', label: '压力', unit: '', color: '#6366F1', icon: '~' },
] as const;
/** 健康评估 */
function assessHealth(type: string, values: Record<string, number>): { level: 'normal' | 'warning' | 'danger'; text: string } {
switch (type) {
case 'heart_rate': {
const v = values.heart_rate ?? 0;
if (v >= 60 && v <= 100) return { level: 'normal', text: '心率正常' };
if (v < 50 || v > 120) return { level: 'danger', text: '心率异常' };
return { level: 'warning', text: '心率偏离正常范围' };
}
case 'blood_oxygen': {
const v = values.blood_oxygen ?? 0;
if (v >= 95) return { level: 'normal', text: '血氧正常' };
if (v >= 90) return { level: 'warning', text: '血氧偏低' };
return { level: 'danger', text: '血氧过低' };
}
case 'blood_pressure': {
const sys = values.systolic ?? 0;
const dia = values.diastolic ?? 0;
if (sys >= 90 && sys <= 140 && dia >= 60 && dia <= 90) return { level: 'normal', text: '血压正常' };
if (sys > 160 || dia > 100) return { level: 'danger', text: '血压过高' };
return { level: 'warning', text: '血压偏高' };
}
case 'temperature': {
const v = values.temperature ?? 0;
if (v >= 36.0 && v <= 37.3) return { level: 'normal', text: '体温正常' };
if (v > 38.0) return { level: 'danger', text: '发热' };
return { level: 'warning', text: '体温偏离正常' };
}
case 'pressure': {
const v = values.pressure ?? 0;
if (v >= 1 && v <= 40) return { level: 'normal', text: '压力正常' };
if (v > 60) return { level: 'danger', text: '压力过高' };
return { level: 'warning', text: '压力偏高' };
}
default:
return { level: 'normal', text: '' };
}
}
/** 格式化显示值 */
function formatValue(type: string, values: Record<string, number>): string {
if (type === 'blood_pressure') {
return `${values.systolic ?? '--'}/${values.diastolic ?? '--'}`;
}
const v = Object.values(values)[0];
return v !== undefined ? String(v) : '--';
}
export default function VeepooMeasure() {
const modeClass = useElderClass();
const router = useRouter();
const patient = useAuthStore((s) => s.currentPatient);
const navigatedRef = useRef(false);
const [results, setResults] = React.useState<Record<string, NativeMeasureResult>>({});
const [sleepData, setSleepData] = React.useState<NativeSleepResult[]>([]);
const [uploadStatus, setUploadStatus] = React.useState<'idle' | 'uploading' | 'success' | 'error'>('idle');
// 从 URL 或 store 获取 patientId
const patientId = patient?.id || router.params.patientId || '';
// C3 修复:用 ref 防重入,避免 React Strict Mode 双触发
if (!navigatedRef.current) {
navigatedRef.current = true;
// 延迟到下一个微任务,确保页面渲染完成后再跳转
setTimeout(() => {
Taro.navigateTo({
url: `/pkg-veepoo/index?patientId=${patientId}`,
events: {
measureResult: (data: NativeMeasureResult) => {
setResults((prev) => ({ ...prev, [data.type]: data }));
},
measureComplete: (data: { results: Record<string, NativeMeasureResult>; count: number }) => {
if (data.results) setResults(data.results);
},
},
});
}, 50);
}
// 页面恢复时读取原生页面返回的测量结果 + 睡眠数据
useDidShow(() => {
try {
const raw = Taro.getStorageSync('hms:veepoo_measure_results');
if (raw) {
const parsed = JSON.parse(raw) as Record<string, NativeMeasureResult>;
setResults(parsed);
Taro.removeStorageSync('hms:veepoo_measure_results');
}
} catch { /* ignore */ }
try {
const rawSleep = Taro.getStorageSync('hms:veepoo_sleep_results');
if (rawSleep) {
const parsedSleep = JSON.parse(rawSleep) as NativeSleepResult[];
if (parsedSleep.length > 0) {
setSleepData(parsedSleep);
}
Taro.removeStorageSync('hms:veepoo_sleep_results');
}
} catch { /* ignore */ }
});
const handleUpload = async () => {
// 修复:添加明确的错误提示,不再静默退出
if (!patientId) {
console.warn('[veepoo-measure] 上传失败:未获取到患者 ID');
Taro.showToast({ title: '请先绑定患者档案', icon: 'none' });
return;
}
const allResults = Object.values(results);
const hasMeasureData = allResults.length > 0;
const hasSleep = sleepData.length > 0;
if (!hasMeasureData && !hasSleep) {
console.warn('[veepoo-measure] 上传失败:无数据');
Taro.showToast({ title: '暂无测量数据', icon: 'none' });
return;
}
setUploadStatus('uploading');
try {
const allReadings: NormalizedReading[] = [];
// 测量结果
if (hasMeasureData) {
console.log('[veepoo-measure] 上传测量数据', allResults.length, '项');
allReadings.push(...allResults.map((r) => ({
device_type: r.type as NormalizedReading['device_type'],
values: r.values,
measured_at: new Date(r.measuredAt).toISOString(),
})));
}
// 睡眠数据
if (hasSleep) {
const now = new Date();
console.log('[veepoo-measure] 上传睡眠数据', sleepData.length, '天');
allReadings.push(...sleepData.map((s) => {
const baseDate = new Date(now.getTime() - s.day * 86400000);
return {
device_type: 'sleep' as const,
values: {
deep_sleep_minutes: s.deepSleepMinutes,
light_sleep_minutes: s.lightSleepMinutes,
total_sleep_minutes: s.totalSleepMinutes,
quality_score: s.qualityScore,
},
measured_at: baseDate.toISOString(),
};
}));
}
await uploadReadings(patientId, 'veepoo_m2', 'Veepoo M2', allReadings);
setUploadStatus('success');
Taro.showToast({ title: '数据已上传', icon: 'success' });
} catch (err) {
console.error('[veepoo-measure] 上传失败:', err);
setUploadStatus('error');
Taro.showToast({ title: '上传失败,请重试', icon: 'none' });
}
};
const hasResults = Object.keys(results).length > 0;
const measuredCount = Object.keys(results).length;
const measuredAt = hasResults
? new Date(Object.values(results)[0].measuredAt).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
: '';
return (
<PageShell padding="none" className={`vm-page ${modeClass}`}>
{hasResults ? (
<View className="vm-upload">
{/* 页面标题 */}
<View className="vm-upload__header">
<Text className="vm-upload__title"></Text>
<Text className="vm-upload__subtitle">Veepoo M2 · </Text>
</View>
{/* 结果卡片网格 */}
<View className="vm-results-grid">
{METRIC_CONFIG.map((metric) => {
const result = results[metric.type];
if (result) {
const assessment = assessHealth(metric.type, result.values);
return (
<View
key={metric.type}
className={`vm-result-card ${metric.type === 'blood_pressure' ? 'vm-result-card--full' : ''}`}
>
<View className="vm-result-card__badge" style={{ background: metric.color }} />
<Text className="vm-result-card__label">{metric.label}</Text>
<View className="vm-result-card__row">
<Text className="vm-result-card__value">{formatValue(metric.type, result.values)}</Text>
<Text className="vm-result-card__unit">{metric.unit}</Text>
</View>
<View className={`vm-result-card__tag vm-result-card__tag--${assessment.level}`}>
<Text> {assessment.text}</Text>
</View>
</View>
);
}
// 未测量占位
return (
<View
key={metric.type}
className={`vm-result-card vm-result-card--empty ${metric.type === 'blood_pressure' ? 'vm-result-card--full' : ''}`}
>
<View className="vm-result-card__badge" style={{ background: metric.color, opacity: 0.3 }} />
<Text className="vm-result-card__label">{metric.label}</Text>
<Text className="vm-result-card__placeholder"></Text>
</View>
);
})}
{/* 睡眠数据卡片 */}
{sleepData.length > 0 && (
<View className="vm-result-card vm-result-card--full vm-result-card--sleep">
<View className="vm-result-card__badge" style={{ background: '#5B7A5E' }} />
<Text className="vm-result-card__label">{sleepData.length} </Text>
{sleepData.map((sleep, idx) => {
const hours = Math.floor(sleep.totalSleepMinutes / 60);
const mins = sleep.totalSleepMinutes % 60;
const dayLabel = sleep.day === 0 ? '昨晚' : sleep.day === 1 ? '前晚' : '大前晚';
return (
<View key={idx} className="vm-sleep-row">
<Text className="vm-sleep-row__day">{dayLabel}</Text>
<Text className="vm-sleep-row__time">{hours}h{mins > 0 ? ` ${mins}min` : ''}</Text>
<View className="vm-sleep-row__quality">
{'★'.repeat(Math.min(sleep.qualityScore, 5))}{'☆'.repeat(Math.max(5 - sleep.qualityScore, 0))}
</View>
</View>
);
})}
<View className="vm-result-card__tag vm-result-card__tag--normal">
<Text> </Text>
</View>
</View>
)}
</View>
{/* 底部上传播区 */}
<View className="vm-upload-footer">
<Text className="vm-upload-footer__hint"></Text>
<View className="vm-upload-footer__btn">
<PrimaryButton onClick={handleUpload} disabled={uploadStatus === 'uploading'}>
{uploadStatus === 'uploading'
? '上传中...'
: uploadStatus === 'success'
? '✓ 已上传'
: `上传数据(${measuredCount} 项测量${sleepData.length > 0 ? ' + ' + sleepData.length + ' 天睡眠' : ''}`}
</PrimaryButton>
</View>
{measuredAt && <Text className="vm-upload-footer__time">{measuredAt}</Text>}
</View>
</View>
) : (
<View className="vm-connect">
<View className="vm-connect__anim">
<View className="vm-connect__ring" />
<View className="vm-connect__center"><Text className="vm-connect__bt">BT</Text></View>
</View>
<Text className="vm-connect__title">M2 </Text>
<Text className="vm-connect__hint">...</Text>
</View>
)}
</PageShell>
);
}

View File

@@ -68,7 +68,9 @@ export interface PatientSummary {
}
/** 获取患者摘要列表(字段最小化,替代 getPatients */
export async function getPatientSummaries() {
const res = await api.get<PaginatedData<PatientSummary>>('/health/patients/summary');
export async function getPatientSummaries(userId?: string) {
const params: Record<string, string> = {};
if (userId) params.user_id = userId;
const res = await api.get<PaginatedData<PatientSummary>>('/health/patients/summary', { params });
return Array.isArray(res?.data) ? res.data : (Array.isArray(res) ? res : []);
}

View File

@@ -0,0 +1,305 @@
/**
* Veepoo SDK 桥接模块
*
* 调用顺序(基于 SDK Demo 验证):
* 1. startScan() — 初始化蓝牙 + 扫描
* 2. stopScan() — 找到设备后停止扫描
* 3. connectDevice(deviceObj) — 传入完整设备对象(非 deviceId 字符串)
* 4. registerDataListener() — 连接成功后注册数据监听
* 5. authenticate() — 延迟 500ms 后调用秘钥认证
* 6. 认证结果通过数据监听回调 type=1 返回
*/
// @ts-ignore — SDK 类型声明为 any
import { veepooBle, veepooFeature, veepooLogger } from './veepoo-sdk';
// ── SDK 事件类型常量 ──
/** 秘钥认证结果 */
export const SDK_EVENT_AUTH = 1;
/** 日常数据 */
export const SDK_EVENT_DAILY = 5;
/** 体温检测 */
export const SDK_EVENT_TEMPERATURE = 6;
/** 血压 */
export const SDK_EVENT_BLOOD_PRESSURE = 18;
/** 血氧手动测量 */
export const SDK_EVENT_BLOOD_OXYGEN = 31;
/** 心率测量 */
export const SDK_EVENT_HEART_RATE = 51;
/** 压力测量 */
export const SDK_EVENT_PRESSURE = 58;
/** 设备正忙状态枚举SDK state 字段) */
export const DEVICE_STATE = {
IDLE: 0,
MEASURING_BP: 1,
MEASURING_HR: 2,
AUTO_TEST: 3,
MEASURING_SPO2: 4,
MEASURING_FATIGUE: 5,
NOT_WORN: 6,
CHARGING: 7,
LOW_BATTERY: 8,
BUSY: 9,
} as const;
/** 连接回调中 connection 字段为 true 表示连接成功 */
export interface VeepooConnectionResult {
connection?: boolean;
errno?: number;
errCode?: number;
errMsg?: string;
}
/** SDK 事件回调数据(统一格式) */
export interface SdkEventData {
name: string;
type: number;
content: Record<string, unknown>;
Progress?: number;
state?: number;
control?: number;
ack?: number;
}
// ── 蓝牙模块 ──
/** 初始化蓝牙 + 开始扫描 */
export function startScan(onDeviceFound: (device: unknown) => void): void {
veepooBle.veepooWeiXinSDKStartScanDeviceAndReceiveScanningDevice(
(res: unknown) => {
const device = Array.isArray(res) ? res[0] : res;
if (device) onDeviceFound(device);
},
);
}
/** 停止扫描 */
export function stopScan(): Promise<void> {
return new Promise((resolve) => {
veepooBle.veepooWeiXinSDKStopSearchBleManager(() => resolve());
});
}
/** 连接设备 — 传入完整设备对象 */
export function connectDevice(device: unknown): Promise<VeepooConnectionResult> {
return new Promise((resolve) => {
veepooBle.veepooWeiXinSDKBleConnectionServicesCharacteristicsNotifyManager(
device,
(res: VeepooConnectionResult) => resolve(res),
);
});
}
/** 注册数据监听(必须在连接成功后调用) */
export function registerDataListener(callback: (data: SdkEventData) => void): void {
veepooBle.veepooWeiXinSDKNotifyMonitorValueChange(callback);
}
/** 监听蓝牙连接状态变化 */
export function registerConnectionListener(callback: (res: { deviceId: string; connected: boolean }) => void): void {
veepooBle.veepooWeiXinSDKBLEConnectionStateChangeManager(callback);
}
/** 断开连接 */
export function disconnect(): Promise<void> {
return new Promise((resolve) => {
veepooBle.veepooWeiXinSDKloseBluetoothAdapterManager(() => resolve());
});
}
// ── 功能模块:认证 ──
/** 秘钥认证(无参数无回调,结果通过数据监听 type=1 返回) */
export function authenticate(): void {
veepooFeature.veepooBlePasswordCheckManager();
}
// ── 功能模块:测量指令 ──
/** 心率测量开关true=开启false=关闭) */
export function setHeartRateMeasure(on: boolean): void {
veepooFeature.veepooSendHeartRateTestSwitchManager({ switch: on });
}
/** 血氧测量开关('start'=开启,'stop'=关闭) */
export function setBloodOxygenMeasure(action: 'start' | 'stop'): void {
veepooFeature.veepooSendBloodOxygenControlDataManager({ switch: action });
}
/** 血压测量开关('start'=开启,'stop'=关闭) */
export function setBloodPressureMeasure(action: 'start' | 'stop'): void {
veepooFeature.veepooSendReadUniversalBloodPressureDataManager({ switch: action });
}
/** 体温测量(单次触发) */
export function startTemperatureMeasure(): void {
veepooFeature.veepooSendTemperatureMeasurementSwitchManager();
}
/** 压力测量开关true=开启false=关闭) */
export function setPressureMeasure(on: boolean): void {
veepooFeature.veepooSendPressureTestManager({ switch: on });
}
// ── 功能模块:日常数据 ──
/** 读取日常数据day: 0=今天, 1=昨天, 2=前天package: 开始包序号默认1 */
export function readDailyData(day: number, pkg: number = 1): void {
veepooFeature.veepooSendReadDailyDataManager({ day, package: pkg });
}
// ── 功能模块:精准睡眠数据 ──
/** 精准睡眠事件类型 */
export const SDK_EVENT_SLEEP = 4;
/** 精准睡眠数据SDK 回调 type=4 */
export interface SleepData {
/** 入睡时间(时间戳字符串) */
fallAsleepTime: string;
/** 退出睡眠时间(时间戳字符串) */
exitSleepTime: string;
/** 起夜得分 */
nightScore: number;
/** 深睡得分 */
deepSleepScore: number;
/** 睡眠效率得分 */
sleepEfficiencyScore: number;
/** 入睡效率得分 */
fallAsleepEfficiencyScore: number;
/** 睡眠时长得分 */
sleepTimeScore: number;
/** 睡眠质量1-5 星) */
sleepQuality: number;
/** 深睡时长(分钟) */
deepSleepTime: number;
/** 浅睡时长(分钟) */
lightSleepTime: number;
/** 其他睡眠时长(分钟) */
otherSleepTime: number;
/** 睡眠总时长(分钟) */
sleepTotalTime: number;
/** 首次深睡眠时长(分钟) */
firstDeepSleepTime: number;
/** 起夜总时长(分钟) */
nightTotalTime: number;
/** 起夜到深睡均值 */
nightDeepSleepMeanValue: number;
/** 失眠得分 */
insomniaScore: number;
/** 失眠次数 */
insomniaCount: number;
/** 睡眠曲线字符串0=深睡, 1=浅睡, 2=REM, 3=失眠, 4=苏醒) */
sleepCurve: string;
}
/** 读取精准睡眠数据day: 0=今天, 1=昨天, 2=前天) */
export function readPreciseSleepData(day: number): void {
veepooFeature.veepooSendReadPreciseSleepManager({ day });
}
// ── 功能模块自动测量B3 ──
/** 自动测量事件类型 */
export const SDK_EVENT_AUTO_TEST = 54;
/** B3 自动测量功能类型枚举 */
export const AUTO_TEST_FUN_TYPES = {
PULSE_RATE: 0, // 脉率
BLOOD_PRESSURE: 1, // 血压
BLOOD_GLUCOSE: 2, // 血糖
PRESSURE: 3, // 压力
BLOOD_OXYGEN: 4, // 血氧
TEMPERATURE: 5, // 体温
LORENTZ_SCATTER: 6, // 洛伦兹散点图
HRV: 7, // HRV
BLOOD_COMPONENT: 8, // 血液成分
} as const;
export type AutoTestFunType = (typeof AUTO_TEST_FUN_TYPES)[keyof typeof AUTO_TEST_FUN_TYPES];
/** B3 自动测量配置项 */
export interface AutoTestConfig {
/** 协议类型(不可修改) */
protocolType: number;
/** 功能类型 0-8可修改 */
funTypeContent: AutoTestFunType;
/** 开关0=关闭, 1=开启 */
funSwitch: number;
/** 最小步进(分钟) */
stepUnit: number;
/** 是否支持时间段修改 */
timeSlotModify: number;
/** 是否支持时间间隔修改 */
timeIntervalModify: number;
/** 支持的测试时间段 */
supportTimeSlot: { startTime: string; stopTime: string };
/** 测量间隔(分钟,按 stepUnit 递增) */
measInterval: number;
/** 当前测试时间段 */
currentTimeSlot: { startTime: string; stopTime: string };
}
/** 读取自动测量功能配置 */
export function readAutoTestConfig(): void {
veepooFeature.veepooSendReadB3AutoTestFeatureDataManager();
}
/** 设置自动测量功能 */
export function setAutoTestConfig(config: AutoTestConfig): void {
veepooFeature.veepooSendSetupB3AutoTestFeatureDataManager({
p_protocol_type: config.protocolType,
p_fun_type_content: config.funTypeContent,
p_fun_switch: config.funSwitch,
p_step_unit: config.stepUnit,
p_time_slot_modify: config.timeSlotModify,
p_time_interval_modify: config.timeIntervalModify,
p_support_time_slot: config.supportTimeSlot,
p_meas_inv: config.measInterval,
p_cur_time_slot: config.currentTimeSlot,
});
}
// ── 功能模块:开关设置 ──
/** 自动心率监测开关 */
export function setAutoHeartRate(enabled: boolean): void {
veepooFeature.veepooSendSwitchSettingDataManager({
VPSettingAutomaticHRTest: enabled ? 'open' : 'close',
});
}
/** 自动血压监测开关 */
export function setAutoBloodPressure(enabled: boolean): void {
veepooFeature.veepooSendSwitchSettingDataManager({
VPSettingAutomaticBPTest: enabled ? 'open' : 'close',
});
}
/** 体温自动监测 */
export function setAutoTemperature(enabled: boolean): void {
veepooFeature.veepooSendSwitchSettingDataManager({
VPSettingAutomaticTemperatureTest: enabled ? 'open' : 'close',
});
}
/** 读取体温自动监测数据 */
export function readAutoTemperatureData(): void {
veepooFeature.veepooReadAutoTemperatureMeasurementDataManager({ day: 0 });
}
// ── 功能模块:设备信息 ──
/** 读取设备电量 */
export function readBatteryLevel(): void {
veepooFeature.veepooReadElectricQuantityManager();
}
// ── 日志模块 ──
/** 设置日志级别0=DEBUG, 1=INFO, 2=WARN, 3=ERROR, 4=NONE */
export function setLogLevel(level: number): void {
veepooLogger.setLevel(level);
}

View File

@@ -0,0 +1,245 @@
/**
* Veepoo 历史数据读取器 — 3天日常数据分批读取 + 上传
*
* SDK 日常数据格式type=5
* - 包含计步、心率、血压、血氧、睡眠、压力、体温等
* - Progress 字段 1-100% 表示读取进度
* - 每次回调可能包含一包数据
*/
import Taro from '@tarojs/taro';
import { readDailyData } from '../VeepooBridge';
import type { SdkEventData } from '../VeepooBridge';
import type { NormalizedReading } from '../types';
import type { SleepReading } from './types';
import { uploadReadings } from '@/services/device-sync';
const CHECKPOINT_KEY = 'veepoo_history_checkpoint';
const UPLOAD_BATCH_SIZE = 20;
interface Checkpoint {
lastProgress: number;
packagesRead: number;
deviceId: string;
timestamp: number;
}
export type HistoryReadPhase = 'idle' | 'reading' | 'uploading' | 'done' | 'error';
export class VeepooHistoryReader {
private phase: HistoryReadPhase = 'idle';
private progress = 0;
private packagesRead = 0;
private buffer: NormalizedReading[] = [];
private day = 0;
private patientId = '';
private deviceId = '';
private onProgress?: (progress: number, phase: HistoryReadPhase) => void;
private uploadedCount = 0;
setCallbacks(cbs: { onProgress?: (progress: number, phase: HistoryReadPhase) => void }): void {
this.onProgress = cbs.onProgress;
}
/** 开始读取3天数据 */
async startRead(patientId: string, deviceId: string): Promise<number> {
this.patientId = patientId;
this.deviceId = deviceId;
this.buffer = [];
this.uploadedCount = 0;
this.phase = 'reading';
// 依次读取 3 天数据
for (let day = 0; day < 3; day++) {
this.day = day;
this.progress = 0;
this.onProgress?.(0, 'reading');
await this.readDay(day);
// 刷新剩余 buffer
if (this.buffer.length > 0) {
await this.flushBuffer();
}
}
this.phase = 'done';
this.onProgress?.(100, 'done');
this.clearCheckpoint();
return this.uploadedCount;
}
/** 读取单天数据 */
private readDay(day: number): Promise<void> {
return new Promise((resolve) => {
// 发送读取指令
readDailyData(day, 1);
// 进度通过 handleDailyEvent 更新
// Progress=100 时 resolve
this.dayResolve = resolve;
// 超时保护30s
this.dayTimeout = setTimeout(() => {
this.dayResolve = null;
resolve();
}, 30_000);
});
}
private dayResolve: (() => void) | null = null;
private dayTimeout: ReturnType<typeof setTimeout> | null = null;
/** 处理 SDK 日常数据回调 */
handleDailyEvent(data: SdkEventData): void {
if (this.phase !== 'reading') return;
const progress = (data.Progress ?? 0) as number;
this.progress = progress;
this.onProgress?.(progress, 'reading');
// 解析数据
const readings = this.parseDailyData(data);
if (readings.length > 0) {
this.buffer.push(...readings);
this.packagesRead++;
}
// 达到批量大小就上传
if (this.buffer.length >= UPLOAD_BATCH_SIZE) {
this.flushBuffer();
}
// 进度 100% 表示当天数据读取完成
if (progress >= 100) {
if (this.dayTimeout) clearTimeout(this.dayTimeout);
this.dayTimeout = null;
const resolve = this.dayResolve;
this.dayResolve = null;
resolve?.();
}
}
/** 解析 SDK 日常数据为 NormalizedReading */
private parseDailyData(data: SdkEventData): NormalizedReading[] {
const content = data.content ?? {};
const readings: NormalizedReading[] = [];
const now = new Date();
// 偏移到对应天
const baseDate = new Date(now.getTime() - this.day * 86400000);
const timestamp = baseDate.toISOString();
// 心率
const hr = content.heartReat ?? content.heartRate;
if (typeof hr === 'number' && hr >= 30 && hr <= 250) {
readings.push({ device_type: 'heart_rate', values: { heart_rate: hr }, measured_at: timestamp });
}
// 血氧
const bo = content.bloodOxygen;
if (typeof bo === 'number' && bo >= 70 && bo <= 100) {
readings.push({ device_type: 'blood_oxygen', values: { blood_oxygen: bo }, measured_at: timestamp });
}
// 血压
const bph = content.bloodPressureHigh;
const bpl = content.bloodPressureLow;
if (typeof bph === 'number' && typeof bpl === 'number' && bph > 0 && bpl > 0) {
readings.push({ device_type: 'blood_pressure', values: { systolic: bph, diastolic: bpl }, measured_at: timestamp });
}
// 体温
const temp = content.bodyTemperature;
if (typeof temp === 'number' && temp > 30 && temp < 45) {
readings.push({ device_type: 'temperature', values: { temperature: temp }, measured_at: timestamp });
}
// 压力
const pressure = content.pressure;
if (typeof pressure === 'number' && pressure >= 0 && pressure <= 100) {
readings.push({ device_type: 'stress', values: { value: pressure }, measured_at: timestamp });
}
// 步数
const steps = content.stepCount ?? content.steps;
if (typeof steps === 'number' && steps >= 0) {
readings.push({ device_type: 'steps', values: { value: steps }, measured_at: timestamp });
}
return readings;
}
/** 上传 buffer 中的数据 */
private async flushBuffer(): Promise<void> {
if (this.buffer.length === 0) return;
const batch = this.buffer.splice(0, this.buffer.length);
this.phase = 'uploading';
this.onProgress?.(this.progress, 'uploading');
try {
await uploadReadings(this.patientId, this.deviceId, 'Veepoo M2', batch);
this.uploadedCount += batch.length;
this.saveCheckpoint();
} catch {
// 上传失败,放回 buffer
this.buffer.unshift(...batch);
}
this.phase = 'reading';
}
private saveCheckpoint(): void {
try {
const checkpoint: Checkpoint = {
lastProgress: this.progress,
packagesRead: this.packagesRead,
deviceId: this.deviceId,
timestamp: Date.now(),
};
Taro.setStorageSync(CHECKPOINT_KEY, JSON.stringify(checkpoint));
} catch { /* ignore */ }
}
private clearCheckpoint(): void {
try { Taro.removeStorageSync(CHECKPOINT_KEY); } catch { /* ignore */ }
}
getPhase(): HistoryReadPhase { return this.phase; }
getProgress(): number { return this.progress; }
getUploadedCount(): number { return this.uploadedCount; }
// ── 睡眠数据上传 ──
/** 将睡眠数据转换为 NormalizedReading 并上传 */
async uploadSleepReadings(patientId: string, deviceId: string, sleepData: SleepReading[]): Promise<number> {
if (sleepData.length === 0) return 0;
const now = new Date();
const readings: NormalizedReading[] = sleepData.map((sleep) => {
// 根据天数偏移计算日期
const baseDate = new Date(now.getTime() - sleep.day * 86400000);
return {
device_type: 'sleep',
values: {
deep_sleep_minutes: sleep.deepSleepMinutes,
light_sleep_minutes: sleep.lightSleepMinutes,
total_sleep_minutes: sleep.totalSleepMinutes,
quality_score: sleep.qualityScore,
},
measured_at: baseDate.toISOString(),
};
});
try {
await uploadReadings(patientId, deviceId, 'Veepoo M2', readings);
this.uploadedCount += readings.length;
console.log('[veepoo-history] 睡眠数据上传成功:', readings.length, '条');
return readings.length;
} catch (err) {
console.error('[veepoo-history] 睡眠数据上传失败:', err);
return 0;
}
}
}

View File

@@ -0,0 +1,588 @@
/**
* Veepoo 管线 — SDK 事件路由 + 连接编排 + 测量 Promise 封装
*
* 职责:
* 1. 连接流程编排:扫描 → 连接 → 注册监听 → 认证 → 就绪
* 2. SDK 事件路由registerDataListener 按 type 分发
* 3. 测量 Promise 化startMeasure(type) → Promise<MeasureResult>
*/
import Taro from '@tarojs/taro';
import {
startScan,
stopScan,
connectDevice,
registerDataListener,
registerConnectionListener,
authenticate,
disconnect as veepooDisconnect,
setHeartRateMeasure,
setBloodOxygenMeasure,
setBloodPressureMeasure,
startTemperatureMeasure,
setPressureMeasure,
readBatteryLevel,
readPreciseSleepData,
readAutoTestConfig,
setAutoHeartRate,
setAutoBloodPressure,
setAutoTemperature,
setLogLevel,
SDK_EVENT_AUTH,
SDK_EVENT_HEART_RATE,
SDK_EVENT_BLOOD_OXYGEN,
SDK_EVENT_BLOOD_PRESSURE,
SDK_EVENT_TEMPERATURE,
SDK_EVENT_PRESSURE,
SDK_EVENT_DAILY,
SDK_EVENT_SLEEP,
SDK_EVENT_AUTO_TEST,
DEVICE_STATE,
} from '../VeepooBridge';
import type { SdkEventData } from '../VeepooBridge';
import type { MeasureType, MeasureResult, SleepReading } from './types';
const AUTH_TIMEOUT = 8_000;
const AUTH_POLL_INTERVAL = 500;
const MEASURE_SETTLE_DELAY = 1_500;
/** pending 测量的 resolve/reject 句柄 */
interface PendingMeasure {
type: MeasureType;
resolve: (result: MeasureResult) => void;
reject: (error: Error) => void;
timer: ReturnType<typeof setTimeout>;
lastValue: number | null;
lastValues: Record<string, number>;
settleTimer: ReturnType<typeof setTimeout> | null;
}
/** SDK type 到 MeasureType 的映射 */
const SDK_TYPE_TO_MEASURE: Record<number, MeasureType> = {
[SDK_EVENT_HEART_RATE]: 'heart_rate',
[SDK_EVENT_BLOOD_OXYGEN]: 'blood_oxygen',
[SDK_EVENT_BLOOD_PRESSURE]: 'blood_pressure',
[SDK_EVENT_TEMPERATURE]: 'temperature',
[SDK_EVENT_PRESSURE]: 'pressure',
};
export type ConnectionChangeCallback = (connected: boolean, deviceId: string) => void;
export type AuthResultCallback = (success: boolean) => void;
export type MeasureEventCallback = (type: MeasureType, data: Record<string, unknown>) => void;
export type DailyDataCallback = (data: SdkEventData) => void;
export type SleepDataCallback = (day: number, sleep: SleepReading) => void;
export class VeepooPipeline {
private pending: PendingMeasure | null = null;
private isConnected = false;
private deviceId = '';
/** 睡眠数据读取 Promise resolve 队列 */
private sleepResolvers: Map<number, (sleep: SleepReading | null) => void> = new Map();
private sleepTimeouts: Map<number, ReturnType<typeof setTimeout>> = new Map();
private onConnectionChange?: ConnectionChangeCallback;
private onAuthResult?: AuthResultCallback;
private onMeasureEvent?: MeasureEventCallback;
private onDailyData?: DailyDataCallback;
private onSleepData?: SleepDataCallback;
/** 注册回调 */
setCallbacks(cbs: {
onConnectionChange?: ConnectionChangeCallback;
onAuthResult?: AuthResultCallback;
onMeasureEvent?: MeasureEventCallback;
onDailyData?: DailyDataCallback;
onSleepData?: SleepDataCallback;
}): void {
this.onConnectionChange = cbs.onConnectionChange;
this.onAuthResult = cbs.onAuthResult;
this.onMeasureEvent = cbs.onMeasureEvent;
this.onDailyData = cbs.onDailyData;
this.onSleepData = cbs.onSleepData;
}
/** 全流程:扫描 → 连接 → 注册监听 → 认证 */
async connect(targetName: string, debug = false): Promise<string> {
console.log('[veepoo-pipeline] connect() 开始, target:', targetName);
if (debug) setLogLevel(0);
// 1. 扫描
console.log('[veepoo-pipeline] Step 1: 扫描...');
const device = await this.scanFor(targetName);
if (!device) {
console.error('[veepoo-pipeline] 扫描未找到设备');
throw new Error(`未找到设备 ${targetName}`);
}
console.log('[veepoo-pipeline] 找到设备:', (device as Record<string, unknown>)?.deviceId);
// 2. 连接
console.log('[veepoo-pipeline] Step 2: 连接...');
const connRes = await connectDevice(device);
console.log('[veepoo-pipeline] 连接结果:', JSON.stringify(connRes));
// SDK 连接成功返回 errno=0 或 connection=true两种都要兼容
const ok = connRes?.connection === true || connRes?.errno === 0 || connRes?.errCode === 0;
if (!ok) throw new Error('连接失败');
const id = (device as Record<string, unknown>).deviceId as string;
this.deviceId = id;
this.isConnected = true;
// 3. 注册数据监听(连接成功后)
registerDataListener((data) => this.routeEvent(data));
registerConnectionListener((res) => {
this.isConnected = res.connected;
this.onConnectionChange?.(res.connected, res.deviceId);
});
// 4. 认证(延迟 500ms
await delay(500);
authenticate();
// 5. 等待认证结果
const authOk = await this.waitForAuth();
if (!authOk) throw new Error('设备认证失败,请重新连接');
// 6. 读取电量
readBatteryLevel();
return id;
}
/** 扫描指定名称的设备 */
private scanFor(targetName: string): Promise<unknown | null> {
return new Promise((resolve) => {
let found: unknown = null;
const upper = targetName.toUpperCase();
startScan((device) => {
const d = device as Record<string, unknown>;
const name = String(d.localName ?? d.name ?? '').toUpperCase();
if (name.includes(upper) && !found) {
found = device;
stopScan().then(() => resolve(found));
}
});
setTimeout(() => {
if (!found) {
stopScan().then(() => resolve(null));
}
}, 10_000);
});
}
/** 等待认证结果(轮询 deviceChipStatus */
private waitForAuth(): Promise<boolean> {
return new Promise((resolve) => {
const start = Date.now();
const poll = () => {
try {
const status = Taro.getStorageSync('deviceChipStatus');
if (status === 'successfulVerification' || status === 'passTheVerification') {
this.onAuthResult?.(true);
resolve(true);
return;
}
} catch { /* ignore */ }
if (Date.now() - start >= AUTH_TIMEOUT) {
this.onAuthResult?.(false);
resolve(false);
return;
}
setTimeout(poll, AUTH_POLL_INTERVAL);
};
poll();
});
}
/** SDK 事件路由 */
private routeEvent(data: SdkEventData): void {
const eventType = data.type;
// 认证回调
if (eventType === SDK_EVENT_AUTH) {
const content = data.content ?? {};
const password = content.VPDevicepassword;
if (password === 'passTheVerification' || password === 'successfulVerification') {
this.onAuthResult?.(true);
}
return;
}
// 日常数据
if (eventType === SDK_EVENT_DAILY) {
this.onDailyData?.(data);
return;
}
// 精准睡眠数据
if (eventType === SDK_EVENT_SLEEP) {
this.handleSleepEvent(data);
return;
}
// 自动测量功能回调
if (eventType === SDK_EVENT_AUTO_TEST) {
console.log('[veepoo-pipeline] 自动测量配置回调:', JSON.stringify(data).substring(0, 300));
return;
}
// 测量数据
const measureType = SDK_TYPE_TO_MEASURE[eventType];
if (!measureType) return;
this.handleMeasureEvent(measureType, data);
this.onMeasureEvent?.(measureType, data.content ?? {});
}
/** 处理测量事件 */
private handleMeasureEvent(type: MeasureType, data: SdkEventData): void {
if (!this.pending || this.pending.type !== type) return;
const content = data.content ?? {};
// 检查设备状态错误
const deviceBusy = content.deviceBusy === true;
const notWear = content.notWear === true;
const state = data.state;
const ack = data.ack;
if (deviceBusy) {
this.rejectPending(new Error('设备正忙,请稍后重试'));
return;
}
if (notWear || state === DEVICE_STATE.NOT_WORN) {
this.rejectPending(new Error('请将手环佩戴到手腕上'));
return;
}
if (state === DEVICE_STATE.CHARGING) {
this.rejectPending(new Error('设备正在充电,请取出后重试'));
return;
}
if (state === DEVICE_STATE.LOW_BATTERY) {
this.rejectPending(new Error('设备电量不足,请充电后重试'));
return;
}
if (type === 'pressure' && ack === 2) {
this.rejectPending(new Error('设备电量不足'));
return;
}
if (type === 'pressure' && ack === 3) {
this.rejectPending(new Error('设备正在测量其他数据'));
return;
}
if (type === 'pressure' && ack === 4) {
this.rejectPending(new Error('佩戴检测未通过'));
return;
}
// 提取数值
const values = this.extractValues(type, content);
if (!values) return;
// 更新 pending 最新值
this.pending.lastValues = values;
// 对于进度型指标,检查是否完成
const progress = data.Progress;
if (progress !== undefined && progress >= 100) {
this.resolvePending(values);
return;
}
// 对于持续测量型/单次型,收到第一个有效值后延迟 settle
if (this.pending.settleTimer === null) {
this.pending.settleTimer = setTimeout(() => {
if (this.pending && this.pending.lastValues && Object.keys(this.pending.lastValues).length > 0) {
this.resolvePending(this.pending.lastValues);
}
}, MEASURE_SETTLE_DELAY);
}
}
/** 从 SDK content 提取标准化数值 */
private extractValues(type: MeasureType, content: Record<string, unknown>): Record<string, number> | null {
switch (type) {
case 'heart_rate': {
const hr = Number(content.heartRate);
if (hr >= 30 && hr <= 250) return { heart_rate: hr };
return null;
}
case 'blood_oxygen': {
const bo = Number(content.bloodOxygen);
if (bo >= 70 && bo <= 100) return { blood_oxygen: bo };
return null;
}
case 'blood_pressure': {
const high = Number(content.bloodPressureHigh);
const low = Number(content.bloodPressureLow);
if (high > 0 && low > 0) return { systolic: high, diastolic: low };
return null;
}
case 'temperature': {
const temp = Number(content.bodyTemperature);
if (temp > 30 && temp < 45) return { temperature: temp };
return null;
}
case 'pressure': {
const p = Number(content.pressure);
if (p >= 0 && p <= 100) return { pressure: p };
return null;
}
default:
return null;
}
}
/** 发起测量 */
startMeasure(type: MeasureType): Promise<MeasureResult> {
if (this.pending) {
throw new Error(`正在测量 ${this.pending.type},请等待完成`);
}
if (!this.isConnected) {
throw new Error('设备未连接');
}
return new Promise<MeasureResult>((resolve, reject) => {
const timeout = getMeasureTimeout(type);
const timer = setTimeout(() => {
this.rejectPending(new Error('测量超时,请重试'));
}, timeout);
this.pending = {
type,
resolve,
reject,
timer,
lastValue: null,
lastValues: {},
settleTimer: null,
};
// 发送 SDK 测量指令
this.sendMeasureCommand(type);
});
}
/** 取消当前测量 */
cancelMeasure(): void {
if (!this.pending) return;
this.stopMeasureCommand(this.pending.type);
if (this.pending.lastValues && Object.keys(this.pending.lastValues).length > 0) {
this.resolvePending(this.pending.lastValues);
} else {
this.rejectPending(new Error('测量已取消'));
}
}
/** 发送 SDK 测量指令 */
private sendMeasureCommand(type: MeasureType): void {
switch (type) {
case 'heart_rate':
setHeartRateMeasure(true);
break;
case 'blood_oxygen':
setBloodOxygenMeasure('start');
break;
case 'blood_pressure':
setBloodPressureMeasure('start');
break;
case 'temperature':
startTemperatureMeasure();
break;
case 'pressure':
setPressureMeasure(true);
break;
}
}
/** 发送 SDK 停止测量指令 */
private stopMeasureCommand(type: MeasureType): void {
switch (type) {
case 'heart_rate':
setHeartRateMeasure(false);
break;
case 'blood_oxygen':
setBloodOxygenMeasure('stop');
break;
case 'blood_pressure':
setBloodPressureMeasure('stop');
break;
case 'temperature':
break; // 体温是单次触发,无法停止
case 'pressure':
setPressureMeasure(false);
break;
}
}
/** 成功 resolve pending 测量 */
private resolvePending(values: Record<string, number>): void {
if (!this.pending) return;
const p = this.pending;
this.pending = null;
clearTimeout(p.timer);
if (p.settleTimer) clearTimeout(p.settleTimer);
// 停止持续测量型指标的 SDK 指令
this.stopMeasureCommand(p.type);
p.resolve({
type: p.type,
values,
measuredAt: Date.now(),
});
}
/** 失败 reject pending 测量 */
private rejectPending(error: Error): void {
if (!this.pending) return;
const p = this.pending;
this.pending = null;
clearTimeout(p.timer);
if (p.settleTimer) clearTimeout(p.settleTimer);
// 停止 SDK 指令
this.stopMeasureCommand(p.type);
p.reject(error);
}
// ── 睡眠数据 ──
/** 读取单天精准睡眠数据,返回 Promise */
readSleepData(day: number): Promise<SleepReading | null> {
if (!this.isConnected) {
return Promise.reject(new Error('设备未连接'));
}
return new Promise<SleepReading | null>((resolve) => {
this.sleepResolvers.set(day, resolve);
// 超时保护 30s
const timer = setTimeout(() => {
this.sleepResolvers.delete(day);
this.sleepTimeouts.delete(day);
resolve(null);
}, 30_000);
this.sleepTimeouts.set(day, timer);
// 发送 SDK 读取指令
readPreciseSleepData(day);
});
}
/** 读取 3 天睡眠数据 */
async readAllSleepData(): Promise<SleepReading[]> {
const results: SleepReading[] = [];
for (let day = 0; day < 3; day++) {
const sleep = await this.readSleepData(day);
if (sleep) {
results.push(sleep);
}
}
return results;
}
/** 处理 SDK 睡眠数据回调type=4 */
private handleSleepEvent(data: SdkEventData): void {
const progress = data.Progress ?? 0;
const readDay = (data as { readDay?: number }).readDay ?? 0;
// 进度未达 100% 忽略
if (progress < 100) return;
const content = data.content ?? {};
const sleep = this.parseSleepData(readDay, content as Record<string, unknown>);
// 通知回调
if (sleep) {
this.onSleepData?.(readDay, sleep);
}
// resolve 等待中的 Promise
const resolve = this.sleepResolvers.get(readDay);
if (resolve) {
const timer = this.sleepTimeouts.get(readDay);
if (timer) clearTimeout(timer);
this.sleepResolvers.delete(readDay);
this.sleepTimeouts.delete(readDay);
resolve(sleep);
}
}
/** 从 SDK content 解析精准睡眠数据 */
private parseSleepData(day: number, content: Record<string, unknown>): SleepReading | null {
const total = Number(content.sleepTotalTime ?? 0);
if (total <= 0) return null;
return {
day,
deepSleepMinutes: Number(content.deepSleepTime ?? 0),
lightSleepMinutes: Number(content.lightSleepTime ?? 0),
otherSleepMinutes: Number(content.otherSleepTime ?? 0),
totalSleepMinutes: total,
qualityScore: Number(content.sleepQuality ?? 0),
fallAsleepTime: String(content.fallAsleepTime ?? ''),
exitSleepTime: String(content.exitSleepTime ?? ''),
};
}
// ── 自动测量 ──
/** 开启自动测量(心率 + 血压 + 血氧 + 体温) */
enableAutoMeasurement(): void {
if (!this.isConnected) return;
console.log('[veepoo-pipeline] 开启自动测量功能');
setAutoHeartRate(true);
setAutoBloodPressure(true);
setAutoTemperature(true);
// 读取当前自动测量配置
readAutoTestConfig();
}
/** 断开连接 */
async disconnect(): Promise<void> {
if (this.pending) {
this.rejectPending(new Error('设备已断开'));
}
this.isConnected = false;
this.deviceId = '';
await veepooDisconnect();
}
/** 获取连接状态 */
getConnected(): boolean {
return this.isConnected;
}
/** 获取设备 ID */
getDeviceId(): string {
return this.deviceId;
}
}
function delay(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
function getMeasureTimeout(type: MeasureType): number {
const timeouts: Record<MeasureType, number> = {
heart_rate: 60_000,
blood_oxygen: 60_000,
blood_pressure: 120_000,
temperature: 60_000,
pressure: 90_000,
};
return timeouts[type];
}

View File

@@ -0,0 +1,21 @@
export { VeepooPipeline } from './VeepooPipeline';
export { VeepooHistoryReader } from './VeepooHistoryReader';
export type {
ConnectionChangeCallback,
AuthResultCallback,
MeasureEventCallback,
DailyDataCallback,
} from './VeepooPipeline';
export type {
MeasureType,
MeasurePhase,
MeasureStatus,
MeasureResult,
MeasureConfig,
ConnectionPhase,
VeepooDeviceInfo,
HistorySyncState,
SleepReading,
AutoTestSyncState,
} from './types';
export { MEASURE_TYPES, MEASURE_CONFIG } from './types';

View File

@@ -0,0 +1,152 @@
/** Veepoo 管线专用类型定义 */
/** 测量指标类型 */
export type MeasureType =
| 'heart_rate'
| 'blood_oxygen'
| 'blood_pressure'
| 'temperature'
| 'pressure';
/** 所有支持的测量指标 */
export const MEASURE_TYPES: readonly MeasureType[] = [
'heart_rate',
'blood_oxygen',
'blood_pressure',
'temperature',
'pressure',
] as const;
/** 测量指标配置 */
export interface MeasureConfig {
label: string;
unit: string;
icon: string;
color: string;
/** 正常范围 [min, max] */
normalRange: [number, number];
/** 测量超时(毫秒) */
timeout: number;
/** 测量模式 */
mode: 'continuous' | 'progress' | 'single';
}
/** 各指标配置表 */
export const MEASURE_CONFIG: Record<MeasureType, MeasureConfig> = {
heart_rate: {
label: '心率',
unit: 'bpm',
icon: '♥',
color: '#EF4444',
normalRange: [60, 100],
timeout: 60_000,
mode: 'continuous',
},
blood_oxygen: {
label: '血氧',
unit: '%',
icon: 'O₂',
color: '#3B82F6',
normalRange: [95, 100],
timeout: 60_000,
mode: 'continuous',
},
blood_pressure: {
label: '血压',
unit: 'mmHg',
icon: '↕',
color: '#8B5CF6',
normalRange: [90, 140],
timeout: 120_000,
mode: 'progress',
},
temperature: {
label: '体温',
unit: '°C',
icon: 'T',
color: '#F59E0B',
normalRange: [36.0, 37.3],
timeout: 60_000,
mode: 'single',
},
pressure: {
label: '压力',
unit: '',
icon: '~',
color: '#6366F1',
normalRange: [1, 40],
timeout: 90_000,
mode: 'progress',
},
};
/** 连接阶段 */
export type ConnectionPhase =
| 'idle'
| 'scanning'
| 'connecting'
| 'authenticating'
| 'ready'
| 'disconnected'
| 'error';
/** 测量阶段 */
export type MeasurePhase = 'idle' | 'measuring' | 'success' | 'error';
/** 单个指标的测量状态 */
export interface MeasureStatus {
phase: MeasurePhase;
progress: number;
currentValue: number | null;
result: MeasureResult | null;
error: string | null;
}
/** 测量结果 */
export interface MeasureResult {
type: MeasureType;
values: Record<string, number>;
measuredAt: number;
}
/** 设备信息 */
export interface VeepooDeviceInfo {
deviceId: string;
name: string;
batteryLevel: number | null;
}
/** 历史数据同步状态 */
export interface HistorySyncState {
phase: 'idle' | 'reading' | 'uploading' | 'done';
progress: number;
packagesRead: number;
lastCheckpoint: number;
}
/** 睡眠数据(从 SDK 精准睡眠解析) */
export interface SleepReading {
/** 读取天数0=今天, 1=昨天, 2=前天) */
day: number;
/** 深睡时长(分钟) */
deepSleepMinutes: number;
/** 浅睡时长(分钟) */
lightSleepMinutes: number;
/** 其他睡眠时长(分钟) */
otherSleepMinutes: number;
/** 睡眠总时长(分钟) */
totalSleepMinutes: number;
/** 睡眠质量评分1-5 星) */
qualityScore: number;
/** 入睡时间(时间戳字符串) */
fallAsleepTime: string;
/** 退出睡眠时间(时间戳字符串) */
exitSleepTime: string;
}
/** 自动测量同步状态 */
export interface AutoTestSyncState {
phase: 'idle' | 'reading_config' | 'configuring' | 'configured';
enabledTypes: string[];
intervalMinutes: number;
}

View File

@@ -148,13 +148,19 @@ export const useAuthStore = create<AuthState>((set, get) => ({
get().loadPatients();
return true;
}
// 未绑定:存储 openid 供后续绑定流程使用
if (!resp.openid) {
set({ loading: false });
throw new Error('登录失败:服务器未返回用户标识');
}
secureSet('wechat_openid', resp.openid);
set({ loading: false });
return false;
} catch (err) {
console.warn('[auth] 微信登录失败:', err);
set({ loading: false });
return false;
// 不吞掉错误 — 让调用方区分"未绑定"和"真正的错误"
throw err;
}
},
@@ -243,7 +249,8 @@ export const useAuthStore = create<AuthState>((set, get) => ({
loadPatients: async () => {
try {
const summaries = await authApi.getPatientSummaries();
const userId = get().user?.id;
const summaries = await authApi.getPatientSummaries(userId);
const patients: authApi.PatientInfo[] = summaries.map((p) => ({
id: p.id,
name: p.name,

View File

@@ -0,0 +1,335 @@
import { create } from 'zustand';
import { VeepooPipeline } from '@/services/ble/veepoo/VeepooPipeline';
import { VeepooHistoryReader } from '@/services/ble/veepoo/VeepooHistoryReader';
import type {
MeasureType,
MeasureStatus,
MeasureResult,
ConnectionPhase,
VeepooDeviceInfo,
HistorySyncState,
SleepReading,
} from '@/services/ble/veepoo/types';
import { MEASURE_TYPES } from '@/services/ble/veepoo/types';
import { useAuthStore } from './auth';
/** 初始化每个指标的默认状态 */
function initialMeasureStates(): Record<MeasureType, MeasureStatus> {
const states = {} as Record<MeasureType, MeasureStatus>;
for (const t of MEASURE_TYPES) {
states[t] = { phase: 'idle', progress: 0, currentValue: null, result: null, error: null };
}
return states;
}
interface VeepooState {
// 连接
connectionPhase: ConnectionPhase;
device: VeepooDeviceInfo | null;
error: string | null;
// 测量
activeMeasure: MeasureType | null;
measureStates: Record<MeasureType, MeasureStatus>;
// 历史
historySync: HistorySyncState;
// 睡眠
sleepData: SleepReading[];
sleepLoading: boolean;
// Actions
connect: (targetName?: string) => Promise<void>;
disconnect: () => Promise<void>;
startMeasure: (type: MeasureType) => Promise<MeasureResult>;
cancelMeasure: () => void;
syncHistory: (patientId: string) => Promise<void>;
readSleepData: () => Promise<SleepReading[]>;
enableAutoMeasurement: () => void;
reset: () => void;
}
let pipelineInstance: VeepooPipeline | null = null;
let historyReaderInstance: VeepooHistoryReader | null = null;
function getPipeline(): VeepooPipeline {
if (!pipelineInstance) {
pipelineInstance = new VeepooPipeline();
}
return pipelineInstance;
}
function getHistoryReader(): VeepooHistoryReader {
if (!historyReaderInstance) {
historyReaderInstance = new VeepooHistoryReader();
}
return historyReaderInstance;
}
export const useVeepooStore = create<VeepooState>((set, get) => ({
connectionPhase: 'idle',
device: null,
error: null,
activeMeasure: null,
measureStates: initialMeasureStates(),
historySync: { phase: 'idle', progress: 0, packagesRead: 0, lastCheckpoint: 0 },
sleepData: [],
sleepLoading: false,
connect: async (targetName = 'M2') => {
console.log('[veepoo-store] connect() 开始, target:', targetName);
set({ connectionPhase: 'scanning', error: null });
const pipeline = getPipeline();
const historyReader = getHistoryReader();
// 注册全部回调(包含新增的 onSleepData
pipeline.setCallbacks({
onConnectionChange: (connected) => {
if (!connected) {
set({ connectionPhase: 'disconnected', device: null });
}
},
onAuthResult: (success) => {
if (success) {
set({ connectionPhase: 'ready' });
}
},
onMeasureEvent: (type, data) => {
const state = get();
if (state.activeMeasure !== type) return;
const value = extractDisplayValue(type, data);
set({
measureStates: {
...state.measureStates,
[type]: {
...state.measureStates[type],
phase: 'measuring',
progress: (data.Progress ?? data.progress ?? 0) as number,
currentValue: value,
},
},
});
},
onDailyData: (data) => {
// 转发给 HistoryReader 处理
historyReader.handleDailyEvent(data);
const progress = data.Progress ?? 0;
set((s) => ({
historySync: { ...s.historySync, progress: progress as number },
}));
},
onSleepData: (_day, sleep) => {
// 收集睡眠数据到 store
set((s) => ({
sleepData: [...s.sleepData, sleep],
}));
},
});
// 注册 HistoryReader 进度回调
historyReader.setCallbacks({
onProgress: (progress, phase) => {
set((s) => ({
historySync: {
...s.historySync,
phase: phase === 'uploading' ? 'uploading' : 'reading',
progress,
},
}));
},
});
try {
set({ connectionPhase: 'connecting' });
const deviceId = await pipeline.connect(targetName);
set({
connectionPhase: 'authenticating',
device: { deviceId, name: targetName, batteryLevel: null },
});
// 认证结果由 onAuthResult 回调设置
// 等待 ready 状态(最多 10s
await waitForState(() => get().connectionPhase === 'ready', 10_000);
// 认证通过后:自动同步历史 + 读取睡眠 + 开启自动测量
const patient = useAuthStore.getState().currentPatient;
const readyState = get().connectionPhase === 'ready';
if (patient && readyState) {
const deviceIdForReader = get().device?.deviceId ?? 'veepoo_m2';
// 并行执行三件事:
// 1. 同步日常历史数据(后台执行,进度通过回调更新)
get().syncHistory(patient.id);
// 2. 读取睡眠数据 → 完成后自动上传
get().readSleepData().then((sleepResults) => {
if (sleepResults.length > 0) {
historyReader.uploadSleepReadings(patient.id, deviceIdForReader, sleepResults);
}
});
// 3. 开启自动测量(心率+血压+体温)
pipeline.enableAutoMeasurement();
}
} catch (err) {
console.error('[veepoo-store] connect 失败:', err);
set({
connectionPhase: 'error',
error: err instanceof Error ? err.message : '连接失败',
});
}
},
disconnect: async () => {
const pipeline = getPipeline();
await pipeline.disconnect();
set({
connectionPhase: 'idle',
device: null,
error: null,
activeMeasure: null,
measureStates: initialMeasureStates(),
sleepData: [],
sleepLoading: false,
});
},
startMeasure: async (type: MeasureType) => {
const state = get();
if (state.activeMeasure) {
throw new Error(`正在测量 ${state.activeMeasure},请等待完成`);
}
if (state.connectionPhase !== 'ready') {
throw new Error('设备未就绪');
}
set({
activeMeasure: type,
measureStates: {
...state.measureStates,
[type]: { phase: 'measuring', progress: 0, currentValue: null, result: null, error: null },
},
});
const pipeline = getPipeline();
try {
const result = await pipeline.startMeasure(type);
set((s) => ({
activeMeasure: null,
measureStates: {
...s.measureStates,
[type]: { phase: 'success', progress: 100, currentValue: null, result, error: null },
},
}));
return result;
} catch (err) {
const msg = err instanceof Error ? err.message : '测量失败';
set((s) => ({
activeMeasure: null,
measureStates: {
...s.measureStates,
[type]: { phase: 'error', progress: 0, currentValue: null, result: null, error: msg },
},
}));
throw err;
}
},
cancelMeasure: () => {
const pipeline = getPipeline();
pipeline.cancelMeasure();
},
syncHistory: async (patientId: string) => {
const deviceId = get().device?.deviceId ?? 'veepoo_m2';
set((s) => ({ historySync: { ...s.historySync, phase: 'reading', progress: 0 } }));
try {
const historyReader = getHistoryReader();
const count = await historyReader.startRead(patientId, deviceId);
set((s) => ({
historySync: { ...s.historySync, phase: 'done', progress: 100, packagesRead: count },
}));
console.log('[veepoo-store] 历史数据同步完成, 上传:', count, '条');
} catch (err) {
console.error('[veepoo-store] 历史数据同步失败:', err);
set((s) => ({ historySync: { ...s.historySync, phase: 'done', progress: 100 } }));
}
},
readSleepData: async () => {
const pipeline = getPipeline();
if (!pipeline.getConnected()) {
console.warn('[veepoo-store] 设备未连接,跳过睡眠数据读取');
return [];
}
set({ sleepLoading: true, sleepData: [] });
try {
const sleepResults = await pipeline.readAllSleepData();
set({ sleepData: sleepResults, sleepLoading: false });
console.log('[veepoo-store] 睡眠数据读取完成:', sleepResults.length, '天');
return sleepResults;
} catch (err) {
console.error('[veepoo-store] 睡眠数据读取失败:', err);
set({ sleepLoading: false });
return [];
}
},
enableAutoMeasurement: () => {
const pipeline = getPipeline();
pipeline.enableAutoMeasurement();
},
reset: () => {
set({
connectionPhase: 'idle',
device: null,
error: null,
activeMeasure: null,
measureStates: initialMeasureStates(),
historySync: { phase: 'idle', progress: 0, packagesRead: 0, lastCheckpoint: 0 },
sleepData: [],
sleepLoading: false,
});
},
}));
/** 从 SDK 事件 content 提取显示值 */
function extractDisplayValue(type: MeasureType, content: Record<string, unknown>): number | null {
switch (type) {
case 'heart_rate': {
const v = Number(content.heartRate);
return v >= 30 && v <= 250 ? v : null;
}
case 'blood_oxygen': {
const v = Number(content.bloodOxygen);
return v >= 70 && v <= 100 ? v : null;
}
case 'blood_pressure':
return Number(content.bloodPressureHigh) || null;
case 'temperature':
return Number(content.bodyTemperature) || null;
case 'pressure':
return Number(content.pressure) || null;
default:
return null;
}
}
/** 轮询等待状态满足条件 */
function waitForState(check: () => boolean, timeoutMs: number): Promise<void> {
return new Promise((resolve, reject) => {
const start = Date.now();
const poll = () => {
if (check()) { resolve(); return; }
if (Date.now() - start >= timeoutMs) { reject(new Error('等待超时')); return; }
setTimeout(poll, 200);
};
poll();
});
}

View File

@@ -16,7 +16,7 @@ export function showToast(options: {
const duration = options.duration ?? (mode === 'elder' ? 3000 : 1500);
if (mode === 'elder') {
try { Taro.vibrateShort({ type: 'light' }); } catch { /* 不支持时静默 */ }
Taro.vibrateShort({ type: 'light' }).catch(() => {});
}
Taro.showToast({ ...options, duration, icon: options.icon ?? 'none' });

View File

@@ -2,21 +2,15 @@ import Taro from '@tarojs/taro';
/** 轻触反馈(按钮点击) */
export function hapticLight(): void {
try {
Taro.vibrateShort({ type: 'light' });
} catch { /* 部分设备不支持 */ }
Taro.vibrateShort({ type: 'light' }).catch(() => { /* DevTools 不支持 type 参数,真机正常 */ });
}
/** 中等反馈(成功操作) */
export function hapticMedium(): void {
try {
Taro.vibrateShort({ type: 'medium' });
} catch { /* ignore */ }
Taro.vibrateShort({ type: 'medium' }).catch(() => { /* ignore */ });
}
/** 重度反馈(错误/警告) */
export function hapticHeavy(): void {
try {
Taro.vibrateShort({ type: 'heavy' });
} catch { /* ignore */ }
Taro.vibrateShort({ type: 'heavy' }).catch(() => { /* ignore */ });
}

View File

@@ -46,7 +46,7 @@ const AiPromptList = lazy(() => import('./pages/health/AiPromptList'));
const AiAnalysisList = lazy(() => import('./pages/health/AiAnalysisList'));
const AiUsageDashboard = lazy(() => import('./pages/health/AiUsageDashboard'));
const AiConfigPage = lazy(() => import('./pages/health/AiConfigPage'));
const AiKnowledgePage = lazy(() => import('./pages/health/AiKnowledgePage'));
const KnowledgeV2Page = lazy(() => import('./pages/ai/KnowledgeV2Page'));
const AiChatPage = lazy(() => import('./pages/ai/ChatPage'));
const AlertList = lazy(() => import('./pages/health/AlertList'));
const AlertDashboard = lazy(() => import('./pages/health/AlertDashboard'));
@@ -330,7 +330,7 @@ export default function App() {
<Route path="/health/ai-analysis" element={<AiAnalysisList />} />
<Route path="/health/ai-usage" element={<AiUsageDashboard />} />
<Route path="/health/ai-config" element={<AiConfigPage />} />
<Route path="/health/ai-knowledge" element={<AiKnowledgePage />} />
<Route path="/health/ai-knowledge" element={<KnowledgeV2Page />} />
<Route path="/ai/chat" element={<AiChatPage />} />
<Route path="/health/alerts" element={<AlertList />} />
<Route path="/health/alert-dashboard" element={<AlertDashboard />} />

View File

@@ -1,110 +0,0 @@
import client from '../client';
// === Types ===
export interface KnowledgeReference {
id: string;
tenant_id: string;
title: string;
analysis_type: string;
source_name: string;
content_summary: string;
tags: Record<string, unknown> | null;
is_enabled: boolean;
created_at: string;
updated_at: string;
}
export interface KnowledgeGuide {
id: string;
tenant_id: string;
title: string;
analysis_type: string;
content: string;
category: string | null;
is_enabled: boolean;
created_at: string;
updated_at: string;
}
export interface CreateReferenceReq {
title: string;
analysis_type: string;
source_name: string;
content_summary: string;
tags?: Record<string, unknown>;
is_enabled?: boolean;
}
export interface UpdateReferenceReq {
title?: string;
analysis_type?: string;
source_name?: string;
content_summary?: string;
tags?: Record<string, unknown>;
is_enabled?: boolean;
}
export interface CreateGuideReq {
title: string;
analysis_type: string;
content: string;
category?: string;
is_enabled?: boolean;
}
export interface UpdateGuideReq {
title?: string;
analysis_type?: string;
content?: string;
category?: string;
is_enabled?: boolean;
}
// === API ===
export const knowledgeApi = {
// References
listReferences: async (params?: { analysis_type?: string }) => {
const resp = await client.get('/ai/knowledge/references', { params });
return resp.data.data as { data: KnowledgeReference[]; total: number };
},
createReference: async (data: CreateReferenceReq) => {
const resp = await client.post('/ai/knowledge/references', data);
return resp.data.data as { id: string };
},
updateReference: async (id: string, data: UpdateReferenceReq) => {
const resp = await client.put(`/ai/knowledge/references/${id}`, data);
return resp.data.data as { id: string };
},
deleteReference: async (id: string) => {
const resp = await client.delete(`/ai/knowledge/references/${id}`);
return resp.data.data as { id: string };
},
reEmbedReference: async (id: string) => {
const resp = await client.post(`/ai/knowledge/references/${id}/re-embed`);
return resp.data.data as { id: string };
},
// Guides
listGuides: async (params?: { analysis_type?: string }) => {
const resp = await client.get('/ai/knowledge/guides', { params });
return resp.data.data as { data: KnowledgeGuide[]; total: number };
},
createGuide: async (data: CreateGuideReq) => {
const resp = await client.post('/ai/knowledge/guides', data);
return resp.data.data as { id: string };
},
updateGuide: async (id: string, data: UpdateGuideReq) => {
const resp = await client.put(`/ai/knowledge/guides/${id}`, data);
return resp.data.data as { id: string };
},
deleteGuide: async (id: string) => {
const resp = await client.delete(`/ai/knowledge/guides/${id}`);
return resp.data.data as { id: string };
},
reEmbedGuide: async (id: string) => {
const resp = await client.post(`/ai/knowledge/guides/${id}/re-embed`);
return resp.data.data as { id: string };
},
};

View File

@@ -0,0 +1,188 @@
import client from '../client';
// === Types ===
export interface KnowledgeBase {
id: string;
tenant_id: string;
name: string;
kb_type: string;
description: string | null;
icon: string | null;
chunk_strategy: Record<string, unknown>;
intent_keywords: Record<string, unknown>;
embedding_model: string | null;
is_enabled: boolean;
document_count: number;
chunk_count: number;
created_at: string;
updated_at: string;
}
export interface KnowledgeDocument {
id: string;
tenant_id: string;
knowledge_base_id: string;
title: string;
doc_type: string;
source_type: string;
source_url: string | null;
file_name: string | null;
file_size: number | null;
file_mime_type: string | null;
content: string | null;
status: string;
chunk_count: number;
embedded_count: number;
error_message: string | null;
processing_started_at: string | null;
processing_completed_at: string | null;
created_at: string;
updated_at: string;
}
export interface SearchHit {
chunk_id: string;
document_id: string;
chunk_index: number;
content: string;
doc_title: string;
similarity: number;
metadata: Record<string, unknown>;
}
export interface CreateKnowledgeBaseReq {
name: string;
kb_type: string;
description?: string;
icon?: string;
chunk_strategy?: Record<string, unknown>;
intent_keywords?: Record<string, unknown>;
embedding_model?: string;
is_enabled?: boolean;
}
export interface UpdateKnowledgeBaseReq {
name?: string;
kb_type?: string;
description?: string;
icon?: string;
chunk_strategy?: Record<string, unknown>;
intent_keywords?: Record<string, unknown>;
embedding_model?: string;
is_enabled?: boolean;
}
export interface CreateDocumentReq {
kb_id: string;
title: string;
doc_type?: string;
source_type?: string;
source_url?: string;
content?: string;
}
// === API ===
export const knowledgeV2Api = {
// Knowledge Bases
listKnowledgeBases: async (params?: {
kb_type?: string;
is_enabled?: boolean;
page?: number;
page_size?: number;
}) => {
const resp = await client.get('/ai/knowledge-bases', { params });
return resp.data.data as {
data: KnowledgeBase[];
total: number;
page: number;
page_size: number;
};
},
getKnowledgeBase: async (id: string) => {
const resp = await client.get(`/ai/knowledge-bases/${id}`);
return resp.data.data as KnowledgeBase;
},
createKnowledgeBase: async (data: CreateKnowledgeBaseReq) => {
const resp = await client.post('/ai/knowledge-bases', data);
return resp.data.data as { id: string };
},
updateKnowledgeBase: async (id: string, data: UpdateKnowledgeBaseReq) => {
const resp = await client.put(`/ai/knowledge-bases/${id}`, data);
return resp.data.data as { id: string };
},
deleteKnowledgeBase: async (id: string) => {
const resp = await client.delete(`/ai/knowledge-bases/${id}`);
return resp.data.data as { id: string };
},
// Documents
listDocuments: async (
kbId: string,
params?: { status?: string; page?: number; page_size?: number },
) => {
const resp = await client.get(
`/ai/knowledge-bases/${kbId}/documents`,
{ params },
);
return resp.data.data as {
data: KnowledgeDocument[];
total: number;
page: number;
page_size: number;
};
},
getDocument: async (id: string) => {
const resp = await client.get(`/ai/documents/${id}`);
return resp.data.data as KnowledgeDocument;
},
createManualDocument: async (data: CreateDocumentReq) => {
const resp = await client.post('/ai/documents/manual', data);
return resp.data.data as { id: string };
},
uploadDocument: async (
kbId: string,
file: File,
title?: string,
) => {
const formData = new FormData();
formData.append('kb_id', kbId);
formData.append('file', file);
if (title) {
formData.append('title', title);
}
const resp = await client.post('/ai/documents/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return resp.data.data as { id: string };
},
deleteDocument: async (kbId: string, id: string) => {
const resp = await client.delete(
`/ai/knowledge-bases/${kbId}/documents/${id}`,
);
return resp.data.data as { id: string };
},
// Hit Test
hitTest: async (kbId: string, query: string, topK?: number) => {
const resp = await client.post('/ai/documents/hit-test', {
kb_id: kbId,
query,
top_k: topK,
});
return resp.data.data as {
query: string;
total: number;
hits: SearchHit[];
};
},
};

View File

@@ -18,10 +18,10 @@ export interface UpdateUserRequest {
version: number;
}
export async function listUsers(page = 1, pageSize = 20, search = '') {
export async function listUsers(page = 1, pageSize = 20, search = '', excludeOnlyRoles?: string) {
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<UserInfo> }>(
'/users',
{ params: { page, page_size: pageSize, search: search || undefined } }
{ params: { page, page_size: pageSize, search: search || undefined, exclude_only_roles: excludeOnlyRoles } }
);
return data.data;
}

View File

@@ -52,7 +52,7 @@ export default function Users() {
const {
data: users, total, page, loading, refresh,
} = usePaginatedData<UserInfo>(async (p, pageSize, search) => {
const result = await listUsers(p, pageSize, search);
const result = await listUsers(p, pageSize, search, 'patient');
return { data: result.data, total: result.total };
}, 20);

View File

@@ -0,0 +1,566 @@
import { useCallback, useEffect, useState } from 'react';
import {
Card,
Table,
Button,
Space,
Modal,
Form,
Input,
Select,
Switch,
message,
Popconfirm,
Tag,
Upload,
Progress,
Drawer,
List,
Empty,
} from 'antd';
import {
PlusOutlined,
DeleteOutlined,
UploadOutlined,
SearchOutlined,
FileTextOutlined,
DatabaseOutlined,
} from '@ant-design/icons';
import type { UploadFile } from 'antd/es/upload/interface';
import {
knowledgeV2Api,
type KnowledgeBase,
type KnowledgeDocument,
type SearchHit,
type CreateKnowledgeBaseReq,
} from '../../api/ai/knowledgeV2';
const KB_TYPES = [
{ label: '临床指南', value: 'clinical_guide' },
{ label: '操作规程', value: 'sop' },
{ label: 'FAQ', value: 'faq' },
{ label: '产品知识', value: 'product' },
{ label: '通用', value: 'general' },
];
export default function KnowledgeV2Page() {
const [kbs, setKbs] = useState<KnowledgeBase[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [editKb, setEditKb] = useState<KnowledgeBase | null>(null);
const [form] = Form.useForm();
// Document drawer state
const [docDrawerKb, setDocDrawerKb] = useState<KnowledgeBase | null>(null);
const [docs, setDocs] = useState<KnowledgeDocument[]>([]);
const [docsLoading, setDocsLoading] = useState(false);
const [uploadModalOpen, setUploadModalOpen] = useState(false);
const [uploadKbId, setUploadKbId] = useState<string>('');
const [fileList, setFileList] = useState<UploadFile[]>([]);
// Hit test state
const [hitTestKb, setHitTestKb] = useState<KnowledgeBase | null>(null);
const [hitTestQuery, setHitTestQuery] = useState('');
const [hitResults, setHitResults] = useState<SearchHit[]>([]);
const [hitTestLoading, setHitTestLoading] = useState(false);
const loadKbs = useCallback(async () => {
setLoading(true);
try {
const res = await knowledgeV2Api.listKnowledgeBases({
page,
page_size: 20,
});
setKbs(res.data);
setTotal(res.total);
} catch {
message.error('加载知识库列表失败');
} finally {
setLoading(false);
}
}, [page]);
useEffect(() => {
loadKbs();
}, [loadKbs]);
const handleCreate = async () => {
try {
const values = await form.validateFields();
const req: CreateKnowledgeBaseReq = {
name: values.name,
kb_type: values.kb_type,
description: values.description,
is_enabled: values.is_enabled ?? true,
};
await knowledgeV2Api.createKnowledgeBase(req);
message.success('知识库创建成功');
setCreateModalOpen(false);
form.resetFields();
loadKbs();
} catch {
// validation error
}
};
const handleUpdate = async () => {
if (!editKb) return;
try {
const values = await form.validateFields();
await knowledgeV2Api.updateKnowledgeBase(editKb.id, {
name: values.name,
kb_type: values.kb_type,
description: values.description,
is_enabled: values.is_enabled,
});
message.success('知识库更新成功');
setEditKb(null);
form.resetFields();
loadKbs();
} catch {
// validation error
}
};
const handleDelete = async (id: string) => {
try {
await knowledgeV2Api.deleteKnowledgeBase(id);
message.success('知识库已删除');
loadKbs();
} catch {
message.error('删除失败');
}
};
const loadDocuments = async (kb: KnowledgeBase) => {
setDocDrawerKb(kb);
setDocsLoading(true);
try {
const res = await knowledgeV2Api.listDocuments(kb.id, {
page: 1,
page_size: 50,
});
setDocs(res.data);
} catch {
message.error('加载文档列表失败');
} finally {
setDocsLoading(false);
}
};
const handleUpload = async () => {
if (!uploadKbId || fileList.length === 0) return;
try {
const file = fileList[0].originFileObj;
if (!file) return;
await knowledgeV2Api.uploadDocument(uploadKbId, file);
message.success('文档上传成功,正在处理...');
setUploadModalOpen(false);
setFileList([]);
if (docDrawerKb) {
loadDocuments(docDrawerKb);
}
} catch {
message.error('上传失败');
}
};
const handleDeleteDoc = async (kbId: string, docId: string) => {
try {
await knowledgeV2Api.deleteDocument(kbId, docId);
message.success('文档已删除');
if (docDrawerKb) {
loadDocuments(docDrawerKb);
}
} catch {
message.error('删除失败');
}
};
const handleHitTest = async () => {
if (!hitTestKb || !hitTestQuery.trim()) return;
setHitTestLoading(true);
try {
const res = await knowledgeV2Api.hitTest(hitTestKb.id, hitTestQuery, 5);
setHitResults(res.hits);
} catch {
message.error('搜索失败');
setHitResults([]);
} finally {
setHitTestLoading(false);
}
};
const statusTag = (status: string) => {
const map: Record<string, { color: string; label: string }> = {
pending: { color: 'default', label: '待处理' },
processing: { color: 'processing', label: '处理中' },
completed: { color: 'success', label: '已完成' },
failed: { color: 'error', label: '失败' },
};
const info = map[status] || { color: 'default', label: status };
return <Tag color={info.color}>{info.label}</Tag>;
};
const kbColumns = [
{
title: '名称',
dataIndex: 'name',
key: 'name',
render: (name: string, record: KnowledgeBase) => (
<Button type="link" onClick={() => loadDocuments(record)}>
{name}
</Button>
),
},
{
title: '类型',
dataIndex: 'kb_type',
key: 'kb_type',
render: (type: string) => {
const found = KB_TYPES.find((t) => t.value === type);
return found?.label || type;
},
},
{
title: '文档数',
dataIndex: 'document_count',
key: 'document_count',
width: 90,
},
{
title: '切片数',
dataIndex: 'chunk_count',
key: 'chunk_count',
width: 90,
},
{
title: '状态',
dataIndex: 'is_enabled',
key: 'is_enabled',
width: 80,
render: (v: boolean) => (
<Tag color={v ? 'green' : 'red'}>{v ? '启用' : '禁用'}</Tag>
),
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
width: 170,
render: (v: string) => new Date(v).toLocaleString('zh-CN'),
},
{
title: '操作',
key: 'actions',
width: 240,
render: (_: unknown, record: KnowledgeBase) => (
<Space size="small">
<Button
size="small"
icon={<UploadOutlined />}
onClick={() => {
setUploadKbId(record.id);
setUploadModalOpen(true);
}}
>
</Button>
<Button
size="small"
icon={<SearchOutlined />}
onClick={() => {
setHitTestKb(record);
setHitResults([]);
setHitTestQuery('');
}}
>
</Button>
<Button
size="small"
onClick={() => {
setEditKb(record);
form.setFieldsValue({
name: record.name,
kb_type: record.kb_type,
description: record.description,
is_enabled: record.is_enabled,
});
}}
>
</Button>
<Popconfirm
title="确定删除此知识库?"
onConfirm={() => handleDelete(record.id)}
>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
];
const kbFormContent = (
<Form form={form} layout="vertical">
<Form.Item
name="name"
label="知识库名称"
rules={[{ required: true, message: '请输入知识库名称' }]}
>
<Input placeholder="例:高血压临床指南" />
</Form.Item>
<Form.Item
name="kb_type"
label="知识库类型"
rules={[{ required: true, message: '请选择类型' }]}
>
<Select options={KB_TYPES} placeholder="选择类型" />
</Form.Item>
<Form.Item name="description" label="描述">
<Input.TextArea rows={3} placeholder="知识库描述(可选)" />
</Form.Item>
<Form.Item name="is_enabled" label="启用" valuePropName="checked">
<Switch defaultChecked />
</Form.Item>
</Form>
);
const docColumns = [
{
title: '标题',
dataIndex: 'title',
key: 'title',
ellipsis: true,
},
{
title: '类型',
dataIndex: 'doc_type',
key: 'doc_type',
width: 80,
},
{
title: '来源',
dataIndex: 'source_type',
key: 'source_type',
width: 80,
render: (v: string) => <Tag>{v === 'upload' ? '上传' : '手动'}</Tag>,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 90,
render: statusTag,
},
{
title: '切片/嵌入',
key: 'progress',
width: 130,
render: (_: unknown, record: KnowledgeDocument) => {
if (record.chunk_count === 0) return '-';
const pct = Math.round(
(record.embedded_count / record.chunk_count) * 100,
);
return <Progress percent={pct} size="small" />;
},
},
{
title: '操作',
key: 'actions',
width: 70,
render: (_: unknown, record: KnowledgeDocument) => (
<Popconfirm
title="确定删除此文档?"
onConfirm={() =>
handleDeleteDoc(record.knowledge_base_id, record.id)
}
>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
),
},
];
return (
<div style={{ padding: 24 }}>
<Card
title={
<Space>
<DatabaseOutlined />
V2
</Space>
}
extra={
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
form.resetFields();
setCreateModalOpen(true);
}}
>
</Button>
}
>
<Table
rowKey="id"
columns={kbColumns}
dataSource={kbs}
loading={loading}
pagination={{
current: page,
total,
pageSize: 20,
onChange: setPage,
showTotal: (t) => `${t}`,
}}
/>
</Card>
{/* 创建知识库 Modal */}
<Modal
title="新建知识库"
open={createModalOpen}
onOk={handleCreate}
onCancel={() => {
setCreateModalOpen(false);
form.resetFields();
}}
okText="创建"
>
{kbFormContent}
</Modal>
{/* 编辑知识库 Modal */}
<Modal
title="编辑知识库"
open={!!editKb}
onOk={handleUpdate}
onCancel={() => {
setEditKb(null);
form.resetFields();
}}
okText="保存"
>
{kbFormContent}
</Modal>
{/* 文档列表 Drawer */}
<Drawer
title={
docDrawerKb
? `${docDrawerKb.name} — 文档列表`
: '文档列表'
}
open={!!docDrawerKb}
onClose={() => setDocDrawerKb(null)}
width={720}
>
<Table
rowKey="id"
columns={docColumns}
dataSource={docs}
loading={docsLoading}
pagination={false}
size="small"
locale={{ emptyText: <Empty description="暂无文档" /> }}
/>
</Drawer>
{/* 上传文档 Modal */}
<Modal
title="上传文档"
open={uploadModalOpen}
onOk={handleUpload}
onCancel={() => {
setUploadModalOpen(false);
setFileList([]);
}}
okText="上传"
>
<Upload
beforeUpload={() => false}
maxCount={1}
fileList={fileList}
onChange={({ fileList: fl }) => setFileList(fl)}
accept=".pdf,.txt,.md,.docx,.xlsx"
>
<Button icon={<UploadOutlined />}></Button>
</Upload>
<div style={{ marginTop: 8, color: '#999', fontSize: 12 }}>
PDFTXTMarkdownDOCXXLSX 20MB
</div>
</Modal>
{/* Hit Test Drawer */}
<Drawer
title={
hitTestKb ? `${hitTestKb.name} — 向量搜索测试` : '搜索测试'
}
open={!!hitTestKb}
onClose={() => {
setHitTestKb(null);
setHitResults([]);
}}
width={600}
>
<Space.Compact style={{ width: '100%', marginBottom: 16 }}>
<Input
placeholder="输入搜索文本..."
value={hitTestQuery}
onChange={(e) => setHitTestQuery(e.target.value)}
onPressEnter={handleHitTest}
/>
<Button
type="primary"
icon={<SearchOutlined />}
loading={hitTestLoading}
onClick={handleHitTest}
>
</Button>
</Space.Compact>
<List
dataSource={hitResults}
locale={{ emptyText: <Empty description="输入查询后点击搜索" /> }}
renderItem={(item) => (
<List.Item key={item.chunk_id}>
<List.Item.Meta
avatar={
<FileTextOutlined style={{ fontSize: 20, color: '#1890ff' }} />
}
title={
<Space>
<span>{item.doc_title}</span>
<Tag color="blue"> #{item.chunk_index}</Tag>
<Tag color="green">
{(item.similarity * 100).toFixed(1)}%
</Tag>
</Space>
}
description={
<div
style={{
maxHeight: 120,
overflow: 'hidden',
textOverflow: 'ellipsis',
color: '#666',
}}
>
{item.content}
</div>
}
/>
</List.Item>
)}
/>
</Drawer>
</div>
);
}

View File

@@ -1,508 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import {
Card,
Table,
Button,
Space,
Modal,
Form,
Input,
Select,
Switch,
message,
Popconfirm,
Tabs,
Tag,
Tooltip,
} from 'antd';
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
ReloadOutlined,
ThunderboltOutlined,
} from '@ant-design/icons';
import {
knowledgeApi,
type KnowledgeReference,
type KnowledgeGuide,
type CreateReferenceReq,
type UpdateReferenceReq,
type CreateGuideReq,
type UpdateGuideReq,
} from '../../api/ai/knowledge';
import { AuthButton } from '../../components/AuthButton';
const ANALYSIS_TYPES = [
{ value: 'lab_report', label: '化验报告' },
{ value: 'trend', label: '趋势分析' },
{ value: 'report_summary', label: '报告摘要' },
{ value: 'dialysis_risk', label: '透析风险' },
{ value: 'checkup_plan', label: '体检计划' },
{ value: 'follow_up', label: '随访总结' },
];
export default function AiKnowledgePage() {
return (
<Card title="AI 知识库管理">
<Tabs
items={[
{ key: 'references', label: '参考资料', children: <ReferencesTab /> },
{ key: 'guides', label: '临床指南', children: <GuidesTab /> },
]}
/>
</Card>
);
}
// === References Tab ===
function ReferencesTab() {
const [data, setData] = useState<KnowledgeReference[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<KnowledgeReference | null>(null);
const [filterType, setFilterType] = useState<string | undefined>();
const [form] = Form.useForm();
const fetchData = useCallback(async () => {
setLoading(true);
try {
const result = await knowledgeApi.listReferences(
filterType ? { analysis_type: filterType } : undefined,
);
setData(result.data);
} catch {
message.error('加载参考资料失败');
} finally {
setLoading(false);
}
}, [filterType]);
useEffect(() => {
fetchData();
}, [fetchData]);
const openCreate = () => {
setEditing(null);
form.resetFields();
form.setFieldsValue({ is_enabled: true });
setModalOpen(true);
};
const openEdit = (record: KnowledgeReference) => {
setEditing(record);
form.setFieldsValue({
title: record.title,
analysis_type: record.analysis_type,
source_name: record.source_name,
content_summary: record.content_summary,
is_enabled: record.is_enabled,
});
setModalOpen(true);
};
const handleSubmit = async () => {
const values = await form.validateFields();
try {
if (editing) {
const req: UpdateReferenceReq = {
title: values.title,
analysis_type: values.analysis_type,
source_name: values.source_name,
content_summary: values.content_summary,
is_enabled: values.is_enabled,
};
await knowledgeApi.updateReference(editing.id, req);
message.success('更新成功');
} else {
const req: CreateReferenceReq = {
title: values.title,
analysis_type: values.analysis_type,
source_name: values.source_name,
content_summary: values.content_summary,
is_enabled: values.is_enabled,
};
await knowledgeApi.createReference(req);
message.success('创建成功');
}
setModalOpen(false);
fetchData();
} catch {
message.error(editing ? '更新失败' : '创建失败');
}
};
const handleDelete = async (id: string) => {
try {
await knowledgeApi.deleteReference(id);
message.success('删除成功');
fetchData();
} catch {
message.error('删除失败');
}
};
const handleReEmbed = async (id: string) => {
try {
await knowledgeApi.reEmbedReference(id);
message.success('向量重新生成已触发');
} catch {
message.error('向量重新生成失败');
}
};
const columns = [
{ title: '标题', dataIndex: 'title', key: 'title', ellipsis: true },
{
title: '分析类型',
dataIndex: 'analysis_type',
key: 'analysis_type',
width: 120,
render: (v: string) => {
const found = ANALYSIS_TYPES.find((t) => t.value === v);
return <Tag>{found?.label ?? v}</Tag>;
},
},
{ title: '来源', dataIndex: 'source_name', key: 'source_name', width: 150, ellipsis: true },
{
title: '状态',
dataIndex: 'is_enabled',
key: 'is_enabled',
width: 80,
render: (v: boolean) => (
<Tag color={v ? 'green' : 'default'}>{v ? '启用' : '禁用'}</Tag>
),
},
{
title: '更新时间',
dataIndex: 'updated_at',
key: 'updated_at',
width: 170,
render: (v: string) => (v ? new Date(v).toLocaleString() : '-'),
},
{
title: '操作',
key: 'actions',
width: 200,
render: (_: unknown, record: KnowledgeReference) => (
<Space size="small">
<Tooltip title="编辑">
<Button type="text" icon={<EditOutlined />} onClick={() => openEdit(record)} />
</Tooltip>
<Tooltip title="重新生成向量">
<Button
type="text"
icon={<ThunderboltOutlined />}
onClick={() => handleReEmbed(record.id)}
/>
</Tooltip>
<Popconfirm
title="确定删除此参考资料?"
onConfirm={() => handleDelete(record.id)}
okText="删除"
cancelText="取消"
>
<Button type="text" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
];
return (
<>
<Space style={{ marginBottom: 16 }}>
<Select
allowClear
placeholder="按分析类型过滤"
style={{ width: 180 }}
options={ANALYSIS_TYPES}
value={filterType}
onChange={setFilterType}
/>
<AuthButton code="ai.knowledge.manage">
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button>
</AuthButton>
<Button icon={<ReloadOutlined />} onClick={fetchData}>
</Button>
</Space>
<Table
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
pagination={{ pageSize: 20, showTotal: (total) => `${total}` }}
size="small"
/>
<Modal
title={editing ? '编辑参考资料' : '新增参考资料'}
open={modalOpen}
onOk={handleSubmit}
onCancel={() => setModalOpen(false)}
width={600}
destroyOnClose
>
<Form form={form} layout="vertical">
<Form.Item name="title" label="标题" rules={[{ required: true, message: '请输入标题' }]}>
<Input />
</Form.Item>
<Form.Item
name="analysis_type"
label="分析类型"
rules={[{ required: true, message: '请选择分析类型' }]}
>
<Select options={ANALYSIS_TYPES} />
</Form.Item>
<Form.Item name="source_name" label="来源名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item
name="content_summary"
label="内容摘要"
rules={[{ required: true, message: '请输入内容摘要' }]}
>
<Input.TextArea rows={4} />
</Form.Item>
<Form.Item name="is_enabled" label="启用" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Modal>
</>
);
}
// === Guides Tab ===
function GuidesTab() {
const [data, setData] = useState<KnowledgeGuide[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<KnowledgeGuide | null>(null);
const [filterType, setFilterType] = useState<string | undefined>();
const [form] = Form.useForm();
const fetchData = useCallback(async () => {
setLoading(true);
try {
const result = await knowledgeApi.listGuides(
filterType ? { analysis_type: filterType } : undefined,
);
setData(result.data);
} catch {
message.error('加载临床指南失败');
} finally {
setLoading(false);
}
}, [filterType]);
useEffect(() => {
fetchData();
}, [fetchData]);
const openCreate = () => {
setEditing(null);
form.resetFields();
form.setFieldsValue({ is_enabled: true });
setModalOpen(true);
};
const openEdit = (record: KnowledgeGuide) => {
setEditing(record);
form.setFieldsValue({
title: record.title,
analysis_type: record.analysis_type,
content: record.content,
category: record.category,
is_enabled: record.is_enabled,
});
setModalOpen(true);
};
const handleSubmit = async () => {
const values = await form.validateFields();
try {
if (editing) {
const req: UpdateGuideReq = {
title: values.title,
analysis_type: values.analysis_type,
content: values.content,
category: values.category,
is_enabled: values.is_enabled,
};
await knowledgeApi.updateGuide(editing.id, req);
message.success('更新成功');
} else {
const req: CreateGuideReq = {
title: values.title,
analysis_type: values.analysis_type,
content: values.content,
category: values.category,
is_enabled: values.is_enabled,
};
await knowledgeApi.createGuide(req);
message.success('创建成功');
}
setModalOpen(false);
fetchData();
} catch {
message.error(editing ? '更新失败' : '创建失败');
}
};
const handleDelete = async (id: string) => {
try {
await knowledgeApi.deleteGuide(id);
message.success('删除成功');
fetchData();
} catch {
message.error('删除失败');
}
};
const handleReEmbed = async (id: string) => {
try {
await knowledgeApi.reEmbedGuide(id);
message.success('向量重新生成已触发');
} catch {
message.error('向量重新生成失败');
}
};
const columns = [
{ title: '标题', dataIndex: 'title', key: 'title', ellipsis: true },
{
title: '分析类型',
dataIndex: 'analysis_type',
key: 'analysis_type',
width: 120,
render: (v: string) => {
const found = ANALYSIS_TYPES.find((t) => t.value === v);
return <Tag>{found?.label ?? v}</Tag>;
},
},
{
title: '分类',
dataIndex: 'category',
key: 'category',
width: 100,
render: (v: string | null) => v || '-',
},
{
title: '状态',
dataIndex: 'is_enabled',
key: 'is_enabled',
width: 80,
render: (v: boolean) => (
<Tag color={v ? 'green' : 'default'}>{v ? '启用' : '禁用'}</Tag>
),
},
{
title: '更新时间',
dataIndex: 'updated_at',
key: 'updated_at',
width: 170,
render: (v: string) => (v ? new Date(v).toLocaleString() : '-'),
},
{
title: '操作',
key: 'actions',
width: 200,
render: (_: unknown, record: KnowledgeGuide) => (
<Space size="small">
<Tooltip title="编辑">
<Button type="text" icon={<EditOutlined />} onClick={() => openEdit(record)} />
</Tooltip>
<Tooltip title="重新生成向量">
<Button
type="text"
icon={<ThunderboltOutlined />}
onClick={() => handleReEmbed(record.id)}
/>
</Tooltip>
<Popconfirm
title="确定删除此临床指南?"
onConfirm={() => handleDelete(record.id)}
okText="删除"
cancelText="取消"
>
<Button type="text" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
];
return (
<>
<Space style={{ marginBottom: 16 }}>
<Select
allowClear
placeholder="按分析类型过滤"
style={{ width: 180 }}
options={ANALYSIS_TYPES}
value={filterType}
onChange={setFilterType}
/>
<AuthButton code="ai.knowledge.manage">
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button>
</AuthButton>
<Button icon={<ReloadOutlined />} onClick={fetchData}>
</Button>
</Space>
<Table
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
pagination={{ pageSize: 20, showTotal: (total) => `${total}` }}
size="small"
/>
<Modal
title={editing ? '编辑临床指南' : '新增临床指南'}
open={modalOpen}
onOk={handleSubmit}
onCancel={() => setModalOpen(false)}
width={700}
destroyOnClose
>
<Form form={form} layout="vertical">
<Form.Item name="title" label="标题" rules={[{ required: true, message: '请输入标题' }]}>
<Input />
</Form.Item>
<Form.Item
name="analysis_type"
label="分析类型"
rules={[{ required: true, message: '请选择分析类型' }]}
>
<Select options={ANALYSIS_TYPES} />
</Form.Item>
<Form.Item name="category" label="分类">
<Input placeholder="如:心血管、内分泌" />
</Form.Item>
<Form.Item
name="content"
label="指南内容"
rules={[{ required: true, message: '请输入指南内容' }]}
>
<Input.TextArea rows={8} />
</Form.Item>
<Form.Item name="is_enabled" label="启用" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Modal>
</>
);
}

View File

@@ -240,6 +240,14 @@ where
let provider_name = provider_arc.name().to_string();
let supports_fc = provider_name != "ollama"; // Ollama generate_with_tools 未实现
// 收集 token 和 display_hints
#[allow(unused_assignments)]
let mut input_tokens: u32 = 0;
#[allow(unused_assignments)]
let mut output_tokens: u32 = 0;
let mut duration_ms: u64 = 0;
let mut collected_hints: Option<Vec<crate::agent::tool::DisplayHint>> = None;
let result = if supports_fc {
// FC provider执行完整 Agent ReAct 循环
let orchestrator = AgentOrchestrator::new(provider_arc, std::sync::Arc::new(registry));
@@ -256,6 +264,11 @@ where
tracing::error!(error = %e, "AI Agent run failed");
erp_core::error::AppError::Internal("AI 服务暂时不可用,请稍后再试".into())
})?;
input_tokens = agent_result.total_input_tokens;
output_tokens = agent_result.total_output_tokens;
if !agent_result.display_hints.is_empty() {
collected_hints = Some(agent_result.display_hints);
}
agent_result.reply
} else {
// 非 FC provider降级为普通对话
@@ -279,6 +292,9 @@ where
tracing::error!(error = %e, "AI generate failed");
erp_core::error::AppError::Internal("AI 服务暂时不可用,请稍后再试".into())
})?;
input_tokens = resp.input_tokens;
output_tokens = resp.output_tokens;
duration_ms = resp.duration_ms;
resp.content
};
@@ -297,7 +313,7 @@ where
"AI chat response sent"
);
// 记录用量的 token 消耗(简化模式下无法精确计量,记 0
// 记录用量的 token 消耗
if let Err(e) = ai_state
.usage
.log_usage(
@@ -305,9 +321,9 @@ where
&provider_name,
&run_params.model,
"chat",
0,
0,
0,
input_tokens,
output_tokens,
duration_ms,
0,
false,
)
@@ -362,7 +378,7 @@ where
reply,
message_id,
iterations: if supports_fc { 1 } else { 0 },
display_hints: None,
display_hints: collected_hints,
})))
}

View File

@@ -0,0 +1,87 @@
/// 文本切片:按固定大小 + 重叠切分
pub fn chunk_text(text: &str, chunk_size: usize, overlap: usize) -> Vec<String> {
if text.is_empty() {
return vec![];
}
let chars: Vec<char> = text.chars().collect();
let total = chars.len();
if total <= chunk_size {
return vec![text.to_string()];
}
let mut chunks = Vec::new();
let mut start = 0;
while start < total {
let end = (start + chunk_size).min(total);
let chunk: String = chars[start..end].iter().collect();
let trimmed = chunk.trim().to_string();
if !trimmed.is_empty() {
chunks.push(trimmed);
}
if end >= total {
break;
}
start += chunk_size.saturating_sub(overlap);
// 防止无限循环
if start <= end - chunk_size && start > 0 {
start = end;
}
}
chunks
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_chunk_empty() {
assert_eq!(chunk_text("", 100, 20), Vec::<String>::new());
}
#[test]
fn test_chunk_small_text() {
let text = "hello world";
let chunks = chunk_text(text, 100, 20);
assert_eq!(chunks.len(), 1);
assert_eq!(chunks[0], "hello world");
}
#[test]
fn test_chunk_long_text() {
let text = "abcdefghij".repeat(100); // 1000 chars
let chunks = chunk_text(&text, 200, 50);
assert!(chunks.len() > 1);
// First chunk should be 200 chars
assert_eq!(chars_count(&chunks[0]), 200);
}
#[test]
fn test_chunk_with_overlap() {
let text = "abcdefghijklmnopqrstuvwxyz".repeat(20); // 520 chars
let chunks = chunk_text(&text, 100, 20);
assert!(chunks.len() > 1);
}
#[test]
fn test_chunk_chinese() {
let text = "你好世界这是一段中文测试文本。".repeat(30);
let chunks = chunk_text(&text, 100, 20);
assert!(chunks.len() > 1);
// 确保中文不被截断
for chunk in &chunks {
assert!(!chunk.is_empty());
}
}
fn chars_count(s: &str) -> usize {
s.chars().count()
}
}

View File

@@ -7,6 +7,7 @@ pub mod chat_session;
pub mod comparison;
pub mod cost;
pub mod dialysis_risk_scorer;
pub mod document;
pub mod embedding;
pub mod feature_flag_service;
pub mod insight_service;

View File

@@ -21,6 +21,9 @@ pub struct UserListParams {
pub page_size: Option<u64>,
/// Optional search term — filters by username (case-insensitive contains).
pub search: Option<String>,
/// Exclude users whose *only* role is one of these comma-separated role codes.
/// Example: `exclude_only_roles=patient` hides users that have no role other than "patient".
pub exclude_only_roles: Option<String>,
}
#[utoipa::path(
@@ -54,10 +57,17 @@ where
page: params.page,
page_size: params.page_size,
};
let exclude_only_roles: Option<Vec<String>> = params
.exclude_only_roles
.as_deref()
.filter(|s| !s.is_empty())
.map(|s| s.split(',').map(|c| c.trim().to_string()).collect());
let (users, total) = UserService::list(
ctx.tenant_id,
&pagination,
params.search.as_deref(),
exclude_only_roles.as_deref(),
&state.db,
)
.await?;

View File

@@ -19,8 +19,54 @@ type ScopeCacheEntry = (DeptIds, DataScopes, std::time::Instant);
static USER_SCOPE_CACHE: std::sync::LazyLock<DashMap<uuid::Uuid, ScopeCacheEntry>> =
std::sync::LazyLock::new(DashMap::new);
/// Access Token 吊销黑名单token_hash -> 过期时间戳)
/// key = SHA-256(token) 前 16 字符value = token 的 exp 时间戳
/// 惰性清理:检查时自动移除过期条目
static TOKEN_BLACKLIST: std::sync::LazyLock<DashMap<String, i64>> =
std::sync::LazyLock::new(DashMap::new);
const SCOPE_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(60);
/// 吊销单个 access token直到其自然过期
pub fn revoke_access_token(token: &str, exp: i64) {
let hash = token_hash(token);
TOKEN_BLACKLIST.insert(hash, exp);
}
/// 吊销用户所有 token清除权限缓存强制下次请求重新认证
pub fn revoke_all_user_tokens(user_id: uuid::Uuid) {
USER_SCOPE_CACHE.remove(&user_id);
}
/// 检查 token 是否已被吊销
fn is_token_revoked(token: &str, _exp: i64) -> bool {
let now = chrono::Utc::now().timestamp();
// 惰性清理过期条目
if TOKEN_BLACKLIST.len() > 10_000 {
TOKEN_BLACKLIST.retain(|_, exp_ts| *exp_ts > now);
}
let hash = token_hash(token);
match TOKEN_BLACKLIST.get(&hash) {
Some(exp_ts) => {
if *exp_ts <= now {
drop(exp_ts);
TOKEN_BLACKLIST.remove(&hash);
false
} else {
true
}
}
None => false,
}
}
fn token_hash(token: &str) -> String {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
token.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
/// JWT authentication middleware function.
///
/// Extracts the `Bearer` token from the `Authorization` header, validates it
@@ -71,6 +117,11 @@ pub async fn jwt_auth_middleware_fn(
let claims =
TokenService::decode_token(&token, &jwt_secret).map_err(|_| AppError::Unauthorized)?;
// 检查 token 是否已被吊销(密码修改/管理员强制下线)
if is_token_revoked(&token, claims.exp) {
return Err(AppError::Unauthorized);
}
// Verify this is an access token, not a refresh token
if claims.token_type != "access" {
return Err(AppError::Unauthorized);

View File

@@ -1,4 +1,4 @@
pub mod jwt_auth;
pub use erp_core::rbac::{require_any_permission, require_permission, require_role};
pub use jwt_auth::jwt_auth_middleware_fn;
pub use jwt_auth::{jwt_auth_middleware_fn, revoke_access_token, revoke_all_user_tokens};

View File

@@ -23,12 +23,23 @@ impl AuthModule {
/// These routes do not require a valid JWT token.
/// The caller wraps this into whatever state type the application uses.
pub fn public_routes<S>() -> Router<S>
where
crate::auth_state::AuthState: axum::extract::FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Router::new().route("/auth/login", axum::routing::post(auth_handler::login))
}
/// WeChat public routes — separate from login to allow higher rate limits.
///
/// Mobile users may retry more frequently, so these use 30 req/min
/// instead of the strict 5 req/min for password login.
pub fn wechat_routes<S>() -> Router<S>
where
crate::auth_state::AuthState: axum::extract::FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Router::new()
.route("/auth/login", axum::routing::post(auth_handler::login))
.route(
"/auth/wechat/login",
axum::routing::post(wechat_handler::wechat_login),

View File

@@ -5,6 +5,7 @@ use uuid::Uuid;
use crate::dto::{LoginResp, RoleResp, UserResp};
use crate::entity::{role, user, user_credential, user_role};
use crate::error::AuthError;
use crate::middleware::revoke_all_user_tokens as revoke_access_token_cache;
use erp_core::audit::AuditLog;
use erp_core::audit_service;
use erp_core::events::EventBus;
@@ -284,6 +285,9 @@ impl AuthService {
) -> AuthResult<()> {
TokenService::revoke_all_user_tokens(user_id, tenant_id, db).await?;
// 清除 access token 权限缓存,强制重新认证
revoke_access_token_cache(user_id);
// 审计:登出
audit_service::record(
AuditLog::new(tenant_id, Some(user_id), "user.logout", "user")
@@ -351,6 +355,9 @@ impl AuthService {
// 4. Revoke all refresh tokens — force re-login on all devices
TokenService::revoke_all_user_tokens(user_id, tenant_id, db).await?;
// 清除 access token 权限缓存,密码修改后所有已签发的 access token 强制失效
revoke_access_token_cache(user_id);
// 审计:密码修改
audit_service::record(
AuditLog::new(tenant_id, Some(user_id), "user.change_password", "user")

View File

@@ -144,10 +144,15 @@ impl UserService {
///
/// Returns `(users, total_count)`. When `search` is provided, filters
/// by username using case-insensitive substring match.
///
/// When `exclude_only_roles` is provided, users whose *only* role is one
/// of the listed role codes are excluded (e.g. `["patient"]` hides
/// patient-only users from the staff management page).
pub async fn list(
tenant_id: Uuid,
pagination: &Pagination,
search: Option<&str>,
exclude_only_roles: Option<&[String]>,
db: &sea_orm::DatabaseConnection,
) -> AuthResult<(Vec<UserResp>, u64)> {
let mut query = user::Entity::find()
@@ -161,6 +166,56 @@ impl UserService {
query = query.filter(Expr::col(user::Column::Username).like(format!("%{}%", term)));
}
// Exclude users whose only role is one of the excluded role codes.
// Two-step approach: first find user_ids that have ONLY excluded roles
// via raw SQL, then exclude them from the main query.
if let Some(roles) = exclude_only_roles
&& !roles.is_empty()
{
use sea_orm::{ConnectionTrait, Statement};
let codes: Vec<String> = roles
.iter()
.map(|r| format!("'{}'", r.replace('\'', "''")))
.collect();
let codes_csv = codes.join(",");
// Find user_ids whose ONLY roles are in the excluded list.
// A user qualifies if:
// - they have at least one role in the excluded list
// - they have ZERO roles outside the excluded list
let excluded: Vec<Uuid> = db
.query_all(Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
format!(
r#"SELECT u.id FROM users u
WHERE u.tenant_id = $1 AND u.deleted_at IS NULL
AND EXISTS (
SELECT 1 FROM user_roles ur
JOIN roles r ON r.id = ur.role_id AND r.deleted_at IS NULL
WHERE ur.user_id = u.id AND ur.tenant_id = $1
AND r.code IN ({codes_csv})
)
AND NOT EXISTS (
SELECT 1 FROM user_roles ur
JOIN roles r ON r.id = ur.role_id AND r.deleted_at IS NULL
WHERE ur.user_id = u.id AND ur.tenant_id = $1
AND r.code NOT IN ({codes_csv})
)"#
),
[tenant_id.into()],
))
.await
.map_err(|e| AuthError::DbError(e.to_string()))?
.iter()
.filter_map(|row| row.try_get("", "id").ok())
.collect();
if !excluded.is_empty() {
query = query.filter(user::Column::Id.is_not_in(excluded));
}
}
let paginator = query.paginate(db, pagination.limit());
let total = paginator

View File

@@ -9,6 +9,66 @@ use sha2::{Digest, Sha256};
use tracing;
use uuid::Uuid;
/// 审计日志中需要脱敏的 PII 字段名(小写匹配)
const PII_FIELDS: &[&str] = &[
"id_number",
"phone",
"emergency_contact_phone",
"emergency_contact_name",
"allergy_history",
"medical_history_summary",
"name",
"content",
];
/// 审计日志中需要脱敏的 resource_type 前缀
const PII_RESOURCE_TYPES: &[&str] = &[
"patient",
"consultation",
"follow_up",
"family_member",
"doctor_profile",
];
/// 对 JSON Value 中的 PII 字段进行脱敏
fn sanitize_audit_value(
value: &Option<serde_json::Value>,
resource_type: &str,
) -> Option<serde_json::Value> {
let needs_sanitization = PII_RESOURCE_TYPES
.iter()
.any(|prefix| resource_type.starts_with(prefix));
if !needs_sanitization {
return value.clone();
}
value.as_ref().map(sanitize_json_value)
}
fn sanitize_json_value(v: &serde_json::Value) -> serde_json::Value {
match v {
serde_json::Value::Object(map) => {
let sanitized: serde_json::Map<String, serde_json::Value> = map
.into_iter()
.map(|(k, v)| {
let key_lower = k.to_lowercase();
if PII_FIELDS.iter().any(|f| key_lower.contains(f)) {
(k.clone(), serde_json::Value::String("***".to_string()))
} else {
(k.clone(), sanitize_json_value(v))
}
})
.collect();
serde_json::Value::Object(sanitized)
}
serde_json::Value::Array(arr) => {
serde_json::Value::Array(arr.iter().map(sanitize_json_value).collect())
}
other => other.clone(),
}
}
/// 持久化审计日志到 audit_logs 表。
///
/// 使用 fire-and-forget 模式:失败仅记录 warning 日志,不影响业务操作。
@@ -43,6 +103,10 @@ pub async fn record(mut log: AuditLog, db: &sea_orm::DatabaseConnection) {
// 计算当前记录的 record_hash
let record_hash = compute_record_hash(&log, prev_hash.as_deref());
// 脱敏处理:对 patient/consultation/follow_up 等资源类型的变更值中 PII 字段进行 mask
let sanitized_old = sanitize_audit_value(&log.old_value, &log.resource_type);
let sanitized_new = sanitize_audit_value(&log.new_value, &log.resource_type);
// 保存日志字段用于错误日志model 构建会 move String 字段)
let err_tenant_id = log.tenant_id;
let err_action = log.action.clone();
@@ -56,8 +120,8 @@ pub async fn record(mut log: AuditLog, db: &sea_orm::DatabaseConnection) {
action: Set(log.action),
resource_type: Set(log.resource_type),
resource_id: Set(log.resource_id),
old_value: Set(log.old_value),
new_value: Set(log.new_value),
old_value: Set(sanitized_old),
new_value: Set(sanitized_new),
ip_address: Set(log.ip_address),
user_agent: Set(log.user_agent),
created_at: Set(log.created_at),

View File

@@ -24,6 +24,9 @@ pub struct PatientListParams {
pub page_size: Option<u64>,
pub search: Option<String>,
pub tag_id: Option<Uuid>,
/// Optional user_id filter — only return patients linked to this user.
/// Used by the mini-program to fetch only the logged-in user's own patients.
pub user_id: Option<Uuid>,
}
/// 分配医生请求
@@ -70,7 +73,9 @@ where
require_permission(&ctx, "health.patient.list")?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20).min(100);
let result = patient_service::list_summaries(&state, ctx.tenant_id, page, page_size).await?;
let result =
patient_service::list_summaries(&state, ctx.tenant_id, page, page_size, params.user_id)
.await?;
Ok(Json(ApiResponse::ok(result)))
}

View File

@@ -1,26 +1,12 @@
//! 数据脱敏和状态转换验证
//!
//! 脱敏函数统一使用 erp_core::crypto 中的实现Unicode 安全版本)。
//! 此模块仅保留 health 业务特有的 validate_status_transition。
use crate::error::{HealthError, HealthResult};
/// 身份证号脱敏: 保留前 3 位和后 4 位,中间用 * 替代
pub fn mask_id_number(s: &str) -> String {
if s.len() >= 7 {
format!("{}****{}", &s[..3], &s[s.len() - 4..])
} else {
"****".to_string()
}
}
/// 手机号脱敏: 保留前 3 位和后 4 位,中间用 * 替代
pub fn mask_phone(s: Option<&str>) -> Option<String> {
s.map(|p| {
if p.len() >= 7 {
format!("{}****{}", &p[..3], &p[p.len() - 4..])
} else {
"****".to_string()
}
})
}
// 重导出 erp-core 的脱敏函数,供 health 模块内部统一引用
pub use erp_core::crypto::{mask_id_number, mask_phone};
/// 状态机转换校验: 检查 (current → new) 是否在 allowed_transitions 中
pub fn validate_status_transition(
@@ -54,16 +40,6 @@ mod tests {
assert_eq!("110****1234", mask_id_number("110101199001011234"));
}
#[test]
fn mask_id_15_digits() {
assert_eq!("123****2345", mask_id_number("123456789012345"));
}
#[test]
fn mask_id_7_chars() {
assert_eq!("123****4567", mask_id_number("1234567"));
}
#[test]
fn mask_id_short() {
assert_eq!("****", mask_id_number("123456"));
@@ -82,16 +58,6 @@ mod tests {
);
}
#[test]
fn mask_phone_7_chars() {
assert_eq!(Some("123****4567".to_string()), mask_phone(Some("1234567")));
}
#[test]
fn mask_phone_short() {
assert_eq!(Some("****".to_string()), mask_phone(Some("123456")));
}
#[test]
fn mask_phone_none() {
assert_eq!(None, mask_phone(None));

View File

@@ -552,19 +552,27 @@ pub async fn bind_by_phone(
}
/// 患者摘要列表 — 仅返回非敏感字段,供小程序切换/列表使用
///
/// When `user_id` is provided, only patients linked to that user are returned.
/// This allows the mini-program to fetch only the logged-in user's own patients.
pub async fn list_summaries(
state: &HealthState,
tenant_id: Uuid,
page: u64,
page_size: u64,
user_id: Option<Uuid>,
) -> HealthResult<PaginatedResponse<PatientSummary>> {
let limit = page_size.min(100);
let offset = page.saturating_sub(1) * limit;
let query = patient::Entity::find()
let mut query = patient::Entity::find()
.filter(patient::Column::TenantId.eq(tenant_id))
.filter(patient::Column::DeletedAt.is_null());
if let Some(uid) = user_id {
query = query.filter(patient::Column::UserId.eq(uid));
}
let total = query.clone().count(&state.db).await?;
let models = query

View File

@@ -1,13 +1,34 @@
//! 统计 Service — 工作台管理统计
use std::sync::Mutex;
use std::sync::atomic::Ordering;
use std::time::{Duration, Instant};
use sea_orm::{ConnectionTrait, FromQueryResult};
use tokio::try_join;
use erp_core::error::AppResult;
use crate::dto::stats_dto::*;
use crate::state::HealthState;
/// 文章状态统计
// ---------------------------------------------------------------------------
// 健康检测结果缓存30s TTL
// ---------------------------------------------------------------------------
static HEALTH_CACHE: std::sync::OnceLock<Mutex<Option<(Instant, SystemHealthResp)>>> =
std::sync::OnceLock::new();
fn get_health_cache() -> &'static Mutex<Option<(Instant, SystemHealthResp)>> {
HEALTH_CACHE.get_or_init(|| Mutex::new(None))
}
const HEALTH_CACHE_TTL: Duration = Duration::from_secs(30);
// ---------------------------------------------------------------------------
// 文章统计
// ---------------------------------------------------------------------------
pub async fn get_article_stats(
db: &sea_orm::DatabaseConnection,
tenant_id: uuid::Uuid,
@@ -61,7 +82,10 @@ pub async fn get_article_stats(
})
}
/// 积分最近动态
// ---------------------------------------------------------------------------
// 积分最近动态
// ---------------------------------------------------------------------------
pub async fn get_points_recent_activity(
db: &sea_orm::DatabaseConnection,
tenant_id: uuid::Uuid,
@@ -113,7 +137,10 @@ pub async fn get_points_recent_activity(
.collect())
}
/// 模块状态
// ---------------------------------------------------------------------------
// 模块状态entity_count 校正为实际值)
// ---------------------------------------------------------------------------
pub async fn get_module_status(_state: &HealthState) -> AppResult<Vec<ModuleStatusResp>> {
let modules = vec![
ModuleStatusResp {
@@ -121,7 +148,7 @@ pub async fn get_module_status(_state: &HealthState) -> AppResult<Vec<ModuleStat
display_name: "身份权限".into(),
description: "用户/角色/权限/组织/部门".into(),
active: true,
entity_count: Some(9),
entity_count: Some(13),
route_count: None,
},
ModuleStatusResp {
@@ -129,7 +156,7 @@ pub async fn get_module_status(_state: &HealthState) -> AppResult<Vec<ModuleStat
display_name: "系统配置".into(),
description: "字典/菜单/设置/编号规则".into(),
active: true,
entity_count: Some(6),
entity_count: Some(7),
route_count: None,
},
ModuleStatusResp {
@@ -137,7 +164,7 @@ pub async fn get_module_status(_state: &HealthState) -> AppResult<Vec<ModuleStat
display_name: "工作流引擎".into(),
description: "BPMN 解析/任务分配".into(),
active: true,
entity_count: Some(5),
entity_count: Some(6),
route_count: None,
},
ModuleStatusResp {
@@ -145,7 +172,7 @@ pub async fn get_module_status(_state: &HealthState) -> AppResult<Vec<ModuleStat
display_name: "消息中心".into(),
description: "消息/模板/订阅/通知".into(),
active: true,
entity_count: Some(3),
entity_count: Some(4),
route_count: None,
},
ModuleStatusResp {
@@ -153,7 +180,7 @@ pub async fn get_module_status(_state: &HealthState) -> AppResult<Vec<ModuleStat
display_name: "健康管理".into(),
description: "患者/体征/预约/随访/咨询".into(),
active: true,
entity_count: Some(45),
entity_count: Some(59),
route_count: None,
},
ModuleStatusResp {
@@ -161,7 +188,7 @@ pub async fn get_module_status(_state: &HealthState) -> AppResult<Vec<ModuleStat
display_name: "AI 分析".into(),
description: "智能分析/化验解读/趋势".into(),
active: true,
entity_count: Some(3),
entity_count: Some(24),
route_count: None,
},
ModuleStatusResp {
@@ -169,7 +196,7 @@ pub async fn get_module_status(_state: &HealthState) -> AppResult<Vec<ModuleStat
display_name: "透析管理".into(),
description: "透析记录/处方/用药".into(),
active: true,
entity_count: Some(5),
entity_count: Some(3),
route_count: None,
},
ModuleStatusResp {
@@ -177,7 +204,7 @@ pub async fn get_module_status(_state: &HealthState) -> AppResult<Vec<ModuleStat
display_name: "插件系统".into(),
description: "WASM 运行时/动态表".into(),
active: true,
entity_count: Some(4),
entity_count: Some(6),
route_count: None,
},
];
@@ -185,16 +212,19 @@ pub async fn get_module_status(_state: &HealthState) -> AppResult<Vec<ModuleStat
Ok(modules)
}
/// 用户活跃度统计
// ---------------------------------------------------------------------------
// 用户活跃度(基于 audit_log 真实操作记录)
// ---------------------------------------------------------------------------
pub async fn get_user_activity(
db: &sea_orm::DatabaseConnection,
tenant_id: uuid::Uuid,
) -> AppResult<UserActivityResp> {
let sql = r#"
SELECT
(SELECT COUNT(*) FROM users WHERE tenant_id = $1 AND deleted_at IS NULL AND last_login_at >= NOW() - INTERVAL '1 day') AS daily_active,
(SELECT COUNT(*) FROM users WHERE tenant_id = $1 AND deleted_at IS NULL AND last_login_at >= NOW() - INTERVAL '7 days') AS weekly_active,
(SELECT COUNT(*) FROM users WHERE tenant_id = $1 AND deleted_at IS NULL AND last_login_at >= NOW() - INTERVAL '30 days') AS monthly_active,
(SELECT COUNT(DISTINCT user_id) FROM audit_log WHERE tenant_id = $1 AND created_at >= NOW() - INTERVAL '1 day' AND user_id IS NOT NULL) AS daily_active,
(SELECT COUNT(DISTINCT user_id) FROM audit_log WHERE tenant_id = $1 AND created_at >= NOW() - INTERVAL '7 days' AND user_id IS NOT NULL) AS weekly_active,
(SELECT COUNT(DISTINCT user_id) FROM audit_log WHERE tenant_id = $1 AND created_at >= NOW() - INTERVAL '30 days' AND user_id IS NOT NULL) AS monthly_active,
(SELECT COUNT(*) FROM users WHERE tenant_id = $1 AND deleted_at IS NULL) AS total_registered
"#;
@@ -263,79 +293,237 @@ pub async fn get_user_activity(
})
}
/// 系统健康检查
// ---------------------------------------------------------------------------
// 系统健康检查全部真实检测30s 缓存)
// ---------------------------------------------------------------------------
pub async fn get_system_health(state: &HealthState) -> AppResult<SystemHealthResp> {
let mut services = Vec::new();
// 检查缓存
{
let cache = get_health_cache().lock().unwrap();
if let Some((ts, resp)) = cache.as_ref()
&& ts.elapsed() < HEALTH_CACHE_TTL
{
return Ok(resp.clone());
}
}
let start = std::time::Instant::now();
// 数据库检查
let db_start = std::time::Instant::now();
let db_status = match state
.db
// 并行执行所有检测
let db_fut = check_database(&state.db);
let queue_fut = check_eventbus_backlog(&state.db);
let storage_fut = check_file_storage();
let cron_fut = check_cron_heartbeat(&state.cron_heartbeat);
let (db_status, queue_status, storage_status, cron_status) =
try_join!(db_fut, queue_fut, storage_fut, cron_fut)?;
let total_ms = start.elapsed().as_millis() as i64;
let mut services = Vec::new();
// PostgreSQL
services.push(ServiceHealthStatus {
name: "PostgreSQL".into(),
status: db_status.status.clone(),
message: db_status.message.clone(),
response_ms: db_status.response_ms,
});
// 外部注入的组件检测Redis 等,由 erp-server 提供)
for (name, check_fn) in &state.external_health_checks {
let result = check_fn().await;
services.push(ServiceHealthStatus {
name: (*name).into(),
status: result.status,
message: result.message,
response_ms: result.response_ms,
});
}
// 消息队列
services.push(ServiceHealthStatus {
name: "消息队列".into(),
status: queue_status.status.clone(),
message: queue_status.message.clone(),
response_ms: queue_status.response_ms,
});
// 文件存储
services.push(ServiceHealthStatus {
name: "文件存储".into(),
status: storage_status.status.clone(),
message: storage_status.message.clone(),
response_ms: storage_status.response_ms,
});
// 定时任务
services.push(ServiceHealthStatus {
name: "定时任务".into(),
status: cron_status.status.clone(),
message: cron_status.message.clone(),
response_ms: None,
});
// API 服务(自身响应时间 = 最可靠的 API 健康指标)
services.push(ServiceHealthStatus {
name: "API 服务".into(),
status: "healthy".into(),
message: format!("运行中 (检测耗时 {total_ms}ms)"),
response_ms: Some(total_ms),
});
let resp = SystemHealthResp {
services,
checked_at: chrono::Utc::now().to_rfc3339(),
};
// 更新缓存
{
let mut cache = get_health_cache().lock().unwrap();
*cache = Some((Instant::now(), resp.clone()));
}
Ok(resp)
}
// ---------------------------------------------------------------------------
// 各组件真实检测
// ---------------------------------------------------------------------------
struct CheckResult {
status: String,
message: String,
response_ms: Option<i64>,
}
async fn check_database(db: &sea_orm::DatabaseConnection) -> AppResult<CheckResult> {
let t = std::time::Instant::now();
let result = db
.execute(sea_orm::Statement::from_string(
sea_orm::DatabaseBackend::Postgres,
"SELECT 1".to_string(),
))
.await
{
Ok(_) => "healthy".to_string(),
Err(e) => format!("down: {e}"),
};
let db_ms = db_start.elapsed().as_millis() as i64;
.await;
services.push(ServiceHealthStatus {
name: "PostgreSQL".into(),
status: if db_status == "healthy" {
"healthy".into()
} else {
"down".into()
let ms = t.elapsed().as_millis() as i64;
Ok(match result {
Ok(_) => CheckResult {
status: "healthy".into(),
message: format!("正常 ({ms}ms)"),
response_ms: Some(ms),
},
message: if db_status == "healthy" {
"正常".into()
} else {
db_status
Err(e) => CheckResult {
status: "down".into(),
message: format!("不可用: {e}"),
response_ms: Some(ms),
},
response_ms: Some(db_ms),
});
// 基础服务状态(简化版 — 无 Redis/SMTP 时标记 healthy
services.push(ServiceHealthStatus {
name: "API 服务".into(),
status: "healthy".into(),
message: "运行中".into(),
response_ms: Some(start.elapsed().as_millis() as i64),
});
services.push(ServiceHealthStatus {
name: "定时任务".into(),
status: "healthy".into(),
message: "正常运行".into(),
response_ms: None,
});
services.push(ServiceHealthStatus {
name: "文件存储".into(),
status: "healthy".into(),
message: "可用".into(),
response_ms: None,
});
services.push(ServiceHealthStatus {
name: "消息队列".into(),
status: "healthy".into(),
message: "无积压".into(),
response_ms: None,
});
services.push(ServiceHealthStatus {
name: "缓存服务".into(),
status: "healthy".into(),
message: "正常".into(),
response_ms: None,
});
Ok(SystemHealthResp {
services,
checked_at: chrono::Utc::now().to_rfc3339(),
})
}
async fn check_eventbus_backlog(db: &sea_orm::DatabaseConnection) -> AppResult<CheckResult> {
let t = std::time::Instant::now();
#[derive(FromQueryResult)]
struct CountRow {
cnt: i64,
}
let sql = "SELECT COUNT(*)::bigint AS cnt FROM domain_events WHERE status = 'pending'";
let result: Result<Option<CountRow>, _> = FromQueryResult::find_by_statement(
sea_orm::Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.to_string()),
)
.one(db)
.await;
let ms = t.elapsed().as_millis() as i64;
Ok(match result {
Ok(Some(row)) => match row.cnt {
0 => CheckResult {
status: "healthy".into(),
message: "无积压".into(),
response_ms: Some(ms),
},
n if n <= 100 => CheckResult {
status: "degraded".into(),
message: format!("{n} 条待处理"),
response_ms: Some(ms),
},
n => CheckResult {
status: "down".into(),
message: format!("积压严重: {n} 条"),
response_ms: Some(ms),
},
},
Ok(None) => CheckResult {
status: "healthy".into(),
message: "无积压".into(),
response_ms: Some(ms),
},
Err(e) => CheckResult {
status: "down".into(),
message: format!("查询失败: {e}"),
response_ms: Some(ms),
},
})
}
async fn check_file_storage() -> AppResult<CheckResult> {
let upload_dir = std::path::Path::new("uploads");
if !upload_dir.exists() || !upload_dir.is_dir() {
return Ok(CheckResult {
status: "down".into(),
message: "uploads/ 目录不存在".into(),
response_ms: None,
});
}
let test_path = upload_dir.join(".health_check_tmp");
let t = std::time::Instant::now();
match std::fs::write(&test_path, b"check") {
Ok(_) => {
let _ = std::fs::remove_file(&test_path);
let ms = t.elapsed().as_millis() as i64;
Ok(CheckResult {
status: "healthy".into(),
message: format!("可读写 ({ms}ms)"),
response_ms: Some(ms),
})
}
Err(e) => Ok(CheckResult {
status: "down".into(),
message: format!("不可写: {e}"),
response_ms: None,
}),
}
}
async fn check_cron_heartbeat(
heartbeat: &std::sync::Arc<std::sync::atomic::AtomicU64>,
) -> AppResult<CheckResult> {
let last_ts = heartbeat.load(Ordering::Relaxed);
let now_ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let elapsed_secs = now_ts.saturating_sub(last_ts);
// 阈值:最频繁的定时任务是 30s 一次的指标采样,设 5 分钟为容忍上限
Ok(if elapsed_secs < 300 {
CheckResult {
status: "healthy".into(),
message: format!("正常 (上次心跳 {}s 前)", elapsed_secs),
response_ms: None,
}
} else {
let mins = elapsed_secs / 60;
CheckResult {
status: "degraded".into(),
message: format!("超过 {mins} 分钟无心跳"),
response_ms: None,
}
})
}

View File

@@ -174,6 +174,8 @@ mod m20260526_000164_ai_prompt_add_analysis_type;
mod m20260526_000165_ai_prompt_fix_analysis_type;
mod m20260526_000166_create_ai_knowledge_bases;
mod m20260526_000167_create_ai_knowledge_documents;
mod m20260527_000168_ai_knowledge_v2_menu;
mod m20260529_000169_supplement_rls_for_new_tables;
pub struct Migrator;
@@ -355,6 +357,8 @@ impl MigratorTrait for Migrator {
Box::new(m20260526_000165_ai_prompt_fix_analysis_type::Migration),
Box::new(m20260526_000166_create_ai_knowledge_bases::Migration),
Box::new(m20260526_000167_create_ai_knowledge_documents::Migration),
Box::new(m20260527_000168_ai_knowledge_v2_menu::Migration),
Box::new(m20260529_000169_supplement_rls_for_new_tables::Migration),
]
}
}

View File

@@ -0,0 +1,52 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// 将旧版 AI 知识库菜单更新为 V2 版本
let sql = r#"
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'sys_menu') THEN
UPDATE sys_menu
SET name = '知识库管理',
icon = 'DatabaseOutlined',
component = 'ai/KnowledgeV2Page',
updated_at = now()
WHERE path = '/health/ai-knowledge' AND deleted_at IS NULL;
END IF;
END;
$$ LANGUAGE plpgsql
"#;
manager.get_connection().execute_unprepared(sql).await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.get_connection()
.execute_unprepared(
r#"
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'sys_menu') THEN
UPDATE sys_menu
SET name = 'AI 知识库',
icon = 'BookOutlined',
component = 'health/AiKnowledgePage',
updated_at = now()
WHERE path = '/health/ai-knowledge' AND deleted_at IS NULL;
END IF;
END;
$$ LANGUAGE plpgsql
"#,
)
.await?;
Ok(())
}
}

View File

@@ -0,0 +1,65 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let conn = manager.get_connection();
// 为 m000088 之后创建的新表补充 RLS 策略。
// 幂等操作:仅影响尚未启用 RLS 或缺少策略的表。
conn.execute_unprepared(
r#"
DO $$
DECLARE
tbl TEXT;
policy_exists BOOLEAN;
BEGIN
FOR tbl IN
SELECT c.table_name FROM information_schema.columns c
JOIN information_schema.tables t
ON c.table_name = t.table_name AND c.table_schema = t.table_schema
WHERE c.column_name = 'tenant_id'
AND c.table_schema = 'public'
AND t.table_type = 'BASE TABLE'
ORDER BY c.table_name
LOOP
-- 启用 RLS幂等
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tbl);
-- 检查是否已有 tenant_isolation 策略
SELECT EXISTS(
SELECT 1 FROM pg_policies
WHERE tablename = tbl
AND policyname = 'tenant_isolation'
) INTO policy_exists;
IF NOT policy_exists THEN
EXECUTE format(
'CREATE POLICY tenant_isolation ON %I USING (
current_setting(''app.current_tenant_id'', true) != ''''
AND tenant_id = current_setting(''app.current_tenant_id'', true)::uuid
)',
tbl
);
RAISE NOTICE 'Created RLS policy for table: %', tbl;
END IF;
END LOOP;
END;
$$;
"#,
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// 回滚不需要移除 RLS保持 m000088 的策略不变
// 此迁移补充的 RLS 策略在 down() 中保留,因为 m000088 已处理回滚
let _ = manager;
Ok(())
}
}

View File

@@ -1,5 +1,4 @@
use axum::response::Json;
use serde_json::Value;
use axum::response::{IntoResponse, Json, Response};
use utoipa::OpenApi;
use crate::{ApiDoc, AuthApiDoc, ConfigApiDoc, MessageApiDoc, WorkflowApiDoc};
@@ -7,12 +6,20 @@ use crate::{ApiDoc, AuthApiDoc, ConfigApiDoc, MessageApiDoc, WorkflowApiDoc};
/// GET /docs/openapi.json
///
/// 返回 OpenAPI 3.0 规范 JSON 文档,合并所有模块的路径和 schema。
pub async fn openapi_spec() -> Json<Value> {
let mut spec = ApiDoc::openapi();
spec.merge(AuthApiDoc::openapi());
spec.merge(ConfigApiDoc::openapi());
spec.merge(WorkflowApiDoc::openapi());
spec.merge(MessageApiDoc::openapi());
/// 仅在 debug 模式下可用,生产构建返回 404。
pub async fn openapi_spec() -> Response {
#[cfg(debug_assertions)]
{
let mut spec = ApiDoc::openapi();
spec.merge(AuthApiDoc::openapi());
spec.merge(ConfigApiDoc::openapi());
spec.merge(WorkflowApiDoc::openapi());
spec.merge(MessageApiDoc::openapi());
Json(serde_json::to_value(spec).unwrap_or_default()).into_response()
}
Json(serde_json::to_value(spec).unwrap_or_default())
#[cfg(not(debug_assertions))]
{
(axum::http::StatusCode::NOT_FOUND, "Not Found").into_response()
}
}

View File

@@ -437,12 +437,6 @@ async fn main() -> anyhow::Result<()> {
outbox::start_outbox_relay(db.clone(), event_bus.clone(), config.database.url.clone());
tracing::info!("Outbox relay started");
// Start event cleanup (archive old published events + purge processed_events)
tasks::start_event_cleanup(db.clone());
// Start DB connection pool metrics sampling (every 30s)
tasks::start_pool_metrics(db.clone());
// Start timeout checker (scan overdue tasks every 60s)
erp_workflow::WorkflowModule::start_timeout_checker(db.clone(), event_bus.clone());
tracing::info!("Timeout checker started");
@@ -650,6 +644,13 @@ async fn main() -> anyhow::Result<()> {
erp_ai::service::auto_analysis::start_auto_analysis(ai_state.clone());
tracing::info!("Auto trend analysis scheduler started");
let cron_heartbeat = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
));
let state = AppState {
db,
config,
@@ -664,8 +665,13 @@ async fn main() -> anyhow::Result<()> {
.build(),
ai_state,
pii_crypto,
cron_heartbeat: cron_heartbeat.clone(),
};
// Start background tasks with heartbeat
tasks::start_event_cleanup(state.db.clone(), state.cron_heartbeat.clone());
tasks::start_pool_metrics(state.db.clone(), state.cron_heartbeat.clone());
// --- Build the router ---
//
// The router is split into two layers:
@@ -691,6 +697,15 @@ async fn main() -> anyhow::Result<()> {
))
.with_state(state.clone());
// WeChat routes — higher rate limit (30/min) for mobile users
let wechat_routes = Router::new()
.merge(erp_auth::AuthModule::wechat_routes())
.layer(axum::middleware::from_fn_with_state(
state.clone(),
middleware::rate_limit::rate_limit_wechat,
))
.with_state(state.clone());
// Refresh token routes — higher rate limit (30/min) than login (5/min)
let refresh_routes = Router::new()
.merge(erp_auth::AuthModule::refresh_routes())
@@ -780,6 +795,7 @@ async fn main() -> anyhow::Result<()> {
unthrottled_routes
.merge(public_routes)
.merge(refresh_routes)
.merge(wechat_routes)
.merge(protected_routes)
.nest("/fhir", fhir_routes),
)

View File

@@ -86,6 +86,32 @@ pub async fn rate_limit_refresh_by_ip(
.await
}
/// 基于 Redis 的 IP 限流中间件(微信登录/绑定30 次/分钟)。
///
/// 移动端用户可能频繁重试,使用与 token 刷新相同的宽松配额。
/// 独立于密码登录的 5 次/分钟严格限制。
pub async fn rate_limit_wechat(
State(state): State<AppState>,
req: Request<Body>,
next: Next,
) -> Response {
let identifier = extract_client_ip(req.headers());
let fail_close = state.config.rate_limit.fail_close;
apply_rate_limit(
RateLimitParams {
redis_client: &state.redis,
fail_close,
max_requests: 30,
window_secs: 60,
prefix: "wechat",
},
&identifier,
req,
next,
)
.await
}
/// 基于 Redis 的用户限流中间件。
///
/// 从 TenantContext 中读取 user_id 作为标识符。

View File

@@ -58,6 +58,7 @@ async fn prompt_create_and_get() {
"Analyze: {{data}}".into(),
serde_json::json!({"model": "claude"}),
"analysis".into(),
"lab_report".into(),
)
.await
.expect("创建应成功");
@@ -82,7 +83,11 @@ async fn prompt_list_with_category_filter() {
let tenant_id = uuid::Uuid::new_v4();
let user_id = uuid::Uuid::new_v4();
for (name, cat) in [("p1", "analysis"), ("p2", "summary"), ("p3", "analysis")] {
for (name, cat, at) in [
("p1", "analysis", "lab_report"),
("p2", "summary", "trends"),
("p3", "analysis", "report_summary"),
] {
svc.create_prompt(
tenant_id,
user_id,
@@ -91,15 +96,17 @@ async fn prompt_list_with_category_filter() {
"usr".into(),
serde_json::json!({}),
cat.into(),
at.into(),
)
.await
.expect("创建应成功");
}
// list_prompts 现在按 analysis_type 过滤
let (items, total) = svc
.list_prompts(
tenant_id,
Some("analysis".into()),
Some("lab_report".into()),
&Pagination {
page: Some(1),
page_size: Some(10),
@@ -108,8 +115,9 @@ async fn prompt_list_with_category_filter() {
.await
.expect("查询应成功");
assert_eq!(total, 2);
assert_eq!(items.len(), 2);
assert_eq!(total, 1);
assert_eq!(items.len(), 1);
assert_eq!(items[0].name, "p1");
}
#[tokio::test]
@@ -128,6 +136,7 @@ async fn prompt_activate_switches_version() {
"usr".into(),
serde_json::json!({}),
"cat".into(),
"lab_report".into(),
)
.await
.expect("v1");
@@ -147,9 +156,9 @@ async fn prompt_activate_switches_version() {
assert_eq!(v2.version, 2);
// v1 仍然激活update 继承 is_active
// v1 仍然激活update 继承 is_active,按 analysis_type 查找
let active_before = svc
.get_active_prompt(tenant_id, "my_prompt")
.get_active_prompt(tenant_id, "lab_report")
.await
.expect("active");
assert_eq!(active_before.system_prompt, "sys_v1");
@@ -160,7 +169,7 @@ async fn prompt_activate_switches_version() {
.expect("activate");
let active_after = svc
.get_active_prompt(tenant_id, "my_prompt")
.get_active_prompt(tenant_id, "lab_report")
.await
.expect("active");
assert_eq!(active_after.id, v2.id);
@@ -187,6 +196,7 @@ async fn prompt_rollback_equals_activate() {
"usr".into(),
serde_json::json!({}),
"cat".into(),
"lab_report".into(),
)
.await
.expect("v1");
@@ -214,7 +224,7 @@ async fn prompt_rollback_equals_activate() {
.expect("rollback");
let active = svc
.get_active_prompt(tenant_id, "rb_test")
.get_active_prompt(tenant_id, "lab_report")
.await
.expect("active");
assert_eq!(active.id, v1.id);
@@ -236,6 +246,7 @@ async fn prompt_cross_tenant_isolation() {
"usr".into(),
serde_json::json!({}),
"cat".into(),
"lab_report".into(),
)
.await
.expect("create");
@@ -496,7 +507,7 @@ async fn analysis_list_with_filters() {
.await;
// 按 patient 筛选
let (items, total) = svc
let (_items, total) = svc
.list_analysis(
tenant_id,
Some(patient_a),
@@ -511,7 +522,7 @@ async fn analysis_list_with_filters() {
assert_eq!(total, 2);
// 按 type 筛选
let (items, total) = svc
let (_items, total) = svc
.list_analysis(
tenant_id,
None,

View File

@@ -13,7 +13,7 @@ BACKUP_DIR="${BACKUP_DIR:-/backups}"
PG_HOST="${PGHOST:-postgres}"
PG_PORT="${PGPORT:-5432}"
PG_USER="${PGUSER:-erp}"
PG_DB="${PGDATABSE:-erp}"
PG_DB="${PGDATABASE:-erp}"
KEEP_DAYS="${KEEP_DAYS:-7}"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
FILENAME="${PG_DB}_${TIMESTAMP}.sql.gz"

View File

@@ -0,0 +1,879 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Veepoo M2 手环 — 测量页 & 上传页原型</title>
<style>
/* ═══════════════════════════════════════
Design Token — 复刻小程序 var(--tk-*)
═══════════════════════════════════════ */
:root {
--tk-pri: #C4623A;
--tk-pri-l: #F0DDD4;
--tk-pri-d: #8B3E1F;
--tk-pri-surface: #F5F0EB;
--tk-acc: #5B7A5E;
--tk-acc-l: #E8F0E8;
--tk-bg: #F5F0EB;
--tk-card: #FFFFFF;
--tk-tx: #2D2A26;
--tk-tx2: #5A554F;
--tk-tx3: #78716C;
--tk-bd: #E8E2DC;
--tk-dan: #B54A4A;
--tk-dan-l: #FDEAEA;
--tk-wrn: #C4873A;
--tk-wrn-l: #FFF3E0;
--tk-font-h2: 22px;
--tk-font-body-lg: 18px;
--tk-font-body: 16px;
--tk-font-body-sm: 14px;
--tk-font-num: 30px;
--tk-font-num-lg: 34px;
--tk-font-cap: 13px;
--tk-font-micro: 11px;
--tk-line-height: 1.5;
--tk-card-radius: 16px;
--tk-gap-sm: 12px;
--tk-gap-md: 16px;
--tk-gap-lg: 24px;
--tk-gap-xl: 32px;
--tk-page-padding: 20px;
--tk-shadow-sm: 0 1px 4px rgba(45,42,38,0.06);
--tk-shadow-md: 0 2px 12px rgba(45,42,38,0.10);
--tk-shadow-btn: 0 4px 16px rgba(196,98,58,0.3);
--tk-duration-fast: 150ms;
--tk-duration-normal: 200ms;
--tk-easing: cubic-bezier(0.16,1,0.3,1);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, 'PingFang SC', 'Helvetica Neue', sans-serif;
background: #E8E2DC;
display: flex;
gap: 40px;
justify-content: center;
align-items: flex-start;
padding: 40px;
flex-wrap: wrap;
}
/* ═══════════════════════════════════════
Phone Frame — 模拟小程序容器
═══════════════════════════════════════ */
.phone {
width: 375px;
height: 812px;
background: var(--tk-bg);
border-radius: 40px;
overflow: hidden;
position: relative;
box-shadow: 0 20px 60px rgba(0,0,0,0.15), 0 0 0 1px rgba(0,0,0,0.05);
flex-shrink: 0;
}
.phone-inner {
height: 100%;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.phone-label {
text-align: center;
font-size: 13px;
color: var(--tk-tx3);
margin-bottom: 12px;
font-weight: 500;
}
/* 状态栏 */
.status-bar {
height: 44px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
font-size: 14px;
font-weight: 600;
color: var(--tk-tx);
}
.status-bar-right {
display: flex;
gap: 4px;
align-items: center;
font-size: 12px;
}
/* ═══════════════════════════════════════
页面 1 — 测量页面(就绪态 + 测量中心率)
═══════════════════════════════════════ */
.measure-page {
background: var(--tk-bg);
min-height: 100%;
padding-bottom: 40px;
}
/* 设备状态栏 */
.device-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px var(--tk-page-padding);
background: var(--tk-card);
border-bottom: 1px solid var(--tk-bd);
}
.device-bar__left {
display: flex;
align-items: center;
gap: 8px;
}
.device-bar__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--tk-acc);
}
.device-bar__name {
font-size: var(--tk-font-body);
font-weight: 600;
color: var(--tk-tx);
}
.device-bar__battery {
font-size: var(--tk-font-cap);
color: var(--tk-tx3);
margin-left: 4px;
}
.device-bar__disconnect {
font-size: var(--tk-font-cap);
color: var(--tk-tx3);
padding: 6px 12px;
background: transparent;
border: 1px solid var(--tk-bd);
border-radius: 999px;
cursor: pointer;
}
/* 指标选择器 — 横向滚动药丸 */
.selector {
display: flex;
gap: 0;
padding: var(--tk-gap-md) var(--tk-page-padding);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.selector::-webkit-scrollbar { display: none; }
.selector__pill {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 10px 14px;
border-radius: 16px;
cursor: pointer;
transition: all var(--tk-duration-normal) var(--tk-easing);
position: relative;
min-width: 64px;
}
.selector__pill--active {
background: var(--tk-card);
box-shadow: var(--tk-shadow-md);
}
.selector__pill--done::after {
content: '✓';
position: absolute;
top: 4px;
right: 6px;
font-size: 10px;
color: #fff;
background: var(--tk-acc);
width: 16px;
height: 16px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.selector__icon {
width: 36px;
height: 36px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
color: #fff;
transition: transform var(--tk-duration-normal) var(--tk-easing);
}
.selector__pill--active .selector__icon {
transform: scale(1.15);
}
.selector__label {
font-size: var(--tk-font-cap);
color: var(--tk-tx3);
white-space: nowrap;
}
.selector__pill--active .selector__label {
color: var(--tk-tx);
font-weight: 600;
}
/* 仪表盘区域 */
.gauge-section {
display: flex;
flex-direction: column;
align-items: center;
padding: var(--tk-gap-md) 0 var(--tk-gap-lg);
}
.gauge {
position: relative;
width: 220px;
height: 220px;
}
.gauge__ring-bg {
fill: none;
stroke: var(--tk-bd);
stroke-width: 10;
}
.gauge__ring-progress {
fill: none;
stroke-width: 10;
stroke-linecap: round;
transition: stroke-dasharray 0.4s ease-out;
}
.gauge__center {
position: absolute;
inset: 20px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.gauge__value {
font-family: Georgia, 'Times New Roman', serif;
font-size: 52px;
font-weight: 700;
color: var(--tk-tx);
line-height: 1;
}
.gauge__unit {
font-size: var(--tk-font-body-sm);
color: var(--tk-tx3);
margin-top: 4px;
}
/* 测量中脉冲动画 */
.gauge--measuring {
animation: gauge-breathe 2s ease-in-out infinite;
}
@keyframes gauge-breathe {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.03); }
}
/* 测量进度条 */
.progress-bar {
width: 240px;
height: 4px;
background: var(--tk-bd);
border-radius: 2px;
margin-top: var(--tk-gap-md);
overflow: hidden;
}
.progress-bar__fill {
height: 100%;
border-radius: 2px;
transition: width 0.3s ease-out;
}
/* 健康评估标签 */
.assessment {
margin-top: var(--tk-gap-md);
padding: 8px 20px;
border-radius: 999px;
font-size: var(--tk-font-body-sm);
font-weight: 500;
}
.assessment--normal {
background: var(--tk-acc-l);
color: var(--tk-acc);
}
.assessment--warning {
background: var(--tk-wrn-l);
color: var(--tk-wrn);
}
.assessment--danger {
background: var(--tk-dan-l);
color: var(--tk-dan);
}
/* 免责声明 */
.disclaimer {
text-align: center;
padding: 0 var(--tk-page-padding);
margin: var(--tk-gap-sm) 0 var(--tk-gap-lg);
}
.disclaimer__text {
font-size: var(--tk-font-micro);
color: var(--tk-tx3);
line-height: 1.5;
}
/* 操作按钮 */
.actions {
padding: 0 var(--tk-page-padding);
display: flex;
flex-direction: column;
gap: var(--tk-gap-sm);
}
.btn {
height: 52px;
border-radius: 16px;
border: none;
font-size: var(--tk-font-body-lg);
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: opacity var(--tk-duration-fast);
}
.btn:active { opacity: 0.85; }
.btn--primary {
background: var(--tk-pri);
color: #fff;
box-shadow: var(--tk-shadow-btn);
}
.btn--secondary {
background: var(--tk-card);
color: var(--tk-tx);
border: 1px solid var(--tk-bd);
}
.btn--text {
background: transparent;
color: var(--tk-tx3);
height: 44px;
font-size: var(--tk-font-body-sm);
}
/* ═══════════════════════════════════════
页面 2 — 数据上传页面
═══════════════════════════════════════ */
.upload-page {
background: var(--tk-bg);
min-height: 100%;
padding-bottom: 40px;
}
/* 页面标题区 */
.page-header {
padding: var(--tk-gap-lg) var(--tk-page-padding) var(--tk-gap-md);
}
.page-header__title {
font-family: Georgia, 'Times New Roman', serif;
font-size: var(--tk-font-h2);
font-weight: 700;
color: var(--tk-tx);
line-height: 1.3;
}
.page-header__subtitle {
font-size: var(--tk-font-body-sm);
color: var(--tk-tx3);
margin-top: 4px;
}
/* 结果卡片网格 */
.results-grid {
padding: 0 var(--tk-page-padding);
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--tk-gap-sm);
}
.result-card {
background: var(--tk-card);
border-radius: var(--tk-card-radius);
padding: var(--tk-gap-md);
box-shadow: var(--tk-shadow-sm);
position: relative;
overflow: hidden;
}
.result-card--full {
grid-column: 1 / -1;
}
.result-card__badge {
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
border-radius: 0 4px 4px 0;
}
.result-card__label {
font-size: var(--tk-font-cap);
color: var(--tk-tx2);
margin-bottom: 8px;
padding-left: 8px;
}
.result-card__value-row {
display: flex;
align-items: baseline;
gap: 4px;
padding-left: 8px;
}
.result-card__value {
font-family: Georgia, 'Times New Roman', serif;
font-size: var(--tk-font-num-lg);
font-weight: 700;
color: var(--tk-tx);
line-height: 1;
}
.result-card__unit {
font-size: var(--tk-font-cap);
color: var(--tk-tx3);
}
.result-card__tag {
display: inline-flex;
align-items: center;
gap: 3px;
margin-top: 8px;
margin-left: 8px;
padding: 2px 8px;
border-radius: 999px;
font-size: var(--tk-font-micro);
font-weight: 500;
}
.result-card__tag--normal {
background: var(--tk-acc-l);
color: var(--tk-acc);
}
.result-card__tag--warning {
background: var(--tk-wrn-l);
color: var(--tk-wrn);
}
/* 未测量占位 */
.result-card--empty {
opacity: 0.5;
}
.result-card__placeholder {
padding-left: 8px;
font-size: var(--tk-font-body-sm);
color: var(--tk-tx3);
}
/* 底部上传播区 */
.upload-footer {
padding: var(--tk-gap-lg) var(--tk-page-padding) var(--tk-gap-xl);
}
.upload-footer__hint {
font-size: var(--tk-font-cap);
color: var(--tk-tx3);
text-align: center;
margin-bottom: var(--tk-gap-sm);
}
.upload-footer__time {
font-size: var(--tk-font-micro);
color: var(--tk-tx3);
text-align: center;
margin-top: var(--tk-gap-sm);
}
/* ═══════════════════════════════════════
长者模式覆盖
═══════════════════════════════════════ */
.elder-mode {
--tk-font-h2: 25px;
--tk-font-body-lg: 22px;
--tk-font-body: 22px;
--tk-font-body-sm: 19px;
--tk-font-num: 34px;
--tk-font-num-lg: 40px;
--tk-font-cap: 18px;
--tk-font-micro: 17px;
}
.elder-mode .selector {
flex-wrap: wrap;
gap: var(--tk-gap-sm);
}
.elder-mode .selector__pill {
min-width: 80px;
padding: 14px 18px;
}
.elder-mode .gauge {
width: 260px;
height: 260px;
}
.elder-mode .gauge__value {
font-size: 64px;
}
.elder-mode .results-grid {
grid-template-columns: 1fr;
}
.elder-mode .btn {
height: 60px;
}
/* ═══════════════════════════════════════
状态切换按钮(原型交互用)
═══════════════════════════════════════ */
.state-switcher {
display: flex;
gap: 8px;
padding: 12px var(--tk-page-padding);
background: var(--tk-card);
border-top: 1px solid var(--tk-bd);
position: sticky;
bottom: 0;
}
.state-btn {
flex: 1;
height: 36px;
border-radius: 8px;
border: 1px solid var(--tk-bd);
background: var(--tk-bg);
font-size: 12px;
color: var(--tk-tx2);
cursor: pointer;
}
.state-btn--active {
background: var(--tk-pri);
color: #fff;
border-color: var(--tk-pri);
}
</style>
</head>
<body>
<!-- ══════════════════════════════════════════════════════════════
页面 1 — 测量页面(就绪态,正在测量心率)
══════════════════════════════════════════════════════════════ -->
<div>
<div class="phone-label">页面 1 · 测量页(就绪态 — 心率测量中)</div>
<div class="phone">
<div class="phone-inner">
<!-- 状态栏 -->
<div class="status-bar">
<span>9:41</span>
<div class="status-bar-right">
<span>●●●●</span>
<span>WiFi</span>
<span>85%</span>
</div>
</div>
<div class="measure-page">
<!-- 设备状态栏 -->
<div class="device-bar">
<div class="device-bar__left">
<div class="device-bar__dot"></div>
<span class="device-bar__name">M2 手环</span>
<span class="device-bar__battery">85%</span>
</div>
<button class="device-bar__disconnect">断开</button>
</div>
<!-- 5 指标选择器 -->
<div class="selector">
<!-- 心率(选中 + 已完成) -->
<div class="selector__pill selector__pill--active selector__pill--done">
<div class="selector__icon" style="background: #EF4444;"></div>
<span class="selector__label">心率</span>
</div>
<!-- 血氧(已完成) -->
<div class="selector__pill selector__pill--done">
<div class="selector__icon" style="background: #3B82F6;">O₂</div>
<span class="selector__label">血氧</span>
</div>
<!-- 血压 -->
<div class="selector__pill">
<div class="selector__icon" style="background: #8B5CF6;"></div>
<span class="selector__label">血压</span>
</div>
<!-- 体温 -->
<div class="selector__pill">
<div class="selector__icon" style="background: #F59E0B;">T</div>
<span class="selector__label">体温</span>
</div>
<!-- 压力 -->
<div class="selector__pill">
<div class="selector__icon" style="background: #6366F1;">~</div>
<span class="selector__label">压力</span>
</div>
</div>
<!-- 仪表盘 -->
<div class="gauge-section">
<div class="gauge gauge--measuring">
<svg width="220" height="220" viewBox="0 0 220 220">
<circle cx="110" cy="110" r="100" class="gauge__ring-bg"/>
<circle cx="110" cy="110" r="100" class="gauge__ring-progress"
stroke="#EF4444"
stroke-dasharray="471"
stroke-dashoffset="141"
transform="rotate(-90 110 110)"/>
</svg>
<div class="gauge__center">
<span class="gauge__value" style="color: #EF4444;">72</span>
<span class="gauge__unit">bpm</span>
</div>
</div>
<!-- 进度条 -->
<div class="progress-bar">
<div class="progress-bar__fill" style="width: 70%; background: #EF4444;"></div>
</div>
<!-- 评估标签 -->
<div class="assessment assessment--normal">♥ 心率正常</div>
</div>
<!-- 免责声明 -->
<div class="disclaimer">
<p class="disclaimer__text">测量数据仅供参考,不作为医学诊断依据。如有不适请及时就医。</p>
</div>
<!-- 操作按钮 -->
<div class="actions">
<button class="btn btn--primary" style="background: #EF4444; box-shadow: 0 4px 16px rgba(239,68,68,0.3);">
停止测量
</button>
<button class="btn btn--secondary">
完成并查看结果
</button>
</div>
</div>
</div>
</div>
</div>
<!-- ══════════════════════════════════════════════════════════════
页面 2 — 数据上传页面(测量结果汇总)
══════════════════════════════════════════════════════════════ -->
<div>
<div class="phone-label">页面 2 · 数据上传页(结果汇总 + 上传)</div>
<div class="phone">
<div class="phone-inner">
<!-- 状态栏 -->
<div class="status-bar">
<span>9:41</span>
<div class="status-bar-right">
<span>●●●●</span>
<span>WiFi</span>
<span>85%</span>
</div>
</div>
<div class="upload-page">
<!-- 页面标题 -->
<div class="page-header">
<h1 class="page-header__title">测量结果</h1>
<p class="page-header__subtitle">Veepoo M2 · 刚刚完成测量</p>
</div>
<!-- 结果卡片网格 -->
<div class="results-grid">
<!-- 心率 — 已测量,正常 -->
<div class="result-card">
<div class="result-card__badge" style="background: #EF4444;"></div>
<div class="result-card__label">心率</div>
<div class="result-card__value-row">
<span class="result-card__value">72</span>
<span class="result-card__unit">bpm</span>
</div>
<div class="result-card__tag result-card__tag--normal">● 正常</div>
</div>
<!-- 血氧 — 已测量,正常 -->
<div class="result-card">
<div class="result-card__badge" style="background: #3B82F6;"></div>
<div class="result-card__label">血氧</div>
<div class="result-card__value-row">
<span class="result-card__value">98</span>
<span class="result-card__unit">%</span>
</div>
<div class="result-card__tag result-card__tag--normal">● 正常</div>
</div>
<!-- 血压 — 已测量,注意 -->
<div class="result-card result-card--full">
<div class="result-card__badge" style="background: #8B5CF6;"></div>
<div class="result-card__label">血压</div>
<div class="result-card__value-row">
<span class="result-card__value">135</span>
<span class="result-card__unit">/ 88 mmHg</span>
</div>
<div class="result-card__tag result-card__tag--warning">● 偏高</div>
</div>
<!-- 体温 — 未测量 -->
<div class="result-card result-card--empty">
<div class="result-card__badge" style="background: #F59E0B; opacity: 0.3;"></div>
<div class="result-card__label">体温</div>
<div class="result-card__placeholder">未测量</div>
</div>
<!-- 压力 — 未测量 -->
<div class="result-card result-card--empty">
<div class="result-card__badge" style="background: #6366F1; opacity: 0.3;"></div>
<div class="result-card__label">压力</div>
<div class="result-card__placeholder">未测量</div>
</div>
</div>
<!-- 底部上传播区 -->
<div class="upload-footer">
<p class="upload-footer__hint">测量数据将上传至您的健康档案</p>
<button class="btn btn--primary">
上传测量数据3 项)
</button>
<p class="upload-footer__time">测量时间2026-05-30 14:35</p>
</div>
</div>
</div>
</div>
</div>
<!-- ══════════════════════════════════════════════════════════════
页面 3 — 测量页面(未连接态)
══════════════════════════════════════════════════════════════ -->
<div>
<div class="phone-label">页面 1 · 测量页(未连接态)</div>
<div class="phone">
<div class="phone-inner">
<div class="status-bar">
<span>9:41</span>
<div class="status-bar-right">
<span>●●●●</span>
<span>WiFi</span>
<span>85%</span>
</div>
</div>
<div class="measure-page" style="display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 720px;">
<!-- 蓝牙脉冲动画 -->
<div style="position: relative; width: 120px; height: 120px; margin-bottom: 28px;">
<div style="position: absolute; inset: 0; border-radius: 50%; border: 3px solid var(--tk-pri); animation: pulse-ring 2s ease-out infinite;"></div>
<div style="position: absolute; inset: 20px; border-radius: 50%; background: var(--tk-pri); display: flex; align-items: center; justify-content: center;">
<span style="color: #fff; font-size: 20px; font-weight: 700;">BT</span>
</div>
</div>
<h2 style="font-family: Georgia, serif; font-size: var(--tk-font-h2); font-weight: 700; color: var(--tk-tx); margin-bottom: 8px;">
M2 手环健康测量
</h2>
<p style="font-size: var(--tk-font-body-sm); color: var(--tk-tx3); margin-bottom: 32px; text-align: center; padding: 0 40px;">
请确保手环已佩戴且蓝牙已开启
</p>
<button class="btn btn--primary" style="width: 200px;">
连接 M2 手环
</button>
</div>
</div>
</div>
</div>
<!-- ══════════════════════════════════════════════════════════════
页面 4 — 测量页面(血压已测量完成态)
══════════════════════════════════════════════════════════════ -->
<div>
<div class="phone-label">页面 1 · 测量页(血压测量完成)</div>
<div class="phone">
<div class="phone-inner">
<div class="status-bar">
<span>9:42</span>
<div class="status-bar-right">
<span>●●●●</span>
<span>WiFi</span>
<span>85%</span>
</div>
</div>
<div class="measure-page">
<div class="device-bar">
<div class="device-bar__left">
<div class="device-bar__dot"></div>
<span class="device-bar__name">M2 手环</span>
<span class="device-bar__battery">85%</span>
</div>
<button class="device-bar__disconnect">断开</button>
</div>
<!-- 5 指标选择器 — 血压选中 -->
<div class="selector">
<div class="selector__pill selector__pill--done">
<div class="selector__icon" style="background: #EF4444;"></div>
<span class="selector__label">心率</span>
</div>
<div class="selector__pill selector__pill--done">
<div class="selector__icon" style="background: #3B82F6;">O₂</div>
<span class="selector__label">血氧</span>
</div>
<div class="selector__pill selector__pill--active selector__pill--done">
<div class="selector__icon" style="background: #8B5CF6;"></div>
<span class="selector__label">血压</span>
</div>
<div class="selector__pill">
<div class="selector__icon" style="background: #F59E0B;">T</div>
<span class="selector__label">体温</span>
</div>
<div class="selector__pill">
<div class="selector__icon" style="background: #6366F1;">~</div>
<span class="selector__label">压力</span>
</div>
</div>
<!-- 仪表盘 — 血压完成态 -->
<div class="gauge-section">
<div class="gauge">
<svg width="220" height="220" viewBox="0 0 220 220">
<circle cx="110" cy="110" r="100" class="gauge__ring-bg"/>
<circle cx="110" cy="110" r="100" class="gauge__ring-progress"
stroke="#8B5CF6"
stroke-dasharray="471"
stroke-dashoffset="0"
transform="rotate(-90 110 110)"/>
</svg>
<div class="gauge__center">
<span class="gauge__value" style="color: #8B5CF6;">135<span style="font-size: 24px; color: var(--tk-tx3);">/</span>88</span>
<span class="gauge__unit">mmHg</span>
</div>
</div>
<!-- 评估标签 — 偏高 -->
<div class="assessment assessment--warning">↕ 血压偏高,建议关注</div>
</div>
<div class="disclaimer">
<p class="disclaimer__text">测量数据仅供参考,不作为医学诊断依据。如有不适请及时就医。</p>
</div>
<div class="actions">
<button class="btn btn--primary" style="background: #8B5CF6; box-shadow: 0 4px 16px rgba(139,92,246,0.3);">
重新测量
</button>
<button class="btn btn--secondary">
完成并查看结果
</button>
</div>
</div>
</div>
</div>
</div>
<style>
@keyframes pulse-ring {
0% { transform: scale(1); opacity: 1; }
100% { transform: scale(1.4); opacity: 0; }
}
</style>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

View File

@@ -0,0 +1,287 @@
# HMS 六维度深度分析 — 多专家组头脑风暴会议纪要
> 日期: 2026-05-28 | 分支: feat/media-library-banner | 方法: 6 并行专家组独立分析 + 综合交叉验证
> 前序分析: 2026-05-20 V1 就绪度(6.3) / 2026-05-17 六维度均衡(6.8) / 2026-05-11 全面分析(7.0)
## 综合评分: 5.8 / 10 (C+)
> 较 2026-05-20 的 6.3 分下降,原因是本次分析深度显著增加,暴露了更多隐藏问题(审计日志 PII 泄漏、Redis 明文传输、Handler 层 4.5% 覆盖率等)。评分下调反映的是认知深化而非系统退化。
| 维度 | 评分 | 趋势 | 专家组 | 核心一句话 |
|------|------|------|--------|-----------|
| **架构** | **6.7** | → | 首席架构师 | 模块边界 8.5 是最强资产,缓存 4.0 是最弱环节 |
| **安全** | **7.2** | → | 首席安全官 | PII 加密企业级 9/10DB/Redis 明文传输是硬伤 |
| **产品** | **6.7** | → | 产品总监 | 工程能力远超产品化程度AI 后端被困在"看不见"状态 |
| **DevOps** | **3.4** | ↓ | DevOps 总监 | CI/CD 零分Redis 密码公网明文,灾备能力近乎为零 |
| **测试** | **4.5** | ↓ | 质量总监 | Handler 层 4.5% 覆盖率是最大盲区,小程序测试接近于零 |
| **AI** | **6.0** | → | AI 架构师 | Agent 能力 7.5 但 Token 计量为 0前端入口全部缺失 |
---
## 一、各维度关键发现
### 1. 架构 (6.7/10 B)
**最强点:**
- L2 模块间零直接依赖已真正实现grep 验证)
- Outbox 模式三阶段(持久化→广播→更新+NOTIFY是生产级质量
- ErpModule trait 天然支持微服务拆分
**最弱点:**
- 业务数据缓存几乎为零(仅 Moka 插件缓存 + Redis 限流),每次 API 至少 3 次 DB 查询
- 173 个迁移文件管理成本失控(仅 5/20-22 就产生 12 个)
- erp-health module.rs 单文件 916 行(路由+定时任务+权限+生命周期)
### 2. 安全 (7.2/10 B+)
**最强点:**
- PII 加密AES-256-GCM + KEK/DEK + HMAC 盲索引)达企业级 9/10
- API 安全5 层限流 + 文件上传白名单 + CORS 拒绝通配符9/10
- 审计日志 SHA-256 哈希链完整性验证
**最弱点:**
- **PostgreSQL 和 Redis 连接均无 TLS** — 凭据和数据在网络上明文传输
- **审计日志 old_value/new_value 可能包含 PII 明文** — 数据库被入侵后审计表成为泄漏源
- **patient.name 明文存储** — 等保三级要求姓名属于敏感信息
- **JWT 使用 HS256 对称密钥** — 泄漏等于全系统接管
- **X-Forwarded-For 直接信任** — IP 伪造可绕过速率限制
### 3. 产品 (6.7/10 B)
**最强点:**
- 患者全生命周期主干链路已闭环(约 85% 完整度)
- 竞品差异化优势明显AI 深度 + Rust 全栈 + BLE 设备 + 适老化)
- 长者模式 58/58 页面 100% 覆盖是刚需壁垒
**最弱点:**
- **4 个 SSE AI 分析端点无前端 UI 触发入口** — 最大产品断裂
- **6 个冻结模块**(关怀计划/透析/用药等)有后端无前端
- **小程序 4 个域完全无入口**(告警/透析/知情同意/AI
- **商业化路径不清晰** — 无用量计费基础设施,积分商城无核销闭环
### 4. DevOps (3.4/10 D+)
**最强点:**
- Docker 配置文件质量高(三阶段构建 + 资源限制 + 健康检查)
- 安全基础设施配置到位Nginx TLS + HSTS + CSP + 备份加密)
**最弱点:**
- **CI/CD 评分 1/10** — 零自动化,所有质量关卡人工操作
- **灾难恢复 1.5/10** — 无 RTO/RPO 定义,备份仅本地无异地
- **数据库运维 2/10** — 单实例无 HA连接池 max_connections=20 偏小
- **Redis 密码 `redis_KBCYJk` 通过公网明文传输到腾讯云**
- **监控"配置齐全、运行为零"** — Prometheus 10 条告警规则从未实际运行
### 5. 测试 (4.5/10 D+)
**最强点:**
- CI 流水线结构合理(三平台并行执行)
- erp-server 167 个集成测试是系统中测试质量最高的部分
- Clippy 全 workspace 0 警告
**最弱点:**
- **Handler 层覆盖率 4.5%**66 文件中仅 3 个有测试)
- **Middleware 层覆盖率 0%** — 多租户隔离无自动化回归验证
- **小程序 src 目录下 0 个测试文件** — 192 个源文件中 6% 覆盖率
- **性能测试完全空白** — 无 criterion/k6/locust
- **E2E 测试不在 CI 中** — 17 个 spec 全靠手动执行
### 6. AI (6.0/10 B-)
**最强点:**
- ReAct Agent 完整实现9 工具 + Token 预算 + 角色沙箱)
- 7 种 DisplayHint 富展示类型设计前瞻
- PII 脱敏双重保障SanitizationService + HealthDataProvider
**最弱点:**
- **Token 计量记录为 0**chat_handler 第 310 行硬编码 0 — 成本控制形同虚设
- **display_hints 被丢弃**chat_handler 第 362 行写死 None — 前端 RichMessage 组件就绪但收不到数据
- **Ollama Function Calling 未实现** — 本地部署时 Agent 退化为纯对话
- **RAG 纯向量搜索无混合检索** — 医疗术语精确匹配不够
---
## 二、跨维度交叉发现
> 以下问题是多个专家组独立发现的同一根因,说明是系统性问题而非局部缺陷。
### 交叉问题 1: AI 能力"有后无前"6 个专家中 4 个独立发现)
| 专家 | 表述 |
|------|------|
| AI 架构师 | "4 个 SSE 端点无前端 UI 触发入口" |
| 产品总监 | "工程能力远超产品化程度,后端投入大量资源但只有 AI 对话一个入口对用户可见" |
| 架构师 | "知识库 V2 的 RAG 能力只用于 ChatPage 通用对话,未嵌入业务场景" |
| 质量总监 | "AI 模块 206 个测试中绝大多数是数据结构和序列化测试" |
**根因**: AI 模块按后端优先策略开发,前端对接计划滞后。这是 ROI 最高的修复点。
### 交叉问题 2: 数据传输安全缺口(安全 + DevOps 独立发现)
| 专家 | 表述 |
|------|------|
| 安全官 | "PostgreSQL 和 Redis 连接均无 TLS凭据在网络上明文传输" |
| DevOps | "Redis 密码通过公网明文传输到腾讯云 129.204.154.246:6379" |
**根因**: 开发环境便捷性优先安全配置被推迟。修复成本极低1-2 天),影响极高。
### 交叉问题 3: 测试盲区集中在安全关键路径(安全 + 质量 独立发现)
| 专家 | 表述 |
|------|------|
| 安全官 | "无权限绕过测试、无 SQL 注入测试、无跨租户数据泄漏测试" |
| 质量总监 | "Handler 层 4.5% 覆盖率、Middleware 0%、小程序 service 层 0 测试" |
**根因**: TDD 流程在 handler/middleware 层未执行。历史数据显示 24% 的提交是 fix大部分可在合并前被 CI 拦截。
---
## 三、风险矩阵
**影响×概率** 排序的 TOP 10 风险:
| # | 风险 | 影响 | 概率 | 维度 | 行动 |
|---|------|------|------|------|------|
| 1 | Redis 密码公网明文传输 | 致命 | 已发生 | 安全+DevOps | 启用 TLS1天 |
| 2 | 数据库单点故障无 HA | 致命 | 中 | DevOps | 流复制+热备3天 |
| 3 | 备份无异地存储 | 致命 | 低 | DevOps | S3/OSS 上传1天 |
| 4 | 审计日志含 PII 明文 | 高 | 已发生 | 安全 | 脱敏处理2天 |
| 5 | Handler 层 4.5% 测试覆盖率 | 高 | 高 | 质量 | 权限+验证测试5天 |
| 6 | AI Token 计量为 0 | 高 | 已发生 | AI | 从 Provider 提取1天 |
| 7 | JWT HS256 对称密钥 | 高 | 低 | 安全 | 迁移 RS2565天 |
| 8 | 缓存层空白 | 中 | 高 | 架构 | Redis+Moka 缓存5天 |
| 9 | AI 前端入口缺失 | 中 | 已发生 | 产品+AI | 4 个业务页面嵌入5天 |
| 10 | CI/CD 零自动化 | 中 | 高 | DevOps+质量 | GitHub Actions3天 |
---
## 四、专家组头脑风暴 — 争议与共识
### 共识6/6 专家一致)
1. **AI 产品化是最大杠杆点** — 后端能力已构建但用户无法感知,投入产出比最高
2. **DevOps 是最短木板** — CI/CD + 灾备 + 监控三个维度都在 D 级,是上线的硬阻塞
3. **安全基础设施已到位但自动化不足** — TLS/密钥轮换/依赖扫描都需自动化
4. **测试覆盖需要聚焦在安全关键路径** — Handler + Middleware + 多租户隔离
### 争议
1. **架构师 vs DevOps: 优先级分歧**
- 架构师认为缓存层5天是 ROI 最高的架构改进
- DevOps 认为 Redis TLS1天和 CI3天是生存优先
- **结论**: DevOps P0 项TLS/CI/备份)先做,缓存层紧随其后
2. **产品 vs 安全: AI 免责声明时机**
- 产品认为 AI 前端入口可以和免责声明同步上线
- 安全认为必须先有免责声明和人工确认流程才能开放 AI 入口
- **结论**: 安全优先 — 先实现免责声明1天再开放 AI 入口
3. **质量 vs 产品: 冻结模块处理策略**
- 质量认为冻结模块(有后端无前端)应先补测试再解冻
- 产品认为关怀计划和透析是核心业务,应尽快解冻交付
- **结论**: 关怀计划优先解冻(已有 handler + 权限码),透析等待测试补齐后解冻
---
## 五、行动路线图
### Phase 0: 生存保障1-2 周P0 阻塞项)
> 目标: 消除致命风险,建立基本运维能力
| # | 行动 | 负责维度 | 工作量 | 风险消除 |
|---|------|---------|--------|---------|
| 0.1 | Redis + PostgreSQL 连接强制 TLS | 安全+DevOps | 2天 | 公网明文传输 |
| 0.2 | GitHub Actions CI 流水线 | DevOps+质量 | 3天 | 代码质量零门禁 |
| 0.3 | 备份异地存储S3/OSS+ 恢复演练 | DevOps | 2天 | 灾难时数据永久丢失 |
| 0.4 | 审计日志 PII 脱敏 | 安全 | 2天 | 审计表成为泄漏源 |
| 0.5 | Prometheus + Grafana + 告警通知上线 | DevOps | 2天 | 生产环境"盲飞" |
| 0.6 | AI Token 计量修复 + display_hints 传递 | AI | 1天 | 成本控制失效 |
### Phase 1: 产品释放2-3 周,用户价值释放)
> 目标: 把已建好的后端能力通过前端释放给用户
| # | 行动 | 负责维度 | 工作量 |
|---|------|---------|--------|
| 1.1 | AI 分析嵌入 4 个业务页面 | 产品+AI | 5天 |
| 1.2 | AI 免责声明 + 人工确认流程 | 安全+产品 | 2天 |
| 1.3 | 知识库 V2 化验场景化接入 | AI | 3天 |
| 1.4 | 关怀计划解冻 + AI 建议→关怀计划 | 产品 | 3天 |
| 1.5 | 小程序补齐告警/AI 入口 | 产品 | 3天 |
| 1.6 | 业务数据缓存层(字典/菜单/权限/患者列表) | 架构 | 5天 |
### Phase 2: 安全加固2-3 周,合规底线)
> 目标: 满足医疗数据合规和等保三级基本要求
| # | 行动 | 负责维度 | 工作量 |
|---|------|---------|--------|
| 2.1 | 患者姓名加密存储 + name_hash 盲索引 | 安全 | 5天 |
| 2.2 | JWT 迁移 RS256 + Trusted Proxy 配置 | 安全 | 5天 |
| 2.3 | cargo-deny + npm audit CI 集成 | 安全+DevOps | 2天 |
| 2.4 | 患者数据导出 API + 数据留存策略 | 安全+产品 | 5天 |
| 2.5 | ICD-10 编码校验 + 诊断标准化 | 产品 | 3天 |
### Phase 3: 质量提升2-3 周,回归保障)
> 目标: 关键路径测试覆盖率达到 70%+
| # | 行动 | 负责维度 | 工作量 |
|---|------|---------|--------|
| 3.1 | Handler 层关键路径测试(权限 403 + 验证 422 | 质量 | 5天 |
| 3.2 | Middleware 测试tenant_id/frozen/security_headers | 质量 | 2天 |
| 3.3 | 小程序 service 层单元测试request/storage/auth | 质量 | 4天 |
| 3.4 | 安全测试套件SQL注入/认证绕过/越权) | 质量+安全 | 3天 |
| 3.5 | E2E 扩展 + CI 集成 | 质量 | 3天 |
---
## 六、投入产出比分析
| 行动 | 工作量 | 评分提升预期 | ROI |
|------|--------|------------|-----|
| Redis/PG TLS | 2天 | 安全 7.2→8.0 | ★★★★★ |
| AI 前端入口 | 5天 | 产品 6.7→7.5, AI 6.0→7.0 | ★★★★★ |
| CI 流水线 | 3天 | DevOps 3.4→4.5, 质量 4.5→5.5 | ★★★★☆ |
| 缓存层 | 5天 | 架构 6.7→7.5 | ★★★★☆ |
| Handler 测试 | 5天 | 质量 4.5→5.5 | ★★★☆☆ |
| Token 计量 | 1天 | AI 6.0→6.5 | ★★★★★ |
| 患者姓名加密 | 5天 | 安全 7.2→7.8 | ★★★☆☆ |
| JWT RS256 | 5天 | 安全 7.2→7.6 | ★★☆☆☆ |
---
## 七、最终结论
### 系统画像
HMS 是一个**工程能力超越产品化程度**的健康管理平台:
- **后端架构**Rust 模块化单体 + 事件驱动 + 多租户)达到医疗 SaaS 优秀水平
- **安全基础**PII 加密 + RBAC + 速率限制)在同类项目中属中上
- **AI 能力**ReAct Agent + RAG + 知识库 V2后端完整但前端入口缺失
- **DevOps**CI/CD/灾备/监控)是致命短板,需立即修复才能支撑生产部署
- **测试质量**Handler 4.5% + Middleware 0%)是安全回归的隐患
### 核心建议
1. **先活下来再活得好** — Phase 02 周消除致命风险Phase 1-3 逐步提升
2. **释放已建能力** — AI 前端入口是 ROI 最高的单项投入5 天,提升 2 个维度评分)
3. **安全不能事后补** — TLS/脱敏/加密是合规底线,不是"锦上添花"
4. **测试聚焦安全关键路径** — Handler + Middleware + 多租户隔离,不做"到处撒网"
### 预期评分变化
| 维度 | 当前 | Phase 0 后 | Phase 1 后 | 全部完成后 |
|------|------|-----------|-----------|-----------|
| 架构 | 6.7 | 6.7 | 7.5 | 8.0 |
| 安全 | 7.2 | 7.8 | 7.8 | 8.5 |
| 产品 | 6.7 | 6.7 | 7.5 | 8.0 |
| DevOps | 3.4 | 5.0 | 5.5 | 6.5 |
| 测试 | 4.5 | 4.5 | 4.5 | 7.0 |
| AI | 6.0 | 6.5 | 7.0 | 7.5 |
| **综合** | **5.8** | **6.3** | **6.8** | **7.6** |
---
*本报告由 6 个并行专家组独立分析后综合而成,所有发现基于实际代码审查而非推测。*

View File

@@ -0,0 +1,206 @@
# Veepoo M2 BLE SDK 正确对接流程
> 日期: 2026-05-30 | 参与者: iven, Claude
> 状态: 已验证通过
## 背景
Veepoo M2 手环的 BLE SDK372KB Webpack 打包)在微信小程序中的对接遇到了多层问题。
本文档记录**经过实际验证的正确对接流程**和踩过的坑,避免后续开发者重复踩坑。
## SDK 架构
```
Taro 页面 (veepoo-measure/index.tsx)
↓ navigateTo
原生分包页面 (pkg-veepoo/index.js + index.wxml)
↓ require('./libs/veepoo-sdk')
Veepoo SDK (veepoo-sdk.js)
↓ wx.* BLE API
微信蓝牙底层
```
### 为什么用原生分包而非 Taro
1. SDK 是纯 JS372KB Webpack CommonJS2不兼容 Taro 编译流程
2. SDK 使用全局变量 `veepooBle`/`veepooFeature`/`veepooLogger`,需要 `require()` 直接加载
3. 微信小程序 JS 引擎不支持 ES2020 语法(`??``?.`),原生页面可精确控制语法
4. 分包独立,不影响主包体积
### 构建集成
```js
// config/index.ts — mini.copy.patterns
{ from: 'vendor/veepoo-sdk/libs/vp_sdk/index.js', to: 'dist/pkg-veepoo/libs/veepoo-sdk.js' },
{ from: 'native/pkg-veepoo/', to: 'dist/pkg-veepoo/', ignore: ['*.ts'] },
```
**注意**`dev:weapp` 的 watch 模式不监听 `native/` 目录变化,修改原生页面后需清除 dist 重建:
```bash
rm -rf apps/miniprogram/dist/pkg-veepoo
pnpm run build:weapp # 或 pnpm run dev:weapp
```
## 正确对接流程(已验证)
### 1. 页面加载onLoad
```js
onLoad: function () {
this._eventChannel = this.getOpenerEventChannel();
// ❌ 不要在这里注册 veepooWeiXinSDKNotifyMonitorValueChange
// 该函数内部会调用 wx.notifyBLECharacteristicValueChange
// 此时蓝牙适配器未初始化 → 返回 "not init" 错误
}
```
### 2. 扫描
```js
veepooBle.veepooWeiXinSDKStartScanDeviceAndReceiveScanningDevice(function (res) {
var name = (device.localName || device.name || '').toUpperCase();
// 匹配条件要放宽M2 / VPM / VEEPOO
if (name.indexOf('M2') !== -1 || name.indexOf('VPM') !== -1 || name.indexOf('VEEPOO') !== -1) {
// 找到设备
}
});
```
### 3. 停止扫描 → 连接
```js
veepooBle.veepooWeiXinSDKStopSearchBleManager(function () {
veepooBle.veepooWeiXinSDKBleConnectionServicesCharacteristicsNotifyManager(device, callback);
});
```
### 4. 连接回调(关键!)
**连接回调触发 4 次**,每个阶段一次:
| # | 回调内容 | 含义 |
|---|---------|------|
| 1 | `{errno:0, errMsg:"createBLEConnection:ok"}` | BLE TCP 连接建立 |
| 2 | `[{uuid:...}, ...]` | 服务发现完成 |
| 3 | `{characteristics:[...], errno:0}` | 特征值发现完成 |
| 4 | `{connection:true, deviceId:"..."}` | **特征值订阅完成,通道就绪** |
**只响应第 4 次 `connection:true`**
```js
veepooBle.veepooWeiXinSDKBleConnectionServicesCharacteristicsNotifyManager(device, function (result) {
// ❌ 不要用 errno===0 匹配!第 1 次回调就满足,但此时订阅未完成
// ✅ 只匹配 connection:true第 4 次回调)
if (result.connection === true) {
// 此时 BLE 通道完全就绪
}
});
```
### 5. 注册数据监听器(在 connection:true 回调内)
```js
if (result.connection === true) {
// ✅ 此时蓝牙适配器已初始化 + 连接已建立 + 特征值已订阅
veepooBle.veepooWeiXinSDKNotifyMonitorValueChange(function (data) {
// data.type 对应不同事件1=认证, 2=电量, 6=体温, 18=血压, 31=血氧, 51=心率, 58=压力
handleSdkEvent(data);
});
// 同时注册连接状态变化监听
veepooBle.veepooWeiXinSDKBLEConnectionStateChangeManager(function (res) {
if (!res.connected) { /* 断开处理 */ }
});
}
```
### 6. 认证(连接就绪后延迟 500ms
```js
setTimeout(function () {
veepooFeature.veepooBlePasswordCheckManager();
}, 500);
```
### 7. 认证结果判断(关键!)
```js
// SDK 认证回调结构:
// {
// type: 1,
// content: {
// VPDevicepassword: "0000", ← 设备密码原始值
// VPDeviceAck: "successfulVerification", ← ✅ 认证结果
// VPDeviceVersion: "01.63.01.00-7466",
// VPDeviceMAC: "BC:92:DC:9F:CA:6A",
// ...
// }
// }
// ❌ 错误:检查 VPDevicepassword值是 "0000",永远不匹配)
if (content.VPDevicepassword === 'successfulVerification') { ... }
// ✅ 正确:检查 VPDeviceAck
if (content.VPDeviceAck === 'successfulVerification' ||
content.VPDeviceAck === 'passTheVerification') {
// 认证成功
}
```
**VPDeviceAck 可能的值**
- `successfulVerification` — 密码和时间校验成功
- `passTheVerification` — 核验通过
- `verifyNotPass` — 核验不通过
- `setupFailed` — 设置不成功
### 8. Storage 轮询兜底
SDK 会写入 `deviceChipStatus` 到 Storage但**可能是布尔值 `true` 而非字符串**
```js
var status = wx.getStorageSync('deviceChipStatus');
// ✅ 同时匹配字符串和布尔值
if (status === 'successfulVerification' || status === 'passTheVerification' || status === true) {
// 认证成功
}
```
## 完整流程图
```
onLoad → 扫描 → 找到M2 → 停止扫描
连接(veepooWeiXinSDKBleConnectionServicesCharacteristicsNotifyManager)
→ 回调1(errno:0) → 忽略
→ 回调2(services) → 忽略
→ 回调3(characteristics) → 忽略
→ 回调4(connection:true) →
① 注册数据监听器(veepooWeiXinSDKNotifyMonitorValueChange)
② 注册连接状态监听器
③ 延迟500ms → 调用认证(veepooBlePasswordCheckManager)
④ 启动 Storage 轮询(deviceChipStatus) + 8s 超时
数据监听器收到 type=1 事件 → 检查 VPDeviceAck === "successfulVerification"
认证成功 → 设备就绪 → 可开始测量
```
## 踩坑清单
| # | 问题 | 根因 | 解决方案 |
|---|------|------|----------|
| 1 | 原生页面 `??` 报 SyntaxError | 微信小程序 JS 引擎不支持 ES2020 | 用 `!= null` 三元表达式替代 |
| 2 | veepoo-measure 白屏 `useRef is not defined` | TSX import 未解构 `useRef` | `import React, { useRef } from 'react'` |
| 3 | 扫描不到 M2 设备 | 过滤条件只匹配 `M2`,设备可能广播其他名 | 放宽匹配 M2/VPM/VEEPOO |
| 4 | 认证超时 — 回调匹配过早 | `errno:0` 在第 1 次回调就匹配 | 只匹配 `connection:true` |
| 5 | 认证超时 — 监听器注册过早 | `onLoad` 时适配器未初始化 → `not init` | 改到 `connection:true` 后注册 |
| 6 | 认证超时 — 字段检查错误 | 检查 `VPDevicepassword`(值="0000")而非 `VPDeviceAck` | 改为检查 `VPDeviceAck` |
| 7 | `deviceChipStatus` 轮询失败 | SDK 写入布尔值 `true` 而非字符串 | 同时匹配字符串和布尔值 |
| 8 | `vibrateShort` promise rejection | DevTools 不支持 `type` 参数try/catch 无法捕获异步 rejection | 改用 `.catch()` |
| 9 | dist 不更新 | `dev:weapp` watch 不监听 `native/` 目录 | 修改原生页面后需 `rm -rf dist/pkg-veepoo` 重建 |
## dev:weapp 注意事项
- 原生页面修改后**不会自动热更新**,需手动清除 dist 重建
- DevTools 日志需要选中正确的页面上下文(`pkg-veepoo`)才能看到原生页面日志
- 关闭 DevTools 后重新打开,确保加载最新的 dist 文件

View File

@@ -4,30 +4,30 @@
## 关键数字
> 最后更新: 2026-05-25 | 数据截止: feat/media-library-banner 分支(小程序 DevTools 卡死排查 + 构建优化
> 最后更新: 2026-06-05 | 数据截止: feat/media-library-banner 分支(用户管理过滤 + 患者摘要过滤 + 微信限流修复
| 指标 | 值 |
|------|-----|
| Rust crate | 17 个erp-core + 5 基础业务 + erp-health + erp-ai + erp-dialysis + erp-plugin + 7 插件/原型) |
| Rust 源文件 | **705**~130,000 行) |
| 数据库表 | 30 基础表 + 49 健康业务表 + 13 AI 表(+4 会话/消息/tool_log/user_profile + 3 媒体库/轮播图表 |
| 数据库迁移 | **165 个**(最新 m20260522_000162 |
| Rust 源文件 | **726**~134,000 行) |
| 数据库表 | 30 基础表 + 49 健康业务表 + 15 AI 表(+4 会话/消息/tool_log/user_profile + 2 知识库 V2 + 3 媒体库/轮播图表 |
| 数据库迁移 | **175 个**(最新 m20260529_000169 |
| 后端路由 | **385+ 个**11 公开 + 14 FHIR + 2 网关 + ~358 受保护) |
| 核心模块 | 5 基础 (auth/config/workflow/message/plugin) + 3 业务 (health + ai + dialysis) |
| erp-health 实体 | **58** Entity31 handler / 57 service / 22 DTO216 文件) |
| erp-ai 实体 | 20 个 Entity95 文件4 AI Providerchat_handler 支持 FC/Ollama fallback |
| 全系统 Entity | **115**58 health + 20 ai + 33 基础 + 4 core |
| erp-health 实体 | **59** Entity33 handler / 57 service / 22 DTO217 文件) |
| erp-ai 实体 | **24 个** Entity**105 文件**4 AI Providerchat_handler 支持 FC/Ollama fallback,知识库 V2 |
| 全系统 Entity | **118**59 health + 24 ai + 31 基础 + 4 core |
| Web 前端 | 316 个 TS/TSX 文件54 活跃路由83 API 模块108 页面) |
| 微信小程序 | Taro 4.2 + React 18180 个 TS/TSX 文件 / 61 页面(15 主包 + 46 分包) / 4 TabBar + 医生端独立分包34 组件(ui 21 + patterns 4 + 独立 9) / 45 service 文件 / 4 Zustand store / 12 hooks统一组件库 + CSS 变量主题102 SCSS 全量接入 `var(--tk-*)`,字号 token 对齐原型统计,医生端 `.doctor-mode` 靛蓝覆盖,登录页账号密码+微信一键登录);**Phase 2+3 完成**Token 构建时生成 + Canvas 适老 + PII 清理 + 缓存加密 + any 清零 + 大文件拆分(3→6) + 触觉反馈 + 导航状态保持 + 独立分包 + CI 集成 + HMAC 请求签名;**并发安全**:长轮询独立通道 `requestUnlimited` + ConcurrencyLimiter(12) + safeNavigateTo 全局页栈保护 + reLaunch 去重 + 分包预加载 preloadRule**构建优化**`lazyCodeLoading: requiredComponents` 仅生产构建启用dev 下已知 DevTools 卡死 bug`addChunkPages` 仅 TabBar 页注入 common chunk主包 dev 892KB / prod 766KBtaro.js 526→131KB / vendors.js 230→28KB**DevTools 兼容**:游客首页 Swiper dev 模式禁用 circular + 间隔 15s防 DevTools Chromium 渲染进程逐渐卡死;**离线抑制**指数退避3s→6s→12s→30s cap防请求洪泛**五维度分析评分 6.7/10**架构7.25/安全6.0/UX7.4/工程6.2 |
| 微信小程序 | Taro 4.2 + React 18**202 个 TS/TSX 文件** / 62 页面(15 主包 + 47 分包) + 1 原生分包页(pkg-veepoo) / 4 TabBar + 医生端独立分包34 组件(ui 21 + patterns 4 + 独立 9) / **51 service 文件** / **6 Zustand store** / **13 hooks**,统一组件库 + CSS 变量主题(**110 SCSS** 全量接入 `var(--tk-*)`,字号 token 对齐原型统计,医生端 `.doctor-mode` 靛蓝覆盖,登录页账号密码+微信一键登录);**Phase 2+3 完成**Token 构建时生成 + Canvas 适老 + PII 清理 + 缓存加密 + any 清零 + 大文件拆分(3→6) + 触觉反馈 + 导航状态保持 + 独立分包 + CI 集成 + HMAC 请求签名;**并发安全**:长轮询独立通道 `requestUnlimited` + ConcurrencyLimiter(12) + safeNavigateTo 全局页栈保护 + reLaunch 去重**Veepoo M2 BLE 管线**独立管线VeepooBridge 24 API含精准睡眠/自动测量/开关设置/体温自动数据)+ VeepooPipeline 事件路由type=1/4/5/6/18/31/51/54/58+ VeepooHistoryReader 日常+睡眠上传 + VeepooStore 状态管理(含 sleepData/sleepLoading+ **原生分包页面**`pkg-veepoo` 原生 JS+WXML脱离 Taro 直接调用 SDK绕过框架兼容性限制+ **自动测量队列**(连接认证后自动依次测量心率→血氧→血压→体温→压力 5 项指标,列表式进度 UI面向中老年人零操作设计+ 3 天历史数据同步VeepooHistoryReader 分批上传 + 断点续传)+ **精准睡眠数据自动读取**(认证后自动读取 3 天睡眠:深睡/浅睡/总时长/质量评分,通过 Storage 回传 Taro 页面)+ **自动测量功能**(认证后自动开启心率/血压/体温自动监测);**UI 重构**:测量页药丸式选择器 + SVG 圆环仪表盘 + 健康评估标签;数据上传页 2 列结果卡片网格 + 彩色条标识 + 睡眠数据卡片(★ 评分 + 总时长);**preloadRule 已移除 pkg-health** 防止 380KB SDK 预加载导致首页 DevTools 卡死;**构建优化**`lazyCodeLoading: requiredComponents` 仅生产构建启用dev 下已知 DevTools 卡死 bug`addChunkPages` 仅 TabBar 页注入 common chunk主包 dev 892KB / prod 766KB**五维度分析评分 6.7/10**架构7.25/安全6.0/UX7.4/工程6.2 |
| 前端测试 | Web 62 单元测试文件(~693 断言) + 17 E2E spec(13 Web + 4 MP~64 断言);小程序 12 单元测试文件(127 断言) + 4 E2E spec(~16 断言),覆盖率 ~6% |
| 后端测试 | **1030 个函数**839 同步 + 191 异步96 个文件含测试 |
| 后端测试 | **1031 个函数**839 同步 + 192 异步97 个文件含测试 |
| 事件系统 | 31 事件类型health/ 51 全系统 / 82 发布点 / 15 消费者模块 / Outbox + LISTEN/NOTIFY |
| 权限码 | **141 个**health 57 + ai 21 + auth 24 + config 18 + workflow 8 + message 5 + plugin 2 + dialysis 5 + system 1 |
| utoipa 注解 | **94 个**文件含注解 |
| utoipa 注解 | **98 个**文件含注解 |
| Clippy | **全 workspace 0 警告**2026-05-07 清零) |
| 依赖版本 | 全部最新主版本线Rust edition 2024 |
| API 文档 | `http://localhost:3000/api/docs/openapi.json` |
| Git 提交 | **996** |
| Git 提交 | **1,065** |
| Graphify 知识图谱 | **18,517 节点** / 22,666 边 / 1,841 社区(`graphify-out/`AST 解析,无 API 成本) |
| 系统分析评分 | **6.9/10 (B)**多专家组生产就绪度分析2026-05-21业务 8.5 / 医疗合规 6.5 / 前端 8.0 / 安全 7.5 / DevOps 4.0 |
| 审计状态 | V1: 83% → V2: 85%P0 安全修复已完成E2E 测试 157 端点(Health 63% / AI+Plugin 92.4%)CRITICAL×2 待修复 |
@@ -148,6 +148,17 @@
| TS 编译错误 `readonly Tab[]` 不可赋值给 `Tab[]` | [[miniprogram]] SegmentTabs | 页面组件用 `as const` 创建的 readonly 数组无法传入 mutable `Tab[]` 类型 | **已修复:** SegmentTabs 的 `Tab` 属性改为 `readonly` + `tabs` prop 改为 `readonly Tab[]`2026-05-24 |
| 重建失败 `dist/` 被锁定 | [[miniprogram]] 构建流程 | 微信 DevTools 进程持有 dist 目录文件句柄taro build 无法写入 | **解决:** `taskkill /F /IM wechatdevtools.exe` 后重新构建2026-05-24 |
| DevTools 打开即卡死所有项目Taro/原生均复现) | [[miniprogram]] appid 配置 | appid `wx20f4ef9cc2ec66c5` 的微信后台配置触发 `WAServiceMainContext.js` 内部 timeout导致 DevTools 渲染进程逐渐无响应;**根因定位:** 换用其他 appid如测试 appid `wx97debf52c9547da4`)后 Taro/原生均不卡死,确认是 appid 后台服务配置问题而非框架/代码问题 | **待解决:** 需到微信公众平台mp.weixin.qq.com检查该 appid 是否开通了云开发/云函数/第三方插件等导致 DevTools 初始化时连接超时的服务;临时方案:开发调试时使用测试 appid2026-05-24 |
| 首页加载后 DevTools 卡死Veepoo SDK 预加载触发) | [[miniprogram]] preloadRule | `preloadRule` 首页预加载 `pkg-health` 分包 → `sub-vendors.js` 含 380KB Veepoo SDK → SDK 初始化调用蓝牙 API → DevTools Chromium 渲染进程卡死 | **已修复:**`preloadRule` 首页/健康页移除 `pkg-health` 预加载SDK 仅在用户导航到设备同步/测量页时才加载2026-05-30 |
| 原生页面 `??` 运算符报 SyntaxError | [[miniprogram]] 原生页面 | 微信小程序 JS 引擎不支持 ES2020 `??`nullish coalescing`?.`optional chaining | **已修复:** `values.systolic ?? '--'` 改为 `values.systolic != null ? values.systolic : '--'`2026-05-30 |
| veepoo-measure 页面空白useRef is not defined | [[miniprogram]] 原生页面桥接 | TSX 文件使用 `useRef` 但仅 `import React from 'react'` 未解构导入 | **已修复:** 改为 `import React, { useRef } from 'react'`2026-05-30 |
| M2 设备扫描不到(名称匹配过严) | [[miniprogram]] 原生页面扫描 | 过滤条件 `name.indexOf('M2')` 过严,设备可能广播为 VPM/VEEPOO | **已修复:** 放宽匹配 M2/VPM/VEEPOO 三种前缀2026-05-30 |
| M2 设备认证超时3 层根因) | [[miniprogram]] 原生页面认证 | **根因链**:①连接回调 `errno:0` 在第 1 次回调就匹配,认证在特征值订阅前发送 → 修复为只匹配 `connection:true`;②`veepooWeiXinSDKNotifyMonitorValueChange``onLoad` 注册时内部调用 `wx.notifyBLECharacteristicValueChange`,适配器未初始化 → `not init` 错误,改到 `connection:true` 后注册;③认证结果字段检查错误:代码检查 `VPDevicepassword`(值="0000")而非 `VPDeviceAck`(值="successfulVerification" | **已修复:** 三层修复 — connection:true 唯一匹配 + 监听器时序 + VPDeviceAck 字段2026-05-30 |
| Veepoo 上传按钮无响应(无日志无报错) | [[miniprogram]] veepoo-measure | `handleUpload``if (!patient) return;` 静默退出,`currentPatient` 从 auth store 恢复可能为 null原生页返回后 | **已修复:** patientId 增加 URL 参数 fallback + 每个 early return 添加 console.warn + Taro.showToast 用户提示 + 上传按钮 disabled/loading 状态2026-05-31 |
| M2 测量页仪表盘数值不可见 | [[miniprogram]] 原生测量页 WXSS | `.gauge__center` 无背景色,`conic-gradient` 填满整个圆形区域,数值文字对比度极低 | **已修复:** `.gauge__center` 添加 `background: var(--bg)` + `border-radius: 50%`2026-06-04 |
| 微信登录后显示"绑定失败 — 登录态丢失" | [[miniprogram]] auth store | `login()` catch 块把 API 错误吞掉返回 false调用方误判为"未绑定"显示绑定按钮;`bindPhone()` 读不到 `wechat_openid` | **已修复:** API 失败时 throw 而非 return false + 增加 `resp.openid` 空值校验2026-06-04 |
| wx_* 患者混入用户管理 | [[erp-auth]] wechat_service | 微信登录创建 `users` 记录 + `patient` 角色,与内部员工混在一起 | **已修复:** `list_users` 新增 `exclude_only_roles` 参数前端默认排除纯患者用户2026-06-05 |
| 小程序上传数据看不到 | [[erp-health]] patient_service | `list_summaries` 无 user_id 过滤,小程序取到别人的 patient 作为 currentPatient | **已修复:** `list_summaries` 增加 `user_id` 参数,小程序传入当前用户 ID 过滤2026-06-05 |
| 真机微信登录"请求过于频繁" | [[erp-server]] rate_limit | 微信登录与密码登录共享 5 次/分限制 + `extract_client_ip` 无代理头返回 "unknown" 导致所有真机共享同一个 key | **已修复:** 微信登录路由独立为 `wechat_routes`30 次/分钟宽松限流2026-06-05 |
## 模块导航
@@ -194,6 +205,42 @@
**患者/医护与 erp-auth 的关系?** 账号走 `users`erp-health 通过 `user_id` 外键关联扩展字段(科室、职称、档案等)。患者可先建档后绑定账号。
## 上线后待办
> 2026-06-05 会议讨论确定,合并到 main 前后需要逐项处理。
### 必须项(上线前/上线时)
| # | 待办 | 关联提交 | 说明 |
|---|------|---------|------|
| 1 | **重启后端** | `01a0fffc` | 用户管理过滤 + 患者摘要过滤 + 微信限流修复,三项都需要重启生效 |
| 2 | **重新构建小程序** | `1982698b` | `getPatientSummaries` 新增 `userId` 参数,需 `pnpm dev:weapp``build:weapp` |
| 3 | **Nginx 配置 X-Real-IP** | `01a0fffc` | `extract_client_ip` 无代理头时 fallback 为 `"unknown"`,所有真机共享限流 key。Nginx 必须添加 `proxy_set_header X-Real-IP $remote_addr;`,否则限流形同虚设 |
| 4 | **真机验证微信登录** | — | 用 7141 真机登录,确认不再触发"请求过于频繁" |
| 5 | **真机验证数据上传** | `1982698b` | 7141 登录后连接 M2 手环上传数据,确认数据关联到正确的 patient |
| 6 | **用户管理页面验证** | `201a9158` | 确认 `wx_*` 患者不再出现在用户管理列表,内部员工正常显示 |
### 建议项(上线后尽快)
| # | 待办 | 说明 |
|---|------|------|
| 7 | **清理 `wx__cod` 脏数据** | 用户名 `wx__cod` / 手机号 `1380000_cod` 是早期测试遗留,`users` 表有记录但无 `patient`,也无 `user_credentials`。建议软删除 |
| 8 | **统一早期 patient 命名** | `wx_7141` 的 patient 名叫 "MP User 7141"(早期测试命名),其他都是 "微信用户XXXX"。建议 SQL 统一为 `微信用户XXXX` 格式 |
| 9 | **检查其他 wx_* 用户数据归属** | `device_readings` 中有 73 条数据属于 `wx_6897`(69条) 和 `wx_6391`(4条)。旧代码可能将数据写到了错误的 patient 下,需核实这些用户是否真的上传了自己的数据 |
| 10 | **`extract_client_ip` 改进** | 当前无代理头时返回 `"unknown"`,所有客户端共享限流配额。生产环境依赖 Nginx 头即可,但开发环境应考虑从 Axum `ConnectInfo` 获取真实 IP |
### 数据修复 SQL上线后按需执行
```sql
-- #7: 软删除 wx__cod 脏数据
UPDATE users SET deleted_at = NOW(), status = 'disabled'
WHERE username = 'wx__cod' AND deleted_at IS NULL;
-- #8: 统一 7141 的 patient 名称
UPDATE patient SET name = '微信用户7141'
WHERE id = '14f34cc3-7a16-48c0-bafc-9bb0989d5fbd' AND name = 'MP User 7141';
```
## 文档索引
| 类型 | 位置 |

416
wiki/permissions.md Normal file
View File

@@ -0,0 +1,416 @@
---
title: 角色权限体系
updated: 2026-05-22
status: active
tags: [permissions, roles, rbac, frontend, miniprogram]
---
> 2026-05-22 更新:补齐 patient 角色小程序端 manage 权限15 项),注册 `system.analytics.submit` 幽灵权限,新增 §6.4 医生端小程序权限矩阵。
# 角色权限体系
> 从 [[index]] 导航。关联: [[architecture]] [[erp-health]] [[frontend]] [[miniprogram]]
## 1. 概述
HMS 采用 **RBAC基于角色的访问控制** 模型,包含 7 个系统角色,约 141+ 权限码覆盖 auth / config / workflow / message / plugin / health / ai / copilot / dialysis / system 十大模块。
权限执行层分两端:
- **Web 管理后台**:权限码守卫(`routeConfig.ts` 声明 `permissions: [...]`),后端 handler 层 `require_permission` 强制校验
- **微信小程序**:角色码守卫(`isMedicalStaff()` / `isDoctor()` / `isNurse()` / `isHealthManager()`),患者端按角色分流
### 数据范围data_scope
| 角色 | data_scope | 说明 |
|------|-----------|------|
| admin | `all` | 全部数据 |
| viewer | `all` | 全部数据(只读) |
| doctor | `all` | 全部患者数据 |
| nurse | `all` | 全部患者数据 |
| health_manager | `all` | 全部患者数据 |
| operator | `all` | 全部数据 |
| patient | `self` | 仅本人数据 |
---
## 2. 角色定义
| 角色码 | 名称 | 定位 | 典型用户 |
|--------|------|------|----------|
| `admin` | 系统管理员 | 全部权限,系统配置与用户管理 | IT 管理员 |
| `viewer` | 查看者 | 只读权限,查看基础数据 | 上级领导、审计 |
| `doctor` | 医生 | 患者诊疗、诊断、随访、咨询、透析 | 临床医生 |
| `nurse` | 护士 | 患者护理、日常监测、体征录入、设备 | 临床护士 |
| `health_manager` | 健康管理师 | 全流程健康管理、告警规则、AI 分析 | 健康管理师 |
| `operator` | 运营人员 | 内容管理、积分、媒体、轮播图 | 运营/编辑 |
| `patient` | 患者 | 小程序自助服务(仅本人数据) | 患者端用户 |
---
## 3. 权限码总表
### 3.1 基础模块auth / config / workflow / message / plugin
| 模块 | 权限码 | 说明 |
|------|--------|------|
| **用户管理** | `user.list` `user.create` `user.read` `user.update` `user.delete` | 用户 CRUD |
| **角色管理** | `role.list` `role.create` `role.read` `role.update` `role.delete` | 角色 CRUD |
| **权限管理** | `permission.list` | 权限列表(只读) |
| **组织管理** | `organization.list` `organization.create` `organization.update` `organization.delete` | 组织架构 |
| **部门管理** | `department.list` `department.create` `department.update` `department.delete` | 部门 CRUD |
| **岗位管理** | `position.list` `position.create` `position.update` `position.delete` | 岗位 CRUD |
| **字典管理** | `dictionary.list` `dictionary.create` `dictionary.update` `dictionary.delete` | 数据字典 |
| **菜单管理** | `menu.list` `menu.update` | 菜单配置 |
| **系统设置** | `setting.read` `setting.update` `setting.delete` | 系统参数 |
| **编号规则** | `numbering.list` `numbering.create` `numbering.update` `numbering.delete` `numbering.generate` | 编号序列 |
| **主题** | `theme.read` `theme.update` | UI 主题 |
| **语言** | `language.list` `language.update` | 国际化 |
| **工作流** | `workflow.create` `workflow.list` `workflow.read` `workflow.update` `workflow.publish` `workflow.start` `workflow.approve` `workflow.delegate` | BPMN 流程 |
| **消息** | `message.list` `message.send` `message.template.list` `message.template.create` `message.template.manage` | 消息通知 |
| **插件** | `plugin.admin` `plugin.list` | WASM 插件管理 |
| **租户** | `tenant.manage` | 多租户管理 |
### 3.2 健康模块health
| 子域 | 权限码 | 说明 |
|------|--------|------|
| **患者** | `health.patient.list` `health.patient.manage` | 患者档案 |
| **健康数据** | `health.health-data.list` `health.health-data.manage` | 体征/化验 |
| **预约** | `health.appointment.list` `health.appointment.manage` | 预约排班 |
| **随访** | `health.follow-up.list` `health.follow-up.manage` | 随访任务 |
| **咨询** | `health.consultation.list` `health.consultation.manage` | 在线咨询 |
| **医生** | `health.doctor.list` `health.doctor.manage` | 医生管理 |
| **诊断** | `health.diagnosis.list` `health.diagnosis.manage` | 诊断记录 |
| **日常监测** | `health.daily-monitoring.list` `health.daily-monitoring.manage` | 每日监测 |
| **告警** | `health.alerts.list` `health.alerts.manage` | 告警列表 |
| **告警规则** | `health.alert-rules.list` `health.alert-rules.manage` | 告警规则配置 |
| **危急值** | `health.critical-alerts.list` `health.critical-alerts.manage` | 危急值告警 |
| **危急值阈值** | `health.critical-value-thresholds.list` `health.critical-value-thresholds.manage` | 阈值设置 |
| **随访模板** | `health.follow-up-templates.list` `health.follow-up-templates.manage` | 模板管理 |
| **知情同意** | `health.consent.list` `health.consent.manage` | 同意管理 |
| **用药记录** | `health.medication-records.list` `health.medication-records.manage` | 用药记录 |
| **用药提醒** | `health.medication-reminders.list` `health.medication-reminders.manage` | 提醒设置 |
| **行动收件箱** | `health.action-inbox.list` `health.action-inbox.manage` `health.action-inbox.team` | 待办任务 |
| **仪表盘** | `health.dashboard.manage` | 统计仪表盘 |
| **OAuth** | `health.oauth.list` `health.oauth.manage` | 第三方授权 |
| **关怀计划** | `health.care-plan.list` `health.care-plan.manage` | 关怀计划 |
| **排班** | `health.shifts.list` `health.shifts.manage` | 医护排班 |
| **BLE 网关** | `health.ble-gateways.list` `health.ble-gateways.manage` | 蓝牙网关 |
| **家庭代理** | `health.family-proxy.list` `health.family-proxy.manage` | 家属代管 |
| **媒体库** | `health.media.list` `health.media.manage` | 媒体文件 |
| **轮播图** | `health.banners.list` `health.banners.manage` | 首页轮播 |
| **标签** | `health.tags.list` `health.tags.manage` | 患者标签 |
| **设备** | `health.devices.list` `health.devices.manage` | 设备管理 |
| **设备读数** | `health.device-readings.list` `health.device-readings.manage` | 设备数据 |
| **透析** | `health.dialysis.list` `health.dialysis.manage` | 透析记录 |
| **透析处方** | `health.dialysis-prescription.list` `health.dialysis-prescription.manage` | 透析处方 |
| **透析统计** | `health.dialysis.stats` | 透析统计 |
| **线下活动** | `health.offline-events.list` `health.offline-events.manage` | 线下活动 |
| **文章** | `health.articles.list` `health.articles.manage` `health.articles.review` | 内容管理 |
| **积分** | `health.points.list` `health.points.manage` | 积分商城 |
| **统计** | `health.stats.list` | 健康统计 |
### 3.3 AI 模块ai
| 权限码 | 说明 |
|--------|------|
| `ai.analysis.list` `ai.analysis.manage` | AI 分析 |
| `ai.prompt.list` `ai.prompt.manage` | 提示词管理 |
| `ai.provider.manage` | AI Provider 配置 |
| `ai.suggestion.list` `ai.suggestion.manage` | AI 建议 |
| `ai.usage.list` | AI 用量统计 |
| `ai.chat.send` | AI 对话 |
| `ai.config.read` `ai.config.manage` | AI 配置 |
| `ai.knowledge.list` `ai.knowledge.manage` | 知识库 |
| `ai.admin.dashboard` `ai.admin.flags` | AI 管理后台 |
### 3.4 Copilot 模块
| 权限码 | 说明 |
|--------|------|
| `copilot.insights.list` `copilot.insights.manage` | Copilot 洞察 |
| `copilot.risk.view` | 风险查看 |
| `copilot.rules.list` `copilot.rules.manage` | Copilot 规则 |
### 3.5 透析模块dialysis
| 权限码 | 说明 |
|--------|------|
| `health.dialysis.list` `health.dialysis.manage` | 透析管理 |
| `health.dialysis-prescription.list` `health.dialysis-prescription.manage` | 透析处方 |
| `health.dialysis.stats` | 透析统计 |
---
## 4. 各角色权限矩阵
> `+` = 拥有,`-` = 不拥有。admin 拥有全部权限,不再逐一列出。
### 4.1 医生doctor
| 子域 | list | manage | 特殊 |
|------|:----:|:------:|------|
| 患者 | + | + | |
| 健康数据 | + | - | 仅查看 |
| 预约 | + | + | |
| 随访 | + | + | |
| 咨询 | + | + | |
| 医生 | + | + | |
| 诊断 | + | + | |
| 日常监测 | + | + | |
| 告警 | + | + | |
| 告警规则 | + | - | 仅查看 |
| 危急值 | + | - | 仅查看 |
| 知情同意 | + | + | |
| 随访模板 | + | + | |
| 行动收件箱 | + | + | |
| 关怀计划 | + | + | |
| 透析 | + | + | |
| 透析处方 | + | + | |
| 透析统计 | + | | |
| AI 分析 | + | - | 仅查看 |
| AI 建议 | + | - | 仅查看 |
| AI 提示词 | + | - | 仅查看 |
| AI 用量 | + | - | 仅查看 |
| 消息 | + | | |
| 工作流 | + | | `list` + `read` |
**无权访问:** 文章管理、积分、标签、媒体库、轮播图、AI 管理、设备管理、线下活动、copilot
### 4.2 护士nurse
| 子域 | list | manage | 特殊 |
|------|:----:|:------:|------|
| 患者 | + | + | |
| 健康数据 | + | - | 仅查看 |
| 预约 | + | + | |
| 随访 | + | + | |
| 咨询 | + | - | 仅查看 |
| 诊断 | + | - | 仅查看 |
| 日常监测 | + | + | |
| 告警 | + | - | 仅查看 |
| 危急值 | + | - | 仅查看 |
| 知情同意 | + | + | |
| 设备 | + | - | 仅查看 |
| 设备读数 | + | - | 仅查看 |
| 行动收件箱 | + | + | |
| 消息 | + | | |
**无权访问:** 医生管理、告警管理manage、告警规则、AI、文章、积分、标签、媒体库、轮播图、透析、copilot
### 4.3 健康管理师health_manager
| 子域 | list | manage | 特殊 |
|------|:----:|:------:|------|
| 患者 | + | + | |
| 健康数据 | + | + | |
| 医生 | + | - | 仅查看 |
| 随访 | + | + | |
| 咨询 | + | + | |
| 诊断 | + | + | |
| 日常监测 | + | + | |
| 告警 | + | + | |
| 告警规则 | + | + | |
| 危急值 | + | - | 仅查看 |
| 危急值阈值 | + | - | 仅查看 |
| 知情同意 | + | + | |
| 随访模板 | + | + | |
| 标签 | + | + | |
| 设备 | + | - | 仅查看 |
| 行动收件箱 | + | + | `team` 额外权限 |
| 仪表盘 | + | | `dashboard.manage` |
| AI 分析 | + | + | |
| AI 建议 | + | + | |
| AI 提示词 | + | - | 仅查看 |
| AI 用量 | + | - | 仅查看 |
| 消息 | + | | |
| 工作流 | + | | `list` + `read` + `start` |
**无权访问:** 预约、透析、文章、积分、媒体库、轮播图、线下活动、copilot、OAuth、关怀计划、排班
### 4.4 运营人员operator
| 子域 | list | manage | 特殊 |
|------|:----:|:------:|------|
| 患者 | + | - | 仅查看 |
| 标签 | + | + | |
| 文章 | + | + | `review` 额外权限 |
| 积分 | + | + | |
| 设备 | + | - | 仅查看 |
| 告警 | + | - | 仅查看 |
| 媒体库 | + | + | |
| 轮播图 | + | + | |
| 仪表盘 | + | | `dashboard.manage` |
| AI 用量 | + | - | 仅查看 |
| 消息 | + | | |
**无权访问:** 健康数据、预约、随访、咨询、诊断、日常监测、告警规则、危急值、知情同意、AI 分析、透析、copilot
### 4.5 患者patient
> data_scope = `self`,所有操作仅限本人数据。通过小程序访问(`m20260522_000162` 完成全量配置)。
| 子域 | list | manage | 说明 |
|------|:----:|:------:|------|
| 健康数据 | + | + | 录入体征、查看本人体征/化验 |
| 患者 | + | + | 查看/更新本人档案、绑定手机 |
| 预约 | + | + | 创建/取消本人预约 |
| 医生 | + | - | 预约时选择医生 |
| 随访 | + | + | 提交随访记录 |
| 咨询 | + | + | 创建咨询会话、发送消息 |
| 积分 | + | + | 签到、兑换商品 |
| 文章 | + | - | 阅读公开文章 |
| 告警 | + | - | 查看本人告警 |
| 日常监测 | + | + | 创建日常监测记录 |
| 设备读数 | + | + | 上传设备数据 |
| 设备 | + | - | 查看绑定设备 |
| 知情同意 | + | + | 授权/撤回本人同意 |
| 用药记录 | + | - | 查看本人用药 |
| 用药提醒 | + | + | CRUD 本人提醒 |
| 关怀计划 | + | - | 查看本人计划 |
| 行动收件箱 | + | - | 查看本人待办 |
| 透析 | + | - | 查看本人透析 |
| AI 分析 | + | - | 查看本人分析报告 |
| AI 建议 | + | - | 查看分析建议 |
| AI 对话 | + | - | `ai.chat.send` + 会话 list/manage |
| 消息 | + | - | 查看本人消息 |
| 埋点 | - | - | `system.analytics.submit`data_scope=self |
### 4.6 查看者viewer
基础模块只读权限auth / config / workflow / message / plugin 的 `list`/`read`)。无 health / AI / copilot 权限。
---
## 5. Web 前端路由权限
Web 管理后台通过 `routeConfig.ts` 声明每个路由所需权限码。用户登录后,路由守卫检查其角色是否拥有对应权限。
### 5.1 各角色可见菜单
#### admin
全部菜单可见。
#### doctor
首页、统计仪表盘、患者管理、日常监测、诊断记录、知情同意、咨询管理、随访任务、随访模板、行动收件箱、告警仪表盘、告警管理、AI 分析、AI 用量、AI 对话、消息
#### nurse
首页、统计仪表盘、患者管理、日常监测、诊断记录、知情同意、咨询管理、随访任务、行动收件箱、告警仪表盘、告警管理、消息
#### health_manager
首页、统计仪表盘、患者管理、日常监测、诊断记录、知情同意、咨询管理、标签管理、医生管理、随访任务、随访模板、行动收件箱、实时监测、告警仪表盘、告警管理、告警规则、设备管理、危急值阈值、AI 提示词、AI 分析、AI 知识库、AI 用量、AI 配置、AI 对话、消息
#### operator
首页、统计仪表盘、患者管理只读、标签管理、设备管理、告警仪表盘、告警管理只读、文章管理、积分规则、积分商品、积分订单、线下活动、媒体库、轮播图、AI 用量、消息
---
## 6. 小程序角色控制
小程序端采用**角色码**(非权限码)做前端控制,后端仍通过权限码校验 API 请求。
### 6.1 角色判断函数
```typescript
// stores/auth.ts
isMedicalStaff() roles doctor / nurse / admin / health_manager
isDoctor() roles doctor / admin
isNurse() roles nurse / admin
isHealthManager() roles health_manager / admin
hasRole(code) roles code / admin
```
### 6.2 角色与小程序页面映射
| 角色 | 可访问页面 |
|------|-----------|
| **patient** | 首页、健康、咨询、商城、我的5 TabBar+ 分包页面预约、随访、告警、积分、文章、设备、AI 对话) |
| **doctor / admin** | 首页 → 自动跳转医生端分包(`pkg-doctor-core`+ 患者管理、咨询、随访、行动收件箱 |
| **nurse** | 同 doctor但部分管理功能降级为只读 |
| **health_manager** | 同 doctor额外可管理告警规则、AI 分析 |
### 6.3 患者端patientAPI 权限
患者通过小程序访问以下 API均需 `patient` 角色且 `data_scope=self`)。完整权限配置见迁移 `m20260522_000162`
| 小程序页面 | API 路径 | 所需权限码 | 操作类型 |
|-----------|----------|-----------|---------|
| 健康总览 | `GET /health/health-data/summary` | `health.health-data.list` | 只读 |
| 体征录入 | `POST /health/health-data` | `health.health-data.manage` | 写入 |
| 健康趋势 | `GET /health/health-data/trend` | `health.health-data.list` | 只读 |
| 我的报告 | `GET /health/patients/{id}/lab-reports` | `health.health-data.list` | 只读 |
| AI 解读 | `GET /ai/analyses` | `ai.analysis.list` | 只读 |
| 健康档案 | `GET /health/health-records` | `health.health-data.list` | 只读 |
| 诊断记录 | `GET /health/diagnoses` | `health.health-data.list` | 只读 |
| 我的预约 | `GET /health/appointments` | `health.appointment.list` | 只读 |
| 创建预约 | `POST /health/appointments` | `health.appointment.manage` | 写入 |
| 医生列表 | `GET /health/doctors` | `health.doctor.list` | 只读 |
| 我的随访 | `GET /health/follow-up-tasks` | `health.follow-up.list` | 只读 |
| 提交随访 | `POST /health/follow-up-records` | `health.follow-up.manage` | 写入 |
| 在线咨询 | `POST /health/consultation-sessions` | `health.consultation.manage` | 写入 |
| 咨询消息 | `GET/POST /health/consultation-sessions/{id}/messages` | `health.consultation.list` + `manage` | 读写 |
| 告警列表 | `GET /health/alerts` | `health.alerts.list` | 只读 |
| 行动收件箱 | `GET /health/action-inbox` | `health.action-inbox.list` | 只读 |
| 设备同步 | `POST /health/device-readings` | `health.device-readings.manage` | 写入 |
| 药物提醒 | `GET/POST/PUT/DELETE /health/medication-reminders` | `health.medication-reminders.list` + `manage` | 读写 |
| 知情同意 | `POST/PUT /health/consents` | `health.consent.list` + `manage` | 读写 |
| 积分账户 | `GET /health/points/account` | `health.points.list` | 只读 |
| 积分签到 | `POST /health/points/checkin` | `health.points.manage` | 写入 |
| 积分兑换 | `POST /health/points/redeem` | `health.points.manage` | 写入 |
| 文章列表 | `GET /health/articles` | `health.articles.list` | 只读 |
| 消息通知 | `GET /messages` | `message.list` | 只读 |
| AI 对话 | `POST /ai/chat/send` | `ai.chat.send` | 写入 |
| AI 会话 | `GET/POST /ai/chat/sessions` | `ai.chat.session.list` + `manage` | 读写 |
| 埋点上报 | `POST /analytics/batch` | `system.analytics.submit` | 写入 |
| 就诊人管理 | `GET/PUT /health/patients/{id}` | `health.patient.list` + `manage` | 读写 |
| 日常监测 | `GET/POST /health/daily-monitoring` | `health.daily-monitoring.list` + `manage` | 读写 |
### 6.4 医生端小程序权限
医生/护士通过小程序医生端分包(`pkg-doctor-core`)访问以下 API。权限码与 Web 管理后台一致。
| 小程序页面 | API 路径 | 所需权限码 | 角色要求 |
|-----------|----------|-----------|---------|
| 医生首页 | `GET /health/doctor/dashboard` | `health.dashboard.manage` | doctor/nurse/hm |
| 患者管理 | `GET /health/patients` | `health.patient.list` | doctor/nurse/hm |
| 患者详情 | `GET /health/patients/{id}/health-summary` | `health.patient.list` | doctor/nurse/hm |
| 咨询列表 | `GET /health/consultation-sessions` | `health.consultation.list` | doctor/nurse/hm |
| 咨询回复 | `POST /health/consultation-sessions/{id}/messages` | `health.consultation.manage` | doctor/hm |
| 随访管理 | `GET/PUT /health/follow-up-tasks` | `health.follow-up.list` + `manage` | doctor/nurse/hm |
| 告警处理 | `POST /health/alerts/{id}/acknowledge` | `health.alerts.manage` | doctor/hm |
| 行动收件箱 | `GET /health/action-inbox` | `health.action-inbox.list` | doctor/nurse/hm |
| 团队概览 | `GET /health/action-inbox/team` | `health.action-inbox.team` | hm |
| 化验报告 | `GET /health/patients/{id}/lab-reports` | `health.health-data.list` | doctor/nurse/hm |
| 透析管理 | `GET/POST /health/dialysis-records` | `health.dialysis.list` + `manage` | doctor |
| 透析处方 | `GET/POST /health/dialysis-prescriptions` | `health.dialysis-prescription.list` + `manage` | doctor |
---
## 7. 权限配置维护
### 7.1 新增权限码流程
1. 在对应模块 `module.rs``PermissionDescriptor` 中声明权限码
2. 创建迁移文件 seed 权限到 `permissions`
3. 迁移中为需要该权限的角色添加 `role_permissions` 记录
4. 前端路由声明对应 `permissions` 数组
5. 更新本文档
### 7.2 关键迁移文件
| 迁移 | 说明 |
|------|------|
| `seed.rs` | 基础权限auth/config/workflow/message/plugin |
| `m20260506_000125` | 创建 doctor/nurse/health_manager/operator 角色及初始权限 |
| `m20260508_000131` | 权威修复:重新分配 doctor/nurse/operator 权限 |
| `m20260510_000133` | 创建 patient 角色data_scope=self18 个 .list 权限) |
| `m20260510_000137` | 媒体库/轮播图权限 + operator 补充 |
| `m20260516_000147` | AI 对话权限patient + admin |
| `m20260518_000149` | admin 全量权限修复 |
| `m20260521_000164` | 菜单体系重组 |
| `m20260522_000161` | patient 积分 manage 权限 |
| `m20260522_000162` | patient 全量小程序权限15 manage + 1 list + system.analytics.submit 注册) |
### 7.3 权限同步机制
系统启动时自动同步:模块通过 `ErpModule` trait 注册 `PermissionDescriptor``sync_module_permissions()` 将新权限码插入 `permissions`admin 角色自动获得所有新权限。